Repository Public
1// One deposit portal: its settings in short, and every deposit received with its journal.2import { formatBytes } from '@spd/shared';3import { useQuery } from '@tanstack/react-query';4import { Link, useLocation } from 'wouter';5import { useState } from 'react';6import { get } from '../api.ts';7import { Glyph } from '../glyphs.tsx';8import { fmtDate, getLocale, useT } from '../i18n.ts';9import { uaSummary } from '../sharing/common.tsx';10import { PortalActions, PortalState, REJECT_CODES } from '../sharing/PortalParts.tsx';11import type { Crumb, Portal, Submission, SubmissionDetail } from '../types.ts';12import { Badge, Button, Dialog, EmptyState, ErrorState, Loading } from '../ui.tsx';1314const SUB_TONE: Record<Submission['status'], 'ok' | 'danger' | 'warn' | 'neutral' | 'accent'> = { open: 'accent', processing: 'accent', completed: 'ok', rejected: 'danger', expired: 'warn' };15const busyOf = (s: { status: string }) => s.status === 'open' || s.status === 'processing';1617export function RequestDetail({ id }: { id: string }) {18 const t = useT();19 const loc = getLocale();20 const [, navigate] = useLocation();21 const [openId, setOpenId] = useState<string | null>(null);22 const q = useQuery({ queryKey: ['requests', 'one', id], queryFn: () => get<{ request: Portal; folderPath: Crumb[] }>(`/requests/${id}`), refetchOnMount: 'always' });23 // while a deposit is being sent or inspected, the table follows it24 const subs = useQuery({ queryKey: ['requests', 'submissions', id], queryFn: () => get<Submission[]>(`/requests/${id}/submissions`), refetchOnMount: 'always', refetchInterval: (query) => (query.state.data?.some(busyOf) ? 3000 : false) });25 if (q.isLoading) return <Loading />;26 if (q.error || !q.data) return <ErrorState error={q.error} onRetry={() => void q.refetch()} />;27 const p = q.data.request, path = q.data.folderPath;28 const modes = (['name', 'email', 'reference'] as const).filter((k) => p.fields[k] !== 'off').map((k) => `${t(`sharing.portal.field.${k}`)} (${t(`sharing.portal.mode.${p.fields[k]}`).toLowerCase()})`);29 const facts: [string, string][] = [30 [t('sharing.detail.limits'), `${t('sharing.detail.perFile', { size: formatBytes(p.maxFileBytes, loc) })} · ${t('sharing.detail.files', { n: p.maxFilesPerSubmission })} · ${t('sharing.detail.perHour', { n: p.submissionsPerHour })}`],31 [t('sharing.requests.volume'), `${formatBytes(p.bytesReceived, loc)} / ${formatBytes(p.maxTotalBytes, loc)}`],32 [t('sharing.requests.deposits'), `${p.submissions}${p.maxSubmissions !== null ? ` / ${p.maxSubmissions}` : ''}`],33 [t('sharing.portal.sTypes'), p.allowedExtensions.length ? p.allowedExtensions.map((e) => `.${e}`).join(' ') : t('sharing.portal.preset.any')],34 [t('sharing.portal.sForm'), [...modes, ...p.fields.custom.map((c) => `${c.label}${c.required ? ` (${t('sharing.portal.mode.required').toLowerCase()})` : ''}`)].join(' · ') || t('sharing.detail.noFields')],35 [t('sharing.portal.sAccess'), [p.hasPassword ? t('sharing.item.password') : t('sharing.detail.noPassword'), p.expiresAt ? t('sharing.item.expires', { date: fmtDate(p.expiresAt) }) : t('sharing.item.noExpiry'), p.requireConsent ? t('sharing.detail.consent') : ''].filter(Boolean).join(' · ')],36 [t('sharing.portal.sAfter'), [p.subfolderPerSubmission ? t('sharing.detail.subfolder') : t('sharing.detail.flat'), p.notifyOwner ? t('sharing.detail.notify') : t('sharing.detail.noNotify')].join(' · ')],37 ];38 return (39 <div className="pb-dock mx-auto w-full max-w-4xl px-4 pt-3 sm:px-6 sm:pt-7" data-testid="request-detail">40 <Link href="/requests" className="inline-flex items-center gap-1 text-sm text-ink-2 hover:text-ink"><Glyph name="chevronLeft" size={16} />{t('sharing.requests.title')}</Link>41 <header className="mt-3 card overflow-hidden">42 <div className="h-1.5" style={{ background: p.brandColor }} aria-hidden="true" />43 <div className="p-4 sm:p-5">44 <div className="flex flex-wrap items-start gap-3">45 <div className="min-w-0 basis-full sm:flex-1 sm:basis-0">46 <div className="flex flex-wrap items-center gap-2"><h1 className="min-w-0 text-2xl font-bold tracking-tight break-words">{p.title}</h1><PortalState state={p.state} /></div>47 <p className="mt-0.5 text-xs text-ink-3">{t(`sharing.pstateHint.${p.state}`)}</p>48 {p.description ? <p className="mt-2 text-sm whitespace-pre-line text-ink-2">{p.description}</p> : null}49 <p className="mt-2 flex min-w-0 flex-wrap items-center gap-1 text-sm text-ink-2"><Glyph name="folder" size={16} className="shrink-0 text-ink-3" />50 {path.map((c, i) => <span key={c.id} className="inline-flex min-w-0 items-center gap-1">{i ? <Glyph name="chevronRight" size={12} className="text-ink-3" /> : null}51 {p.state === 'target_missing' ? <span className="truncate">{c.name}</span> : <Link href={i === 0 ? '/' : `/folder/${c.id}`} className="truncate text-accent underline-offset-2 hover:underline">{i === 0 ? t('sharing.picker.root') : c.name}</Link>}</span>)}52 </p>53 </div>54 <div className="flex shrink-0 items-center gap-2"><PortalActions portal={{ ...p, folderName: path[path.length - 1]?.name ?? '' }} onPurged={() => navigate('/requests')} /></div>55 </div>56 <dl className="mt-4 grid grid-cols-1 gap-x-6 gap-y-2.5 border-t border-line pt-4 text-sm sm:grid-cols-2" data-testid="portal-facts">57 {facts.map(([k, v]) => <div key={k} className="min-w-0"><dt className="text-xs text-ink-3">{k}</dt><dd className="break-words">{v}</dd></div>)}58 </dl>59 </div>60 </header>6162 <section className="mt-6" aria-labelledby="subs-h">63 <div className="flex items-center justify-between gap-3">64 <h2 id="subs-h" className="text-base font-semibold">{t('sharing.detail.submissions')}{subs.data ? <span className="ml-2 text-sm font-normal text-ink-3">{subs.data.length}</span> : null}</h2>65 <Button size="sm" variant="ghost" icon="refresh" busy={subs.isFetching} onClick={() => void subs.refetch()}>{t('sharing.refresh')}</Button>66 </div>67 {subs.isLoading ? <Loading /> : subs.error ? <ErrorState error={subs.error} onRetry={() => void subs.refetch()} /> : !subs.data?.length ? <EmptyState icon="inbox" title={t('sharing.detail.empty')} text={t('sharing.detail.emptyText')} /> : (68 <div className="mt-3 card overflow-hidden">69 <table className="w-full text-left text-sm" data-testid="submissions">70 <thead className="hidden border-b border-line text-xs text-ink-3 sm:table-header-group"><tr>71 <th className="px-4 py-2.5 font-medium">{t('sharing.detail.ref')}</th><th className="px-2 py-2.5 font-medium">{t('sharing.detail.depositor')}</th><th className="px-2 py-2.5 font-medium">{t('sharing.detail.date')}</th>72 <th className="px-2 py-2.5 font-medium">{t('sharing.detail.status')}</th><th className="px-2 py-2.5 font-medium">{t('sharing.detail.filesCol')}</th><th className="px-2 py-2.5 font-medium">{t('sharing.requests.volume')}</th><th className="px-4 py-2.5"><span className="sr-only">{t('sharing.detail.folder')}</span></th>73 </tr></thead>74 <tbody className="divide-y divide-line">75 {subs.data.map((s) => (76 <tr key={s.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 hover:bg-hover sm:table-row sm:p-0" data-status={s.status}>77 <td className="sm:px-4 sm:py-3"><button type="button" onClick={() => setOpenId(s.id)} className="font-mono text-sm font-semibold whitespace-nowrap text-accent underline-offset-2 hover:underline" aria-label={t('sharing.detail.openSub', { ref: s.ref })}>{s.ref}</button></td>78 <td className="min-w-0 flex-1 sm:px-2 sm:py-3"><span className="block truncate">{s.name || s.email || t('sharing.detail.anonymous')}</span>{s.name && s.email ? <span className="block truncate text-xs text-ink-3">{s.email}</span> : null}</td>79 <td className="w-full text-xs text-ink-3 sm:w-auto sm:px-2 sm:py-3 sm:text-sm sm:text-ink-2">{fmtDate(s.createdAt)}</td>80 <td className="sm:px-2 sm:py-3"><Badge tone={SUB_TONE[s.status]}>{t(`sharing.sstatus.${s.status}`)}</Badge></td>81 <td className="tabular-nums sm:px-2 sm:py-3"><span className="text-ok">{t('sharing.detail.accepted', { n: s.placed })}</span>{s.rejected ? <span className="text-danger"> · {t('sharing.detail.refused', { n: s.rejected })}</span> : null}</td>82 <td className="text-ink-2 tabular-nums sm:px-2 sm:py-3">{formatBytes(s.bytes, loc)}</td>83 <td className="sm:px-4 sm:py-3 sm:text-right">{s.folderId ? <Link href={`/folder/${s.folderId}`} className="whitespace-nowrap text-accent underline-offset-2 hover:underline">{t('sharing.detail.openFolder')}</Link> : null}</td>84 </tr>85 ))}86 </tbody>87 </table>88 </div>89 )}90 </section>91 {openId ? <SubmissionDialog id={openId} portal={p} onClose={() => setOpenId(null)} /> : null}92 </div>93 );94}9596function SubmissionDialog({ id, portal, onClose }: { id: string; portal: Portal; onClose: () => void }) {97 const t = useT();98 const loc = getLocale();99 const q = useQuery({ queryKey: ['requests', 'submission', id], queryFn: () => get<SubmissionDetail>(`/submissions/${id}`), refetchInterval: (query) => (query.state.data && busyOf(query.state.data) ? 2500 : false) });100 const s = q.data;101 const reason = (code: string | null) => t(`sharing.reject.${REJECT_CODES.includes(code ?? '') ? code : 'other'}`);102 const eventText = (e: SubmissionDetail['events'][number]) => {103 const m = e.meta as Record<string, any>;104 switch (e.action) {105 case 'submission.started': return t('sharing.event.started', { n: m.files ?? 0, size: formatBytes(Number(m.bytes ?? 0), loc) });106 case 'submission.finalized': return m.incomplete ? t('sharing.event.finalizedPartial', { n: m.received ?? 0, k: m.incomplete }) : t('sharing.event.finalized', { n: m.received ?? 0 });107 case 'submission.item_rejected': return t('sharing.event.itemRejected', { name: String(m.name ?? ''), reason: reason(String(m.code ?? '')) });108 case 'submission.completed': return t('sharing.event.completed', { n: m.placed ?? 0, k: m.rejected ?? 0 });109 case 'submission.rejected': return m.reason ? t('sharing.event.rejectedWhy', { reason: reason(String(m.reason)) }) : t('sharing.event.rejected');110 default: return e.action;111 }112 };113 const answers: [string, string][] = s ? [114 ...(['name', 'email', 'reference'] as const).filter((k) => s[k]).map((k): [string, string] => [t(`sharing.portal.field.${k}`), s[k]]),115 ...Object.entries(s.meta ?? {}).map(([k, v]): [string, string] => [portal.fields.custom.find((c) => c.key === k)?.label ?? k, String(v)]),116 ] : [];117 return (118 <Dialog title={s ? t('sharing.detail.subTitle', { ref: s.ref }) : t('common.loading')} onClose={onClose} wide footer={<>{s?.folderId ? <Link href={`/folder/${s.folderId}`} className="mr-auto text-sm text-accent underline-offset-2 hover:underline">{t('sharing.detail.openFolder')}</Link> : null}<Button variant="primary" onClick={onClose}>{t('common.close')}</Button></>}>119 {q.isLoading ? <Loading /> : q.error || !s ? <ErrorState error={q.error} onRetry={() => void q.refetch()} /> : (120 <div className="space-y-5" data-testid="submission-detail">121 <p className="flex flex-wrap items-center gap-2 text-sm"><Badge tone={SUB_TONE[s.status]}>{t(`sharing.sstatus.${s.status}`)}</Badge><span className="text-ink-2">{t('sharing.detail.received', { date: fmtDate(s.createdAt) })}{s.completedAt ? ` · ${t('sharing.detail.processed', { date: fmtDate(s.completedAt, { timeStyle: 'medium' }) })}` : ''}</span></p>122 <section>123 <h3 className="text-xs font-semibold tracking-wide text-ink-3 uppercase">{t('sharing.detail.answers')}</h3>124 <dl className="mt-2 grid grid-cols-1 gap-x-6 gap-y-2 text-sm sm:grid-cols-2" data-testid="answers">125 {answers.length ? answers.map(([k, v]) => <div key={k} className="min-w-0"><dt className="text-xs text-ink-3">{k}</dt><dd className="break-words whitespace-pre-line">{v}</dd></div>) : <p className="text-ink-3">{t('sharing.detail.noAnswers')}</p>}126 <div><dt className="text-xs text-ink-3">{t('sharing.detail.consentAt')}</dt><dd>{s.consentAt ? fmtDate(s.consentAt, { dateStyle: 'medium', timeStyle: 'medium' }) : t('sharing.detail.consentNone')}</dd></div>127 <div className="min-w-0"><dt className="text-xs text-ink-3">{t('sharing.detail.origin')}</dt><dd className="break-words">{s.ip || '—'} · {uaSummary(s.userAgent, t('sharing.events.unknownDevice'))}</dd></div>128 </dl>129 </section>130 <section>131 <h3 className="text-xs font-semibold tracking-wide text-ink-3 uppercase">{t('sharing.detail.filesCol')}</h3>132 <ul className="mt-2 divide-y divide-line rounded-xl border border-line" data-testid="sub-items">133 {s.items.map((i) => (134 <li key={i.id} className="flex items-start gap-3 px-3 py-2.5" data-status={i.status}>135 <Glyph name={i.status === 'placed' ? 'check' : i.status === 'rejected' ? 'x' : 'clock'} size={18} className={i.status === 'placed' ? 'mt-0.5 shrink-0 text-ok' : i.status === 'rejected' ? 'mt-0.5 shrink-0 text-danger' : 'mt-0.5 shrink-0 text-ink-3'} />136 <div className="min-w-0 flex-1">137 <p className="text-sm font-medium break-words">{i.name}{i.originalName !== i.name ? <span className="font-normal text-ink-3"> ({t('sharing.detail.sentAs', { name: i.originalName })})</span> : null}</p>138 <p className="text-xs text-ink-3">{formatBytes(i.size, loc)}{i.mime ? ` · ${i.mime}` : ''}</p>139 {i.status === 'rejected' ? <p className="text-sm text-danger">{t('sharing.detail.itemRejected')} — {reason(i.rejectCode)}</p>140 : i.status === 'placed' ? <p className="text-sm text-ok">{t('sharing.detail.itemPlaced')}{s.folderId ? <> · <Link href={`/folder/${s.folderId}`} className="text-accent underline-offset-2 hover:underline">{t('sharing.detail.openFolder')}</Link></> : null}</p>141 : <p className="text-sm text-ink-2">{i.status === 'quarantined' ? t('sharing.detail.itemQuarantine') : t('sharing.detail.itemUploading')}</p>}142 </div>143 </li>144 ))}145 </ul>146 </section>147 <section>148 <h3 className="text-xs font-semibold tracking-wide text-ink-3 uppercase">{t('sharing.detail.journal')}</h3>149 <ol className="mt-2 space-y-2" data-testid="sub-events">150 {s.events.map((e) => <li key={e.id} className="flex gap-3 text-sm" data-action={e.action}><time className="w-20 shrink-0 text-xs text-ink-3 tabular-nums" dateTime={e.at}>{fmtDate(e.at, { timeStyle: 'medium' })}</time><span className="min-w-0 break-words">{eventText(e)}</span></li>)}151 </ol>152 </section>153 </div>154 )}155 </Dialog>156 );157}158