Compare commits
2 Commits
da8ce9147f
...
a310b0a8b9
| Author | SHA1 | Date | |
|---|---|---|---|
| a310b0a8b9 | |||
| 607693a04a |
@ -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
|
- `src/components/ActivityForm.jsx` — renders the **live** activity schema and submits it
|
||||||
back. `src/components/FileField.jsx` handles `file`/`ocr` fields;
|
back. `src/components/FileField.jsx` handles `file`/`ocr` fields;
|
||||||
`src/components/Timeline.jsx` renders the audit trail as a story.
|
`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
|
Routing: `/stage/:stageUid`, `/lead/:instanceId`, `/add`; unauthenticated renders
|
||||||
`Login` for every path.
|
`Login` for every path.
|
||||||
|
|||||||
@ -19,24 +19,40 @@
|
|||||||
color: #fff;
|
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 {
|
.mast__z {
|
||||||
width: 30px; height: 30px; border-radius: 50%;
|
width: 30px; height: 30px; border-radius: 50%;
|
||||||
background: #fff; color: var(--zk-navy);
|
background: #fff; color: var(--zk-navy);
|
||||||
font-weight: 700; font-size: 17px;
|
font-weight: 700; font-size: 17px;
|
||||||
display: grid; place-items: center;
|
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 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__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__app span { font-weight: 400; opacity: .7; margin-left: 8px; font-size: 15px; }
|
||||||
|
|
||||||
.mast__user { margin-left: auto; }
|
.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 ────────────────────────────────────────────────────────────────── */
|
/* ── Frame ────────────────────────────────────────────────────────────────── */
|
||||||
.shell__body { display: flex; flex: 1; min-height: 0; align-items: stretch; }
|
.shell__body { display: flex; flex: 1; min-height: 0; align-items: stretch; }
|
||||||
|
|
||||||
@ -133,13 +149,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@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 {
|
.shell__nav {
|
||||||
position: static; width: 100%; min-height: 0; max-height: none;
|
position: fixed; top: 56px; left: 0; bottom: 0; z-index: 40;
|
||||||
border-right: 0; border-bottom: 1px solid var(--zk-line);
|
width: 268px; max-width: 82vw; min-height: 0; max-height: none;
|
||||||
flex-direction: row; flex-wrap: wrap; padding: 10px 12px;
|
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; }
|
.shell__nav.is-open { transform: none; box-shadow: 0 12px 40px rgba(11, 42, 91, .22); }
|
||||||
.nav__group { flex-direction: row; flex-wrap: wrap; }
|
.shell__scrim { display: block; }
|
||||||
.shell__main { padding: 20px 16px 48px; }
|
.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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { NavLink, Outlet } from 'react-router-dom'
|
import { NavLink, Outlet } from 'react-router-dom'
|
||||||
import { NAV_GROUPS, STAGES } from '../api/config.js'
|
import { NAV_GROUPS, STAGES } from '../api/config.js'
|
||||||
import { rolesOf, visibleStages } from '../api/permissions.js'
|
import { rolesOf, visibleStages } from '../api/permissions.js'
|
||||||
@ -30,6 +31,14 @@ export default function Shell() {
|
|||||||
const roles = rolesOf(user)
|
const roles = rolesOf(user)
|
||||||
const stages = visibleStages(roles)
|
const stages = visibleStages(roles)
|
||||||
const { counts, ready } = useStageCounts()
|
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 }
|
const count = (s) => counts[s.uid] || { total: 0, waiting: 0, working: 0, urgent: 0 }
|
||||||
|
|
||||||
@ -58,6 +67,17 @@ export default function Shell() {
|
|||||||
return (
|
return (
|
||||||
<div className="shell">
|
<div className="shell">
|
||||||
<header className="shell__top">
|
<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">
|
<div className="mast__logo">
|
||||||
<span className="mast__z" aria-hidden="true">Z</span>
|
<span className="mast__z" aria-hidden="true">Z</span>
|
||||||
<div className="mast__word">
|
<div className="mast__word">
|
||||||
@ -71,7 +91,10 @@ export default function Shell() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="shell__body">
|
<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' : '')}>
|
<NavLink to="/" end className={({ isActive }) => 'nav__link' + (isActive ? ' is-on' : '')}>
|
||||||
<span className="nav__name">Overview</span>
|
<span className="nav__name">Overview</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|||||||
@ -46,8 +46,9 @@ export default function UserMenu() {
|
|||||||
// Role slugs read as titles: partner_agent -> Partner agent.
|
// Role slugs read as titles: partner_agent -> Partner agent.
|
||||||
const roles = (user?.roles || [])
|
const roles = (user?.roles || [])
|
||||||
// `sme_underwriter` → "SME underwriter": the acronyms the roles here use stay upper.
|
// `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())
|
.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(', ')
|
.join(', ')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user