import { useCallback, useEffect, useState } from 'react' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { useZino } from '../api/provider.jsx' import ActivityForm from '../components/ActivityForm.jsx' import AgentCard from '../components/AgentCard.jsx' import AgentChip from '../components/AgentChip.jsx' import CallTranscript from '../components/CallTranscript.jsx' import ClampText from '../components/ClampText.jsx' import Conversation from '../components/Conversation.jsx' import LeadFileDialog from '../components/LeadFileDialog.jsx' import StageRail from '../components/StageRail.jsx' import Timeline from '../components/Timeline.jsx' import { APP_ID, DOC_SLOTS, DV_LEAD, PRODUCTS, STAGES, STALL_AFTER_MS, blockedOn, nudgeFor, phaseOf } from '../api/config.js' import { AGENTS } from '../api/agents.js' import { actionsFor, rolesOf } from '../api/permissions.js' import { describeError } from '../api/errors.js' import './screens.css' /** The countdown, not just the date. On a renewal book this is the number * that decides whether anyone should act today. */ function expiryOf(dateStr) { if (!dateStr) return null const d = new Date(String(dateStr).substring(0, 10) + 'T00:00:00Z') if (isNaN(d)) return null const days = Math.round((d.getTime() - Date.parse(new Date().toISOString().substring(0, 10) + 'T00:00:00Z')) / 86400000) return { days, tone: days < 0 ? 'lapsed' : days <= 7 ? 'urgent' : days <= 30 ? 'soon' : 'later', label: days < 0 ? Math.abs(days) + ' days overdue' : days === 0 ? 'expires today' : 'in ' + days + ' days', on: d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }), } } function inrShort(v) { const n = Number(v) if (!Number.isFinite(n) || !n) return null if (n >= 1e7) return '₹' + (n / 1e7).toFixed(2) + ' Cr' if (n >= 1e5) return '₹' + (n / 1e5).toFixed(2) + ' L' return '₹' + Math.round(n).toLocaleString('en-IN') } 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','sme_stock_si']) /** Field groups, in the order the lead was actually worked. */ const GROUPS = [ ['Source', ['lead_ref','product_line','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','outreach_window','contact_notes','call_transcript']], // The five OCR slots led this list and were absent from it, which is how a // lead with an RC, an expiring policy and a PAN attached showed "Documents 2" // — the status and the notes. They were also absent from the view itself // until 76; adding them here without that would have changed nothing. ['Documents', ['doc_rc','doc_prev_policy','doc_pan','doc_gst_cert','doc_udyam_cert', 'doc_address_proof','doc_premises_proof','doc_stock_statement','doc_premises_photos', 'doc_vehicle_photos','doc_financials','documents_status','documents_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_stock_si','sme_other_si','sme_value_at_risk','sme_floor','sme_num_floors','sme_floor_material','sme_walls','sme_roof','sme_building_age_band','sme_unit_age_years','sme_fire_protection','sme_fire_amc','sme_fire_brigade_km','sme_claims_36m_count','sme_claims_36m_amount','sme_sections','sme_bi_gross_profit','sme_bi_indemnity_months','sme_burglary_si','sme_ee_si']], ['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_policy_no','motor_prev_expiry','motor_prev_claim']], ['AI advice', ['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','quoted_breakup','quote_valid_till']], ['Conversation', ['customer_reply','customer_reply_from','customer_answer','answer_count']], ['Proposal', ['acceptance_ref','accepted_at']], ['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_link','payment_ref','premium_realised','realised_at','policy_no','policy_issued_at']], ['Commission', ['commission_rate_pct','commission_base','commission_amount','payout_status','payout_ref','payout_at']], ['Outcome', ['lost_reason','welcome_sent','renewal_due']], ] /** Past this, a value is prose and gets folded rather than printed in full. */ const LONG = 150 /** * WHAT THE PAGE SHOWS WITHOUT BEING ASKED. * * Three things are always true of a lead — who the customer is, what is being * insured, and what has been attached — so those are always here. The fourth * section depends on where the lead has got to: a lead in Quoted needs the * cover and the quote's expiry; the same lead in Onboarded needs the policy * number and when it renews. Everything else is in the full file. * * THIS IS A MIRROR, in the same sense as STAGES, ACTIONS and DOC_SLOTS, and it * carries the same hazard: a stage not listed here simply gets no fourth * section, and a field renamed in the workflow quietly stops appearing. That is * the safe direction to fail — the full file is generated from the data and * still holds everything — but it is a mirror, not a source. * * The metrics strip at the top of the page already carries premium, commission, * product, renewal date, lead age and AI confidence. Nothing is repeated here. */ const GLANCE_STAGE = { 'zk-state-new': ['lead_score', 'eligibility_outcome'], 'zk-state-qualified': ['lead_score', 'eligibility_outcome', 'outreach_window'], 'zk-state-contacted': ['contact_outcome', 'outreach_window'], 'zk-state-docs': ['documents_status', 'motor_prev_expiry'], 'zk-state-quoted': ['ai_recommended_cover', 'quote_valid_till', 'acceptance_ref'], 'zk-state-payment': ['payment_ref', 'quote_valid_till'], 'zk-state-issued': ['policy_no', 'policy_issued_at', 'payment_ref'], 'zk-state-onboarded': ['policy_no', 'policy_issued_at', 'renewal_due', 'payout_status'], 'zk-state-referred': ['uw_outcome', 'uw_referral_reason'], 'zk-state-parked': ['contact_outcome', 'outreach_window'], 'zk-state-lost': ['lost_reason', 'contact_outcome'], } /** The risk, in whichever line's vocabulary this lead is written. */ const GLANCE_RISK = { motor: ['motor_reg_no', 'motor_make_model', 'motor_mfg_year', 'motor_idv', 'motor_ncb_pct'], sme: ['sme_product_variant', 'sme_occupancy', 'sme_value_at_risk'], } /** * Field ids are snake_case and several carry acronyms, which sentence-casing * turns into "Pan" and "Gstin". Only the words that need it are listed; every * other word passes through. * * This is the FALLBACK. The detail view ships an output_label for every field * and that is preferred — this covers a field the config does not describe. */ const WORDS = { pan: 'PAN', gstin: 'GSTIN', kyc: 'KYC', ai: 'AI', sme: 'SME', uw: 'UW', od: 'OD', tp: 'TP', gst: 'GST', idv: 'IDV', ncb: 'NCB', rm: 'RM', si: 'SI', cc: 'CC', id: 'ID', no: 'no.', pct: '%', wip: 'WIP', bi: 'BI', ee: 'EE', amc: 'AMC', km: 'km', posp: 'POSP', } function label(k) { const words = k.split('_').map((w) => WORDS[w] ?? w).join(' ') return words.charAt(0).toUpperCase() + words.slice(1) } /** The uploaded files on a field value, if that is what it holds. */ function filesOf(v) { return Array.isArray(v) ? v.filter((f) => f && typeof f === 'object' && f.uuid) : [] } function fmt(k, v) { if (v === null || v === undefined || v === '') return null if (k === 'product_line') return PRODUCTS[v] ?? String(v) if (Array.isArray(v)) { // A file field holds an array of upload references — {uuid, original_name, // blob_path, …} — so joining it raw prints [object Object]. return v .map((x) => (x && typeof x === 'object' ? (x.original_name || x.file_name || x.uuid || '') : x)) .filter((x) => x !== '' && x !== null && x !== undefined) .join(', ') || null } // No field carries a bare object today, but one arriving as JSON should not // print as [object Object]. if (typeof v === 'object') return JSON.stringify(v) if (MONEY.has(k)) { const n = Number(v); return Number.isFinite(n) ? '₹' + n.toLocaleString('en-IN') : String(v) } return String(v) } /** * Whose move it is, right now — the question an operator opens a lead to * answer, said as a coloured pill in the header. Derived from the stage's own * kind, so it stays in step with the workflow: an AI-run stage is with the AI, * a customer-gated one is with the customer, a person-gated one is waiting on * someone, and a closed lead holds nobody. */ function holdsOf(stage) { if (!stage) return null switch (stage.kind) { case 'auto': return { tone: 'ai', label: 'With the AI' } case 'customer': return { tone: 'grey', pillTone: 'cust', label: 'With the customer' } case 'needs': return { tone: 'person', label: stage.approval ? 'Awaiting your approval' : 'Waiting on a person' } case 'waiting': return { tone: 'grey', label: 'In nurture' } case 'end': return { tone: 'grey', label: 'Closed' } default: return null } } export default function Lead() { const { instanceId } = useParams() const { client, user } = useZino() const roles = rolesOf(user) const navigate = useNavigate() const location = useLocation() // What the workflow said when the last activity was submitted. It is written // per activity — "Documents received — capturing the risk" — and is the // workflow telling the operator what it just set in motion, so it is shown // verbatim rather than replaced with a generic "Saved". const [note, setNote] = useState(location.state?.message ?? null) const [row, setRow] = useState(null) const [err, setErr] = useState(null) const [open, setOpen] = useState(null) const [labels, setLabels] = useState({}) // The audit rows live HERE, not inside Timeline, for two reasons: Timeline // fetched them once on mount and never again — so a lead worked by five // agents in three minutes showed the trail as it was when you opened the // page — and the conversation view needs the same rows. One fetch, one // poll, two readers that cannot disagree. const [audit, setAudit] = useState(null) const [showChat, setShowChat] = useState(false) const [openAgent, setOpenAgent] = useState(null) const [showCall, setShowCall] = useState(false) // The full record, on demand — see the panel comment below. const [showFile, setShowFile] = useState(false) // A clock in state rather than Date.now() in the render body. Reading the // wall clock while rendering is impure — React may render twice and get two // answers — and it also means the "nothing for 6 minutes" counter would only // move when something else happened to re-render the page. This ticks it. const [now, setNow] = useState(() => Date.now()) const load = useCallback((quiet = false) => { if (!quiet) setErr(null) // Best-effort and never awaited with the record: a failed audit call must // not blank the lead, and a slow one must not hold up the fields. client.audit(instanceId) .then((r) => setAudit(Array.isArray(r) ? r : (r?.data ?? []))) .catch(() => { /* keep whatever is on screen */ }) return client.detailView(DV_LEAD, instanceId) .then((r) => { setRow(r?.data ?? r?.record ?? r) // The detail view ships an output_label for every field. Using the // server's names means a field renamed in Studio is renamed here. const fields = r?.config?.fields if (Array.isArray(fields)) { setLabels(Object.fromEntries( fields.filter((f) => f.field_key && f.output_label).map((f) => [f.field_key, f.output_label]), )) } }) // A poll that fails leaves the screen exactly as it is. Only a first load // becomes an error page: a gateway blip must not throw away a lead the // operator is reading, or unmount a half-filled form under them. .catch((e) => { if (!quiet) setErr(e) }) }, [client, instanceId]) // `load` is CALLED here, not handed to useEffect: it returns a promise so the // poll below and onDone can chain on it, and an effect that returns anything // but a function has that value called as cleanup — React invokes the promise // as destroy(), throws, and unmounts the whole tree to a blank screen. useEffect(() => { load() }, [load]) // The message is handed over on the history entry, so it would show again // every time this lead is reached by Back — on a lead that has since moved on. // Consume it once, then strike it from the entry. useEffect(() => { if (location.state?.message) navigate(location.pathname, { replace: true, state: null }) }, [location.state, location.pathname, navigate]) // A lead that no longer exists is not a page to look at. Send the operator // back to the queue rather than leaving them on an empty shell. useEffect(() => { if (err && describeError(err).kind === 'gone') { const id = setTimeout(() => navigate('/', { replace: true }), 1600) return () => clearTimeout(id) } }, [err, navigate]) /** * Nothing is pushed — no socket, no SSE — and most of this workflow is carried * by AI employees that take one to three minutes a step, so a lead genuinely * moves while it is on screen. Poll quietly: no spinner, no skeleton, and not * at all while the tab is in the background. */ useEffect(() => { const id = setInterval(() => setNow(Date.now()), 20000) return () => clearInterval(id) }, []) useEffect(() => { // Not while a form is open. A successful poll rewrites `actions`, and if the // lead has moved on the open form unmounts — taking whatever was typed into // it with no warning. A submission against a stale state is refused by the // workflow anyway, and that refusal is handled properly below. if (open) return undefined const id = setInterval(() => { if (document.visibilityState === 'visible') load(true) }, 12000) return () => clearInterval(id) }, [load, open]) if (err) { const said = describeError(err) return (
{said.title} {said.detail ?

{said.detail}

: null}

Lead {instanceId} · {err.status} {err.message}

) } if (!row) return

Loading…

// Only groups that actually hold something; an empty tab is a dead end. const groups = GROUPS .map(([title, keys]) => [ title, keys.map((k) => [k, fmt(k, row[k]), filesOf(row[k])]).filter(([, v]) => v !== null), ]) .filter(([, shown]) => shown.length) const stateName = row.current_state_name || '' // The state, then what is really happening inside it — see phaseOf. Actions // are still looked up on the state's own uid; only the presentation moves. const stage = phaseOf(STAGES.find((s) => s.name === stateName), row) const holds = holdsOf(stage) // Only what THIS user may run. The workflow refuses the rest server-side // anyway; showing them a row of buttons that all 403 reads as a broken app // rather than as a control. const actions = actionsFor(roles, stage?.uid) // THE ONE THING THIS USER MUST DO, promoted to the header. "What do I do // now" is the first question an operator asks of a lead, and it was answered // only by scrolling past the metrics and the stage rail to the Next step // panel. When this user has an action, it belongs top-right, where the eye // goes for the next move; when they do not, the passive status stays there // instead. `role: 'do'` is the committing step — never a re-run or a // recovery lever, which must not be dressed as the thing to do. const acceptedAlready = Boolean(row.acceptance_ref) const primaryAction = actions.find( (a) => a.role === 'do' && !(acceptedAlready && a.uid === 'zk-act-accept'), ) || null const goToAction = () => { if (!primaryAction) return setOpen(primaryAction.uid) // Let the form mount, then bring the working surface into view. requestAnimationFrame(() => document.getElementById('lead-next')?.scrollIntoView({ behavior: 'smooth', block: 'center' })) } const fieldCount = groups.reduce((n, [, shown]) => n + shown.length, 0) // Built from the SAME rows the full file renders, so the two can never // disagree about a value. A section with nothing in it is dropped rather // than rendered empty — an SME lead has no registration number, and a // heading over a blank box reads as a bug. const pick = (keys) => keys .map((k) => [k, fmt(k, row[k]), filesOf(row[k])]) .filter(([, v]) => v !== null) const docKeys = Object.keys(DOC_SLOTS).filter((k) => filesOf(row[k]).length) const line = row.product_line === 'sme_package' ? 'sme' : 'motor' const glance = [ ['Customer', pick(['customer_name', 'entity_name', 'mobile', 'email'])], [line === 'sme' ? 'The risk' : 'The vehicle', pick(GLANCE_RISK[line])], ['Documents', pick(docKeys)], // THE CALL IS THE ONE STEP NOBODY CAN REPLAY FROM THE FIELDS. Everything // else on this lead was typed or derived; this happened out loud, and // until now its outcome sat in the full file with the source channel while // the words themselves were reachable only from a button halfway down the // trail. It gets its own card whenever a call has actually happened. ['The call', pick(['contact_outcome', 'outreach_window', 'contact_notes'])], ['This stage', pick(GLANCE_STAGE[stage?.uid] || [])], ].filter(([, rows]) => rows.length) // Two different reasons for an empty action list, and they must not read the // same. A terminal lead is finished; a live one you cannot act on belongs to // somebody else, and saying "closed" about it would be a lie. // Derived from the same audit rows the trail renders, so they cannot drift. const worked = [...new Set((audit || []) .flatMap((r) => (r.user_roles || []).filter((x) => AGENTS[x])))] const chatTurns = (audit || []).reduce((n, r) => { const fs = Array.isArray(r.fields) && r.fields.length ? r.fields : [] return n + fs.filter((f) => { const b = String(f.field_id || '').replace(/_\d+$/, '') return (b === 'customer_reply' || b === 'customer_answer') && typeof f.value === 'string' && f.value.trim() }).length }, 0) // An automated stage that has stopped for a stated reason. Checked before // the "in progress" strip below, which would otherwise keep promising that // something is happening for as long as the lead is left alone. const blocked = stage?.kind === 'auto' ? blockedOn(row) : null // An automated step that has sat too long. Distinct from `blocked`, which is // the workflow refusing for a stated reason — this is the agent not having // come back, and the cause is usually outside this app entirely: a slow or // failing model provider, a thinking budget that ran out mid-sentence, a // wake that never arrived. The console cannot see any of that, so it reports // the only thing it can know — nothing has happened for a while — and offers // the way out rather than going on promising two minutes. const stillFor = row.updated_at ? now - Date.parse(row.updated_at) : 0 const stalled = !blocked && stage?.kind === 'auto' && stillFor > STALL_AFTER_MS ? { mins: Math.round(stillFor / 60000), nudge: nudgeFor(stage, row) } : null return (
{/* React Router stamps the first entry in a session with key 'default', so a lead opened from its own URL has nothing to go back TO and lands on the queue instead of leaving the app. */}

{row.customer_name || row.lead_ref || `Lead ${instanceId}`}

{row.lead_ref || `Lead ${instanceId}`} {row.entity_name ? ` · ${row.entity_name}` : ''}

{/* WHOSE MOVE IT IS. The stage name is in the rail below; this says who holds the lead right now, which is the question the header is really being asked. The customer tone reuses the teal cust palette. */} {primaryAction ? ( /* YOUR MOVE. When this user owns the next step, the header carries it as the prominent thing — amber, because it is waiting on a person — and pressing it opens the form below and scrolls to it. */ ) : holds ? (
Right now {holds.label}
) : null}
{/* Measures, not fields. Age and dwell time are computed — neither is on the record — and they are the two that answer "is this lead moving?", which no label/value pair on the page could. */} {(() => { const e = expiryOf(row.renewal_due_date) const premium = inrShort(row.quoted_premium) const commission = inrShort(row.commission_amount) const conf = Number(row.ai_recommendation_confidence) // FOUR NUMBERS, NOT SEVEN. Money leads, because that is what a renewal // is about; the AI's confidence in the cover sits beside it; the // renewal countdown is the one deadline that matters. Dropped: lead age // and time-in-stage (they overlapped each other and the feed carries // timing), and product/vehicle (the rail's "The vehicle" holds them). // Nothing is lost — every removed measure is still on the page once, // where it belongs. return (
Premium {premium ?? '—'} {premium ? 'quoted, incl. GST' : 'not yet rated'}
Commission {commission ?? '—'} {row.commission_rate_pct ? row.commission_rate_pct + '% · on issue' : 'on placement'}
{Number.isFinite(conf) && conf > 0 ? (
AI confidence {conf}% on the cover advice
) : null}
Renewal {e ? e.label : '—'} {e ? e.on : 'no date on file'}
) })()}
{note ? (

{note}

) : null} {/* An AI-carried stage has no empty action bar and no idle screen: it says what is happening and who is doing it. The activities below it stay available — ops can run them by hand — but they are not the answer to "why is nothing moving?". */} {blocked ? (
) : stalled ? (
) : stage?.kind === 'customer' && stage.doing ? ( /* Not an agent working, and not a task of yours either — the lead is with someone outside the business. Without this the screen went blank at exactly the stage where an operator is most likely to wonder whether something has broken. */
) : stage?.kind === 'auto' ? (
) : null} {/* WHAT TO DO NEXT, not what is permitted. Every action carries a role: `do` is the step this stage is waiting on, `again` is a bounded loop, `force` is an AI's own job offered only so a stalled lead can be pushed by hand, `exit` is Mark Lost. Rendering them as one flat row of equals is what made six buttons appear where one was the answer. */} {(() => { // Once the documents are in, the upload is not the step, not an // alternative to the step, and not a loop worth offering: it is // recovery. Demoting it only to 'again' still printed an Upload // documents button under a panel saying the documents had been // received, which is the contradiction that was reported. It goes // into the fold with the other recovery levers, relabelled for what // it is actually for — a wrong file, or one more. const docsDone = stage?.after === 'documents received' // Recording an acceptance a second time is not a loop, a recovery // or an alternative — it is meaningless. The customer accepted on // WhatsApp at a stated time and the lead carries the reference; // offering the button again invites somebody to overwrite that // with a worse record of the same event. const accepted = Boolean(row.acceptance_ref) const recast = (a) => (docsDone && a.uid === 'zk-act-collect-docs' ? { ...a, role: 'force', label: 'Replace or add a document', by: 'you' } : a) const shown = actions .filter((a) => !(accepted && a.uid === 'zk-act-accept')) .map(recast) const step = shown.filter((a) => a.role === 'do') const again = shown.filter((a) => a.role === 'again') const force = shown.filter((a) => a.role === 'force') const exit = shown.filter((a) => a.role === 'exit') const extras = [...force, ...exit] const form = open ? (
setOpen(null)} onStale={() => { setOpen(null) setNote('Stage changed. Available actions have been refreshed.') load() }} onDone={(res) => { setOpen(null); setNote(res?.message ?? null); load() }} />
) : null if (!step.length && !again.length && !extras.length) return null return (
{/* THE HEADING IS FOR A REAL STEP ONLY. When there is no `do` action, the "what is happening / nothing owed" story is already told once — by the status strip above and the header — so repeating it here as "Next step: nothing is waiting on you" was the same sentence a third time. The secondary actions (add a document, mark lost) still render below, without the framing. */} {step.length ? (

Next step

This is what this lead is waiting on.

) : null} {/* The step. One button, sized like a decision. Referred has two because clear and decline are a pair, not a choice between doing something and doing nothing. */} {step.length ? (
{step.map((a) => ( ))}
) : null} {again.length ? (
{again.map((a) => ( ))}
) : null} {form} {/* Recovery, folded. An operator needs these on the day an agent stalls and never otherwise, and putting them in the open makes an AI's own work look like an outstanding task. */} {extras.length ? (
Lead not moving?

{force.length ? 'These normally run by themselves. Use one only if this lead has been sitting longer than it should.' : 'Close this lead if it is going nowhere.'}

{extras.map((a) => ( ))}
) : null}
) })()} {/* THE STORY IS THE MAIN COLUMN NOW. This sat in a 384px gutter with its own inner scrollbar, beside a panel of reference fields that had the whole width. That was exactly backwards. The trail is what this product is FOR — it is the record of work nobody watched happen — and putting it in a sidebar said it was a footnote to the form fields. It gets the wide column and the page's own scroll. The reference data moved to the rail, where short key/value pairs belong and where a narrow measure costs nothing. */}

What happened

Every step on this lead, who took it, and what they wrote.

{chatTurns ? ( ) : null}
setShowChat(true)} onOpenCall={() => setShowCall(true)} />
{/* Reference, not narrative. Sticky, so the facts stay put while the story is read against them. */}
{showChat ? ( setShowChat(false)} /> ) : null} {openAgent ? ( setOpenAgent(null)} /> ) : null} {showCall ? ( setShowCall(false)} /> ) : null} {showFile ? ( `${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`} onClose={() => setShowFile(false)} /> ) : null}
) }