diff --git a/CLAUDE.md b/CLAUDE.md index 38ffbfb..e3a62ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,15 @@ Workflow config is seeded outside this repo, from `sm2/custom-apps/zurich-kotak/ - `src/components/ActivityForm.jsx` — renders the **live** activity schema and submits it back. `src/components/FileField.jsx` handles `file`/`ocr` fields; `src/components/Timeline.jsx` renders the audit trail as a story. +- `src/api/thread.js` — the WhatsApp conversation, assembled once from the audit rows + and shared by the timeline entry and the dialog. `turns` is every message deduped + (one submission is recorded up to three times); `episodes` are its contiguous bursts, + each carrying the workflow steps that ran before it; `byRow` says which steps sent a + message from inside their own trigger. The trail renders ONE entry per burst and never + a turn — a burst rather than the whole thread, because the payment exchange is the same + conversation resumed twenty minutes and four steps later, and one entry for all of it + would date those messages to the moment of the first. `THREAD_FIELDS` is why no step + quotes `customer_reply` / `customer_answer` itself: the thread owns them. Routing: `/stage/:stageUid`, `/lead/:instanceId`, `/add`; unauthenticated renders `Login` for every path. diff --git a/src/layout/Shell.css b/src/layout/Shell.css index 0e5cb10..1385309 100644 --- a/src/layout/Shell.css +++ b/src/layout/Shell.css @@ -19,24 +19,40 @@ color: #fff; } -.mast__logo { display: flex; align-items: center; gap: 10px; } +.mast__burger { + display: none; /* a phone-only control; see the media block */ + width: 34px; height: 34px; flex: none; + place-items: center; margin-left: -6px; + border: 0; border-radius: 8px; background: transparent; color: #fff; cursor: pointer; +} +.mast__burger svg { width: 20px; height: 20px; } +.mast__burger:hover { background: rgba(255, 255, 255, .12); } +.mast__burger:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(255, 255, 255, .28); } + +.mast__logo { display: flex; align-items: center; gap: 10px; min-width: 0; } .mast__z { width: 30px; height: 30px; border-radius: 50%; background: #fff; color: var(--zk-navy); font-weight: 700; font-size: 17px; display: grid; place-items: center; } -.mast__word b { display: block; font-weight: 700; font-size: 17px; letter-spacing: .06em; line-height: 1.15; } +.mast__word b { display: block; white-space: nowrap; font-weight: 700; font-size: 17px; letter-spacing: .06em; line-height: 1.15; } .mast__word b span { font-weight: 500; letter-spacing: 0; } -.mast__word small { display: block; font-size: 11.5px; opacity: .75; margin-top: -1px; letter-spacing: .02em; } +.mast__word small { display: block; white-space: nowrap; font-size: 11.5px; opacity: .75; margin-top: -1px; letter-spacing: .02em; } .mast__sep { width: 1px; height: 26px; background: rgba(255, 255, 255, .22); } -.mast__app { font-size: 17px; font-weight: 600; } +.mast__app { font-size: 17px; font-weight: 600; white-space: nowrap; } .mast__app span { font-weight: 400; opacity: .7; margin-left: 8px; font-size: 15px; } .mast__user { margin-left: auto; } +.shell__scrim { + display: none; + position: fixed; inset: 56px 0 0 0; z-index: 35; + border: 0; padding: 0; background: rgba(11, 42, 91, .35); cursor: default; +} + /* ── Frame ────────────────────────────────────────────────────────────────── */ .shell__body { display: flex; flex: 1; min-height: 0; align-items: stretch; } @@ -133,13 +149,45 @@ } @media (max-width: 900px) { - .shell__body { flex-direction: column; } + /* ── PHONE ────────────────────────────────────────────────────────────── + The masthead keeps one line and one line only: the mark, the app name, + the person. Everything secondary goes, because a 56px bar that wraps is + a bar that clips, which is what it did. + + The rail becomes a drawer over the page rather than a block above it — + nine queues stacked at the top meant scrolling past the whole menu on + every screen before reaching a single lead. */ + .shell__top { gap: 12px; padding: 0 14px; } + .mast__burger { display: grid; } + .mast__sep, + .mast__word small, + .mast__app span { display: none; } + .mast__word b { font-size: 15.5px; } + .mast__app { font-size: 15.5px; } + + .shell__body { flex-direction: row; } .shell__nav { - position: static; width: 100%; min-height: 0; max-height: none; - border-right: 0; border-bottom: 1px solid var(--zk-line); - flex-direction: row; flex-wrap: wrap; padding: 10px 12px; + position: fixed; top: 56px; left: 0; bottom: 0; z-index: 40; + width: 268px; max-width: 82vw; min-height: 0; max-height: none; + border-right: 1px solid var(--zk-line); + transform: translateX(-100%); + transition: transform .22s var(--ease); + overflow-y: auto; overscroll-behavior: contain; } - .nav__h { display: none; } - .nav__group { flex-direction: row; flex-wrap: wrap; } - .shell__main { padding: 20px 16px 48px; } + .shell__nav.is-open { transform: none; box-shadow: 0 12px 40px rgba(11, 42, 91, .22); } + .shell__scrim { display: block; } + .shell__main { padding: 20px 16px 48px; min-width: 0; flex: 1; } +} + +@media (max-width: 560px) { + /* Below this the app name and the wordmark cannot both fit beside the user + menu without one of them truncating. The mark wins — it is the brand. */ + .mast__app { display: none; } + .um__id { display: none; } + .um__caret { display: none; } + .um__trigger { padding: 4px; } +} + +@media (prefers-reduced-motion: reduce) { + .shell__nav { transition: none; } } diff --git a/src/layout/Shell.jsx b/src/layout/Shell.jsx index 5da293b..9e1fb8b 100644 --- a/src/layout/Shell.jsx +++ b/src/layout/Shell.jsx @@ -1,4 +1,5 @@ -import { NavLink, Outlet } from 'react-router-dom' +import { useEffect, useState } from 'react' +import { NavLink, Outlet, useLocation } from 'react-router-dom' import { NAV_GROUPS, STAGES } from '../api/config.js' import { rolesOf, visibleStages } from '../api/permissions.js' import { useStageCounts } from '../api/portfolio.jsx' @@ -30,6 +31,12 @@ export default function Shell() { const roles = rolesOf(user) const stages = visibleStages(roles) const { counts, ready } = useStageCounts() + // On a phone the rail becomes a drawer. It is closed on every navigation, + // because a menu that stays open over the page you just chose is a menu you + // have to dismiss twice. Desktop never reads this — the rail is always there. + const [navOpen, setNavOpen] = useState(false) + const { pathname } = useLocation() + useEffect(() => { setNavOpen(false) }, [pathname]) const count = (s) => counts[s.uid] || { total: 0, waiting: 0, working: 0, urgent: 0 } @@ -58,6 +65,17 @@ export default function Shell() { return (
+
@@ -71,7 +89,10 @@ export default function Shell() {
-