SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
8.1 KB · 147 lines tsx
Raw Blame History
1import Link from 'next/link';2import { Section } from '@/components/ui/section';3import { Badge, MatchBadge, StatusBadge } from '@/components/ui/badge';4import { EmptyState } from '@/components/ui/empty-state';5import { Pagination } from '@/components/ui/pagination';6import { JsonView } from '@/components/ui/json-view';7import { listUnresolved } from '@/lib/queries/admin';8import { resolveAction, rejectAction } from '@/lib/admin/actions';9import { fmtInt, fmtDateTime } from '@/lib/format';10import { str, int, withParams, type SP } from '@/lib/search-params';1112const PAGE_SIZE = 50;1314export default async function UnresolvedPage({ searchParams }: { searchParams: Promise<SP> }) {15  const sp = await searchParams;16  const entityKind = str(sp, 'kind');17  const status = str(sp, 'status', 'open');18  const page = int(sp, 'page', 1, 1, 10_000);19  const msg = str(sp, 'msg');20  const ok = str(sp, 'ok');21  const { rows, total, kinds } = await listUnresolved({ entityKind, status, page, pageSize: PAGE_SIZE });22  const href = (o: Record<string, string | number | null | undefined>) => `/admin/unresolved${withParams({ kind: entityKind, status }, o)}`;2324  return (25    <div className="space-y-6">26      <header className="pt-4">27        <h1 className="text-3xl">Unresolved labels</h1>28        <p className="mt-1 text-[13.5px] text-ink-2">Source labels that no identifier, alias or normalized string could map. Resolving adds an alias to the target entity, marks the label mapped and writes the audit log.</p>29      </header>30      {msg ? (31        <p role="status" className={`border-l-2 pl-3 text-[13px] ${ok === '1' ? 'border-ok text-ok' : 'border-danger text-danger'}`}>32          {msg}33        </p>34      ) : null}35      <div className="flex flex-wrap gap-4 text-[12.5px]">36        <div className="flex flex-wrap items-center gap-1.5">37          <span className="ci-kicker mr-1">Kind</span>38          <Link href={href({ kind: '', page: '' })} className={`border px-2 py-0.5 no-underline ${!entityKind ? 'border-accent bg-accent-soft' : 'border-rule'}`}>39            All40          </Link>41          {kinds.map((k) => (42            <Link key={k.k} href={href({ kind: k.k, page: '' })} className={`border px-2 py-0.5 no-underline ${entityKind === k.k ? 'border-accent bg-accent-soft' : 'border-rule'}`}>43              {k.k} <span className="ci-num text-ink-3">{fmtInt(k.n)}</span>44            </Link>45          ))}46        </div>47        <div className="flex flex-wrap items-center gap-1.5">48          <span className="ci-kicker mr-1">Status</span>49          {['open', 'mapped', 'rejected', 'ignored', ''].map((s) => (50            <Link key={s || 'all'} href={href({ status: s, page: '' })} className={`border px-2 py-0.5 no-underline ${status === s ? 'border-accent bg-accent-soft' : 'border-rule'}`}>51              {s || 'all'}52            </Link>53          ))}54        </div>55      </div>56      <Section id="queue" title={`${fmtInt(total)} labels`} kicker="Queue · sorted by occurrence count">57        {rows.length === 0 ? (58          <EmptyState compact title="Queue empty">No unresolved label matches these filters. Labels are enqueued by connectors when reconciliation fails.</EmptyState>59        ) : (60          <>61            <div className="ci-table-wrap">62              <table className="ci-table">63                <thead>64                  <tr>65                    <th className="num">Count</th>66                    <th>Label</th>67                    <th>Kind · source</th>68                    <th>Suggestion</th>69                    <th>Status</th>70                    <th>Actions</th>71                  </tr>72                </thead>73                <tbody>74                  {rows.map((u) => (75                    <tr key={u.id}>76                      <td className="num font-medium">{fmtInt(u.count)}</td>77                      <td className="min-w-[220px]">78                        <span className="font-medium">{u.source_text}</span>79                        <span className="ci-mono block text-[11px] text-ink-3">{u.normalized}</span>80                        {Object.keys(u.context ?? {}).length ? (81                          <details className="text-[11.5px]">82                            <summary className="ci-link">context</summary>83                            <JsonView data={u.context} />84                          </details>85                        ) : null}86                        <span className="block text-[11px] text-ink-4">#{u.id} · {fmtDateTime(u.updated_at)}</span>87                      </td>88                      <td>89                        <Badge>{u.entity_kind}</Badge> <span className="ci-mono text-[11.5px]">{u.source_slug}</span>90                      </td>91                      <td className="min-w-[200px] text-[12.5px]">92                        {u.suggested_id ? (93                          <>94                            <span className="ci-mono">{u.suggested_id}</span> {u.suggested_name ? <span>— {u.suggested_name}</span> : null}95                            <span className="mt-0.5 flex items-center gap-1">96                              <MatchBadge matchType={u.suggested_match_type} />97                              {u.suggested_score != null ? <span className="ci-num text-[11px] text-ink-3">score {u.suggested_score.toFixed(2)}</span> : null}98                            </span>99                          </>100                        ) : (101                          <span className="text-ink-4">no suggestion</span>102                        )}103                      </td>104                      <td>105                        <StatusBadge status={u.status} />106                        {u.resolved_id ? <span className="ci-mono block text-[11px]">{u.resolved_id}</span> : null}107                      </td>108                      <td className="min-w-[280px]">109                        {u.status === 'open' ? (110                          <div className="space-y-1.5 text-[12.5px]">111                            <form action={resolveAction} className="flex flex-wrap gap-1">112                              <input type="hidden" name="id" value={u.id} />113                              <input name="targetId" defaultValue={u.suggested_id ?? ''} placeholder="CI-CAN-00000000" className="ci-mono w-40 border border-rule-strong bg-white px-1.5 py-0.5 text-[12px]" aria-label="Target entity id" required />114                              <input name="reason" placeholder="reason (optional)" className="w-36 border border-rule-strong bg-white px-1.5 py-0.5 text-[12px]" aria-label="Reason" />115                              <button type="submit" className="border border-accent bg-accent-soft px-2 py-0.5 text-accent-2 hover:bg-accent hover:text-white">116                                Resolve117                              </button>118                            </form>119                            <form action={rejectAction} className="flex flex-wrap gap-1">120                              <input type="hidden" name="id" value={u.id} />121                              <select name="status" className="border border-rule-strong bg-white px-1 py-0.5 text-[12px]" aria-label="Rejection status">122                                <option value="rejected">rejected (not a {u.entity_kind})</option>123                                <option value="ignored">ignored</option>124                              </select>125                              <input name="reason" placeholder="reason (optional)" className="w-36 border border-rule-strong bg-white px-1.5 py-0.5 text-[12px]" aria-label="Reason" />126                              <button type="submit" className="border border-rule px-2 py-0.5 text-ink-2 hover:border-danger hover:text-danger">127                                Reject128                              </button>129                            </form>130                          </div>131                        ) : (132                          <span className="text-[12px] text-ink-3">by {u.resolved_by ?? '—'}</span>133                        )}134                      </td>135                    </tr>136                  ))}137                </tbody>138              </table>139            </div>140            <Pagination page={page} pageSize={PAGE_SIZE} total={total} hrefFor={(p) => href({ page: p === 1 ? '' : p })} />141          </>142        )}143      </Section>144    </div>145  );146}147