'use client' import { useState, useEffect, useCallback } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' import { ChevronDown, ChevronRight, Paperclip, AlertTriangle, CircleSlash, Loader2, BookOpen, X, Copy, Lock } from 'lucide-react' import { formatDate } from '@/lib/utils' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { Input } from '@/components/ui/input' import { AccountNumber } from '@/components/ui/account-number' import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions' import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments' import NoDocRequiredToggle from '@/components/bookkeeping/NoDocRequiredToggle' import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog' import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge' import AttachmentPreviewSheet from '@/components/bookkeeping/AttachmentPreviewSheet' import { useToast } from '@/components/ui/use-toast' import { useCanWrite } from '@/lib/hooks/use-can-write' import { getErrorMessage } from '@/lib/errors/get-error-message' import type { JournalEntry, JournalEntryLine } from '@/types' const NEEDS_ATTACHMENT = new Set([ 'manual', 'bank_transaction', 'supplier_invoice_registered', 'supplier_invoice_paid', 'supplier_invoice_cash_payment', 'import', ]) interface Props { periodId?: string } export default function JournalEntryList({ periodId }: Props) { const router = useRouter() const { toast } = useToast() const { canWrite } = useCanWrite() const t = useTranslations('journal_list') const [entries, setEntries] = useState([]) const [committingId, setCommittingId] = useState(null) const [loading, setLoading] = useState(true) const [expandedId, setExpandedId] = useState(null) const [count, setCount] = useState(0) const [page, setPage] = useState(0) const [attachmentCounts, setAttachmentCounts] = useState>({}) const [noDocRequired, setNoDocRequired] = useState>(new Map()) const [showMissingOnly, setShowMissingOnly] = useState(false) const [correctionEntry, setCorrectionEntry] = useState(null) const [previewEntryId, setPreviewEntryId] = useState(null) const [sortBy, setSortBy] = useState<'date_desc' | 'date_asc' | 'voucher_asc' | 'voucher_desc'>('date_desc') const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') const [dateFromInput, setDateFromInput] = useState('') const [dateToInput, setDateToInput] = useState('') const [seriesFilter, setSeriesFilter] = useState('all') const pageSize = 20 const normalizeDate = (v: string): string | null => { const trimmed = v.trim() if (!trimmed) return null // YYYY if (/^\d{4}$/.test(trimmed)) { const y = parseInt(trimmed, 10) if (y < 1900 || y > 2100) return null return `${trimmed}-01-01` } // YYYY-MM if (/^\d{4}-\d{2}$/.test(trimmed)) { const [y, m] = trimmed.split('-').map(Number) if (y < 1900 || y > 2100 || m < 1 || m > 12) return null return `${trimmed}-01` } // YYYY-MM-DD if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { const d = new Date(trimmed + 'T00:00:00') if (isNaN(d.getTime())) return null // Verify the date didn't roll over (e.g. 2024-02-31 → March) const [y, m, day] = trimmed.split('-').map(Number) if (d.getFullYear() !== y || d.getMonth() + 1 !== m || d.getDate() !== day) return null return trimmed } return null } const applyDateFilter = () => { const fromVal = dateFromInput.trim() const toVal = dateToInput.trim() const nextFrom = fromVal === '' ? '' : normalizeDate(fromVal) ?? dateFrom const nextTo = toVal === '' ? '' : normalizeDate(toVal) ?? dateTo setDateFromInput(nextFrom) setDateToInput(nextTo) if (nextFrom !== dateFrom || nextTo !== dateTo) { setDateFrom(nextFrom) setDateTo(nextTo) setPage(0) } } const fetchAttachmentCounts = useCallback(async (entryIds: string[]) => { if (entryIds.length === 0) return try { const res = await fetch( `/api/documents/counts?journal_entry_ids=${entryIds.join(',')}` ) const { data } = await res.json() setAttachmentCounts(data || {}) } catch { // Non-critical — silently ignore } }, []) const fetchNoDocRequired = useCallback(async () => { try { const res = await fetch('/api/bookkeeping/no-doc-required') if (!res.ok) return const { data } = await res.json() const map = new Map() for (const row of (data || []) as { journal_entry_id: string; reason: string | null }[]) { map.set(row.journal_entry_id, row.reason) } setNoDocRequired(map) } catch { // Non-critical — silently ignore } }, []) useEffect(() => { fetchNoDocRequired() }, [fetchNoDocRequired]) async function fetchEntries() { setLoading(true) const params = new URLSearchParams({ limit: String(pageSize), offset: String(page * pageSize), sort_by: sortBy, }) if (periodId) params.set('period_id', periodId) if (dateFrom) params.set('date_from', dateFrom) if (dateTo) params.set('date_to', dateTo) if (seriesFilter !== 'all') params.set('series', seriesFilter) const res = await fetch(`/api/bookkeeping/journal-entries?${params}`) if (!res.ok) { setLoading(false) return } const { data, count: total } = await res.json() const loadedEntries = data || [] setEntries(loadedEntries) setCount(total || 0) setLoading(false) // Fetch attachment counts for the loaded entries const ids = loadedEntries.map((e: JournalEntry) => e.id) fetchAttachmentCounts(ids) } useEffect(() => { fetchEntries() }, [periodId, page, sortBy, dateFrom, dateTo, seriesFilter]) const handleAttachmentCountChange = useCallback((entryId: string, count: number) => { setAttachmentCounts((prev) => ({ ...prev, [entryId]: count })) }, []) const toggleExpand = (id: string) => { setExpandedId(expandedId === id ? null : id) } const handleCommit = async (entryId: string) => { setCommittingId(entryId) try { const res = await fetch(`/api/bookkeeping/journal-entries/${entryId}/commit`, { method: 'POST' }) const result = await res.json() if (res.ok) { const posted = result.data toast({ title: t('toast_posted_title'), description: t('toast_posted_description', { voucher: formatVoucher(posted ?? {}) }), }) await fetchEntries() } else { toast({ title: t('toast_post_failed'), description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' }) } } catch { toast({ title: t('toast_post_failed_generic'), variant: 'destructive' }) } finally { setCommittingId(null) } } if (loading) { return (

{t('loading')}

) } if (entries.length === 0) { return (

{t('empty_title')}

{t('empty_description')}

) } const filteredEntries = showMissingOnly ? entries.filter( (e) => NEEDS_ATTACHMENT.has(e.source_type) && !attachmentCounts[e.id] && e.status === 'posted' && !noDocRequired.has(e.id) ) : entries return (
{/* Filters and sorting */}
{showMissingOnly && ( {filteredEntries.length} )}
setDateFromInput(e.target.value)} onBlur={() => { const v = dateFromInput.trim() if (v === '') return const normalized = normalizeDate(v) if (normalized) setDateFromInput(normalized) }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() applyDateFilter() } }} className="h-8 flex-1 sm:flex-none sm:w-[145px] text-xs" /> setDateToInput(e.target.value)} onBlur={() => { const v = dateToInput.trim() if (v === '') return const normalized = normalizeDate(v) if (normalized) setDateToInput(normalized) }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() applyDateFilter() } }} className="h-8 flex-1 sm:flex-none sm:w-[145px] text-xs" /> {(dateFrom || dateTo) && ( )}
{filteredEntries.map((entry) => { const isExpanded = expandedId === entry.id const lines = (entry.lines || []) as JournalEntryLine[] return ( ) : ( NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && ( noDocRequired.has(entry.id) ? ( ) : ( ) ) )}
{/* Mobile: two rows */}
{isExpanded ? ( ) : ( )} e.stopPropagation()} > {formatVoucher(entry)} {formatDate(entry.entry_date)} {entry.out_of_period && ( {t('out_of_period_label')} )} {(entry.status === 'reversed' || entry.status === 'draft' || entry.source_type === 'storno' || entry.source_type === 'correction') && ( )} {attachmentCounts[entry.id] ? ( ) : ( NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && ( noDocRequired.has(entry.id) ? ( ) : ( ) ) )}

{entry.description}

{isExpanded && ( {lines.length === 0 ? (

{t('no_lines')}

) : ( <>
{lines .sort((a, b) => a.sort_order - b.sort_order) .map((line) => { const accountName = getAccountDescription(line.account_number)?.name const desc = line.line_description const showDesc = desc && desc.toLowerCase() !== accountName?.toLowerCase() && desc.toLowerCase() !== entry.description?.toLowerCase() return (
{showDesc && (

{desc}

)}
{Number(line.debit_amount) > 0 ? t('debit') : t('credit')}
{Number(line.debit_amount) > 0 ? Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {line.currency && line.currency !== 'SEK' && line.amount_in_currency != null && ( {Number(line.amount_in_currency).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {line.currency} )}
) })}
{t('sum_debit')} {lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
{t('sum_credit')} {lines.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
)} {entry.notes && (

{entry.notes}

)} handleAttachmentCountChange(entry.id, c)} /> {entry.status === 'posted' && NEEDS_ATTACHMENT.has(entry.source_type) && ( { setNoDocRequired((prev) => { const next = new Map(prev) if (exempted) next.set(entry.id, reason ?? null) else next.delete(entry.id) return next }) }} /> )}
{entry.status === 'draft' && ( )} {entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && ( )}
)} ) })}
{/* Correction dialog */} {correctionEntry && ( { if (!open) setCorrectionEntry(null) }} onCorrected={() => { setCorrectionEntry(null); fetchEntries() }} /> )} {/* Attachment preview sheet */} { if (!open) setPreviewEntryId(null) }} /> {/* Pagination */} {count > pageSize && (
{t('page_of', { page: page + 1, total: Math.ceil(count / pageSize) })}
)} ) }