Compare commits

..

No commits in common. "a310b0a8b945f3d4ae6a7162089a9c6aab42dde3" and "da8ce9147fc2a68870a86b5143ac66185d710472" have entirely different histories.

4 changed files with 13 additions and 94 deletions

View File

@ -61,15 +61,6 @@ 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,40 +19,24 @@
color: #fff;
}
.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__logo { display: flex; align-items: center; gap: 10px; }
.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; white-space: nowrap; font-weight: 700; font-size: 17px; letter-spacing: .06em; line-height: 1.15; }
.mast__word b { display: block; 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; white-space: nowrap; font-size: 11.5px; opacity: .75; margin-top: -1px; letter-spacing: .02em; }
.mast__word small { display: block; 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; white-space: nowrap; }
.mast__app { font-size: 17px; font-weight: 600; }
.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; }
@ -149,45 +133,13 @@
}
@media (max-width: 900px) {
/* 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__body { flex-direction: column; }
.shell__nav {
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;
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;
}
.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; }
.nav__h { display: none; }
.nav__group { flex-direction: row; flex-wrap: wrap; }
.shell__main { padding: 20px 16px 48px; }
}

View File

@ -1,4 +1,3 @@
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'
@ -31,14 +30,6 @@ 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 }
@ -67,17 +58,6 @@ 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">
@ -91,10 +71,7 @@ export default function Shell() {
</header>
<div className="shell__body">
{/* 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}>
<nav className="shell__nav" aria-label="Pipeline">
<NavLink to="/" end className={({ isActive }) => 'nav__link' + (isActive ? ' is-on' : '')}>
<span className="nav__name">Overview</span>
</NavLink>

View File

@ -46,9 +46,8 @@ 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|kyc)\b/gi, (m) => m.toUpperCase()))
.replace(/\b(sme|uw|rm|posp|csr|ops|kyc)\b/gi, (m) => m.toUpperCase()))
.join(', ')
return (