Files
pn-new-crm/src/components/companies/company-files-tab.tsx
Matt 5bd0e1ad9a 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).
2026-05-23 01:01:52 +02:00

126 lines
4.0 KiB
TypeScript

'use client';
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';
import { apiFetch } from '@/lib/api/client';
import { triggerUrlDownload } from '@/lib/utils/download';
import type { FileRow } from '@/components/files/file-grid';
interface CompanyFilesTabProps {
companyId: string;
}
export function CompanyFilesTab({ companyId }: CompanyFilesTabProps) {
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>({
queryKey: ['files', { companyId }],
endpoint: `/api/v1/files?companyId=${encodeURIComponent(companyId)}`,
filterDefinitions: [],
});
useRealtimeInvalidation({
'file:uploaded': [['files', { companyId }]],
'file:updated': [['files', { companyId }]],
'file:deleted': [['files', { companyId }]],
});
const handleDownload = async (file: FileRow) => {
try {
const res = await apiFetch<{ data: { url: string; filename: string } }>(
`/api/v1/files/${file.id}/download`,
);
triggerUrlDownload(res.data.url, res.data.filename);
} catch {
// silent
}
};
const handleDelete = async (file: FileRow) => {
const ok = await confirm({
title: 'Delete file',
description: `Delete "${file.filename}"? This cannot be undone.`,
confirmLabel: 'Delete',
});
if (!ok) return;
try {
await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' });
queryClient.invalidateQueries({ queryKey: ['files', { companyId }] });
} catch {
// silent
}
};
return (
<div className="space-y-4">
<PermissionGate resource="files" action="upload">
<div className="space-y-3">
<FileUploadZone
companyId={companyId}
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: ['files', { companyId }] });
}}
/>
<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
files={data}
onDownload={handleDownload}
onPreview={setPreviewFile}
onRename={() => {}}
onDelete={handleDelete}
isLoading={isLoading}
/>
<FilePreviewDialog
open={!!previewFile}
onOpenChange={(open) => !open && setPreviewFile(null)}
fileId={previewFile?.id}
fileName={previewFile?.filename}
mimeType={previewFile?.mimeType ?? undefined}
/>
{uploadForSigningOpen && (
<UploadForSigningDialog
open={uploadForSigningOpen}
onOpenChange={setUploadForSigningOpen}
interestId={null}
documentType="generic"
entity={{ type: 'company', id: companyId }}
/>
)}
{confirmDialog}
</div>
);
}