feat(documents): universal upload-with-fields UI wiring (B3 #11)

Backend foundations were already in place ('generic' CustomDocumentType,
storage-path routing). This wires the UI surface across Documents Hub +
entity file tabs.

- UploadForSigningDialog: interestId now string | null; new entity?,
  folderId?, onCreated? props. Generic path POSTs to new endpoint
  /api/v1/upload-for-signing; interest-scoped paths unchanged.
- uploadDocumentForSigning service: interestId nullable; skips interest
  lookup, pipeline-stage advance, doc-status flip on the generic path.
  Routes file FK + auto-filed folder via either interest.clientId or the
  caller-supplied entity. Validation enforces the matching invariant
  (generic must be interestId=null, type-specific must carry one).
- New menu item in NewDocumentMenu ("Upload & send for signature") on
  Documents Hub root + folder views.
- Upload & send-for-signature button on ClientFilesTab + CompanyFilesTab,
  gated by documents.send_for_signing.

Existing unit tests for the service still pass (validation paths unchanged).
This commit is contained in:
2026-05-23 01:01:52 +02:00
parent 221ae5784e
commit 5bd0e1ad9a
7 changed files with 432 additions and 56 deletions

View File

@@ -2,11 +2,14 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Pen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { FileGrid } from '@/components/files/file-grid';
import { FileUploadZone } from '@/components/files/file-upload-zone';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useConfirmation } from '@/hooks/use-confirmation';
@@ -21,6 +24,7 @@ interface ClientFilesTabProps {
export function ClientFilesTab({ clientId }: ClientFilesTabProps) {
const queryClient = useQueryClient();
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
const [uploadForSigningOpen, setUploadForSigningOpen] = useState(false);
const { confirm, dialog: confirmDialog } = useConfirmation();
const { data, isLoading } = usePaginatedQuery<FileRow>({
@@ -64,12 +68,28 @@ export function ClientFilesTab({ clientId }: ClientFilesTabProps) {
return (
<div className="space-y-4">
<PermissionGate resource="files" action="upload">
<FileUploadZone
clientId={clientId}
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: ['files', { clientId }] });
}}
/>
<div className="space-y-3">
<FileUploadZone
clientId={clientId}
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: ['files', { clientId }] });
}}
/>
<PermissionGate resource="documents" action="send_for_signing">
<div className="flex items-center justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setUploadForSigningOpen(true)}
className="gap-2"
>
<Pen className="h-4 w-4" aria-hidden />
Upload &amp; send for signature
</Button>
</div>
</PermissionGate>
</div>
</PermissionGate>
<FileGrid
@@ -88,6 +108,17 @@ export function ClientFilesTab({ clientId }: ClientFilesTabProps) {
fileName={previewFile?.filename}
mimeType={previewFile?.mimeType ?? undefined}
/>
{uploadForSigningOpen && (
<UploadForSigningDialog
open={uploadForSigningOpen}
onOpenChange={setUploadForSigningOpen}
interestId={null}
documentType="generic"
entity={{ type: 'client', id: clientId }}
/>
)}
{confirmDialog}
</div>
);