Compare commits

..

2 Commits

Author SHA1 Message Date
a310b0a8b9 console: close the drawer from the link, not from an effect
Also leaves the Closed fold openable without dismissing the menu.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:47:28 +05:30
607693a04a console: make the phone layout work
Opened at 390px the masthead wrapped inside its own 56px bar and clipped —
"ZURICH kotak", "Lead Desk / Lead to policy" and the user's name all on
three lines each — and the nine-item rail stacked above the content, so
every page began with a screenful of menu.

The masthead now keeps one line: the mark, the app name, the person, with
the secondary lines dropped below 900px and the app name below 560px,
where it cannot fit beside the user menu without truncating.

The rail becomes a drawer behind a button, over the page rather than above
it, closing on navigation and on a tap outside. Desktop is untouched: the
button is display:none, the rail is static, and the drawer rules live
entirely inside the media query.

Also: "Ops admin", not "OPS admin" — the acronym rule over-fired on a word.

Verified at 390x844 and 1440x900: no horizontal scroll, nothing clipped in
the masthead, the drawer closes on navigation, and the desktop rail keeps
its transform and its hidden button.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:46:27 +05:30
4 changed files with 94 additions and 13 deletions

View File

@ -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.

View File

@ -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; }
}

View File

@ -1,3 +1,4 @@
import { useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom'
import { NAV_GROUPS, STAGES } from '../api/config.js'
import { rolesOf, visibleStages } from '../api/permissions.js'
@ -30,6 +31,14 @@ 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)
// Closing on the link rather than on the route: a menu that stays open over
// the page you just chose is a menu you have to dismiss twice. Keyed off the
// anchor so opening the Closed fold which is not navigation leaves it up.
const closeIfLink = (e) => { if (e.target.closest('a')) setNavOpen(false) }
const count = (s) => counts[s.uid] || { total: 0, waiting: 0, working: 0, urgent: 0 }
@ -58,6 +67,17 @@ export default function Shell() {
return (
<div className="shell">
<header className="shell__top">
<button
type="button" className="mast__burger"
aria-label={navOpen ? 'Close the menu' : 'Open the menu'} aria-expanded={navOpen}
onClick={() => setNavOpen((o) => !o)}
>
<svg viewBox="0 0 20 20" aria-hidden="true">
{navOpen
? <path d="M5 5l10 10M15 5L5 15" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
: <path d="M3 6h14M3 10h14M3 14h14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />}
</svg>
</button>
<div className="mast__logo">
<span className="mast__z" aria-hidden="true">Z</span>
<div className="mast__word">
@ -71,7 +91,10 @@ export default function Shell() {
</header>
<div className="shell__body">
<nav className="shell__nav" aria-label="Pipeline">
{/* The scrim only exists while the drawer is open, and only on a phone:
tapping the page you can see should put the menu away. */}
{navOpen ? <button type="button" className="shell__scrim" aria-label="Close the menu" onClick={() => setNavOpen(false)} /> : null}
<nav className={'shell__nav' + (navOpen ? ' is-open' : '')} aria-label="Pipeline" onClick={closeIfLink}>
<NavLink to="/" end className={({ isActive }) => 'nav__link' + (isActive ? ' is-on' : '')}>
<span className="nav__name">Overview</span>
</NavLink>

View File

@ -46,8 +46,9 @@ export default function UserMenu() {
// Role slugs read as titles: partner_agent -> Partner agent.
const roles = (user?.roles || [])
// `sme_underwriter` "SME underwriter": the acronyms the roles here use stay upper.
// `sme_underwriter` "SME underwriter". Ops is a word, not an acronym.
.map((r) => String(r).replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase())
.replace(/\b(sme|uw|rm|posp|csr|ops|kyc)\b/gi, (m) => m.toUpperCase()))
.replace(/\b(sme|uw|rm|posp|csr|kyc)\b/gi, (m) => m.toUpperCase()))
.join(', ')
return (