A separate app from the desktop console, talking to the same platform with the same client. Not a breakpoint on the old one: the two answer different questions. The console answers "what is the state of the book?" across a wide grid; a phone answers "what needs me, and what happened to this one?" in a column you scroll with a thumb. The old console at 390px showed why. Tables scrolled sideways, nine queues stacked above the content ate the first screenful, and the action you came to perform sat below the fold. So every row of every table is a CARD — customer, stage, when it is due, who is holding it, and one line about what is happening. Everything else is one tap away. The queues live in a drawer. The action a lead is waiting on is pinned to the bottom of the screen, where a thumb already is. Shared with the console, because a divergence would be two apps disagreeing about the same lead: the whole api/ layer, ActivityForm, and Timeline — whose audit-row merging (one submission writes three rows) is hard-won and must not be reimplemented twice. Written fresh: the shell, the three screens, and the stylesheet. The colour rule is unchanged and is the product in four colours: blue the AI holds it, amber a person, teal the customer, red risk and nothing else. A PWA, so Add to Home Screen gives a full-screen app; the layout pads for the notch and the home indicator. Verified at 402x874: login, overview, drawer, a queue, and a lead, with no horizontal overflow on any of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
161 lines
7.3 KiB
JavaScript
161 lines
7.3 KiB
JavaScript
import { useState } from 'react'
|
||
import { NavLink, Outlet, useNavigate } 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'
|
||
import { useZino } from '../api/provider.jsx'
|
||
|
||
|
||
/**
|
||
* The frame: a navy bar that stays put, and a drawer behind the hamburger.
|
||
*
|
||
* On a phone the queues cannot live beside the content, and stacked above it
|
||
* they cost a screenful before the first lead. So they go in a drawer, and the
|
||
* bar carries the only two things worth permanent space: where you are, and
|
||
* who you are.
|
||
*/
|
||
|
||
/** Two letters for the avatar: a name if we have one, else the local part. */
|
||
function initials(user) {
|
||
const from = user?.name || user?.email || ''
|
||
const parts = from.replace(/@.*$/, '').split(/[\s._-]+/).filter(Boolean)
|
||
if (!parts.length) return '?'
|
||
return (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
|
||
}
|
||
|
||
/** The badge colour is the colour of whoever is being waited on. */
|
||
function tone(stage) {
|
||
if (stage.kind === 'needs') return 'red'
|
||
if (stage.kind === 'customer') return 'amber'
|
||
return 'grey'
|
||
}
|
||
|
||
/** A queue's icon, chosen for what it is waiting on rather than decoration. */
|
||
function NavIcon({ kind }) {
|
||
if (kind === 'needs') {
|
||
return <svg viewBox="0 0 20 20" fill="none"><path d="M6 2.5h5.5L15 6v11.5H6z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/><path d="M11 2.5V6h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/></svg>
|
||
}
|
||
if (kind === 'customer') {
|
||
return <svg viewBox="0 0 20 20" fill="none"><path d="M3 4.5h14v9h-8l-3.5 3v-3H3z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/></svg>
|
||
}
|
||
if (kind === 'waiting') {
|
||
return <svg viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="7" stroke="currentColor" strokeWidth="1.5"/><path d="M10 6v4.2l2.6 1.6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
|
||
}
|
||
return <svg viewBox="0 0 20 20" fill="none"><path d="M4 10.5 8 14l8-8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||
}
|
||
|
||
export default function Shell() {
|
||
const { user, signOut } = useZino()
|
||
const navigate = useNavigate()
|
||
const roles = rolesOf(user)
|
||
const stages = visibleStages(roles)
|
||
const { counts, ready } = useStageCounts()
|
||
const [open, setOpen] = useState(false)
|
||
const [me, setMe] = useState(false)
|
||
|
||
const count = (s) => counts[s.uid] || { total: 0, waiting: 0, working: 0, urgent: 0 }
|
||
const openTotal = STAGES.filter((s) => s.kind !== 'end').reduce((t, s) => t + count(s).total, 0)
|
||
|
||
const row = (s) => {
|
||
const c = count(s)
|
||
const n = s.kind === 'end' ? c.total : c.waiting
|
||
const t = tone(s)
|
||
return (
|
||
<NavLink
|
||
key={s.uid} to={`/stage/${s.uid}`} onClick={() => setOpen(false)}
|
||
className={({ isActive }) => 'nav nav--' + (s.kind === 'customer' ? 'teal' : s.kind === 'needs' ? 'red' : 'blue') + (isActive ? ' is-on' : '')}
|
||
>
|
||
<span className="nav__i" aria-hidden="true"><NavIcon kind={s.kind} /></span>
|
||
<span className="nav__t">{s.need ?? s.name}</span>
|
||
{ready ? <span className={`nav__n nav__n--${n ? t : 'grey'}`}>{n}</span> : null}
|
||
</NavLink>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="shell">
|
||
<header className="appbar">
|
||
<button type="button" className="appbar__burger" aria-label="Open the menu" onClick={() => setOpen(true)}>
|
||
<svg viewBox="0 0 22 22" fill="none"><path d="M3 6h16M3 11h16M3 16h16" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/></svg>
|
||
</button>
|
||
<div className="appbar__brand">
|
||
<span className="appbar__z" aria-hidden="true">Z</span>
|
||
<span className="appbar__word">ZURICH <span>kotak</span></span>
|
||
</div>
|
||
<button type="button" className="appbar__me" aria-label="Your account" onClick={() => setMe(true)}>
|
||
{initials(user)}
|
||
</button>
|
||
</header>
|
||
|
||
<main>
|
||
<Outlet />
|
||
</main>
|
||
|
||
{open ? (
|
||
<>
|
||
<div className="scrim" role="presentation" onClick={() => setOpen(false)} />
|
||
<nav className="drawer" aria-label="Queues">
|
||
<div className="drawer__head">
|
||
<button type="button" className="drawer__x" aria-label="Close the menu" onClick={() => setOpen(false)}>
|
||
<svg viewBox="0 0 20 20" fill="none"><path d="M5 5l10 10M15 5L5 15" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round"/></svg>
|
||
</button>
|
||
<span className="appbar__z" aria-hidden="true">Z</span>
|
||
<span className="appbar__word">ZURICH <span>kotak</span></span>
|
||
</div>
|
||
<div className="drawer__body">
|
||
<NavLink to="/" end onClick={() => setOpen(false)} className={({ isActive }) => 'nav' + (isActive ? ' is-on' : '')}>
|
||
<span className="nav__i" aria-hidden="true">
|
||
<svg viewBox="0 0 20 20" fill="none"><path d="M3.5 3.5h6v6h-6zM10.5 3.5h6v6h-6zM3.5 10.5h6v6h-6zM10.5 10.5h6v6h-6z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/></svg>
|
||
</span>
|
||
<span className="nav__t">Overview</span>
|
||
</NavLink>
|
||
<NavLink to="/stage/all" onClick={() => setOpen(false)} className={({ isActive }) => 'nav' + (isActive ? ' is-on' : '')}>
|
||
<span className="nav__i" aria-hidden="true">
|
||
<svg viewBox="0 0 20 20" fill="none"><circle cx="7" cy="7" r="2.6" stroke="currentColor" strokeWidth="1.5"/><path d="M2.5 16a4.5 4.5 0 0 1 9 0" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/><path d="M13 5.2a2.6 2.6 0 0 1 0 5M14.5 16a4.4 4.4 0 0 0-2-3.7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
|
||
</span>
|
||
<span className="nav__t">All leads</span>
|
||
{ready ? <span className="nav__n">{openTotal}</span> : null}
|
||
</NavLink>
|
||
|
||
{NAV_GROUPS.map((group) => {
|
||
const inGroup = stages.filter((s) => group.kinds.includes(s.kind))
|
||
if (!inGroup.length) return null
|
||
return (
|
||
<div key={group.label}>
|
||
<h6 className="drawer__h">{group.label}</h6>
|
||
{inGroup.map(row)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</nav>
|
||
</>
|
||
) : null}
|
||
|
||
{me ? (
|
||
<>
|
||
<div className="scrim" style={{ zIndex: 60 }} role="presentation" onClick={() => setMe(false)} />
|
||
<div className="sheet" role="dialog" aria-label="Your account">
|
||
<span className="sheet__grab" aria-hidden="true" />
|
||
<div className="sheet__head">
|
||
<div>
|
||
<h2>{user?.name || user?.email || 'Signed in'}</h2>
|
||
<p>{user?.email}</p>
|
||
</div>
|
||
<button type="button" className="sheet__x" aria-label="Close" onClick={() => setMe(false)}>×</button>
|
||
</div>
|
||
<div className="sheet__body">
|
||
<button
|
||
type="button" className="btn btn--ghost btn--block"
|
||
onClick={() => { setMe(false); signOut(); navigate('/login') }}
|
||
>
|
||
Sign out
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|