console: unmangle the document form and rebuild the upload control

Two problems on the collect-documents form.

LAYOUT. Every field sat in one flat auto-fit grid, so on a wide screen the three
uploads took the first half of a row and three text inputs took the rest — and
an upload that has read a document is several times taller than a text box, so
the inputs stranded themselves up beside the buttons and the row went ragged.
Documents now render in their own section above the details, and each grid is
internally one height. The upload cells align to the top so a read document no
longer drags its neighbours down.

THE UPLOAD ITSELF. It was one pink slab that only changed its words, so a
finished upload shouted as loudly as an empty one. Now there are two states: an
empty field is a dashed target with an icon and the formats it takes; a filled
one is a quiet row with a document icon, the file name, and a small Replace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-09 17:06:11 +05:30
parent f6682813a4
commit 3f36ea610b
3 changed files with 123 additions and 36 deletions

View File

@ -245,11 +245,14 @@
display: none; display: none;
} }
.ff__btn { .ff__drop {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
width: 100%;
font: inherit; font: inherit;
font-size: var(--fs-xs); padding: 16px 12px;
font-weight: 500;
padding: 14px 14px;
border-radius: var(--r-md); border-radius: var(--r-md);
cursor: pointer; cursor: pointer;
border: 1px dashed var(--zk-blue-light); border: 1px dashed var(--zk-blue-light);
@ -258,28 +261,56 @@
text-align: center; text-align: center;
transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast); transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast);
} }
.ff__drop svg { width: 20px; height: 20px; }
.ff__drop strong { font-size: var(--fs-2xs); font-weight: 500; }
.ff__drop span { font-size: var(--fs-3xs); color: var(--zk-grey); }
.ff__btn:hover:not(:disabled) { .ff__drop:hover:not(:disabled) {
background: var(--zk-white); background: var(--zk-white);
border-style: solid; border-style: solid;
border-color: var(--zk-blue); border-color: var(--zk-blue);
box-shadow: var(--ring);
} }
.ff__drop:focus-visible { outline: none; box-shadow: var(--ring); }
.ff__drop:disabled { opacity: 0.65; cursor: default; border-style: solid; }
.ff__btn:disabled { /* Uploaded: the file as a fact, not a call to action. */
opacity: 0.6; .ff__file {
cursor: default; display: flex;
border-style: solid; align-items: center;
gap: 8px;
padding: 9px 9px 9px 11px;
border: 1px solid var(--zk-line);
border-radius: var(--r-md);
background: var(--zk-white);
} }
.ff__file > svg { width: 15px; height: 15px; flex: none; color: var(--zk-blue-dark); }
.ff__got { .ff__name {
flex: 1;
min-width: 0;
font-size: var(--fs-2xs); font-size: var(--fs-2xs);
color: var(--zk-muted); color: var(--zk-ink);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.ff__replace {
flex: none;
font: inherit;
font-size: var(--fs-3xs);
font-weight: 500;
padding: 4px 10px;
border: 1px solid var(--zk-line);
border-radius: var(--r-pill);
background: var(--zk-white);
color: var(--zk-blue-dark);
cursor: pointer;
transition: background var(--t-fast), border-color var(--t-fast);
}
.ff__replace:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-mid); }
.ff__replace:focus-visible { outline: none; box-shadow: var(--ring); }
.ff__read { .ff__read {
border: 1px solid var(--zk-blue-light); border: 1px solid var(--zk-blue-light);
border-left-width: 3px; border-left-width: 3px;
@ -349,3 +380,19 @@
} }
.af__err--soft ul { margin: 6px 0 0; padding-left: 18px; } .af__err--soft ul { margin: 6px 0 0; padding-left: 18px; }
.af__err--soft li { margin: 2px 0; } .af__err--soft li { margin: 2px 0; }
/* ---- form sections ---- */
.af__sec + .af__sec { margin-top: 22px; }
.af__sech {
margin: 0 0 10px;
font-size: var(--fs-3xs);
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--zk-grey);
}
/* Uploads size to their content and never stretch to the tallest card in the
row, so a document that has been read does not pull its neighbours down. */
.af__grid--docs { align-items: start; }

View File

@ -267,18 +267,23 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
if (error && !schema) return <div className="af__err">Could not load the form {describeError(error).title}</div> if (error && !schema) return <div className="af__err">Could not load the form {describeError(error).title}</div>
if (!schema) return <p className="af__loading">Loading the form</p> if (!schema) return <p className="af__loading">Loading the form</p>
return ( // Stamped fields are in `fields` they validate and they submit but they
<form className="af" onSubmit={submit}> // are not drawn. See STAMPED_FIELDS. A stamped field that came back EMPTY is
<div className="af__grid"> // drawn anyway: source_channel is mandatory, so a prefill that did not
{/* Stamped fields are in `fields` they validate and they submit // resolve would otherwise fail validation against a box that is not on the
but they are not drawn. See STAMPED_FIELDS. // screen and cannot be filled.
const visible = fields.filter((f) => !fieldIsStamped(f.id) || isEmpty(values[f.id]))
A stamped field that came back EMPTY is drawn anyway. source_channel /* DOCUMENTS GET THEIR OWN ROW. In one flat auto-fit grid the uploads sat
is mandatory, so a prefill that did not resolve would otherwise fail beside plain inputs, and an upload that has read a document is several
validation against a box that is not on the screen and cannot be times taller than a text box so a row carried three tall OCR cards and
filled the exact failure the field filter elsewhere in this file three short fields, and the form went ragged with the inputs stranded up
guards against. Hidden when it is answered; asked when it is not. */} beside the buttons. Split in two, each grid is internally one height. */
{fields.filter((f) => !fieldIsStamped(f.id) || isEmpty(values[f.id])).map((f) => { const isDoc = (f) => f.data_type === 'file' || f.data_type === 'ocr'
const docFields = visible.filter(isDoc)
const dataFields = visible.filter((f) => !isDoc(f))
const renderField = (f) => {
const opts = f.properties?.options || [] const opts = f.properties?.options || []
const v = values[f.id] ?? (f.data_type === 'multiselect' ? [] : '') const v = values[f.id] ?? (f.data_type === 'multiselect' ? [] : '')
return ( return (
@ -326,8 +331,23 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
)} )}
</label> </label>
) )
})} }
</div>
return (
<form className="af" onSubmit={submit}>
{docFields.length ? (
<section className="af__sec">
<h4 className="af__sech">Documents</h4>
<div className="af__grid af__grid--docs">{docFields.map(renderField)}</div>
</section>
) : null}
{dataFields.length ? (
<section className="af__sec">
{docFields.length ? <h4 className="af__sech">Details</h4> : null}
<div className="af__grid">{dataFields.map(renderField)}</div>
</section>
) : null}
{/* The form's own complaint, before anything is sent. */} {/* The form's own complaint, before anything is sent. */}
{missing.length ? ( {missing.length ? (

View File

@ -61,20 +61,40 @@ export default function FileField({ field, value, instanceId, activityUid, onCha
} }
} }
const name = files.length ? (files[0].original_name || files[0].uuid) : ''
const open = () => inputRef.current?.click()
return ( return (
<div className="ff"> <div className="ff">
<input <input
ref={inputRef} type="file" className="ff__input" ref={inputRef} type="file" className="ff__input"
accept=".pdf,.png,.jpg,.jpeg" onChange={pick} disabled={Boolean(busy)} accept=".pdf,.png,.jpg,.jpeg" onChange={pick} disabled={Boolean(busy)}
/> />
<button type="button" className="ff__btn" disabled={Boolean(busy)}
onClick={() => inputRef.current?.click()}>
{busy || (files.length ? 'Replace file' : isOcr ? 'Upload — we read it for you' : 'Upload')}
</button>
{files.length ? ( {/* Two states, not one button that changes its words. Nothing uploaded is
<div className="ff__got">{files[0].original_name || files[0].uuid}</div> an invitation a dashed target that says what it takes. Something
) : null} uploaded is a fact the file, named, with a quiet way to swap it. The
old single pink slab stayed the same size and weight either way, which
made a finished upload shout as loudly as an empty one. */}
{files.length && !busy ? (
<div className="ff__file">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M9 1.8H4.4a1 1 0 0 0-1 1v10.4a1 1 0 0 0 1 1h7.2a1 1 0 0 0 1-1V5.4Zm0 0V5.4h3.6"
fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" />
</svg>
<span className="ff__name" title={name}>{name}</span>
<button type="button" className="ff__replace" onClick={open}>Replace</button>
</div>
) : (
<button type="button" className="ff__drop" disabled={Boolean(busy)} onClick={open}>
<svg viewBox="0 0 20 20" aria-hidden="true">
<path d="M10 13.5V4.2m0 0L6.6 7.6M10 4.2l3.4 3.4M3.5 13v2a1.5 1.5 0 0 0 1.5 1.5h10a1.5 1.5 0 0 0 1.5-1.5v-2"
fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<strong>{busy || (isOcr ? 'Upload — we read it for you' : 'Upload document')}</strong>
{busy ? null : <span>PDF, PNG or JPG</span>}
</button>
)}
{extracted ? ( {extracted ? (
<div className="ff__read"> <div className="ff__read">