'use client' import { useState, useEffect, useCallback } from 'react' import Link from 'next/link' 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 { Switch } from '@/components/ui/switch' import { ArrowDownNarrowWide, ArrowUpNarrowWide, ChevronDown, ChevronRight, Paperclip, AlertTriangle, Loader2, BookOpen, X } from 'lucide-react' import { Input } from '@/components/ui/input' import { AccountNumber } from '@/components/ui/account-number' import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments' import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog' import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge' 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 [entries, setEntries] = useState([]) 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 [showMissingOnly, setShowMissingOnly] = useState(false) const [correctionEntry, setCorrectionEntry] = useState(null) const [dateSortDir, setDateSortDir] = useState<'desc' | 'asc'>('desc') const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') const [dateFromInput, setDateFromInput] = useState('') const [dateToInput, setDateToInput] = useState('') 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 } }, []) async function fetchEntries() { setLoading(true) const params = new URLSearchParams({ limit: String(pageSize), offset: String(page * pageSize), sort_date: dateSortDir, }) if (periodId) params.set('period_id', periodId) if (dateFrom) params.set('date_from', dateFrom) if (dateTo) params.set('date_to', dateTo) 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, dateSortDir, dateFrom, dateTo]) const handleAttachmentCountChange = useCallback((entryId: string, count: number) => { setAttachmentCounts((prev) => ({ ...prev, [entryId]: count })) }, []) const toggleExpand = (id: string) => { setExpandedId(expandedId === id ? null : id) } if (loading) { return (

Laddar verifikationer...

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

Inga verifikationer

Verifikationer skapas automatiskt vid fakturering och transaktionsbokföring, eller manuellt via fliken "Ny verifikation".

) } const filteredEntries = showMissingOnly ? entries.filter( (e) => NEEDS_ATTACHMENT.has(e.source_type) && !attachmentCounts[e.id] && e.status === 'posted' ) : 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 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 w-[145px] text-xs" /> {(dateFrom || dateTo) && ( )}
{filteredEntries.map((entry) => { const isExpanded = expandedId === entry.id const lines = (entry.lines || []) as JournalEntryLine[] return ( {isExpanded && ( {lines.length === 0 ? (

Inga kontorader hittades för denna verifikation.

) : ( <> {/* Mobile: stacked cards */}
{lines .sort((a, b) => a.sort_order - b.sort_order) .map((line) => (
{line.line_description && (

{line.line_description}

)}
{Number(line.debit_amount) > 0 && (

{Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} D

)} {Number(line.credit_amount) > 0 && (

{Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} K

)}
))}
Summa
D: {lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} K: {lines.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
{/* Desktop: table */}
{lines .sort((a, b) => a.sort_order - b.sort_order) .map((line) => ( ))}
Konto Beskrivning Debet Kredit
{line.line_description || ''} {Number(line.debit_amount) > 0 ? Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2, }) : ''} {Number(line.credit_amount) > 0 ? Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2, }) : ''}
Summa {lines .reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0) .toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {lines .reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0) .toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
)} handleAttachmentCountChange(entry.id, c)} />
{entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && ( )}
)}
) })}
{/* Correction dialog */} {correctionEntry && ( { if (!open) setCorrectionEntry(null) }} onCorrected={() => { setCorrectionEntry(null); fetchEntries() }} /> )} {/* Pagination */} {count > pageSize && (
Sida {page + 1} av {Math.ceil(count / pageSize)}
)}
) }