Compare commits

..

16 Commits

Author SHA1 Message Date
a9b22f2add NGINX: set Cache-Control via a map, not a nested location, so security headers survive
The Cache-Control rule I added in the previous commit used a nested
`location ~* \.(html|css|js|webmanifest)$`. nginx does NOT inherit add_header into a
block that declares its own add_header, so every HTML, CSS and JS response would have
been served WITHOUT the CSP, HSTS, X-Frame-Options, Referrer-Policy and nosniff headers
from the Phase S hardening — the headers dropped for exactly the files that matter most,
and silently, since the pages would still work.

Now computed by `map $uri $wp_cache_control` at http level and applied with one
server-level add_header alongside the security headers, so nothing is scoped away. An
empty value makes nginx omit the header entirely, so images and fonts stay cacheable.
Applied to both the Docker config (nginx/conf.d/wp-suite.conf) and the bare-metal one
(nginx-wp-suite.conf), which carries the same header set.

Caught while checking whether the stack was safe to redeploy. Not verified with
`nginx -t` — this machine has neither nginx nor docker — so DEPLOYMENT.md now records
the rule and the one-line curl that confirms both headers are present after a deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:37:18 -07:00
e3527a6e1d Act on the fragility audit: boot-order crash, real cache correctness, deep links
A 55-agent audit of the last few commits confirmed 32 findings. The high and medium
ones are fixed here; the ranked leftovers are listed at the end.

Boot-order crash (my regression, wave 2)
- wp-format.js loaded AFTER wp-creation-app.js on every page, but the creator boots
  synchronously at parse time and its comment renderer calls wpFormatDateTime(). With
  any review comment present that threw a ReferenceError and aborted the rest of boot.
  The formatter now parses before the app scripts on all five pages. Verified with a
  comment seeded: the date renders and boot completes.

The network-first fix didn't actually work
- `fetch(req)` inherits the request's default cache mode, so it consults the browser
  HTTP cache — the previous commit's "network-first" still allowed a page to run
  against a stale sibling. Code is now fetched with cache:'no-cache' and precached
  with cache:'reload'.
- Nothing pinned freshness on the wire either: no Cache-Control anywhere, so browsers
  applied heuristic caching (~10% of a file's age) and each file expired at a
  different moment. NGINX and the dev server now send no-cache for html/css/js/
  webmanifest; images stay cacheable. Verified on the wire.
- Non-ok responses were returned verbatim, so a 502 broke pages the cache could have
  served; they now fall back to the cache. Cache keys drop the query string, which
  fixes both the offline miss on every in-app link (?project=…&tab=…) and unbounded
  cache growth. respondWith can no longer resolve to undefined. Cache bumped to v5.

Embedded creator
- Dropped the &t=Date.now() cache-buster and made the frame's identity the PROJECT.
  The view and which package to open are now applied by calling into the loaded
  document, so switching tabs no longer reloads it — that reload discarded unsaved form
  edits, made the creator unreachable offline, and stored a fresh copy per click.
- ?view=dashboard was re-read on every tab switch, so after one deep link the
  "Work Package Creation" tab kept opening the Dashboard for the rest of the session.
  Deep-link params are consumed once now.
- ?wp=<id> — which the global search has been emitting since wave 2 — was read by
  nothing, so picking a work package in search opened a blank one. The creator now
  exposes openWpById() and the shell applies it after a new 'wp-creator-ready' event,
  because the frame's load fires before pullProject() resolves.
- Math.max(320,…) could make the frame taller than the space available while page
  scrolling was disabled, pushing content off a window that couldn't scroll. Full-bleed
  is now only used when at least 460px remains, and the SOP-incomplete gate never runs
  inside it. A ResizeObserver re-measures when wp-chrome.js grows the app bar.

Contract drift
- .field-hint and .user-pick are used on the SOP suite page but their only rules lived
  in wp-creation-styles.css, which that page doesn't link — the CM hint and the
  sign-off pickers had no styling at all. Rules added to the suite's stylesheet.
- The creator's critical floor now also hides modal overlays (a stale stylesheet
  rendered their contents inline in the form) and gives the jump bar a sane sticky top.
- login.js dereferenced ids unguarded where the old version guarded, so a cached older
  login.html would break sign-in itself. Guarded.
- The "Language & time" menu item was added only if wp-format.js had already parsed;
  the check now happens at click time.

Verified: 157 API checks across five suites on a clean database, plus 22 driven UI
checks — boot-with-comment, tab switching with a no-reload probe, short-viewport
fallback, and the search deep link landing on the right package.

Not done, ranked: ~50 dead CSS rules across three stylesheets; dead .team-pick and
.constraint-option contracts; wp-chrome.js's documented '.header' mount branch is
unreachable because the creator loads neither wp-chrome.js nor its CSS; the squeeze
half of the embed layout (.content-area.embed-full) is still CSS-only, which degrades
to the old narrow column rather than breaking; fingerprinted asset URLs would make a
mismatched pair unrepresentable rather than merely unlikely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:07:20 -07:00
917a728399 Rebuild the work-package side panel in the style of MS Planner
The auto-hiding drawer was the wrong model — a list you navigate by shouldn't appear
and disappear under the pointer, and its vertical text tab read as a stray artifact.
Replaced with a persistent side panel following the Planner reference:

- Collapse toggle at the top (the panel glyph, arrow flips), remembered across visits.
  Collapsed leaves a 56px icon rail where the coloured package badges are still
  clickable, rather than hiding the list entirely.
- One primary action: "+ New work package" with a split caret for Duplicate, Split by
  discipline and Export all.
- Icon nav with counts: My packages (owned by you), All packages, Needs attention
  (on hold or not release-ready), Dashboard. These filter the list below.
- Packages as rows with a colour-coded initial badge, number, subject and readiness
  state, still grouped by status, with a left accent bar on the current package.
  The badge colour is hashed from the WP number, so a package keeps its swatch
  instead of shuffling when another is added or deleted.
- The panel sits IN the layout: the form and the full-width chrome shift beside it
  rather than being overlaid.

Also, the reason it appeared as loose unstyled widgets in the middle of the form: the
panel's markup and its stylesheet are cached independently, so a browser can run new
markup against old CSS. Its essential layout (fixed position, width, the row/badge
flex, the collapsed rules) is now injected by wp-creation-app.js as a floor, inserted
first in <head> so the stylesheet still wins on everything it defines. Same lesson as
the iframe: a component whose CSS-missing state is "broken" rather than "plain" must
carry its own critical layout.

Verified with 25 driven checks in headless Chrome: persistence, the four nav links,
badge colours and text, view filtering, collapse/expand, the split menu, row selection
and highlighting — and, with wp-creation-styles.css removed from the page entirely, the
panel is still a fixed 288px side panel with the form shifted beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:47:40 -07:00
40bd19b6cf Stop the embedded creator collapsing to a 300x150 box; serve code network-first
The Creator rendered as a tiny double-scrolling square in the WP tab. My fault, and
the mechanism matters more than the symptom:

I had moved the iframe's sizing (width:100%, border:0, min-height) out of its inline
style attribute and into work-package-suite-styles.css. The service worker cached the
HTML and the stylesheet as INDEPENDENT entries, cache-first — so a browser could hold
the new HTML together with the old CSS. With the inline sizing gone and the new rule
absent, the iframe fell back to the HTML default 300x150 box and the whole tool
collapsed. Moving self-contained markup into a separately-cached file created that
window; nothing about the layout itself was wrong.

Three layers so it cannot recur:
- The iframe's width/border/min-height are inline again, on purpose, with a comment
  saying why. An iframe with no intrinsic size has a catastrophic failure mode, so its
  sizing must not depend on another file being in step.
- applyEmbedLayout() now sets the fill height and width as INLINE styles via
  sizeWPFrame(). Inline beats any stylesheet, including a stale cached one, so the
  class is a refinement rather than a requirement.
- sw.js: HTML/CSS/JS are now fetched NETWORK-FIRST with the cache as offline fallback;
  images/icons/manifest stay stale-while-revalidate. These files reference each other,
  so a page must never run against a stale sibling — this same staleness had already
  masked two other fixes during development. Cache bumped to v4.

Verified: at 2560x1440 the tool spans the window with a single scrollbar; with
work-package-suite-styles.css removed entirely (strictly worse than stale) the frame
still measures 1469x662 instead of 300x150, and re-running the layout pass keeps it
there; 12 checks across sop -> wp -> dashboard -> sop confirm body.embed-full, the
content-area class, the fill class and the inline height are all cleared on the way
out, so the wizard never ends up unscrollable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:27:53 -07:00
6c3098922f Field view: a package waiting on a predecessor is not "Ready"
The field list judged readiness from open constraints alone, so a package whose
predecessor isn't Closed showed a green Ready pill even though the server would
refuse to issue it. It now shows "waits on N", matching the form, the dashboard and
the navigator drawer. A deleted predecessor still doesn't block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:26:27 -07:00
fcba74b584 Fix the WP navigator and the squeezed embedded layout; add per-project permissions
Layout — the reported "skinny scrolling windows"
- .content-area capped the whole suite at 1000px, so on a 1920 screen the embedded
  Work Package Creator ran in a ~930px column with its own scrollbar inside the
  page's. The wizard now caps at 1700px and the Creator/Dashboard tab goes
  full-bleed: the iframe fills the window below the app chrome and owns the only
  scrollbar. Needed `flex: none` on the content area — as a `flex: 1` item its
  flex-basis overrode `height`, leaving the used height indefinite so the child's
  `height: 100%` collapsed the iframe to its 150px default.
- The SOP wizard's fields were one per row; they now flow into ~340px columns.

Navigator — now an auto-hiding drawer
- It was a fixed 262px column that stole width from the form AND was hidden below
  1100px, so embedded (the normal path) it never appeared at all — that's the
  "broken side menu". It's now an overlay drawer behind a slim always-visible edge
  handle: hover or tap to open, move away / Escape / pick a package to close, or pin
  it to keep it open (pinned shifts the form and the page chrome across, and is
  remembered). A gutter keeps the handle off the section-nav chips.

Bugs found while checking the site over
- collectStepData() still read the SOP team fields as text inputs, but wave 1 made
  them account pickers — so it wrote a user ID into state.team.pm where the display
  NAME belongs, and the SOP would print `user_ab12…` as the PM. Now synced properly
  from the pickers.
- loadSampleData() set .value on those selects with fictional names; setting an
  unmatched value on a <select> silently does nothing, so the sample lost its team.
  It now stores them as names without an account, which the picker shows as
  "(no account)".
- My earlier CSS block replacement had deleted the SOP-chip, people-picker and
  critical-tag styles. Restored.

Same picker everywhere the SOP names someone
- Sign-off roles (step 3, required and optional) are account pickers now, storing
  userId alongside the name, so a signature belongs to an account that can be
  notified. Titles stay free text.

Per-project permissions (asked for: "change project permissions for individual users")
- project_members.role overrides the account's role on that project, so a PM on one
  job can be a Project User on another. Empty = inherit; app admin is admin
  everywhere. effective_role() feeds require_project_admin, so WP delete, completed-
  SOP edits and project delete are all judged per project.
- Project access is now its own column in the admin console (it was buried among the
  action buttons, which is why it couldn't be found), showing the project count per
  account; the dialog sets access plus the role on each project.
- The members endpoint reports each person's effective role on that project.

Verified: 157 API checks across five suites on clean databases (44 permissions +
22 password reset + 34 search/localization + 39 gates/notifications + 18 new
per-project permission checks), 16 drawer-behaviour + 4 pinned-mode UI checks driven
in headless Chrome, and probes confirming the team/sign-off pickers populate and no
longer corrupt state.team on step navigation. Screenshots reviewed at 1920x1080.

Service-worker cache bumped to v3 so browsers pick up the new shell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:22:19 -07:00
b38348e6ae Wave 3: predecessor references with a release gate, and critical-constraint reopen alerts
Predecessors are real references now
- data.predecessors holds work-package ids, replacing a free-text SOP phase label
  that couldn't express "WP04 waits on WP02" and gated nothing. The SOP phase
  survives beside it as the descriptive "Sequence phase" field.
- readiness() has two gates: constraints clear AND every predecessor Closed. The
  banner, sticky bar, left rail, dashboard Gates column and the ready counters all
  reflect the second one.
- Enforced server-side by enforce_release_gates() on every path that sets a status
  — the plain upsert included, since that's how the browser and the offline outbox
  save. /issue and /status would otherwise have been ways around it.
- Cycles are refused directly and through a chain, with a message naming the
  package that already waits on this one. The Creator's picker also hides itself
  and its own descendants, so a cycle is hard to build in the first place.
- A deleted predecessor does not block: it would freeze everything downstream of a
  package someone removed.
- The gate is refusable, on purpose. Planners release ahead of upstream close-out,
  so an explicit reason (data.gateOverride) allows it, gets a gate_overridden audit
  event naming what was skipped, and prints on the package. A blank reason is not
  an override, and changing the predecessor set clears it. The dashboard won't
  release a blocked package at all — it points at the form where the reason is
  captured.

Critical constraints reopened after release
- Reopening a SOP-critical constraint on a released package emails the owner, PM,
  CM and the package's distribution list (minus whoever did it) and writes a
  constraint_reopened audit event.
- Detected by diffing the incoming constraints against the stored ones inside the
  normal upsert rather than via a new endpoint: the sync outbox only replays
  POST /api/wps, so a dedicated route would be lost offline. It fires only on a
  real cleared→open transition, so re-saving an already-open constraint doesn't
  re-announce, and never before release or for a non-critical constraint.
- Bodies carry the constraint name, WP number and a link — never package contents.

Verified: 139 API checks on one fresh database (44 permissions + 22 password reset
+ 34 search/localization + 39 gates/notifications), including every bypass path,
cycle shapes, the deleted-predecessor case, blank-reason overrides, and the four
recipients confirmed both in the outbox and on the wire against a local SMTP sink.
27 driven UI checks against the real Creator page in headless Chrome covering the
picker, the override prompt (accept and cancel), override invalidation, the cycle
exclusions and the dashboard refusal. Screenshots reviewed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:15:36 -07:00
61d1cf4bff Wave 2: form cleanups from the site comments, plus localization, project switcher and global search
Site comments (8/3)
- BIM card: LOD removed, IFF # added next to the coordination status, and required
  once that status is "Signed off (IFF)" — an unnumbered sign-off isn't traceable.
  A LOD already stored on a package is preserved and shown as legacy, not blanked.
- The blue "from SOP types" subtext under a field is now a SOP chip on the label
  with the detail in a tooltip. The chip stays visible rather than hover-only:
  field tablets have no hover, and "this came from the SOP" is the part that
  matters. The hint elements stay in the DOM (hidden) so the code writing to them
  keeps working; an observer mirrors their text into the tooltip.
- Specification Section is no longer typed per package. Each WP type carries a
  spec section on the SOP; the field is read-only in the Creator and follows the
  type, with the SOP's spec folder linked underneath. This reads both spec
  comments as one intent — stop typing it, derive it.
- Assignees and Distribution are multi-selects over the SOP project team, showing
  each person's job function, with the CM pre-added to Distribution (removable per
  package) and a free-text option for people with no account. The stored display
  strings are unchanged so print/export/dashboard keep working; account ids ride
  alongside for the notification work in wave 3.

Localization + time
- Per-user locale/timezone (Language & time in the user menu), an app-wide default
  in the admin console, then the browser. Timezones are validated against the
  server's zoneinfo and the picker is fed from it. Calendar dates are formatted
  from their parts so a due date never reads a day early in another zone.
- Every displayed timestamp now goes through the shared helpers.

Top-bar chrome
- Project switcher beside the logo and a centered global search, injected into
  either generation of top bar; skipped in an iframe so the embedded Creator
  doesn't get a second one. Ctrl/Cmd-K focuses search.
- GET /api/search covers work packages, projects and SOPs, scoped to the caller's
  projects, hiding archived packages, with LIKE wildcards escaped.

Fixed along the way: showForm() cleared every card's inline display, which undid
applyKind() — so the Package Type and BIM cards reappeared on an install-only
project. Split out applyKindVisibility() and re-apply it there.

Verified: 100 API checks on a fresh database (44 permissions + 22 password reset +
34 search/localization), 24 driven UI checks against the real Creator page in
headless Chrome (SOP chips, both people pickers, spec auto-fill, critical tags,
BIM suppression), and the chrome harness on both bar styles. Screenshots reviewed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:17:51 -07:00
79b0e955b4 Wave 1: permissions roles, account-backed SOP team, critical constraints, password reset, BIM flag
Acts on the site comments from 8/3 plus the follow-ups. Foundation work first —
four of the comments all needed the project team to resolve to real user accounts.

Permissions vs project role (new)
- User.role is now the PERMISSIONS role: admin | project_admin | project_user.
  project_admin may delete work packages, change a SOP after it is complete, and
  delete a project; project_user may not (archiving a WP is still open to them).
  Enforced by require_project_admin() server-side; the UI only hides dead ends.
- New User.project_role holds the person's JOB FUNCTION on the project. It grants
  nothing — it feeds the SOP team pickers and notification routing.
- Admin console shows both columns and explains the difference. Migration rewrites
  the legacy role 'user' to 'project_user'.
- Deleting a project was previously open to any member and unaudited; it now needs
  project_admin and writes an audit event. ProjectData.remove no longer drops the
  project from the local cache when the server refuses.

SOP project team from user accounts
- PM/APM/CM/QM and additional team members are pickers over the project's members,
  storing the account id next to the display name. A name from an older SOP with no
  matching account is kept and flagged rather than dropped.
- The WP Creator lists the SOP team first in the Owner picker, and a new package
  defaults to whoever is creating it.

Critical constraints
- SOP constraints carry a Critical flag; buildConstraints() now copies the whole
  definition through to the package (it previously reduced them to names, losing
  description too), and critical rows are marked in the WP form. The email on
  reopen-after-release is wave 3.

Password reset by email
- login.html gains Forgot password and a set-a-new-password view, offered only when
  the server reports email is actually configured.
- Single-use signed token (AUTH_RESET_MINUTES, default 60) bound to token_version,
  sent immediately rather than through the notifications outbox so a reset link is
  never persisted. Identical response for unknown accounts; per-account send
  cooldown; a completed reset clears any login lockout.
- Session and reset tokens are no longer interchangeable.

BIM kill-switch
- New admin Features card with bim_enabled, OFF by default. The SOP creator hides
  the BIM section and the Creator treats every package as install-only while it is
  off; a SOP that already has BIM keeps its data untouched.

Verified with two throwaway-database test scripts: 44 checks on the permissions
matrix and token handling, 22 on the reset flow end-to-end against a local SMTP
sink (real message captured, link extracted and used). Front-end files parse-checked
in headless Chrome. Not yet exercised in a browser against a real login.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:48:59 -07:00
1d004cab75 Widen the IWP screen and add a left work-package navigator
The Work Package form was capped at a 1000px column, which wasted most of a
desktop screen, and the only way to reach another package was to scroll to the
Saved table at the bottom.

- Put the form in a wide two-column shell (max 1760px); ctx-bar, mode-wrap and
  the release banner widened to match.
- Above 1200px the two-up field grids flow to 3-4 columns instead of stretching
  two fields across the whole card. Narrow layouts are unchanged.
- New sticky left rail listing every saved package, grouped by status in field
  order, with WP number, subject, readiness dot and type. Click to open it in
  the form; the package being edited is highlighted. Filter box, + New and
  Dashboard shortcuts, collapsible (state persisted), hidden under 1100px where
  the Saved table still covers navigation.
- The rail re-renders from renderSavedList(), so saves, deletes, splits,
  archive/restore and the project pull all keep it current.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:59:34 -07:00
39b48055ff Productionize WP Suite: auth, security hardening, sync, dashboard, PWA, email
Brings the Work Package Suite from a browser-local prototype to a
multi-tenant, SQL-backed deployment hardened for customer IP.

Auth & access control
- Local username/password login (bcrypt + JWT in an HttpOnly cookie),
  admin-managed users, per-project membership, and project-scoped API access.
- Admin console: change user roles, view the audit trail, manage settings.

Security hardening
- CSP / HSTS / X-Frame-Options / nosniff headers in nginx; Secure cookie via
  X-Forwarded-Proto; CSRF Origin check; attribute-safe output escaping.
- Login lockout, token_version session revocation, stronger password policy,
  fail-closed secret loading, encrypted (AES-256) database backups.

Persistence & schema
- SOPs and Work Packages are now DB-backed and shared across users, written
  through a durable client sync outbox that queues offline edits.
- Alembic migrations applied automatically on container start.

New capabilities
- Phase 2 dashboard (progress, gating, pagination, archive).
- Phase 3 PWA "Field View" with offline caching and auth fallback.
- WP owner assignment with OPTIONAL email notifications, OFF by default and
  toggled from the admin console. SMTP password is read only from the
  SMTP_PASSWORD env var (never stored); emails carry a WP number + deep link,
  never customer IP.

Also: IBM Carbon restyle, Help section, and DEPLOYMENT.md brought up to date.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 17:51:15 -07:00
dd37f1f551 Keep the 2D sheets / spool-drawings step out of install-only sequences
That deliverable is a BIM/EWP output (and on install-only jobs Prime
often doesn't own it), so it no longer appears in the default IWP
sequence. It now lives in the BIM sequence as the hand-off step, so it
only shows on BIM-enabled projects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:58:01 -04:00
afdc815fb4 Store SOPs and Work Packages in the DB (shared across users)
Reintegrates C-West8's "storing data in DB instead of client only"
(commit e5102446 on shared-data) on top of the BIM / per-package work.
localStorage becomes a per-browser cache; the server is authoritative.

- project-data.js: pullProject() hydrates the apps' existing localStorage
  keys from the API on load; pushSOP()/pushWP()/removeWP() write through
  on save/delete. WPs store the whole flat object in `data`, so BIM
  fields, kind, and projectLinks round-trip intact.
- index.html: home pulls the project before showing SOP status; feedback
  loads from /api/comments (server-authoritative, local fallback).
- work-package-suite-app.js: pull-then-restore on boot; completeSOP
  pushes the SOP to the server.
- wp-creation-app.js: save/duplicate/issue/setStatus push; delete/clear
  remove; boot pulls from the server first, then boots off the cache.
- server/app.py: /api/sops and /api/wps take full=true to return the
  data JSON for one-request hydration (list stays lean by default).

Co-Authored-By: C-West8 <125926137+C-West8@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:58:33 -07:00
ca4176ac00 Sequence: BIM steps first, and default flow matches the field spec
- enableBIM() prepends the BIM steps (BIM precedes construction) instead
  of appending them.
- Default construction sequence updated to the agreed flow (2D sheets /
  spool drawings -> conduit -> tray -> QC hold -> wire pull -> device ->
  termination -> QC hold -> commissioning -> as-built), with QC-hold gates.
- Sample project now enables BIM/VDC so it demonstrates the full
  BIM -> construction sequence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:49:11 -07:00
0e698438a2 BIM as a per-package kind; one project flows BIM -> construction
Replace the whole-project BIM 'mode' with an opt-in capability + a
per-package kind, so a single project can produce both model and
install packages (and install-only projects are unaffected).

SOP tool:
- Step 4 "Include BIM / VDC work packages" checkbox (state.bimEnabled).
  Enabling adds BIM package types + BIM release gates (flagged bim) plus
  BIM roles/sources/process steps alongside the construction defaults;
  disabling strips the bim-flagged items.
- Generated SOP carries bimEnabled and a per-type / per-constraint bim flag.
- Required sign-off role titles stay editable (no longer force-renamed).

Work Package Creator:
- Shows a Package Type selector (Install IWP / BIM EWP) only when the
  SOP has bimEnabled; kind is saved per package and labeled in the output.
- WP types and release gates are filtered by kind (BIM types+gates for
  EWP, install types+gates for IWP).
- EWP reveals the BIM Details card and hides controls.dev Assets /
  Materials / Kitting-MIMO; IWP shows those plus the "Enabled by - BIM
  package" traceability link.

Supersedes the earlier whole-project BIM mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:07:08 -07:00
566ace9969 Add BIM/VDC work packages, platform links, and AWP traceability
SOP tool:
- "Load BIM / VDC template" (Step 4): one click sets BIM deliverable
  types, install-phase disciplines, BIM release-gate constraints (with
  EN06 citations), the BIM process sequence, reference sources, BIM
  sign-off roles, and MWP##-[Area]-[PHASE] numbering. Tags the SOP mode
  as 'bim'.
- Step 3 required sign-off role titles are now editable (default
  Superintendent/Foreman); the BIM template sets them to BIM Coordinator
  and Construction Lead (CRS).
- Step 7: capture a project-homepage link for the chosen tracking and
  commissioning platforms.

Work Package Creator:
- BIM mode (SOP.mode==='bim'): hides controls.dev Assets, Material List,
  and Kitting/MIMO; shows a BIM Details card (LOD, model area, clash /
  coordination status, linked scan).
- Project tracking/commissioning links are copied onto every WP and
  shown in the WP output.
- "Enabled by - BIM package(s)" field links a field IWP back to the BIM
  package that enabled it (EWP -> IWP traceability).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:54:57 -07:00
55 changed files with 7207 additions and 723 deletions

11
.gitignore vendored
View File

@@ -15,5 +15,16 @@ wpsuite.db
# Runtime directories (created by containers)
logs/
# Database backup dumps (large + sensitive) — keep the folder, ignore contents
/backups/*
!/backups/.gitkeep
# Local server logs
*.log
# Local scratch / test artifacts (curl cookie jars hold live session tokens)
_*.txt
cookies.txt
# Claude Code local workspace (agent memory, session data)
.claude/

View File

@@ -51,9 +51,31 @@ Create a file named `.env` in the **project root** (same folder as
POSTGRES_DB=wpsuite
POSTGRES_USER=wpsuite
POSTGRES_PASSWORD=<strong-random-password>
# REQUIRED — signs login session cookies. If unset, `docker compose up` errors
# out and the API refuses to start. Generate once and keep it stable:
# openssl rand -base64 48
AUTH_SECRET_KEY=<strong-random-secret>
# Encrypts database backups at rest (AES-256). Set this BEFORE the DB holds
# customer IP. Keep the passphrase OFF this host — losing it makes dumps
# unrecoverable: openssl rand -base64 32
BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
# OPTIONAL — SMTP password for WP-assignment email + password-reset links. Email
# is OFF by default and enabled from the Admin console; the host/port/from-address
# are configured there, but the password is only ever read from this variable
# (never stored in the DB or shown in the UI). Leave unset until you have SMTP
# details.
# SMTP_PASSWORD=<smtp-app-password>
# OPTIONAL — password-reset link lifetime (minutes) and the per-account send
# cooldown (seconds). Defaults shown; both only matter once email is enabled.
# AUTH_RESET_MINUTES=60
# AUTH_RESET_COOLDOWN_SECONDS=120
```
That's it — the API now builds its own connection string from these three
The API builds its own DB connection string from the `POSTGRES_*`
values and **encodes the password automatically**, so a password with special
characters (`@ ! # : /` …) works without any manual escaping. `DATABASE_URL`
is **optional** and only needed if you want to point the API at some other
@@ -64,7 +86,8 @@ Generate a strong password with `openssl rand -base64 32`.
> **Portainer note:** for a Git-based stack these go in the stack's
> **Environment variables** section (Portainer doesn't read a local `.env`).
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` there.
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` / `AUTH_SECRET_KEY` /
> `BACKUP_ENC_PASSPHRASE` (and `SMTP_PASSWORD`, if you enable email) there.
These are the only credentials in the system, and they never appear in the
compose file or in git.
@@ -77,6 +100,14 @@ chosen hostname (e.g. `wp-suite.company.local`) to the `nginx_webserver`
container on that network. The container already proxies `/api/` to the `api`
service internally — no extra app config needed.
> **Serve it over HTTPS, and forward the scheme.** The bundled nginx sets the
> security response headers (CSP, HSTS, `X-Frame-Options`, `nosniff`) and passes
> `X-Forwarded-Proto: https` to the API, which is what makes the session cookie
> `Secure`. If you front the stack with your **own** proxy instead, make sure it
> terminates TLS and forwards `X-Forwarded-Proto: https` — otherwise the login
> cookie won't get the `Secure` flag. HSTS also assumes the site is only ever
> reached over HTTPS.
## 4. Bring it up
From the project root:
@@ -145,12 +176,10 @@ python3 server/seed_demo.py https://wp-suite.company.local --insecure
python3 server/seed_demo.py https://wp-suite.company.local --clean # remove it later
```
> **What shows where:** the DEMO **project** is API/SQL-backed, so it appears in
> the home-page project picker right away (this is the visible proof that the
> projects → SQL path works end-to-end). The DEMO **SOP and Work Packages** are
> written to SQL too, but the current front end still reads SOPs/WPs from the
> browser, so they won't render in the Creator/Dashboard until the Phase 2
> wiring. Inspect them at the SQL layer with `smoketest.py` or:
> **What shows where:** the DEMO **project**, its **SOP**, and its **Work
> Packages** are all API/SQL-backed, so they appear in the home-page project
> picker and render in the Creator/Dashboard as soon as any user opens the
> project. Inspect them at the SQL layer with `smoketest.py` or:
> ```bash
> docker compose exec db psql -U wpsuite -d wpsuite \
> -c "select number, subject, status from work_packages order by number;"
@@ -160,20 +189,21 @@ python3 server/seed_demo.py https://wp-suite.company.local --clean # remove it
## What is stored in SQL today
Be aware of the current persistence split — the API + Postgres are fully
deployed, and:
The API + Postgres are the system of record. Everything below is server-stored
and shared across every user who opens the project:
| Data | Stored in PostgreSQL today? |
|------|------------------------------|
| **Projects** | **Yes** — the front end is API-first (`/api/projects`), falling back to the browser only if the API is unreachable. |
| **Comments / feedback** | **Yes** — every feedback surface posts to `/api/feedback`. |
| **SOPs** | Endpoints exist (`/api/sops`); the front end still keeps the SOP in the browser (namespaced per project). Wiring it to the API is the remaining **Phase 2** step. |
| **Work Packages** | Same — `/api/wps` (+ issue/status/metrics) exist and are ready; the creator still saves to the browser per project. |
| **SOPs** | **Yes** — pulled from `/api/sops` on load and written through on every save. |
| **Work Packages** | **Yes** — same write-through to `/api/wps` (+ issue / status / archive / metrics), including the owner assignment (`assignee_id`). |
So a fresh deployment gives you **shared, server-stored projects and comments
immediately**. Moving SOPs and Work Packages off the browser and onto the API
(so they're shared across users too) is a front-end change only — the database
and endpoints are already in place.
Saves go through a **durable client-side sync outbox**: edits are written to the
API immediately, and if the device is offline they queue and retry when it
reconnects (4xx rejections are dropped rather than retried forever). The browser
cache is only an offline fallback that reconciles through that outbox — so two
users on the same project see the same server-stored SOP and Work Packages.
## Data model (PostgreSQL)
@@ -181,8 +211,13 @@ and endpoints are already in place.
|-------|-------|-------------|
| `projects` | top-level construction projects | `name`, `number`, `client`, `division`, `site`, `sample`, `data` |
| `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) |
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `issued_at`, `data` (full WP JSON) |
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `assignee_id` (owner), `issued_at`, `archived_at`, `data` (full WP JSON) |
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` |
| `users` | login accounts | `username`, `password_hash` (bcrypt), `role`, `full_name`, `email`, `is_active`, login-lockout + `token_version` fields |
| `project_members` | per-project access control | `user_id` → users, `project_id` → projects |
| `audit_log` | append-only activity trail | `actor`, `action`, `entity_type`, `entity_id`, `project_id`, `summary`, `detail` |
| `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) |
| `app_settings` | admin-configured settings (e.g. email) | `key`, `value` (JSON) |
The complete client document is stored verbatim in each row's `data` JSON
column; frequently-listed fields are promoted to real columns for filtering.
@@ -192,8 +227,13 @@ column; frequently-listed fields are promoted to real columns for filtering.
Projects `GET/POST /api/projects`, `GET/DELETE /api/projects/{id}` ·
SOPs `GET/POST /api/sops`, `GET /api/sops/latest`, `GET/DELETE /api/sops/{id}` ·
Work Packages `GET/POST /api/wps`, `GET/DELETE /api/wps/{id}`,
`POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `GET /api/wps/metrics` ·
Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments`.
`POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `POST /api/wps/{id}/archive`,
`GET /api/wps/metrics` ·
Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments` ·
Auth `POST /api/auth/login` / `logout`, `GET /api/auth/me`, admin user management
under `/api/auth/users` · Admin-only `GET/PUT /api/settings`,
`POST /api/settings/test-email`, `GET /api/notifications`,
`GET /api/projects/{id}/members`.
List/latest/metrics accept a `project_id` (and `sop_id`) filter. Full reference
and request shapes: `/api/docs` and [`server/README.md`](server/README.md).
@@ -209,27 +249,196 @@ docker compose up -d --build api # backend change (server/)
## Backups & retention
The whole dataset is in the `pgdata` volume — back it up on a schedule:
A **`backup` sidecar** (in `docker-compose.yml`) runs `pg_dump` on a schedule and
writes gzipped, timestamped dumps to `./backups/` on the host. It starts with the
stack — no cron to set up.
```bash
# Backup (run from project root)
docker compose exec -T db pg_dump -U wpsuite wpsuite > backup-$(date +%F).sql
- **Cadence / retention:** daily, keeping the newest 14 dumps. Override in `.env`
with `BACKUP_INTERVAL_SECONDS` (seconds between dumps) and `BACKUP_KEEP` (how many
to keep).
- **Encryption at rest:** set `BACKUP_ENC_PASSPHRASE` in `.env` and dumps are
written AES-256-encrypted as `*.sql.gz.enc`. **Do this before any customer IP
goes in** — without it the dumps (and every offsite copy) are plaintext. Store
the passphrase somewhere other than this host; if you lose it the backups can't
be restored.
- **Ad-hoc backup now:** `docker compose exec backup sh /scripts/db-backup.sh`
- **Restore (destructive — overwrites current data):**
`docker compose exec backup sh /scripts/db-restore.sh /backups/wpsuite-YYYYMMDD-HHMMSSZ.sql.gz.enc`
- **Offsite — do this:** the dumps live in `./backups/` on the host; if the host/volume
dies, so do they. Sync that folder offsite from the **host** (e.g. a cron running
`rclone`/`aws s3 sync`). The `db`/`backup` containers are on an egress-less
`internal` network on purpose, so offsite must be pushed from the host.
- **Test restores quarterly:** load the latest dump into a throwaway database and
confirm it applies. An untested backup is not a backup.
# Restore
docker compose exec -T db psql -U wpsuite -d wpsuite < backup-YYYY-MM-DD.sql
```
## Field devices & data at rest
## Schema migrations (important)
The field view (PWA) caches a project's Work Packages/SOP in the browser's
localStorage so it works offline — i.e. **customer IP sits on the device**.
localStorage is not encrypted and is not a security boundary. Signing out clears
the cached project data, but for any tablet/phone that opens customer-IP projects:
Tables are auto-created on API startup (`Base.metadata.create_all`). This
creates **missing tables**, but it does **not** alter existing ones. The
multi-project work added the `projects` table and new columns
(`sops.project_id`, `work_packages.project_id` / `parent_id` / `issued_at`):
- **Require full-disk encryption** (BitLocker / FileVault / Android FBE / iOS is
encrypted by default) and a device passcode.
- **Enrol field devices in MDM** so a lost device can be remotely wiped, and keep
the browser profile per-user on shared devices.
- Users should **sign out** when handing off a shared device (clears the cache).
- On a **fresh** database these appear automatically — nothing to do.
- On a database that **already has data** from an older schema, add the new
columns with a migration (introduce **Alembic**) or apply them manually with
`ALTER TABLE` before deploying — don't rely on `create_all` for column changes.
## Email notifications (optional)
Work-package **owner assignment** works out of the box (in-app only). Optional
**email** on assignment is **OFF by default** and is turned on from the **Admin
console → Notifications & email** card, where an admin sets the SMTP host / port /
TLS / From address and flips the master toggle.
- The **SMTP password is never stored in the database.** It is read only from the
`SMTP_PASSWORD` environment variable (see the `.env` block in step 2 and the
`api` service in `docker-compose.yml`). The UI shows only whether it is set.
- Email stays effectively off until **all** of: the toggle is on, SMTP host + From
are configured, and `SMTP_PASSWORD` is present. Until then, assignments are
still recorded in-app (status `skipped`); nothing is sent.
- Notification emails carry only a **WP number and a deep link** — never the work
package contents — so customer IP stays behind the login.
- Use the card's **Send test email** button to confirm SMTP before enabling.
### Self-service password reset
Turning email on also enables **Forgot password** on the login page. Until then the
link explains that an admin must reset it (`server/manage_users.py`, or the Admin
console's **Reset password** button).
- The emailed link carries a short-lived signed token — `AUTH_RESET_MINUTES`
(default 60). It is **single-use**: completing a reset bumps the account's
`token_version`, which both burns the link and signs out that user's other
sessions. A completed reset also clears any login lockout.
- `/api/auth/forgot-password` answers **identically for unknown accounts**, so it
can't be used to discover usernames. Misses are recorded in the audit log
(`password_reset_miss`) instead.
- One reset mail per account+client per `AUTH_RESET_COOLDOWN_SECONDS` (default 120)
so the form can't be used to flood someone's inbox. The throttle is per worker
and in-memory; the token expiry is the real control.
- Reset mails are sent **immediately, not through the notifications outbox** — a
reset link must never be persisted where an admin could read it and take over an
account.
- Set `app_base_url` in the admin card, or the emailed link will be relative and
therefore useless.
## Permissions roles
`User.role` is the **permissions** role; `User.project_role` is the person's **job
function** on the project (Project Manager, Superintendent, …) and grants nothing.
Both are set in the Admin console's user table.
| Role | May do |
|---|---|
| `admin` | User administration, app settings, and every project |
| `project_admin` | On assigned projects: delete work packages, change a **completed** SOP, delete the project |
| `project_user` | Create/edit work packages, author a SOP up to completion; may archive a WP but not delete one |
Enforced server-side by `require_project_admin` in `server/app.py`; the front end
only hides controls to avoid dead-end clicks. Accounts created before this change
carried the role `user`, which the migration rewrites to `project_user`.
## Feature flags
**Admin console → Features.** `bim_enabled` is **OFF by default**: the SOP creator
hides the BIM/VDC section and every project is install-only (IWP). A SOP that
already has BIM enabled keeps its data — it just stops being offered — so turning
the flag off never deletes BIM types, gates, or sequence steps.
## Release gates (constraints + predecessors)
A work package reaches **Issued** only when both gates are met:
1. every constraint is **Cleared** or **N/A** — a hard gate, no override;
2. every **predecessor work package** (`data.predecessors`, a list of WP ids) is
**Closed**.
Enforced by `enforce_release_gates()` on **every** path that can set a status —
`/api/wps` (the browser and the offline outbox both save through it),
`/api/wps/{id}/issue`, and `/api/wps/{id}/status`. Also:
- **Overridable, deliberately.** Planners legitimately release ahead of upstream
close-out, so the predecessor gate accepts `data.gateOverride = {reason, by, at}`.
A blank reason is not an override. The server writes a `gate_overridden` audit
event naming the reason and what was skipped, and the reason prints on the
package. Changing the predecessor set clears the override.
- **Cycles are refused** (`check_predecessor_cycle`) — direct and through a chain,
with a 400 explaining which package already waits on this one.
- **A deleted predecessor does not block.** It would otherwise freeze everything
downstream of a package someone removed.
- The Creator's picker hides itself and any package that already waits on it, so a
cycle is hard to build in the first place; the dashboard refuses to issue a
blocked package and points at the form for the logged override.
`data.seq` (the SOP sequence phase) is still stored and shown, but it is
descriptive — it gates nothing.
## Critical constraints reopened after release
A constraint marked **Critical** on the SOP that reopens **after** the package was
released emails the **owner, PM, CM and everyone on the package's distribution
list** (minus whoever reopened it), and writes a `constraint_reopened` audit event.
Detected by comparing incoming constraints against the stored ones inside the
normal upsert — *not* a separate endpoint, because the browser saves through the
sync outbox, which only replays `POST /api/wps`; anything hung off another route
would be lost offline. It fires only on a real transition (cleared/N-A → open), so
re-saving an already-open constraint doesn't re-announce, and never for a package
that was never released or a non-critical constraint. Bodies carry the constraint
name, WP number and a link — never the package contents.
## Localization (dates, times, numbers)
Three levels, most specific first — resolved in `html/wp-format.js`:
1. **the user's own preference** — *Language & time* in the top-right menu
(`users.locale` / `users.timezone`, via `POST /api/auth/preferences`)
2. **the app default** — Admin console → Features → *Localization defaults*
(`default_locale` / `default_timezone`)
3. **the browser**, as before
Timezone names are validated against the server's own `zoneinfo` database, and the
picker is fed from `GET /api/timezones` so it can only offer what will be accepted.
Calendar dates (a due date, a kitting date) are formatted from their parts and are
**never** shifted by a timezone — only real instants (MIMO windows, history,
notifications) are converted. Use the shared helpers (`wpFormatDate`,
`wpFormatDateTime`, `wpFormatTime`, `wpFormatNumber`) rather than
`toLocaleString()`, or a page will quietly ignore the preference.
## Top-bar chrome (project switcher + search)
`html/wp-chrome.js` + `wp-chrome.css` inject a project switcher and a centered
global search into whichever top bar a page has — the dark `.wp-appbar` or the
older `.header`. It is skipped inside an iframe, so the embedded WP creator does
not get a second bar.
- Switching project reloads the current page with `?project=<id>`; every page
already resolves its project from that parameter.
- Search calls `GET /api/search?q=`, which is **scoped to the caller's projects**
(`scope_to_access`) and hides archived work packages. LIKE wildcards in the query
are escaped, so searching `100%` matches a literal `100%`. Two-character minimum.
- Ctrl/Cmd-K focuses the field from anywhere.
## Schema migrations (Alembic)
Schema is managed by **Alembic** (`server/alembic/`). The API container runs
`alembic upgrade head` on startup (see the `Dockerfile` CMD), so **deploys apply
pending migrations automatically**.
- The **baseline** migration is idempotent: on a fresh database it creates every
table; on a database whose tables already exist (made by the old `create_all`)
it adopts the schema as-is — no manual `alembic stamp` needed.
- Local dev on SQLite still auto-creates tables for a zero-config run; Postgres is
migrations-only.
- **To change the schema:** edit `server/models.py`, then generate and review a
migration before committing:
```bash
# from the project root (against your dev SQLite or a staging DB)
python -m alembic -c server/alembic.ini revision --autogenerate -m "describe the change"
python -m alembic -c server/alembic.ini upgrade head # apply locally to test
```
The next `docker compose up -d --build api` applies it in production on startup.
## Local trial without Postgres
@@ -237,3 +446,63 @@ For a quick local look, the API falls back to a SQLite file when `DATABASE_URL`
is unset (`sqlite:///./wpsuite.db`) — see [`server/README.md`](server/README.md)
§ *Local dev*. The front end alone can also be served statically from `html/`
(it falls back to browser storage when the API isn't reachable).
## Per-project permissions
`users.role` is the account's **default** permissions role. A membership row can
override it **per project** (`project_members.role`), so someone can be Project
Admin on one job and a plain Project User on another. Empty means "inherit the
account's role", which is how every pre-existing membership behaves.
Resolved by `effective_role()` in `server/app.py`; `require_project_admin()` uses it,
so deleting a work package, changing a completed SOP and deleting a project are all
judged **on that project**. An app `admin` is admin everywhere and bypasses
membership entirely.
Set it in **Admin console → User administration → Project access** (its own column,
showing how many projects each account can reach). The dialog ticks project access
and picks the role on each; `/api/auth/users/{id}/projects` takes
`{project_ids: [...], roles: {project_id: role}}` and only accepts the two
project-scoped roles. Changes are audit-logged as `project_access_changed`.
**Who appears in the SOP's people pickers** is `GET /api/projects/{id}/members` —
the project's members plus app admins, each with their effective role on that
project. A project with nobody assigned shows only the admins, which is why
assigning people is the first step on a new job.
## Asset freshness (why the app can't run half-updated)
A page must never run against a stylesheet or script from a previous deploy. Three
things enforce that, and all three are needed:
1. **`Cache-Control: no-cache` on HTML/CSS/JS** — set by NGINX
(`nginx/conf.d/wp-suite.conf`) and by the dev server (`_NoCacheCode` in
`server/app.py`). With no header at all the browser applies *heuristic* freshness,
roughly 10% of each file's age, so the least recently changed file gets the longest
lifetime — which is exactly how HTML and CSS drift apart. ETag/Last-Modified still
make each revalidation a cheap 304.
2. **The service worker fetches code with `cache: 'no-cache'`** (`html/sw.js`) and
precaches with `cache: 'reload'`. A plain `fetch(req)` inherits the request's
default cache mode and consults the browser HTTP cache, so "network-first" alone
was not enough. Non-`ok` responses fall back to the cache rather than replacing a
page the cache could still serve, and cache keys drop the query string so in-app
links (`?project=…&tab=…`) still resolve offline.
3. **Components whose CSS-missing state is *broken* carry their own critical layout.**
The embedded creator's iframe keeps its sizing inline (and `sizeWPFrame()` re-applies
it), and the work-package panel injects a floor of positioning rules from
`wp-creation-app.js`. Both had failure modes — a 300×150 iframe, and panel controls
dumped loose into the form — that a missing rule turned into a broken page rather
than a plain one.
If you change the shell file list in `sw.js`, bump `CACHE`.
> **NGINX note:** the `Cache-Control` value comes from a `map $uri $wp_cache_control`
> at http level, applied with a single server-level `add_header`. Do **not** move it
> into a `location` block: nginx does not inherit `add_header` into a block that
> declares its own, so a `location ~* \.(html|css|js)$` setting only `Cache-Control`
> silently drops the CSP / HSTS / X-Frame-Options / nosniff headers for exactly those
> files. After deploying, confirm both are present on one response:
>
> ```bash
> curl -sI https://wp-suite.company.local/work-package-suite.html > | grep -Ei 'cache-control|content-security-policy'
> ```

View File

@@ -4,7 +4,8 @@ COPY server/requirements.txt ./server/
RUN pip install --no-cache-dir -r server/requirements.txt
COPY server/ ./server/
EXPOSE 8000
# --preload imports the app once in the master (so create_all runs a single time)
# before forking workers, preventing a table-creation race on first startup.
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "--preload", \
"-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"]
# Apply any pending DB migrations, THEN start the app. `alembic upgrade head` is
# safe on both fresh and existing databases (the baseline migration adopts an
# existing schema, so no manual stamp is needed). `exec` hands PID 1 to gunicorn
# for correct signal handling; --preload imports the app once before forking.
CMD ["sh", "-c", "alembic -c server/alembic.ini upgrade head && exec gunicorn -k uvicorn.workers.UvicornWorker --preload -b 0.0.0.0:8000 --workers 2 server.app:app"]

0
backups/.gitkeep Normal file
View File

View File

@@ -27,9 +27,14 @@ services:
POSTGRES_HOST: db
# Optional full-URL override (must be URL-encoded if used).
DATABASE_URL: ${DATABASE_URL:-}
# Signs login session cookies. MUST be set (see server/.env.example).
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY}
# Signs login session cookies. REQUIRED — compose fails fast if it's unset,
# and the API refuses to start in production without it (see server/auth.py).
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?set AUTH_SECRET_KEY in .env (see server/.env.example)}
AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12}
# Optional — SMTP password for WP-assignment emails. Email is off by
# default and enabled from the Admin console; this is the only email
# secret and it is never stored in the DB. Leave unset until configured.
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
restart: unless-stopped
depends_on:
db:
@@ -55,6 +60,36 @@ services:
networks:
- internal
# Scheduled pg_dump backups. Writes gzipped, timestamped dumps to ./backups on
# the host (sync that folder offsite from the host — this container has no
# internet egress). See scripts/db-backup.sh and DEPLOYMENT.md § Backups.
backup:
build:
context: .
dockerfile: scripts/backup.Dockerfile # postgres client + openssl
container_name: wp_db_backup
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
PGHOST: db
BACKUP_DIR: /backups
BACKUP_KEEP: ${BACKUP_KEEP:-14} # keep the newest N dumps
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400} # 86400 = daily
# Set BACKUP_ENC_PASSPHRASE in .env to encrypt dumps at rest (AES-256).
# Required once the DB holds customer IP. Keep the passphrase off this host.
BACKUP_ENC_PASSPHRASE: ${BACKUP_ENC_PASSPHRASE:-}
volumes:
- ./scripts:/scripts:ro
- ./backups:/backups
entrypoint: ["/bin/sh", "/scripts/backup-cron.sh"]
restart: unless-stopped
depends_on:
db:
condition: service_healthy
networks:
- internal
volumes:
pgdata:
nginx_logs:

View File

@@ -5,19 +5,26 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Console — Work Package Suite</title>
<script src="auth-guard.js"></script>
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
timestamps during their own boot. -->
<script src="wp-format.js"></script>
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-chrome.css">
<style>
:root{ --bg:#f4f5f7; --surface:#fff; --border:#e3e6ec; --border-strong:#d0d5de; --text:#1a2230;
--muted:#5a6675; --dim:#9aa3b2; --accent:#2563d6; --green:#15924f; --green-bg:#e4f6ec;
--red:#cf3b3b; --red-bg:#fbeaea; --amber:#b87100; --amber-bg:#fdf2e0; --mono:'Cascadia Mono',Consolas,monospace; }
:root{ --bg:#f4f4f4; --surface:#fff; --border:#e0e0e0; --border-strong:#8d8d8d; --text:#161616;
--muted:#525252; --dim:#8d8d8d; --accent:#0f62fe; --green:#198038; --green-bg:#defbe6;
--red:#da1e28; --red-bg:#fff1f1; --amber:#8e6a00; --amber-bg:#fdf6dd; --mono:'IBM Plex Mono','Cascadia Mono',Consolas,monospace; }
*{ box-sizing:border-box; }
body{ margin:0; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); }
body{ margin:0; font-family:'IBM Plex Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); }
.wrap{ max-width:860px; margin:0 auto; padding:28px 20px 80px; }
h1{ font-size:20px; margin:0 0 2px; }
.sub{ color:var(--muted); font-size:13px; margin-bottom:18px; }
.card{ background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:18px 20px; margin-bottom:16px; }
.card{ background:var(--surface); border:1px solid var(--border); border-radius:0; padding:18px 20px; margin-bottom:16px; }
.card h2{ font-size:14px; margin:0 0 12px; text-transform:uppercase; letter-spacing:.03em; color:var(--accent); }
button{ font:inherit; font-size:13px; font-weight:600; border-radius:6px; padding:8px 14px; cursor:pointer;
button{ font:inherit; font-size:13px; font-weight:600; border-radius:0; padding:8px 14px; cursor:pointer;
border:1px solid var(--border-strong); background:#fff; color:var(--text); }
button:hover{ border-color:var(--accent); color:var(--accent); }
button.primary{ background:var(--accent); border-color:var(--accent); color:#fff; }
@@ -25,10 +32,10 @@
button.danger{ border-color:var(--red); color:var(--red); }
button.danger:hover{ background:var(--red-bg); }
.row{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
.banner{ padding:10px 14px; border-radius:8px; font-size:13px; font-weight:600; margin-top:10px; border:1px solid var(--border); background:var(--surface); }
.banner{ padding:10px 14px; border-radius:0; font-size:13px; font-weight:600; margin-top:10px; border:1px solid var(--border); background:var(--surface); }
.banner.ok{ background:var(--green-bg); color:var(--green); border-color:var(--green); }
.banner.bad{ background:var(--red-bg); color:var(--red); border-color:var(--red); }
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:8px; padding:12px 14px; font-family:var(--mono);
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:0; padding:12px 14px; font-family:var(--mono);
font-size:12px; line-height:1.55; white-space:pre-wrap; max-height:340px; overflow:auto; margin:12px 0 0; }
pre.out .p{ color:#56d364; font-weight:700; } pre.out .f{ color:#ff7b72; font-weight:700; }
table.kv{ border-collapse:collapse; font-size:13px; margin-top:8px; }
@@ -36,30 +43,41 @@
table.kv td{ padding:5px 0; font-variant-numeric:tabular-nums; font-weight:700; }
.note{ font-size:12px; color:var(--dim); margin-top:10px; }
.gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:20px; }
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:28px; max-width:380px; width:100%; box-shadow:0 8px 30px rgba(20,30,50,.12); }
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius:0; padding:28px; max-width:380px; width:100%; box-shadow:0 8px 30px rgba(20,30,50,.12); }
.gate-box h2{ margin:0 0 4px; font-size:17px; }
.gate-box p{ color:var(--muted); font-size:13px; margin:0 0 16px; }
.gate-box input{ width:100%; padding:10px 12px; font-size:14px; border:1px solid var(--border-strong); border-radius:6px; margin-bottom:12px; }
.gate-box input{ width:100%; padding:10px 12px; font-size:14px; border:1px solid var(--border-strong); border-radius:0; margin-bottom:12px; }
.gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:8px; }
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:8px; padding:9px 13px; font-size:12px; margin-bottom:16px; }
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:0; padding:9px 13px; font-size:12px; margin-bottom:16px; }
a.home{ color:var(--accent); font-size:13px; text-decoration:none; }
.urow{ display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
.urow input, .urow select{ padding:8px 10px; font:inherit; font-size:13px; border:1px solid var(--border-strong);
border-radius:6px; background:#fff; color:var(--text); }
border-radius:0; background:#fff; color:var(--text); }
.urow input{ flex:1; min-width:130px; }
table.users{ border-collapse:collapse; width:100%; font-size:13px; }
table.users th{ text-align:left; padding:7px 10px; color:var(--muted); font-weight:600; border-bottom:1px solid var(--border); white-space:nowrap; }
table.users td{ padding:7px 10px; border-bottom:1px solid var(--border); vertical-align:middle; }
table.users tr:last-child td{ border-bottom:none; }
.tag{ display:inline-block; padding:1px 9px; border-radius:11px; font-size:11px; font-weight:700; }
.tag.admin{ background:#e7effe; color:#1d4ed8; } .tag.user{ background:#eef1f6; color:#5a6675; }
.tag.admin{ background:#edf5ff; color:#0f62fe; } .tag.user{ background:#e8e8e8; color:#525252; }
.tag.on{ background:var(--green-bg); color:var(--green); } .tag.off{ background:var(--red-bg); color:var(--red); }
button.mini{ padding:4px 9px; font-size:12px; }
.me-tag{ font-size:11px; color:var(--dim); margin-left:6px; }
select.role-select{ padding:4px 8px; font:inherit; font-size:12px; border:1px solid var(--border-strong); border-radius:0; background:#fff; color:var(--text); cursor:pointer; }
select.role-select:hover{ border-color:var(--accent); }
select.role-select.is-admin{ color:var(--accent); border-color:var(--accent); font-weight:700; }
</style>
</head>
<body>
<!-- SHARED DARK APP BAR -->
<header class="wp-appbar">
<a href="index.html" class="wp-appbar-brand" title="Back to site">
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
<span class="wp-appbar-title">Work Package Suite <span class="wp-appbar-sub">| Admin Console</span></span>
</a>
</header>
<!-- ADMINS ONLY (shown if the signed-in account isn't an admin) -->
<div class="wrap" id="admin-denied" style="display:none">
<div class="card">
@@ -72,7 +90,7 @@
<!-- CONSOLE -->
<div class="wrap" id="admin-main" style="display:none">
<div class="row" style="justify-content:space-between">
<div><h1>Work Package Suite — Admin Console</h1><div class="sub">Stack diagnostics &amp; tests · talks to <code>/api</code> on this host</div></div>
<div><h1>Admin Console</h1><div class="sub">Stack diagnostics &amp; tests · talks to <code>/api</code> on this host</div></div>
<div class="row"><a class="home" href="index.html">← Site</a></div>
</div>
@@ -96,13 +114,33 @@
<input id="nu-username" placeholder="Username *" autocomplete="off">
<input id="nu-fullname" placeholder="Full name" autocomplete="off">
<input id="nu-email" placeholder="Email" autocomplete="off">
<select id="nu-role"><option value="user">user</option><option value="admin">admin</option></select>
<input id="nu-password" type="password" placeholder="Password (min 8)" autocomplete="new-password">
<select id="nu-role" title="Permissions — what this account may do">
<option value="project_user">Project User</option>
<option value="project_admin">Project Admin</option>
<option value="admin">Administrator</option>
</select>
<select id="nu-project-role" title="Job function on the project"></select>
<input id="nu-password" type="password" placeholder="Password (min 12)" autocomplete="new-password">
<button class="primary" onclick="createUser()">Create user</button>
</div>
<div id="users-create-msg" class="note"></div>
</div>
<!-- FEATURE FLAGS -->
<div class="card">
<h2>Features</h2>
<div class="sub" style="margin-bottom:10px">Switches that change what the suite offers on every project.</div>
<div id="features-box" class="note">Loading…</div>
</div>
<!-- NOTIFICATIONS / EMAIL -->
<div class="card">
<h2>Notifications &amp; email</h2>
<div class="sub" style="margin-bottom:10px">Email notifications for work-package assignments, and self-service password resets. <strong>Off by default</strong> — turn this on only once SMTP is configured. The SMTP <strong>password</strong> is read from the <code>SMTP_PASSWORD</code> environment variable and is never stored here.</div>
<div id="settings-box" class="note">Loading…</div>
<div id="notif-box" class="note" style="margin-top:14px"></div>
</div>
<!-- ALL FEEDBACK / COMMENTS -->
<div class="card">
<h2>All feedback &amp; comments</h2>
@@ -110,11 +148,29 @@
<div class="row">
<button onclick="loadComments()">Refresh comments</button>
<select id="cmt-filter" onchange="renderComments()"><option value="">All sources</option></select>
<input id="cmt-search" placeholder="Search text / author…" oninput="renderComments()" style="flex:1;min-width:160px;padding:8px 10px;font:inherit;font-size:13px;border:1px solid var(--border-strong);border-radius:6px;">
<input id="cmt-search" placeholder="Search text / author…" oninput="renderComments()" style="flex:1;min-width:160px;padding:8px 10px;font:inherit;font-size:13px;border:1px solid var(--border-strong);border-radius:0;">
</div>
<div id="comments-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
</div>
<!-- ACTIVITY LOG (AUDIT TRAIL) -->
<div class="card">
<h2>Activity log</h2>
<div class="sub" style="margin-bottom:10px">Who changed what, and when — across projects, SOPs, work packages, and user accounts. Stored server-side in the shared database.</div>
<div class="row">
<button onclick="loadAudit()">Refresh</button>
<select id="audit-type" onchange="renderAudit()">
<option value="">All types</option>
<option value="wp">Work packages</option>
<option value="sop">SOPs</option>
<option value="project">Projects</option>
<option value="user">User accounts</option>
</select>
<input id="audit-search" placeholder="Search actor / action / item…" oninput="renderAudit()" style="flex:1;min-width:160px;padding:8px 10px;font:inherit;font-size:13px;border:1px solid var(--border-strong);border-radius:0;">
</div>
<div id="audit-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
</div>
<!-- USAGE LOGS -->
<div class="card">
<h2>Usage logs</h2>
@@ -155,5 +211,6 @@
</div>
<script src="admin.js"></script>
<script src="wp-chrome.js"></script>
</body>
</html>

View File

@@ -9,9 +9,13 @@
function reveal(){
document.getElementById('admin-main').style.display='';
fillProjectRoleOptions();
checkHealth();
loadUsers();
loadSettings();
loadNotifications();
loadComments();
loadAudit();
loadUsage();
}
function showDenied(){
@@ -174,12 +178,68 @@ async function loadUsers(){
banner.style.display='none';
const meId = await currentUserId();
renderUsers(json, meId);
// Fill in the project-access counts, then repaint that column.
await loadProjectCounts(json);
renderUsers(json, meId);
}
// Permissions roles (what an account may do) — mirrors auth.ROLES on the server.
const PERM_ROLES = ['admin','project_admin','project_user'];
const PERM_LABELS = { admin:'Administrator', project_admin:'Project Admin', project_user:'Project User' };
// Job functions on a project. Descriptive only — no permissions attached.
const PROJECT_ROLES = ['Project Manager','Assistant Project Manager','Construction Manager',
'Quality Manager','Superintendent','General Foreman','Foreman','Planner / Scheduler',
'BIM / VDC Coordinator','Engineer','Safety (HSE)','Warehouse / Materials','Commissioning',
'Field Technician'];
// Accounts created before permissions roles existed carry the legacy value 'user'.
function normRole(r){ return r==='user' ? 'project_user' : (PERM_ROLES.indexOf(r)>=0 ? r : 'project_user'); }
function fillProjectRoleOptions(){
const sel=document.getElementById('nu-project-role'); if(!sel) return;
sel.innerHTML='<option value="">Project role…</option>'+
PROJECT_ROLES.map(r=>'<option value="'+uesc(r)+'">'+uesc(r)+'</option>').join('');
}
// Per-user project access gets its own column: it was buried among the action
// buttons, which is exactly where you'd fail to find "which projects can this
// person see, and what may they do there".
let _userProjectCounts = {}; // user id -> number of assigned projects
function projAccessCell(u){
const uname = uesc(u.username).replace(/'/g, "\\'");
if(normRole(u.role) === 'admin'){
return '<span class="tag admin" title="Admins can access every project">all projects</span>';
}
const n = _userProjectCounts[u.id];
const label = (n === undefined) ? 'Projects…'
: (n === 0 ? 'No projects yet' : n + ' project' + (n === 1 ? '' : 's'));
return '<button class="mini' + (n === 0 ? ' danger' : '') +
'" onclick="manageProjects(\'' + u.id + '\',\'' + uname + '\')"' +
' title="Choose which projects this user can access, and their role on each">' +
label + '</button>';
}
// A project's role dropdown only matters while that project is ticked.
function projRowToggled(cb){
const row = cb.closest('div');
const sel = row && row.querySelector('select');
if(sel) sel.disabled = !cb.checked;
}
// Counts for that column. One call per user, but only for non-admins and only on a
// refresh — the admin console is not a hot path.
async function loadProjectCounts(list){
const targets = (list || []).filter(u => normRole(u.role) !== 'admin');
await Promise.all(targets.map(async u => {
const { status, json } = await api('GET','/api/auth/users/'+u.id+'/projects');
if(status === 200 && json) _userProjectCounts[u.id] = (json.assigned || []).length;
}));
}
function renderUsers(list, meId){
const wrap=document.getElementById('users-table');
if(!list.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; }
const fmt = s => s ? new Date(s).toLocaleString() : '—';
const fmt = s => s ? wpFormatDateTime(s) : '—';
let rows = list.map(u=>{
const me = u.id===meId;
const active = u.is_active;
@@ -189,23 +249,60 @@ function renderUsers(list, meId){
const delBtn = me
? ''
: '<button class="mini danger" onclick="deleteUser(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Delete</button>';
// Role can be changed at any time via an inline dropdown. Your own row is
// locked (a shown-as-tag) so an admin can't accidentally demote themselves.
const escUname = uesc(u.username).replace(/'/g,"\\'");
// PERMISSIONS role — what the account may do. Your own row is locked (shown as
// a tag) so an admin can't accidentally demote themselves.
const role = normRole(u.role);
const roleCell = me
? '<span class="tag '+(role==='admin'?'admin':'user')+'">'+uesc(PERM_LABELS[role]||role)+'</span><span class="me-tag">locked</span>'
: '<select class="role-select'+(role==='admin'?' is-admin':'')+'" title="Change what this account may do" onchange="changeRole(\''+u.id+'\',this.value,\''+escUname+'\')">'+
PERM_ROLES.map(function(r){
return '<option value="'+r+'"'+(role===r?' selected':'')+'>'+uesc(PERM_LABELS[r])+'</option>';
}).join('')+
'</select>';
// PROJECT role — the person's job function. Descriptive only; grants nothing.
const pr = u.project_role || '';
const projRoleCell =
'<select class="role-select" title="Job function on the project" onchange="changeProjectRole(\''+u.id+'\',this.value,\''+escUname+'\')">'+
'<option value=""'+(pr?'':' selected')+'>— none —</option>'+
PROJECT_ROLES.map(function(r){
return '<option value="'+uesc(r)+'"'+(pr===r?' selected':'')+'>'+uesc(r)+'</option>';
}).join('')+
// Keep a title that isn't on the list (set via the API or an older record).
(pr && PROJECT_ROLES.indexOf(pr)<0 ? '<option value="'+uesc(pr)+'" selected>'+uesc(pr)+'</option>' : '')+
'</select>';
return '<tr>'+
'<td><strong>'+uesc(u.username)+'</strong>'+(me?'<span class="me-tag">you</span>':'')+'</td>'+
'<td>'+uesc(u.full_name||'')+'</td>'+
'<td>'+uesc(u.email||'')+'</td>'+
'<td><span class="tag '+(u.role==='admin'?'admin':'user')+'">'+uesc(u.role)+'</span></td>'+
'<td>'+roleCell+'</td>'+
'<td>'+projRoleCell+'</td>'+
'<td>'+projAccessCell(u)+'</td>'+
'<td><span class="tag '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></td>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
'<td style="white-space:nowrap"><div class="row" style="gap:6px">'+
'<button class="mini" onclick="manageProjects(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Projects</button>'+
'<button class="mini" onclick="resetPw(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Reset password</button>'+
disableBtn+delBtn+
'</div></td>'+
'</tr>';
}).join('');
wrap.innerHTML='<table class="users"><thead><tr>'+
'<th>Username</th><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last login</th><th>Actions</th>'+
'</tr></thead><tbody>'+rows+'</tbody></table>';
'<th>Username</th><th>Name</th><th>Email</th>'+
'<th title="What this account may do in the app">Permissions</th>'+
'<th title="Job function on the project — descriptive only">Project role</th>'+
'<th title="Which projects this user can access, and their role on each">Project access</th>'+
'<th>Status</th><th>Last login</th><th>Actions</th>'+
'</tr></thead><tbody>'+rows+'</tbody></table>'+
'<div class="note" style="margin-top:10px"><strong>Permissions</strong> — '+
'<em>Administrator</em>: manages users, settings and every project. '+
'<em>Project Admin</em>: on their assigned projects, may delete work packages, '+
'change a completed SOP, and delete the project. '+
'<em>Project User</em>: creates and edits work packages and authors the SOP, '+
'but cannot delete WPs or change the SOP once it\'s complete. '+
'<strong>Project role</strong> is the person\'s job function — it feeds the SOP '+
'team pickers and notification routing, and grants nothing on its own.</div>';
}
async function createUser(){
@@ -214,11 +311,12 @@ async function createUser(){
const full_name=document.getElementById('nu-fullname').value.trim();
const email=document.getElementById('nu-email').value.trim();
const role=document.getElementById('nu-role').value;
const project_role=(document.getElementById('nu-project-role')||{}).value||'';
const password=document.getElementById('nu-password').value;
if(!username){ msg.style.color='var(--red)'; msg.textContent='Username is required.'; return; }
if(password.length<8){ msg.style.color='var(--red)'; msg.textContent='Password must be at least 8 characters.'; return; }
if(password.length<12){ msg.style.color='var(--red)'; msg.textContent='Password must be at least 12 characters.'; return; }
msg.style.color='var(--muted)'; msg.textContent='Creating…';
const { status, json } = await api('POST','/api/auth/users',{username,full_name,email,role,password});
const { status, json } = await api('POST','/api/auth/users',{username,full_name,email,role,project_role,password});
if(status===200){
msg.style.color='var(--green)'; msg.textContent='✅ Created '+username+'.';
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id=>document.getElementById(id).value='');
@@ -244,6 +342,26 @@ async function toggleActive(id, makeActive){
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
}
// Change a user's role (user ↔ admin) at any time. The server enforces the same
// admin-only rule as every other user-management call, and refuses to remove the
// last admin. On any failure we reload so the dropdown snaps back to the truth.
async function changeRole(id, role, username){
const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role});
if(status===200){ loadUsers(); }
else {
alert('Could not change permissions for '+username+': '+((json && json.detail)||('HTTP '+status)));
loadUsers();
}
}
async function changeProjectRole(id, project_role, username){
const { status, json } = await api('POST','/api/auth/users/'+id+'/project-role',{project_role});
if(status===200){ loadUsers(); }
else {
alert('Could not set the project role for '+username+': '+((json && json.detail)||('HTTP '+status)));
loadUsers();
}
}
async function deleteUser(id, username){
if(!confirm('Delete user "'+username+'"? This cannot be undone.')) return;
const { status, json } = await api('DELETE','/api/auth/users/'+id);
@@ -255,25 +373,45 @@ async function deleteUser(id, username){
async function manageProjects(id, username){
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
if(status!==200 || !json){ alert('Could not load projects (HTTP '+status+').'); return; }
openProjectModal(id, username, json.projects||[], new Set(json.assigned||[]), json.user);
openProjectModal(id, username, json.projects||[], new Set(json.assigned||[]), json.user, json.roles||{});
}
function closeProjectModal(){ const m=document.getElementById('proj-modal'); if(m) m.remove(); }
function openProjectModal(userId, username, projects, assigned, userObj){
function openProjectModal(userId, username, projects, assigned, userObj, roles){
closeProjectModal();
const isAdmin = userObj && userObj.role==='admin';
const items = projects.length ? projects.map(p =>
'<label style="display:flex;align-items:center;gap:8px;padding:7px 4px;border-bottom:1px solid var(--border);font-size:13px;cursor:pointer;">'+
'<input type="checkbox" value="'+uesc(p.id)+'"'+(assigned.has(p.id)?' checked':'')+(isAdmin?' disabled':'')+'>'+
'<span><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+'</span>'+
'</label>').join('') : '<div class="note">No projects exist yet.</div>';
const isAdmin = userObj && normRole(userObj.role)==='admin';
const acctRole = userObj ? normRole(userObj.role) : 'project_user';
roles = roles || {};
// Each project row: access tick + the role ON THAT project. "Same as account"
// inherits the account's Permissions, so the common case needs no thought.
const items = projects.length ? projects.map(p => {
const on = assigned.has(p.id);
const cur = roles[p.id] || '';
const sel = '<select data-role-for="'+uesc(p.id)+'"'+(isAdmin||!on?' disabled':'')+
' style="padding:3px 6px;font-size:12px;border:1px solid var(--border-strong);background:#fff;">'+
'<option value=""'+(cur===''?' selected':'')+'>Same as account ('+uesc(PERM_LABELS[acctRole]||acctRole)+')</option>'+
'<option value="project_admin"'+(cur==='project_admin'?' selected':'')+'>Project Admin here</option>'+
'<option value="project_user"'+(cur==='project_user'?' selected':'')+'>Project User here</option>'+
'</select>';
return '<div style="display:flex;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid var(--border);font-size:13px;">'+
'<label style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;cursor:pointer;">'+
'<input type="checkbox" value="'+uesc(p.id)+'"'+(on?' checked':'')+(isAdmin?' disabled':'')+
' onchange="projRowToggled(this)">'+
'<span style="overflow:hidden;text-overflow:ellipsis;"><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+'</span>'+
'</label>'+ sel +
'</div>';
}).join('') : '<div class="note">No projects exist yet.</div>';
const modal = document.createElement('div');
modal.id = 'proj-modal';
modal.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;justify-content:center;z-index:10002;padding:20px;';
modal.innerHTML =
'<div style="background:#fff;border-radius:10px;max-width:460px;width:100%;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 40px rgba(20,30,50,.3);">'+
'<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;">Project access — '+uesc(username)+'</div>'+
'<div style="background:#fff;border-radius:10px;max-width:660px;width:100%;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 40px rgba(20,30,50,.3);">'+
'<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;">Project access &amp; permissions — '+uesc(username)+'</div>'+
'<div style="padding:14px 18px;overflow:auto;">'+
(isAdmin ? '<div class="banner" style="margin:0 0 10px">This user is an <strong>admin</strong> and can access every project regardless of assignment.</div>' : '<div class="note" style="margin:0 0 10px">Tick the projects this user may access.</div>')+
(isAdmin ? '<div class="banner" style="margin:0 0 10px">This user is an <strong>Administrator</strong> and can access every project regardless of assignment.</div>'
: '<div class="note" style="margin:0 0 10px">Tick the projects this user may access, and set their role on each. '+
'<strong>Project Admin</strong> can delete work packages, change a completed SOP and delete that project; '+
'<strong>Project User</strong> cannot. Leave it on <em>Same as account</em> to use their Permissions setting.</div>')+
'<div id="proj-list">'+items+'</div>'+
'</div>'+
'<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">'+
@@ -286,8 +424,13 @@ function openProjectModal(userId, username, projects, assigned, userObj){
const saveBtn = document.getElementById('proj-save');
if(saveBtn) saveBtn.onclick = async () => {
const ids = [...modal.querySelectorAll('#proj-list input[type=checkbox]:checked')].map(c=>c.value);
const { status } = await api('PUT','/api/auth/users/'+userId+'/projects',{project_ids:ids});
if(status===200) closeProjectModal();
const roleMap = {};
ids.forEach(pid => {
const sel = modal.querySelector('#proj-list select[data-role-for="'+pid+'"]');
if(sel && sel.value) roleMap[pid] = sel.value;
});
const { status } = await api('PUT','/api/auth/users/'+userId+'/projects',{project_ids:ids, roles:roleMap});
if(status===200){ closeProjectModal(); loadUsers(); }
else alert('Save failed (HTTP '+status+').');
};
}
@@ -316,7 +459,7 @@ function renderComments(){
(!q || ((c.text||'')+' '+(c.author||'')).toLowerCase().indexOf(q)>=0));
if(!rows.length){ box.innerHTML = '<div class="note">No comments'+((src||q)?' match the filter.':' yet.')+'</div>'; return; }
rows = rows.slice().sort((a,b)=> String(b.created_at||'').localeCompare(String(a.created_at||'')));
const fmt = s => s ? new Date(s).toLocaleString() : '—';
const fmt = s => s ? wpFormatDateTime(s) : '—';
const where = c => {
const bits = [];
if(c.page) bits.push(uesc(c.page));
@@ -335,6 +478,233 @@ function renderComments(){
'</tr>').join('')+'</tbody></table>';
}
// ── activity log (audit trail) ──────────────────────────────────────────────────
let _audit = [];
async function loadAudit(){
const box = document.getElementById('audit-admin');
box.textContent = 'Loading…';
const { status, json } = await api('GET','/api/audit?limit=500');
if(status!==200 || !Array.isArray(json)){
box.innerHTML = '<div class="banner bad">Could not load activity (HTTP '+status+').</div>'; return;
}
_audit = json;
renderAudit();
}
function renderAudit(){
const box = document.getElementById('audit-admin');
const type = document.getElementById('audit-type').value;
const q = (document.getElementById('audit-search').value||'').toLowerCase();
let rows = _audit.filter(e => (!type || e.entity_type===type) &&
(!q || ((e.actor||'')+' '+(e.action||'')+' '+(e.summary||'')).toLowerCase().indexOf(q)>=0));
if(!rows.length){ box.innerHTML = '<div class="note">No activity'+((type||q)?' matches the filter.':' yet.')+'</div>'; return; }
const fmt = s => s ? wpFormatDateTime(s) : '—';
const det = e => {
const d = e.detail || {};
if(d.from!=null || d.to!=null) return uesc((d.from==null?'—':d.from)+' → '+(d.to==null?'—':d.to));
return uesc(Object.keys(d).map(k=>k+': '+d[k]).join(', '));
};
box.innerHTML = '<table class="users"><thead><tr><th>When</th><th>Who</th><th>Action</th><th>Type</th><th>Item</th><th>Detail</th></tr></thead><tbody>'+
rows.map(e => '<tr>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(e.at)+'</td>'+
'<td><strong>'+uesc(e.actor||'—')+'</strong></td>'+
'<td>'+uesc((e.action||'').replace(/_/g,' '))+'</td>'+
'<td>'+uesc(e.entity_type||'')+'</td>'+
'<td>'+uesc(e.summary||e.entity_id||'')+'</td>'+
'<td style="color:var(--muted)">'+det(e)+'</td>'+
'</tr>').join('')+'</tbody></table>';
}
// ── notifications / email settings ──────────────────────────────────────────────
let _settings = {};
async function loadSettings(){
const box = document.getElementById('settings-box');
const { status, json } = await api('GET','/api/settings');
if(status!==200 || !json){ box.innerHTML = '<div class="banner bad">Could not load settings (HTTP '+status+').</div>'; return; }
_settings = json; renderSettings();
}
// Feature flags live in the same settings record but get their own card — they're
// not email, and they change what every project sees.
function renderFeatures(){
const s = _settings, box = document.getElementById('features-box');
if(!box) return;
const bim = !!s.bim_enabled;
box.innerHTML =
'<label style="display:inline-flex;align-items:center;gap:8px;font-size:14px;font-weight:700">'+
'<input type="checkbox" id="set-bim"'+(bim?' checked':'')+' onchange="saveFeatures()"> '+
'BIM / VDC tooling is <span style="color:'+(bim?'var(--green)':'var(--muted)')+'">'+(bim?'ON':'OFF')+'</span>'+
'</label>'+
'<div class="note" style="margin-top:8px">When OFF, the SOP creator hides the BIM/VDC section entirely and '+
'every project is install-only (IWP). Existing SOPs that already have BIM enabled keep their data — it just '+
'stops being shown or offered, so no project can be put on the BIM path while it\'s off.</div>'+
'<div id="features-msg" class="note" style="margin-top:6px"></div>'+
// Localization defaults. A user's own "Language & time" preference wins over
// these; these decide what everyone else sees instead of the browser's guess.
'<h2 style="margin-top:22px">Localization defaults</h2>'+
'<div class="sub" style="margin-bottom:10px">How dates, times and numbers are written for users who haven\'t '+
'set their own preference. Each user can override this from <strong>Language &amp; time</strong> in the '+
'top-right menu.</div>'+
'<div class="urow">'+
'<select id="set-locale" style="min-width:220px"></select>'+
'<select id="set-tz" style="min-width:240px"></select>'+
'<button class="primary" onclick="saveLocalization()">Save defaults</button>'+
'<span id="l10n-msg" class="note" style="margin:0"></span>'+
'</div>'+
'<div class="note" id="l10n-preview" style="margin-top:8px"></div>';
fillLocalization();
}
// Locale shortlist mirrors wp-format.js so the admin default and the per-user
// preference offer the same choices.
const L10N_LOCALES = [['','Browser default'],['en-US','en-US — 8/3/2026, 2:07 PM'],
['en-GB','en-GB — 03/08/2026, 14:07'],['en-CA','en-CA'],['es-MX','es-MX'],['es-US','es-US'],
['fr-CA','fr-CA'],['de-DE','de-DE'],['ja-JP','ja-JP'],['ko-KR','ko-KR'],['zh-TW','zh-TW']];
const L10N_ZONES = ['America/Chicago','America/New_York','America/Denver','America/Phoenix',
'America/Los_Angeles','America/Boise','Asia/Tokyo','Asia/Taipei','Asia/Seoul','Asia/Singapore',
'Europe/Dublin','Europe/London','UTC'];
function fillLocalization(){
const s = _settings;
const loc = document.getElementById('set-locale');
const tz = document.getElementById('set-tz');
if(!loc || !tz) return;
const curL = s.default_locale || '', curZ = s.default_timezone || '';
loc.innerHTML = L10N_LOCALES.map(p =>
'<option value="'+uesc(p[0])+'"'+(p[0]===curL?' selected':'')+'>'+uesc(p[1])+'</option>').join('');
if(curL && !L10N_LOCALES.some(p=>p[0]===curL)) loc.add(new Option(curL, curL, true, true));
let browserZone = '';
try { browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch(e){}
tz.innerHTML = '<option value=""'+(curZ?'':' selected')+'>Browser default'+
(browserZone?' ('+uesc(browserZone)+')':'')+'</option>'+
L10N_ZONES.map(z => '<option value="'+uesc(z)+'"'+(z===curZ?' selected':'')+'>'+uesc(z)+'</option>').join('')+
(curZ && L10N_ZONES.indexOf(curZ)<0 ? '<option value="'+uesc(curZ)+'" selected>'+uesc(curZ)+'</option>' : '');
const preview = () => {
const el = document.getElementById('l10n-preview'); if(!el) return;
let out;
try {
out = new Intl.DateTimeFormat(loc.value||undefined, {year:'numeric',month:'short',day:'numeric',
hour:'2-digit',minute:'2-digit',timeZone:tz.value||undefined}).format(new Date());
} catch(e){ out = 'not supported by this browser'; }
el.textContent = 'Preview — right now reads: ' + out;
};
loc.onchange = preview; tz.onchange = preview; preview();
// Offer the server's full zone list once it arrives (it validates against the
// same list, so anything offered here will be accepted).
api('GET','/api/timezones').then(({status,json}) => {
if(status!==200 || !Array.isArray(json) || !json.length) return;
const rest = json.filter(z => L10N_ZONES.indexOf(z) < 0);
if(!rest.length) return;
const g = document.createElement('optgroup'); g.label = 'All time zones';
rest.forEach(z => g.appendChild(new Option(z, z, false, z === curZ)));
tz.appendChild(g);
if(curZ) tz.value = curZ;
});
}
async function saveLocalization(){
const msg = document.getElementById('l10n-msg');
const patch = {
default_locale: document.getElementById('set-locale').value,
default_timezone: document.getElementById('set-tz').value,
};
msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)';
const { status, json } = await api('PUT','/api/settings', patch);
if(status===200){
_settings = json; renderSettings();
const m = document.getElementById('l10n-msg');
if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; }
} else {
msg.textContent = '❌ '+((json && json.detail) || ('HTTP '+status));
msg.style.color = 'var(--red)';
}
}
async function saveFeatures(){
const el = document.getElementById('set-bim');
const msg = document.getElementById('features-msg');
if(msg){ msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)'; }
const { status, json } = await api('PUT','/api/settings', { bim_enabled: !!(el && el.checked) });
if(status===200){
_settings = json; renderFeatures();
const m = document.getElementById('features-msg');
if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; }
} else if(msg){
msg.textContent = 'Save failed (HTTP '+status+').'; msg.style.color = 'var(--red)';
}
}
function renderSettings(){
renderFeatures();
const s = _settings, box = document.getElementById('settings-box');
const on = !!s.email_enabled;
const pwOk = !!s.smtp_password_set;
box.innerHTML =
'<label style="display:inline-flex;align-items:center;gap:8px;font-size:14px;font-weight:700;margin-bottom:12px">'+
'<input type="checkbox" id="set-enabled"'+(on?' checked':'')+'> Email notifications are <span style="color:'+(on?'var(--green)':'var(--muted)')+'">'+(on?'ON':'OFF')+'</span></label>'+
'<div class="urow" style="margin-bottom:8px">'+
'<input id="set-host" placeholder="SMTP host (e.g. smtp.company.local)" value="'+uesc(s.smtp_host||'')+'">'+
'<input id="set-port" style="flex:0 0 90px;min-width:70px" placeholder="Port" value="'+uesc(s.smtp_port||587)+'">'+
'<label style="display:inline-flex;align-items:center;gap:6px;font-size:13px;white-space:nowrap"><input type="checkbox" id="set-tls"'+(s.smtp_use_tls?' checked':'')+'> STARTTLS</label>'+
'</div>'+
'<div class="urow" style="margin-bottom:8px">'+
'<input id="set-from" placeholder="From address (e.g. wp-suite@company.com)" value="'+uesc(s.from_addr||'')+'">'+
'<input id="set-fromname" placeholder="From name" value="'+uesc(s.from_name||'')+'">'+
'<input id="set-user" placeholder="SMTP username (optional)" value="'+uesc(s.smtp_username||'')+'">'+
'</div>'+
'<div class="urow" style="margin-bottom:8px">'+
'<input id="set-baseurl" placeholder="App base URL for email links (e.g. https://wp.controls.dev)" value="'+uesc(s.app_base_url||'')+'">'+
'</div>'+
'<div class="note" style="margin-bottom:10px">SMTP password: '+(pwOk?'<span style="color:var(--green);font-weight:600">set via SMTP_PASSWORD env ✓</span>':'<span style="color:var(--amber);font-weight:600">not set — add SMTP_PASSWORD to the environment before enabling</span>')+'</div>'+
'<div class="row">'+
'<button class="primary" onclick="saveSettings()">Save settings</button>'+
'<button onclick="testEmail()">Send test email to me</button>'+
'<span id="set-msg" class="note" style="margin:0"></span>'+
'</div>';
}
async function saveSettings(){
const v = id => document.getElementById(id);
const patch = {
email_enabled: v('set-enabled').checked,
smtp_host: v('set-host').value.trim(),
smtp_port: parseInt(v('set-port').value, 10) || 587,
smtp_use_tls: v('set-tls').checked,
from_addr: v('set-from').value.trim(),
from_name: v('set-fromname').value.trim(),
smtp_username: v('set-user').value.trim(),
app_base_url: v('set-baseurl').value.trim(),
};
const msg = v('set-msg'); msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)';
const { status, json } = await api('PUT','/api/settings', patch);
if(status===200){ _settings = json; renderSettings(); const m = document.getElementById('set-msg'); if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; } }
else { msg.textContent = 'Save failed (HTTP '+status+').'; msg.style.color = 'var(--red)'; }
}
async function testEmail(){
const msg = document.getElementById('set-msg'); msg.textContent = 'Sending test…'; msg.style.color = 'var(--muted)';
const { status, json } = await api('POST','/api/settings/test-email', {});
if(status===200) { msg.textContent = '✅ Test sent to '+((json&&json.to)||'you')+'.'; msg.style.color = 'var(--green)'; }
else { msg.textContent = '❌ '+((json && json.detail) || ('HTTP '+status)); msg.style.color = 'var(--red)'; }
}
async function loadNotifications(){
const box = document.getElementById('notif-box'); if(!box) return;
const { status, json } = await api('GET','/api/notifications?all=1&limit=50');
if(status!==200 || !Array.isArray(json)){ box.innerHTML = ''; return; }
if(!json.length){ box.innerHTML = '<div class="note">No notifications yet.</div>'; return; }
const fmt = s => s ? wpFormatDateTime(s) : '—';
const stColor = st => st==='sent'?'var(--green)':st==='failed'?'var(--red)':st==='skipped'?'var(--muted)':'var(--amber)';
box.innerHTML = '<div class="sub" style="margin:4px 0 6px;color:var(--muted)">Recent notifications</div>'+
'<table class="users"><thead><tr><th>When</th><th>To</th><th>Kind</th><th>Subject</th><th>Status</th></tr></thead><tbody>'+
json.map(n => '<tr>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(n.created_at)+'</td>'+
'<td>'+uesc(n.email||n.user_id)+'</td>'+
'<td>'+uesc((n.kind||'').replace(/_/g,' '))+'</td>'+
'<td>'+uesc(n.subject||'')+'</td>'+
'<td style="color:'+stColor(n.status)+';font-weight:600">'+uesc(n.status)+(n.error?' <span title="'+uesc(n.error)+'">ⓘ</span>':'')+'</td>'+
'</tr>').join('')+'</tbody></table>';
}
// ── usage logs (read from this browser's localStorage) ──────────────────────────
const USAGE_KEY = 'wp_suite_analytics_v1';
function usageLoad(){ try { return JSON.parse(localStorage.getItem(USAGE_KEY)) || {events:[]}; } catch(e){ return {events:[]}; } }
@@ -350,7 +720,7 @@ function loadUsage(){
if(e.event==='step_view' && e.detail) byStep[e.detail.step] = (byStep[e.detail.step]||0)+1;
if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts;
});
const fmt = s => s ? new Date(s).toLocaleString() : '—';
const fmt = s => s ? wpFormatDateTime(s) : '—';
let html = '<table class="kv">'+
'<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+
'<tr><th>Events</th><td>'+evs.length+'</td></tr>'+

View File

@@ -14,6 +14,12 @@
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
// Register the PWA service worker (caches the app shell for offline use). Only
// from the top window; the API and writes are never cached (see sw.js).
if (!inIframe && 'serviceWorker' in navigator) {
try { navigator.serviceWorker.register('/sw.js'); } catch (e) {}
}
// Hide the page until we know the user is allowed, to avoid a flash of the app
// before a redirect. A safety timer reveals it even if the check hangs.
var root = document.documentElement;
@@ -34,6 +40,19 @@
}
window.wpLogout = function () {
try {
// Clear the auth cache AND all cached project data (customer IP) from this
// device on sign-out — important on shared/field tablets. The outbox
// (wp_sync_outbox_v1) is left intact so unsynced writes aren't lost.
// (localStorage is not a security boundary; field devices still need
// full-disk encryption / MDM — see DEPLOYMENT.md.)
localStorage.removeItem('wp_auth_cache');
Object.keys(localStorage).forEach(function (k) {
if (/^wp_(iwp_v1|suite_sop|suite_state|projects|active_project)/.test(k)) {
localStorage.removeItem(k);
}
});
} catch (e) {}
fetch('/api/auth/logout', { method: 'POST' })
.catch(function () {})
.then(function () { window.location.replace('login.html'); });
@@ -56,7 +75,7 @@
'<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' +
'<label style="' + lbl + '">Current password</label>' +
'<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' +
'<label style="' + lbl + '">New password (at least 8 characters)</label>' +
'<label style="' + lbl + '">New password (at least 12 characters)</label>' +
'<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' +
'<label style="' + lbl + '">Confirm new password</label>' +
'<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' +
@@ -81,7 +100,7 @@
var n1 = document.getElementById('wp-pw-new').value;
var n2 = document.getElementById('wp-pw-new2').value;
if (!cur || !n1) { msg('Please fill in every field.', false); return; }
if (n1.length < 8) { msg('New password must be at least 8 characters.', false); return; }
if (n1.length < 12) { msg('New password must be at least 12 characters.', false); return; }
if (n1 !== n2) { msg('New passwords do not match.', false); return; }
fetch('/api/auth/password', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -96,58 +115,146 @@
};
};
// ── permissions helpers ────────────────────────────────────────────────────
// The server enforces all of this; these are for hiding controls the signed-in
// user can't use, so nobody clicks a button just to get a 403.
// 'user' is the legacy value for what is now 'project_user'.
window.wpRole = function () {
var r = (window.WP_USER && window.WP_USER.role) || '';
return r === 'user' ? 'project_user' : r;
};
window.wpIsAdmin = function () { return window.wpRole() === 'admin'; };
window.wpIsProjectAdmin = function () {
var r = window.wpRole();
return r === 'admin' || r === 'project_admin';
};
// Deleting a work package, deleting a project, and editing a completed SOP are
// all Project Admin actions (see server require_project_admin).
window.wpCanDeleteWP = window.wpIsProjectAdmin;
window.wpCanEditCompletedSOP = window.wpIsProjectAdmin;
// ── app feature flags ──────────────────────────────────────────────────────
// Cached per page load. Pages that must know before rendering should await
// wpFlags(); anything already rendered can re-check on the 'wp-flags-ready' event.
window.WP_FLAGS = null;
var _flagsPromise = null;
window.wpFlags = function () {
if (window.WP_FLAGS) return Promise.resolve(window.WP_FLAGS);
if (_flagsPromise) return _flagsPromise;
_flagsPromise = fetch('/api/app-flags', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : {}; })
.catch(function () { return {}; }) // offline: fall through to defaults
.then(function (f) {
window.WP_FLAGS = f || {};
try { document.dispatchEvent(new CustomEvent('wp-flags-ready', { detail: window.WP_FLAGS })); } catch (e) {}
return window.WP_FLAGS;
});
return _flagsPromise;
};
// BIM/VDC is off unless an admin has switched it on, so an unreachable API or a
// stale cache errs toward hiding the unfinished tooling rather than showing it.
window.wpBimEnabled = function () { return !!(window.WP_FLAGS && window.WP_FLAGS.bim_enabled); };
function isDarkBg(el) {
try {
var m = (getComputedStyle(el).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
if (!m) return true;
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
} catch (e) { return true; }
}
// The user menu (name · Admin · Password · Sign out). Text colors adapt to the
// bar it sits in (light links on a dark bar, blue links on a light bar).
function buildUserMenu(user, dark) {
var wrap = document.createElement('div');
wrap.id = 'wp-usermenu';
var linkColor = dark ? '#ffffff' : '#0f62fe';
wrap.style.cssText = 'display:flex;align-items:center;gap:8px;margin-left:auto;padding-left:14px;white-space:nowrap;' +
'font:400 13px/1.2 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' +
'color:' + (dark ? '#c6c6c6' : '#525252') + ';';
function sep() { var s = document.createElement('span'); s.textContent = '·'; s.style.color = dark ? '#6f6f6f' : '#a8a8a8'; return s; }
function link(text, onClick, href) {
var a = document.createElement('a'); a.textContent = text; a.href = href || '#';
a.style.cssText = 'color:' + linkColor + ';text-decoration:none;font-weight:600;';
if (onClick) a.addEventListener('click', function (e) { e.preventDefault(); onClick(); });
return a;
}
var who = document.createElement('span');
who.textContent = user.full_name || user.username;
who.style.color = dark ? '#ffffff' : '#161616';
wrap.appendChild(who);
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
if (window.wpIsAdmin() && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); }
// Always offered; wp-format.js may still be parsing when the menu is built, so
// the check happens at click time rather than once, up front.
wrap.appendChild(sep());
wrap.appendChild(link('Language & time', function () {
if (typeof window.wpPreferences === 'function') window.wpPreferences();
}));
wrap.appendChild(sep()); wrap.appendChild(link('Password', function () { window.wpChangePassword(); }));
wrap.appendChild(sep()); wrap.appendChild(link('Sign out', function () { window.wpLogout(); }));
return wrap;
}
function addLogoutPill(user) {
if (inIframe) return; // the parent page already shows it
if (document.getElementById('wp-logout-pill')) return;
if (document.getElementById('wp-usermenu') || document.getElementById('wp-logout-pill')) return;
// Preferred: drop the menu INTO the top bar so it never floats over the
// header's own links (Help, etc.). Works with the dark UI-shell appbar and
// the older .header bars alike.
var host = document.querySelector('.wp-appbar') || document.querySelector('.header');
if (host) {
var menu = buildUserMenu(user, isDarkBg(host));
// The older .header bars already right-align their own toolbar (via flex:1
// or a button's margin-left:auto). A second auto-margin would split the free
// space, so only the .wp-appbar (which may have no spacer, e.g. admin) keeps it.
if (!host.classList.contains('wp-appbar')) menu.style.marginLeft = '0';
host.appendChild(menu);
return;
}
// Fallback for any page with no header bar: a floating pill (as before).
var pill = document.createElement('div');
pill.id = 'wp-logout-pill';
pill.style.cssText = 'position:fixed;top:12px;right:12px;z-index:10001;' +
'display:flex;align-items:center;gap:8px;background:#fff;border:1px solid #e0e0e0;' +
'box-shadow:0 1px 4px rgba(0,0,0,.16);border-radius:16px;padding:5px 12px;' +
'font:500 12px/1.2 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#525252;';
function sep() { var s = document.createElement('span'); s.textContent = '·'; s.style.color = '#a8a8a8'; return s; }
var who = document.createElement('span');
who.textContent = user.full_name || user.username;
pill.appendChild(who);
// Admins get a link to the Admin Console (hidden when already on it).
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
if (user.role === 'admin' && !onAdmin) {
var adm = document.createElement('a');
adm.href = 'admin.html'; adm.textContent = 'Admin';
adm.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
pill.appendChild(sep()); pill.appendChild(adm);
}
var pw = document.createElement('a');
pw.href = '#'; pw.textContent = 'Password';
pw.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
pw.addEventListener('click', function (e) { e.preventDefault(); window.wpChangePassword(); });
pill.appendChild(sep()); pill.appendChild(pw);
var out = document.createElement('a');
out.href = '#'; out.textContent = 'Sign out';
out.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
out.addEventListener('click', function (e) { e.preventDefault(); window.wpLogout(); });
pill.appendChild(sep()); pill.appendChild(out);
'display:flex;align-items:center;background:#fff;border:1px solid #e0e0e0;' +
'box-shadow:0 1px 4px rgba(0,0,0,.16);border-radius:16px;padding:5px 12px;';
pill.appendChild(buildUserMenu(user, false));
document.body.appendChild(pill);
}
function proceed(user) {
clearTimeout(safety);
window.WP_USER = user;
reveal();
if (window.WP_USER) {
window.wpFlags(); // start the feature-flag fetch; pages await it as needed
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
if (document.body) addLogoutPill(window.WP_USER);
else document.addEventListener('DOMContentLoaded', function () { addLogoutPill(window.WP_USER); });
}
}
fetch('/api/auth/me', { headers: { 'Accept': 'application/json' } })
.then(function (r) {
if (r.status === 401 || r.status === 403) { goToLogin(); return; }
if (r.status === 401 || r.status === 403) { try { localStorage.removeItem('wp_auth_cache'); } catch (e) {} goToLogin(); return; }
if (!r.ok) { reveal(); clearTimeout(safety); return; } // unexpected; show page rather than trap
return r.json().then(function (data) {
clearTimeout(safety);
window.WP_USER = data && data.user;
reveal();
if (window.WP_USER) {
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
if (document.body) addLogoutPill(window.WP_USER);
else document.addEventListener('DOMContentLoaded', function () { addLogoutPill(window.WP_USER); });
}
var user = data && data.user;
// Remember the last good auth so the PWA can open offline. The server is
// still the real gate; offline writes queue in the outbox until reconnect.
try { if (user) localStorage.setItem('wp_auth_cache', JSON.stringify({ user: user, at: Date.now() })); } catch (e) {}
proceed(user);
});
})
.catch(function () { goToLogin(); }); // API unreachable → send to login
.catch(function () {
// Offline / API unreachable: fall back to a recent cached auth if present,
// so the app (and the field view) still open without a network.
try {
var c = JSON.parse(localStorage.getItem('wp_auth_cache') || 'null');
if (c && c.user && (Date.now() - (c.at || 0)) < 12 * 3600 * 1000) { proceed(c.user); return; }
} catch (e) {}
goToLogin();
});
})();

91
html/field.html Normal file
View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Field View — Work Package Suite</title>
<script src="auth-guard.js"></script>
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
timestamps during their own boot. -->
<script src="wp-format.js"></script>
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-chrome.css">
<style>
* { box-sizing: border-box; }
body { -webkit-text-size-adjust: 100%; }
.field-wrap { max-width: 760px; margin: 0 auto; padding: 16px 16px 40px; }
.fld-ctx { font-size: 13px; color: var(--cds-text-secondary); margin-bottom: 12px; }
.fld-ctx b { color: var(--cds-text-primary); }
.fld-search { width: 100%; padding: 14px; font-size: 16px; border: 1px solid var(--cds-border-strong); background: #fff; margin-bottom: 14px; }
.fld-search:focus { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
.wp-card { display: block; width: 100%; text-align: left; background: var(--cds-layer); border: 1px solid var(--cds-border-subtle); border-left: 4px solid var(--cds-border-strong); padding: 14px 16px; margin-bottom: 10px; cursor: pointer; font-family: inherit; }
.wp-card:active { background: var(--cds-layer-hover); }
.wp-card.ready { border-left-color: var(--cds-support-success); }
.wp-card.hold { border-left-color: var(--cds-support-error); }
.wp-card .num { font-weight: 600; font-size: 16px; color: var(--cds-text-primary); }
.wp-card .subj { color: var(--cds-text-secondary); font-size: 13px; margin-top: 2px; }
.wp-card .meta { margin-top: 10px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.pill { display: inline-block; font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 14px; }
.pill.st { background: var(--cds-layer-accent); color: var(--cds-text-secondary); }
.pill.ok { background: #defbe6; color: #0e6027; }
.pill.warn { background: #fdf6dd; color: #8a6d00; }
.pill.bad { background: #fff1f1; color: #da1e28; }
.fld-empty { padding: 32px; text-align: center; color: var(--cds-text-helper); border: 1px dashed var(--cds-border-strong); background: #fff; }
.fld-empty a { color: var(--cds-link-primary); }
.fld-back { background: none; border: none; color: var(--cds-link-primary); font-size: 15px; padding: 8px 0; cursor: pointer; font-family: inherit; }
.fld-h1 { font-size: 20px; font-weight: 600; margin: 4px 0 2px; }
.fld-sub { color: var(--cds-text-secondary); font-size: 14px; margin-bottom: 16px; }
.fld-sec { background: var(--cds-layer); border: 1px solid var(--cds-border-subtle); padding: 14px 16px; margin-bottom: 14px; }
.fld-sec h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--cds-text-helper); margin-bottom: 10px; }
.st-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; }
.st-btn { padding: 14px 10px; font-size: 15px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: #fff; color: var(--cds-text-secondary); cursor: pointer; font-family: inherit; }
.st-btn.on { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); color: #fff; }
.st-btn.hold.on { background: var(--cds-support-error); border-color: var(--cds-support-error); }
.cx-row { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--cds-border-subtle); }
.cx-row:last-child { border-bottom: none; }
.cx-name { flex: 1; font-size: 15px; }
.cx-state { min-width: 96px; padding: 10px 12px; font-size: 14px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: #fff; cursor: pointer; text-align: center; font-family: inherit; }
.cx-state.cleared { background: #defbe6; color: #0e6027; border-color: #a7f0ba; }
.cx-state.na { background: var(--cds-layer-accent); color: var(--cds-text-secondary); }
.cx-state.open { background: #fff1f1; color: #da1e28; border-color: #ffd7d9; }
.fld-note { width: 100%; padding: 12px; font-size: 16px; border: 1px solid var(--cds-border-strong); min-height: 84px; font-family: inherit; resize: vertical; }
.fld-photo-row { display: flex; gap: 10px; align-items: center; margin-top: 10px; flex-wrap: wrap; }
.fld-btn { padding: 12px 18px; font-size: 15px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: #fff; cursor: pointer; font-family: inherit; }
.fld-btn.primary { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); color: #fff; }
.log-item { border: 1px solid var(--cds-border-subtle); padding: 10px 12px; margin-bottom: 8px; font-size: 14px; color: var(--cds-text-primary); white-space: pre-wrap; }
.log-item .lm { color: var(--cds-text-helper); font-size: 11px; margin-bottom: 4px; }
.log-item img { max-width: 160px; max-height: 120px; margin-top: 6px; display: block; border: 1px solid var(--cds-border-subtle); }
.fld-toast { position: fixed; bottom: 76px; left: 50%; transform: translateX(-50%); background: #161616; color: #fff; padding: 12px 20px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 50; }
.fld-toast.show { opacity: 1; }
</style>
</head>
<body>
<header class="wp-appbar">
<a href="index.html" class="wp-appbar-brand" title="Home">
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
<span class="wp-appbar-title">Field View</span>
</a>
<div class="wp-appbar-spacer"></div>
<a class="wp-appbar-link" href="index.html">Home</a>
</header>
<div class="field-wrap">
<div class="fld-ctx" id="fld-ctx"></div>
<section id="screen-list">
<input class="fld-search" id="fld-search" type="search" placeholder="Search work packages…" oninput="renderList()" aria-label="Search work packages">
<div id="wp-list"></div>
</section>
<section id="screen-detail" style="display:none"></section>
</div>
<div id="toast" class="fld-toast"></div>
<script src="project-data.js"></script>
<script src="help.js"></script>
<script src="field.js"></script>
<script src="wp-chrome.js"></script>
</body>
</html>

178
html/field.js Normal file
View File

@@ -0,0 +1,178 @@
/* Field view — a touch-optimized screen for updating a Work Package's status,
constraints, and a photo/note log from the work face. Reads the same shared
data as the desktop creator (via project-data.js) and saves through the sync
outbox, so it works offline and syncs when the network returns. */
'use strict';
var PID = '', PROJECT = null, WPS = [], curId = null, pendingPhoto = '', draftNote = '';
var STATUSES = ['Draft', 'Scheduled', 'Issued', 'In Progress', 'QC', 'Closed', 'Issue'];
var GATED = ['Issued', 'In Progress', 'QC', 'Closed']; // need all constraints cleared to enter
function esc(s) { return s == null ? '' : String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
function nsKey(id) { return 'wp_iwp_v1__' + id; }
function stLabel(s) { return s === 'Issue' ? 'Issue (Hold)' : s; }
function openCount(p) { return ((p && p.constraints) || []).filter(function (c) { return c.status === 'open'; }).length; }
// Predecessor packages that aren't Closed yet. A package waiting on upstream work
// is not release-ready either, so the field list must not call it Ready — the
// server would refuse to issue it (see enforce_release_gates).
function waitingCount(p, all) {
var preds = (p && p.predecessors) || [];
if (!preds.length) return 0;
var byId = {};
(all || []).forEach(function (x) { byId[x.id] = x; });
return preds.filter(function (id) { var q = byId[id]; return q && q.status !== 'Closed'; }).length;
}
function fmtTs(s) { try { return wpFormatDateTime(s); } catch (e) { return s || ''; } }
function me() { try { return (window.WP_USER && (window.WP_USER.full_name || window.WP_USER.username)) || ''; } catch (e) { return ''; } }
function toast(m) { var t = document.getElementById('toast'); if (!t) return; t.textContent = m; t.classList.add('show'); clearTimeout(toast._t); toast._t = setTimeout(function () { t.classList.remove('show'); }, 2000); }
// ── boot / data ──────────────────────────────────────────────────────────────
function boot() {
var params = new URLSearchParams(location.search);
PID = params.get('project') || (ProjectData.getActiveId && ProjectData.getActiveId()) || '';
if (!PID) { showNoProject(); return; }
if (ProjectData.getActiveId && ProjectData.getActiveId() !== PID) { try { ProjectData.setActive({ id: PID }); } catch (e) {} }
if (ProjectData.get) { ProjectData.get(PID).then(function (p) { PROJECT = p; renderCtx(); }).catch(function () {}); }
loadWPs();
}
function renderCtx() {
var el = document.getElementById('fld-ctx'); if (!el) return;
if (PROJECT) el.innerHTML = 'Project: <b>' + esc(PROJECT.name || '') + '</b>' + (PROJECT.number ? ' · ' + esc(PROJECT.number) : '');
else el.textContent = 'Project: ' + PID;
}
function readCache() { try { return JSON.parse(localStorage.getItem(nsKey(PID)) || '[]') || []; } catch (e) { return []; } }
function writeCache() { try { localStorage.setItem(nsKey(PID), JSON.stringify(WPS)); } catch (e) {} }
function activePkgs(list) { return list.filter(function (p) { return !p.split && !p.archived; }); } // real work, not masters/archived
function loadWPs() {
WPS = activePkgs(readCache()); // offline-first: show cached packages immediately
renderList();
if (ProjectData.pullProject) {
ProjectData.pullProject(PID).then(function () {
WPS = activePkgs(readCache());
if (!curId) renderList(); else renderDetail();
}).catch(function () {});
}
}
function showNoProject() {
var s = document.getElementById('screen-list');
if (s) s.innerHTML = '<div class="fld-empty">No project selected.<br><a href="index.html">Pick a project on the home page</a>, then reopen the field view.</div>';
}
// ── list ───────────────────────────────────────────────────────────────────
function renderList() {
var box = document.getElementById('wp-list'); if (!box) return;
var q = ((document.getElementById('fld-search') || {}).value || '').toLowerCase();
var rows = WPS.filter(function (p) { return !q || ((p.number || '') + ' ' + (p.subject || '') + ' ' + (p.type || '')).toLowerCase().indexOf(q) >= 0; });
if (!rows.length) { box.innerHTML = '<div class="fld-empty">' + (WPS.length ? 'No packages match your search.' : 'No work packages for this project yet.') + '</div>'; return; }
box.innerHTML = rows.map(function (p) {
var open = openCount(p);
var waiting = waitingCount(p, WPS); // the full set, not the filtered rows
var cls = p.status === 'Issue' ? 'hold' : ((open === 0 && !waiting) ? 'ready' : '');
var readyPill = p.status === 'Issue' ? '<span class="pill bad">On hold</span>'
: (open ? '<span class="pill warn">' + open + ' open</span>'
: (waiting ? '<span class="pill warn">waits on ' + waiting + '</span>'
: '<span class="pill ok">Ready</span>'));
return '<button class="wp-card ' + cls + '" onclick="openWP(\'' + esc(p.id) + '\')">' +
'<div class="num">' + esc(p.number || '(no number)') + '</div>' +
'<div class="subj">' + esc(p.subject || '') + '</div>' +
'<div class="meta"><span class="pill st">' + esc(stLabel(p.status)) + '</span>' + readyPill +
(p.type ? '<span class="pill st">' + esc(p.type) + '</span>' : '') + '</div></button>';
}).join('');
}
// ── detail ─────────────────────────────────────────────────────────────────
function curWP() { return WPS.find(function (p) { return p.id === curId; }); }
function openWP(id) { curId = id; pendingPhoto = ''; draftNote = ''; renderDetail(); window.scrollTo(0, 0); }
function backToList() {
curId = null; pendingPhoto = ''; draftNote = '';
document.getElementById('screen-detail').style.display = 'none';
document.getElementById('screen-list').style.display = '';
renderList();
}
function renderDetail() {
var p = curWP(); if (!p) { backToList(); return; }
document.getElementById('screen-list').style.display = 'none';
var d = document.getElementById('screen-detail'); d.style.display = '';
var stBtns = STATUSES.map(function (s) {
return '<button class="st-btn' + (s === 'Issue' ? ' hold' : '') + (p.status === s ? ' on' : '') + '" onclick="setStatus(\'' + s + '\')">' + esc(stLabel(s)) + '</button>';
}).join('');
var cx = (p.constraints) || [];
var cxRows = cx.length ? cx.map(function (c, i) {
var st = c.status || 'open';
return '<div class="cx-row"><div class="cx-name">' + esc(c.name) + '</div>' +
'<button class="cx-state ' + st + '" onclick="cycleConstraint(' + i + ')">' + (st === 'cleared' ? 'Cleared' : st === 'na' ? 'N/A' : 'Open') + '</button></div>';
}).join('') : '<div style="color:var(--cds-text-helper);font-size:14px">No constraints on this package.</div>';
var log = ((p.fieldLog) || []).slice().reverse().map(function (e) {
return '<div class="log-item"><div class="lm">' + esc(e.by || '—') + ' · ' + esc(fmtTs(e.ts)) + (e.status ? ' · ' + esc(stLabel(e.status)) : '') + '</div>' +
(e.note ? esc(e.note) : '') + (e.photo && /^data:image\//.test(e.photo) ? '<img src="' + esc(e.photo) + '" alt="site photo">' : '') + '</div>';
}).join('') || '<div style="color:var(--cds-text-helper);font-size:14px">No field updates yet.</div>';
d.innerHTML =
'<button class="fld-back" onclick="backToList()"> All packages</button>' +
'<div class="fld-h1">' + esc(p.number || '(no number)') + '</div>' +
'<div class="fld-sub">' + esc(p.subject || '') + (p.type ? ' · ' + esc(p.type) : '') + '</div>' +
'<div class="fld-sec"><h3>Status</h3><div class="st-grid">' + stBtns + '</div></div>' +
'<div class="fld-sec"><h3>Constraints — ' + openCount(p) + ' open</h3>' + cxRows + '</div>' +
'<div class="fld-sec"><h3>Add field update</h3>' +
'<textarea class="fld-note" id="fld-note" placeholder="What happened on site? (progress, blockers, notes)" oninput="draftNote=this.value">' + esc(draftNote) + '</textarea>' +
'<div class="fld-photo-row"><label class="fld-btn">📷 Add photo<input type="file" accept="image/*" capture="environment" style="display:none" onchange="onPhoto(event)"></label>' +
'<span id="photo-status" style="font-size:13px;color:var(--cds-text-secondary)">' + (pendingPhoto ? 'Photo attached ✓' : '') + '</span></div>' +
'<div style="margin-top:12px"><button class="fld-btn primary" onclick="addUpdate()">Add to log</button></div>' +
'</div>' +
'<div class="fld-sec"><h3>Field log</h3>' + log + '</div>';
}
// ── mutations (each auto-saves via the outbox; the global sync badge shows state) ──
function saveWP(p) {
var ix = WPS.findIndex(function (x) { return x.id === p.id; });
if (ix >= 0) WPS[ix] = p;
writeCache();
if (typeof ProjectData !== 'undefined' && ProjectData.pushWP) ProjectData.pushWP(p, PID);
}
function setStatus(s) {
var p = curWP(); if (!p) return;
if (GATED.indexOf(s) >= 0 && openCount(p) > 0) { toast('Clear all constraints before moving to ' + stLabel(s)); return; }
if (p.status === s) return;
p.status = s;
if (s === 'Issued' && !p.issuedAt) p.issuedAt = new Date().toISOString();
saveWP(p); renderDetail(); toast('Status: ' + stLabel(s));
}
function cycleConstraint(i) {
var p = curWP(); if (!p || !p.constraints || !p.constraints[i]) return;
var order = ['open', 'cleared', 'na'];
var cur = p.constraints[i].status || 'open';
p.constraints[i].status = order[(order.indexOf(cur) + 1) % 3];
saveWP(p); renderDetail();
}
function onPhoto(ev) {
var f = ev.target.files && ev.target.files[0]; if (!f) return;
var st = document.getElementById('photo-status'); if (st) st.textContent = 'Processing…';
var url = URL.createObjectURL(f);
var img = new Image();
img.onload = function () {
var max = 1280, w = img.width, h = img.height, scale = Math.min(1, max / Math.max(w, h));
var cv = document.createElement('canvas');
cv.width = Math.round(w * scale); cv.height = Math.round(h * scale);
cv.getContext('2d').drawImage(img, 0, 0, cv.width, cv.height);
try { pendingPhoto = cv.toDataURL('image/jpeg', 0.7); } catch (e) { pendingPhoto = ''; }
URL.revokeObjectURL(url);
if (st) st.textContent = pendingPhoto ? 'Photo attached ✓' : 'Could not read photo';
};
img.onerror = function () { URL.revokeObjectURL(url); if (st) st.textContent = 'Could not read photo'; };
img.src = url;
}
function addUpdate() {
var p = curWP(); if (!p) return;
var note = (draftNote || '').trim();
if (!note && !pendingPhoto) { toast('Add a note or photo first'); return; }
if (!p.fieldLog) p.fieldLog = [];
p.fieldLog.push({ ts: new Date().toISOString(), by: me(), note: note, photo: pendingPhoto || '', status: p.status });
pendingPhoto = ''; draftNote = '';
saveWP(p); renderDetail(); toast('Update added to log');
}
boot();

View File

@@ -15,64 +15,64 @@
// ── styles ────────────────────────────────────────────────────────────────
var css = `
.help-tip{ display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px;
margin-left:5px; border-radius:50%; background:#5a6675; color:#fff; font-size:10px; font-weight:700;
margin-left:5px; border-radius:50%; background:#525252; color:#fff; font-size:10px; font-weight:700;
font-family:ui-sans-serif,system-ui,sans-serif; cursor:help; vertical-align:middle; position:relative; }
.help-tip::after{ content:attr(data-tip); position:absolute; bottom:130%; left:50%; transform:translateX(-50%);
background:#1a2230; color:#fff; padding:7px 10px; border-radius:6px; font-size:12px; font-weight:400;
background:#161616; color:#fff; padding:7px 10px; border-radius:0; font-size:12px; font-weight:400;
line-height:1.4; white-space:normal; width:max-content; max-width:260px; text-align:left; z-index:9999;
opacity:0; pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); }
.help-tip::before{ content:''; position:absolute; bottom:130%; left:50%; transform:translate(-50%,95%);
border:5px solid transparent; border-top-color:#1a2230; opacity:0; transition:opacity .12s; z-index:9999; }
border:5px solid transparent; border-top-color:#161616; opacity:0; transition:opacity .12s; z-index:9999; }
.help-tip:hover::after, .help-tip:hover::before, .help-tip:focus::after, .help-tip:focus::before{ opacity:1; }
.ui-help-overlay{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:none; align-items:center;
justify-content:center; z-index:10000; padding:4vh 16px; }
.ui-help-overlay.open{ display:flex; }
.ui-help-modal{ background:#fff; color:#1a2230; max-width:980px; width:100%; height:88vh; max-height:880px;
border-radius:10px; box-shadow:0 12px 40px rgba(20,30,50,.3); display:flex; flex-direction:column; overflow:hidden;
.ui-help-modal{ background:#fff; color:#161616; max-width:980px; width:100%; height:88vh; max-height:880px;
border-radius:0; box-shadow:0 12px 40px rgba(20,30,50,.3); display:flex; flex-direction:column; overflow:hidden;
font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; }
.ui-help-head{ display:flex; align-items:center; gap:14px; padding:13px 18px; border-bottom:1px solid #e3e6ec; flex:none; }
.ui-help-head{ display:flex; align-items:center; gap:14px; padding:13px 18px; border-bottom:1px solid #e0e0e0; flex:none; }
.ui-help-head .ui-help-title{ font-size:15px; font-weight:700; white-space:nowrap; }
.ui-help-search{ flex:1; position:relative; max-width:420px; }
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid #d0d5de; border-radius:7px;
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid #8d8d8d; border-radius:0;
font-size:13px; outline:none; background:#f7f8fa; }
.ui-help-search input:focus{ border-color:#2563d6; background:#fff; box-shadow:0 0 0 2px rgba(37,99,214,.15); }
.ui-help-head .ui-help-x{ margin-left:auto; background:none; border:none; font-size:20px; cursor:pointer; color:#5a6675; line-height:1; }
.ui-help-search input:focus{ border-color:#0f62fe; background:#fff; box-shadow:0 0 0 2px rgba(37,99,214,.15); }
.ui-help-head .ui-help-x{ margin-left:auto; background:none; border:none; font-size:20px; cursor:pointer; color:#525252; line-height:1; }
.ui-help-wrap{ display:flex; flex:1; min-height:0; }
.ui-help-nav{ width:230px; flex:none; border-right:1px solid #e3e6ec; overflow:auto; padding:10px 8px; background:#fafbfc; }
.ui-help-nav a{ display:block; padding:7px 10px; border-radius:6px; color:#27313f; text-decoration:none; font-size:13px;
.ui-help-nav{ width:230px; flex:none; border-right:1px solid #e0e0e0; overflow:auto; padding:10px 8px; background:#fafbfc; }
.ui-help-nav a{ display:block; padding:7px 10px; border-radius:0; color:#27313f; text-decoration:none; font-size:13px;
cursor:pointer; margin-bottom:1px; }
.ui-help-nav a:hover{ background:#eef1f6; }
.ui-help-nav a.active{ background:#e7effe; color:#1d4ed8; font-weight:600; }
.ui-help-nav a.active{ background:#edf5ff; color:#0353e9; font-weight:600; }
.ui-help-nav a.nohit{ display:none; }
.ui-help-content{ flex:1; overflow:auto; padding:22px 28px; scroll-behavior:smooth; }
.ui-help-sec{ margin-bottom:30px; }
.ui-help-sec.hide{ display:none; }
.ui-help-sec h3{ font-size:18px; margin:0 0 10px; color:#16213a; scroll-margin-top:10px; }
.ui-help-sec h4{ margin:18px 0 6px; font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:#2563d6; }
.ui-help-sec h3{ font-size:18px; margin:0 0 10px; color:#161616; scroll-margin-top:10px; }
.ui-help-sec h4{ margin:18px 0 6px; font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:#0f62fe; }
.ui-help-content p{ font-size:13.5px; line-height:1.62; margin:0 0 9px; color:#27313f; }
.ui-help-content ol, .ui-help-content ul{ margin:0 0 10px; padding-left:20px; font-size:13.5px; line-height:1.6; }
.ui-help-content li{ margin-bottom:5px; }
.ui-help-content code{ background:#eef1f6; padding:1px 5px; border-radius:4px; font-size:12px; }
.ui-help-content table{ border-collapse:collapse; width:100%; font-size:12.5px; margin:6px 0 12px; }
.ui-help-content th, .ui-help-content td{ border:1px solid #e3e6ec; padding:6px 9px; text-align:left; vertical-align:top; }
.ui-help-content th, .ui-help-content td{ border:1px solid #e0e0e0; padding:6px 9px; text-align:left; vertical-align:top; }
.ui-help-content th{ background:#f4f6f9; font-weight:600; }
.ui-help-pill{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600; }
.pill-draft{ background:#eef1f6; color:#5a6675; } .pill-sched{ background:#e7effe; color:#1d4ed8; }
.pill-draft{ background:#eef1f6; color:#525252; } .pill-sched{ background:#edf5ff; color:#0353e9; }
.pill-prog{ background:#fef3e0; color:#b45309; } .pill-issued{ background:#e4f6ec; color:#15924f; }
.pill-qc{ background:#f3e8ff; color:#7c3aed; } .pill-closed{ background:#e2e8f0; color:#334155; }
.pill-hold{ background:#fde8e8; color:#c0392b; }
.ui-help-callout{ background:#f4f8ff; border-left:3px solid #2563d6; padding:10px 14px; border-radius:0 6px 6px 0;
.ui-help-callout{ background:#f4f8ff; border-left:3px solid #0f62fe; padding:10px 14px; border-radius:0;
font-size:13px; line-height:1.55; margin:10px 0; }
.ui-help-noresult{ display:none; color:#5a6675; font-size:14px; padding:10px 2px; }
.ui-help-noresult{ display:none; color:#525252; font-size:14px; padding:10px 2px; }
.ui-help-content mark{ background:#fff1a8; color:inherit; border-radius:2px; padding:0 1px; }
.ui-help-fab{ position:fixed; bottom:12px; left:12px; z-index:9998; width:38px; height:38px; border-radius:50%;
border:none; background:#2563d6; color:#fff; font-size:18px; font-weight:700; cursor:pointer;
border:none; background:#0f62fe; color:#fff; font-size:18px; font-weight:700; cursor:pointer;
box-shadow:0 2px 10px rgba(20,30,50,.28); }
.ui-help-fab:hover{ background:#1d4ed8; }
.ui-help-fab:hover{ background:#0353e9; }
@media (max-width:760px){
.ui-help-modal{ height:92vh; } .ui-help-wrap{ flex-direction:column; }
.ui-help-nav{ width:auto; display:flex; flex-wrap:wrap; gap:4px; border-right:none; border-bottom:1px solid #e3e6ec; }
.ui-help-nav{ width:auto; display:flex; flex-wrap:wrap; gap:4px; border-right:none; border-bottom:1px solid #e0e0e0; }
.ui-help-nav a{ margin:0; font-size:12px; padding:5px 9px; }
.ui-help-head{ flex-wrap:wrap; }
}`;
@@ -94,6 +94,13 @@
</ol>
<h4>Moving around</h4>
<p>From the home page, open <strong>SOP Configuration</strong>, the <strong>Work Package Creator</strong>, or the <strong>Dashboard</strong>. Inside the suite, switch any time using the top tabs: <strong>⚙️ SOP Configuration</strong>, <strong>📋 Work Package Creation</strong>, and <strong>📊 Dashboard</strong>. The active project and SOP follow you across all of them.</p>
<h4>Quick start</h4>
<ol>
<li><strong>Open “SOP Configuration”</strong> and complete the 10 steps for your project (~15 minutes).</li>
<li><strong>Finish the SOP</strong> — its home-page card turns green and unlocks the Work Package Creator.</li>
<li><strong>Open “Work Package Creation”</strong> to author packages with your SOP defaults pre-populated.</li>
<li><strong>Update from the field</strong> using the <strong>Field View</strong>, and <strong>leave feedback</strong> on any page with the Feedback button.</li>
</ol>
<div class="ui-help-callout">New here? On the home page choose the <strong>Sample Project</strong>, then click <strong>⭐ Load Sample</strong> in the suite to see a fully filled-out SOP and an example Work Package.</div>` },
{ id: 'projects', title: 'Projects', body: `

BIN
html/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
html/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -5,115 +5,63 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Work Package Suite — Prime Controls</title>
<script src="auth-guard.js"></script>
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
timestamps during their own boot. -->
<script src="wp-format.js"></script>
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-chrome.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--cds-background);
color: var(--cds-text-primary);
line-height: 1.5;
}
/* HEADER */
.header {
background: var(--cds-layer);
padding: 1.5rem 2rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
border-bottom: 1px solid var(--cds-border-subtle);
}
.header-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 1.5rem;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 700;
font-size: 16px;
text-decoration: none;
color: var(--cds-text-primary);
background: white;
padding: 0.5rem 0.75rem;
border-radius: 6px;
}
.logo img {
height: 32px;
width: auto;
}
.logo:hover { opacity: 0.9; }
.header-spacer { flex: 1; }
.header-nav {
display: flex;
gap: 1.5rem;
align-items: center;
}
.header-nav a {
color: var(--cds-text-secondary);
text-decoration: none;
font-size: 13px;
transition: color 0.2s;
}
.header-nav a:hover { color: var(--cds-text-primary); }
/* CONTAINER */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 3rem 2rem;
padding: 2.5rem 2rem 3rem;
}
/* HERO */
.hero {
text-align: center;
margin-bottom: 4rem;
margin-bottom: 2.5rem;
}
.hero h1 {
font-size: 2.625rem;
font-size: 2.25rem;
font-weight: 300;
margin-bottom: 1rem;
letter-spacing: -0.01em;
margin-bottom: 0.5rem;
color: var(--cds-text-primary);
}
.hero p {
font-size: 1.125rem;
font-size: 1rem;
color: var(--cds-text-secondary);
margin-bottom: 2rem;
max-width: 700px;
margin-left: auto;
margin-right: auto;
max-width: 760px;
}
/* CARDS */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.card {
background: var(--cds-layer);
border: 1px solid var(--cds-border-subtle);
border-radius: 4px;
border-left: 4px solid var(--cds-border-strong);
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
transition: all 0.2s;
transition: border-color 0.15s, background 0.15s;
text-decoration: none;
color: var(--cds-text-primary);
display: flex;
@@ -121,9 +69,8 @@
}
.card:hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.4);
transform: translateY(-2px);
border-color: var(--cds-button-primary);
border-left-color: var(--cds-interactive-01);
background: var(--cds-layer-hover);
}
.card-badge {
@@ -153,10 +100,10 @@
.card-button {
display: inline-block;
align-self: flex-start;
background: var(--cds-button-primary);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 3px;
padding: 0.7rem 1.25rem;
text-decoration: none;
font-weight: 600;
text-align: center;
@@ -172,16 +119,15 @@
/* COMPLETE STATE (SOP done) */
.card.complete {
background: #ecfdf5;
border-color: #16a34a;
border-left-color: var(--cds-support-success);
}
.card.complete .card-button { background: #16a34a; }
.card.complete .card-button:hover { background: #15803d; }
.card.complete .card-button { background: var(--cds-support-success); }
.card.complete .card-button:hover { background: #0e6027; }
.card-status {
display: inline-block;
font-size: 12px;
font-weight: 600;
color: #16a34a;
color: var(--cds-support-success);
margin-bottom: 0.5rem;
}
.card.disabled {
@@ -192,9 +138,8 @@
/* SECTION */
.section {
background: var(--cds-layer);
border-radius: 4px;
padding: 2rem;
margin-bottom: 2rem;
padding: 1.75rem;
margin-bottom: 1.5rem;
border: 1px solid var(--cds-border-subtle);
}
@@ -217,16 +162,6 @@
font-size: 0.95rem;
}
.quick-start {
background: var(--cds-button-primary);
color: white;
padding: 2rem;
}
.quick-start h2 { color: white; }
.quick-start ol { margin-left: 1.5rem; line-height: 2; }
.quick-start li { margin-bottom: 0.5rem; }
/* FOOTER */
.footer {
background: var(--cds-ui-01);
@@ -247,18 +182,16 @@
/* COMMENTS SECTION */
.comments-section {
background: var(--cds-layer);
border-radius: 4px;
padding: 1.5rem;
margin-bottom: 2rem;
margin-bottom: 1.5rem;
border: 1px solid var(--cds-border-subtle);
}
.comments-toggle {
padding: 0.75rem 1.5rem;
padding: 0.7rem 1.25rem;
background: var(--cds-button-primary);
color: white;
border: none;
border-radius: 3px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
@@ -271,8 +204,7 @@
display: none;
margin-top: 1rem;
padding: 1rem;
background: var(--cds-ui-01);
border-radius: 3px;
background: var(--cds-layer-accent);
border: 1px solid var(--cds-border-subtle);
}
@@ -281,15 +213,17 @@
.comments-panel input,
.comments-panel textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--cds-border-subtle);
border-radius: 3px;
background: var(--cds-ui-02);
padding: 0.7rem;
border: 1px solid var(--cds-border-strong);
background: var(--cds-field);
color: var(--cds-text-primary);
font-family: inherit;
margin-bottom: 1rem;
}
.comments-panel input:focus,
.comments-panel textarea:focus { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
.comments-panel textarea {
resize: vertical;
min-height: 80px;
@@ -298,12 +232,12 @@
.comment-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.comment-buttons button {
padding: 0.5rem 1rem;
border: none;
border-radius: 3px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
@@ -318,11 +252,12 @@
.submit-btn:hover { background: var(--cds-hover-primary); }
.close-btn {
background: var(--cds-border-subtle);
background: var(--cds-layer-selected);
color: var(--cds-text-primary);
border: 1px solid var(--cds-border-strong);
}
.close-btn:hover { background: var(--cds-hover-ui); }
.close-btn:hover { background: var(--cds-layer-selected-hover); }
.comments-list {
margin-top: 1rem;
@@ -334,7 +269,6 @@
padding: 0.75rem;
background: var(--cds-background);
border: 1px solid var(--cds-border-subtle);
border-radius: 3px;
margin-bottom: 0.5rem;
font-size: 12px;
}
@@ -353,22 +287,20 @@
.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; }
border: 1px solid var(--cds-border-strong, #8d8d8d); background: #fff; }
.proj-empty { background: var(--cds-ui-01, #fff); border: 1px dashed var(--cds-border-strong, #8d8d8d);
border-radius: 6px; padding: 1.25rem; }
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 { margin-top: 1rem; padding: 1rem; border: 1px solid var(--cds-ui-03, #e0e0e0); 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-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong, #8d8d8d); }
.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; }
.header-spacer { display: none; }
.hero h1 { font-size: 1.75rem; }
.cards-grid { grid-template-columns: 1fr; }
.container { padding: 1.5rem; }
@@ -377,19 +309,17 @@
</head>
<body>
<!-- HEADER -->
<header class="header">
<div class="header-content">
<a href="index.html" class="logo">
<img src="prime-controls-logo.jpg" alt="Prime Controls">
<div>Work Package Suite</div>
</a>
<div class="header-spacer"></div>
<nav class="header-nav">
<a href="#overview">Overview</a>
<a href="#comments">Feedback</a>
<a href="#" onclick="openHelp();return false;">Help</a>
</nav>
</div>
<header class="wp-appbar">
<a href="index.html" class="wp-appbar-brand" title="Work Package Suite home">
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
<span class="wp-appbar-title">Work Package Suite</span>
</a>
<div class="wp-appbar-spacer"></div>
<nav class="wp-appbar-actions">
<a class="wp-appbar-link" href="#overview">Overview</a>
<a class="wp-appbar-link" href="#comments">Feedback</a>
<a class="wp-appbar-link" href="#" onclick="openHelp();return false;">Help</a>
</nav>
</header>
<!-- MAIN CONTENT -->
@@ -432,17 +362,13 @@
<button class="card-button" id="card-dash-btn">Open Dashboard</button>
</a>
</div>
<!-- FIELD VIEW -->
<a href="field.html" class="card" id="card-field">
<h3>Field View</h3>
<p>A phone-friendly view for the work face — update status, clear constraints, and log photos and notes. Installable to a home screen; works offline and syncs when you're back on network.</p>
<button class="card-button" id="card-field-btn">Open Field View</button>
</a>
<!-- QUICK START -->
<div class="section quick-start">
<h2>Getting Started</h2>
<ol>
<li><strong>Open "SOP Configuration"</strong> and complete the 10 steps for your project (~15 minutes)</li>
<li><strong>Finish the SOP</strong> — this card turns green and unlocks the Work Package Creator</li>
<li><strong>Open "Work Package Creator"</strong> to author Work Packages with your SOP defaults pre-populated</li>
<li><strong>Leave feedback</strong> on any page using the feedback button below</li>
</ol>
</div>
<!-- COMMENTS SECTION -->
@@ -588,6 +514,7 @@
setHref('card-sop', 'work-package-suite.html?tab=sop');
setHref('card-wp', 'work-package-suite.html?tab=wp');
setHref('card-dash', 'work-package-suite.html?view=dashboard');
setHref('card-field', 'field.html?src=home');
cards.style.display = '';
heroTitle.textContent = active.name || 'Work Package Suite';
@@ -595,8 +522,8 @@
if(info) info.innerHTML = `<div class="proj-active">✓ Active project: <strong>${esc(active.name||'')}</strong>${active.number?' ('+esc(active.number)+')':''}
&nbsp;<button class="link-like" onclick="clearActiveProject()">change</button></div>`;
// Pull the project's shared SOP from the server into the local cache first,
// so the SOP "Complete / Review" status reflects what other users have done.
// Pull the project's shared SOP/WPs from the server into the local cache
// first, so the SOP "Complete / Review" status reflects what other users did.
if(ProjectData.pullProject){ ProjectData.pullProject(active.id).then(()=>reflectSOPStatus(active)).catch(()=>reflectSOPStatus(active)); }
else reflectSOPStatus(active);
}
@@ -738,5 +665,6 @@
.catch(() => {});
}
</script>
<script src="wp-chrome.js"></script>
</body>
</html>

View File

@@ -21,7 +21,7 @@
max-width: 400px;
background: var(--cds-layer);
border: 1px solid var(--cds-border-subtle);
box-shadow: 0 2px 6px var(--cds-shadow);
border-top: 3px solid var(--cds-interactive-01);
padding: 2.5rem 2rem;
}
.brand {
@@ -70,6 +70,25 @@
}
.error.show { display: block; }
.foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; }
.ok {
display: none;
background: #defbe6;
border-left: 3px solid var(--cds-support-success);
color: #0e6027;
padding: 0.75rem;
font-size: 0.8125rem;
margin-bottom: 1.25rem;
}
.ok.show { display: block; }
.note {
font-size: 0.8125rem; color: var(--cds-text-secondary);
background: var(--cds-layer-accent); border-left: 3px solid var(--cds-link-primary);
padding: 0.75rem; margin-bottom: 1.25rem;
}
.hint { font-size: 0.75rem; color: var(--cds-text-helper); margin-top: -0.75rem; margin-bottom: 1.25rem; }
a.link { color: var(--cds-link-primary); text-decoration: none; font-size: 0.8125rem; }
a.link:hover { text-decoration: underline; }
.center { text-align: center; margin-top: 1.25rem; }
</style>
</head>
<body>
@@ -77,29 +96,64 @@
<div class="brand">
<img src="prime-controls-logo.jpg" alt="Prime Controls" onerror="this.style.display='none'">
</div>
<h1>Sign in</h1>
<p class="sub">Work Package Suite</p>
<div id="error" class="error" role="alert"></div>
<div id="ok" class="ok" role="status"></div>
<form id="login-form" autocomplete="on">
<div class="field">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username" autofocus required>
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
</div>
<button id="submit" type="submit">Sign in</button>
</form>
<!-- SIGN IN -->
<section id="view-login">
<h1>Sign in</h1>
<p class="sub">Work Package Suite</p>
<form id="login-form" autocomplete="on">
<div class="field">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username" autofocus required>
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
</div>
<button id="submit" type="submit">Sign in</button>
</form>
<p class="center"><a href="#" id="forgot-link" class="link">Forgot password?</a></p>
</section>
<p style="margin-top:1.25rem; text-align:center; font-size:0.8125rem;">
<a href="#" id="forgot-link" style="color:var(--cds-link-primary); text-decoration:none;">Forgot password?</a>
</p>
<div id="forgot-msg" style="display:none; margin-top:0.5rem; font-size:0.8125rem; color:var(--cds-text-secondary); background:var(--cds-layer-accent); border-left:3px solid var(--cds-link-primary); padding:0.75rem; border-radius:0 6px 6px 0;">
Password resets are handled by an administrator. Contact your project admin and they'll set a new one for you. Once you're signed in, you can change it yourself anytime from the menu in the top-right corner.
</div>
<!-- FORGOT PASSWORD (email reset) -->
<section id="view-forgot" style="display:none">
<h1>Reset password</h1>
<p class="sub">We'll email you a link to set a new one.</p>
<div id="forgot-unavailable" class="note" style="display:none">
Password reset by email isn't switched on yet. Contact your project admin and
they'll set a new password for you. Once you're signed in you can change it
yourself from the menu in the top-right corner.
</div>
<form id="forgot-form" autocomplete="on">
<div class="field">
<label for="forgot-username">Username or email</label>
<input id="forgot-username" type="text" autocomplete="username" required>
</div>
<button id="forgot-submit" type="submit">Email me a reset link</button>
</form>
<p class="center"><a href="#" id="back-to-login" class="link">← Back to sign in</a></p>
</section>
<!-- SET A NEW PASSWORD (arrived from the emailed link) -->
<section id="view-reset" style="display:none">
<h1>Set a new password</h1>
<p class="sub">Choose a password you don't use anywhere else.</p>
<form id="reset-form" autocomplete="on">
<div class="field">
<label for="new-password">New password</label>
<input id="new-password" type="password" autocomplete="new-password" autofocus required>
</div>
<div class="hint">At least 12 characters.</div>
<div class="field">
<label for="new-password2">Confirm new password</label>
<input id="new-password2" type="password" autocomplete="new-password" required>
</div>
<button id="reset-submit" type="submit">Set password &amp; sign in</button>
</form>
<p class="center"><a href="#" id="reset-to-login" class="link">← Back to sign in</a></p>
</section>
<p class="foot">Authorized use only · BTG / Pilot</p>
</main>

View File

@@ -1,13 +1,42 @@
/* Login page logic for the Work Package Suite.
Posts credentials to /api/auth/login. On success the server sets an HttpOnly
session cookie (not readable here — that's the point) and we redirect to the
page the user was trying to reach, or the home page. */
Three views on one page:
• sign in posts to /api/auth/login. On success the server sets an
HttpOnly session cookie (not readable here — that's the
point) and we redirect to ?next= or the home page.
• forgot password posts to /api/auth/forgot-password, which emails a
single-use link. Only offered when the server reports
email is actually configured (/api/auth/reset-available);
otherwise we say to ask an admin.
• set a new password shown when the page is opened as login.html?reset=<token>
from that email. Posts to /api/auth/reset-password.
The reset token stays in the URL only until it's used; on success we strip it
from the address bar so it isn't left in history or copied out of the bar. */
(function () {
'use strict';
var form = document.getElementById('login-form');
var errorBox = document.getElementById('error');
var submitBtn = document.getElementById('submit');
var okBox = document.getElementById('ok');
function show(el) { if (el) el.style.display = ''; }
function hide(el) { if (el) el.style.display = 'none'; }
function byId(id) { return document.getElementById(id); }
function showError(msg) {
okBox.classList.remove('show');
errorBox.textContent = msg;
errorBox.classList.add('show');
}
function showOk(msg) {
errorBox.classList.remove('show');
okBox.textContent = msg;
okBox.classList.add('show');
}
function clearBanners() {
errorBox.classList.remove('show');
okBox.classList.remove('show');
}
// Where to go after signing in: the ?next= param if it's a safe same-site
// path, otherwise the home page. (Reject absolute/scheme URLs to avoid an
@@ -20,44 +49,58 @@
return 'index.html';
}
function showError(msg) {
errorBox.textContent = msg;
errorBox.classList.add('show');
function resetToken() {
try { return new URLSearchParams(location.search).get('reset') || ''; } catch (e) { return ''; }
}
var forgot = document.getElementById('forgot-link');
if (forgot) {
forgot.addEventListener('click', function (e) {
e.preventDefault();
var m = document.getElementById('forgot-msg');
if (m) m.style.display = 'block';
function postJson(url, payload) {
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(function (r) {
return r.json().catch(function () { return null; }).then(function (j) {
return { status: r.status, ok: r.ok, json: j };
});
});
}
function detail(res, fallback) {
var d = res && res.json && res.json.detail;
return (typeof d === 'string' && d) ? d : fallback;
}
function view(which) {
clearBanners();
['login', 'forgot', 'reset'].forEach(function (v) {
(which === v ? show : hide)(byId('view-' + v));
});
}
// ── sign in ────────────────────────────────────────────────────────────────
var form = byId('login-form');
var submitBtn = byId('submit');
// Guarded because a cached older login.html may not have the reset views; an
// unguarded addEventListener on null would break sign-in itself.
if (!form || !submitBtn) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
errorBox.classList.remove('show');
var username = document.getElementById('username').value.trim();
var password = document.getElementById('password').value;
clearBanners();
var username = byId('username').value.trim();
var password = byId('password').value;
if (!username || !password) { showError('Enter your username and password.'); return; }
submitBtn.disabled = true;
submitBtn.textContent = 'Signing in…';
fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username, password: password })
})
.then(function (r) {
if (r.ok) { location.replace(nextTarget()); return null; }
return r.json().catch(function () { return null; }).then(function (j) {
if (r.status === 401) showError('Invalid username or password.');
else if (r.status === 403) showError((j && j.detail) || 'Your account is disabled.');
else showError((j && j.detail) || ('Sign-in failed (HTTP ' + r.status + ').'));
submitBtn.disabled = false;
submitBtn.textContent = 'Sign in';
});
postJson('/api/auth/login', { username: username, password: password })
.then(function (res) {
if (res.ok) { location.replace(nextTarget()); return; }
if (res.status === 401) showError('Invalid username or password.');
else if (res.status === 403) showError(detail(res, 'Your account is disabled.'));
else if (res.status === 429) showError(detail(res, 'Too many failed attempts. Try again later.'));
else showError(detail(res, 'Sign-in failed (HTTP ' + res.status + ').'));
submitBtn.disabled = false;
submitBtn.textContent = 'Sign in';
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
@@ -65,4 +108,108 @@
submitBtn.textContent = 'Sign in';
});
});
// ── forgot password ────────────────────────────────────────────────────────
var resetAvailable = null; // null = not checked yet
function checkResetAvailable() {
if (resetAvailable !== null) return Promise.resolve(resetAvailable);
return fetch('/api/auth/reset-available')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { resetAvailable = !!(j && j.enabled); return resetAvailable; })
.catch(function () { resetAvailable = false; return false; });
}
(byId('forgot-link') || {addEventListener: function(){}}).addEventListener('click', function (e) {
e.preventDefault();
view('forgot');
// Prefill from the sign-in box so nobody types their username twice.
var u = byId('username').value.trim();
if (u) byId('forgot-username').value = u;
checkResetAvailable().then(function (enabled) {
// With email off there's nothing to submit — say so and hide the form.
(enabled ? hide : show)(byId('forgot-unavailable'));
(enabled ? show : hide)(byId('forgot-form'));
if (enabled) byId('forgot-username').focus();
});
});
(byId('back-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
e.preventDefault();
view('login');
});
var forgotForm = byId('forgot-form') || document.createElement('form');
var forgotBtn = byId('forgot-submit') || document.createElement('button');
forgotForm.addEventListener('submit', function (e) {
e.preventDefault();
clearBanners();
var who = byId('forgot-username').value.trim();
if (!who) { showError('Enter your username or email.'); return; }
forgotBtn.disabled = true;
forgotBtn.textContent = 'Sending…';
postJson('/api/auth/forgot-password', { username: who })
.then(function (res) {
if (res.status === 503) {
showError(detail(res, "Password reset by email isn't available. Ask an administrator."));
} else if (res.ok) {
// Deliberately the same message whether or not the account exists.
showOk('If that account exists, a reset link is on its way. The link expires in an hour.');
hide(forgotForm);
} else {
showError(detail(res, 'Could not send the reset email (HTTP ' + res.status + ').'));
}
forgotBtn.disabled = false;
forgotBtn.textContent = 'Email me a reset link';
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
forgotBtn.disabled = false;
forgotBtn.textContent = 'Email me a reset link';
});
});
// ── set a new password (from the emailed link) ──────────────────────────────
(byId('reset-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
e.preventDefault();
view('login');
});
var resetForm = byId('reset-form') || document.createElement('form');
var resetBtn = byId('reset-submit') || document.createElement('button');
resetForm.addEventListener('submit', function (e) {
e.preventDefault();
clearBanners();
var token = resetToken();
var pw = byId('new-password').value;
var pw2 = byId('new-password2').value;
if (!token) { showError('This reset link is incomplete. Request a new one.'); return; }
if (pw !== pw2) { showError('The two passwords do not match.'); return; }
if (pw.length < 12) { showError('Password must be at least 12 characters.'); return; }
resetBtn.disabled = true;
resetBtn.textContent = 'Saving…';
postJson('/api/auth/reset-password', { token: token, new_password: pw })
.then(function (res) {
if (res.ok) {
// Take the token out of the URL before anything else — it's spent.
try { history.replaceState(null, '', 'login.html'); } catch (err) {}
view('login');
showOk('Password updated. Sign in with your new password.');
byId('username').focus();
return;
}
showError(detail(res, 'Could not set your password (HTTP ' + res.status + ').'));
resetBtn.disabled = false;
resetBtn.textContent = 'Set password & sign in';
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
resetBtn.disabled = false;
resetBtn.textContent = 'Set password & sign in';
});
});
// Arriving from the reset email opens straight into the new-password view.
if (resetToken()) view('reset');
})();

18
html/manifest.webmanifest Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "Prime Work Package Suite",
"short_name": "WP Suite",
"description": "Prime Controls Work Package Suite — SOPs, work packages, and field updates.",
"start_url": "/index.html",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#f4f4f4",
"theme_color": "#161616",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
],
"shortcuts": [
{ "name": "Field View", "short_name": "Field", "url": "/field.html", "description": "Update work packages from the field" }
]
}

View File

@@ -12,7 +12,7 @@
var LS_ACTIVE_OBJ = 'wp_active_project_obj';
function uid() { return 'proj_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
function readLocal() { try { return JSON.parse(localStorage.getItem(LS_PROJECTS) || '[]') || []; } catch (e) { return []; } }
function writeLocal(list) { try { localStorage.setItem(LS_PROJECTS, JSON.stringify(list)); } catch (e) {} }
@@ -59,10 +59,18 @@
.catch(function () { cacheUpsert(p); return p; }); // offline / no API → local only
},
// Deleting a project cascades its SOPs and work packages, and the server
// allows it only for a Project Admin. Drop it from the local cache ONLY if
// the server actually deleted it (or it was already gone) — removing it on a
// 403 would hide a project that still exists for everyone else.
remove: function (id) {
return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' })
.then(function () { cacheRemove(id); })
.catch(function () { cacheRemove(id); });
.then(function (r) {
if (r.ok || r.status === 404) { cacheRemove(id); return true; }
return r.json().catch(function () { return null; }).then(function (j) {
throw new Error((j && j.detail) || ('Could not delete the project (HTTP ' + r.status + ').'));
});
});
},
// ── active project context ────────────────────────────────────────────────
@@ -87,6 +95,8 @@
// localStorage keys as a per-browser CACHE: pullProject() hydrates those exact
// keys from the API on page load, and the push* helpers write through to the
// API whenever the apps save. The apps' own (synchronous) reads are unchanged.
// (Original author: C-West8, "storing data in DB instead of client only";
// reintegrated on top of the BIM/per-package work.)
function nsKey(base, id) { return id ? base + '__' + id : base; }
function currentUser() {
try { return (window.WP_USER && (window.WP_USER.username || window.WP_USER.full_name)) || ''; } catch (e) { return ''; }
@@ -94,7 +104,8 @@
// A saved Work Package is a flat object in the browser; the API splits it into
// promoted columns + a `data` blob. We store the whole flat object in `data`
// for perfect round-tripping, and mirror the few fields the API promotes.
// for perfect round-tripping (so BIM fields, kind, projectLinks, etc. all
// survive), and mirror the few fields the API promotes to columns.
function pkgToServer(p, projectId) {
return {
id: p.id,
@@ -104,6 +115,7 @@
subject: p.subject || '',
type: p.type || '',
status: p.status || 'Draft',
assignee_id: p.assigneeId || null,
created_by: p.createdBy || currentUser(),
data: p
};
@@ -117,6 +129,8 @@
if (row.type != null) p.type = row.type;
if (row.status) p.status = row.status; // honor server-side status changes
if (row.parent_id) p.instanceOf = row.parent_id;
p.archived = !!row.archived_at;
p.assigneeId = row.assignee_id || '';
return p;
}
@@ -149,37 +163,200 @@
return Promise.all(jobs).then(function () {});
};
// Write a completed SOP (plus the builder's raw state) to the API. Uses a
// deterministic id per project so re-completing updates the same row.
// ── Durable write-through outbox ───────────────────────────────────────────
// SOP/WP saves must survive a flaky network, a reload, or a crash — otherwise a
// silently-failed POST leaves the browser and server divergent. Instead of a
// fire-and-forget request, each mutation is appended to a localStorage-backed
// queue and flushed to the API with retry + backoff. The API upserts by id and
// DELETE is idempotent, so re-sending a queued op is always safe. The app's own
// local cache still updates immediately, so rendering never waits on the network.
var OUTBOX_KEY = 'wp_sync_outbox_v1';
var _flushTimer = null, _backoff = 0, _flushing = false;
function qRead() { try { return JSON.parse(localStorage.getItem(OUTBOX_KEY) || '[]') || []; } catch (e) { return []; } }
function qWrite(list) { try { localStorage.setItem(OUTBOX_KEY, JSON.stringify(list)); } catch (e) {} }
// Append an op, coalescing by (kind,key) so only the latest write per entity is
// queued. A delete supersedes any pending upsert for the same id.
function enqueue(op) {
var q = qRead();
if (op.kind === 'wp-del') {
q = q.filter(function (o) { return !(o.key === op.key && (o.kind === 'wp' || o.kind === 'wp-del')); });
} else {
q = q.filter(function (o) { return !(o.kind === op.kind && o.key === op.key); });
}
op.opId = op.kind + ':' + op.key + ':' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
op.tries = 0;
q.push(op);
qWrite(q);
notifySync();
scheduleFlush(0);
}
function opRequest(op) {
if (op.kind === 'wp-del') {
return fetch(API + '/wps/' + encodeURIComponent(op.key), { method: 'DELETE' });
}
return fetch(API + (op.kind === 'sop' ? '/sops' : '/wps'), {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(op.body)
});
}
function bumpTries(opId, err) {
var q = qRead();
for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].tries = (q[i].tries || 0) + 1; q[i].lastErr = err; break; } }
qWrite(q);
}
// Permanently-failed op (a 4xx client error) — keep it for visibility but stop
// retrying, so a rejected write can't loop forever.
function markDead(opId, err) {
var q = qRead();
for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].dead = true; q[i].lastErr = err; break; } }
qWrite(q);
}
// Attempt every live op; successes are removed, 4xx client errors are marked
// dead (won't succeed on retry), transient failures (429/5xx/network) stay queued.
function flush() {
if (_flushing) return Promise.resolve();
var q = qRead().filter(function (o) { return !o.dead; });
if (!q.length) { notifySync(); return Promise.resolve(); }
_flushing = true; notifySync();
var chain = Promise.resolve(), anyFail = false;
q.forEach(function (op) {
chain = chain.then(function () {
return opRequest(op).then(function (r) {
var status = r ? r.status : 0;
var done = r && (r.ok || (op.kind === 'wp-del' && status === 404)); // 404 on delete = already gone
if (done) { qWrite(qRead().filter(function (o) { return o.opId !== op.opId; })); }
else if (status >= 400 && status < 500 && status !== 429) { markDead(op.opId, 'HTTP ' + status); }
else { anyFail = true; bumpTries(op.opId, 'HTTP ' + status); }
}).catch(function (e) { anyFail = true; bumpTries(op.opId, String(e)); });
});
});
return chain.then(function () {
_flushing = false;
notifySync();
if (qRead().filter(function (o) { return !o.dead; }).length) {
_backoff = anyFail ? Math.min((_backoff || 5000) * 2, 60000) : 0;
scheduleFlush(_backoff || 15000);
} else { _backoff = 0; }
});
}
function scheduleFlush(delay) {
if (_flushTimer) return; // one pending flush at a time
_flushTimer = setTimeout(function () { _flushTimer = null; flush(); }, delay || 0);
}
// ── sync status (drives the indicator + any listeners) ──────────────────────
function syncCounts() {
var q = qRead(), pending = 0, failed = 0;
for (var i = 0; i < q.length; i++) {
if (q[i].dead || (q[i].tries || 0) >= 3) failed++; else pending++;
}
return { pending: pending, failed: failed, syncing: _flushing };
}
ProjectData.syncStatus = syncCounts;
function notifySync() {
var c = syncCounts();
try { document.dispatchEvent(new CustomEvent('wp-sync-changed', { detail: c })); } catch (e) {}
renderSyncBadge(c);
}
// Tiny sync indicator (bottom-left). Rendered only in the top-level window so it
// isn't duplicated inside the embedded creator iframe; the top window still sees
// the iframe's queue changes via the 'storage' event below.
var _isTop = (function () { try { return window.top === window.self; } catch (e) { return true; } })();
var _badgeHideTimer = null;
function renderSyncBadge(c) {
if (!_isTop || !document.body) return;
var el = document.getElementById('wp-sync-badge');
if (!el) {
el = document.createElement('div');
el.id = 'wp-sync-badge';
el.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:9998;pointer-events:none;display:none;align-items:center;gap:7px;' +
'font:500 12px/1.3 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' +
'padding:6px 12px;border:1px solid #e0e0e0;background:#fff;color:#525252;box-shadow:0 1px 4px rgba(0,0,0,.12);transition:opacity .2s;';
document.body.appendChild(el);
}
if (_badgeHideTimer) { clearTimeout(_badgeHideTimer); _badgeHideTimer = null; }
if (c.failed) {
el.textContent = '⚠ ' + c.failed + ' change' + (c.failed === 1 ? '' : 's') + ' not saved — retrying';
el.style.color = '#8a6d00'; el.style.borderColor = '#f1c21b'; el.style.background = '#fdf6dd'; el.style.display = 'inline-flex';
} else if (c.pending) {
el.textContent = '↻ Saving ' + c.pending + ' change' + (c.pending === 1 ? '' : 's') + '…';
el.style.color = '#525252'; el.style.borderColor = '#e0e0e0'; el.style.background = '#fff'; el.style.display = 'inline-flex';
} else {
el.textContent = '✓ All changes saved';
el.style.color = '#0e6027'; el.style.borderColor = '#a7f0ba'; el.style.background = '#defbe6'; el.style.display = 'inline-flex';
_badgeHideTimer = setTimeout(function () { if (el) el.style.display = 'none'; }, 1800);
}
}
// Flush triggers: on reconnect, on cross-frame queue changes, on tab focus, and
// a periodic backstop. Anything left from a previous session flushes on load.
try {
window.addEventListener('online', function () { _backoff = 0; scheduleFlush(0); });
window.addEventListener('storage', function (e) { if (e.key === OUTBOX_KEY) { notifySync(); scheduleFlush(0); } });
document.addEventListener('visibilitychange', function () { if (!document.hidden) scheduleFlush(0); });
setInterval(function () { if (qRead().filter(function (o) { return !o.dead; }).length) scheduleFlush(0); }, 20000);
} catch (e) {}
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { notifySync(); scheduleFlush(0); }); }
else { setTimeout(function () { notifySync(); scheduleFlush(0); }, 0); }
// ── public write API (now durable via the outbox) ───────────────────────────
// Write a completed SOP (plus the builder's raw state). Deterministic id per
// project so re-completing updates the same row.
ProjectData.pushSOP = function (projectId, sop, state) {
if (!projectId) return Promise.resolve(null);
var body = {
id: 'sop__' + projectId,
project_id: projectId,
name: (sop && sop.project && sop.project.name) || 'SOP',
number: (sop && sop.project && sop.project.number) || '',
complete: true,
created_by: currentUser(),
data: { sop: sop, state: state }
};
return fetch(API + '/sops', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
enqueue({
kind: 'sop', key: 'sop__' + projectId,
body: {
id: 'sop__' + projectId, project_id: projectId,
name: (sop && sop.project && sop.project.name) || 'SOP',
number: (sop && sop.project && sop.project.number) || '',
complete: true, created_by: currentUser(), data: { sop: sop, state: state }
}
});
return Promise.resolve(true);
};
// Upsert a single Work Package to the API (fire-and-forget from the caller's
// perspective; the local cache is the source of truth for immediate rendering).
// Upsert a single Work Package. The local cache stays the source of truth for
// immediate rendering; the outbox guarantees the write reaches the server.
ProjectData.pushWP = function (p, projectId) {
if (!p || !p.id) return Promise.resolve(null);
return fetch(API + '/wps', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(pkgToServer(p, projectId))
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
enqueue({ kind: 'wp', key: p.id, body: pkgToServer(p, projectId) });
return Promise.resolve(true);
};
ProjectData.removeWP = function (id) {
if (!id) return Promise.resolve();
return fetch(API + '/wps/' + encodeURIComponent(id), { method: 'DELETE' })
.then(function () {}).catch(function () {});
enqueue({ kind: 'wp-del', key: id });
return Promise.resolve(true);
};
// Force a flush now and resolve when the queue drains (or a round-trip is done).
ProjectData.flushSync = function () { _backoff = 0; return flush(); };
// Archive / unarchive a Work Package (hide from active lists without deleting).
// Direct request (not the outbox) — it's a deliberate, low-frequency action and
// the caller updates the view on the returned result.
ProjectData.archiveWP = function (id, archived) {
if (!id) return Promise.resolve(null);
return fetch(API + '/wps/' + encodeURIComponent(id) + '/archive', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ archived: archived !== false })
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
};
// Fetch this project's ARCHIVED packages (full docs) for the dashboard's
// "show archived" view. Returns app-shaped package objects (p.archived === true).
ProjectData.listArchived = function (projectId) {
if (!projectId) return Promise.resolve([]);
return fetch(API + '/wps?full=true&archived=only&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (rows) { return Array.isArray(rows) ? rows.map(serverToPkg) : []; })
.catch(function () { return []; });
};
// One-time discard of pre-multi-project (un-namespaced) SOP/WP data so stale

107
html/sw.js Normal file
View File

@@ -0,0 +1,107 @@
/* Service worker for the Work Package Suite PWA.
Goal: let the app (and especially the field view) load and run offline. Data
durability is already handled by the sync outbox in project-data.js — this
worker only caches the static app shell so the pages open without a network.
Strategy:
• /api/* and non-GET → never touched (pass straight to the network; offline
reads fall back to the app's localStorage cache, writes queue in the outbox).
• HTML / CSS / JS → network-first, cache as fallback. These reference each
other, so a page must never run against a stale sibling.
• images / icons / manifest → stale-while-revalidate (instant from cache).
*/
'use strict';
// Bumped when the shell file list changes, so clients fetch the new assets
// instead of serving a half-old shell from the previous cache.
const CACHE = 'wp-suite-shell-v5';
const SHELL = [
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
'/field.html', '/login.html', '/admin.html',
'/theme-light.css', '/work-package-suite-styles.css', '/wp-creation-styles.css',
'/wp-chrome.css',
'/auth-guard.js', '/project-data.js', '/feedback-config.js', '/help.js',
'/work-package-suite-app.js', '/wp-creation-app.js', '/field.js',
'/wp-chrome.js', '/wp-format.js', '/login.js', '/admin.js',
'/prime-controls-logo.jpg', '/favicon.ico',
'/manifest.webmanifest', '/icon-192.png', '/icon-512.png',
];
self.addEventListener('install', (e) => {
// Cache each shell asset individually so one missing file doesn't abort install.
e.waitUntil(
caches.open(CACHE)
// cache:'reload' bypasses the browser HTTP cache. Without it the precache
// can be filled from stale HTTP entries, freezing a mismatched shell.
.then((c) => Promise.all(SHELL.map(
(u) => c.add(new Request(u, { cache: 'reload' })).catch(() => {}))))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
// Code (HTML / CSS / JS) is fetched NETWORK-FIRST, falling back to the cache when
// offline. Everything else (images, icons, the manifest) stays cache-first, which is
// where offline speed actually comes from.
//
// Why not cache-first for code: these files reference each other, and the cache
// stores them as independent entries. Cache-first served whichever copy of each file
// happened to be stored, so a browser could run new HTML against old CSS — which is
// exactly how the embedded creator once collapsed to a 300x150 iframe. A page must
// only ever run against the stylesheet and scripts it shipped with.
const CODE_RE = /\.(html|css|js)$|\/$/i;
self.addEventListener('fetch', (e) => {
const req = e.request;
if (req.method !== 'GET') return; // outbox owns writes
const url = new URL(req.url);
if (url.origin !== self.location.origin) return; // third-party: default
if (url.pathname.startsWith('/api/')) return; // never cache the API
const isCode = CODE_RE.test(url.pathname);
// Cache key WITHOUT the query string. Links inside the app carry ?project=…&tab=…,
// and the embedded creator used to carry a cache-busting timestamp, so keying on the
// full URL both missed every offline navigation and grew the cache without bound.
const key = new Request(url.origin + url.pathname, { credentials: 'same-origin' });
const fromCache = () => caches.match(key).then((c) => c || caches.match(req));
const store = (res) => {
if (res && res.ok && res.type !== 'opaque') {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(key, copy)).catch(() => {});
}
return res;
};
if (isCode) {
e.respondWith(
// cache:'no-cache' forces revalidation with the server. Plain fetch() inherits
// the request's default cache mode, which consults the browser HTTP cache — so
// "network-first" alone still let a page run against a stale sibling file.
fetch(req, { cache: 'no-cache' })
.then((res) => {
// A 502/404 must not replace a page the cache could still serve.
if (!res || !res.ok) return fromCache().then((c) => c || res);
return store(res);
})
.catch(() => fromCache()) // offline → last good copy
.then((res) => res || Response.error()) // never resolve to undefined
);
return;
}
e.respondWith(
caches.match(key).then((cached) => {
const network = fetch(req).then(store).catch(() => cached);
return cached || network.then((res) => res || Response.error());
})
);
});

View File

@@ -157,3 +157,93 @@ input, textarea, select {
font-family: inherit;
color: var(--cds-text-primary);
}
/* ============================================================================
App shell — shared "UI Shell" chrome (Prime Controls, IBM Carbon styling)
----------------------------------------------------------------------------
One dark top bar across every page so the suite reads as a single product.
The Prime Controls logo is a white-background wordmark, so it sits inside a
white "chip" on the near-black bar (reads as intentional, not a stray box).
Flip --wp-appbar-bg to a light value if a light header is ever preferred.
============================================================================ */
:root {
--wp-appbar-bg: #161616; /* near-black UI Shell bar */
--wp-appbar-fg: #ffffff;
--wp-appbar-fg-dim: #c6c6c6;
--wp-appbar-border: #6f6f6f; /* outline for ghost buttons on the bar */
--wp-appbar-hover: #353535;
--wp-appbar-height: 48px;
}
.wp-appbar {
background: var(--wp-appbar-bg);
color: var(--wp-appbar-fg);
display: flex;
align-items: center;
gap: 16px;
height: var(--wp-appbar-height);
padding: 0 16px;
position: sticky;
top: 0;
z-index: 100;
}
.wp-appbar-brand {
display: flex;
align-items: center;
gap: 12px;
height: 100%;
text-decoration: none;
color: var(--wp-appbar-fg);
}
.wp-appbar-brand:hover { text-decoration: none; opacity: .92; }
.wp-logo-chip {
display: inline-flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 4px;
padding: 4px 8px;
}
.wp-logo-chip img { height: 24px; width: auto; display: block; }
.wp-appbar-title {
font-size: 15px;
font-weight: 600;
color: var(--wp-appbar-fg);
white-space: nowrap;
letter-spacing: .01em;
}
.wp-appbar-title .wp-appbar-sub { font-weight: 400; color: var(--wp-appbar-fg-dim); }
.wp-appbar-spacer { flex: 1 1 auto; }
.wp-appbar-meta { font-size: 13px; color: var(--wp-appbar-fg-dim); white-space: nowrap; }
.wp-appbar-actions { display: flex; align-items: center; gap: 8px; }
/* Buttons and links that live on the dark bar */
.wp-appbar-btn {
background: transparent;
color: var(--wp-appbar-fg);
border: 1px solid var(--wp-appbar-border);
border-radius: 0;
padding: 7px 14px;
font-size: 14px;
font-family: inherit;
line-height: 1.2;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
transition: background .15s, border-color .15s;
}
.wp-appbar-btn:hover { background: var(--wp-appbar-hover); color: var(--wp-appbar-fg); text-decoration: none; }
.wp-appbar-btn.primary { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); }
.wp-appbar-btn.primary:hover { background: var(--cds-hover-primary); border-color: var(--cds-hover-primary); }
.wp-appbar-btn:focus-visible { outline: 2px solid var(--wp-appbar-fg); outline-offset: 1px; }
.wp-appbar-count { font-size: 13px; color: var(--wp-appbar-fg-dim); padding: 0 2px; white-space: nowrap; }
/* Plain text links on the dark bar (Overview / Feedback / Help, Admin, etc.) */
.wp-appbar-link { color: var(--wp-appbar-fg-dim); text-decoration: none; font-size: 14px; white-space: nowrap; }
.wp-appbar-link:hover { color: var(--wp-appbar-fg); text-decoration: none; }
@media (max-width: 720px) {
.wp-appbar { height: auto; flex-wrap: wrap; gap: 8px; padding: 8px 12px; }
.wp-appbar-actions { flex-wrap: wrap; }
.wp-appbar-meta { width: 100%; order: 5; }
}

View File

@@ -24,14 +24,20 @@ function onSizePresetChange(){
}
let state = {
bimEnabled: false, // does this project also produce BIM/VDC packages? If so the Creator tags each WP Install (IWP) or BIM (EWP); if not, it's IWP-only.
project: {name:'', number:'', client:'', division:'', site:''},
// Leadership names are kept as display strings (so existing SOPs and exports
// still read the same) alongside the user-account id each one resolves to.
// The ids are what let the Creator offer these people as a WP owner and what
// notification routing uses — a typed name can't be emailed.
team: {pm:'', apm:'', cm:'', qm:''},
teamIds: {pm:'', apm:'', cm:'', qm:''},
teamMembers: [],
signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}],
wpTypes: [],
governance: {woformat:'', wosize:'', issuance:[], disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:''},
quality: {qcreq:'', photo:'', hold:''},
platforms: {tracking:'CxAlloy', commissioning:'CxAlloy'},
platforms: {tracking:'CxAlloy', commissioning:'CxAlloy', trackingUrl:'', commissioningUrl:''},
sequence: [],
constraints: [],
sources: []
@@ -92,7 +98,14 @@ const OPTIONAL_ROLES = [
'Quality Representative',
'Planner',
'Safety Manager',
'Project Controls Manager'
'Project Controls Manager',
// BIM / VDC roles (used by the BIM template; also selectable on any SOP)
'BIM Coordinator',
'BIM Modeler / Detailer',
'VDC Manager',
'Construction Lead (CRS)',
'Field Lead',
'General Contractor'
];
const LABOR_COST_CODES = [
@@ -137,6 +150,125 @@ const LABOR_COST_CODES = [
'9000|Administration'
];
// ── BIM / VDC TEMPLATE ──────────────────────────────────────────────────────────
// The BIM/VDC department also produces work packages under Advanced Work Packaging —
// model & engineering deliverables (EWPs) that feed the field's install packages.
// These defaults are drawn from the EN06 SOP + work instructions and are added by
// enableBIM() when the "Include BIM / VDC work packages" box is ticked on Step 4
// (each is flagged bim so the Creator can offer them under the EWP kind). Everything
// remains editable afterward.
const BIM_WP_TYPES = [
'Model Area Package', 'Conduit Routing Package', 'Coordination / Clash Package',
'First-of-Kind (FOK) Package', '2D Installation Sheet Set', 'Field Detailing / Markup Package',
'Laser Scan Package', 'As-Built Model / Drawings'
];
// BIM "disciplines" are the install phases work is broken into (EN06-SOP §4.1.2.5).
const BIM_PHASES = [
'Cable Tray & Hangers', 'Conduits & Hangers', 'Wall & Slab Penetrations',
'Panels & Instrument Racks', 'In-wall Instruments & Stud-ups'
];
const BIM_TEMPLATE_ROLES = [
'BIM Coordinator', 'BIM Modeler / Detailer', 'VDC Manager',
'Construction Lead (CRS)', 'Field Lead', 'General Contractor'
];
// Release-gate constraints for a BIM/model package (the BIM equivalent of the field's
// AWP constraints). Citations point back to the EN06 documents.
const BIM_CONSTRAINTS = [
{name:'Required Docs Received (IO list, P&IDs, drawings, models, specs)', description:'Project-start inputs available — EN06-SOP §3.1'},
{name:'Conduit Schedule & Schematic Redlines Received', description:'Hard gate: no conduit modeled without these — EN06-SOP §3'},
{name:'LOD Defined & Agreed', description:'Level of detail set at kick-off — EN06-G-01'},
{name:'Field Coordination / Laser Scan Complete', description:'Field walk or scan done — EN06-WI-01 / WI-03'},
{name:'Clash-Free / Coordinated with GC & Trades', description:'Coordination complete — EN06-SOP §8.1'},
{name:'Constructability Review (CRS) Signed', description:'Internal construction-lead sign-off before GC — EN06-SOP §8.4'},
{name:'GC / Trade Sign-Off', description:'GC review and approval — EN06-SOP §8.2'},
{name:'Issued-For-Fabrication (IFF) Granted', description:'Model approved for field use — EN06-SOP §9.4'}
];
const BIM_SEQUENCE = [
'Kick-off (LOD, schedule, cost code)', 'Project start — gather required docs',
'Field coordination / laser scan', 'Model racks, instruments & panels',
'Model conduit (after schedule + redlines)', 'BIM coordination / clash with GC & trades',
'Constructability review (CRS)', 'GC submission & sign-off (IFF)',
// BIM deliverable that hands off to the field — only present when BIM is enabled.
'2D installation sheets / Spool Drawings'
];
const BIM_SOURCES = [
{label:'IO List (Point Matrix DB)', ph:'controls.dev / SharePoint'},
{label:'P&IDs', ph:'Procore / SharePoint'},
{label:'Contract / Design Drawings', ph:'Procore / Bluebeam'},
{label:'Navisworks / Revit Models', ph:'BIM360 / SharePoint'},
{label:'Specs & Submittals', ph:'client portal'},
{label:'Conduit Schedule', ph:'Excel on SharePoint'},
{label:'Bluebeam Project', ph:'Bluebeam Studio'},
{label:'Pre-Construction Tracker', ph:'SharePoint'},
{label:'Constructability Review Sheet (CRS)', ph:'SharePoint'}
];
// Toggle BIM/VDC capability on the project. ON augments the SOP with BIM package
// types + release gates (flagged bim) plus BIM roles/sources/sequence steps, so the
// project produces both install (IWP) and BIM (EWP) packages. OFF strips the
// bim-flagged items. Everything stays editable.
// ── BIM/VDC app switch (admin console → Features) ─────────────────────────────
// BIM is off until it's ready for the field. With the flag off we hide the
// per-project toggle so no new project can be put on the BIM path — but we never
// strip a SOP that already has it on, because that would silently delete its BIM
// types, gates and sequence steps. Such a SOP just stops offering BIM until the
// flag comes back.
function applyBimFlag(){
const wrap = document.getElementById('bim-toggle-wrap');
const note = document.getElementById('bim-disabled-note');
const enabled = (typeof wpBimEnabled === 'function') ? wpBimEnabled() : false;
if(wrap) wrap.style.display = enabled ? '' : 'none';
if(note){
const stale = !enabled && !!state.bimEnabled;
note.style.display = enabled ? 'none' : '';
note.innerHTML = stale
? '<strong>BIM / VDC is switched off for the whole suite.</strong> This SOP already has BIM enabled, so its ' +
'BIM package types, gates and sequence steps are kept as they are — but they aren\'t offered while the ' +
'feature is off. An administrator can turn it back on under Features in the Admin Console.'
: '<strong>BIM / VDC packages aren\'t available yet.</strong> Every project is install-only (IWP) for now. ' +
'An administrator can enable the BIM tooling under Features in the Admin Console once it\'s ready.';
}
}
// Re-check once the flags land (they arrive asynchronously after auth).
document.addEventListener('wp-flags-ready', applyBimFlag);
function setBimEnabled(on){
state.bimEnabled = !!on;
if(on) enableBIM(); else disableBIM();
const cb = document.getElementById('bim_enabled'); if(cb) cb.checked = !!on;
track(on ? 'bim_enabled' : 'bim_disabled');
}
function enableBIM(){
// Package types (flagged bim so the Creator can offer them under "BIM (EWP)").
BIM_WP_TYPES.forEach(n => {
const t = state.wpTypes.find(x => x.name === n);
if(t){ t.bim = true; t.enabled = true; }
else state.wpTypes.push({name:n, enabled:true, notes:'', approval:'', bim:true});
});
renderWPTypes();
// Release-gate constraints (seed standard 10 first if empty, then add BIM gates).
if(!state.constraints || !state.constraints.length) state.constraints = STANDARD_10_CONSTRAINTS.map(c => ({...c}));
_constraintsSeeded = true;
BIM_CONSTRAINTS.forEach(c => { if(!state.constraints.some(x => x.name === c.name)) state.constraints.push({...c, bim:true}); });
renderStandardConstraints();
// BIM sign-off roles (optional), reference sources, and process steps (idempotent).
BIM_TEMPLATE_ROLES.forEach(r => { if(!state.signoffRoles.some(x => x.role === r)) state.signoffRoles.push({role:r, name:'', bim:true}); });
renderOptionalRoles();
// BIM work precedes construction, so put the BIM steps at the FRONT of the sequence.
const bimSteps = BIM_SEQUENCE.filter(lbl => !state.sequence.some(s => s.label === lbl)).map(lbl => ({label:lbl, kind:'step', bim:true}));
state.sequence = [...bimSteps, ...state.sequence];
renderSequenceSteps();
BIM_SOURCES.forEach(s => { if(!state.sources.some(x => x.label === s.label)) state.sources.push({label:s.label, system:'', notes:'', link:'', ph:s.ph, preset:true, bim:true}); });
renderSources();
}
function disableBIM(){
state.wpTypes = state.wpTypes.filter(t => !t.bim); renderWPTypes();
state.constraints = (state.constraints || []).filter(c => !c.bim); renderStandardConstraints();
state.signoffRoles = state.signoffRoles.filter((r,i) => i < 2 || !r.bim); renderOptionalRoles();
state.sequence = (state.sequence || []).filter(s => !s.bim); renderSequenceSteps();
state.sources = (state.sources || []).filter(s => !s.bim); renderSources();
}
// ── INITIALIZATION ────────────────────────────────────────────────────────────
window.addEventListener('DOMContentLoaded',()=>{
initializeWPTypes();
@@ -157,10 +289,17 @@ window.addEventListener('DOMContentLoaded',()=>{
restoreSavedSOP();
updateStepUI();
updateProjectDisplay();
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page.
loadProjectUsers(); // team pickers: who's on this project
applyBimFlag(); // hide the BIM section unless an admin enabled it
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard | ?wp=<id>. Consumed ONCE —
// leaving ?view=dashboard in the URL used to make the Work Package Creation tab
// keep opening the dashboard for the rest of the session.
const tab = params.get('tab');
if(params.get('view') === 'dashboard') switchTool('dashboard');
_deepLinkWp = params.get('wp') || '';
const wantDashboard = params.get('view') === 'dashboard';
if(wantDashboard) switchTool('dashboard');
else if(tab === 'wp' || tab === 'sop') switchTool(tab);
else if(_deepLinkWp) switchTool('wp');
}
if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
ProjectData.pullProject(projId).then(afterPull).catch(afterPull);
@@ -208,15 +347,25 @@ function loadSampleData(){
document.getElementById('proj_division').value = 'Semiconductor';
document.getElementById('proj_site').value = 'Boise, ID — Fab 7';
// Populate Step 2
document.getElementById('proj_pm').value = 'Mariano Sanchez';
document.getElementById('proj_apm').value = 'Assistant PM';
document.getElementById('proj_cm').value = 'K. Boyd';
document.getElementById('proj_qm').value = 'D. Nguyen';
// Populate Step 2. The leadership slots are account pickers now, so the sample's
// fictional names can't be "selected" — setting .value on a <select> with no
// matching option silently does nothing. Store them as names without an account,
// which is exactly how the picker shows a person who isn't a suite user yet.
state.team.pm = 'Mariano Sanchez';
state.team.apm = 'Assistant PM';
state.team.cm = 'K. Boyd';
state.team.qm = 'D. Nguyen';
state.teamIds = {pm:'', apm:'', cm:'', qm:''};
renderTeamPickers();
// Step 3 already has defaults
document.getElementById('role_super_name').value = 'John Smith';
document.getElementById('role_foreman_name').value = 'Mike Jones';
// Step 3 — standard required roles
if(state.signoffRoles[0]) state.signoffRoles[0].role = 'Superintendent';
if(state.signoffRoles[1]) state.signoffRoles[1].role = 'Foreman';
const stEl = document.getElementById('role_super_title'); if(stEl) stEl.value = 'Superintendent';
const ftEl = document.getElementById('role_foreman_title'); if(ftEl) ftEl.value = 'Foreman';
state.signoffRoles[0].name = 'John Smith'; state.signoffRoles[0].userId = '';
state.signoffRoles[1].name = 'Mike Jones'; state.signoffRoles[1].userId = '';
renderSignoffRolePickers();
// Populate Step 5
document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]';
@@ -232,6 +381,12 @@ function loadSampleData(){
// Step 7 already has defaults
// The Micron FMCS sample includes BIM/VDC — enable it so the sequence shows the
// full BIM → construction flow (BIM steps first) and the Creator offers IWP/EWP.
state.bimEnabled = true;
const beEl = document.getElementById('bim_enabled'); if(beEl) beEl.checked = true;
enableBIM();
// Collect all data
collectStepData();
track('sample_loaded');
@@ -257,6 +412,9 @@ function restoreSavedSOP(){
state = savedState;
sop = savedSop;
sopComplete = true;
// A SOP saved before the team was account-backed has no teamIds; default them
// so the pickers render (the stored names show as "(no account)" until linked).
if(!state.teamIds) state.teamIds = {pm:'', apm:'', cm:'', qm:''};
// Re-render dynamic lists from restored state.
renderWPTypes();
@@ -278,12 +436,12 @@ function repopulateForm(){
set('proj_client', state.project.client);
set('proj_division', state.project.division);
set('proj_site', state.project.site);
set('proj_pm', state.team.pm);
set('proj_apm', state.team.apm);
set('proj_cm', state.team.cm);
set('proj_qm', state.team.qm);
if(state.signoffRoles[0]) set('role_super_name', state.signoffRoles[0].name);
if(state.signoffRoles[1]) set('role_foreman_name', state.signoffRoles[1].name);
// The leadership slots and sign-off names are account pickers, not text inputs —
// these build their options and mark the current selection.
renderTeamPickers();
renderSignoffRolePickers();
if(state.signoffRoles[0]) set('role_super_title', state.signoffRoles[0].role);
if(state.signoffRoles[1]) set('role_foreman_title', state.signoffRoles[1].role);
set('gov_woformat', state.governance.woformat);
// gov_wosize is now a <select>; if a saved value isn't one of the presets
// (e.g. legacy free text), add it as an option so the round-trip preserves it.
@@ -300,6 +458,9 @@ function repopulateForm(){
set('qual_hold', state.quality.hold);
set('plat_tracking', state.platforms.tracking);
set('plat_commissioning', state.platforms.commissioning);
set('plat_tracking_url', state.platforms.trackingUrl);
set('plat_commissioning_url', state.platforms.commissioningUrl);
const beEl = document.getElementById('bim_enabled'); if(beEl) beEl.checked = !!state.bimEnabled;
}
// ── TOOL SWITCHING ────────────────────────────────────────────────────────────
@@ -323,11 +484,81 @@ function switchTool(tool){
document.getElementById('total-steps').textContent = (tool === 'sop') ? '10' : '—';
if(contentTool === 'wp') renderWPTab(isDash);
// Only go full-bleed when the creator is actually showing. With the SOP
// incomplete this tab shows a short 'complete the SOP first' gate; making the
// page unscrollable around it can clip its button off the bottom.
applyEmbedLayout(contentTool === 'wp' && sopComplete);
updateStepUI();
updateProjectDisplay();
}
// The embedded creator/dashboard fills the window below the app chrome, so there
// is ONE scrollbar (the iframe's) instead of a skinny inner pane inside a scrolling
// page — and the creator's sticky bars have a real viewport to stick to.
// A work package the global search asked us to open, handed to the creator on its
// next load and then cleared.
let _deepLinkWp = '';
function applyEmbedLayout(on){
// Below this, "fill the window" leaves nothing usable: the iframe would be shorter
// than the creator's own sticky bars while the page itself can't scroll. Fall back
// to normal page flow and let the page scroll instead.
const MIN_FILL_H = 460;
const fits = (window.innerHeight - chromeHeight()) >= MIN_FILL_H;
const full = !!on && fits;
const area = document.querySelector('.content-area');
const frame = document.getElementById('wp-frame');
if(area) area.classList.toggle('embed-full', full);
if(frame) frame.classList.toggle('fill', full);
document.body.classList.toggle('embed-full', full);
sizeWPFrame(full);
}
// Size the frame with INLINE styles, not only CSS classes. Inline wins over any
// stylesheet — including a stale cached one — so the creator can't collapse to the
// 300x150 default iframe box if the CSS and the HTML are ever out of step.
function sizeWPFrame(on){
const frame = document.getElementById('wp-frame');
if(!frame) return;
frame.style.width = '100%';
frame.style.border = '0';
if(on){
const h = viewportMinusChrome();
document.documentElement.style.setProperty('--wp-chrome-h', (window.innerHeight - h) + 'px');
frame.style.height = h + 'px';
frame.style.minHeight = '0';
} else {
frame.style.height = '';
frame.style.minHeight = 'calc(100vh - 200px)';
}
}
function chromeHeight(){
const hdr = document.querySelector('.header');
const nav = document.querySelector('.main-nav');
return (hdr ? hdr.offsetHeight : 48) + (nav ? nav.offsetHeight : 48);
}
// Whatever is left of the window below the app bar + tab strip. No floor: a floor
// taller than the remaining space pushes the frame (and the creator's fixed save bar)
// off a window that has scrolling disabled.
function viewportMinusChrome(){
return Math.max(0, window.innerHeight - chromeHeight());
}
// Re-measure on resize, and re-decide whether full-bleed still fits.
window.addEventListener('resize', () => {
if(currentTool === 'wp' || currentTool === 'dashboard') applyEmbedLayout(true);
}, {passive:true});
// The app bar grows when wp-chrome.js injects the project switcher and search, which
// happens after the frame has already been sized. Watch the chrome instead of relying
// on someone resizing the window.
try {
const _ro = new ResizeObserver(() => {
if(document.body.classList.contains('embed-full')) sizeWPFrame(true);
});
['.header', '.main-nav'].forEach(sel => { const el = document.querySelector(sel); if(el) _ro.observe(el); });
} catch(e) { /* no ResizeObserver: the resize handler above still covers it */ }
// Show the gate or the embedded Work Package Creator depending on SOP status.
// wantDash=true opens the creator straight to the dashboard view.
function renderWPTab(wantDash){
@@ -337,12 +568,49 @@ function renderWPTab(wantDash){
if(sopComplete){
gate.style.display = 'none';
frame.style.display = 'block';
// Reload each time so the creator picks up the latest SOP from localStorage.
const sp = new URLSearchParams(window.location.search);
const dash = wantDash || sp.get('view') === 'dashboard';
const projId = sp.get('project') || (activeProject && activeProject.id) || '';
frame.src = 'wp-creation-index.html?embedded=1' + (dash ? '&view=dashboard' : '')
+ (projId ? '&project=' + encodeURIComponent(projId) : '') + '&t=' + Date.now();
const wantWp = _deepLinkWp; _deepLinkWp = '';
const dash = wantDash;
// The frame's identity is the PROJECT only. The view (form vs dashboard) and
// which package to open are applied by calling into the loaded document, so
// switching tabs never reloads it — reloading discarded unsaved form edits, made
// the creator unreachable offline, and stored a fresh copy in the SW cache each
// time. It's same-origin, so a direct call is fine.
const src = 'wp-creation-index.html?embedded=1'
+ (projId ? '&project=' + encodeURIComponent(projId) : '');
const applyNow = () => {
try {
const cw = frame.contentWindow;
if(!cw) return;
if(wantWp && typeof cw.openWpById === 'function' && !cw.openWpById(wantWp)){
if(typeof cw.toast === 'function') cw.toast('That work package is not on this project.');
}
if(dash && typeof cw.showDashboard === 'function') cw.showDashboard();
else if(!dash && typeof cw.showForm === 'function') cw.showForm();
} catch(e){ /* cross-document timing; nothing useful to do */ }
};
// Wait for the creator's data, not just its document. `load` fires before
// pullProject() resolves, so opening a specific package straight after load
// silently found nothing.
const whenReady = () => {
try {
const cw = frame.contentWindow;
if(cw && cw.wpCreatorReady) { applyNow(); return; }
if(cw && cw.document) { cw.document.addEventListener('wp-creator-ready', applyNow, {once:true}); return; }
} catch(e){}
applyNow();
};
if(frame.getAttribute('data-src') === src && frame.contentWindow){
whenReady();
} else {
frame.setAttribute('data-src', src);
frame.addEventListener('load', whenReady, {once:true});
frame.src = src;
}
}else{
gate.style.display = 'block';
frame.style.display = 'none';
@@ -401,6 +669,7 @@ function renderWPTypes(){
container.innerHTML = `<div class="wp-types-header">
<div>Work Order Type</div>
<div style="text-align:center;">Enabled</div>
<div>Spec Section</div>
<div>Special Rules / Notes</div>
<div>WO Complete Approval</div>
</div>`;
@@ -416,6 +685,7 @@ function renderWPTypes(){
row.innerHTML = `
${nameCell}
<div style="text-align:center;"><input type="checkbox" ${t.enabled?'checked':''} onchange="toggleWPType(${i})" style="width:18px; height:18px; cursor:pointer;"></div>
<input type="text" placeholder="e.g. 26_05_33_00" title="Specification section for this WP type. The Creator fills it in automatically on every package of this type, so nobody types it per package." value="${(t.specSection||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].specSection=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px; font-family:var(--mono,monospace); font-size:12.5px;">
<input type="text" placeholder="Special rules…" value="${(t.notes||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].notes=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<input type="text" placeholder="PM / CM / QC…" value="${(t.approval||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].approval=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
`;
@@ -447,20 +717,163 @@ function removeWPType(i){
renderWPTypes();
}
// ── PROJECT TEAM (drawn from user accounts on the project) ────────────────
// The people nameable on a SOP are the project's members (plus admins), so every
// name on the team resolves to an account the suite can assign work to and email.
// Their `project_role` (job function, set in the Admin Console) is offered as the
// default title for an additional team member.
let projectUsers = []; // [{id, full_name, username, email, project_role}]
let projectUsersLoaded = false;
function userLabel(u){
const name = u.full_name || u.username || '';
return u.project_role ? `${name}${u.project_role}` : name;
}
function userById(id){ return projectUsers.find(u => u.id === id) || null; }
async function loadProjectUsers(){
const pid = (typeof ProjectData !== 'undefined' && ProjectData.getActiveId) ? ProjectData.getActiveId() : '';
if(pid){
try {
const r = await fetch('/api/projects/' + encodeURIComponent(pid) + '/members', {credentials:'same-origin'});
if(r.ok) projectUsers = await r.json();
} catch(e){ /* offline — fall back to whatever the SOP already stored */ }
}
projectUsersLoaded = true;
renderTeamPickers();
renderTeamMembers();
renderSignoffRolePickers();
}
// One <select> per leadership slot. A name already on the SOP that no longer
// matches an account is kept as a selected option (tagged) rather than silently
// dropped — an old SOP shouldn't lose its PM because they left the project.
function renderTeamPickers(){
const warn = document.getElementById('team-accounts-warn');
const orphans = [];
['pm','apm','cm','qm'].forEach(key => {
const sel = document.getElementById('proj_' + key);
if(!sel) return;
const curId = state.teamIds[key] || '';
const curName = state.team[key] || '';
let html = '<option value="">— not assigned —</option>' +
projectUsers.map(u => `<option value="${escAttr(u.id)}"${u.id===curId?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
// A stored name with no matching account (typed on an older SOP, or the
// person has since been removed from the project).
if(curName && !userById(curId)){
html += `<option value="__orphan__" selected>${escAttr(curName)} (no account)</option>`;
orphans.push(curName);
}
sel.innerHTML = html;
sel.onchange = function(){ setTeamLead(key, this.value); };
});
if(!warn) return;
if(projectUsersLoaded && !projectUsers.length){
warn.style.display = '';
warn.innerHTML = 'No user accounts are assigned to this project yet, so there is nobody to pick. ' +
'Assign people to the project in the <a href="admin.html" target="_blank" rel="noopener">Admin Console</a> ' +
'(User administration → Projects), then reopen this step.';
} else if(orphans.length){
warn.style.display = '';
warn.textContent = 'Named on this SOP but not a user account on the project: ' + orphans.join(', ') +
'. They cannot be assigned work packages or emailed until they are added as a user and assigned to this project.';
} else {
warn.style.display = 'none';
}
}
// One <select> of project people, reused everywhere the SOP names someone. Keeps a
// name that has no matching account as a selected "(no account)" option so older
// SOPs — and the sample's fictional names — are never silently dropped.
function userSelectOptions(curId, curName){
let html = '<option value="">— not assigned —</option>' +
projectUsers.map(u => `<option value="${escAttr(u.id)}"${u.id===curId?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
if(curName && !userById(curId)){
html += `<option value="__orphan__" selected>${escAttr(curName)} (no account)</option>`;
}
return html;
}
// Sign-off roles (step 3) name the people who must sign a package, so they use the
// same picker as the leadership slots — a signature belongs to an account.
function renderSignoffRolePickers(){
[['role_super_name', 0], ['role_foreman_name', 1]].forEach(([id, ix]) => {
const sel = document.getElementById(id);
const r = state.signoffRoles[ix];
if(!sel || !r) return;
sel.innerHTML = userSelectOptions(r.userId || '', r.name || '');
sel.onchange = function(){
if(this.value === '__orphan__') return;
const u = userById(this.value);
r.userId = u ? u.id : '';
r.name = u ? (u.full_name || u.username) : '';
renderSignoffRolePickers();
};
});
renderOptionalRoles();
}
// Read the four leadership pickers back into state. `state.team[key]` always holds
// a display NAME and `state.teamIds[key]` the account id; a name kept from an older
// SOP whose person has no account (the "(no account)" option) is left alone.
function syncTeamFromPickers(){
if(!state.teamIds) state.teamIds = {pm:'', apm:'', cm:'', qm:''};
['pm','apm','cm','qm'].forEach(key => {
const sel = document.getElementById('proj_' + key);
if(!sel) return;
if(sel.value === '__orphan__') return; // legacy typed name — keep it
const u = userById(sel.value);
state.teamIds[key] = u ? u.id : '';
if(u) state.team[key] = u.full_name || u.username;
else if(sel.value === '') state.team[key] = ''; // explicitly unassigned
});
}
function setOptionalRolePerson(ix, value){
const r = state.signoffRoles[ix];
if(!r || value === '__orphan__') return;
const u = userById(value);
r.userId = u ? u.id : '';
r.name = u ? (u.full_name || u.username) : '';
renderOptionalRoles();
}
function setTeamLead(key, userId){
if(userId === '__orphan__') return; // re-selecting the legacy name changes nothing
const u = userById(userId);
state.teamIds[key] = u ? u.id : '';
state.team[key] = u ? (u.full_name || u.username) : '';
renderTeamPickers();
}
function renderTeamMembers(){
const container = document.getElementById('team-members-list');
if(!container) return;
const opts = (cur) => '<option value="">— pick a person —</option>' +
projectUsers.map(u => `<option value="${escAttr(u.id)}"${u.id===cur?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
container.innerHTML = state.teamMembers.map((m,i)=>`
<div style="display:grid; grid-template-columns:1fr 1fr 30px; gap:1rem; align-items:center; padding:0.75rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
<input type="text" placeholder="Role / title (e.g., Scheduler)" value="${(m.role||'').replace(/"/g,'&quot;')}" onchange="state.teamMembers[${i}].role=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<input type="text" placeholder="Name" value="${(m.name||'').replace(/"/g,'&quot;')}" onchange="state.teamMembers[${i}].name=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<select onchange="setExtraTeamMember(${i}, this.value)" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">${opts(m.userId||'')}</select>
<input type="text" placeholder="Role / title on this project" value="${escAttr(m.role)}" onchange="state.teamMembers[${i}].role=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="removeTeamMember(${i})">✕</button>
</div>
${(m.name && !m.userId) ? `<div style="font-size:12px; margin:-0.25rem 0 0.75rem 0.25rem; color:var(--warning);">“${escAttr(m.name)}” was typed on an earlier version of this SOP and has no user account — pick the person to link them.</div>` : ''}
`).join('');
}
// Picking the person fills the title from their project role but leaves it
// editable — the same person can wear a different hat on a given project.
function setExtraTeamMember(i, userId){
const m = state.teamMembers[i]; if(!m) return;
const u = userById(userId);
m.userId = u ? u.id : '';
m.name = u ? (u.full_name || u.username) : '';
if(u && !m.role) m.role = u.project_role || '';
renderTeamMembers();
}
function addTeamMember(){
state.teamMembers.push({role:'',name:''});
state.teamMembers.push({role:'',name:'',userId:''});
renderTeamMembers();
}
@@ -471,13 +884,14 @@ function removeTeamMember(i){
function renderOptionalRoles(){
const container = document.getElementById('optional-roles-list');
const current = state.signoffRoles.filter(r=>r.role!=='Superintendent'&&r.role!=='Foreman');
// The first two entries are the required (editable-title) roles; the rest are optional.
const current = state.signoffRoles.slice(2);
container.innerHTML = current.map((r,i)=>`
<div style="display:grid; grid-template-columns:1fr 200px 30px; gap:1rem; align-items:center; padding:0.75rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
<select onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].role=this.value">
${OPTIONAL_ROLES.map(o=>`<option ${r.role===o?'selected':''}>${o}</option>`).join('')}
</select>
<input type="text" placeholder="Name (optional)" value="${r.name||''}" onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].name=this.value">
<select onchange="setOptionalRolePerson(${state.signoffRoles.indexOf(r)}, this.value)">${userSelectOptions(r.userId||'', r.name||'')}</select>
<button class="row-del" style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer;" onclick="removeRole(${state.signoffRoles.indexOf(r)})">✕</button>
</div>
`).join('');
@@ -505,6 +919,7 @@ function renderStandardConstraints(){
_constraintsSeeded = true;
}
const active = name => state.constraints.some(c=>c.name===name);
const critical = name => { const c = state.constraints.find(x=>x.name===name); return !!(c && c.critical); };
container.innerHTML = STANDARD_10_CONSTRAINTS.map(c=>`
<div style="display:flex; align-items:start; gap:0.75rem; padding:0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;">
<input type="checkbox" id="const_${c.name}" ${active(c.name)?'checked':''} onchange="toggleConstraint('${c.name}')" style="width:18px; height:18px; cursor:pointer; margin-top:0.2rem;">
@@ -512,11 +927,33 @@ function renderStandardConstraints(){
<label for="const_${c.name}" style="margin:0; font-weight:600; display:block; cursor:pointer;">${c.name}</label>
<div style="font-size:12px; color:var(--text-dim); margin-top:0.25rem;">${c.description}</div>
</div>
${criticalToggle(c.name, active(c.name), critical(c.name))}
</div>
`).join('');
renderCustomConstraints();
}
// A CRITICAL constraint is one whose reopening after release is announced by
// email (PM + CM + the package owner) rather than only dropping the package to
// Issue (Hold). Only meaningful for a constraint that's switched on.
function criticalToggle(name, enabled, isCritical){
const id = 'crit_' + name.replace(/[^A-Za-z0-9]+/g,'_');
const tip = 'Critical: if this constraint reopens after the work package has been released, ' +
'notify the PM, CM and the package owner by email.';
return `<label for="${id}" title="${escAttr(tip)}" style="display:flex; align-items:center; gap:0.4rem; white-space:nowrap; font-size:12px; font-weight:600; cursor:${enabled?'pointer':'not-allowed'}; opacity:${enabled?1:0.45}; color:${isCritical?'var(--danger)':'var(--text-light)'};">
<input type="checkbox" id="${id}" ${isCritical?'checked':''} ${enabled?'':'disabled'} onchange="toggleCriticalConstraint(this.dataset.name, this.checked)" data-name="${escAttr(name)}" style="width:16px; height:16px; cursor:inherit;">
${isCritical ? '⚠ Critical' : 'Critical?'}
</label>`;
}
function toggleCriticalConstraint(name, on){
const c = state.constraints.find(x=>x.name===name);
if(!c) return;
c.critical = !!on;
renderStandardConstraints();
track(on ? 'constraint_marked_critical' : 'constraint_unmarked_critical', {name});
}
// Render the custom (non-standard) constraints into their own list with remove buttons.
function renderCustomConstraints(){
const el = document.getElementById('custom-constraints-list'); if(!el) return;
@@ -525,7 +962,10 @@ function renderCustomConstraints(){
el.innerHTML = customs.length ? customs.map(c=>`
<div style="display:flex; align-items:center; justify-content:space-between; gap:0.75rem; padding:0.6rem 0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;">
<strong>${escAttr(c.name)}</strong>
<span style="display:flex; align-items:center; gap:0.75rem;">
${criticalToggle(c.name, true, !!c.critical)}
<button onclick="removeCustomConstraint('${c.name.replace(/'/g,"\\'")}')" title="Remove" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button>
</span>
</div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`;
}
@@ -537,7 +977,13 @@ function removeCustomConstraint(name){
function toggleConstraint(name){
const idx = state.constraints.findIndex(c=>c.name===name);
if(idx>=0) state.constraints.splice(idx,1);
else state.constraints.push(STANDARD_10_CONSTRAINTS.find(c=>c.name===name));
else {
// Copy the library entry — pushing the shared object would let one project's
// `critical` flag leak into every other project's default constraint set.
const def = STANDARD_10_CONSTRAINTS.find(c=>c.name===name);
if(def) state.constraints.push({...def});
}
renderStandardConstraints(); // the Critical toggle enables/disables with the row
}
function showConstraintLibrary(){
@@ -575,13 +1021,26 @@ function addCustomConstraintText(){
renderStandardConstraints();
}
const DEFAULT_SEQUENCE = ['Layout','Conduit Install','Tray Install','Wire Pull','Device Install','Termination','QC Inspection','Commissioning'];
// Default construction flow (used when BIM is off; the BIM steps are prepended when
// the project includes BIM/VDC). Includes QC-hold gates. Items may be strings or
// {label, kind} objects.
const DEFAULT_SEQUENCE = [
{label:'Conduit Install', kind:'step'},
{label:'Tray Install', kind:'step'},
{label:'QC Hold', kind:'gate'},
{label:'Wire Pull', kind:'step'},
{label:'Device Install', kind:'step'},
{label:'Termination', kind:'step'},
{label:'QC Hold', kind:'gate'},
{label:'Commissioning', kind:'step'},
{label:'As-built (scan / redlines)', kind:'step'}
];
let seqDragIndex = null;
function renderSequenceSteps(){
const container = document.getElementById('sequence-list');
if(!container) return;
if(!state.sequence.length) state.sequence = DEFAULT_SEQUENCE.map(s=>({label:s,kind:'step'}));
if(!state.sequence.length) state.sequence = DEFAULT_SEQUENCE.map(s=> typeof s==='string' ? {label:s,kind:'step'} : {label:s.label, kind:s.kind||'step'});
container.innerHTML = '';
let stepNo = 0;
state.sequence.forEach((item,i)=>{
@@ -736,14 +1195,19 @@ function collectStepData(){
state.project.site = document.getElementById('proj_site').value;
break;
case 2:
state.team.pm = document.getElementById('proj_pm').value;
state.team.apm = document.getElementById('proj_apm').value;
state.team.cm = document.getElementById('proj_cm').value;
state.team.qm = document.getElementById('proj_qm').value;
// These are user-account pickers now, so their .value is an ID, not a name.
// Reading them straight into state.team would put an id where the display
// name belongs (and it would then print on the SOP as `user_ab12…`).
syncTeamFromPickers();
break;
case 3:
state.signoffRoles[0].name = document.getElementById('role_super_name').value;
state.signoffRoles[1].name = document.getElementById('role_foreman_name').value;
// The two required roles now have editable titles (default Superintendent/Foreman).
state.signoffRoles[0].role = (document.getElementById('role_super_title').value || 'Role 1').trim();
// Titles are still free text; the NAMES are account pickers whose .value is
// an id, so they're maintained by their own onchange (see
// renderSignoffRolePickers) rather than read as text here.
state.signoffRoles[0].role = document.getElementById('role_super_title').value || 'Superintendent';
state.signoffRoles[1].role = document.getElementById('role_foreman_title').value || 'Foreman';
break;
case 5:
state.governance.woformat = document.getElementById('gov_woformat').value;
@@ -763,6 +1227,8 @@ function collectStepData(){
case 7:
state.platforms.tracking = document.getElementById('plat_tracking').value;
state.platforms.commissioning = document.getElementById('plat_commissioning').value;
state.platforms.trackingUrl = (document.getElementById('plat_tracking_url').value || '').trim();
state.platforms.commissioningUrl = (document.getElementById('plat_commissioning_url').value || '').trim();
break;
}
}
@@ -787,12 +1253,25 @@ function validateStep(n){
}
// ── SOP COMPLETION ────────────────────────────────────────────────────────────
// Re-saving a SOP that is already complete changes the project's baseline, which
// the server restricts to a Project Admin. Check before doing the work so the
// answer is a clear message rather than a 403 from the sync outbox.
function canEditCompletedSOP(){
return (typeof wpCanEditCompletedSOP === 'function') ? wpCanEditCompletedSOP() : true;
}
function completeSOP(){
if(sopComplete && !canEditCompletedSOP()){
alert('This project\'s SOP is already complete, and changing it needs the Project Admin role.\n\n' +
'Ask a project admin to make the change — the SOP is the baseline every work package inherits.');
return;
}
if(!validateStep(10)) return;
collectStepData();
sop = {
meta: {tool:'Work Package Configuration', sample:false},
bimEnabled: !!state.bimEnabled, // project also produces BIM (EWP) packages → Creator offers per-package IWP/EWP kind
project: {
name: state.project.name,
number: state.project.number,
@@ -802,10 +1281,20 @@ function completeSOP(){
apm: state.team.apm,
cm: state.team.cm,
qm: state.team.qm,
// User-account ids for the same four people. These are what the Creator
// uses to offer an owner and what notification routing needs — a display
// name alone can't be assigned work or emailed.
pmId: state.teamIds.pm || '',
apmId: state.teamIds.apm || '',
cmId: state.teamIds.cm || '',
qmId: state.teamIds.qm || '',
site: state.project.site,
teamMembers: state.teamMembers.filter(m=>(m.role||m.name))
teamMembers: state.teamMembers.filter(m=>(m.role||m.name||m.userId))
},
roles: state.signoffRoles.filter(r=>r.role),
roles: state.signoffRoles.filter(r=>r.role).map(r=>({
role: r.role, name: r.name || '',
userId: r.userId || '' // who signs — an account, so it can be notified
})),
governance: {
issuance: state.governance.issuance.length ? state.governance.issuance : ['By Sector / Area'],
woSize: state.governance.wosize,
@@ -820,11 +1309,21 @@ function completeSOP(){
name: t.name.trim(),
enabled: true,
notes: t.notes || '',
approval: t.approval || ''
approval: t.approval || '',
// Spec section for this type — the Creator fills the WP's Specification
// Section from it, so it's authored once here instead of per package.
specSection: t.specSection || '',
bim: !!t.bim
})),
sources: state.sources.filter(s=>s.label),
field: {trackPlatform: state.platforms.tracking},
commissioning: {tool: state.platforms.commissioning},
field: {trackPlatform: state.platforms.tracking, trackPlatformUrl: state.platforms.trackingUrl || ''},
commissioning: {tool: state.platforms.commissioning, toolUrl: state.platforms.commissioningUrl || ''},
// Project homepage links in the tracking / commissioning systems. The Creator
// copies these onto every Work Package created for this project.
projectLinks: [
state.platforms.trackingUrl ? {label:'Tracking — '+state.platforms.tracking, system:state.platforms.tracking, url:state.platforms.trackingUrl} : null,
state.platforms.commissioningUrl ? {label:'Commissioning — '+state.platforms.commissioning, system:state.platforms.commissioning, url:state.platforms.commissioningUrl} : null
].filter(Boolean),
quality: {
qcReq: state.quality.qcreq,
photo: state.quality.photo,
@@ -836,7 +1335,11 @@ function completeSOP(){
kind: s.kind || 'step'
})),
costCodes: LABOR_COST_CODES,
constraints: state.constraints.map(c=>({name: c.name, description: c.description || ''}))
constraints: state.constraints.map(c=>({
name: c.name, description: c.description || '', bim: !!c.bim,
// Critical → reopening after release is emailed, not just flagged on the package.
critical: !!c.critical
}))
};
sopComplete = true;
@@ -953,8 +1456,8 @@ function loadStepComments(){
}else{
list.innerHTML = stepComments.map(c=>`
<div style="padding:0.5rem; background:white; border:1px solid var(--border); border-radius:4px; margin-bottom:0.5rem;">
<div style="font-size:11px; color:var(--text-dim); margin-bottom:0.25rem;"><strong>${c.name}</strong> • ${c.timestamp}</div>
<div style="font-size:12px; color:var(--text);">${c.text.replace(/</g,'&lt;').replace(/>/g,'&gt;')}</div>
<div style="font-size:11px; color:var(--text-dim); margin-bottom:0.25rem;"><strong>${escAttr(c.name)}</strong> • ${escAttr(c.timestamp)}</div>
<div style="font-size:12px; color:var(--text);">${escAttr(c.text)}</div>
</div>
`).join('');
}

View File

@@ -1,22 +1,25 @@
:root {
--primary: #2563eb;
--primary-light: #dbeafe;
--success: #16a34a;
--warning: #ea580c;
--danger: #dc2626;
--text: #1f2937;
--text-light: #6b7280;
--text-dim: #9ca3af;
--border: #e5e7eb;
--bg: #f9fafb;
--primary: #0f62fe;
--primary-light: #edf5ff;
--success: #198038;
--warning: #8e6a00;
--warning-bg: #fdf6dd;
--danger: #da1e28;
--text: #161616;
--text-light: #525252;
--text-dim: #8d8d8d;
--border: #e0e0e0;
--border-strong: #8d8d8d;
--bg: #f4f4f4;
--bg-card: #ffffff;
--shadow: 0 1px 3px rgba(0,0,0,0.1);
--shadow-lg: 0 10px 25px rgba(0,0,0,0.1);
--appbar: #161616;
--shadow: none;
--shadow-lg: 0 4px 16px rgba(0,0,0,0.16);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
color: var(--text);
background: var(--bg);
line-height: 1.5;
@@ -28,43 +31,46 @@ body {
min-height: 100vh;
}
/* HEADER */
/* HEADER — dark UI Shell bar */
.header {
background: #ffffff;
color: var(--text);
padding: 1.5rem 2rem;
background: var(--appbar);
color: #fff;
padding: 0 16px;
height: 48px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: var(--shadow);
border-bottom: 1px solid var(--border);
}
.header-left {
flex: 1;
display: flex;
align-items: center;
gap: 1.5rem;
gap: 14px;
min-width: 0;
}
/* Prime logo (white-background wordmark) sits in a white chip on the dark bar */
.logo {
display: flex;
display: inline-flex;
align-items: center;
gap: 0.5rem;
justify-content: center;
background: #fff;
padding: 4px 8px;
border-radius: 4px;
text-decoration: none;
color: var(--text);
font-weight: 700;
font-size: 14px;
transition: opacity 0.2s;
flex-shrink: 0;
}
.logo:hover { opacity: 0.7; }
.logo:hover { opacity: 0.92; }
.logo img { height: 24px; width: auto; display: block; }
.logo-icon {
width: 36px;
height: 36px;
background: var(--primary-light);
border-radius: 6px;
border-radius: 0;
display: flex;
align-items: center;
justify-content: center;
@@ -73,97 +79,142 @@ body {
}
.header-title {
font-size: 24px;
font-weight: 700;
font-size: 15px;
font-weight: 600;
margin-bottom: 0;
color: var(--text);
color: #fff;
white-space: nowrap;
}
.header-subtitle {
font-size: 13px;
color: var(--text-light);
min-height: 20px;
font-size: 12px;
color: #c6c6c6;
min-height: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-right {
display: flex;
align-items: center;
gap: 1.5rem;
gap: 8px;
}
.header-button {
padding: 0.5rem 1rem;
background: var(--bg);
color: var(--primary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 7px 14px;
background: transparent;
color: #fff;
border: 1px solid #6f6f6f;
border-radius: 0;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.2s;
font-size: 14px;
font-weight: 400;
transition: background 0.15s, border-color 0.15s;
}
.header-button:hover {
background: var(--primary-light);
border-color: var(--primary);
background: #353535;
border-color: #6f6f6f;
}
.step-counter {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text-light);
padding: 0.4rem 0.8rem;
background: transparent;
border: 1px solid #6f6f6f;
color: #c6c6c6;
padding: 4px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
/* MAIN NAVIGATION */
/* MAIN NAVIGATION — underline tabs */
.main-nav {
display: flex;
gap: 0.5rem;
padding: 1rem 2rem;
gap: 0;
padding: 0 16px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow);
}
.nav-tab {
padding: 0.75rem 1.5rem;
background: var(--bg);
border: 2px solid var(--border);
border-radius: 6px;
padding: 13px 18px;
background: none;
border: none;
border-bottom: 3px solid transparent;
border-radius: 0;
cursor: pointer;
font-size: 14px;
font-weight: 600;
font-size: 15px;
font-weight: 400;
color: var(--text-light);
display: flex;
align-items: center;
gap: 0.5rem;
transition: all 0.2s;
transition: background 0.15s, color 0.15s;
}
.nav-tab:hover {
border-color: var(--primary);
color: var(--primary);
background: var(--bg);
color: var(--text);
}
.nav-tab.active {
background: var(--primary);
color: white;
border-color: var(--primary);
background: none;
color: var(--text);
border-bottom-color: var(--primary);
font-weight: 600;
}
.tab-icon { font-size: 16px; }
/* CONTENT AREA */
/* CONTENT AREA
The SOP wizard reads better with a bound on line length, but 1000px on a 1920
screen wasted half the display — and it also squeezed the embedded Work Package
Creator (an iframe living in here) into a ~930px column with its own scrollbar
inside the page's. Wider cap for the wizard; the embedded tools go full-bleed
(see .content-area.embed-full below). */
.content-area {
flex: 1;
padding: 2rem;
max-width: 1000px;
max-width: 1700px;
margin: 0 auto;
width: 100%;
}
/* Work Package Creation / Dashboard: the iframe fills the window below the app
chrome and owns the only scrollbar, so the creator's sticky save bar and
navigator drawer position against a real viewport instead of scrolling away. */
.content-area.embed-full {
/* `flex: none` matters: .content-area is a column flex item with `flex: 1`, whose
flex-basis:0% overrides `height` and leaves the used height INDEFINITE — so a
child's `height:100%` resolves to auto and the iframe collapses to its 150px
default. Opting out of flex sizing makes the height definite. */
flex: none;
max-width: none;
padding: 0;
height: calc(100vh - var(--wp-chrome-h, 96px));
overflow: hidden;
display: flex;
flex-direction: column;
}
.content-area.embed-full > .tool.active {
flex: 1 1 auto;
min-height: 0; /* let it shrink instead of overflowing the shell */
height: 100%;
}
#wp-frame {
width: 100%;
border: 0;
min-height: calc(100vh - 200px);
}
#wp-frame.fill {
display: block;
height: 100%;
min-height: 0;
}
/* No page scrollbar while a full-bleed tool is open — the iframe scrolls. */
body.embed-full { overflow: hidden; }
.tool {
display: none;
}
@@ -186,35 +237,37 @@ body {
}
.step-item {
padding: 0.75rem 1rem;
border-radius: 6px;
background: var(--bg);
border: 2px solid var(--border);
padding: 0.6rem 0.9rem;
border-radius: 0;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-light);
cursor: pointer;
font-size: 12px;
font-weight: 600;
font-weight: 500;
white-space: nowrap;
transition: all 0.2s;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.step-item:hover { background: var(--primary-light); border-color: var(--primary); }
.step-item.active { background: var(--primary); color: white; border-color: var(--primary); }
.step-item:hover { background: var(--bg); border-color: var(--border-strong); color: var(--text); }
.step-item.active { background: var(--primary); color: white; border-color: var(--primary); font-weight: 600; }
/* STEP CONTENT */
.step-content {
background: var(--bg-card);
padding: 2rem;
border-radius: 8px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
border-radius: 0;
margin-bottom: 2rem;
}
.step { display: none; }
.step h2 {
font-size: 20px;
font-weight: 700;
margin-bottom: 0.5rem;
font-size: 22px;
font-weight: 400;
letter-spacing: -0.01em;
margin-bottom: 0.75rem;
color: var(--text);
}
@@ -223,15 +276,20 @@ body {
color: var(--text-light);
background: var(--primary-light);
padding: 0.75rem 1rem;
border-radius: 6px;
border-radius: 0;
margin-bottom: 1.5rem;
border-left: 4px solid var(--primary);
}
/* FIELDS */
/* FIELDS
The wizard's fields were one per row, which looked right in a 1000px column but
stretches a text input across the screen now that the content area is wide. Flow
them into as many ~340px columns as fit; `.col1` still forces a single column for
the fields that genuinely want the width (long text, textareas). */
.field-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
}
.field-grid.col1 { grid-template-columns: 1fr; }
@@ -253,7 +311,7 @@ body {
.field textarea {
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
border-radius: 0;
font-size: 14px;
font-family: inherit;
color: var(--text);
@@ -275,6 +333,19 @@ body {
margin-top: 0.25rem;
}
/* Small helper text under a field. It's used on this page (step 2's CM hint, the
team-member notices) but its only rule used to live in wp-creation-styles.css,
which this page does not link — so it rendered as unstyled body text. */
.field-hint { font-size: 12px; color: var(--text-dim); margin-top: 0.25rem; }
.field-hint strong { color: var(--text-light); }
/* The sign-off name pickers sit outside .field, so they got no form styling at all. */
.user-pick {
padding: 0.75rem; border: 1px solid var(--border); border-radius: 0;
font-size: 14px; font-family: inherit; color: var(--text); background: var(--bg);
}
.user-pick:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-light); }
/* ROLES */
.required-roles {
display: flex;
@@ -287,7 +358,7 @@ body {
align-items: center;
padding: 1rem;
background: var(--bg);
border-radius: 6px;
border-radius: 0;
border: 1px solid var(--border);
}
@@ -314,20 +385,20 @@ body {
.wp-type-row {
display: grid;
grid-template-columns: 1.2fr 90px 2fr 1.5fr;
gap: 1rem;
grid-template-columns: 1.1fr 80px 1.3fr 1.6fr 1.2fr;
gap: 0.85rem;
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg);
border-radius: 6px;
border-radius: 0;
margin-bottom: 0.5rem;
border: 1px solid var(--border);
}
.wp-types-header {
display: grid;
grid-template-columns: 1.2fr 90px 2fr 1.5fr;
gap: 1rem;
grid-template-columns: 1.1fr 80px 1.3fr 1.6fr 1.2fr;
gap: 0.85rem;
padding: 0.5rem 1rem;
font-size: 11px;
font-weight: 600;
@@ -339,31 +410,32 @@ body {
/* BUTTONS */
.add-btn {
padding: 0.75rem 1.25rem;
padding: 0.7rem 1.25rem;
background: var(--primary);
color: white;
border: none;
border-radius: 6px;
border-radius: 0;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.add-btn:hover { background: #1d4ed8; }
.add-btn:hover { background: var(--cds-hover-primary, #0353e9); }
.nav-btn {
padding: 0.75rem 1.5rem;
background: var(--bg);
border: 2px solid var(--border);
border-radius: 6px;
padding: 0.7rem 1.4rem;
background: var(--bg-card);
border: 1px solid var(--border-strong);
border-radius: 0;
font-size: 14px;
font-weight: 600;
color: var(--text);
cursor: pointer;
transition: all 0.2s;
transition: all 0.15s;
}
.nav-btn:hover { border-color: var(--primary); color: var(--primary); }
.nav-btn:hover { border-color: var(--primary); color: var(--primary); background: var(--bg); }
.nav-btn.primary {
background: var(--success);
@@ -371,7 +443,7 @@ body {
border-color: var(--success);
}
.nav-btn.primary:hover { background: #15803d; border-color: #15803d; }
.nav-btn.primary:hover { background: #0e6027; border-color: #0e6027; color: white; }
.nav-btn:disabled { opacity: 0.5; cursor: not-allowed; }
@@ -382,7 +454,7 @@ body {
justify-content: space-between;
padding: 1.5rem;
background: var(--bg-card);
border-radius: 8px;
border-radius: 0;
box-shadow: var(--shadow);
}
@@ -398,7 +470,7 @@ body {
background: var(--primary-light);
color: var(--primary);
border: 1px solid var(--primary);
border-radius: 6px;
border-radius: 0;
font-size: 13px;
font-weight: 600;
cursor: pointer;
@@ -411,7 +483,7 @@ body {
#sequence-list { display: flex; flex-direction: column; gap: 8px; }
.seq-step {
display: flex; align-items: center; gap: 12px; padding: 11px 14px;
background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px;
background: var(--bg-card); border: 1px solid var(--border); border-radius: 0;
box-shadow: var(--shadow); transition: border-color .12s, box-shadow .12s, opacity .12s;
}
.seq-step:hover { border-color: var(--primary); }
@@ -432,7 +504,7 @@ body {
width: 28px; height: 28px; cursor: pointer; font-weight: 600; flex-shrink: 0;
}
.seq-arrow { text-align: center; color: var(--text-dim); font-size: 13px; line-height: .4; margin: -2px 0; }
.seq-step.gate { border-color: var(--warning); background: #fff7ed; border-style: dashed; }
.seq-step.gate { border-color: var(--warning); background: var(--warning-bg); border-style: dashed; }
.seq-step.gate .seq-label { color: var(--warning); font-weight: 500; }
.seq-gate-badge {
flex-shrink: 0; padding: 3px 9px; border-radius: 20px; background: var(--warning); color: #fff;
@@ -448,7 +520,7 @@ body {
max-width: calc(100vw - 2rem);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
border-radius: 0;
box-shadow: var(--shadow-lg);
padding: 1.25rem;
z-index: 1200;
@@ -488,7 +560,7 @@ body {
.modal-content {
background: var(--bg-card);
border-radius: 8px;
border-radius: 0;
padding: 2rem;
max-width: 600px;
max-height: 80vh;
@@ -528,7 +600,7 @@ body {
/* RESPONSIVE */
@media (max-width: 768px) {
.header { flex-direction: column; text-align: center; gap: 1rem; }
.header { height: auto; flex-direction: column; align-items: stretch; text-align: center; gap: 0.75rem; padding: 12px 16px; }
.main-nav { flex-wrap: wrap; }
.content-area { padding: 1rem; }
.step-content { padding: 1rem; }

View File

@@ -5,8 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Work Package Suite</title>
<script src="auth-guard.js"></script>
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
timestamps during their own boot. -->
<script src="wp-format.js"></script>
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-chrome.css">
<link rel="stylesheet" href="work-package-suite-styles.css">
</head>
<body>
@@ -15,9 +21,9 @@
<div class="header">
<div class="header-left">
<a href="index.html" class="logo" title="Back to Home">
<img src="prime-controls-logo.jpg" alt="Prime Controls" style="height: 36px; width: auto;">
<img src="prime-controls-logo.jpg" alt="Prime Controls" style="height: 24px; width: auto;">
</a>
<div>
<div style="min-width:0;overflow:hidden">
<div class="header-title">Work Package Suite</div>
<div class="header-subtitle" id="project-display"></div>
</div>
@@ -101,23 +107,28 @@
<!-- STEP 2: PROJECT TEAM -->
<div class="step" id="sop-step-2" style="display: none;">
<h2>2. Project Team Leadership</h2>
<div class="notice">Name the key project leaders. These are informational and will appear in SOP exports.</div>
<div class="notice">Pick the key project leaders from the people assigned to this project. Choosing a
<strong>user account</strong> (rather than typing a name) is what lets the Work Package Creator offer them
as an owner and lets the suite email them — so add anyone missing to the project first, in the
<a href="admin.html" target="_blank" rel="noopener">Admin Console</a>.</div>
<div id="team-accounts-warn" class="notice" style="display:none; background:var(--warning-bg); color:var(--warning);"></div>
<div class="field-grid">
<div class="field">
<label>Project Manager (PM)</label>
<input type="text" id="proj_pm" placeholder="e.g., Mariano Sanchez">
<select id="proj_pm" class="team-pick" data-team="pm"></select>
</div>
<div class="field">
<label>Assistant Project Manager (APM)</label>
<input type="text" id="proj_apm" placeholder="e.g., Assistant PM name">
<select id="proj_apm" class="team-pick" data-team="apm"></select>
</div>
<div class="field">
<label>Construction Manager (CM)</label>
<input type="text" id="proj_cm" placeholder="e.g., K. Boyd">
<select id="proj_cm" class="team-pick" data-team="cm"></select>
<div class="field-hint">Kept on the distribution list of every work package by default.</div>
</div>
<div class="field">
<label>Quality Manager (QM)</label>
<input type="text" id="proj_qm" placeholder="e.g., D. Nguyen">
<select id="proj_qm" class="team-pick" data-team="qm"></select>
</div>
</div>
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
@@ -130,21 +141,21 @@
<!-- STEP 3: SIGN-OFF ROLES -->
<div class="step" id="sop-step-3" style="display: none;">
<h2>3. Required Sign-Off Roles</h2>
<div class="notice">Superintendent and Foreman are required. Add other roles as needed for your project structure.</div>
<div class="notice">Two roles are required on every package. They default to <strong>Superintendent</strong> and <strong>Foreman</strong> — rename either to fit your project (e.g. a BIM SOP uses <em>BIM Coordinator</em> and <em>Construction Lead</em>). Add more below.</div>
<div class="required-roles">
<div class="role-required">
<div class="role-checkbox">
<input type="checkbox" id="role_super" checked disabled>
<label>Superintendent *</label>
<input type="text" id="role_super_title" value="Superintendent" title="Required role title" style="font-weight:600; padding:0.4rem 0.5rem; border:1px solid var(--border); border-radius:4px; min-width:180px;"><span style="color:var(--danger); margin-left:4px;">*</span>
</div>
<input type="text" id="role_super_name" placeholder="Name (optional)" style="flex: 1; margin-left: 1rem;">
<select id="role_super_name" class="user-pick" style="flex: 1; margin-left: 1rem;"></select>
</div>
<div class="role-required">
<div class="role-checkbox">
<input type="checkbox" id="role_foreman" checked disabled>
<label>Foreman *</label>
<input type="text" id="role_foreman_title" value="Foreman" title="Required role title" style="font-weight:600; padding:0.4rem 0.5rem; border:1px solid var(--border); border-radius:4px; min-width:180px;"><span style="color:var(--danger); margin-left:4px;">*</span>
</div>
<input type="text" id="role_foreman_name" placeholder="Name (optional)" style="flex: 1; margin-left: 1rem;">
<select id="role_foreman_name" class="user-pick" style="flex: 1; margin-left: 1rem;"></select>
</div>
</div>
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
@@ -157,7 +168,15 @@
<!-- STEP 4: WORK PACKAGE TYPES -->
<div class="step" id="sop-step-4" style="display: none;">
<h2>4. Work Package Types</h2>
<div class="notice">Enable the WP types your project will use. Add any special rules and the roles required to approve WO completion.</div>
<div class="notice">Enable the WP types your project will use. Add any special rules and the roles required to approve WO completion.
<strong>Spec Section</strong> is filled onto every work package of that type automatically, so nobody types it per package.</div>
<label id="bim-toggle-wrap" style="display:flex; align-items:flex-start; gap:0.6rem; padding:0.85rem 1rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin:0 0 1rem; cursor:pointer;">
<input type="checkbox" id="bim_enabled" onchange="setBimEnabled(this.checked)" style="width:18px; height:18px; margin-top:2px; flex:none;">
<span><strong>Include BIM / VDC work packages on this project</strong><br>
<span style="color:var(--text-dim); font-size:12px;">Adds model/engineering package types &amp; release gates. In the Creator each package is then tagged <strong>Install (IWP)</strong> or <strong>BIM (EWP)</strong>, so the project can flow from BIM into construction. Leave off for install-only projects.</span></span>
</label>
<!-- Shown instead of the toggle when an admin has the BIM tooling switched off app-wide. -->
<div id="bim-disabled-note" class="notice" style="display:none; background:var(--warning-bg); color:var(--warning);"></div>
<div id="wp-types-table" style="margin-top: 1.5rem;"></div>
</div>
@@ -287,6 +306,16 @@
</select>
</div>
</div>
<div class="field" style="margin-top:1rem;">
<label>Tracking platform — project homepage link</label>
<input type="url" id="plat_tracking_url" placeholder="Paste the project's URL in the tracking platform (e.g. its Procore / CxAlloy project home)">
<small>Optional. Saved with every Work Package on this project for one-click access.</small>
</div>
<div class="field" style="margin-top:0.75rem;">
<label>Commissioning tool — project homepage link</label>
<input type="url" id="plat_commissioning_url" placeholder="Paste the project's URL in the commissioning tool">
<small>Optional. Saved with every Work Package on this project for one-click access.</small>
</div>
</div>
<!-- STEP 8: SEQUENCE -->
@@ -346,7 +375,13 @@
<button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">Go to SOP Configuration</button>
</div>
<!-- The real Work Package Creator, embedded once the SOP is complete -->
<iframe id="wp-frame" title="Work Package Creator" style="display:none; width:100%; border:0; min-height: calc(100vh - 200px);"></iframe>
<!-- Sizing stays INLINE on purpose. An iframe with no width/height falls back
to the HTML default 300x150 box, and the service worker caches this page
and the stylesheet separately — so a browser can hold new HTML with old
CSS and collapse the creator to a tiny scrolling box. Inline attributes
survive any cache mismatch; the CSS below only refines them. -->
<iframe id="wp-frame" title="Work Package Creator"
style="display:none; width:100%; border:0; min-height:calc(100vh - 200px)"></iframe>
</div>
</div>
@@ -397,5 +432,6 @@
<script src="project-data.js"></script>
<script src="help.js"></script>
<script src="work-package-suite-app.js"></script>
<script src="wp-chrome.js"></script>
</body>
</html>

211
html/wp-chrome.css Normal file
View File

@@ -0,0 +1,211 @@
/* ============================================================================
SHARED APP CHROME — project switcher + global search
----------------------------------------------------------------------------
Injected by wp-chrome.js into whichever top bar a page has: the dark UI-shell
bar (.wp-appbar on index / admin / field) or the older light bars (.header on
the SOP suite and the WP creator). The two live side by side, so every colour
here comes from a variable that wp-chrome.js sets per host bar — the markup and
behaviour are identical on both.
============================================================================ */
.wp-chrome {
display: flex;
align-items: center;
gap: 10px;
min-width: 0; /* lets the search shrink instead of overflowing */
flex: 1 1 auto;
}
/* Light host bar (the two tool pages) */
.wp-chrome {
--wpc-fg: #161616;
--wpc-fg-dim: #525252;
--wpc-bg: #ffffff;
--wpc-bg-soft: #f4f4f4;
--wpc-border: #c6c6c6;
--wpc-hover: #e8e8e8;
--wpc-accent: #0f62fe;
}
/* Dark host bar (the UI-shell appbar) */
.wp-chrome[data-bar="dark"] {
--wpc-fg: #ffffff;
--wpc-fg-dim: #c6c6c6;
--wpc-bg: #262626;
--wpc-bg-soft: #393939;
--wpc-border: #6f6f6f;
--wpc-hover: #353535;
--wpc-accent: #78a9ff;
}
/* ── project switcher ─────────────────────────────────────────────────────── */
.wpc-proj { position: relative; flex: 0 0 auto; }
.wpc-proj-btn {
display: flex;
align-items: center;
gap: 10px;
max-width: 280px;
padding: 5px 10px;
background: transparent;
border: 1px solid transparent;
border-radius: 3px;
color: var(--wpc-fg);
font: inherit;
font-size: 13px;
line-height: 1.25;
text-align: left;
cursor: pointer;
}
.wpc-proj-btn:hover { background: var(--wpc-hover); border-color: var(--wpc-border); }
.wpc-proj-btn[aria-expanded="true"] { background: var(--wpc-hover); border-color: var(--wpc-border); }
.wpc-proj-labels { min-width: 0; }
.wpc-proj-kicker {
display: block;
font-size: 10px;
font-weight: 600;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--wpc-fg-dim);
}
.wpc-proj-name {
display: block;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 240px;
}
.wpc-caret { flex: 0 0 auto; align-self: flex-end; margin-bottom: 3px; font-size: 10px;
line-height: 1; color: var(--wpc-fg-dim); }
/* ── dropdown / results panel (shared shell) ──────────────────────────────── */
.wpc-pop {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 2000;
min-width: 320px;
max-width: min(460px, 92vw);
max-height: min(70vh, 560px);
overflow-y: auto;
background: #fff;
color: #161616;
border: 1px solid #e0e0e0;
box-shadow: 0 8px 28px rgba(20, 30, 50, .22);
border-radius: 4px;
}
.wpc-pop[hidden] { display: none; }
.wpc-pop-head {
padding: 9px 12px 6px;
font-size: 10px;
font-weight: 700;
letter-spacing: .07em;
text-transform: uppercase;
color: #6f6f6f;
border-bottom: 1px solid #f0f0f0;
}
.wpc-item {
display: block;
width: 100%;
padding: 8px 12px;
background: transparent;
border: 0;
border-left: 3px solid transparent;
text-align: left;
font: inherit;
font-size: 13px;
color: #161616;
cursor: pointer;
text-decoration: none;
}
.wpc-item:hover, .wpc-item.is-active { background: #f4f4f4; }
.wpc-item.is-current { border-left-color: #0f62fe; background: #edf5ff; }
.wpc-item-title { display: block; font-weight: 600; }
.wpc-item-sub { display: block; font-size: 11.5px; color: #6f6f6f; }
.wpc-item-mono { font-family: 'IBM Plex Mono', ui-monospace, Consolas, monospace; font-size: 12px; color: #0f62fe; }
.wpc-empty { padding: 14px 12px; font-size: 13px; color: #6f6f6f; }
.wpc-pop-foot {
padding: 8px 12px;
border-top: 1px solid #f0f0f0;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.wpc-foot-btn {
font: inherit;
font-size: 12px;
font-weight: 600;
padding: 5px 10px;
border: 1px solid #c6c6c6;
background: #fff;
color: #161616;
border-radius: 3px;
cursor: pointer;
text-decoration: none;
}
.wpc-foot-btn:hover { border-color: #0f62fe; color: #0f62fe; }
/* ── global search ────────────────────────────────────────────────────────── */
/* Centered in the bar: the wrapper takes the free space and centres a capped box,
which keeps the field mid-screen without absolute positioning (so it can never
sit on top of the bar's own buttons). */
.wpc-search {
position: relative;
flex: 1 1 auto;
display: flex;
justify-content: center;
min-width: 0;
}
.wpc-search-box {
position: relative;
width: 100%;
max-width: 560px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
height: 34px;
background: var(--wpc-bg);
border: 1px solid var(--wpc-border);
border-radius: 3px;
}
.wpc-search-box:focus-within { outline: 2px solid var(--wpc-accent); outline-offset: -2px; }
.wpc-search-ico { flex: 0 0 auto; color: var(--wpc-fg-dim); font-size: 13px; }
.wpc-search-input {
flex: 1 1 auto;
min-width: 0;
background: transparent;
border: 0;
outline: none;
color: var(--wpc-fg);
font: inherit;
font-size: 13.5px;
}
.wpc-search-input::placeholder { color: var(--wpc-fg-dim); }
.wpc-kbd {
flex: 0 0 auto;
font-family: 'IBM Plex Mono', ui-monospace, Consolas, monospace;
font-size: 10.5px;
color: var(--wpc-fg-dim);
border: 1px solid var(--wpc-border);
border-radius: 3px;
padding: 1px 5px;
white-space: nowrap;
}
.wpc-search .wpc-pop { left: 50%; transform: translateX(-50%); min-width: min(560px, 92vw); }
.wpc-clear {
flex: 0 0 auto; background: transparent; border: 0; cursor: pointer;
color: var(--wpc-fg-dim); font: inherit; font-size: 14px; line-height: 1; padding: 2px 4px;
}
.wpc-clear:hover { color: var(--wpc-fg); }
/* ── narrow screens ───────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.wpc-search-box { max-width: none; }
.wpc-kbd { display: none; }
.wpc-proj-btn { max-width: 190px; }
.wpc-proj-name { max-width: 150px; }
}
@media (max-width: 620px) {
/* Keep the switcher (you must be able to change project) and let the search
collapse to an icon-width field rather than pushing the bar out of shape. */
.wpc-proj-kicker { display: none; }
.wpc-search { flex: 1 1 120px; }
}

335
html/wp-chrome.js Normal file
View File

@@ -0,0 +1,335 @@
/* Shared app chrome for the Work Package Suite: a project switcher beside the
Prime logo and a global search centered in the top bar.
One script for every page because there are two generations of top bar — the
dark UI-shell `.wp-appbar` (home, admin, field) and the older light `.header`
(SOP suite, WP creator). We find whichever exists, insert the same markup, and
flip a colour set based on how dark the host bar is.
Search hits GET /api/search, which scopes results to the projects the signed-in
user may access — so this is a convenience, never a way to see another job.
Skipped inside an iframe: the WP creator is embedded in the suite page, and a
second bar inside the frame would be nonsense. */
(function () {
'use strict';
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
if (inIframe) return;
var SEARCH_MIN = 2; // characters before we ask the server
var DEBOUNCE_MS = 180;
function el(tag, cls, html) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (html != null) n.innerHTML = html;
return n;
}
function esc(v) {
return String(v == null ? '' : v)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function isDark(node) {
try {
var m = (getComputedStyle(node).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
if (!m) return false;
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
} catch (e) { return false; }
}
// ── where to put the chrome ────────────────────────────────────────────────
// Returns {host, insertBefore} or null. The insertion point matters: on the
// dark bar we sit before the spacer (so search takes the middle); on the light
// bars we sit between the left block and the right-hand buttons.
function findMount() {
var appbar = document.querySelector('.wp-appbar');
if (appbar) {
return { host: appbar, before: appbar.querySelector('.wp-appbar-spacer') };
}
var header = document.querySelector('.header');
if (header) {
// The suite page wraps its own left/right groups; the creator's bar is a
// flat row of buttons whose first button carries margin-left:auto.
var right = header.querySelector('.header-right');
if (right) return { host: header, before: right };
var firstBtn = header.querySelector('.btn, button');
return { host: header, before: firstBtn };
}
return null;
}
// ── project switcher ───────────────────────────────────────────────────────
var projects = [];
function activeProject() {
try { return (window.ProjectData && ProjectData.getActive()) || null; } catch (e) { return null; }
}
function projectLabel(p) {
if (!p) return 'Select a project';
var n = p.name || '(unnamed)';
return p.number ? (p.number + ' — ' + n) : n;
}
// Switching project reloads the current page with ?project=<id>. Every page
// already resolves its project from that param (falling back to the stored
// active id), so a reload is both the simplest and the safest route — no page
// has to re-hydrate half its state in place.
function switchProject(p) {
try { if (window.ProjectData) ProjectData.setActive(p); } catch (e) {}
var url = new URL(location.href);
url.searchParams.set('project', p.id);
url.hash = '';
location.assign(url.toString());
}
function buildProjectSwitcher() {
var wrap = el('div', 'wpc-proj');
var btn = el('button', 'wpc-proj-btn');
btn.type = 'button';
btn.setAttribute('aria-haspopup', 'listbox');
btn.setAttribute('aria-expanded', 'false');
btn.title = 'Switch project';
var cur = activeProject();
btn.innerHTML =
'<span class="wpc-proj-labels">' +
'<span class="wpc-proj-kicker">Project</span>' +
'<span class="wpc-proj-name">' + esc(projectLabel(cur)) + '</span>' +
'</span><span class="wpc-caret">▾</span>';
var pop = el('div', 'wpc-pop');
pop.hidden = true;
wrap.appendChild(btn);
wrap.appendChild(pop);
function render() {
var curId = (activeProject() || {}).id || '';
var rows = projects.map(function (p) {
return '<button type="button" class="wpc-item' + (p.id === curId ? ' is-current' : '') +
'" data-pid="' + esc(p.id) + '">' +
'<span class="wpc-item-title">' + esc(p.name || '(unnamed)') + '</span>' +
'<span class="wpc-item-sub">' + esc([p.number, p.client, p.site].filter(Boolean).join(' · ') ||
'no number') + (p.sample ? ' · sample' : '') + '</span>' +
'</button>';
}).join('');
pop.innerHTML =
'<div class="wpc-pop-head">Switch project</div>' +
(rows || '<div class="wpc-empty">No projects you can access yet.</div>') +
'<div class="wpc-pop-foot"><a class="wpc-foot-btn" href="index.html">All projects / new project</a></div>';
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (item) {
item.addEventListener('click', function () {
var p = projects.filter(function (x) { return x.id === item.getAttribute('data-pid'); })[0];
if (p) switchProject(p);
});
});
}
function open() {
render();
pop.hidden = false;
btn.setAttribute('aria-expanded', 'true');
}
function close() {
pop.hidden = true;
btn.setAttribute('aria-expanded', 'false');
}
btn.addEventListener('click', function (e) {
e.stopPropagation();
if (pop.hidden) open(); else close();
});
document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
// Refresh the label once the project list (and any active project) is known.
wrap.wpcRefresh = function () {
var c = activeProject();
var nameEl = btn.querySelector('.wpc-proj-name');
if (nameEl) nameEl.textContent = projectLabel(c);
if (!pop.hidden) render();
};
return wrap;
}
function loadProjects(switcher) {
// ProjectData.list() already hits the API and falls back to its local cache
// when offline, so there's no second request to make here.
var p;
try {
p = (window.ProjectData && ProjectData.list) ? ProjectData.list() : null;
} catch (e) { p = null; }
if (!p) {
p = fetch('/api/projects', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : []; });
}
Promise.resolve(p)
.then(function (list) { projects = Array.isArray(list) ? list : []; switcher.wpcRefresh(); })
.catch(function () {});
}
// ── global search ──────────────────────────────────────────────────────────
function buildSearch() {
var wrap = el('div', 'wpc-search');
var box = el('div', 'wpc-search-box');
box.innerHTML =
'<span class="wpc-search-ico" aria-hidden="true">⌕</span>' +
'<input class="wpc-search-input" type="search" autocomplete="off" spellcheck="false" ' +
'placeholder="Search work packages, projects, SOPs…" aria-label="Search">' +
'<button class="wpc-clear" type="button" title="Clear" hidden>✕</button>' +
'<span class="wpc-kbd">Ctrl K</span>';
var pop = el('div', 'wpc-pop');
pop.hidden = true;
wrap.appendChild(box);
wrap.appendChild(pop);
var input = box.querySelector('.wpc-search-input');
var clear = box.querySelector('.wpc-clear');
var timer = null, seq = 0, items = [], activeIx = -1;
function close() { pop.hidden = true; activeIx = -1; }
function highlight() {
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (n, i) {
n.classList.toggle('is-active', i === activeIx);
if (i === activeIx && n.scrollIntoView) n.scrollIntoView({ block: 'nearest' });
});
}
// A work package lives inside the suite's Creator tab, so open the suite on
// that project with the package requested; a SOP opens the SOP tab.
function hrefFor(hit) {
if (hit.kind === 'project') return 'work-package-suite.html?project=' + encodeURIComponent(hit.id);
if (hit.kind === 'wp') {
return 'work-package-suite.html?tab=wp&project=' + encodeURIComponent(hit.project_id || '') +
'&wp=' + encodeURIComponent(hit.id);
}
return 'work-package-suite.html?tab=sop&project=' + encodeURIComponent(hit.project_id || '');
}
function go(hit) {
if (!hit) return;
if (hit.kind === 'project') {
var p = projects.filter(function (x) { return x.id === hit.id; })[0];
if (p) { switchProject(p); return; }
}
// Set the active project only from a full record — writing a stub would
// clobber the cached project (name, number, client) other pages read. The
// ?project= param in the URL is what actually switches context.
var full = projects.filter(function (x) { return x.id === hit.project_id; })[0];
if (full) { try { if (window.ProjectData) ProjectData.setActive(full); } catch (e) {} }
location.assign(hrefFor(hit));
}
function renderResults(data) {
items = [];
var html = '';
function group(title, rows) {
if (!rows.length) return;
html += '<div class="wpc-pop-head">' + esc(title) + '</div>' + rows.join('');
}
group('Work packages', (data.wps || []).map(function (w) {
items.push({ kind: 'wp', id: w.id, project_id: w.project_id, project_name: w.project_name });
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
'<span class="wpc-item-title"><span class="wpc-item-mono">' + esc(w.number || '(unnumbered)') + '</span> ' +
esc(w.subject || '') + '</span>' +
'<span class="wpc-item-sub">' + esc([w.status, w.type, w.project_name].filter(Boolean).join(' · ')) + '</span>' +
'</button>';
}));
group('Projects', (data.projects || []).map(function (p) {
items.push({ kind: 'project', id: p.id });
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
'<span class="wpc-item-title">' + esc(p.name || '(unnamed)') + '</span>' +
'<span class="wpc-item-sub">' + esc([p.number, p.client].filter(Boolean).join(' · ') || 'project') + '</span>' +
'</button>';
}));
group('SOPs', (data.sops || []).map(function (s) {
items.push({ kind: 'sop', id: s.id, project_id: s.project_id, project_name: s.project_name });
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
'<span class="wpc-item-title">' + esc(s.name || 'SOP') + '</span>' +
'<span class="wpc-item-sub">' + esc([s.complete ? 'complete' : 'draft', s.project_name].filter(Boolean).join(' · ')) + '</span>' +
'</button>';
}));
if (!items.length) {
html = '<div class="wpc-empty">Nothing matches “' + esc(data.query || '') + '” in the projects you can access.</div>';
}
pop.innerHTML = html;
pop.hidden = false;
activeIx = items.length ? 0 : -1;
highlight();
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (n) {
n.addEventListener('click', function () { go(items[+n.getAttribute('data-ix')]); });
n.addEventListener('mouseenter', function () { activeIx = +n.getAttribute('data-ix'); highlight(); });
});
}
function run(q) {
var mine = ++seq;
fetch('/api/search?q=' + encodeURIComponent(q), { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (mine !== seq) return; // a newer keystroke already won
if (!data) { close(); return; }
renderResults(data);
})
.catch(function () {
if (mine !== seq) return;
pop.innerHTML = '<div class="wpc-empty">Search is unavailable offline.</div>';
pop.hidden = false;
});
}
input.addEventListener('input', function () {
var q = input.value.trim();
clear.hidden = !q;
clearTimeout(timer);
if (q.length < SEARCH_MIN) { close(); return; }
timer = setTimeout(function () { run(q); }, DEBOUNCE_MS);
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { close(); input.blur(); return; }
if (pop.hidden || !items.length) return;
if (e.key === 'ArrowDown') { e.preventDefault(); activeIx = (activeIx + 1) % items.length; highlight(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); activeIx = (activeIx - 1 + items.length) % items.length; highlight(); }
else if (e.key === 'Enter') { e.preventDefault(); go(items[activeIx]); }
});
input.addEventListener('focus', function () {
if (input.value.trim().length >= SEARCH_MIN && items.length) pop.hidden = false;
});
clear.addEventListener('click', function () {
input.value = ''; clear.hidden = true; close(); input.focus();
});
document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
// Ctrl/Cmd-K from anywhere focuses search (matches the tools people already
// use). Ignored while typing in another field so it can't steal a shortcut.
document.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
input.focus();
input.select();
}
});
return wrap;
}
// ── mount ──────────────────────────────────────────────────────────────────
function mount() {
if (document.querySelector('.wp-chrome')) return;
var m = findMount();
if (!m) return;
var chrome = el('div', 'wp-chrome');
if (isDark(m.host)) chrome.setAttribute('data-bar', 'dark');
var switcher = buildProjectSwitcher();
chrome.appendChild(switcher);
chrome.appendChild(buildSearch());
if (m.before) m.host.insertBefore(chrome, m.before);
else m.host.appendChild(chrome);
loadProjects(switcher);
window.wpChromeRefresh = function () { switcher.wpcRefresh(); };
}
// Wait for the auth guard: an unauthenticated page is about to redirect, and
// /api/search would 401 anyway.
if (window.WP_USER) mount();
else document.addEventListener('wp-auth-ready', mount);
})();

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Work Package (IWP) — Prime Controls</title>
<script src="auth-guard.js"></script>
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
timestamps during their own boot. -->
<script src="wp-format.js"></script>
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-creation-styles.css">
</head>
@@ -28,6 +33,7 @@
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button>
<button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="newPackage()">+ New</button>
<button class="btn btn-ghost" style="padding:7px 16px" onclick="duplicateWP()">⧉ Duplicate</button>
<button class="btn btn-ghost" style="padding:7px 16px" onclick="showHistoryCurrent()" title="Change history for this work package">🕘 History</button>
<button class="btn btn-ghost embed-hide" id="comments-btn" style="padding:7px 16px" onclick="toggleComments()">💬 Comments <span class="cbadge-total" id="cbadge-total" style="display:none">0</span></button>
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showAnalytics()">▤ Usage Data</button>
</div>
@@ -42,8 +48,78 @@
<!-- SECTION NAV (jump links, built from the form cards) -->
<div class="section-nav-bar" id="section-nav"></div>
<div class="wp-layout">
<!-- WORK PACKAGE NAVIGATOR
A persistent side panel (not a hover drawer): collapse toggle, a primary action,
icon nav, then the project's packages as rows with colour-coded initial badges.
Collapsing leaves a narrow icon rail so you can still see and switch packages. -->
<aside class="wp-nav" id="wp-nav" aria-label="Work packages">
<div class="wp-nav-top">
<button class="wp-nav-toggle" id="wp-nav-toggle" onclick="toggleWpNav()"
title="Collapse the panel" aria-label="Collapse the panel" aria-expanded="true">
<svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true">
<rect x="2.5" y="3.5" width="15" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/>
<line x1="7.5" y1="3.5" x2="7.5" y2="16.5" stroke="currentColor" stroke-width="1.4"/>
<path class="wp-nav-toggle-arrow" d="M14 10 H10 M11.6 8.2 L9.8 10 L11.6 11.8"
fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="wp-nav-primary">
<button class="wp-nav-cta" onclick="newPackage()" title="Start a new work package">
<span class="wp-nav-cta-plus" aria-hidden="true">+</span><span class="wp-nav-cta-label">New work package</span>
</button>
<button class="wp-nav-cta-more" id="wp-nav-more-btn" onclick="toggleWpNavMore(event)"
title="More actions" aria-label="More actions" aria-haspopup="true" aria-expanded="false"></button>
<div class="wp-nav-menu" id="wp-nav-more" hidden>
<button type="button" onclick="wpNavAction('duplicate')">Duplicate this package</button>
<button type="button" onclick="wpNavAction('split')">Split by discipline</button>
<button type="button" onclick="wpNavAction('export')">Export all (JSON)</button>
</div>
</div>
<nav class="wp-nav-links" aria-label="Views">
<button type="button" class="wp-nav-link" data-view="mine" onclick="setWpNavView('mine')" title="Packages you own">
<span class="wp-nav-ico" aria-hidden="true"></span><span class="wp-nav-link-label">My packages</span>
<span class="wp-nav-link-n" id="wp-nav-n-mine"></span>
</button>
<button type="button" class="wp-nav-link is-current" data-view="all" onclick="setWpNavView('all')" title="Every package on this project">
<span class="wp-nav-ico" aria-hidden="true"></span><span class="wp-nav-link-label">All packages</span>
<span class="wp-nav-link-n" id="wp-nav-n-all"></span>
</button>
<button type="button" class="wp-nav-link" data-view="open" onclick="setWpNavView('open')" title="Not release-ready yet">
<span class="wp-nav-ico" aria-hidden="true"></span><span class="wp-nav-link-label">Needs attention</span>
<span class="wp-nav-link-n" id="wp-nav-n-open"></span>
</button>
<button type="button" class="wp-nav-link" onclick="showDashboard()" title="Status and gating across the project">
<span class="wp-nav-ico" aria-hidden="true"></span><span class="wp-nav-link-label">Dashboard</span>
</button>
</nav>
<div class="wp-nav-sect">
<span class="wp-nav-sect-label" id="wp-nav-sect-label">Work packages</span>
<span class="wp-nav-count" id="wp-nav-count"></span>
</div>
<div class="wp-nav-filter">
<input type="search" class="wp-nav-search" id="wp-nav-search" placeholder="Filter packages…" oninput="renderWpNav()">
</div>
<div class="wp-nav-list" id="wp-nav-list"></div>
</aside>
<div class="main">
<!-- PACKAGE KIND (only shown when the project's SOP includes BIM/VDC) -->
<div class="card" id="kind-row" style="display:none">
<div class="sub-heading">Package Type</div>
<div class="notice">This project includes BIM/VDC packages. Choose what this one is — it tailors the fields below and the WP types / release gates offered.</div>
<div class="radio-group" id="kind-group" style="margin-bottom:0">
<label class="radio-pill" data-val="iwp"><input type="radio" name="pkgkind" onclick="setKind('iwp')"><span class="dot"></span>Install package (IWP)</label>
<label class="radio-pill" data-val="ewp"><input type="radio" name="pkgkind" onclick="setKind('ewp')"><span class="dot"></span>BIM package (EWP)</label>
</div>
</div>
<!-- GENERAL INFORMATION -->
<div class="card">
<div class="section-header"><div class="section-title">General Information</div>
@@ -74,16 +150,39 @@
<div class="field"><label>Acumatica Task</label><input type="text" id="wp_wbs" placeholder="Acumatica task no."></div>
</div>
<div class="field-grid">
<div class="field"><label>Assignees</label><input type="text" id="wp_assignees" placeholder="name (company), name (company)"></div>
<div class="field"><label>Distribution</label><input type="text" id="wp_distribution" placeholder="notify list"></div>
<div class="field"><label>Owner <span class="help-tip" data-tip="The accountable owner (a user account on this project). Assigning notifies them by email if email notifications are enabled in the admin console.">i</span></label><select id="wp_assignee"><option value="">— Unassigned —</option></select></div>
<div class="field"><label>Assignees<span class="help-tip" data-tip="The crew and staff working this package. Pick from the project team named on the SOP; anyone without a user account can still be added by name.">i</span></label>
<div class="people-pick" id="pick_assignees"></div>
<input type="hidden" id="wp_assignees"></div>
<div class="field"><label>Distribution<span class="help-tip" data-tip="Who gets notified about this package. The project's Construction Manager is included by default and can be removed per package.">i</span></label>
<div class="people-pick" id="pick_distribution"></div>
<input type="hidden" id="wp_distribution"></div>
<div class="field"><label>Due Date</label><input type="date" id="wp_due"></div>
<div class="field"><label>Specification Section</label><input type="text" id="wp_spec" placeholder="e.g. 26_05_33_00 - Raceway and Boxes"><div class="field-hint" id="spec-folder-link"></div></div>
<div class="field"><label>Specification Section</label>
<input type="text" id="wp_spec" readonly class="locked-field" placeholder="set on the WP type in the SOP">
<div class="field-hint" id="spec-folder-link"></div></div>
</div>
<div class="field field-grid col1"><div class="field"><label>Description</label><textarea id="wp_desc" rows="2" placeholder="Short summary of the package"></textarea></div></div>
<div class="field field-grid col1" id="bimlink-wrap"><div class="field"><label>Enabled by — BIM package(s)<span class="help-tip" data-tip="Advanced Work Packaging traceability: link the BIM / model package(s) that enabled this install package. Paste the MWP number(s) or a link to the model package.">i</span></label><input type="text" id="wp_bimlink" placeholder="e.g. MWP07-FAB-CONDUITS, or a link to the model package"></div></div>
</div>
<!-- BIM / MODEL DETAILS (shown for BIM/VDC SOPs) -->
<div class="card" id="bim-card" style="display:none">
<div class="sub-heading">BIM / Model Details</div>
<div class="notice">For BIM/VDC work packages — the model deliverable's level of detail, area, source scan, and coordination status.</div>
<div class="field-grid">
<div class="field"><label>Model Area / Zone</label><input type="text" id="wp_model_area" placeholder="e.g. Fab 09 Subfab — Level 2"></div>
<div class="field"><label>Clash / Coordination Status</label>
<select id="wp_clash" onchange="onClashChange()"><option value=""></option><option>Not started</option><option>In coordination</option><option>Clashes open</option><option>Clash-free</option><option>Signed off (IFF)</option></select></div>
<div class="field"><label>IFF #<span class="help-tip" data-tip="Issued-For-Fabrication/Field number — the GC sign-off reference for this model package. Required once the coordination status is Signed off (IFF).">i</span></label>
<input type="text" id="wp_iff" placeholder="e.g. IFF-2026-0142" oninput="onClashChange()">
<div class="field-hint" id="iff-hint"></div></div>
<div class="field"><label>Linked Scan / Point Cloud</label><input type="url" id="wp_scan_link" placeholder="WebShare / BIM360 / SharePoint link"></div>
</div>
</div>
<!-- ASSETS (controls.dev) -->
<div class="card">
<div class="card" id="asset-card">
<div class="sub-heading">Assets</div>
<div class="notice">Every work package is based on one or more assets managed in <strong>controls.dev</strong>. Paste the controls.dev link for each asset this package covers. <span style="color:var(--text-dim)">A direct integration to pick assets from a list is planned — for now, link them manually.</span></div>
<div class="table-wrap"><table><thead><tr><th style="width:200px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link <span class="req">*</span></th><th style="width:44px"></th></tr></thead><tbody id="asset-body"></tbody></table></div>
@@ -111,12 +210,16 @@
<button class="btn btn-ghost" id="split-disc-btn" style="display:none;margin-top:10px" onclick="splitByDiscipline()" title="Break this multi-discipline package into one numbered instance per discipline">⎘ Split by Discipline</button>
<div class="field-grid" style="margin-top:14px">
<div class="field"><label>Labor Est. Hrs.</label><input type="number" id="wp_hours" min="0" step="1" placeholder="e.g. 20" oninput="onHoursChange()"><div class="field-hint" id="size-check"></div></div>
<div class="field"><label>Package Predecessor</label><select id="wp_seq"></select><div class="field-hint">The package/step (from the SOP sequence) that must finish before this work can start. Choose "None" if it has no predecessor.</div></div>
<div class="field"><label>Predecessor work packages<span class="help-tip" data-tip="The packages that must be Closed before this one can be released. A package with an open predecessor is not release-ready — you can still release it, but the override is logged.">i</span></label>
<div class="people-pick" id="pick_predecessors"></div>
<div class="field-hint" id="pred-hint"></div></div>
<div class="field"><label>Sequence phase <span class="help-tip" data-tip="Which phase of the SOP's construction sequence this package belongs to. Descriptive — it does not gate release; predecessor packages do.">i</span></label>
<select id="wp_seq"></select><div class="field-hint sop-hint">from the SOP construction sequence</div></div>
</div>
</div>
<!-- MATERIAL LIST -->
<div class="card">
<div class="card" id="material-card">
<div class="sub-heading">Material List<span class="help-tip" data-tip="Bill of materials — feeds kitting. On a multi-discipline package each line can be tagged to a discipline so a split routes each instance only its own materials. Import from CSV is supported.">i</span></div>
<div class="notice">Structured bill of materials. Feeds kitting and the delivery forecast. Unit is from the Acumatica unit list.</div>
<div class="table-wrap"><table><thead><tr><th style="width:90px">Qty</th><th style="width:120px">Unit</th><th>Description</th><th id="mat-disc-th" style="width:140px;display:none">Discipline</th><th style="width:44px"></th></tr></thead><tbody id="material-body"></tbody></table></div>
@@ -147,7 +250,7 @@
</div>
<!-- KITTING & MIMO -->
<div class="card">
<div class="card" id="mimo-card">
<div class="sub-heading">Kitting & Material Movement (MIMO)</div>
<div class="field-grid">
<div class="field"><label>Kitting Status</label>
@@ -239,6 +342,7 @@
</div>
</div>
</div>
<!-- HOLD LOG MODAL (comment 7) -->
<div class="modal-overlay" id="hold-modal">

View File

@@ -7,25 +7,25 @@
body.embedded .embed-first { margin-left: auto; }
:root {
--bg: #f4f5f7;
--bg: #f4f4f4;
--surface: #ffffff;
--surface2: #f7f8fa;
--border: #e3e6ec;
--border-strong: #d0d5de;
--text: #1a2230;
--text-muted: #5a6675;
--text-dim: #9aa3b2;
--accent: #2563d6;
--accent-dim: #e8f0fe;
--accent-green: #15924f;
--accent-green-dim: #e4f6ec;
--accent-amber: #b87100;
--accent-amber-dim: #fdf2e0;
--red: #cf3b3b;
--red-dim: #fbeaea;
--radius: 5px;
--shadow: 0 1px 2px rgba(20,30,50,.04), 0 1px 3px rgba(20,30,50,.06);
--shadow-lg: 0 4px 16px rgba(20,30,50,.08);
--surface2: #f4f4f4;
--border: #e0e0e0;
--border-strong: #8d8d8d;
--text: #161616;
--text-muted: #525252;
--text-dim: #8d8d8d;
--accent: #0f62fe;
--accent-dim: #edf5ff;
--accent-green: #198038;
--accent-green-dim: #defbe6;
--accent-amber: #8e6a00;
--accent-amber-dim: #fdf6dd;
--red: #da1e28;
--red-dim: #fff1f1;
--radius: 0;
--shadow: none;
--shadow-lg: 0 4px 16px rgba(20,30,50,.12);
--mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', Consolas, monospace;
--sans: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
}
@@ -99,8 +99,15 @@
.step-tab.done { color: var(--accent-green); background: var(--accent-green-dim); }
.step-num { display: block; font-size: 9px; opacity: .65; margin-bottom: 2px; }
/* ── MAIN ── */
.main { max-width: 1000px; margin: 0 auto; padding: 28px 32px 64px; }
/* ── MAIN ──
The form uses the full width it's given. The work-package navigator is an
auto-hiding overlay drawer (see below) rather than a column, so it never takes
width away from the form — which matters most when this page is embedded in the
suite's tab and every pixel is shared with the app chrome. */
.wp-layout { display: block; width: 100%; margin: 0; }
.main { min-width: 0; max-width: none; margin: 0;
padding: 22px 28px 72px calc(var(--nav-w,288px) + 28px);
transition: padding-left .18s ease; }
.section { display: none; }
.section.active { display: block; animation: fade .25s ease; }
@@ -126,6 +133,11 @@
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-bottom: 18px; }
.field-grid.col3 { grid-template-columns: 1fr 1fr 1fr; }
.field-grid.col1 { grid-template-columns: 1fr; }
/* On a wide screen let the two-up grids flow into 34 columns instead of
stretching two fields across the whole card. */
@media (min-width: 1200px) {
.field-grid:not(.col1):not(.col3) { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
}
.field { display: flex; flex-direction: column; gap: 6px; }
.field.span2 { grid-column: span 2; }
@@ -213,7 +225,7 @@
.notice {
background: var(--accent-dim); border: 1px solid #b9d2fb; border-radius: var(--radius);
padding: 10px 14px; font-size: 12px; color: #1a4fad; margin-bottom: 18px; font-family: var(--mono);
padding: 10px 14px; font-size: 12px; color: #0043ce; margin-bottom: 18px; font-family: var(--mono);
}
/* ── DELIVERABLES ── */
@@ -245,9 +257,9 @@
.btn-ghost { background: var(--surface); border-color: var(--border-strong); color: var(--text-muted); }
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: var(--shadow); }
.btn-primary:hover { background: #1d52b8; }
.btn-primary:hover { background: #0353e9; }
.btn-generate { background: var(--accent-green); border-color: var(--accent-green); color: #fff; font-weight: 700; box-shadow: var(--shadow); }
.btn-generate:hover { background: #117a42; }
.btn-generate:hover { background: #0e6027; }
/* ── OUTPUT ── */
#output-section { display: none; }
@@ -419,7 +431,7 @@
.ov-select.ov-unset { color:var(--red) !important; border-color:var(--red); }
.use-btn { display:inline-block; margin-left:8px; padding:4px 14px; font-family:var(--sans); font-size:11px; font-weight:700;
color:#fff; background:var(--accent-green); border:none; border-radius:var(--radius); cursor:pointer; letter-spacing:.03em; }
.use-btn:hover { background:#0f7a40; }
.use-btn:hover { background:#0e6027; }
.sum-chips { display:flex; flex-wrap:wrap; gap:7px; }
.sum-chip { background:var(--accent-dim); color:var(--accent); border:1px solid #b9d2fb; border-radius:3px;
padding:3px 10px; font-family:var(--mono); font-size:10px; }
@@ -427,7 +439,7 @@
border-radius:var(--radius); padding:7px 10px; font-size:11px; }
/* ── CREATION TOOL ───────────────────────────────────────────────── */
.ctx-bar { max-width:1080px; margin:0 auto; padding:12px 28px; display:flex; align-items:center; gap:20px;
.ctx-bar { max-width:none; margin:0; padding:12px 28px 12px calc(var(--nav-w,288px) + 28px); display:flex; align-items:center; gap:20px;
border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; }
.ctx-empty { color:var(--text-muted); font-size:13px; }
.ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; }
@@ -439,7 +451,7 @@
.ctx-meta code { background:var(--surface2); padding:1px 6px; border-radius:3px; color:var(--accent); }
.link-btn { background:none; border:none; color:var(--accent); cursor:pointer; font-size:inherit; padding:0; text-decoration:underline; }
.mode-wrap { max-width:1080px; margin:0 auto; padding:16px 28px 0; display:flex; align-items:center; gap:16px; }
.mode-wrap { max-width:none; margin:0; padding:16px 28px 0; display:flex; align-items:center; gap:16px; }
.mode-toggle { display:inline-flex; border:1px solid var(--border-strong); border-radius:6px; overflow:hidden; }
.mode-btn { padding:8px 18px; font-family:var(--sans); font-size:13px; font-weight:600; border:none; background:var(--surface);
color:var(--text-muted); cursor:pointer; }
@@ -463,7 +475,7 @@
/* ── WORK PACKAGE FORM ───────────────────────────────────────────── */
.sop-hint { color:var(--accent) !important; }
.release-banner { max-width:1080px; margin:0 auto; padding:0 28px; }
.release-banner { max-width:none; margin:0; padding:0 28px 0 calc(var(--nav-w,288px) + 28px); }
.release-banner .rb-inner { margin-top:14px; border-radius:var(--radius); padding:11px 16px; font-size:13px; font-weight:600;
display:flex; align-items:center; gap:10px; }
.rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid #b6e3c6; }
@@ -495,7 +507,7 @@
.modal-overlay { position:fixed; inset:0; background:rgba(20,28,40,.55); display:none; align-items:center; justify-content:center; z-index:9000; padding:20px; }
.modal-overlay.open { display:flex; }
.modal { background:var(--surface); border-radius:12px; width:100%; max-width:520px; box-shadow:0 20px 60px rgba(0,0,0,.3); overflow:hidden; max-height:90vh; display:flex; flex-direction:column; }
.modal { background:var(--surface); border-radius:0; width:100%; max-width:520px; box-shadow:0 20px 60px rgba(0,0,0,.3); overflow:hidden; max-height:90vh; display:flex; flex-direction:column; }
.modal-head { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border); }
.modal-title { font-weight:700; font-size:15px; color:var(--text); }
.modal-body { padding:18px 20px; overflow-y:auto; }
@@ -571,15 +583,231 @@
/* Section nav (jump chips) */
.section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px;
padding:8px 12px; background:rgba(255,255,255,.92); backdrop-filter:blur(4px);
border-bottom:1px solid var(--border); }
padding:8px 12px 8px calc(var(--nav-w,288px) + 28px); background:rgba(255,255,255,.94); backdrop-filter:blur(4px);
border-bottom:1px solid var(--border); box-shadow:0 1px 4px rgba(20,30,50,.06);
transition:transform .22s ease; }
.section-nav-bar:empty{ display:none; }
.section-nav-bar.nav-hidden{ transform:translateY(-160%); }
.sec-chip{ font-size:12px; font-weight:600; color:var(--text-muted); background:var(--surface2);
border:1px solid var(--border); border-radius:14px; padding:4px 11px; cursor:pointer; white-space:nowrap; }
.sec-chip:hover{ border-color:var(--accent); color:var(--accent); }
/* ── SOP-inherited marker ───────────────────────────────────────────────────
The "from SOP types" subtext used to sit under the field. It's now a small
chip on the label with the detail in a hover tooltip (site comment 8/3).
The chip stays VISIBLE rather than hover-only: on a field tablet there is no
hover, and "this value came from the SOP" is the part people need to see. */
.field-hint.sop-hint { display: none; }
.sop-chip { display:inline-block; margin-left:6px; padding:0 6px; border-radius:9px;
background:var(--accent-dim); color:var(--accent); border:1px solid #b9d2fb;
font-size:9.5px; font-weight:700; letter-spacing:.04em; text-transform:uppercase;
vertical-align:middle; cursor:help; position:relative; }
.sop-chip::after { content:attr(data-tip); position:absolute; bottom:135%; left:50%;
transform:translateX(-50%); background:#161616; color:#fff; padding:7px 10px; font-size:12px;
font-weight:400; letter-spacing:0; text-transform:none; line-height:1.4; white-space:normal;
width:max-content; max-width:260px; text-align:left; z-index:9999; opacity:0;
pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); }
.sop-chip::before { content:''; position:absolute; bottom:135%; left:50%;
transform:translate(-50%,95%); border:5px solid transparent; border-top-color:#161616;
opacity:0; transition:opacity .12s; z-index:9999; }
.sop-chip:hover::after, .sop-chip:hover::before,
.sop-chip:focus::after, .sop-chip:focus::before { opacity:1; }
/* ── people picker (Assignees / Distribution / Predecessors) ─────────────────
Multi-select over the SOP project team instead of a free-text list. */
.people-pick { border:1px solid var(--border-strong); border-radius:4px; background:var(--surface);
padding:5px 6px; min-height:38px; display:flex; flex-wrap:wrap; gap:5px; align-items:center; }
.people-pick:focus-within { outline:2px solid var(--accent); outline-offset:-2px; }
.pp-chip { display:inline-flex; align-items:center; gap:5px; padding:2px 6px 2px 8px;
background:var(--surface2); border:1px solid var(--border); border-radius:12px;
font-size:12px; max-width:100%; }
.pp-chip.pp-locked { background:var(--accent-dim); border-color:#b9d2fb; color:var(--accent); }
.pp-chip .pp-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pp-chip .pp-x { background:none; border:0; cursor:pointer; color:var(--text-muted);
font-size:12px; line-height:1; padding:0 1px; }
.pp-chip .pp-x:hover { color:var(--red); }
.pp-add { position:relative; }
.pp-add-btn { background:none; border:1px dashed var(--border-strong); border-radius:12px;
color:var(--text-muted); font:inherit; font-size:12px; padding:2px 9px; cursor:pointer; }
.pp-add-btn:hover { border-color:var(--accent); color:var(--accent); }
.pp-menu { position:absolute; top:calc(100% + 4px); left:0; z-index:60; min-width:270px;
max-height:300px; overflow-y:auto; background:var(--surface); border:1px solid var(--border-strong);
box-shadow:0 8px 24px rgba(20,30,50,.18); border-radius:4px; padding:6px 0; }
.pp-menu[hidden] { display:none; }
.pp-group { font-size:9.5px; font-weight:700; letter-spacing:.07em; text-transform:uppercase;
color:var(--text-dim); padding:7px 10px 3px; }
.pp-opt { display:flex; align-items:center; gap:8px; padding:5px 10px; font-size:13px; cursor:pointer; }
.pp-opt:hover { background:var(--surface2); }
.pp-opt input { width:15px; height:15px; cursor:pointer; }
.pp-opt .pp-role { color:var(--text-dim); font-size:11.5px; }
.pp-free { border-top:1px solid var(--border); margin-top:5px; padding:7px 10px 3px; }
.pp-free input { width:100%; padding:5px 7px; font:inherit; font-size:12.5px;
border:1px solid var(--border); border-radius:3px; }
.pp-free .field-hint { margin-top:4px; }
/* Critical constraint marker (from the SOP) */
.crit-tag { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px; font-size:10px;
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);
border:1px solid #ffc4c4; white-space:nowrap; vertical-align:middle; }
/* -- WORK PACKAGE NAVIGATOR ------------------------------------------------
A persistent side panel in the spirit of MS Planner: collapse toggle, one
primary action, icon nav, then the packages as rows with colour-coded initial
badges and a highlighted current row. It sits IN the layout (the form shifts
across) rather than hovering over the content, and collapses to a 56px icon
rail so you can still see and switch packages with it closed. */
.wp-nav {
position: fixed;
top: var(--rail-top, 48px);
left: 0;
bottom: 0;
width: var(--nav-w, 288px);
z-index: 120;
display: flex;
flex-direction: column;
background: #fbfbfc;
border-right: 1px solid var(--border);
overflow: hidden;
transition: width .16s ease;
}
body { --nav-w: 288px; }
body.wp-nav-collapsed { --nav-w: 56px; }
/* -- collapse toggle -- */
.wp-nav-top { display: flex; align-items: center; padding: 8px 10px 2px; }
.wp-nav-toggle {
display: inline-flex; align-items: center; justify-content: center;
width: 34px; height: 34px; padding: 0;
background: transparent; border: 1px solid transparent; border-radius: 5px;
color: var(--text-muted); cursor: pointer;
}
.wp-nav-toggle:hover { background: #eef0f3; color: var(--text); }
/* Arrow flips to point right when the panel is closed. */
body.wp-nav-collapsed .wp-nav-toggle-arrow { transform: rotate(180deg); transform-origin: 11px 10px; }
/* -- primary action -- */
.wp-nav-primary { position: relative; display: flex; gap: 2px; padding: 6px 10px 12px; }
.wp-nav-cta {
flex: 1 1 auto; min-width: 0;
display: inline-flex; align-items: center; justify-content: flex-start; gap: 9px;
height: 40px; padding: 0 14px;
background: var(--accent); color: #fff;
border: 0; border-radius: 6px 0 0 6px;
font: inherit; font-size: 14px; font-weight: 600;
cursor: pointer; white-space: nowrap;
}
.wp-nav-cta:hover { background: #0353e9; }
.wp-nav-cta-plus { font-size: 17px; font-weight: 400; line-height: 1; }
.wp-nav-cta-more {
flex: 0 0 auto; width: 30px; height: 40px;
background: var(--accent); color: #fff; border: 0; border-left: 1px solid rgba(255,255,255,.28);
border-radius: 0 6px 6px 0; font: inherit; font-size: 12px; cursor: pointer;
}
.wp-nav-cta-more:hover { background: #0353e9; }
.wp-nav-menu {
position: absolute; top: calc(100% - 6px); left: 10px; right: 10px; z-index: 10;
background: var(--surface); border: 1px solid var(--border-strong); border-radius: 6px;
box-shadow: 0 10px 26px rgba(20,30,50,.18); padding: 5px 0;
}
.wp-nav-menu[hidden] { display: none; }
.wp-nav-menu button {
display: block; width: 100%; text-align: left; background: none; border: 0;
padding: 8px 12px; font: inherit; font-size: 13px; color: var(--text); cursor: pointer;
}
.wp-nav-menu button:hover { background: var(--surface2); }
/* -- icon nav -- */
.wp-nav-links { padding: 2px 8px 10px; display: flex; flex-direction: column; gap: 1px; }
.wp-nav-link {
display: flex; align-items: center; gap: 12px;
width: 100%; padding: 9px 10px;
background: none; border: 0; border-radius: 6px;
font: inherit; font-size: 14px; color: var(--text);
cursor: pointer; text-align: left; white-space: nowrap;
}
.wp-nav-link:hover { background: #eef0f3; }
.wp-nav-link.is-current { background: #e8eaed; font-weight: 600; }
.wp-nav-ico { flex: 0 0 20px; width: 20px; text-align: center; font-size: 15px; color: var(--text-muted); }
.wp-nav-link-label { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; }
.wp-nav-link-n { flex: 0 0 auto; font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
/* -- section header + filter -- */
.wp-nav-sect {
display: flex; align-items: baseline; gap: 6px;
padding: 6px 18px 4px; border-top: 1px solid var(--border);
}
.wp-nav-sect-label { font-size: 12px; color: var(--text-muted); }
.wp-nav-count { font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
.wp-nav-filter { padding: 4px 12px 8px; }
.wp-nav-search {
width: 100%; padding: 7px 10px; font: inherit; font-size: 13px;
border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text);
}
.wp-nav-search:focus { outline: none; border-color: var(--accent); }
/* -- package rows -- */
.wp-nav-list { flex: 1 1 auto; overflow-y: auto; overflow-x: hidden; padding: 0 8px 14px; }
.wp-nav-group {
font-size: 11px; font-weight: 600; letter-spacing: .02em;
color: var(--text-dim); padding: 12px 10px 4px;
}
.wp-nav-item {
position: relative;
display: flex; align-items: center; gap: 11px;
width: 100%; padding: 7px 10px; margin-bottom: 1px;
background: none; border: 0; border-radius: 6px;
font: inherit; color: var(--text); text-align: left; cursor: pointer;
}
.wp-nav-item:hover { background: #eef0f3; }
.wp-nav-item.active { background: #e8eaed; }
/* Left accent bar on the current package, like Planner's selected plan. */
.wp-nav-item.active::before {
content: ''; position: absolute; left: 0; top: 6px; bottom: 6px;
width: 3px; border-radius: 2px; background: var(--accent);
}
.wp-nav-badge {
flex: 0 0 28px; width: 28px; height: 28px; border-radius: 5px;
display: inline-flex; align-items: center; justify-content: center;
font-family: var(--sans); font-size: 11px; font-weight: 700; letter-spacing: .02em;
color: #fff; text-transform: uppercase;
}
.wp-nav-body { min-width: 0; flex: 1 1 auto; }
.wp-nav-num { display: block; font-size: 13.5px; font-weight: 600; color: var(--text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wp-nav-subj { display: block; font-size: 12px; color: var(--text-muted); line-height: 1.35;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wp-nav-state { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 5px;
font-size: 10.5px; color: var(--text-dim); white-space: nowrap; }
.wp-nav-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-dim); flex: 0 0 auto; }
.wp-nav-dot.ok { background: var(--accent-green); }
.wp-nav-dot.open { background: var(--accent-amber); }
.wp-nav-dot.hold { background: var(--red); }
.wp-nav-empty { padding: 14px 10px; font-size: 12.5px; color: var(--text-dim); }
/* -- collapsed rail: badges and icons only -- */
body.wp-nav-collapsed .wp-nav-cta-label,
body.wp-nav-collapsed .wp-nav-cta-more,
body.wp-nav-collapsed .wp-nav-link-label,
body.wp-nav-collapsed .wp-nav-link-n,
body.wp-nav-collapsed .wp-nav-sect,
body.wp-nav-collapsed .wp-nav-filter,
body.wp-nav-collapsed .wp-nav-body,
body.wp-nav-collapsed .wp-nav-state,
body.wp-nav-collapsed .wp-nav-group { display: none; }
body.wp-nav-collapsed .wp-nav-primary { padding: 6px 10px 10px; }
body.wp-nav-collapsed .wp-nav-cta { justify-content: center; padding: 0; border-radius: 6px; }
body.wp-nav-collapsed .wp-nav-link { justify-content: center; padding: 9px 0; }
body.wp-nav-collapsed .wp-nav-item { justify-content: center; padding: 6px 0; }
body.wp-nav-collapsed .wp-nav-list { padding: 6px 6px 14px; }
/* Narrow screens: keep the rail collapsed-width so the form still has room. */
@media (max-width: 860px) {
body { --nav-w: 56px; }
body:not(.wp-nav-collapsed) .wp-nav { width: 288px; box-shadow: 6px 0 22px rgba(20,30,50,.16); }
}
/* Sticky save bar */
.sticky-save{ position:fixed; left:0; right:0; bottom:0; z-index:40; display:flex; align-items:center;
.sticky-save{ position:fixed; left:var(--nav-w,288px); right:0; bottom:0; z-index:40; display:flex; align-items:center;
justify-content:space-between; gap:14px; padding:10px 20px; background:#fff;
border-top:1px solid var(--border-strong); box-shadow:0 -2px 10px rgba(20,30,50,.08); }
.sticky-save .sticky-status{ font-size:13px; font-weight:600; }
@@ -606,11 +834,12 @@
/* Dashboard */
.dash-metrics { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:12px; margin-bottom:16px; }
.dash-metric { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:14px 16px; text-align:center; }
.dash-metric { background:var(--surface); border:1px solid var(--border); border-radius:0; padding:14px 16px; text-align:center; }
.dash-metric .dm-val { font-size:26px; font-weight:800; line-height:1; }
.dash-metric .dm-label { font-size:11px; color:var(--text-muted); margin-top:6px; text-transform:uppercase; letter-spacing:.03em; }
.dash-metric.dm-green .dm-val { color:var(--accent-green); }
.dash-metric.dm-red .dm-val { color:var(--red); }
.dash-metric.dm-blue .dm-val { color:var(--accent, #0f62fe); }
.dash-metric[onclick] { cursor:pointer; transition:border-color .12s, box-shadow .12s; }
.dash-metric[onclick]:hover { border-color:var(--accent); }
.dash-metric.dm-active { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-dim); }
@@ -620,7 +849,7 @@
.dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; }
.dash-chip { display:inline-block; font-size:12px; background:var(--surface2); border:1px solid var(--border); border-radius:14px; padding:3px 10px; margin:0 6px 6px 0; }
.dash-chip.chip-red { background:var(--red-dim); color:var(--red); border-color:var(--red); }
.dash-panel { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:14px 16px; margin-bottom:16px; }
.dash-panel { background:var(--surface); border:1px solid var(--border); border-radius:0; padding:14px 16px; margin-bottom:16px; }
.dash-panel-title { font-weight:700; font-size:13px; margin-bottom:10px; }
.dash-table { width:100%; border-collapse:collapse; font-size:12.5px; }
.dash-table th { text-align:left; background:var(--surface2); border-bottom:1px solid var(--border); padding:6px 8px; font-size:11px; text-transform:uppercase; color:var(--text-muted); }
@@ -629,3 +858,27 @@
.dash-filters input, .dash-filters select { padding:7px 10px; border:1px solid var(--border-strong); border-radius:6px; font-size:13px; }
.dash-filters input[type=search] { flex:1; min-width:200px; }
@media (max-width:640px){ .dash-breakdown { grid-template-columns:1fr; } }
/* ── WP history (audit trail) modal ──────────────────────────────────────── */
.hist-list { display:flex; flex-direction:column; }
.hist-item { display:grid; grid-template-columns:170px 1fr auto; gap:12px; align-items:baseline;
padding:9px 2px; border-bottom:1px solid var(--border); }
.hist-item:last-child { border-bottom:none; }
.hist-when { font-family:var(--mono); font-size:11px; color:var(--text-muted); white-space:nowrap; }
.hist-action { font-weight:600; color:var(--text); }
.hist-detail { color:var(--accent); font-size:13px; }
.hist-actor { font-size:12px; color:var(--text-muted); white-space:nowrap; }
@media (max-width:560px){ .hist-item { grid-template-columns:1fr; gap:2px; } }
/* ── Dashboard progress bars + pager + archived toggle (Phase 2) ──────────── */
.prog-row { display:grid; grid-template-columns:150px 1fr 96px; gap:10px; align-items:center; margin-bottom:7px; }
.prog-name { font-size:12.5px; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.prog-bar { height:10px; background:var(--surface2); border:1px solid var(--border); overflow:hidden; }
.prog-fill { height:100%; background:var(--accent); transition:width .3s ease; }
.prog-pct { font-size:12px; font-weight:600; color:var(--text); text-align:right; white-space:nowrap; }
.prog-sub { font-weight:400; color:var(--text-muted); font-size:11px; }
.dash-arch-toggle { display:inline-flex; align-items:center; gap:6px; font-size:13px; color:var(--text-muted); white-space:nowrap; cursor:pointer; }
.dash-pager { display:flex; align-items:center; gap:12px; margin-top:12px; font-size:12.5px; color:var(--text-muted); }
.dash-pager-btns { margin-left:auto; display:flex; gap:8px; }
.dash-pager .btn { padding:5px 12px; }
@media (max-width:560px){ .prog-row { grid-template-columns:110px 1fr 74px; } }

232
html/wp-format.js Normal file
View File

@@ -0,0 +1,232 @@
/* Localization + time formatting for the Work Package Suite.
Every date the app shows should agree, wherever it's rendered. Three sources,
most specific first:
1. the signed-in user's own preference (users.locale / users.timezone)
2. the app default set by an admin (Admin console → Localization)
3. the browser's own locale / timezone (the previous behaviour)
Why store it server-side: on a shared field tablet the browser's locale isn't
the person's, and a package due date that reads a day early because the device
sits in another zone is a real scheduling problem — not a cosmetic one.
Exposes:
wpFormatDate(v) → 3 Aug 2026 (date only)
wpFormatDateTime(v) → 3 Aug 2026, 14:07 (date + time)
wpFormatTime(v) → 14:07
wpFormatNumber(v) → locale-grouped number
wpTimeZoneLabel() → the zone in effect, for a UI hint
wpPreferences() → opens the preferences dialog
All formatters take an ISO string, Date, or epoch ms, and return '' for empty
input (never 'Invalid Date'), so they're safe to drop into a template. */
(function () {
'use strict';
function prefs() {
var u = window.WP_USER || {};
var f = window.WP_FLAGS || {};
return {
locale: (u.locale || f.default_locale || '') || undefined,
timezone: (u.timezone || f.default_timezone || '') || undefined
};
}
// A date-only value ('2026-08-03') is a calendar date, not an instant. Parsed as
// UTC midnight by the platform, it can render as the previous day in a western
// zone — so format these from their parts and never apply a timezone.
var DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
function toDate(v) {
if (v == null || v === '') return null;
if (v instanceof Date) return isNaN(v.getTime()) ? null : v;
if (typeof v === 'number') { var n = new Date(v); return isNaN(n.getTime()) ? null : n; }
var s = String(v).trim();
if (!s) return null;
var d = new Date(s);
return isNaN(d.getTime()) ? null : d;
}
function fmt(v, opts, forceNoTz) {
var s = (typeof v === 'string') ? v.trim() : v;
var dateOnly = (typeof s === 'string') && DATE_ONLY.test(s);
var d = dateOnly ? new Date(s + 'T12:00:00') : toDate(s); // noon: immune to ±12h shifts
if (!d) return '';
var p = prefs();
var o = {};
for (var k in opts) if (Object.prototype.hasOwnProperty.call(opts, k)) o[k] = opts[k];
if (p.timezone && !dateOnly && !forceNoTz) o.timeZone = p.timezone;
try {
return new Intl.DateTimeFormat(p.locale, o).format(d);
} catch (e) {
// Bad locale/zone (e.g. a preference set before tzdata was available):
// fall back to the platform default rather than showing nothing.
try { return new Intl.DateTimeFormat(undefined, opts).format(d); } catch (e2) { return String(v); }
}
}
window.wpFormatDate = function (v) {
return fmt(v, { year: 'numeric', month: 'short', day: 'numeric' });
};
window.wpFormatDateTime = function (v) {
return fmt(v, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
window.wpFormatTime = function (v) {
return fmt(v, { hour: '2-digit', minute: '2-digit' });
};
window.wpFormatNumber = function (v, opts) {
if (v == null || v === '' || isNaN(+v)) return '';
try { return new Intl.NumberFormat(prefs().locale, opts || {}).format(+v); }
catch (e) { return String(v); }
};
window.wpTimeZoneLabel = function () {
var p = prefs();
if (p.timezone) return p.timezone;
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'browser default'; }
catch (e) { return 'browser default'; }
};
window.wpLocaleLabel = function () {
var p = prefs();
if (p.locale) return p.locale;
try { return Intl.DateTimeFormat().resolvedOptions().locale || 'browser default'; }
catch (e) { return 'browser default'; }
};
// ── preferences dialog ─────────────────────────────────────────────────────
var COMMON_LOCALES = [
['', 'Browser default'],
['en-US', 'English (United States) — 8/3/2026, 2:07 PM'],
['en-GB', 'English (United Kingdom) — 03/08/2026, 14:07'],
['en-CA', 'English (Canada)'],
['es-MX', 'Español (México)'],
['es-US', 'Español (Estados Unidos)'],
['fr-CA', 'Français (Canada)'],
['de-DE', 'Deutsch (Deutschland)'],
['ja-JP', '日本語 (日本)'],
['ko-KR', '한국어 (대한민국)'],
['zh-TW', '中文 (台灣)']
];
// Zones the fabs and offices actually sit in, offered before the full list.
var COMMON_ZONES = [
'America/Chicago', 'America/New_York', 'America/Denver', 'America/Phoenix',
'America/Los_Angeles', 'America/Boise', 'Asia/Tokyo', 'Asia/Taipei',
'Asia/Seoul', 'Asia/Singapore', 'Europe/Dublin', 'Europe/London', 'UTC'
];
window.wpPreferences = function () {
if (document.getElementById('wp-prefs-modal')) return;
var u = window.WP_USER || {};
var ov = document.createElement('div');
ov.id = 'wp-prefs-modal';
ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' +
'justify-content:center;z-index:10002;padding:20px;font:14px/1.45 "IBM Plex Sans",-apple-system,' +
'BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
var fld = 'width:100%;padding:9px 10px;margin-bottom:4px;border:1px solid #8d8d8d;border-radius:4px;font-size:14px;background:#fff;';
var lbl = 'display:block;font-size:12px;color:#525252;margin:14px 0 4px;font-weight:600;';
var hint = 'font-size:11.5px;color:#6f6f6f;margin-bottom:6px;';
ov.innerHTML =
'<div style="background:#fff;color:#161616;border-radius:10px;max-width:460px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
'<div style="padding:14px 18px;border-bottom:1px solid #e0e0e0;font-weight:700;">Language &amp; time</div>' +
'<div style="padding:4px 18px 16px;">' +
'<div id="wp-prefs-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin:12px 0 0;"></div>' +
'<label style="' + lbl + '">Language &amp; number format</label>' +
'<select id="wp-prefs-locale" style="' + fld + '"></select>' +
'<div style="' + hint + '">Sets how dates and numbers are written. It does not translate the app.</div>' +
'<label style="' + lbl + '">Time zone</label>' +
'<select id="wp-prefs-tz" style="' + fld + '"></select>' +
'<div style="' + hint + '">Times (MIMO windows, history, notifications) are shown in this zone. ' +
'Calendar dates like a due date are never shifted.</div>' +
'<div id="wp-prefs-preview" style="margin-top:14px;padding:10px 12px;background:#f4f4f4;border-radius:6px;font-size:12.5px;"></div>' +
'</div>' +
'<div style="padding:12px 18px;border-top:1px solid #e0e0e0;display:flex;gap:8px;justify-content:flex-end;">' +
'<button type="button" id="wp-prefs-cancel" style="padding:8px 14px;border:1px solid #8d8d8d;background:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
'<button type="button" id="wp-prefs-save" style="padding:8px 14px;border:none;background:#0f62fe;color:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Save</button>' +
'</div>' +
'</div>';
function close() { var m = document.getElementById('wp-prefs-modal'); if (m) m.remove(); }
function msg(text, ok) {
var e = document.getElementById('wp-prefs-msg');
e.style.display = 'block'; e.textContent = text;
e.style.background = ok ? '#defbe6' : '#fff1f1';
e.style.color = ok ? '#0e6027' : '#da1e28';
}
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
document.body.appendChild(ov);
var locSel = document.getElementById('wp-prefs-locale');
var tzSel = document.getElementById('wp-prefs-tz');
var preview = document.getElementById('wp-prefs-preview');
locSel.innerHTML = COMMON_LOCALES.map(function (p) {
return '<option value="' + p[0] + '"' + (p[0] === (u.locale || '') ? ' selected' : '') + '>' + p[1] + '</option>';
}).join('');
// A stored locale that isn't in the shortlist stays selectable.
if (u.locale && !COMMON_LOCALES.some(function (p) { return p[0] === u.locale; })) {
locSel.add(new Option(u.locale, u.locale, true, true));
}
function fillZones(all) {
var cur = u.timezone || '';
var browser = '';
try { browser = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {}
var html = '<option value=""' + (cur ? '' : ' selected') + '>Browser default' +
(browser ? ' (' + browser + ')' : '') + '</option>';
html += '<optgroup label="Common">' + COMMON_ZONES.map(function (z) {
return '<option value="' + z + '"' + (z === cur ? ' selected' : '') + '>' + z + '</option>';
}).join('') + '</optgroup>';
var rest = (all || []).filter(function (z) { return COMMON_ZONES.indexOf(z) < 0; });
if (rest.length) {
html += '<optgroup label="All time zones">' + rest.map(function (z) {
return '<option value="' + z + '"' + (z === cur ? ' selected' : '') + '>' + z + '</option>';
}).join('') + '</optgroup>';
} else if (cur && COMMON_ZONES.indexOf(cur) < 0) {
html += '<option value="' + cur + '" selected>' + cur + '</option>';
}
tzSel.innerHTML = html;
updatePreview();
}
// Preview uses the picked values, not the saved ones, so the effect is visible
// before committing.
function updatePreview() {
var l = locSel.value || undefined, z = tzSel.value || undefined;
var now = new Date();
var out;
try {
out = new Intl.DateTimeFormat(l, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit', timeZone: z
}).format(now);
} catch (e) { out = 'Not supported by this browser'; }
preview.innerHTML = '<strong>Preview</strong><br>Right now: ' +
String(out).replace(/[<>]/g, '') +
'<br>A due date (2026-08-03) always reads: ' + window.wpFormatDate('2026-08-03');
}
locSel.addEventListener('change', updatePreview);
tzSel.addEventListener('change', updatePreview);
// The picker offers exactly what the server will accept.
fetch('/api/timezones', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : []; })
.then(fillZones)
.catch(function () { fillZones([]); });
document.getElementById('wp-prefs-cancel').onclick = close;
document.getElementById('wp-prefs-save').onclick = function () {
var body = { locale: locSel.value || '', timezone: tzSel.value || '' };
fetch('/api/auth/preferences', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
})
.then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
.then(function (res) {
if (!res.ok) { msg((res.j && res.j.detail) || ('Could not save (HTTP ' + res.status + ').'), false); return; }
if (res.j && res.j.user) window.WP_USER = res.j.user;
try { localStorage.setItem('wp_auth_cache', JSON.stringify({ user: window.WP_USER, at: Date.now() })); } catch (e) {}
msg('Saved. Reloading so every date on the page agrees…', true);
// Dates are formatted at render time all over the app; a reload is the
// honest way to apply the change everywhere at once.
setTimeout(function () { location.reload(); }, 700);
})
.catch(function () { msg('Could not reach the server.', false); });
};
};
})();

View File

@@ -14,6 +14,22 @@
# 5. sudo nginx -t && sudo systemctl reload nginx
# ─────────────────────────────────────────────────────────────────────────────
# Cache-Control per file type. Computed in a map rather than a nested location
# because nginx's add_header is NOT inherited into a block that declares its own —
# a `location ~* \.(html|css|js)$` setting only Cache-Control would silently drop the
# CSP / HSTS / X-Frame-Options / nosniff headers below for exactly those files. An
# empty value makes nginx omit the header, so images and fonts stay cacheable.
#
# Code must revalidate on every load: with no Cache-Control the browser applies
# HEURISTIC freshness (~10% of the file's age), so the least recently changed file
# gets the LONGEST lifetime — which is how a page ends up running against a
# stylesheet or script from a previous deploy. ETag/Last-Modified keep it a 304.
map $uri $wp_cache_control {
default "";
~*\.(?:html|css|js|webmanifest)$ "no-cache";
~*/$ "no-cache"; # directory index -> index.html
}
# Redirect plain HTTP to HTTPS
server {
listen 80;
@@ -34,6 +50,15 @@ server {
root /var/www/wp-suite; # <-- web root
index index.html;
# ── Security response headers (defense-in-depth) ─────────────────────────
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "no-referrer" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'self'" always;
# Empty for anything that isn't code, in which case nginx omits the header.
add_header Cache-Control $wp_cache_control always;
location / {
try_files $uri $uri/ =404;
}

View File

@@ -2,6 +2,23 @@
# This container sits behind an external reverse proxy that handles SSL.
# It listens on port 80 (plain HTTP on the internal Docker network).
# Cache-Control per file type, computed here rather than in a nested location.
# WHY A MAP: nginx's add_header is not inherited into a block that declares its own
# add_header — a `location ~* \.(html|css|js)$` that set only Cache-Control would have
# silently dropped the CSP / HSTS / X-Frame-Options / nosniff headers below for exactly
# those files. Computing the value here keeps every header in ONE scope. An empty value
# means nginx omits the header entirely, so images and fonts stay freely cacheable.
#
# Code assets must revalidate on every load: with no Cache-Control at all the browser
# applies HEURISTIC freshness (~10% of the file's age), so the least recently changed
# file gets the LONGEST lifetime — which is how a page ends up running against a
# stylesheet or script from a previous deploy. ETag/Last-Modified keep it a cheap 304.
map $uri $wp_cache_control {
default "";
~*\.(?:html|css|js|webmanifest)$ "no-cache";
~*/$ "no-cache"; # directory index → index.html
}
server {
listen 80;
server_name wp.controls.dev;
@@ -9,6 +26,19 @@ server {
root /usr/share/nginx/html;
index index.html;
# ── Security response headers (defense-in-depth) ─────────────────────────
# CSP keeps 'unsafe-inline' for now because the app uses inline handlers/styles
# heavily; even so, connect-src/img-src/object-src/base-uri/frame-ancestors
# sharply limit what injected script could load or exfiltrate. Tighten toward
# nonce-based scripts once inline handlers are refactored.
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "no-referrer" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'self'" always;
# Empty for anything that isn't code, in which case nginx omits the header.
add_header Cache-Control $wp_cache_control always;
location / {
try_files $uri $uri/ =404;
}
@@ -19,7 +49,10 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
# This container is only ever reached via the TLS-terminating external
# proxy, so the real client scheme is HTTPS. Hard-set it (a local $scheme
# here is always "http") so the API marks the session cookie Secure.
proxy_set_header X-Forwarded-Proto https;
client_max_body_size 5m;
}
}

16
scripts/backup-cron.sh Normal file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
# Entry point for the `backup` sidecar container. Runs db-backup.sh on a fixed
# interval (default: daily). Kept deliberately simple — a sleep loop instead of a
# cron daemon — so it works in a bare postgres:16-alpine image.
set -eu
INTERVAL="${BACKUP_INTERVAL_SECONDS:-86400}" # 86400 = once a day
echo "[backup] sidecar started; interval=${INTERVAL}s, keep=${BACKUP_KEEP:-14}, dir=${BACKUP_DIR:-/backups}"
# Take one backup shortly after start so a freshly-deployed stack has an
# immediate restore point instead of waiting a whole interval.
sleep 20
while true; do
sh /scripts/db-backup.sh || echo "[backup] run failed; will retry next interval" >&2
sleep "$INTERVAL"
done

View File

@@ -0,0 +1,5 @@
# Backup sidecar image: Postgres client tools (pg_dump/psql) + openssl for
# at-rest encryption of dumps. The scripts themselves are bind-mounted at runtime
# (see the `backup` service in docker-compose.yml), so they're not COPYed here.
FROM postgres:16-alpine
RUN apk add --no-cache openssl

58
scripts/db-backup.sh Normal file
View File

@@ -0,0 +1,58 @@
#!/bin/sh
# One database backup: pg_dump -> gzip [-> openssl AES-256] -> timestamped file in
# $BACKUP_DIR, then prune to the newest $BACKUP_KEEP files.
#
# Encryption: if BACKUP_ENC_PASSPHRASE is set, the dump is encrypted at rest with
# AES-256 (openssl, PBKDF2) and written as *.sql.gz.enc. STRONGLY recommended once
# the database holds customer IP — otherwise the dump (and every offsite copy) is
# plaintext. Keep the passphrase OUT of the backups directory (and off the host if
# possible); losing it means the backups are unrecoverable.
#
# Runs inside a container that has pg_dump + openssl (see scripts/backup.Dockerfile).
set -eu
BACKUP_DIR="${BACKUP_DIR:-/backups}"
KEEP="${BACKUP_KEEP:-14}"
PGHOST="${PGHOST:-db}"
PGPORT="${PGPORT:-5432}"
DB="${POSTGRES_DB:?POSTGRES_DB is required}"
DB_USER="${POSTGRES_USER:?POSTGRES_USER is required}"
export PGPASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
ENC="${BACKUP_ENC_PASSPHRASE:-}"
mkdir -p "$BACKUP_DIR"
ts="$(date -u +%Y%m%d-%H%M%SZ)"
if [ -n "$ENC" ]; then
out="$BACKUP_DIR/wpsuite-$ts.sql.gz.enc"
else
out="$BACKUP_DIR/wpsuite-$ts.sql.gz"
echo "[db-backup] WARNING: BACKUP_ENC_PASSPHRASE not set — this dump is UNENCRYPTED. Set it to protect data at rest." >&2
fi
tmp="$out.partial"
echo "[db-backup] $(date -u) dumping ${DB}@${PGHOST} -> ${out}"
if [ -n "$ENC" ]; then
if pg_dump -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" --clean --if-exists \
| gzip -c \
| openssl enc -aes-256-cbc -pbkdf2 -salt -pass env:BACKUP_ENC_PASSPHRASE > "$tmp"; then
mv "$tmp" "$out"
else
echo "[db-backup] FAILED — pg_dump/encrypt error" >&2; rm -f "$tmp"; exit 1
fi
else
if pg_dump -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" --clean --if-exists | gzip -c > "$tmp"; then
mv "$tmp" "$out"
else
echo "[db-backup] FAILED — pg_dump error" >&2; rm -f "$tmp"; exit 1
fi
fi
echo "[db-backup] wrote $(du -h "$out" | cut -f1) ${out}"
# Retention: keep the newest $KEEP dumps (plaintext or encrypted), delete the rest.
count="$(ls -1t "$BACKUP_DIR"/wpsuite-*.sql.gz* 2>/dev/null | grep -v '\.partial$' | wc -l | tr -d ' ')"
if [ "$count" -gt "$KEEP" ]; then
ls -1t "$BACKUP_DIR"/wpsuite-*.sql.gz* 2>/dev/null | grep -v '\.partial$' | tail -n +"$((KEEP + 1))" | while IFS= read -r f; do
echo "[db-backup] pruning $f"
rm -f "$f"
done
fi

33
scripts/db-restore.sh Normal file
View File

@@ -0,0 +1,33 @@
#!/bin/sh
# Restore a pg_dump backup (plaintext *.sql.gz or encrypted *.sql.gz.enc).
#
# DESTRUCTIVE: dumps are taken with --clean --if-exists, so restoring drops and
# recreates objects before loading. Take a fresh backup first if in doubt.
#
# Usage (from the project root):
# docker compose exec backup sh /scripts/db-restore.sh /backups/wpsuite-YYYYMMDD-HHMMSSZ.sql.gz.enc
# For an encrypted (.enc) file, BACKUP_ENC_PASSPHRASE must be set (it is, in the
# backup container's environment).
set -eu
FILE="${1:?usage: db-restore.sh <path-to-.sql.gz[.enc]>}"
PGHOST="${PGHOST:-db}"
PGPORT="${PGPORT:-5432}"
DB="${POSTGRES_DB:?POSTGRES_DB is required}"
DB_USER="${POSTGRES_USER:?POSTGRES_USER is required}"
export PGPASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
[ -f "$FILE" ] || { echo "[db-restore] no such file: $FILE" >&2; exit 1; }
echo "[db-restore] restoring ${FILE} -> ${DB}@${PGHOST} (this OVERWRITES current data)"
case "$FILE" in
*.enc)
: "${BACKUP_ENC_PASSPHRASE:?BACKUP_ENC_PASSPHRASE is required to decrypt ${FILE}}"
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:BACKUP_ENC_PASSPHRASE -in "$FILE" \
| gunzip -c | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" -v ON_ERROR_STOP=1
;;
*)
gunzip -c "$FILE" | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" -v ON_ERROR_STOP=1
;;
esac
echo "[db-restore] done."

View File

@@ -20,3 +20,13 @@ AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
# How long a login lasts before re-authentication (hours). Default 12.
# AUTH_SESSION_HOURS=12
# ── Email notifications (optional) ─────────────────────────────────────────────
# WP-assignment emails are OFF by default and are turned on from the Admin
# console (Notifications & email card), where the SMTP host/port/from-address
# live. The one secret that must NOT be stored in the database — the SMTP
# password — is read from this environment variable instead. Leave it unset
# until you have the SMTP details; the toggle stays effectively off (queued
# notifications are marked "skipped", nothing is sent) until both the toggle is
# on and SMTP is configured.
# SMTP_PASSWORD=your-smtp-app-password

43
server/alembic.ini Normal file
View File

@@ -0,0 +1,43 @@
# Alembic configuration for the Work Package Suite.
# The database URL is NOT hard-coded here — env.py pulls it from the same place
# the app does (server/db.py: POSTGRES_* / DATABASE_URL / SQLite fallback), so
# migrations always target the same database as the running app.
[alembic]
script_location = %(here)s/alembic
prepend_sys_path = .
# Use OS-native path separators on Windows dev machines.
path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

63
server/alembic/env.py Normal file
View File

@@ -0,0 +1,63 @@
"""Alembic environment for the Work Package Suite.
We reuse the application's own database configuration (server/db.py) so a
migration always targets the same database the app would connect to — Postgres
in production (from POSTGRES_* / DATABASE_URL) or the SQLite dev file otherwise.
No connection string is stored in alembic.ini.
"""
import os
import sys
from logging.config import fileConfig
from alembic import context
# Make the `server` package importable no matter where alembic is invoked from
# (repo root, /app in the container, etc.). env.py lives at server/alembic/env.py,
# so the repo root is two directories up.
_HERE = os.path.dirname(os.path.abspath(__file__))
_REPO = os.path.dirname(os.path.dirname(_HERE))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
from server.db import Base, DATABASE_URL, engine # noqa: E402
from server import models # noqa: E402,F401 (imported for its side effect: registers all tables on Base.metadata)
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# The app resolves its URL from the environment; feed the same value to Alembic.
config.set_main_option("sqlalchemy.url", str(DATABASE_URL))
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Emit SQL to stdout (`alembic upgrade --sql`) without a live connection."""
context.configure(
url=str(DATABASE_URL),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations against a live connection, reusing the app's engine."""
with engine.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,23 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

View File

@@ -0,0 +1,30 @@
"""user login lockout fields
Revision ID: 18373f14809e
Revises: 47bbe76aa749
Create Date: 2026-07-15 14:50:58.423834
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '18373f14809e'
down_revision = '47bbe76aa749'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# server_default backfills existing rows to 0 (the column is NOT NULL).
op.add_column('users', sa.Column('failed_attempts', sa.Integer(), nullable=False, server_default='0'))
op.add_column('users', sa.Column('locked_until', sa.DateTime(timezone=True), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'locked_until')
op.drop_column('users', 'failed_attempts')
# ### end Alembic commands ###

View File

@@ -0,0 +1,29 @@
"""wp archived_at
Revision ID: 47bbe76aa749
Revises: 4e094197c9aa
Create Date: 2026-07-15 12:00:14.356398
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '47bbe76aa749'
down_revision = '4e094197c9aa'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('work_packages', sa.Column('archived_at', sa.DateTime(timezone=True), nullable=True))
op.create_index(op.f('ix_work_packages_archived_at'), 'work_packages', ['archived_at'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_work_packages_archived_at'), table_name='work_packages')
op.drop_column('work_packages', 'archived_at')
# ### end Alembic commands ###

View File

@@ -0,0 +1,48 @@
"""audit log
Revision ID: 4e094197c9aa
Revises: c6af106a04da
Create Date: 2026-07-15 10:12:52.859694
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4e094197c9aa'
down_revision = 'c6af106a04da'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('audit_log',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
sa.Column('actor', sa.String(length=200), nullable=False),
sa.Column('action', sa.String(length=60), nullable=False),
sa.Column('entity_type', sa.String(length=40), nullable=False),
sa.Column('entity_id', sa.String(length=40), nullable=False),
sa.Column('project_id', sa.String(length=40), nullable=True),
sa.Column('summary', sa.String(length=400), nullable=False),
sa.Column('detail', sa.JSON(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_audit_log_action'), 'audit_log', ['action'], unique=False)
op.create_index(op.f('ix_audit_log_at'), 'audit_log', ['at'], unique=False)
op.create_index(op.f('ix_audit_log_entity_id'), 'audit_log', ['entity_id'], unique=False)
op.create_index(op.f('ix_audit_log_entity_type'), 'audit_log', ['entity_type'], unique=False)
op.create_index(op.f('ix_audit_log_project_id'), 'audit_log', ['project_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_audit_log_project_id'), table_name='audit_log')
op.drop_index(op.f('ix_audit_log_entity_type'), table_name='audit_log')
op.drop_index(op.f('ix_audit_log_entity_id'), table_name='audit_log')
op.drop_index(op.f('ix_audit_log_at'), table_name='audit_log')
op.drop_index(op.f('ix_audit_log_action'), table_name='audit_log')
op.drop_table('audit_log')
# ### end Alembic commands ###

View File

@@ -0,0 +1,63 @@
"""assignment + settings + notifications
Revision ID: 57dec34f11cb
Revises: ad8e6cc5de0f
Create Date: 2026-07-15 16:43:09.230419
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '57dec34f11cb'
down_revision = 'ad8e6cc5de0f'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('app_settings',
sa.Column('key', sa.String(length=80), nullable=False),
sa.Column('value', sa.JSON(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('key')
)
op.create_table('notifications',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('user_id', sa.String(length=40), nullable=False),
sa.Column('email', sa.String(length=200), nullable=False),
sa.Column('kind', sa.String(length=40), nullable=False),
sa.Column('wp_id', sa.String(length=40), nullable=True),
sa.Column('project_id', sa.String(length=40), nullable=True),
sa.Column('subject', sa.String(length=300), nullable=False),
sa.Column('body', sa.Text(), nullable=False),
sa.Column('link', sa.String(length=500), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('error', sa.String(length=400), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('sent_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_notifications_created_at'), 'notifications', ['created_at'], unique=False)
op.create_index(op.f('ix_notifications_kind'), 'notifications', ['kind'], unique=False)
op.create_index(op.f('ix_notifications_project_id'), 'notifications', ['project_id'], unique=False)
op.create_index(op.f('ix_notifications_status'), 'notifications', ['status'], unique=False)
op.create_index(op.f('ix_notifications_user_id'), 'notifications', ['user_id'], unique=False)
op.add_column('work_packages', sa.Column('assignee_id', sa.String(length=40), nullable=True))
op.create_index(op.f('ix_work_packages_assignee_id'), 'work_packages', ['assignee_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_work_packages_assignee_id'), table_name='work_packages')
op.drop_column('work_packages', 'assignee_id')
op.drop_index(op.f('ix_notifications_user_id'), table_name='notifications')
op.drop_index(op.f('ix_notifications_status'), table_name='notifications')
op.drop_index(op.f('ix_notifications_project_id'), table_name='notifications')
op.drop_index(op.f('ix_notifications_kind'), table_name='notifications')
op.drop_index(op.f('ix_notifications_created_at'), table_name='notifications')
op.drop_table('notifications')
op.drop_table('app_settings')
# ### end Alembic commands ###

View File

@@ -0,0 +1,28 @@
"""user token_version
Revision ID: ad8e6cc5de0f
Revises: 18373f14809e
Create Date: 2026-07-15 16:03:57.736556
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ad8e6cc5de0f'
down_revision = '18373f14809e'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# server_default backfills existing rows to 0 (the column is NOT NULL).
op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0'))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'token_version')
# ### end Alembic commands ###

View File

@@ -0,0 +1,35 @@
"""permissions roles + project (job function) role
Adds `users.project_role` (job function on the project — carries no permissions)
and migrates the permissions vocabulary: the legacy role 'user' becomes
'project_user'. 'admin' is untouched; 'project_admin' is new and is only ever
granted explicitly from the admin console.
Revision ID: b41c7ae90d52
Revises: 57dec34f11cb
Create Date: 2026-08-03 15:12:04.118322
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b41c7ae90d52'
down_revision = '57dec34f11cb'
branch_labels = None
depends_on = None
def upgrade() -> None:
# server_default backfills existing rows (the column is NOT NULL).
op.add_column('users', sa.Column('project_role', sa.String(length=120),
nullable=False, server_default=''))
# Legacy 'user' means exactly what 'project_user' means now.
op.execute("UPDATE users SET role = 'project_user' WHERE role = 'user'")
def downgrade() -> None:
# Fold the new role back onto the legacy value so an older build still reads
# the table. A project_admin loses its elevated rights on downgrade.
op.execute("UPDATE users SET role = 'user' WHERE role IN ('project_user', 'project_admin')")
op.drop_column('users', 'project_role')

View File

@@ -0,0 +1,145 @@
"""baseline schema
Revision ID: c6af106a04da
Revises:
Create Date: 2026-07-15 08:21:07.450350
This is the initial baseline. It creates the current schema on a fresh database,
and safely ADOPTS an existing database (one whose tables were created by the old
`Base.metadata.create_all()` before Alembic was introduced): if the schema is
already present it records this revision without recreating anything. That means
`alembic upgrade head` is safe to run on both new and existing deployments — no
manual `alembic stamp` step required.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c6af106a04da'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
if sa.inspect(bind).has_table("projects"):
# Existing pre-Alembic database — adopt it as the baseline as-is.
return
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('comments',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('source', sa.String(length=40), nullable=False),
sa.Column('sop_id', sa.String(length=40), nullable=True),
sa.Column('wp_id', sa.String(length=40), nullable=True),
sa.Column('step', sa.Integer(), nullable=True),
sa.Column('author', sa.String(length=200), nullable=False),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('page', sa.String(length=200), nullable=False),
sa.Column('extra', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_comments_sop_id'), 'comments', ['sop_id'], unique=False)
op.create_index(op.f('ix_comments_source'), 'comments', ['source'], unique=False)
op.create_index(op.f('ix_comments_wp_id'), 'comments', ['wp_id'], unique=False)
op.create_table('projects',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('name', sa.String(length=300), nullable=False),
sa.Column('number', sa.String(length=100), nullable=False),
sa.Column('client', sa.String(length=300), nullable=False),
sa.Column('division', sa.String(length=200), nullable=False),
sa.Column('site', sa.String(length=300), nullable=False),
sa.Column('sample', sa.Boolean(), nullable=False),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('created_by', sa.String(length=200), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_projects_number'), 'projects', ['number'], unique=False)
op.create_table('users',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('username', sa.String(length=120), nullable=False),
sa.Column('email', sa.String(length=200), nullable=False),
sa.Column('full_name', sa.String(length=200), nullable=False),
sa.Column('password_hash', sa.String(length=200), nullable=False),
sa.Column('role', sa.String(length=20), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_table('project_members',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('user_id', sa.String(length=40), nullable=False),
sa.Column('project_id', sa.String(length=40), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'project_id', name='uq_project_member')
)
op.create_index(op.f('ix_project_members_project_id'), 'project_members', ['project_id'], unique=False)
op.create_index(op.f('ix_project_members_user_id'), 'project_members', ['user_id'], unique=False)
op.create_table('sops',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('project_id', sa.String(length=40), nullable=True),
sa.Column('name', sa.String(length=300), nullable=False),
sa.Column('number', sa.String(length=100), nullable=False),
sa.Column('complete', sa.Boolean(), nullable=False),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('created_by', sa.String(length=200), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_sops_project_id'), 'sops', ['project_id'], unique=False)
op.create_table('work_packages',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('project_id', sa.String(length=40), nullable=True),
sa.Column('sop_id', sa.String(length=40), nullable=True),
sa.Column('parent_id', sa.String(length=40), nullable=True),
sa.Column('number', sa.String(length=120), nullable=False),
sa.Column('subject', sa.String(length=400), nullable=False),
sa.Column('type', sa.String(length=120), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('issued_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('created_by', sa.String(length=200), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['sop_id'], ['sops.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_work_packages_parent_id'), 'work_packages', ['parent_id'], unique=False)
op.create_index(op.f('ix_work_packages_project_id'), 'work_packages', ['project_id'], unique=False)
op.create_index(op.f('ix_work_packages_sop_id'), 'work_packages', ['sop_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_work_packages_sop_id'), table_name='work_packages')
op.drop_index(op.f('ix_work_packages_project_id'), table_name='work_packages')
op.drop_index(op.f('ix_work_packages_parent_id'), table_name='work_packages')
op.drop_table('work_packages')
op.drop_index(op.f('ix_sops_project_id'), table_name='sops')
op.drop_table('sops')
op.drop_index(op.f('ix_project_members_user_id'), table_name='project_members')
op.drop_index(op.f('ix_project_members_project_id'), table_name='project_members')
op.drop_table('project_members')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_projects_number'), table_name='projects')
op.drop_table('projects')
op.drop_index(op.f('ix_comments_wp_id'), table_name='comments')
op.drop_index(op.f('ix_comments_source'), table_name='comments')
op.drop_index(op.f('ix_comments_sop_id'), table_name='comments')
op.drop_table('comments')
# ### end Alembic commands ###

View File

@@ -0,0 +1,29 @@
"""user locale + timezone preferences
Per-user display preferences. Empty means "use the app default (admin console),
then the browser". Stored server-side so they follow the person between devices —
shared field tablets are the case that matters.
Revision ID: c93f2b1d7e04
Revises: b41c7ae90d52
Create Date: 2026-08-03 16:44:10.882931
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c93f2b1d7e04'
down_revision = 'b41c7ae90d52'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('users', sa.Column('locale', sa.String(length=20), nullable=False, server_default=''))
op.add_column('users', sa.Column('timezone', sa.String(length=60), nullable=False, server_default=''))
def downgrade() -> None:
op.drop_column('users', 'timezone')
op.drop_column('users', 'locale')

View File

@@ -0,0 +1,28 @@
"""per-project member role
Lets someone be Project Admin on one job and a normal Project User on another.
Empty string means "inherit the account's own role" (users.role), which is exactly
how every existing membership behaved, so this is a no-op for current data.
Revision ID: d15b8c4ef207
Revises: c93f2b1d7e04
Create Date: 2026-08-03 17:58:22.401118
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd15b8c4ef207'
down_revision = 'c93f2b1d7e04'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('project_members', sa.Column('role', sa.String(length=20),
nullable=False, server_default=''))
def downgrade() -> None:
op.drop_column('project_members', 'role')

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,19 @@ Security model:
set; if it is missing we fall back to a random per-process key (which logs a
warning and invalidates every session on restart) so dev still works.
Roles: 'admin' (may manage users) and 'user'.
Permissions roles (`User.role`) — distinct from a person's job function on the
project, which lives in `User.project_role` and grants nothing:
• admin application administrator: user administration, app settings,
and implicit access to every project.
• project_admin within their assigned projects: may delete work packages,
modify a SOP after it has been completed, and delete projects.
• project_user normal member: creates and edits work packages, authors a SOP
up to completion. May NOT delete WPs or change a completed SOP.
Password reset: a short-lived signed token (see `create_reset_token`) is emailed
to the account's address. It is single-use by construction — it embeds the user's
`token_version`, which is bumped when the password changes, so a used or
superseded link stops validating.
"""
import os
import secrets
@@ -30,7 +42,7 @@ from fastapi import Depends, HTTPException, Request, Response, status
from sqlalchemy import select, func
from sqlalchemy.orm import Session
from .db import get_db
from .db import get_db, DATABASE_URL
from . import models
log = logging.getLogger("wpsuite.auth")
@@ -39,6 +51,71 @@ COOKIE_NAME = "wp_session"
JWT_ALG = "HS256"
# How long a login lasts before the user must sign in again.
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
# How long an emailed password-reset link stays valid.
RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
# ── permissions roles ─────────────────────────────────────────────────────────
ROLE_ADMIN = "admin"
ROLE_PROJECT_ADMIN = "project_admin"
ROLE_PROJECT_USER = "project_user"
ROLES = (ROLE_ADMIN, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
ROLE_LABELS = {
ROLE_ADMIN: "Administrator",
ROLE_PROJECT_ADMIN: "Project Admin",
ROLE_PROJECT_USER: "Project User",
}
# Job functions offered in the admin console. Free text underneath, so a project
# can use a title that isn't on this list.
PROJECT_ROLES = (
"Project Manager", "Assistant Project Manager", "Construction Manager",
"Quality Manager", "Superintendent", "General Foreman", "Foreman",
"Planner / Scheduler", "BIM / VDC Coordinator", "Engineer",
"Safety (HSE)", "Warehouse / Materials", "Commissioning", "Field Technician",
)
def normalize_role(role: Optional[str]) -> str:
"""Map a stored/incoming role onto the current vocabulary.
Accounts created before permissions roles existed carry the legacy value
'user', which means exactly what 'project_user' means now."""
r = (role or "").strip()
if r == "user":
return ROLE_PROJECT_USER
return r if r in ROLES else ROLE_PROJECT_USER
def is_admin(user: "models.User") -> bool:
return normalize_role(user.role) == ROLE_ADMIN
def is_project_admin(user: "models.User") -> bool:
"""True for app admins and project admins — the two roles allowed to delete
work packages and change a completed SOP."""
return normalize_role(user.role) in (ROLE_ADMIN, ROLE_PROJECT_ADMIN)
# Password policy (shared by the API and the CLI).
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
_COMMON_PASSWORDS = {
"password", "password1", "password123", "passw0rd", "12345678", "123456789",
"1234567890", "qwerty123", "letmein123", "changeme", "admin123", "welcome123",
"iloveyou1", "abc12345", "qwertyuiop",
}
def password_problem(pw: str, username: str = "", email: str = "") -> Optional[str]:
"""Return a human-readable reason the password is unacceptable, or None if OK.
Shared by the API endpoints and the CLI so the policy is enforced everywhere."""
if len(pw) < MIN_PASSWORD_LEN:
return f"Password must be at least {MIN_PASSWORD_LEN} characters."
low = pw.lower()
if username and low == username.strip().lower():
return "Password must not be the same as the username."
if email and low == email.strip().lower():
return "Password must not be the same as the email."
if low in _COMMON_PASSWORDS:
return "That password is too common — choose something less guessable."
return None
# Paths under /api that do NOT require a session (login itself, health, docs).
_EXEMPT_PREFIXES = ("/api/auth/",)
@@ -55,13 +132,24 @@ def _load_secret() -> str:
s = os.getenv("AUTH_SECRET_KEY")
if s:
return s
# No secret configured: generate an ephemeral one so the app still runs in
# dev. Sessions won't survive a restart, and this is unsafe across multiple
# workers — production must set AUTH_SECRET_KEY.
# No key configured. In production (a real database is configured via
# POSTGRES_* / DATABASE_URL) this is FATAL — refuse to start rather than sign
# sessions with a throwaway key that silently rotates on every restart. In
# local dev (SQLite, no DB env) fall back to an ephemeral key so the app still
# runs zero-config.
# "Prod" = a real (non-SQLite) database is in use — matches exactly the
# condition db.py uses to pick Postgres, so we don't wrongly block a
# zero-config SQLite dev run just because a stray POSTGRES_USER is exported.
is_prod = not str(DATABASE_URL).startswith("sqlite")
if is_prod:
raise RuntimeError(
"AUTH_SECRET_KEY is not set. Refusing to start in production with an "
"ephemeral signing key — set a strong fixed AUTH_SECRET_KEY "
"(see server/.env.example / DEPLOYMENT.md)."
)
log.warning(
"AUTH_SECRET_KEY is not set — using a random ephemeral key. "
"Logins will reset on restart and break across multiple workers. "
"Set AUTH_SECRET_KEY in the environment for production."
"AUTH_SECRET_KEY is not set — using a random ephemeral key for local dev. "
"Logins reset on restart. Set AUTH_SECRET_KEY for anything non-dev."
)
return secrets.token_urlsafe(48)
@@ -92,6 +180,7 @@ def create_token(user: "models.User") -> str:
"sub": user.id,
"username": user.username,
"role": user.role,
"ver": user.token_version or 0,
"iat": now,
"exp": now + timedelta(hours=SESSION_HOURS),
}
@@ -99,11 +188,43 @@ def create_token(user: "models.User") -> str:
def decode_token(token: str) -> Optional[dict]:
"""Return the token claims if the signature and expiry are valid, else None."""
"""Return the token claims if the signature and expiry are valid, else None.
Session cookies only — a token of any other type is rejected."""
try:
return jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError:
return None
# A password-reset token must never be usable as a session cookie.
if claims.get("typ"):
return None
return claims
def create_reset_token(user: "models.User") -> str:
"""Short-lived, single-use token for an emailed password-reset link.
Single-use falls out of `ver`: completing a reset bumps the user's
token_version, so the link (and any older link) no longer validates."""
now = datetime.now(timezone.utc)
payload = {
"typ": "pwreset",
"sub": user.id,
"ver": user.token_version or 0,
"iat": now,
"exp": now + timedelta(minutes=RESET_MINUTES),
}
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
def decode_reset_token(token: str) -> Optional[dict]:
"""Claims for a valid, unexpired reset token, else None."""
try:
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError:
return None
if claims.get("typ") != "pwreset":
return None
return claims
# ── cookie helpers ────────────────────────────────────────────────────────────
@@ -163,11 +284,15 @@ def get_current_user(request: Request, db: Session = Depends(get_db)) -> "models
user = db.get(models.User, claims.get("sub"))
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is inactive")
# Session revocation: a mismatch means the token was invalidated (e.g. the
# password was changed after this token was issued).
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired")
return user
def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.User":
if user.role != "admin":
if not is_admin(user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user

View File

@@ -30,15 +30,16 @@ def _gen_id() -> str:
return f"user_{uuid.uuid4().hex[:12]}"
def _prompt_password(provided: str | None) -> str:
def _prompt_password(provided: str | None, username: str = "") -> str:
pw = provided
if not pw:
pw = getpass.getpass("New password: ")
confirm = getpass.getpass("Confirm password: ")
if pw != confirm:
sys.exit("Passwords do not match.")
if len(pw) < 8:
sys.exit("Password must be at least 8 characters.")
problem = auth.password_problem(pw, username)
if problem:
sys.exit(problem)
return pw
@@ -46,7 +47,7 @@ def cmd_create(args, role: str | None = None) -> None:
role = role or args.role
if role not in ("admin", "user"):
sys.exit("role must be 'admin' or 'user'")
pw = _prompt_password(getattr(args, "password", None))
pw = _prompt_password(getattr(args, "password", None), args.username)
with SessionLocal() as db:
if auth.find_user(db, args.username):
sys.exit(f"A user named '{args.username}' already exists.")
@@ -75,7 +76,7 @@ def cmd_list(args) -> None:
def cmd_reset_password(args) -> None:
pw = _prompt_password(getattr(args, "password", None))
pw = _prompt_password(getattr(args, "password", None), args.username)
with SessionLocal() as db:
u = auth.find_user(db, args.username)
if not u:

View File

@@ -92,7 +92,13 @@ class WorkPackage(Base):
subject: Mapped[str] = mapped_column(String(400), default="")
type: Mapped[str] = mapped_column(String(120), default="")
status: Mapped[str] = mapped_column(String(40), default="Draft")
# The accountable owner (a user id), for "My Work Packages" + assignment
# notifications. Free-text `data.assignees`/`distribution` still hold the wider list.
assignee_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
issued_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Archived packages are hidden from the default lists/dashboard but kept for
# the record (years-long projects accumulate hundreds of closed WPs).
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
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)
@@ -102,7 +108,9 @@ class WorkPackage(Base):
return {
"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),
"type": self.type, "status": self.status, "assignee_id": self.assignee_id,
"issued_at": _iso(self.issued_at),
"archived_at": _iso(self.archived_at), "archived": self.archived_at is not None,
"created_by": self.created_by,
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
}
@@ -113,8 +121,15 @@ class WorkPackage(Base):
class User(Base):
"""A login account. Passwords are never stored in the clear — only a bcrypt
hash (see server/auth.py). `username` is what people sign in with; `role` is
either 'admin' (can manage users) or 'user'."""
hash (see server/auth.py). `username` is what people sign in with.
Two independent notions of "role", deliberately separate:
• role the PERMISSIONS role — what the account may do in the app.
'admin' | 'project_admin' | 'project_user' (see auth.ROLES).
• project_role the person's JOB FUNCTION on the project (Project Manager,
Superintendent, QA/QC, …). Carries no permissions; it's what
the SOP team pickers and notification routing read.
"""
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
@@ -122,25 +137,44 @@ class User(Base):
email: Mapped[str] = mapped_column(String(200), default="")
full_name: Mapped[str] = mapped_column(String(200), default="")
password_hash: Mapped[str] = mapped_column(String(200), default="")
role: Mapped[str] = mapped_column(String(20), default="user") # 'admin' | 'user'
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
# Job function on the project — free text, offered from a suggested list.
project_role: Mapped[str] = mapped_column(String(120), default="")
# Display preferences. Empty means "fall back to the app default, then to the
# browser". A stored value follows the person between devices, which matters on
# shared field tablets where the browser locale isn't theirs.
locale: Mapped[str] = mapped_column(String(20), default="") # BCP47, e.g. en-US
timezone: Mapped[str] = mapped_column(String(60), default="") # IANA, e.g. America/Chicago
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
last_login_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Online-guessing throttle (see login()): consecutive failures + a lockout window.
failed_attempts: Mapped[int] = mapped_column(Integer, default=0)
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Bumped to invalidate all existing sessions for this user (e.g. on a password
# change). The value is embedded in the JWT and re-checked on every request.
token_version: Mapped[int] = mapped_column(Integer, default=0)
def to_dict(self) -> dict:
"""Public view of a user — NEVER includes the password hash."""
return {
"id": self.id, "username": self.username, "email": self.email,
"full_name": self.full_name, "role": self.role, "is_active": self.is_active,
"full_name": self.full_name, "role": self.role,
"project_role": self.project_role or "", "is_active": self.is_active,
"locale": self.locale or "", "timezone": self.timezone or "",
"created_at": _iso(self.created_at), "last_login_at": _iso(self.last_login_at),
}
class ProjectMember(Base):
"""Which users may access which projects. A user sees/operates on a project
only if a row links them to it (admins bypass this entirely). One row per
(user, project) pair."""
"""Which users may access which projects, and what they may do there. A user
sees/operates on a project only if a row links them to it (admins bypass this
entirely). One row per (user, project) pair.
`role` is the permissions role ON THIS PROJECT: someone can be Project Admin on
one job and a normal Project User on another. Empty means "inherit the account's
own role" (User.role), which is how every existing row behaves."""
__tablename__ = "project_members"
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)
@@ -151,6 +185,7 @@ class ProjectMember(Base):
project_id: Mapped[str] = mapped_column(
String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
role: Mapped[str] = mapped_column(String(20), default="") # '' = inherit User.role
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
@@ -176,5 +211,74 @@ class Comment(Base):
}
class AuditLog(Base):
"""Append-only history: who changed what, when. Rows are written inside the
same transaction as the change they describe (see server/app.py: log_event),
so the trail can't drift from the data. `detail` holds a compact JSON summary
of the change, e.g. {"from": "Scheduled", "to": "Issued"}.
Not a ForeignKey to any entity on purpose — the log must survive the deletion
of the thing it describes (you still want "who deleted WP01, and when")."""
__tablename__ = "audit_log"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
actor: Mapped[str] = mapped_column(String(200), default="") # username who made the change
action: Mapped[str] = mapped_column(String(60), default="", index=True) # created | updated | status_changed | issued | role_changed | ...
entity_type: Mapped[str] = mapped_column(String(40), default="", index=True) # wp | sop | project | user
entity_id: Mapped[str] = mapped_column(String(40), default="", index=True)
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
summary: Mapped[str] = mapped_column(String(400), default="") # human one-liner (e.g. the WP number/subject)
detail: Mapped[dict] = mapped_column(JSON, default=dict)
def to_dict(self) -> dict:
return {
"id": self.id, "at": _iso(self.at), "actor": self.actor, "action": self.action,
"entity_type": self.entity_type, "entity_id": self.entity_id,
"project_id": self.project_id, "summary": self.summary, "detail": self.detail or {},
}
class AppSetting(Base):
"""Admin-editable application settings (feature flags, SMTP config, …) stored
as key -> JSON value. Read/written via /api/settings (admin only). Secrets like
the SMTP password are NOT stored here — they come from the environment."""
__tablename__ = "app_settings"
key: Mapped[str] = mapped_column(String(80), primary_key=True)
value: Mapped[dict] = mapped_column(JSON, default=dict)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
class Notification(Base):
"""Outbox for user notifications (an in-app record + an optional email). A row
is written when something notable happens (e.g. a WP assignment); the email
sender processes it only when email notifications are enabled AND SMTP is set —
otherwise it's recorded as 'skipped'. See server/notify.py."""
__tablename__ = "notifications"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
user_id: Mapped[str] = mapped_column(String(40), index=True) # recipient
email: Mapped[str] = mapped_column(String(200), default="")
kind: Mapped[str] = mapped_column(String(40), default="", index=True) # wp_assigned | …
wp_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True)
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
subject: Mapped[str] = mapped_column(String(300), default="")
body: Mapped[str] = mapped_column(Text, default="")
link: Mapped[str] = mapped_column(String(500), default="")
status: Mapped[str] = mapped_column(String(20), default="pending", index=True) # pending|sent|failed|skipped
error: Mapped[str] = mapped_column(String(400), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
def to_dict(self) -> dict:
return {
"id": self.id, "user_id": self.user_id, "email": self.email, "kind": self.kind,
"wp_id": self.wp_id, "project_id": self.project_id, "subject": self.subject,
"status": self.status, "error": self.error,
"created_at": _iso(self.created_at), "sent_at": _iso(self.sent_at),
}
def _iso(dt: Optional[datetime]) -> Optional[str]:
return dt.isoformat() if dt else None

174
server/notify.py Normal file
View File

@@ -0,0 +1,174 @@
"""Notifications: admin-configurable email + an outbox.
Email notifications are OFF by default and controlled from the admin console (a
toggle stored in `app_settings`). Even when enabled, mail is only sent if SMTP is
configured. The SMTP PASSWORD is read from the `SMTP_PASSWORD` environment variable
and is NEVER stored in the database or shown in the UI.
Every notable event (e.g. a WP assignment) writes a `notifications` row — an in-app
record — and, when email is on + SMTP is set, the row is delivered by email in a
background task. Notification bodies deliberately avoid customer IP: they carry a WP
number and a deep link, not the work-package contents.
"""
import os
import smtplib
import uuid
import logging
from email.message import EmailMessage
from typing import Optional
from sqlalchemy.orm import Session
from . import models
log = logging.getLogger("wpsuite.notify")
SETTINGS_KEY = "notifications"
DEFAULTS = {
"email_enabled": False, # master toggle — OFF until SMTP is sorted
"smtp_host": "",
"smtp_port": 587,
"smtp_use_tls": True,
"smtp_username": "",
"from_addr": "",
"from_name": "Work Package Suite",
"app_base_url": "", # e.g. https://wp.controls.dev — used to build email links
# Feature flags (admin console). BIM/VDC is off until it's ready for the field:
# with it off, the SOP creator hides the BIM section entirely and every SOP is
# install-only, so no project can be put on the BIM path by accident.
"bim_enabled": False,
# Localization defaults for dates, times and numbers. Empty = use each
# browser's own locale / timezone. A user's own preference wins over these.
"default_locale": "", # BCP47, e.g. en-US
"default_timezone": "", # IANA, e.g. America/Chicago
}
# Settings the app needs before anyone is signed in, or that carry no secrets and
# are safe for any authenticated user to read (feature flags + localization
# defaults + whether self-service password reset can work at all).
PUBLIC_KEYS = ("bim_enabled", "default_locale", "default_timezone")
def get_settings(db: Session) -> dict:
row = db.get(models.AppSetting, SETTINGS_KEY)
s = dict(DEFAULTS)
if row and row.value:
s.update({k: row.value[k] for k in row.value if k in DEFAULTS})
return s
def save_settings(db: Session, patch: dict) -> dict:
cur = get_settings(db)
for k in DEFAULTS:
if k in patch and patch[k] is not None:
cur[k] = patch[k]
row = db.get(models.AppSetting, SETTINGS_KEY)
if row:
row.value = cur
else:
db.add(models.AppSetting(key=SETTINGS_KEY, value=cur))
db.commit()
return cur
def public_settings(db: Session) -> dict:
"""Settings safe to return to the admin UI — no secrets."""
s = get_settings(db)
s["smtp_password_set"] = bool(os.getenv("SMTP_PASSWORD"))
return s
def app_flags(db: Session) -> dict:
"""Feature flags for any signed-in user (no secrets, no SMTP detail).
`password_reset_enabled` tells the login page whether a self-service reset can
actually deliver mail — there's no point offering the link otherwise."""
s = get_settings(db)
out = {k: s.get(k) for k in PUBLIC_KEYS}
out["password_reset_enabled"] = bool(s.get("email_enabled")) and smtp_ready(s)
return out
def smtp_ready(s: dict) -> bool:
return bool(s.get("smtp_host") and s.get("from_addr"))
def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
"""Send one email via SMTP. Raises on any failure (caller records it)."""
if not to_addr:
raise ValueError("no recipient email")
msg = EmailMessage()
from_name = s.get("from_name") or ""
msg["From"] = f"{from_name} <{s['from_addr']}>" if from_name else s["from_addr"]
msg["To"] = to_addr
msg["Subject"] = subject
msg.set_content(body)
host = s["smtp_host"]
port = int(s.get("smtp_port") or 587)
user = s.get("smtp_username") or ""
pw = os.getenv("SMTP_PASSWORD", "")
with smtplib.SMTP(host, port, timeout=15) as srv:
if s.get("smtp_use_tls", True):
srv.starttls()
if user:
srv.login(user, pw)
srv.send_message(msg)
def send_now(db: Session, to_addr: str, subject: str, body: str) -> bool:
"""Send one email immediately, outside the outbox. Used for password resets —
a reset link must never sit in a queue, and it must not be persisted in the
notifications table where an admin could read it and take over the account.
Returns True if it went out."""
s = get_settings(db)
if not (s.get("email_enabled") and smtp_ready(s) and to_addr):
return False
try:
send_email(s, to_addr, subject, body)
return True
except Exception as e: # noqa: BLE001 — never surface SMTP detail to the caller
log.warning("password-reset email to %s failed: %s", to_addr, e)
return False
def enqueue(db: Session, *, user: "models.User", kind: str, subject: str, body: str,
link: str = "", wp_id: Optional[str] = None, project_id: Optional[str] = None) -> "models.Notification":
"""Record a notification. Marked 'pending' only if email is enabled + SMTP ready +
the recipient has an email; otherwise 'skipped' (still an in-app record). Does NOT
commit — the caller commits with its own transaction. Returns the row."""
s = get_settings(db)
deliverable = bool(s.get("email_enabled")) and smtp_ready(s) and bool(user.email)
n = models.Notification(
id="ntf_" + uuid.uuid4().hex[:12],
user_id=user.id, email=user.email or "", kind=kind,
wp_id=wp_id, project_id=project_id, subject=subject[:300], body=body,
link=link[:500], status="pending" if deliverable else "skipped",
)
db.add(n)
return n
def deliver(notif_id: str) -> None:
"""Background task: send one pending notification, on its own DB session."""
from .db import SessionLocal
db = SessionLocal()
try:
n = db.get(models.Notification, notif_id)
if not n or n.status != "pending":
return
s = get_settings(db)
if not (s.get("email_enabled") and smtp_ready(s) and n.email):
n.status = "skipped"
db.commit()
return
try:
send_email(s, n.email, n.subject, n.body)
n.status = "sent"
n.sent_at = models.utcnow()
except Exception as e: # noqa: BLE001 — record any SMTP failure, don't crash the worker
n.status = "failed"
n.error = str(e)[:400]
log.warning("notification %s failed to send: %s", notif_id, e)
db.commit()
finally:
db.close()

View File

@@ -1,9 +1,16 @@
fastapi>=0.110
uvicorn[standard]>=0.29
gunicorn>=21.2
sqlalchemy>=2.0
psycopg[binary]>=3.1
pydantic>=2.6
python-dotenv>=1.0
bcrypt>=4.1 # password hashing
PyJWT>=2.8 # signed session tokens
# Pinned to exact versions for reproducible builds — no silent dependency drift
# on every `docker compose up --build`. To update: bump a version here on purpose,
# run `pip-audit` against the result, and test. For supply-chain integrity, the
# next step is a hashed lockfile (`pip-compile --generate-hashes` → install with
# `pip install --require-hashes`).
fastapi==0.138.1
uvicorn[standard]==0.49.0
gunicorn==26.0.0
sqlalchemy==2.0.51
alembic==1.18.5 # database migrations
psycopg[binary]==3.3.4
pydantic==2.13.4
python-dotenv==1.2.2
bcrypt==5.0.0 # password hashing
PyJWT==2.13.0 # signed session tokens
starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant)