Work-order codes no longer overlap (#42), Materials before Heat map (#44), 60 min idle for Admin and Clerical (#45)
ci / frontend (pull_request) Successful in 54s
ci / server (pull_request) Failing after 12m12s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Skipped

- Traveler: each work-order line shrinks, then clips, to fit its own box; 18 pt gap between boxes
- Work orders tab: card text wraps inside the card, smaller work order, wider cards
- Packet tabs: Materials before Heat map
- Sessions: SESSION_DESK_MINUTES (60) for Admin and Clerical lock and sign-out; /me answers
  lock_minutes and the browser's idle lock follows it

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-21 13:16:26 -06:00
co-authored by Claude Opus 5.5
parent b0f6a09173
commit de5b361d85
14 changed files with 119 additions and 36 deletions
+1
View File
@@ -66,6 +66,7 @@ def me_payload(conn: Any, p: Principal) -> dict[str, Any]:
"expires_at": s["expires_at"],
"locked": s.get("locked_since") is not None,
"locked_since": s.get("locked_since"),
"lock_minutes": p.lock_minutes,
"device": devices.public(p.device),
},
"pin": pins.state(conn, p.user_id).public(),
+17 -2
View File
@@ -28,6 +28,18 @@ log = logging.getLogger(__name__)
LAST_SEEN_SLACK = timedelta(seconds=30)
def desk_roles(roles: list[str]) -> bool:
"""Admin and Clerical get the longer desk idle limits."""
return any(r == "admin" or r.startswith("clerical") for r in roles)
def idle_minutes(s: Settings, roles: list[str]) -> tuple[int, int]:
"""(sign-out, screen lock) idle minutes for a session with these roles."""
if desk_roles(roles):
return max(s.session_idle_minutes, s.session_desk_minutes), max(s.session_lock_minutes, s.session_desk_minutes)
return s.session_idle_minutes, s.session_lock_minutes
# ---------------------------------------------------------------- plumbing
@@ -160,6 +172,7 @@ class Principal:
kind: str
device: dict[str, Any] | None
ip: str | None
lock_minutes: int = 3
@property
def session_id(self) -> uuid.UUID:
@@ -253,11 +266,12 @@ def resolve(request: Request, d: Db, *, allow_locked: bool, token: str | None =
raise ApiError(401, "no_session", "Not signed in.")
now = row["db_now"]
idle = now - row["last_seen_at"]
if idle > timedelta(minutes=s.session_idle_minutes):
idle_min, lock_min = idle_minutes(s, list((row.get("roles_snapshot") or {}).get("roles", [])))
if idle > timedelta(minutes=idle_min):
_expire(conn, row, "idle", ip)
raise ApiError(401, "session_expired", "Signed out after a period of no activity.")
lock_source: str | None = None
if row["locked_since"] is None and idle > timedelta(minutes=s.session_lock_minutes):
if row["locked_since"] is None and idle > timedelta(minutes=lock_min):
lock_source = "idle"
hidden_since = row.get("hidden_since")
if hidden_since is not None:
@@ -308,6 +322,7 @@ def resolve(request: Request, d: Db, *, allow_locked: bool, token: str | None =
kind=str(row["kind"]),
device=device,
ip=ip,
lock_minutes=idle_minutes(s, roles)[1],
)
+41 -21
View File
@@ -17,16 +17,30 @@ from pypdf import PdfReader, PdfWriter
from reportlab.graphics import renderPDF
from reportlab.graphics.barcode.qr import QrCodeWidget
from reportlab.graphics.shapes import Drawing
from reportlab.pdfbase.pdfmetrics import stringWidth
from . import parts, stamps
from .databook import heats_for
from .doc import Doc
from .doc import Doc, font_of
from .style import CW, GREY, LINE, Built, L, PdfError, R, WhoRef, clean, fmt_d, fmt_dt, fmt_num, site_tz, utc_now
BLANK = "________"
BLANK_LONG = "________________"
QR_BOX_H = 96.0
QR_SIZE = 84.0
QR_GAP = 18.0 # between work-order boxes: a quiet zone so the kiosk scanner reads one code at a time
def fit_line(s: Any, width: float, size: float, *, bold: bool = False) -> tuple[str, float]:
"""One line that fits `width` (issue #42): shrink down to 70% of `size`, then cut with "..."."""
text, font, z = clean(s), font_of(bold), size
while z - 0.5 >= size * 0.7 and stringWidth(text, font, z) > width:
z -= 0.5
if stringWidth(text, font, z) > width:
while len(text) > 1 and stringWidth(text + "...", font, z) > width:
text = text[:-1]
text += "..."
return text, z
def qr_text(wo: Mapping[str, Any]) -> str:
@@ -150,32 +164,38 @@ def _draw(conn: Any, settings: Any, p: Mapping[str, Any]) -> Doc:
y += 16
y = d.section_label("Work orders - scan at the labor kiosk to clock on in Visual", y)
wos = p.get("work_orders") or []
bw = (CW - 12) / 2
bw = (CW - QR_GAP) / 2
for i, w in enumerate(wos):
col = i % 2
if col == 0:
y = d.ensure_space(y, QR_BOX_H + 8)
x = L + col * (bw + 12)
y = d.ensure_space(y, QR_BOX_H + QR_GAP)
x = L + col * (bw + QR_GAP)
d.rect(x, y, bw, QR_BOX_H, stroke=LINE, width=0.8)
draw_qr(d, qr_text(w), x + 6, y + 6, QR_SIZE)
tx0 = x + QR_BOX_H + 2
d.tx(w.get("wo_base") or "", tx0, y + 20, size=12, bold=True)
d.tx(
f"Lot {w.get('lot') or ''} Seq {'' if w.get('seq') is None else w.get('seq')} {w.get('op') or ''}",
tx0,
y + 36,
size=8.5,
)
d.tx(f"Target {fmt_num(w.get('target_hrs'))} h Qty {fmt_num(w.get('qty'))}", tx0, y + 50, size=8.5)
d.tx(
f"Drawing {w.get('dwg') or p.get('drawing') or ''} Rev {w.get('rev') or p.get('rev') or ''}",
tx0,
y + 64,
size=7.5,
color=GREY,
)
tx0, tw = x + QR_BOX_H + 2, bw - QR_BOX_H - 8 # text stays inside its own box
lines: list[tuple[str, float, float, bool, Any]] = [
(w.get("wo_base") or "", 20, 12, True, None),
(
f"Lot {w.get('lot') or ''} Seq {'' if w.get('seq') is None else w.get('seq')} {w.get('op') or ''}",
36,
8.5,
False,
None,
),
(f"Target {fmt_num(w.get('target_hrs'))} h Qty {fmt_num(w.get('qty'))}", 50, 8.5, False, None),
(
f"Drawing {w.get('dwg') or p.get('drawing') or ''} Rev {w.get('rev') or p.get('rev') or ''}",
64,
7.5,
False,
GREY,
),
]
for s, dy, size, bold, color in lines:
text, z = fit_line(s, tw, size, bold=bold)
d.tx(text, tx0, y + dy, size=z, bold=bold, **({"color": color} if color else {}))
if col == 1 or i == len(wos) - 1:
y += QR_BOX_H + 8
y += QR_BOX_H + QR_GAP
if not wos:
y = d.note_box("No work orders were read from this packet.", y, True)
+2
View File
@@ -87,6 +87,8 @@ class Settings(BaseSettings):
session_idle_minutes: int = Field(default=15, ge=1)
session_lock_minutes: int = Field(default=3, ge=1)
# Admin and Clerical sit at their own desks: sign-out and screen lock wait at least this long (issue #45)
session_desk_minutes: int = Field(default=60, ge=1)
session_hidden_lock_seconds: int = Field(default=60, ge=0)
session_password_hours: int = Field(default=12, ge=1)
session_pin_hours: int = Field(default=10, ge=1)