'use client' import { useState, useEffect, useMemo } from 'react' import { useTranslations } from 'next-intl' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { Input } from '@/components/ui/input' import { useToast } from '@/components/ui/use-toast' import { cn, formatCurrency, formatDate } from '@/lib/utils' import { FileText, ImageIcon, Loader2, Search, Inbox, Eye } from 'lucide-react' // InboxDocumentPicker // // Lists invoice-inbox documents that have not yet been consumed (no supplier // invoice, no journal entry, not matched to a transaction, document not // already linked) so the user can attach one as underlag to a verifikat. // // Two modes: // - Link mode (JournalEntryAttachments, "Välj från inkorgen"): `journalEntryId` // is set and picking a document immediately links it to the journal entry AND // stamps the inbox item so it drops out of the active inbox: see // app/api/documents/[id]/link/route.ts. // - Select mode (TransactionBookingDialog, "Välj befintligt underlag"): the // journal entry does not exist yet, so `onSelect` is provided instead and the // pick is reported to the parent, which links after the entry is created. // // Each row carries a preview button (eye) that opens a quick dialog rendering // the document inline, so the user can confirm the right file before attaching. // Attaching is the row's primary click (fast path) and is also offered from // inside the preview dialog (preview → confirm). export interface AvailableInboxDoc { inbox_item_id: string document_id: string file_name: string mime_type: string | null file_size_bytes: number source: string | null created_at: string supplier_name: string | null amount: number | null currency: string | null invoice_date: string | null } interface Props { open: boolean onClose: () => void /** Link mode: the journal entry to link the picked document to. */ journalEntryId?: string /** Link mode: called after a successful link so the parent can refresh its document list. */ onLinked?: () => void /** Select mode: report the picked document to the parent instead of linking. */ onSelect?: (doc: AvailableInboxDoc) => void } function isImageType(type: string | null): boolean { return type?.startsWith('image/') ?? false } function isPdfType(type: string | null): boolean { return type === 'application/pdf' } function DocIcon({ mime }: { mime: string | null }) { if (isImageType(mime)) { return } return } export default function InboxDocumentPicker({ open, onClose, journalEntryId, onLinked, onSelect }: Props) { const t = useTranslations('journal_attachments') const { toast } = useToast() const [loading, setLoading] = useState(true) const [items, setItems] = useState([]) const [search, setSearch] = useState('') const [linkingId, setLinkingId] = useState(null) const [previewItem, setPreviewItem] = useState(null) // Reset + fetch each time the dialog opens. useEffect(() => { if (!open) return setSearch('') setItems([]) setPreviewItem(null) setLoading(true) let cancelled = false ;(async () => { try { const res = await fetch('/api/documents/inbox-available') const json = (await res.json().catch(() => ({}))) as { data?: AvailableInboxDoc[] } if (cancelled) return setItems(json.data ?? []) } catch { if (!cancelled) setItems([]) } finally { if (!cancelled) setLoading(false) } })() return () => { cancelled = true } }, [open]) const filtered = useMemo(() => { const q = search.trim().toLowerCase() if (!q) return items return items.filter((it) => `${it.supplier_name ?? ''} ${it.file_name}`.toLowerCase().includes(q), ) }, [items, search]) async function handlePick(item: AvailableInboxDoc) { // Select mode: the journal entry doesn't exist yet: hand the pick to the // parent and let it link after creation. Clear the preview first: the // preview dialog's open state is `previewItem !== null`, so leaving it set // would strand a floating preview after the picker closes (the component // stays mounted; the on-open reset only runs on the next open). if (onSelect) { setPreviewItem(null) onSelect(item) onClose() return } if (!journalEntryId) return setLinkingId(item.document_id) try { const res = await fetch(`/api/documents/${item.document_id}/link`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ journal_entry_id: journalEntryId, inbox_item_id: item.inbox_item_id, }), }) if (!res.ok) { const json = (await res.json().catch(() => ({}))) as { error?: string | { message?: string } } const description = typeof json.error === 'string' ? json.error : json.error?.message toast({ title: t('picker_link_failed'), description, variant: 'destructive' }) return } toast({ title: t('picker_linked') }) setPreviewItem(null) onLinked?.() onClose() } catch { toast({ title: t('picker_link_failed'), variant: 'destructive' }) } finally { setLinkingId(null) } } const hasSearch = search.trim().length > 0 const previewSrc = previewItem ? `/api/documents/${previewItem.document_id}/inline` : null return ( <> !o && onClose()}> {t('picker_title')} {t('picker_description')}
setSearch(e.target.value)} placeholder={t('picker_search_placeholder')} className="pl-9" />
{!loading && filtered.length > 0 && (
{t('picker_results', { count: filtered.length })}
)}
{loading ? (
{Array.from({ length: 5 }).map((_, i) => ( ))}
) : filtered.length === 0 ? (

{hasSearch ? t('picker_empty_search', { query: search.trim() }) : t('picker_empty')}

) : ( filtered.map((it) => { const isLinking = linkingId === it.document_id const sourceLabel = it.source === 'email' ? t('picker_source_email') : t('picker_source_upload') return (
) }) )}
!o && setPreviewItem(null)}> {previewItem?.file_name} {previewItem && (previewItem.supplier_name || previewItem.amount != null) && ( {previewItem.supplier_name && {previewItem.supplier_name}} {previewItem.amount != null && ( {formatCurrency(previewItem.amount, previewItem.currency ?? 'SEK')} )} {previewItem.invoice_date && {formatDate(previewItem.invoice_date)}} )} {previewItem && previewSrc && (
{isImageType(previewItem.mime_type) ? ( {previewItem.file_name} ) : isPdfType(previewItem.mime_type) ? ( // + type="application/pdf" invokes Chrome's PDF plugin // directly;