Compare commits

..

No commits in common. "de8b158ca9d70ed328d697a2003e4d2aab1431e0" and "4b590474e4526cc8e3f2ad13d924f34e5c850bad" have entirely different histories.

5 changed files with 72 additions and 131 deletions

View File

@ -195,6 +195,41 @@
animation: zk-rise 0.28s var(--ease) both; animation: zk-rise 0.28s var(--ease) both;
} }
/* ── Primary action ────────────────────────────────────────── */
.shell__add {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: 0 2px 22px;
padding: 11px 14px;
border-radius: var(--r-md);
text-decoration: none;
font-size: 0.86rem;
font-weight: 500;
color: var(--zk-white);
background: var(--grad-blue);
box-shadow: var(--sh-sm);
transition: box-shadow var(--t), transform var(--t-fast), background var(--t);
}
.shell__add:hover {
background: var(--grad-blue-hover);
box-shadow: var(--sh-md);
transform: translateY(-1px);
}
.shell__add:active {
transform: none;
box-shadow: var(--sh-xs);
}
.shell__add svg {
width: 15px;
height: 15px;
flex: none;
}
/* ── Narrow viewports: the rail lies down and scrolls ──────── */ /* ── Narrow viewports: the rail lies down and scrolls ──────── */
@media (max-width: 900px) { @media (max-width: 900px) {
.shell__body { .shell__body {

View File

@ -1,6 +1,6 @@
import { NavLink, Outlet } from 'react-router-dom' import { NavLink, Outlet } from 'react-router-dom'
import { NAV_GROUPS } from '../api/config.js' import { ENTRY, NAV_GROUPS } from '../api/config.js'
import { rolesOf, visibleStages } from '../api/permissions.js' import { entryDoorsFor, rolesOf, visibleStages } from '../api/permissions.js'
import { useStageCounts } from '../api/portfolio.jsx' import { useStageCounts } from '../api/portfolio.jsx'
import { useZino } from '../api/provider.jsx' import { useZino } from '../api/provider.jsx'
import UserMenu from './UserMenu.jsx' import UserMenu from './UserMenu.jsx'
@ -36,6 +36,7 @@ export default function Shell() {
// The sidebar shows the queues this role WORKS in. Reporting on the rest // The sidebar shows the queues this role WORKS in. Reporting on the rest
// lives on the overview, which counts the whole book for everyone. // lives on the overview, which counts the whole book for everyone.
const stages = visibleStages(roles) const stages = visibleStages(roles)
const canFile = entryDoorsFor(roles, ENTRY).length > 0
const { counts, ready } = useStageCounts() const { counts, ready } = useStageCounts()
// One queue row. Extracted because the live groups and the folded Closed // One queue row. Extracted because the live groups and the folded Closed
@ -97,11 +98,15 @@ export default function Shell() {
All leads All leads
</NavLink> </NavLink>
{/* "New lead" used to sit here. A sidebar is for moving between {canFile ? (
places; filing a lead is an ACTION, and an action in a list of <NavLink to="/add" className="shell__add">
destinations is the one item that does not behave like its <svg viewBox="0 0 16 16" aria-hidden="true">
neighbours. It lives on the screens where leads are looked at <path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor" strokeWidth="1.7"
the overview and the queues beside the thing it adds to. */} strokeLinecap="round" />
</svg>
New lead
</NavLink>
) : null}
{NAV_GROUPS.filter((g) => g.kind !== 'end').map((group) => { {NAV_GROUPS.filter((g) => g.kind !== 'end').map((group) => {
const inGroup = stages.filter((s) => s.kind === group.kind) const inGroup = stages.filter((s) => s.kind === group.kind)

View File

@ -2,8 +2,8 @@ import { useMemo } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { usePortfolio } from '../api/portfolio.jsx' import { usePortfolio } from '../api/portfolio.jsx'
import { useZino } from '../api/provider.jsx' import { useZino } from '../api/provider.jsx'
import { ENTRY, STAGES, phaseOf } from '../api/config.js' import { STAGES, phaseOf } from '../api/config.js'
import { entryDoorsFor, rolesOf, visibleStages } from '../api/permissions.js' import { rolesOf, visibleStages } from '../api/permissions.js'
import { describeError } from '../api/errors.js' import { describeError } from '../api/errors.js'
import './screens.css' import './screens.css'
@ -58,7 +58,6 @@ export default function Overview() {
const { user } = useZino() const { user } = useZino()
const navigate = useNavigate() const navigate = useNavigate()
const roles = rolesOf(user) const roles = rolesOf(user)
const canFile = entryDoorsFor(roles, ENTRY).length > 0
const state = usePortfolio() const state = usePortfolio()
const m = useMemo(() => { const m = useMemo(() => {
@ -168,25 +167,11 @@ export default function Overview() {
<h1 className="page__title">Portfolio overview</h1> <h1 className="page__title">Portfolio overview</h1>
<p className="page__sub">Motor and SME renewals · organisation-wide</p> <p className="page__sub">Motor and SME renewals · organisation-wide</p>
</div> </div>
{/* The action sits with the thing it acts on. It was in the sidebar,
which is for moving between places an action among destinations
is the one item that does not behave like its neighbours. */}
<div className="page__right">
{state.at ? ( {state.at ? (
<span className="stamp"> <span className="stamp">
Updated {state.at.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })} Updated {state.at.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })}
</span> </span>
) : null} ) : null}
{canFile ? (
<Link to="/add" className="newlead">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor"
strokeWidth="1.7" strokeLinecap="round" />
</svg>
New lead
</Link>
) : null}
</div>
</header> </header>
<div className="kpis"> <div className="kpis">

View File

@ -1,8 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { useZino } from '../api/provider.jsx' import { useZino } from '../api/provider.jsx'
import { CHANNELS, ENTRY, RV_LEADS, STAGES, phaseOf } from '../api/config.js' import { CHANNELS, RV_LEADS, STAGES, phaseOf } from '../api/config.js'
import { entryDoorsFor, rolesOf } from '../api/permissions.js'
import { describeError } from '../api/errors.js' import { describeError } from '../api/errors.js'
/** Short absolute date plus how long ago a queue needs both: the absolute /** Short absolute date plus how long ago a queue needs both: the absolute
@ -71,20 +70,12 @@ import './screens.css'
export default function Pipeline() { export default function Pipeline() {
const { stageUid } = useParams() const { stageUid } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const { client, user } = useZino() const { client } = useZino()
const canFile = entryDoorsFor(rolesOf(user), ENTRY).length > 0 // "all" is not a stage it is every open lead in one list. An operator
// "all" is not a stage it is every lead in one list, so an operator // wanting to find a lead should not have to guess which queue it is in.
// wanting to find one does not have to guess which queue it is in.
//
// IT MEANS ALL. This view used to drop closed leads, so a lead that was lost
// or onboarded disappeared from the page called "All leads" the one place
// somebody goes when they cannot find something. A lead that ended is still
// a lead, and "where did it go?" is exactly the question this page exists to
// answer. Closed rows are shown, and dimmed, and can be hidden by choice.
const isAll = stageUid === 'all' const isAll = stageUid === 'all'
const [hideClosed, setHideClosed] = useState(false)
const stage = isAll const stage = isAll
? { uid: 'all', name: 'All leads', kind: 'all', by: 'everyone' } ? { uid: 'all', name: 'All open leads', kind: 'all', by: 'everyone' }
: STAGES.find((s) => s.uid === stageUid) : STAGES.find((s) => s.uid === stageUid)
const [state, setState] = useState({ status: 'loading', rows: [], total: 0, error: null }) const [state, setState] = useState({ status: 'loading', rows: [], total: 0, error: null })
@ -108,9 +99,10 @@ export default function Pipeline() {
.then((res) => { .then((res) => {
if (cancelled) return if (cancelled) return
let rows = res?.data ?? res?.rows ?? res?.records ?? [] let rows = res?.data ?? res?.rows ?? res?.records ?? []
// Nothing is dropped here any more. Whether closed leads are shown if (isAll) {
// is a choice made below, on data already fetched a filter that const closed = new Set(STAGES.filter((x) => x.kind === 'end').map((x) => x.name))
// throws rows away at the query cannot be undone by a toggle. rows = rows.filter((r) => !closed.has(r.current_state_name))
}
// total_count is the size of the QUEUE; rows is one page of at most // total_count is the size of the QUEUE; rows is one page of at most
// 100 of it. Counting the page would quietly under-report a busy stage. // 100 of it. Counting the page would quietly under-report a busy stage.
const total = isAll ? rows.length : (res?.pagination?.total_count ?? rows.length) const total = isAll ? rows.length : (res?.pagination?.total_count ?? rows.length)
@ -132,14 +124,6 @@ export default function Pipeline() {
return () => { cancelled = true; clearInterval(id) } return () => { cancelled = true; clearInterval(id) }
}, [client, stageUid, stage?.name, isAll]) }, [client, stageUid, stage?.name, isAll])
// Split rather than filtered at the query, so the header can say how many
// are closed instead of silently showing fewer than the page claims.
const CLOSED = new Set(STAGES.filter((x) => x.kind === 'end').map((x) => x.name))
const closedCount = isAll ? state.rows.filter((r) => CLOSED.has(r.current_state_name)).length : 0
const shownRows = isAll && hideClosed
? state.rows.filter((r) => !CLOSED.has(r.current_state_name))
: state.rows
if (!stage) return <p className="empty">Unknown stage.</p> if (!stage) return <p className="empty">Unknown stage.</p>
return ( return (
@ -161,29 +145,9 @@ export default function Pipeline() {
</p> </p>
</div> </div>
{state.status === 'ready' ? ( {state.status === 'ready' ? (
<div className="page__right">
{/* Offered only where there is something to hide, and OFF by
default: this page's job is that nothing disappears from it. */}
{isAll && closedCount ? (
<label className="toggle">
<input type="checkbox" checked={hideClosed}
onChange={(ev) => setHideClosed(ev.target.checked)} />
Hide {closedCount} closed
</label>
) : null}
<div className="page__count"> <div className="page__count">
<strong>{isAll ? shownRows.length : state.total}</strong> <strong>{state.total}</strong>
<span>{(isAll ? shownRows.length : state.total) === 1 ? 'lead' : 'leads'}</span> <span>{state.total === 1 ? 'lead' : 'leads'}</span>
</div>
{canFile ? (
<Link to="/add" className="newlead">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor"
strokeWidth="1.7" strokeLinecap="round" />
</svg>
New lead
</Link>
) : null}
</div> </div>
) : null} ) : null}
</header> </header>
@ -202,11 +166,11 @@ export default function Pipeline() {
</div> </div>
) : null} ) : null}
{state.status === 'ready' && shownRows.length === 0 ? ( {state.status === 'ready' && state.rows.length === 0 ? (
<p className="empty">{isAll ? 'No leads yet.' : 'No records in this stage.'}</p> <p className="empty">No records in this stage.</p>
) : null} ) : null}
{state.status === 'ready' && shownRows.length > 0 ? ( {state.status === 'ready' && state.rows.length > 0 ? (
<div className="gridwrap"> <div className="gridwrap">
<table className="grid"> <table className="grid">
<thead> <thead>
@ -219,7 +183,7 @@ export default function Pipeline() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{shownRows.map((r) => { {state.rows.map((r) => {
const id = r.instance_id ?? r.id const id = r.instance_id ?? r.id
const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '—' } const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '—' }
const exp = expiry(r.renewal_due_date) const exp = expiry(r.renewal_due_date)
@ -232,9 +196,7 @@ export default function Pipeline() {
return ( return (
<tr <tr
key={id} key={id}
className={'grid__row' className={'grid__row' + (prog ? ' is-' + prog.state : '')}
+ (prog ? ' is-' + prog.state : '')
+ (CLOSED.has(r.current_state_name) ? ' is-done' : '')}
onClick={() => navigate(`/lead/${id}`)} onClick={() => navigate(`/lead/${id}`)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/lead/${id}`) } if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/lead/${id}`) }

View File

@ -1369,49 +1369,3 @@
background: var(--zk-amber-tint); background: var(--zk-amber-tint);
} }
.doing--stalled strong { color: var(--zk-amber-ink); } .doing--stalled strong { color: var(--zk-amber-ink); }
/* ---- header right-hand cluster ---- */
.page__right { display: flex; align-items: center; gap: 18px; flex: none; }
.toggle {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 0.78rem;
color: var(--zk-muted);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.toggle input { accent-color: var(--zk-blue); cursor: pointer; }
.toggle:hover { color: var(--zk-ink); }
/* A finished lead, still listed. Dimmed rather than removed: "where did it
go?" is the question this page exists to answer, and a lead that ended is
still a lead. Hover restores it it is muted, not disabled. */
.grid__row.is-done { color: var(--zk-grey); }
.grid__row.is-done .grid__name { color: var(--zk-muted); font-weight: 400; }
.grid__row.is-done:hover { color: var(--zk-ink); }
.grid__row.is-done:hover .grid__name { color: var(--zk-blue-dark); }
/* ---- file a lead ----
Moved off the sidebar. A nav is for moving between places; this adds
something, and it now sits beside the lists it adds to. */
.newlead {
display: inline-flex;
align-items: center;
gap: 8px;
flex: none;
padding: 9px 16px;
border-radius: var(--r-pill);
text-decoration: none;
font-size: 0.84rem;
font-weight: 500;
color: var(--zk-white);
background: var(--grad-blue);
box-shadow: var(--sh-sm);
transition: box-shadow var(--t), transform var(--t-fast), background var(--t);
}
.newlead svg { width: 14px; height: 14px; }
.newlead:hover { background: var(--grad-blue-hover); box-shadow: var(--sh-md); transform: translateY(-1px); }
.newlead:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 2px; }