SPB Git

Repository Public

RequestDetail.tsx 14.9 KB 158 lines tsx

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
Files main
Menu

Theme