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).
126 lines
4.0 KiB
TypeScript
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 & 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>
|
|
);
|
|
}
|