Compare commits
No commits in common. "de8b158ca9d70ed328d697a2003e4d2aab1431e0" and "4b590474e4526cc8e3f2ad13d924f34e5c850bad" have entirely different histories.
de8b158ca9
...
4b590474e4
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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}`) }
|
||||||
|
|||||||
@ -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; }
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user