Make the console operable: add a lead, work a lead
The pipeline could be looked at but not driven. Adds the three pieces that make the machine walkable end to end from the UI. - ActivityForm renders whatever /view/form-screens returns — labels, types, select options, mandatory flags — and submits it back. No form is defined in this repo, so a field added in Studio appears here with no code change. - AddLead presents the three doors. Each is a separate INIT activity with its own permissions; the bank one lands further along because the bank already did CKYC. - Lead shows the file grouped in the order it was worked, ending with what the sourcing agent earns, plus the activities this state allows and who normally performs each. The "who normally performs this" label is presentational only. Nothing here enforces anything — the workflow refuses server-side and the form reports what it said, including a note when a 403 is the platform declining rather than the console misbehaving. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7a86118169
commit
5ea627b064
@ -4,6 +4,7 @@ import Login from './pages/Login.jsx'
|
||||
import Shell from './layout/Shell.jsx'
|
||||
import Pipeline from './screens/Pipeline.jsx'
|
||||
import Lead from './screens/Lead.jsx'
|
||||
import AddLead from './screens/AddLead.jsx'
|
||||
|
||||
export default function App() {
|
||||
const { isAuthed } = useZino()
|
||||
@ -23,6 +24,7 @@ export default function App() {
|
||||
<Route element={<Shell />}>
|
||||
<Route path="/" element={<Navigate to="/stage/zk-state-referred" replace />} />
|
||||
<Route path="/stage/:stageUid" element={<Pipeline />} />
|
||||
<Route path="/add" element={<AddLead />} />
|
||||
<Route path="/lead/:instanceId" element={<Lead />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
|
||||
@ -43,3 +43,42 @@ export const CHANNELS = {
|
||||
/** View source-uids. Seeded by sm2/custom-apps/zurich-kotak/04_views.sql. */
|
||||
export const RV_LEADS = 'zk-rv-leads'
|
||||
export const DV_LEAD = 'zk-dv-lead'
|
||||
|
||||
/**
|
||||
* Which activities are runnable from which state, and who is expected to do it.
|
||||
*
|
||||
* Mirrors workflow.tbl_wf_state_allowed_activities by hand — the same reason
|
||||
* STAGES is duplicated: the console needs the ORDER and the labels, and the API
|
||||
* returns neither.
|
||||
*
|
||||
* `by` is presentational ONLY. Nothing here enforces anything: the workflow
|
||||
* refuses server-side and the console reports what it said. Showing an action
|
||||
* the signed-in user cannot perform is deliberate — the refusal is the demo.
|
||||
*/
|
||||
export const ACTIONS = {
|
||||
'zk-state-new': [{ uid: 'zk-act-qualify', label: 'Qualify Lead', by: 'Intake AI' }],
|
||||
'zk-state-qualified': [{ uid: 'zk-act-contact', label: 'Log Contact', by: 'Engage AI' }],
|
||||
'zk-state-contacted': [{ uid: 'zk-act-capture-sme', label: 'Capture SME Risk', by: 'Engage AI' },
|
||||
{ uid: 'zk-act-capture-motor', label: 'Capture Motor Risk', by: 'Engage AI' }],
|
||||
'zk-state-risk': [{ uid: 'zk-act-advise', label: 'AI Cover Recommendation', by: 'Advisor AI' },
|
||||
{ uid: 'zk-act-quote', label: 'Generate Quote', by: 'Rating engine' }],
|
||||
'zk-state-quoted': [{ uid: 'zk-act-accept', label: 'Accept Proposal', by: 'Customer' }],
|
||||
'zk-state-accepted': [{ uid: 'zk-act-kyc', label: 'Verify KYC', by: 'KYC AI' }],
|
||||
'zk-state-kyc': [{ uid: 'zk-act-uw-screen', label: 'Underwriting Screen', by: 'Rules' }],
|
||||
'zk-state-referred': [{ uid: 'zk-act-uw-prepare', label: 'Prepare Referral', by: 'Referral AI' },
|
||||
{ uid: 'zk-act-uw-clear', label: 'Clear Referral', by: 'Underwriter' },
|
||||
{ uid: 'zk-act-uw-decline', label: 'Decline Referral', by: 'Underwriter' }],
|
||||
'zk-state-cleared': [{ uid: 'zk-act-payment', label: 'Issue Payment Link', by: 'Ops' }],
|
||||
'zk-state-payment': [{ uid: 'zk-act-realise', label: 'Confirm Premium Realisation', by: 'Payment webhook' }],
|
||||
'zk-state-issued': [{ uid: 'zk-act-onboard', label: 'Complete Onboarding', by: 'Ops' }],
|
||||
}
|
||||
|
||||
/** The three doors. Each is a separate INIT activity with its own permissions. */
|
||||
export const ENTRY = [
|
||||
{ uid: 'zk-act-init-direct', label: 'Self-Serve Lead', channel: 'direct',
|
||||
note: 'Customer or call centre. Self-declared, no KYC.' },
|
||||
{ uid: 'zk-act-init-bank', label: 'Partner Bank Lead', channel: 'bancassurance',
|
||||
note: 'Bank RM. Carries the bank’s CKYC, so it starts at Qualified.' },
|
||||
{ uid: 'zk-act-init-agent', label: 'Partner Agent Lead', channel: 'agency',
|
||||
note: 'POSP or broker. This is the door the demo is about.' },
|
||||
]
|
||||
|
||||
43
src/components/ActivityForm.css
Normal file
43
src/components/ActivityForm.css
Normal file
@ -0,0 +1,43 @@
|
||||
.af { display: flex; flex-direction: column; gap: 18px; }
|
||||
.af__loading { color: var(--zk-muted); font-size: .86rem; }
|
||||
.af__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px 18px; }
|
||||
.af__field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.af__field--wide { grid-column: 1 / -1; }
|
||||
.af__field > span {
|
||||
font-size: .74rem; font-weight: 500; color: var(--zk-muted);
|
||||
letter-spacing: .02em; display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.af__req {
|
||||
font-style: normal; font-size: .6rem; letter-spacing: .07em; text-transform: uppercase;
|
||||
color: var(--zk-blue); background: var(--zk-tint-blue); padding: 1px 5px; border-radius: 3px;
|
||||
}
|
||||
.af input, .af select, .af textarea {
|
||||
font: inherit; font-size: .88rem; padding: 8px 10px; border-radius: 4px;
|
||||
border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-ink); width: 100%;
|
||||
}
|
||||
.af input:focus, .af select:focus, .af textarea:focus {
|
||||
outline: none; border-color: var(--zk-blue); box-shadow: 0 0 0 3px var(--zk-tint-blue);
|
||||
}
|
||||
.af textarea { resize: vertical; }
|
||||
.af__multi { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.af__chip {
|
||||
font: inherit; font-size: .76rem; padding: 5px 10px; border-radius: 4px; cursor: pointer;
|
||||
border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-muted);
|
||||
}
|
||||
.af__chip.is-on { background: var(--zk-blue); border-color: var(--zk-blue); color: var(--zk-white); }
|
||||
.af__actions { display: flex; justify-content: flex-end; gap: 10px; }
|
||||
.af__submit {
|
||||
font: inherit; font-size: .88rem; font-weight: 500; padding: 9px 22px; border-radius: 4px;
|
||||
cursor: pointer; border: 1px solid var(--zk-blue); background: var(--zk-blue); color: var(--zk-white);
|
||||
}
|
||||
.af__submit:disabled { opacity: .55; cursor: default; }
|
||||
.af__ghost {
|
||||
font: inherit; font-size: .88rem; padding: 9px 18px; border-radius: 4px; cursor: pointer;
|
||||
border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-muted);
|
||||
}
|
||||
.af__err {
|
||||
background: #fdf0ef; border: 1px solid #f0c9c6; border-left: 3px solid var(--zk-danger);
|
||||
border-radius: 0 4px 4px 0; padding: 11px 14px; font-size: .84rem; color: var(--zk-ink);
|
||||
}
|
||||
.af__err strong { font-family: ui-monospace, monospace; margin-right: 6px; }
|
||||
.af__err p { margin: 6px 0 0; font-size: .8rem; color: var(--zk-muted); }
|
||||
113
src/components/ActivityForm.jsx
Normal file
113
src/components/ActivityForm.jsx
Normal file
@ -0,0 +1,113 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useZino } from '../api/provider.jsx'
|
||||
import './ActivityForm.css'
|
||||
|
||||
/**
|
||||
* Renders whatever /view/form-screens returns for an activity — labels, types,
|
||||
* select options and which fields are mandatory — and submits it straight back.
|
||||
*
|
||||
* Nothing about this form is defined in the frontend. Add a field to the
|
||||
* activity in Studio, redeploy, and it appears here with no code change. That
|
||||
* is the point: the workflow is the source of truth, and a hardcoded form would
|
||||
* quietly drift from it.
|
||||
*/
|
||||
export default function ActivityForm({ activityUid, instanceId, onDone, onCancel }) {
|
||||
const { client } = useZino()
|
||||
const [schema, setSchema] = useState(null)
|
||||
const [values, setValues] = useState({})
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let dead = false
|
||||
setSchema(null); setError(null); setValues({})
|
||||
client.formSchema(activityUid, instanceId)
|
||||
.then((s) => { if (!dead) setSchema(s) })
|
||||
.catch((e) => { if (!dead) setError(e) })
|
||||
return () => { dead = true }
|
||||
}, [client, activityUid, instanceId])
|
||||
|
||||
function set(id, v) { setValues((p) => ({ ...p, [id]: v })) }
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
// Send only fields the activity defines. A submission is schema-validated
|
||||
// and an unknown field is fatal, so empties are dropped rather than sent.
|
||||
const payload = {}
|
||||
for (const f of schema.fields) {
|
||||
const v = values[f.id]
|
||||
if (v === undefined || v === '' || (Array.isArray(v) && v.length === 0)) continue
|
||||
payload[f.id] = f.data_type === 'number' ? Number(v) : v
|
||||
}
|
||||
try {
|
||||
const res = instanceId
|
||||
? await client.activity(instanceId, activityUid, payload)
|
||||
: await client.start(activityUid, payload)
|
||||
onDone?.(res)
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !schema) return <div className="af__err">Could not load the form — {error.status} {error.message}</div>
|
||||
if (!schema) return <p className="af__loading">Loading the form…</p>
|
||||
|
||||
return (
|
||||
<form className="af" onSubmit={submit}>
|
||||
<div className="af__grid">
|
||||
{schema.fields.map((f) => {
|
||||
const opts = f.properties?.options || []
|
||||
const v = values[f.id] ?? (f.data_type === 'multiselect' ? [] : '')
|
||||
return (
|
||||
<label key={f.uid} className={'af__field' + (f.data_type === 'longtext' ? ' af__field--wide' : '')}>
|
||||
<span>{f.name}{f.mandatory ? <em className="af__req">required</em> : null}</span>
|
||||
|
||||
{f.data_type === 'select' ? (
|
||||
<select value={v} onChange={(e) => set(f.id, e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{opts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
) : f.data_type === 'multiselect' ? (
|
||||
<div className="af__multi">
|
||||
{opts.map((o) => (
|
||||
<button
|
||||
key={o.value} type="button"
|
||||
className={'af__chip' + (v.includes(o.value) ? ' is-on' : '')}
|
||||
onClick={() => set(f.id, v.includes(o.value) ? v.filter((x) => x !== o.value) : [...v, o.value])}
|
||||
>{o.label}</button>
|
||||
))}
|
||||
</div>
|
||||
) : f.data_type === 'longtext' ? (
|
||||
<textarea rows={3} value={v} onChange={(e) => set(f.id, e.target.value)} />
|
||||
) : (
|
||||
<input
|
||||
type={f.data_type === 'number' ? 'number' : f.data_type === 'date' ? 'date' : 'text'}
|
||||
value={v} onChange={(e) => set(f.id, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="af__err">
|
||||
<strong>{error.status}</strong> {error.message}
|
||||
{error.status === 403 || String(error.message).includes('permission denied') ? (
|
||||
<p>This is the workflow refusing, not the console. The signed-in role does not hold this activity.</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="af__actions">
|
||||
{onCancel ? <button type="button" className="af__ghost" onClick={onCancel}>Cancel</button> : null}
|
||||
<button type="submit" className="af__submit" disabled={busy}>
|
||||
{busy ? 'Submitting…' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@ -49,3 +49,10 @@
|
||||
}
|
||||
|
||||
.shell__main { flex: 1; min-width: 0; padding: 26px 28px 60px; }
|
||||
|
||||
.shell__add {
|
||||
display: block; text-align: center; text-decoration: none; margin: 0 2px 18px;
|
||||
padding: 9px 12px; border-radius: 4px; font-size: .85rem; font-weight: 500;
|
||||
background: var(--zk-blue); color: var(--zk-white); border: 1px solid var(--zk-blue);
|
||||
}
|
||||
.shell__add:hover { background: var(--zk-blue-dark); border-color: var(--zk-blue-dark); }
|
||||
|
||||
@ -40,6 +40,7 @@ export default function Shell() {
|
||||
|
||||
<div className="shell__body">
|
||||
<nav className="shell__nav" aria-label="Pipeline">
|
||||
<NavLink to="/add" className="shell__add">+ Add a lead</NavLink>
|
||||
<p className="shell__navlabel">Pipeline</p>
|
||||
{work.map((s) => (
|
||||
<NavLink
|
||||
|
||||
55
src/screens/AddLead.jsx
Normal file
55
src/screens/AddLead.jsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import ActivityForm from '../components/ActivityForm.jsx'
|
||||
import { ENTRY } from '../api/config.js'
|
||||
import './screens.css'
|
||||
|
||||
/**
|
||||
* The three doors. Each is a separate INIT activity with its own permissions,
|
||||
* and the bank one lands the instance further along because the bank already
|
||||
* did CKYC. Whichever door is used, everything after it is identical.
|
||||
*/
|
||||
export default function AddLead() {
|
||||
const [entry, setEntry] = useState(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<section>
|
||||
<header className="page__head">
|
||||
<div>
|
||||
<h1 className="page__title">Add a lead</h1>
|
||||
<p className="page__sub">Three doors, one machine. The door decides who may submit and where it lands — nothing after that.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!entry ? (
|
||||
<div className="doors">
|
||||
{ENTRY.map((e) => (
|
||||
<button key={e.uid} type="button" className="door" onClick={() => setEntry(e)}>
|
||||
<strong>{e.label}</strong>
|
||||
<span className="chip">{e.channel}</span>
|
||||
<p>{e.note}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="panel">
|
||||
<div className="panel__head">
|
||||
<div>
|
||||
<h2 className="panel__title">{entry.label}</h2>
|
||||
<p className="panel__sub">{entry.note}</p>
|
||||
</div>
|
||||
<button type="button" className="af__ghost" onClick={() => setEntry(null)}>Change door</button>
|
||||
</div>
|
||||
<ActivityForm
|
||||
activityUid={entry.uid}
|
||||
onDone={(res) => {
|
||||
const id = res?.instance_id || res?.data?.instance_id
|
||||
if (id) navigate(`/lead/${id}`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -1,23 +1,132 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useZino } from '../api/provider.jsx'
|
||||
import ActivityForm from '../components/ActivityForm.jsx'
|
||||
import { ACTIONS, DV_LEAD, STAGES } from '../api/config.js'
|
||||
import './screens.css'
|
||||
|
||||
/** The lead file. Built once the detail view is seeded. */
|
||||
const MONEY = new Set(['quoted_premium','quoted_od_premium','quoted_tp_premium','quoted_addon_premium',
|
||||
'quoted_gst','commission_base','commission_amount','sme_building_si','sme_plant_si','sme_furniture_si',
|
||||
'sme_rawmaterial_si','sme_wip_si','sme_finished_si','sme_other_si','sme_value_at_risk','motor_idv',
|
||||
'sme_burglary_si','sme_ee_si','sme_bi_gross_profit','sme_claims_36m_amount'])
|
||||
|
||||
/** Field groups, in the order the lead was actually worked. */
|
||||
const GROUPS = [
|
||||
['Who sourced it', ['lead_ref','source_channel','partner_code','partner_branch','rm_or_agent_id','consent_artefact']],
|
||||
['Customer', ['customer_name','entity_name','mobile','email','pan','gstin','udyam_no']],
|
||||
['Intake', ['lead_score','attribution_status','attribution_reason','dedupe_match_ref','eligibility_outcome','eligibility_reason']],
|
||||
['Contact', ['contact_outcome','contact_notes']],
|
||||
['SME risk', ['sme_product_variant','sme_occupancy','sme_location_address','sme_building_si','sme_plant_si','sme_furniture_si','sme_rawmaterial_si','sme_wip_si','sme_finished_si','sme_other_si','sme_walls','sme_roof','sme_building_age_band','sme_fire_protection','sme_fire_amc','sme_fire_brigade_km','sme_sections']],
|
||||
['Motor risk', ['motor_reg_no','motor_make_model','motor_mfg_year','motor_cc','motor_fuel','motor_idv','motor_ncb_pct','motor_addons','motor_prev_insurer','motor_prev_claim']],
|
||||
['AI recommendation (non-binding)', ['ai_recommended_cover','ai_recommended_addons','ai_recommendation_rationale','ai_recommendation_confidence']],
|
||||
['Quote', ['product_code','quoted_od_premium','quoted_tp_premium','quoted_addon_premium','quoted_section_premiums','quoted_gst','quoted_premium','quote_valid_till']],
|
||||
['KYC', ['kyc_mode','kyc_ref','kyc_outcome','kyc_mismatch_notes']],
|
||||
['Underwriting', ['uw_outcome','uw_survey_required','referral_analysis','uw_referral_reason','uw_decision_notes']],
|
||||
['Policy', ['payment_ref','premium_realised','policy_no','policy_issued_at']],
|
||||
["The agent's payout", ['commission_rate_pct','commission_base','commission_amount','payout_status','payout_ref','payout_at']],
|
||||
]
|
||||
|
||||
function fmt(k, v) {
|
||||
if (v === null || v === undefined || v === '') return null
|
||||
if (Array.isArray(v)) return v.join(', ')
|
||||
if (MONEY.has(k)) { const n = Number(v); return Number.isFinite(n) ? '₹' + n.toLocaleString('en-IN') : String(v) }
|
||||
return String(v)
|
||||
}
|
||||
|
||||
export default function Lead() {
|
||||
const { instanceId } = useParams()
|
||||
const { client } = useZino()
|
||||
const [row, setRow] = useState(null)
|
||||
const [err, setErr] = useState(null)
|
||||
const [open, setOpen] = useState(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
setErr(null)
|
||||
client.detailView(DV_LEAD, instanceId)
|
||||
.then((r) => setRow(r?.data ?? r?.record ?? r))
|
||||
.catch(setErr)
|
||||
}, [client, instanceId])
|
||||
|
||||
useEffect(load, [load])
|
||||
|
||||
if (err) return <div className="notice"><strong>Could not load lead {instanceId}.</strong><p className="notice__detail">{err.status} {err.message}</p></div>
|
||||
if (!row) return <p className="empty">Loading…</p>
|
||||
|
||||
const stateName = row.current_state_name || ''
|
||||
const stage = STAGES.find((s) => s.name === stateName)
|
||||
const actions = ACTIONS[stage?.uid] || []
|
||||
|
||||
return (
|
||||
<section>
|
||||
<header className="page__head">
|
||||
<div>
|
||||
<h1 className="page__title">Lead {instanceId}</h1>
|
||||
<p className="page__sub">Evidence, attribution and the referral file.</p>
|
||||
<h1 className="page__title">{row.lead_ref || `Lead ${instanceId}`}</h1>
|
||||
<p className="page__sub">
|
||||
{row.customer_name}{row.entity_name ? ` · ${row.entity_name}` : ''} ·{' '}
|
||||
{row.product_line === 'motor' ? 'Motor' : 'SME Package'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="lead__stage">
|
||||
<span className="lead__stagelabel">Stage</span>
|
||||
<strong>{stateName || '—'}</strong>
|
||||
</div>
|
||||
<Link className="grid__link" to="/">Back to the queue</Link>
|
||||
</header>
|
||||
<div className="notice">
|
||||
<strong>Not built yet.</strong>
|
||||
<p>The lead file reads the <code>zk-dv-lead</code> detail view and the live
|
||||
activity forms. Both arrive with the next app-config pass.</p>
|
||||
|
||||
{actions.length ? (
|
||||
<div className="panel">
|
||||
<div className="panel__head">
|
||||
<div>
|
||||
<h2 className="panel__title">What happens next</h2>
|
||||
<p className="panel__sub">
|
||||
These are the only activities this state allows. Who normally performs
|
||||
each is shown — but the workflow decides, server-side, whether you may.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="acts">
|
||||
{actions.map((a) => (
|
||||
<button key={a.uid} type="button"
|
||||
className={'act' + (open === a.uid ? ' is-open' : '')}
|
||||
onClick={() => setOpen(open === a.uid ? null : a.uid)}>
|
||||
<strong>{a.label}</strong>
|
||||
<span className="act__by">{a.by}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{open ? (
|
||||
<div className="acts__form">
|
||||
<ActivityForm
|
||||
activityUid={open}
|
||||
instanceId={Number(instanceId)}
|
||||
onCancel={() => setOpen(null)}
|
||||
onDone={() => { setOpen(null); load() }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="notice"><strong>Terminal.</strong><p>No activity runs from {stateName}.</p></div>
|
||||
)}
|
||||
|
||||
{GROUPS.map(([title, keys]) => {
|
||||
const shown = keys.map((k) => [k, fmt(k, row[k])]).filter(([, v]) => v !== null)
|
||||
if (!shown.length) return null
|
||||
return (
|
||||
<div className="grp" key={title}>
|
||||
<h3 className="grp__title">{title}</h3>
|
||||
<dl className="grp__list">
|
||||
{shown.map(([k, v]) => (
|
||||
<div className="grp__row" key={k}>
|
||||
<dt>{k.replace(/_/g, ' ')}</dt>
|
||||
<dd>{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<p className="lead__back"><Link className="grid__link" to="/">Back to the queue</Link></p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@ -48,3 +48,46 @@
|
||||
background: var(--zk-tint-blue); color: var(--zk-blue-dark); border: 1px solid var(--zk-blue-light);
|
||||
}
|
||||
.chip--warn { background: #fdf1e7; color: #93500f; border-color: #f0c69a; }
|
||||
|
||||
/* ---- doors ---- */
|
||||
.doors { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
|
||||
.door {
|
||||
font: inherit; text-align: left; cursor: pointer; background: var(--zk-white);
|
||||
border: 1px solid var(--zk-line); border-top: 3px solid var(--zk-blue);
|
||||
border-radius: 0 0 6px 6px; padding: 18px 20px; display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.door:hover { border-color: var(--zk-blue-light); border-top-color: var(--zk-blue); background: var(--zk-tint); }
|
||||
.door strong { font-size: 1rem; font-weight: 500; color: var(--zk-ink); }
|
||||
.door p { margin: 0; font-size: .82rem; color: var(--zk-muted); line-height: 1.5; }
|
||||
.door .chip { align-self: flex-start; }
|
||||
|
||||
/* ---- panel ---- */
|
||||
.panel { background: var(--zk-white); border: 1px solid var(--zk-line); border-radius: 6px; padding: 20px 22px; margin-bottom: 22px; }
|
||||
.panel__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
|
||||
.panel__title { margin: 0; font-size: 1.05rem; font-weight: 500; }
|
||||
.panel__sub { margin: 3px 0 0; font-size: .82rem; color: var(--zk-muted); max-width: 62ch; line-height: 1.5; }
|
||||
|
||||
/* ---- actions ---- */
|
||||
.acts { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.act {
|
||||
font: inherit; cursor: pointer; text-align: left; background: var(--zk-white);
|
||||
border: 1px solid var(--zk-line); border-radius: 5px; padding: 10px 14px;
|
||||
display: flex; flex-direction: column; gap: 2px; min-width: 190px;
|
||||
}
|
||||
.act:hover { border-color: var(--zk-blue-light); background: var(--zk-tint); }
|
||||
.act.is-open { border-color: var(--zk-blue); background: var(--zk-tint-blue); }
|
||||
.act strong { font-size: .88rem; font-weight: 500; }
|
||||
.act__by { font-size: .72rem; color: var(--zk-muted); }
|
||||
.acts__form { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--zk-line-soft); }
|
||||
|
||||
/* ---- lead file ---- */
|
||||
.lead__stage { text-align: right; }
|
||||
.lead__stagelabel { display: block; font-size: .66rem; letter-spacing: .1em; text-transform: uppercase; color: var(--zk-grey); }
|
||||
.lead__stage strong { font-size: 1rem; font-weight: 500; color: var(--zk-blue-dark); }
|
||||
.grp { background: var(--zk-white); border: 1px solid var(--zk-line); border-radius: 6px; padding: 16px 20px; margin-bottom: 14px; }
|
||||
.grp__title { margin: 0 0 10px; font-size: .72rem; font-weight: 500; letter-spacing: .09em; text-transform: uppercase; color: var(--zk-muted); }
|
||||
.grp__list { margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 8px 20px; }
|
||||
.grp__row { display: flex; flex-direction: column; gap: 1px; padding: 4px 0; border-bottom: 1px solid var(--zk-line-soft); }
|
||||
.grp__row dt { font-size: .7rem; color: var(--zk-grey); text-transform: capitalize; }
|
||||
.grp__row dd { margin: 0; font-size: .87rem; color: var(--zk-ink); }
|
||||
.lead__back { margin-top: 18px; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user