feat(uat-batch-4): a11y form primitives + click-to-preview + EOI empty-state + lint guards

- FieldError primitive (role=alert, aria-live) — used by Wave 3
  form-error UX work.
- FieldLabel primitive (Label + Info-tooltip slot) — foundational for
  the platform-wide admin-settings tooltip audit.
- ESLint guard against em-dash in user-facing JSX text inside
  src/components + src/app (warning, not error; 111 existing instances
  flagged for follow-up sweep).
- FileGrid card body becomes click-to-preview button (was hidden under
  a kebab); aria-label per row; kebab keeps Download/Rename/Delete.
- DocumentList: title cell on rows with signedFileId opens
  FilePreviewDialog; kebab gains Download action (was missing
  per UAT). Single FilePreviewDialog instance lifted to the parent.
- DocumentList type extended with signedFileId.
- EOI empty state: third ghost button "Mark signed without file"
  wired to existing MarkExternallySignedDialog (parity with
  reservation tab).
- Watcher empty-state padding fix on document-detail.

tsc clean. 1419/1419 vitest. lint clean on touched files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 17:20:13 +02:00
parent 6a4f4ea1dd
commit 52342ee45d
7 changed files with 212 additions and 17 deletions

View File

@@ -543,7 +543,7 @@ function WatchersCard({ documentId, watchers }: { documentId: string; watchers:
</p>
{watchers.length === 0 ? (
<p className="text-xs text-muted-foreground">No one is watching this document yet.</p>
<p className="mb-3 text-xs text-muted-foreground">No one is watching this document yet.</p>
) : (
<ul className="mb-3 space-y-1">
{watchers.map((w) => {

View File

@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { FolderInput } from 'lucide-react';
import { Download, FolderInput } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { Badge } from '@/components/ui/badge';
@@ -12,10 +12,12 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client';
import { triggerUrlDownload } from '@/lib/utils/download';
import { MoveToFolderDialog } from './move-to-folder-dialog';
interface DocumentRow {
@@ -25,6 +27,8 @@ interface DocumentRow {
status: string;
createdAt: string;
folderId: string | null;
/** When set, the doc has a downloaded signed PDF the rep can preview/download. */
signedFileId?: string | null;
signers?: Array<{ status: string }>;
}
@@ -57,10 +61,23 @@ interface DocRowProps {
doc: DocumentRow;
onDelete: (doc: DocumentRow) => void;
onSend: (doc: DocumentRow) => void;
onPreview: (fileId: string, fileName: string) => void;
}
function DocRow({ doc, onDelete, onSend }: DocRowProps) {
async function downloadSignedFile(fileId: string, fallbackName: string) {
try {
const res = await apiFetch<{ data: { url: string; filename: string } }>(
`/api/v1/files/${fileId}/download`,
);
triggerUrlDownload(res.data.url, res.data.filename || fallbackName);
} catch {
// silent — toast handled by the presign route on its own
}
}
function DocRow({ doc, onDelete, onSend, onPreview }: DocRowProps) {
const [moveOpen, setMoveOpen] = useState(false);
const hasSignedFile = !!doc.signedFileId;
const signerProgress = (() => {
if (!doc.signers) return '-';
@@ -74,7 +91,19 @@ function DocRow({ doc, onDelete, onSend }: DocRowProps) {
<td className="px-4 py-3">
<Badge variant="outline">{TYPE_LABELS[doc.documentType] ?? doc.documentType}</Badge>
</td>
<td className="px-4 py-3 font-medium">{doc.title}</td>
<td className="px-4 py-3 font-medium">
{hasSignedFile ? (
<button
type="button"
onClick={() => onPreview(doc.signedFileId!, doc.title)}
className="text-left hover:underline focus:outline-none focus:underline"
>
{doc.title}
</button>
) : (
doc.title
)}
</td>
<td className="px-4 py-3">
<Badge variant={STATUS_COLORS[doc.status] ?? 'default'}>{doc.status}</Badge>
</td>
@@ -90,6 +119,12 @@ function DocRow({ doc, onDelete, onSend }: DocRowProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{hasSignedFile && (
<DropdownMenuItem onClick={() => downloadSignedFile(doc.signedFileId!, doc.title)}>
<Download className="mr-2 h-4 w-4" aria-hidden />
Download
</DropdownMenuItem>
)}
{doc.status === 'draft' && (
<DropdownMenuItem onClick={() => onSend(doc)}>Send for Signing</DropdownMenuItem>
)}
@@ -123,6 +158,7 @@ function DocRow({ doc, onDelete, onSend }: DocRowProps) {
export function DocumentList({ interestId, clientId, emptyState }: DocumentListProps) {
const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
const [previewFile, setPreviewFile] = useState<{ id: string; name: string } | null>(null);
const queryParams = new URLSearchParams();
if (interestId) queryParams.set('interestId', interestId);
@@ -184,11 +220,25 @@ export function DocumentList({ interestId, clientId, emptyState }: DocumentListP
</thead>
<tbody>
{data.map((doc) => (
<DocRow key={doc.id} doc={doc} onDelete={handleDelete} onSend={handleSend} />
<DocRow
key={doc.id}
doc={doc}
onDelete={handleDelete}
onSend={handleSend}
onPreview={(id, name) => setPreviewFile({ id, name })}
/>
))}
</tbody>
</table>
{confirmDialog}
<FilePreviewDialog
open={!!previewFile}
onOpenChange={(o) => {
if (!o) setPreviewFile(null);
}}
fileId={previewFile?.id}
fileName={previewFile?.name}
/>
</div>
);
}