'use client' import { useState, useEffect, useCallback, useRef } from 'react' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' import { openDeferredTab } from '@/lib/browser/deferred-tab' import { FileText, ImageIcon, Download, ChevronDown, ChevronUp, Plus, Trash2, RefreshCw, Loader2, Lock, AlertTriangle, Inbox, } from 'lucide-react' import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' import InboxDocumentPicker from '@/components/bookkeeping/InboxDocumentPicker' interface DocumentRecord { id: string file_name: string file_size_bytes: number mime_type: string | null storage_path: string created_at: string download_url?: string referenced?: boolean } interface JournalEntryAttachmentsProps { journalEntryId: string onCountChange?: (count: number) => void } function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` return `${(bytes / (1024 * 1024)).toFixed(1)} MB` } function isImageType(type: string | null): boolean { return type?.startsWith('image/') ?? false } function isPdfType(type: string | null): boolean { return type === 'application/pdf' } function isPreviewable(type: string | null): boolean { return isImageType(type) || isPdfType(type) } export default function JournalEntryAttachments({ journalEntryId, onCountChange, }: JournalEntryAttachmentsProps) { const t = useTranslations('journal_attachments') const tCommon = useTranslations('common') const { toast } = useToast() const [documents, setDocuments] = useState([]) const [loading, setLoading] = useState(true) const [expandedDoc, setExpandedDoc] = useState(null) const [showUpload, setShowUpload] = useState(false) const [showInboxPicker, setShowInboxPicker] = useState(false) const [uploadFiles, setUploadFiles] = useState([]) // Docs listed here are filtered by journal_entry_id, so every row is bound // to a verifikation: BFL 7 kap 2§ blocks deletion. "Ta bort" therefore // surfaces the educational modal; "Ersätt" goes through createNewVersion() // so the original stays in the version chain. const [blockedDoc, setBlockedDoc] = useState(null) const [replacingDocId, setReplacingDocId] = useState(null) const replaceFileInputRef = useRef(null) const replaceTargetIdRef = useRef(null) const onCountChangeRef = useRef(onCountChange) onCountChangeRef.current = onCountChange const fetchDocuments = useCallback(async () => { try { const [documentsRes, referencesRes] = await Promise.all([ fetch(`/api/documents?journal_entry_id=${journalEntryId}¤t_only=true`), fetch(`/api/bookkeeping/journal-entries/${journalEntryId}/references`), ]) const { data: directDocuments } = await documentsRes.json() const direct = (directDocuments || []) as DocumentRecord[] const directIds = new Set(direct.map((document) => document.id)) let referenced: DocumentRecord[] = [] if (referencesRes.ok) { const { data: referenceData } = await referencesRes.json() const documentIds = Array.from(new Set( (referenceData?.references || []) .map((reference: { document_id?: string }) => reference.document_id) .filter((documentId: string | undefined): documentId is string => ( Boolean(documentId) && !directIds.has(documentId as string) )), )) const referencedDocuments = await Promise.all( documentIds.map(async (documentId) => { try { const response = await fetch(`/api/documents/${documentId}`) if (!response.ok) return null const { data } = await response.json() return data ? { ...data, referenced: true } as DocumentRecord : null } catch { return null } }), ) referenced = referencedDocuments.filter( (document): document is DocumentRecord => document !== null, ) } const allDocuments = [...direct, ...referenced] setDocuments(allDocuments) onCountChangeRef.current?.(allDocuments.length) } catch { // Non-critical: silently ignore } finally { setLoading(false) } }, [journalEntryId]) useEffect(() => { fetchDocuments() }, [fetchDocuments]) // Refresh documents when uploads complete useEffect(() => { const allDone = uploadFiles.length > 0 && uploadFiles.every((f) => f.status !== 'uploading') const hasUploaded = uploadFiles.some((f) => f.status === 'uploaded') if (allDone && hasUploaded) { fetchDocuments() setUploadFiles([]) setShowUpload(false) } }, [uploadFiles, fetchDocuments]) const handleDownload = async (docId: string) => { // Pre-open inside the click's user activation: a window.open after the // await is popup-blocked when the signed-URL fetch is slow. const tab = openDeferredTab(tCommon('loading')) try { const res = await fetch(`/api/documents/${docId}`) const { data } = await res.json() if (!data?.download_url || !tab.navigate(data.download_url)) { tab.close() toast({ title: t('download_failed'), description: tab.blocked ? tCommon('popup_blocked_description') : undefined, variant: 'destructive', }) } } catch { tab.close() toast({ title: t('download_failed'), variant: 'destructive' }) } } const handlePreviewToggle = async (doc: DocumentRecord) => { if (expandedDoc === doc.id) { setExpandedDoc(null) return } if (!doc.download_url) { try { const res = await fetch(`/api/documents/${doc.id}`) const { data } = await res.json() if (data?.download_url) { setDocuments((prev) => prev.map((d) => (d.id === doc.id ? { ...d, download_url: data.download_url } : d)) ) } } catch { return } } setExpandedDoc(doc.id) } const handleRequestRemove = (doc: DocumentRecord) => { setBlockedDoc(doc) } const handleOpenReplacePicker = (docId: string) => { replaceTargetIdRef.current = docId replaceFileInputRef.current?.click() } const handleReplaceFileSelected = async (file: File | null) => { const docId = replaceTargetIdRef.current replaceTargetIdRef.current = null if (replaceFileInputRef.current) { replaceFileInputRef.current.value = '' } if (!file || !docId) return setReplacingDocId(docId) try { const fd = new FormData() fd.append('file', file) const res = await fetch(`/api/documents/${docId}/versions`, { method: 'POST', body: fd, }) if (!res.ok) { const { error } = await res.json().catch(() => ({ error: undefined })) toast({ title: t('replace_failed'), description: error || undefined, variant: 'destructive', }) } else { await fetchDocuments() setBlockedDoc(null) } } catch { toast({ title: t('replace_failed'), variant: 'destructive' }) } finally { setReplacingDocId(null) } } if (loading) { return (
{t('loading')}
) } return (
handleReplaceFileSelected(e.target.files?.[0] ?? null)} />

{t('title')} {documents.length > 0 && `(${documents.length})`}

{showUpload && (
)} {documents.length === 0 && !showUpload ? (

{t('empty')}

) : (
{documents.map((doc) => { const isReplacing = replacingDocId === doc.id return (
{isPreviewable(doc.mime_type) ? ( ) : ( )} {isPreviewable(doc.mime_type) && expandedDoc !== doc.id && ( isImageType(doc.mime_type) ? ( ) : ( ) )} {doc.file_name} {doc.referenced && ( {t('via_supplier_invoice')} )} {formatFileSize(doc.file_size_bytes)} {!doc.referenced && ( <> )}
{expandedDoc === doc.id && doc.download_url && isImageType(doc.mime_type) && (
{doc.file_name}
)} {expandedDoc === doc.id && doc.download_url && isPdfType(doc.mime_type) && (
{/* + type="application/pdf" invokes Chrome's PDF plugin directly.