| import { lazy, Suspense, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } |
| from "react"; |
| import type { ReactNode, TextareaHTMLAttributes } from "react"; |
| import { BodyPortal, useOverlayLayer } from "./OverlaySurface"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const LinkedGrid = lazy(() => import("./CustomerGrid")); |
| import { |
| automationDetail, |
| automationState, |
| automationStateLabel, |
| checkboxOn, |
| formatDisplay, |
| codePreview, |
| jsonPreview, |
| } from "./cells"; |
| |
| |
| import { actionHref } from "./display"; |
| import JsonViewer, { CodeViewer } from "./JsonViewer"; |
| import { StarRow, StarIcon } from "./Stars"; |
| import { codeLanguageOf, isPickType, mayEditField, ratingMax } from "./types"; |
| import type { CustomerDoc, DisplaySpec, Field, Row, Viewer } from "./types"; |
| |
| |
| |
| import { |
| choiceEntries, |
| composeDateValue, |
| composeNumberValue, |
| composeRatingValue, |
| dateInputValue, |
| multiParts, |
| numberInputValue, |
| ratingValue, |
| rawText, |
| toggleMulti, |
| userInitials, |
| } from "./recordFields"; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import { optionBorderColor, optionTint, pickTint } from "./choiceColors"; |
| |
| |
| import { assetUrl, uploadRecordImage } from "./catalogData"; |
| |
| |
| |
| |
| import TimeSeriesPanel from "./TimeSeriesPanel"; |
| import type { SurfaceScope } from "./apiBridge"; |
| import { Documents } from "./Documents"; |
| import { FIELD_DRAG_TYPE, moveKey, nudgeKey, resolveFieldOrder } from "./recordLayout"; |
| import RecordComments from "./RecordComments"; |
| import { isStreamlitComponent } from "./hostBridge"; |
| import "./RecordDetail.css"; |
|
|
| export interface RecordDetailProps { |
| fields: Field[]; |
| record: Row; |
| titleKey: string; |
| positionLabel: string; |
| canPrev: boolean; |
| canNext: boolean; |
| onPrev: () => void; |
| onNext: () => void; |
| onClose: () => void; |
| onNotesChange: (key: string, value: string) => void; |
| onNotesCommit: (key: string, value: string) => void; |
| |
| |
| viewer?: Viewer; |
| |
| |
| |
| docs?: CustomerDoc[]; |
| docPayload?: { pid: number; docId: string; name: string; mime: string; data_b64: string }; |
| onDocAdd?: (file: { name: string; mime: string; size: number; data_b64: string }) => void; |
| onDocFetch?: (docId: string) => void; |
| onDocDelete?: (docId: string) => void; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| recordLayout?: string[]; |
| |
| onRecordLayout?: (order: string[]) => void; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| scope?: SurfaceScope; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| userOptions?: string[]; |
| |
| |
| |
| |
| |
| |
| userAvatars?: Record<string, string>; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function AutoTextarea({ |
| value, |
| ...rest |
| }: TextareaHTMLAttributes<HTMLTextAreaElement> & { value: string }) { |
| const ref = useRef<HTMLTextAreaElement>(null); |
| useLayoutEffect(() => { |
| const el = ref.current; |
| if (!el) return; |
| const fit = () => { |
| el.style.height = "auto"; |
| const cs = window.getComputedStyle(el); |
| const borders = |
| cs.boxSizing === "border-box" |
| ? (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.borderBottomWidth) || 0) |
| : 0; |
| el.style.height = `${el.scrollHeight + borders}px`; |
| }; |
| fit(); |
| window.addEventListener("resize", fit); |
| return () => window.removeEventListener("resize", fit); |
| }, [value]); |
| return <textarea ref={ref} value={value} {...rest} />; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function UserAvatar({ |
| name, |
| avatars, |
| }: { |
| name: string; |
| avatars?: Record<string, string>; |
| }) { |
| const photo = avatars?.[name]; |
| if (photo && photo.startsWith("data:image/")) |
| return <img className="cg-detail-avatar" src={photo} alt="" aria-hidden />; |
| const tint = pickTint(name); |
| return ( |
| <span |
| className="cg-detail-avatar cg-detail-avatar--initials" |
| style={{ background: tint.bg, color: tint.fg }} |
| aria-hidden |
| > |
| {userInitials(name)} |
| </span> |
| ); |
| } |
|
|
| |
| |
| function SixDots() { |
| return ( |
| <svg width="10" height="14" viewBox="0 0 10 14" aria-hidden focusable="false"> |
| {[3, 7, 11].map((cy) => |
| [3, 7].map((cx) => ( |
| <circle key={`${cx}-${cy}`} cx={cx} cy={cy} r="1.15" fill="currentColor" /> |
| )) |
| )} |
| </svg> |
| ); |
| } |
|
|
| export default function RecordDetail({ |
| fields, |
| record, |
| titleKey, |
| positionLabel, |
| canPrev, |
| canNext, |
| onPrev, |
| onNext, |
| onClose, |
| onNotesChange, |
| onNotesCommit, |
| viewer, |
| docs, |
| docPayload, |
| onDocAdd, |
| onDocFetch, |
| onDocDelete, |
| recordLayout, |
| onRecordLayout, |
| scope, |
| userOptions, |
| userAvatars, |
| }: RecordDetailProps) { |
| const panelRef = useRef<HTMLDivElement>(null); |
| const titleId = useId(); |
| const tabsId = useId(); |
| const title = String(record[titleKey] ?? "Untitled"); |
| |
| |
| |
| const showComments = !isStreamlitComponent() && scope != null; |
| |
| |
| |
| |
| const [imageBusy, setImageBusy] = useState(""); |
| const [imageError, setImageError] = useState(""); |
| |
| |
| |
| const [jsonKey, setJsonKey] = useState<string | null>(null); |
| |
| const [codeKey, setCodeKey] = useState<string | null>(null); |
| const overlayFields = fields.filter((field) => field.source === "overlay"); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const typedOverlay = overlayFields.filter( |
| (field) => |
| !isPickType(field.type) && |
| field.type !== "checkbox" && |
| field.type !== "rating" |
| ); |
| const checkboxOverlay = overlayFields.filter((field) => field.type === "checkbox"); |
| const ratingOverlay = overlayFields.filter((field) => field.type === "rating"); |
| const pickedOverlay = overlayFields.filter((field) => isPickType(field.type)); |
| const detailFields = fields.filter( |
| (field) => field.source !== "overlay" && field.key !== titleKey |
| ); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const fieldByKey = new Map(fields.map((field) => [field.key, field])); |
| const defaultOrder = [ |
| ...typedOverlay, |
| ...checkboxOverlay, |
| ...ratingOverlay, |
| ...pickedOverlay, |
| ...detailFields, |
| ].map((field) => field.key); |
| |
| |
| |
| const [pendingOrder, setPendingOrder] = useState<string[] | null>(null); |
| useEffect(() => { |
| setPendingOrder((p) => |
| p && JSON.stringify(p) === JSON.stringify(recordLayout ?? []) ? null : p |
| ); |
| }, [recordLayout]); |
|
|
| |
| |
| const orderedKeys = resolveFieldOrder(pendingOrder ?? recordLayout, defaultOrder); |
|
|
| |
| |
| |
| |
| |
| const dragKeyRef = useRef<string | null>(null); |
| const [dragKey, setDragKey] = useState<string | null>(null); |
| const [dropKey, setDropKey] = useState<string | null>(null); |
| const gripRefs = useRef(new Map<string, HTMLButtonElement | null>()); |
| |
| |
| |
| const refocusGrip = useRef<string | null>(null); |
| useLayoutEffect(() => { |
| const key = refocusGrip.current; |
| if (!key) return; |
| refocusGrip.current = null; |
| gripRefs.current.get(key)?.focus(); |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| const [activeGrip, setActiveGrip] = useState<string | null>(null); |
| const rovingKey = |
| activeGrip && orderedKeys.includes(activeGrip) ? activeGrip : orderedKeys[0]; |
|
|
| const commitOrder = (next: string[]) => { |
| if (next === orderedKeys) return; |
| setPendingOrder(next); |
| onRecordLayout?.(next); |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const [tab, setTab] = useState<"fields" | "details">("fields"); |
| const canInsights = scope != null; |
| const activeTab = canInsights ? tab : "fields"; |
| |
| |
| |
| const [tsDisplay, setTsDisplay] = useState<DisplaySpec>({ mode: "timeseries" }); |
| |
| |
| const insightPids = useMemo(() => [Number(record.pid)], [record.pid]); |
| |
| |
| const insightRows = useMemo(() => [record], [record]); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const [openLinks, setOpenLinks] = useState<Set<string>>(new Set()); |
| const linkSections = useMemo( |
| () => |
| fields |
| .filter((f) => f.type === "link" && typeof f.link?.table === "string" && f.link.table) |
| .map((f) => ({ |
| field: f, |
| table: f.link!.table as SurfaceScope, |
| ids: [...new Set( |
| String(record[f.key] ?? "") |
| .split(",") |
| .map((part) => Number(part.trim())) |
| .filter((pid) => Number.isInteger(pid) && pid > 0) |
| )], |
| })), |
| [fields, record] |
| ); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const grip = (field: Field, index: number) => ( |
| <button |
| type="button" |
| className="cg-detail-grip" |
| ref={(el) => { |
| gripRefs.current.set(field.key, el); |
| }} |
| draggable |
| tabIndex={field.key === rovingKey ? 0 : -1} |
| onFocus={() => setActiveGrip(field.key)} |
| // The POSITION is in the name, not only in the list: a keyboard move repaints silently, |
| // and a fallback whose effect a screen-reader user cannot hear is present rather than |
| // usable. Re-read on every move, because the label changes with the position. |
| aria-label={`Reorder ${field.label}, position ${index + 1} of ${orderedKeys.length}`} |
| title="Drag to reorder β or press the arrow keys" |
| onDragStart={(event) => { |
| dragKeyRef.current = field.key; |
| setDragKey(field.key); |
| event.dataTransfer.effectAllowed = "move"; |
| event.dataTransfer.setData(FIELD_DRAG_TYPE, field.key); |
| // The ghost is the whole ROW. Dragging a 10px handle across a 700px panel gives the |
| // eye nothing to aim with, and the thing being moved is the field, not the dots. |
| const row = event.currentTarget.parentElement; |
| if (row) event.dataTransfer.setDragImage(row, 14, row.clientHeight / 2); |
| }} |
| onDragEnd={() => { |
| dragKeyRef.current = null; |
| setDragKey(null); |
| setDropKey(null); |
| }} |
| onKeyDown={(event) => { |
| if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; |
| event.preventDefault(); // the body scrolls otherwise, chasing the row you moved |
| const next = nudgeKey(orderedKeys, field.key, event.key === "ArrowUp" ? -1 : 1); |
| if (next === orderedKeys) return; |
| refocusGrip.current = field.key; |
| commitOrder(next); |
| }} |
| > |
| <SixDots /> |
| </button> |
| ); |
|
|
| |
| const renderRow = (field: Field, index: number) => { |
| const inputId = `cg-ov-${field.key}`; |
| |
| |
| |
| const mayEdit = mayEditField(field, viewer); |
| |
| |
| const noVocabulary = field.type === "user" && !userOptions?.length; |
| const editable = mayEdit && !noVocabulary; |
| |
| |
| const isEdit = |
| editable && |
| !isPickType(field.type) && |
| field.type !== "checkbox" && |
| field.type !== "rating" && |
| field.type !== "date" && |
| field.type !== "int" && |
| field.type !== "currency" && |
| |
| |
| |
| field.type !== "image" && |
| |
| |
| |
| |
| |
| field.type !== "json" && |
| field.type !== "pct"; |
| const isControl = editable && !isEdit; |
| const cls = |
| "cg-detail-field" + |
| (isEdit ? " cg-detail-field--edit" : "") + |
| (isControl ? " cg-detail-field--control" : "") + |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| (dropKey === field.key ? " is-dropzone" : "") + |
| (dragKey === field.key ? " is-dragging" : ""); |
|
|
| let slot: ReactNode; |
| if (isEdit) { |
| const raw = String(record[field.key] ?? ""); |
| const href = actionHref(field, raw); |
| slot = ( |
| |
| |
| |
| |
| <div className="cg-detail-editwrap"> |
| <AutoTextarea |
| id={inputId} |
| className="cg-detail-textarea" |
| value={raw} |
| placeholder={`Add ${field.label.toLowerCase()}β¦`} |
| onChange={(event) => onNotesChange(field.key, event.target.value)} |
| onBlur={(event) => onNotesCommit(field.key, event.target.value)} |
| /> |
| {/* Wave-5 item 11 β url/email/phone become ACTIONABLE where valid. The raw text |
| stays the editable truth; the link is an affordance. */} |
| {href && ( |
| <a |
| className="cg-detail-action" |
| href={href} |
| target={field.type === "url" ? "_blank" : undefined} |
| rel={field.type === "url" ? "noopener noreferrer" : undefined} |
| > |
| {field.type === "url" |
| ? "Open link" |
| : field.type === "email" |
| ? "Send email" |
| : "Call"} |
| </a> |
| )} |
| </div> |
| ); |
| } else if (field.source === "overlay" && field.type === "checkbox") { |
| slot = ( |
| <input |
| id={inputId} |
| type="checkbox" |
| className="cg-detail-checkbox" |
| checked={checkboxOn(record[field.key] as string | number | null)} |
| disabled={!editable} |
| onChange={(event) => |
| onNotesCommit(field.key, event.target.checked ? "1" : "") |
| } |
| /> |
| ); |
| } else if (field.type === "image") { |
| |
| |
| |
| |
| |
| |
| |
| |
| const ref = String(record[field.key] ?? "").trim(); |
| slot = ( |
| <span className="cg-detail-value cg-detail-value--live cg-detail-imageslot"> |
| {ref ? ( |
| <img |
| className="cg-detail-image" |
| src={assetUrl(ref, "web")} |
| alt={`${field.label} for ${title}`} |
| onError={(event) => { |
| event.currentTarget.style.display = "none"; |
| }} |
| /> |
| ) : ( |
| <span className="cg-detail-imageempty" aria-hidden /> |
| )} |
| <span className="cg-detail-imagemeta"> |
| <span className="cg-detail-imageref">{ref || "No image"}</span> |
| {editable && ( |
| <span className="cg-detail-imageacts"> |
| <label className="cg-detail-imagebtn"> |
| {ref ? "Replace" : "Upload"} |
| <input |
| type="file" |
| accept="image/png,image/jpeg" |
| onChange={(event) => { |
| const file = event.target.files?.[0]; |
| // Reset the input BEFORE the await: picking the same file twice in a row |
| // fires no change event otherwise, so a failed upload could not be retried |
| // with the same picture. |
| event.target.value = ""; |
| if (!file) return; |
| setImageBusy(field.key); |
| void uploadRecordImage(file) |
| .then((newRef) => { |
| setImageError(""); |
| onNotesCommit(field.key, newRef); |
| }) |
| .catch((reason: unknown) => |
| setImageError( |
| reason instanceof Error |
| ? reason.message |
| : "That image was not saved." |
| ) |
| ) |
| .finally(() => setImageBusy("")); |
| }} |
| /> |
| </label> |
| {ref && ( |
| <button |
| type="button" |
| className="cg-detail-imagebtn" |
| onClick={() => onNotesCommit(field.key, "")} |
| > |
| Remove |
| </button> |
| )} |
| </span> |
| )} |
| {imageBusy === field.key && ( |
| <span className="lp-spin" role="status" aria-label="Uploading" /> |
| )} |
| {imageError && imageBusy !== field.key && ( |
| <span className="cg-detail-imageerr">{imageError}</span> |
| )} |
| </span> |
| </span> |
| ); |
| } else if (isControl && field.type === "rating") { |
| |
| |
| |
| const max = ratingMax(field); |
| const n = ratingValue(field, record[field.key]); |
| slot = ( |
| <span |
| className="cg-detail-value cg-detail-value--live" |
| role="group" |
| aria-labelledby={`${inputId}-lbl`} |
| > |
| <span className="cg-detail-stars"> |
| {Array.from({ length: max }, (_, i) => i + 1).map((star) => ( |
| <button |
| key={star} |
| type="button" |
| className="cg-detail-star" |
| aria-pressed={star <= n} |
| aria-label={ |
| star === n |
| ? `Clear ${field.label}` |
| : `${star} star${star === 1 ? "" : "s"}` |
| } |
| title={star === n ? "Click again to clear" : undefined} |
| onClick={() => |
| onNotesCommit( |
| field.key, |
| composeRatingValue(field, record[field.key], star) |
| ) |
| } |
| > |
| <StarIcon on={star <= n} /> |
| </button> |
| ))} |
| </span> |
| </span> |
| ); |
| } else if (isControl && (field.type === "select" || field.type === "user")) { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const current = rawText(record[field.key]); |
| const entries = choiceEntries(field, userOptions, current); |
| slot = ( |
| <span className="cg-detail-value cg-detail-value--live cg-detail-pickslot"> |
| {field.type === "user" && current !== "" && ( |
| <UserAvatar name={current} avatars={userAvatars} /> |
| )} |
| {/* T24: the control wears the SELECTED option's colour. An `<option>` cannot be |
| styled portably (the same platform limit this block already records for avatars), |
| so the colour goes on the box β which is where the reader is looking anyway, and |
| matches what the canvas paints for the same cell. `user` columns are excluded: a |
| person is not a choice with a palette, and `optionTint` would hash a name into |
| one. */} |
| <select |
| id={inputId} |
| className="cg-detail-select" |
| value={current} |
| style={ |
| field.type === "select" |
| ? (() => { |
| const tint = optionTint(field, current); |
| return tint |
| ? { background: tint.bg, color: tint.fg, |
| borderColor: optionBorderColor(tint.bg) } |
| : undefined; |
| })() |
| : undefined |
| } |
| onChange={(event) => onNotesCommit(field.key, event.target.value)} |
| > |
| <option value=""> |
| {field.type === "user" ? "Unassigned" : "None"} |
| </option> |
| {entries.map((entry) => ( |
| <option key={entry.value} value={entry.value}> |
| {entry.label} |
| </option> |
| ))} |
| </select> |
| </span> |
| ); |
| } else if (isControl && field.type === "multiselect") { |
| // A chip row rather than a `<select multiple>`: the native control needs ctrl-click to |
| // add a second value and shows a scrollbox of four rows for a three-choice field. Each |
| // toggle commits on its own β the event log absorbs the burst, exactly as the grid's |
| // picker does. |
| const current = record[field.key]; |
| const held = multiParts(current); |
| const entries = choiceEntries(field, userOptions, current); |
| slot = ( |
| <span |
| className="cg-detail-value cg-detail-value--live" |
| role="group" |
| aria-labelledby={`${inputId}-lbl`} |
| > |
| {entries.length === 0 ? ( |
| <span className="cg-detail-nochoice"> |
| No choices yet. Add them from the column menu. |
| </span> |
| ) : ( |
| <span className="cg-detail-chips"> |
| {entries.map((entry) => { |
| const on = held.includes(entry.value); |
| // T24: the HELD chips wear the option's own colour; an unheld one stays neutral, |
| // because tinting every choice would make "which of these is on" a judgement |
| // about saturation rather than a state you can read. |
| const tint = on ? optionTint(field, entry.value) : undefined; |
| return ( |
| <button |
| key={entry.value} |
| type="button" |
| className={"cg-detail-chip" + (on ? " is-on" : "")} |
| style={ |
| tint |
| ? { |
| background: tint.bg, |
| color: tint.fg, |
| borderColor: optionBorderColor(tint.bg), |
| } |
| : undefined |
| } |
| aria-pressed={on} |
| title={entry.stale ? "No longer a choice on this field" : undefined} |
| onClick={() => |
| onNotesCommit(field.key, toggleMulti(current, entry.value)) |
| } |
| > |
| {entry.label} |
| </button> |
| ); |
| })} |
| </span> |
| )} |
| </span> |
| ); |
| } else if (isControl && field.type === "date") { |
| // `dateInputValue` returns null for text a date input cannot represent. Falling back to |
| // a TEXT box there is the whole point: a date picker fed "02/08/2026" paints BLANK, so a |
| // record that has a date would look like one that has none β and the next save would |
| // make that true. See recordFields.ts note (1). |
| const current = record[field.key]; |
| const iso = dateInputValue(current); |
| slot = |
| iso === null ? ( |
| <span className="cg-detail-value cg-detail-value--live cg-detail-pickslot"> |
| <input |
| id={inputId} |
| type="text" |
| className="cg-detail-input" |
| value={rawText(current)} |
| title="This value is not a date the picker can read β it is shown as stored." |
| onChange={(event) => onNotesChange(field.key, event.target.value)} |
| onBlur={(event) => onNotesCommit(field.key, event.target.value)} |
| /> |
| <span className="cg-detail-hint">not a recognised date</span> |
| </span> |
| ) : ( |
| <span className="cg-detail-value cg-detail-value--live"> |
| <input |
| id={inputId} |
| type="date" |
| className="cg-detail-input" |
| value={iso} |
| onChange={(event) => |
| onNotesCommit(field.key, composeDateValue(current, event.target.value)) |
| } |
| /> |
| </span> |
| ); |
| } else if ( |
| isControl && |
| (field.type === "int" || field.type === "currency" || field.type === "pct") |
| ) { |
| // The plain number, never the display string: `display.ts` renders "$1,234.50" and a |
| // number input rejects the mark and the separator, so a formatted value would render as |
| // an EMPTY box over a populated cell. The unit lives beside the box instead. |
| const unit = field.type === "currency" ? "$" : field.type === "pct" ? "%" : ""; |
| slot = ( |
| <span className="cg-detail-value cg-detail-value--live cg-detail-pickslot"> |
| {field.type === "currency" && <span className="cg-detail-unit">{unit}</span>} |
| <input |
| id={inputId} |
| type="number" |
| className="cg-detail-input cg-detail-input--num" |
| value={numberInputValue(record[field.key])} |
| step={field.type === "int" ? 1 : "any"} |
| placeholder="β" |
| onChange={(event) => onNotesChange(field.key, event.target.value)} |
| onBlur={(event) => { |
| // Junk the browser let through is REFUSED, not coerced: `num()` downstream turns |
| // it into a real 0, and a fabricated zero on a currency row is a wrong number |
| // that reconciles against nothing. |
| const next = composeNumberValue(event.target.value); |
| if (next === null) onNotesChange(field.key, numberInputValue(record[field.key])); |
| else onNotesCommit(field.key, next); |
| }} |
| /> |
| {field.type === "pct" && <span className="cg-detail-unit">{unit}</span>} |
| </span> |
| ); |
| } else if (field.source === "overlay" && field.type === "rating") { |
| // Read-only rating (no permission, or an Odoo-sourced star column). |
| slot = ( |
| <span |
| className="cg-detail-value" |
| title={ |
| noVocabulary ? undefined : "You do not have permission to edit this field" |
| } |
| > |
| <StarRow value={ratingValue(field, record[field.key])} max={ratingMax(field)} /> |
| </span> |
| ); |
| } else if (field.type === "json") { |
| // β Wave-23 C7 β the drawer shows the same compact preview the grid cell does and hands |
| // the document to the SAME viewer, because two readers of one document that fold it |
| // differently is a disagreement nobody can see in either one alone. The button is the |
| // whole affordance: a `<pre>` of 32 KB inside a field row would push every column below |
| // it off the panel. |
| const raw = rawText(record[field.key]); |
| slot = ( |
| <span className="cg-detail-value cg-detail-jsonslot"> |
| <span className="cg-detail-jsonpreview">{jsonPreview(raw) || "β"}</span> |
| <button |
| type="button" |
| className="cg-detail-imagebtn" |
| onClick={() => setJsonKey(field.key)} |
| > |
| {/* "Open" for a reader, "Open" for an editor β the viewer decides what it offers |
| once it is open, and a button that says "Edit" to somebody who then cannot would |
| be the painted-but-inert control the standing rule is about. */} |
| Open |
| </button> |
| </span> |
| ); |
| } else if (field.type === "code") { |
| // β WAVE-27 item 13 (R13) β the SAME shape the json slot above uses, and deliberately |
| // so: both are big values that live behind one button, and giving them different |
| // affordances in the same list would make "open the big value" two gestures to learn. |
| // What differs is the preview (a snippet has no structure to summarise β see |
| // `codePreview`) and the viewer it opens. |
| const raw = rawText(record[field.key]); |
| slot = ( |
| <span className="cg-detail-value cg-detail-jsonslot"> |
| <span className="cg-detail-jsonpreview">{codePreview(raw) || "β"}</span> |
| <button |
| type="button" |
| className="cg-detail-imagebtn" |
| onClick={() => setCodeKey(field.key)} |
| > |
| Open |
| </button> |
| </span> |
| ); |
| } else if (field.type === "automation") { |
| // C5-AUTOFIELD (wave 18). It has to be its OWN branch and not the read-only fallback |
| // below, because that branch explains read-only-ness as a PERMISSION ("You do not have |
| // permission to edit this field") β and for an automation column that sentence is simply |
| // false. Nobody may type here, admins included: the value is what the last run wrote. |
| const raw = rawText(record[field.key]); |
| const state = automationState(raw); |
| slot = ( |
| <span className="cg-detail-value cg-detail-auto" title={raw || "Not run yet"}> |
| <span className={`cg-detail-auto-chip is-${state}`}> |
| {automationStateLabel(raw)} |
| </span> |
| {automationDetail(raw) ? ( |
| <span className="cg-detail-auto-detail">{automationDetail(raw)}</span> |
| ) : null} |
| </span> |
| ); |
| } else { |
| // Everything read-only, with the reason where there is one. After item 9 a picked field |
| // is no longer read-only BY DESIGN, so the only reasons left are real ones: no |
| // permission, no vocabulary to pick from, or a value the ERP owns. |
| const reason = |
| field.source !== "overlay" |
| ? undefined |
| : noVocabulary |
| ? "No assignable people were supplied for this workspace" |
| : "You do not have permission to edit this field"; |
| const isUser = field.type === "user" && rawText(record[field.key]) !== ""; |
| slot = ( |
| <span className={"cg-detail-value" + (isUser ? " cg-detail-pickslot" : "")} title={reason}> |
| {isUser && <UserAvatar name={rawText(record[field.key])} avatars={userAvatars} />} |
| {formatDisplay(field, record[field.key]) || "β"} |
| </span> |
| ); |
| } |
|
|
| // Every native control gets its label back. Without this the label stops being clickable |
| // for most of the panel the moment item 9 lands β the grouped controls (stars, chips) take |
| // `aria-labelledby` instead, because a `<label for>` can only point at ONE element. |
| const groupLabel = isControl && (field.type === "rating" || field.type === "multiselect"); |
| const labelIsFor = !groupLabel && (isEdit || isControl || (field.source === "overlay" && field.type === "checkbox")); |
| return ( |
| <div |
| className={cls} |
| key={field.key} |
| onDragOver={(event) => { |
| // Only OUR drag. Without the guard the whole list would advertise itself as a drop |
| // target for a dragged file or a selection from another tab. |
| if (!dragKeyRef.current) return; |
| event.preventDefault(); |
| event.dataTransfer.dropEffect = "move"; |
| if (dropKey !== field.key) setDropKey(field.key); |
| }} |
| onDragLeave={(event) => { |
| // `dragleave` also fires crossing into a CHILD (the label, the textarea), and |
| // clearing on those makes the drop marker strobe under a stationary pointer. |
| if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; |
| setDropKey((k) => (k === field.key ? null : k)); |
| }} |
| onDrop={(event) => { |
| // `drop` bubbles, so cancelling it here also cancels a textarea's native "insert |
| // the dragged text" β the second layer under FIELD_DRAG_TYPE. |
| event.preventDefault(); |
| const key = event.dataTransfer.getData(FIELD_DRAG_TYPE) || dragKeyRef.current; |
| setDropKey(null); |
| if (key) commitOrder(moveKey(orderedKeys, key, field.key)); |
| }} |
| > |
| {grip(field, index)} |
| {labelIsFor ? ( |
| <label className="cg-detail-label" htmlFor={inputId}> |
| {field.label} |
| </label> |
| ) : ( |
| <span className="cg-detail-label" id={groupLabel ? `${inputId}-lbl` : undefined}> |
| {field.label} |
| </span> |
| )} |
| {slot} |
| </div> |
| ); |
| }; |
|
|
| // One modal surface is shared by Grid, List, Calendar, Kanban, and Map. |
| // Focus stays inside it, then returns to the exact opener on close. |
| useOverlayLayer({ |
| panelRef, |
| onDismiss: onClose, |
| dismissOnOutside: true, |
| initialFocus: "[data-overlay-autofocus]", |
| trapFocus: true, |
| }); |
|
|
| return ( |
| <BodyPortal> |
| <div className="cg-record-backdrop"> |
| <div |
| className={"cg-record-modal" + (showComments ? "" : " cg-record-modal--single")} |
| ref={panelRef} |
| role="dialog" |
| aria-modal="true" |
| aria-labelledby={titleId} |
| data-overlay-kind="record-detail" |
| tabIndex={-1} |
| > |
| <div className="cg-detail-header"> |
| <div className="cg-detail-nav"> |
| <button |
| type="button" |
| className="cg-detail-navbtn" |
| onClick={onPrev} |
| disabled={!canPrev} |
| aria-label="Previous record" |
| title="Previous record" |
| > |
| βΉ |
| </button> |
| <button |
| type="button" |
| className="cg-detail-navbtn" |
| onClick={onNext} |
| disabled={!canNext} |
| aria-label="Next record" |
| title="Next record" |
| > |
| βΊ |
| </button> |
| <span className="cg-detail-pos">{positionLabel}</span> |
| </div> |
| <button |
| type="button" |
| className="cg-detail-close" |
| data-overlay-autofocus |
| onClick={onClose} |
| aria-label="Close panel" |
| title="Close" |
| > |
| Γ |
| </button> |
| </div> |
|
|
| <div className="cg-record-layout"> |
| <main className="cg-record-main"> |
| <div className="cg-detail-titlewrap"> |
| <div className="cg-detail-title" id={titleId} title={title}> |
| {title} |
| </div> |
| </div> |
|
|
| {/* C-EMBED (item 13) β the two ways of reading one record. This row is also |
| what the interleaved field list lost when its two section headings went |
| (see C-LAYOUT's amendment): the label for the list as a whole. */} |
| {canInsights && ( |
| <div className="cg-record-tabs" role="tablist" aria-label="Record view"> |
| {(["fields", "details"] as const).map((key) => ( |
| <button |
| key={key} |
| type="button" |
| role="tab" |
| id={`${tabsId}-${key}`} |
| aria-selected={activeTab === key} |
| aria-controls={`${tabsId}-panel`} |
| className={"cg-record-tab" + (activeTab === key ? " is-on" : "")} |
| onClick={() => setTab(key)} |
| > |
| {key === "fields" ? "Fields" : "Details"} |
| </button> |
| ))} |
| </div> |
| )} |
|
|
| <div |
| className="cg-detail-body" |
| id={`${tabsId}-panel`} |
| role={canInsights ? "tabpanel" : undefined} |
| aria-labelledby={canInsights ? `${tabsId}-${activeTab}` : undefined} |
| > |
| {activeTab === "details" && scope ? ( |
| <div className="cg-record-insights"> |
| {/* β WAVE-27 item 20 (R5 / C10) β ONE SECTION PER LINK FIELD, each an embedded |
| grid of the linked database showing exactly this record's linked rows. |
|
|
| β THE ROWS ARE THE PREMISE, NOT A FILTER, which is the same idea `locked` |
| states for the time series below: you are reading ONE record, so "which rows" |
| is settled by the record and the embedded grid's toolbar narrows within them. |
| `embeddedRecordIds` is the projection that already enforces that |
| (`CustomerGrid.tsx:621-628`), so nothing new decides it here. */} |
| {linkSections.map((sec) => ( |
| <section className="cg-detail-section cg-detail-linksec" key={sec.field.key}> |
| <h3 className="cg-detail-sechead"> |
| {sec.ids.length === 0 ? ( |
| <span>{sec.field.label}</span> |
| ) : ( |
| <button |
| type="button" |
| className="cg-detail-sectoggle" |
| aria-expanded={openLinks.has(sec.field.key)} |
| onClick={() => |
| setOpenLinks((prev) => { |
| const next = new Set(prev); |
| if (next.has(sec.field.key)) next.delete(sec.field.key); |
| else next.add(sec.field.key); |
| return next; |
| }) |
| } |
| > |
| {sec.field.label} |
| </button> |
| )} |
| <span className="cg-detail-seccount"> |
| {sec.ids.length.toLocaleString()}{" "} |
| {sec.ids.length === 1 ? "record" : "records"} |
| </span> |
| </h3> |
| {sec.ids.length === 0 ? ( |
| // Stated, never an empty grid. An embedded grid of nothing looks like a |
| // surface that failed to load; a sentence says which of the two it is. |
| <p className="cg-detail-secempty"> |
| Nothing linked yet. Open the {sec.field.label} cell on the Fields tab to |
| link records. |
| </p> |
| ) : !openLinks.has(sec.field.key) ? null : ( |
| <div className="cg-detail-linkgrid"> |
| {/* β LAZY, and it is load-bearing rather than a performance nicety: |
| `CustomerGrid` mounts THIS component, so a static import here would be |
| a module cycle. `React.lazy` breaks it at the module graph (the import |
| only resolves when a section actually renders) and composes with the |
| route-level splitting rather than fighting it. */} |
| {/* β THE SHARED MARK, NEVER A SENTENCE (owner R6, and `verify_icons` |
| enforces it across every source file β it caught a "Loadingβ¦" here on |
| this gate's first run). `aria-label` is REQUIRED by C-SPIN: "no words" |
| means no words ON SCREEN, and deleting the label would take the wait |
| away from a screen reader entirely. */} |
| <Suspense |
| fallback={ |
| <div className="cg-detail-secempty"> |
| <span className="lp-spin" role="status" aria-label="Loading" /> |
| </div> |
| } |
| > |
| {/* β ONE ATTRIBUTE IS MISSING ON PURPOSE AND IT IS THE VIEW-KIND |
| SWITCHER β `embeddedMode="browse"`. R5 asks for the FULL switcher |
| here; `CustomerGrid.tsx:4113` suppresses the mode control for every |
| embedded grid (`serverWindowed || embedded ? undefined : β¦`), so the |
| prop that distinguishes a BROWSE embed from the link PICKER embed |
| does not exist yet. It is requested as dated amendment A3 to contract |
| C10 in the wave-27 split doc (SESSION C owns `CustomerGrid.tsx`). |
|
|
| Everything else in this section is already correct β the target |
| table, the pinned rows and the toolbar all work today β so this ships |
| useful rather than waiting. When the prop lands, this becomes one |
| added line and nothing else moves. It is deliberately NOT smuggled in |
| through a cast: a spread that passed an unknown prop would typecheck |
| forever and silently do nothing, which is the failure mode this wave |
| keeps paying for. */} |
| <LinkedGrid |
| scope={sec.table} |
| embedded |
| embeddedRecordIds={sec.ids} |
| /> |
| </Suspense> |
| </div> |
| )} |
| </section> |
| ))} |
| {/* The time series STAYS, as ONE section (R5's own wording). `locked` says the pid |
| set is the PREMISE, not a control: you are reading one customer, so the filter |
| surface is the record itself (the owner's words). The panel keeps its own |
| bucket/span/field controls live against local state. */} |
| <section className="cg-detail-section"> |
| <TimeSeriesPanel |
| scope={scope} |
| pids={insightPids} |
| fields={fields} |
| /* wave17 item 5 / R9 β the population `insightPids` names, which here is one |
| record. sum-vs-avg is then moot: both reduce to this record's own value. */ |
| rows={insightRows} |
| display={tsDisplay} |
| onDisplay={(next) => setTsDisplay((d) => ({ ...d, ...next }))} |
| locked |
| /> |
| </section> |
| </div> |
| ) : ( |
| <> |
| {/* C-LAYOUT β ONE list, this user's order, every type interleaved. */} |
| <section className="cg-detail-section cg-detail-fieldlist"> |
| {orderedKeys.map((key, index) => { |
| const field = fieldByKey.get(key); |
| return field ? renderRow(field, index) : null; |
| })} |
| </section> |
|
|
| {/* Wave-8 I12c (C5) β Documents. Rendered only when the host actually |
| serves them: a section with a dead upload control on a deployment |
| that has no document storage would be a promise the app cannot keep. */} |
| {onDocAdd && onDocFetch && onDocDelete && ( |
| <Documents |
| pid={Number(record.pid)} |
| docs={docs ?? []} |
| docPayload={docPayload} |
| onAdd={onDocAdd} |
| onFetch={onDocFetch} |
| onDelete={onDocDelete} |
| /> |
| )} |
| </> |
| )} |
| </div> |
| </main> |
| {/* β WAVE 19 (owner item 12) β the rail finally gets the SCOPE this modal has held |
| since wave 13 and never passed on. `scope != null` gates it for exactly the |
| reason it gates Insights (see the prop's own note): a pid means nothing without |
| the topic it belongs to, and a guessed topic answers a question nobody asked. |
| β The key is scope+pid, not pid: two databases can hold the same numeric id, and |
| a key that only changed on the number would REUSE this component across a surface |
| switch, painting one record's comments under another's name until the fetch |
| returned. */} |
| {showComments && scope != null && ( |
| <RecordComments |
| key={`${scope}:${Number(record.pid)}`} |
| scope={scope} |
| pid={Number(record.pid)} |
| viewer={viewer} |
| /> |
| )} |
| </div> |
| </div> |
| </div> |
| {/* β Wave-23 C7 β the document viewer, INSIDE this portal and therefore stacked above |
| this modal. `useOverlayLayer`'s stack is what makes that work: it registers on top, so |
| Escape closes the viewer and leaves the record open rather than dismissing both. */} |
| {jsonKey && (() => { |
| const field = fields.find((f) => f.key === jsonKey); |
| if (!field) return null; |
| return ( |
| <JsonViewer |
| label={field.label} |
| value={rawText(record[field.key])} |
| onSave={ |
| mayEditField(field, viewer) |
| ? (next) => onNotesCommit(field.key, next) |
| : undefined |
| } |
| onClose={() => setJsonKey(null)} |
| /> |
| ); |
| })()} |
| {/* β Wave-27 item 13 (R13) β the code editor, stacked the same way and for the same |
| reason: `useOverlayLayer` puts it on top, so Escape closes the editor and leaves the |
| record open. */} |
| {codeKey && (() => { |
| const field = fields.find((f) => f.key === codeKey); |
| if (!field) return null; |
| return ( |
| <CodeViewer |
| label={field.label} |
| value={rawText(record[field.key])} |
| language={codeLanguageOf(field)} |
| onSave={ |
| mayEditField(field, viewer) |
| ? (next) => onNotesCommit(field.key, next) |
| : undefined |
| } |
| onClose={() => setCodeKey(null)} |
| /> |
| ); |
| })()} |
| </BodyPortal> |
| ); |
| } |
|
|