Files
accounted/components/bookkeeping/JournalEntryList.tsx
T
Jakob WennbergandClaude Opus 4.6 091d043c85 feat: UI polish, lint fixes, onboarding redesign, help page expansion, and test improvements
Broad update across dashboard pages, components, extensions, and lib code. Includes ESLint config additions, onboarding flow redesign, settings page refactor, help page content expansion, dead code removal, and test mock fixes. Adds dev docs and public assets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:05:49 +01:00

292 lines
11 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback } from 'react'
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 { ChevronDown, ChevronRight, Paperclip, AlertTriangle } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
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<JournalEntry[]>([])
const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [count, setCount] = useState(0)
const [page, setPage] = useState(0)
const [attachmentCounts, setAttachmentCounts] = useState<Record<string, number>>({})
const [showMissingOnly, setShowMissingOnly] = useState(false)
const [correctionEntry, setCorrectionEntry] = useState<JournalEntry | null>(null)
const pageSize = 20
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 {
console.error('Failed to fetch attachment counts')
}
}, [])
async function fetchEntries() {
setLoading(true)
const params = new URLSearchParams({
limit: String(pageSize),
offset: String(page * pageSize),
})
if (periodId) params.set('period_id', periodId)
const res = await fetch(`/api/bookkeeping/journal-entries?${params}`)
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])
const handleAttachmentCountChange = useCallback((entryId: string, count: number) => {
setAttachmentCounts((prev) => ({ ...prev, [entryId]: count }))
}, [])
const toggleExpand = (id: string) => {
setExpandedId(expandedId === id ? null : id)
}
if (loading) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Laddar verifikationer...
</CardContent>
</Card>
)
}
if (entries.length === 0) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga verifikationer hittades.
</CardContent>
</Card>
)
}
const filteredEntries = showMissingOnly
? entries.filter(
(e) =>
NEEDS_ATTACHMENT.has(e.source_type) &&
!attachmentCounts[e.id] &&
e.status === 'posted'
)
: entries
return (
<div className="space-y-4">
{/* Missing attachment filter */}
<div className="flex items-center gap-2">
<Switch
id="missing-attachments"
checked={showMissingOnly}
onCheckedChange={setShowMissingOnly}
/>
<Label htmlFor="missing-attachments" className="text-sm cursor-pointer">
Visa saknade underlag
</Label>
{showMissingOnly && (
<Badge variant="secondary" className="text-xs">
{filteredEntries.length}
</Badge>
)}
</div>
<div className="space-y-2">
{filteredEntries.map((entry) => {
const isExpanded = expandedId === entry.id
const lines = (entry.lines || []) as JournalEntryLine[]
return (
<Card key={entry.id}>
<button
onClick={() => toggleExpand(entry.id)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3 flex-1">
{isExpanded ? (
<ChevronDown className="h-4 w-4 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<span className="font-mono text-sm text-muted-foreground w-16">
{entry.voucher_series}{entry.voucher_number}
</span>
<span className="text-sm text-muted-foreground w-24">
{entry.entry_date}
</span>
<span className="flex-1 truncate">{entry.description}</span>
{/* Attachment indicator */}
{attachmentCounts[entry.id] ? (
<span className="flex items-center gap-0.5 text-muted-foreground mr-1" title={`${attachmentCounts[entry.id]} underlag`}>
<Paperclip className="h-3.5 w-3.5" />
<span className="text-xs">{attachmentCounts[entry.id]}</span>
</span>
) : (
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
<span className="mr-1" title="Underlag saknas">
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" />
</span>
)
)}
</div>
</button>
{isExpanded && (
<CardContent className="pt-0 pb-4">
{lines.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">Inga kontorader hittades för denna verifikation.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-48">Konto</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Debet</th>
<th className="py-2 w-28 text-right">Kredit</th>
</tr>
</thead>
<tbody>
{lines
.sort((a, b) => a.sort_order - b.sort_order)
.map((line) => (
<tr key={line.id} className="border-b last:border-0">
<td className="py-2"><AccountNumber number={line.account_number} showName /></td>
<td className="py-2 text-muted-foreground">
{line.line_description || ''}
</td>
<td className="py-2 text-right">
{Number(line.debit_amount) > 0
? Number(line.debit_amount).toLocaleString('sv-SE', {
minimumFractionDigits: 2,
})
: ''}
</td>
<td className="py-2 text-right">
{Number(line.credit_amount) > 0
? Number(line.credit_amount).toLocaleString('sv-SE', {
minimumFractionDigits: 2,
})
: ''}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="font-semibold">
<td colSpan={2} className="py-2">
Summa
</td>
<td className="py-2 text-right">
{lines
.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0)
.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
<td className="py-2 text-right">
{lines
.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0)
.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
</tr>
</tfoot>
</table>
)}
<JournalEntryAttachments
journalEntryId={entry.id}
onCountChange={(c) => handleAttachmentCountChange(entry.id, c)}
/>
{entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && (
<div className="mt-4 pt-3 border-t flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCorrectionEntry(entry)}
>
Skapa ändringsverifikation
</Button>
</div>
)}
</CardContent>
)}
</Card>
)
})}
</div>
{/* Correction dialog */}
{correctionEntry && (
<CorrectionEntryDialog
entry={correctionEntry}
open={!!correctionEntry}
onOpenChange={(open) => { if (!open) setCorrectionEntry(null) }}
onCorrected={() => { setCorrectionEntry(null); fetchEntries() }}
/>
)}
{/* Pagination */}
{count > pageSize && (
<div className="flex justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 0}
onClick={() => setPage(page - 1)}
>
Föregående
</Button>
<span className="text-sm text-muted-foreground self-center">
Sida {page + 1} av {Math.ceil(count / pageSize)}
</span>
<Button
variant="outline"
size="sm"
disabled={(page + 1) * pageSize >= count}
onClick={() => setPage(page + 1)}
>
Nästa
</Button>
</div>
)}
</div>
)
}