feat: UX improvements — nav, reports tabs, dashboard alerts, transaction hints, settings layout
- Move Reports to Finans nav group and auto-expand Övrigt on its pages - Make report tabs horizontally scrollable with gradient fade on mobile - Surface deadlines and alerts above the fold on dashboard - Add dismissible categorization hint card on transactions page - Split settings company form into 4 separate Cards for scannability - Add monthly breakdown report, document upload zone, journal entry attachments - Add batch category selector, receipt document linking, invoice form improvements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
ba94f60d06
commit
d0c0d8a7d2
@@ -0,0 +1,252 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Upload, FileText, ImageIcon, X, Loader2 } from 'lucide-react'
|
||||
|
||||
export interface UploadedFile {
|
||||
id?: string
|
||||
file: File
|
||||
status: 'pending' | 'uploading' | 'uploaded' | 'error'
|
||||
error?: string
|
||||
fileName: string
|
||||
fileSize: number
|
||||
}
|
||||
|
||||
interface DocumentUploadZoneProps {
|
||||
files: UploadedFile[]
|
||||
onFilesChange: (files: UploadedFile[]) => void
|
||||
journalEntryId?: string
|
||||
maxFiles?: number
|
||||
disabled?: boolean
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
|
||||
const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp']
|
||||
const ACCEPTED_EXTENSIONS = '.pdf,.jpg,.jpeg,.png,.webp'
|
||||
|
||||
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): boolean {
|
||||
return type.startsWith('image/')
|
||||
}
|
||||
|
||||
export default function DocumentUploadZone({
|
||||
files,
|
||||
onFilesChange,
|
||||
journalEntryId,
|
||||
maxFiles = 5,
|
||||
disabled = false,
|
||||
compact = false,
|
||||
}: DocumentUploadZoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const uploadFile = useCallback(async (file: UploadedFile): Promise<UploadedFile> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.file)
|
||||
formData.append('upload_source', 'file_upload')
|
||||
if (journalEntryId) {
|
||||
formData.append('journal_entry_id', journalEntryId)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/documents', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
const result = await res.json()
|
||||
|
||||
if (result.error) {
|
||||
return { ...file, status: 'error', error: result.error }
|
||||
}
|
||||
|
||||
return { ...file, status: 'uploaded', id: result.data?.id }
|
||||
} catch {
|
||||
return { ...file, status: 'error', error: 'Uppladdning misslyckades' }
|
||||
}
|
||||
}, [journalEntryId])
|
||||
|
||||
const handleFiles = useCallback(async (newFiles: File[]) => {
|
||||
const remaining = maxFiles - files.length
|
||||
if (remaining <= 0) return
|
||||
|
||||
const validFiles: UploadedFile[] = []
|
||||
|
||||
for (const file of newFiles.slice(0, remaining)) {
|
||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||
validFiles.push({
|
||||
file,
|
||||
status: 'error',
|
||||
error: 'Filtypen stöds inte',
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
validFiles.push({
|
||||
file,
|
||||
status: 'error',
|
||||
error: 'Filen är för stor (max 10 MB)',
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
})
|
||||
continue
|
||||
}
|
||||
validFiles.push({
|
||||
file,
|
||||
status: 'uploading',
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
})
|
||||
}
|
||||
|
||||
let currentFiles = [...files, ...validFiles]
|
||||
onFilesChange(currentFiles)
|
||||
|
||||
// Upload files that passed validation
|
||||
for (const f of validFiles.filter((f) => f.status === 'uploading')) {
|
||||
const result = await uploadFile(f)
|
||||
currentFiles = currentFiles.map((cf) =>
|
||||
cf.fileName === result.fileName && cf.status === 'uploading' ? result : cf
|
||||
)
|
||||
onFilesChange([...currentFiles])
|
||||
}
|
||||
}, [files, maxFiles, onFilesChange, uploadFile])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
if (!disabled) setIsDragging(true)
|
||||
}, [disabled])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
if (disabled) return
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files)
|
||||
handleFiles(droppedFiles)
|
||||
}, [disabled, handleFiles])
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = e.target.files
|
||||
if (selectedFiles) {
|
||||
handleFiles(Array.from(selectedFiles))
|
||||
}
|
||||
// Reset input so the same file can be re-selected
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
}, [handleFiles])
|
||||
|
||||
const removeFile = useCallback((index: number) => {
|
||||
onFilesChange(files.filter((_, i) => i !== index))
|
||||
}, [files, onFilesChange])
|
||||
|
||||
const isUploading = files.some((f) => f.status === 'uploading')
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-lg text-center transition-colors
|
||||
${compact ? 'p-3' : 'p-5'}
|
||||
${isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'}
|
||||
${disabled ? 'pointer-events-none opacity-50' : 'cursor-pointer hover:border-primary/50'}
|
||||
`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPTED_EXTENSIONS}
|
||||
className="hidden"
|
||||
onChange={handleInputChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<div className={compact ? 'flex items-center justify-center gap-2' : 'space-y-2'}>
|
||||
<Upload className={compact ? 'h-4 w-4 text-muted-foreground' : 'mx-auto h-8 w-8 text-muted-foreground'} />
|
||||
<div>
|
||||
<p className={compact ? 'text-sm text-muted-foreground' : 'text-sm font-medium'}>
|
||||
{compact ? 'Dra och släpp eller klicka' : 'Dra och släpp filer här'}
|
||||
</p>
|
||||
{!compact && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
PDF, bilder (max 10 MB)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{files.map((file, index) => (
|
||||
<div
|
||||
key={`${file.fileName}-${index}`}
|
||||
className="flex items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50"
|
||||
>
|
||||
{isImageType(file.file.type) ? (
|
||||
<ImageIcon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="truncate flex-1">{file.fileName}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{formatFileSize(file.fileSize)}
|
||||
</span>
|
||||
|
||||
{file.status === 'uploading' && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary shrink-0" />
|
||||
)}
|
||||
{file.status === 'uploaded' && (
|
||||
<Badge variant="success" className="text-xs px-1.5 py-0">
|
||||
Uppladdad
|
||||
</Badge>
|
||||
)}
|
||||
{file.status === 'error' && (
|
||||
<Badge variant="destructive" className="text-xs px-1.5 py-0" title={file.error}>
|
||||
Fel
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
removeFile(index)
|
||||
}}
|
||||
disabled={file.status === 'uploading'}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<p className="text-xs text-muted-foreground">Laddar upp...</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FileText, ImageIcon, Download, ChevronDown, ChevronUp, Plus } from 'lucide-react'
|
||||
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
|
||||
interface DocumentRecord {
|
||||
id: string
|
||||
file_name: string
|
||||
file_size_bytes: number
|
||||
mime_type: string | null
|
||||
storage_path: string
|
||||
created_at: string
|
||||
download_url?: string
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export default function JournalEntryAttachments({
|
||||
journalEntryId,
|
||||
onCountChange,
|
||||
}: JournalEntryAttachmentsProps) {
|
||||
const [documents, setDocuments] = useState<DocumentRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedImage, setExpandedImage] = useState<string | null>(null)
|
||||
const [showUpload, setShowUpload] = useState(false)
|
||||
const [uploadFiles, setUploadFiles] = useState<UploadedFile[]>([])
|
||||
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/documents?journal_entry_id=${journalEntryId}¤t_only=true`
|
||||
)
|
||||
const { data } = await res.json()
|
||||
setDocuments(data || [])
|
||||
onCountChange?.(data?.length || 0)
|
||||
} catch {
|
||||
console.error('Failed to fetch documents')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [journalEntryId, onCountChange])
|
||||
|
||||
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) => {
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${docId}`)
|
||||
const { data } = await res.json()
|
||||
if (data?.download_url) {
|
||||
window.open(data.download_url, '_blank')
|
||||
}
|
||||
} catch {
|
||||
console.error('Failed to get download URL')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreviewToggle = async (doc: DocumentRecord) => {
|
||||
if (expandedImage === doc.id) {
|
||||
setExpandedImage(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch signed URL for preview if not already loaded
|
||||
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 {
|
||||
console.error('Failed to get preview URL')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setExpandedImage(doc.id)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-2 text-sm text-muted-foreground">
|
||||
Laddar underlag...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t pt-3 mt-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-medium">
|
||||
Underlag {documents.length > 0 && `(${documents.length})`}
|
||||
</h4>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setShowUpload(!showUpload)}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Lägg till underlag
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Upload zone */}
|
||||
{showUpload && (
|
||||
<div className="mb-3">
|
||||
<DocumentUploadZone
|
||||
files={uploadFiles}
|
||||
onFilesChange={setUploadFiles}
|
||||
journalEntryId={journalEntryId}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document list */}
|
||||
{documents.length === 0 && !showUpload ? (
|
||||
<p className="text-sm text-muted-foreground py-1">
|
||||
Inga underlag bifogade.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{documents.map((doc) => (
|
||||
<div key={doc.id}>
|
||||
<div className="flex items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50">
|
||||
{isImageType(doc.mime_type) ? (
|
||||
<button
|
||||
onClick={() => handlePreviewToggle(doc)}
|
||||
className="shrink-0 hover:text-primary transition-colors"
|
||||
>
|
||||
{expandedImage === doc.id ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
|
||||
{isImageType(doc.mime_type) && expandedImage !== doc.id && (
|
||||
<ImageIcon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
|
||||
<span className="truncate flex-1">{doc.file_name}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{formatFileSize(doc.file_size_bytes)}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 shrink-0"
|
||||
onClick={() => handleDownload(doc.id)}
|
||||
title="Ladda ner"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Image preview */}
|
||||
{expandedImage === doc.id && doc.download_url && (
|
||||
<div className="px-2 py-2">
|
||||
<img
|
||||
src={doc.download_url}
|
||||
alt={doc.file_name}
|
||||
className="max-h-48 rounded-lg object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { useToast } from '@/components/ui/use-toast'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
|
||||
import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent'
|
||||
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { CreateJournalEntryLineInput, FiscalPeriod } from '@/types'
|
||||
|
||||
interface Props {
|
||||
@@ -34,6 +36,9 @@ export default function JournalEntryForm({ onCreated }: Props) {
|
||||
])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [showReview, setShowReview] = useState(false)
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
|
||||
const isUploading = uploadedFiles.some((f) => f.status === 'uploading')
|
||||
|
||||
useEffect(() => {
|
||||
fetchPeriods()
|
||||
@@ -116,6 +121,23 @@ export default function JournalEntryForm({ onCreated }: Props) {
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
// Link uploaded documents to the new journal entry (non-blocking)
|
||||
const journalEntryId = result.data?.id
|
||||
if (journalEntryId) {
|
||||
const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id)
|
||||
for (const file of filesToLink) {
|
||||
try {
|
||||
await fetch(`/api/documents/${file.id}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ journal_entry_id: journalEntryId }),
|
||||
})
|
||||
} catch (linkErr) {
|
||||
console.error('[JournalEntryForm] Failed to link document:', linkErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Verifikation skapad',
|
||||
description: `Verifikation ${result.data?.voucher_series}${result.data?.voucher_number} har skapats.`,
|
||||
@@ -123,6 +145,7 @@ export default function JournalEntryForm({ onCreated }: Props) {
|
||||
setShowReview(false)
|
||||
// Reset form
|
||||
setDescription('')
|
||||
setUploadedFiles([])
|
||||
setLines([
|
||||
{ account_number: '', debit_amount: '', credit_amount: '', line_description: '' },
|
||||
{ account_number: '', debit_amount: '', credit_amount: '', line_description: '' },
|
||||
@@ -275,6 +298,15 @@ export default function JournalEntryForm({ onCreated }: Props) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Document attachments */}
|
||||
<div>
|
||||
<Label className="mb-2 block">Underlag</Label>
|
||||
<DocumentUploadZone
|
||||
files={uploadedFiles}
|
||||
onFilesChange={setUploadedFiles}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isBalanced && totalDebit > 0 && (
|
||||
<p className="text-sm text-red-600">
|
||||
Differens: {Math.abs(totalDebit - totalCredit).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} kr
|
||||
@@ -284,7 +316,7 @@ export default function JournalEntryForm({ onCreated }: Props) {
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleReview}
|
||||
disabled={!isBalanced || !description || !selectedPeriod || isSubmitting}
|
||||
disabled={!isBalanced || !description || !selectedPeriod || isSubmitting || isUploading}
|
||||
>
|
||||
Granska & skapa
|
||||
</Button>
|
||||
@@ -305,6 +337,7 @@ export default function JournalEntryForm({ onCreated }: Props) {
|
||||
lines={lines}
|
||||
totalDebit={totalDebit}
|
||||
totalCredit={totalCredit}
|
||||
attachmentCount={uploadedFiles.filter((f) => f.status === 'uploaded').length}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
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 { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
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 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
|
||||
}
|
||||
@@ -18,8 +30,23 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
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 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')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchEntries()
|
||||
}, [periodId, page])
|
||||
@@ -34,11 +61,20 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
|
||||
const res = await fetch(`/api/bookkeeping/journal-entries?${params}`)
|
||||
const { data, count: total } = await res.json()
|
||||
setEntries(data || [])
|
||||
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)
|
||||
}
|
||||
|
||||
const handleAttachmentCountChange = useCallback((entryId: string, count: number) => {
|
||||
setAttachmentCounts((prev) => ({ ...prev, [entryId]: count }))
|
||||
}, [])
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpandedId(expandedId === id ? null : id)
|
||||
}
|
||||
@@ -66,6 +102,12 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
salary_payment: 'Lön',
|
||||
opening_balance: 'Ingående balans',
|
||||
year_end: 'Årsbokslut',
|
||||
supplier_invoice_registered: 'Leverantörsfaktura',
|
||||
supplier_invoice_paid: 'Leverantörsbetalning',
|
||||
supplier_invoice_cash_payment: 'Kontant leverantörsbetalning',
|
||||
import: 'Import',
|
||||
storno: 'Storno',
|
||||
correction: 'Korrigering',
|
||||
}
|
||||
return labels[source] || source
|
||||
}
|
||||
@@ -90,10 +132,36 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
{entries.map((entry) => {
|
||||
{filteredEntries.map((entry) => {
|
||||
const isExpanded = expandedId === entry.id
|
||||
const lines = (entry.lines || []) as JournalEntryLine[]
|
||||
|
||||
@@ -116,6 +184,19 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
{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>
|
||||
)
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs mr-2">
|
||||
{sourceLabel(entry.source_type)}
|
||||
</Badge>
|
||||
@@ -178,6 +259,11 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<JournalEntryAttachments
|
||||
journalEntryId={entry.id}
|
||||
onCountChange={(c) => handleAttachmentCountChange(entry.id, c)}
|
||||
/>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { CheckCircle2 } from 'lucide-react'
|
||||
import { CheckCircle2, Paperclip } from 'lucide-react'
|
||||
|
||||
interface ReviewLine {
|
||||
account_number: string
|
||||
@@ -18,6 +18,7 @@ interface JournalEntryReviewContentProps {
|
||||
lines: ReviewLine[]
|
||||
totalDebit: number
|
||||
totalCredit: number
|
||||
attachmentCount?: number
|
||||
}
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
@@ -31,6 +32,7 @@ export function JournalEntryReviewContent({
|
||||
lines,
|
||||
totalDebit,
|
||||
totalCredit,
|
||||
attachmentCount,
|
||||
}: JournalEntryReviewContentProps) {
|
||||
const activeLines = lines.filter(
|
||||
(l) => l.account_number && (l.debit_amount || l.credit_amount)
|
||||
@@ -62,6 +64,12 @@ export function JournalEntryReviewContent({
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
Debet = Kredit
|
||||
</Badge>
|
||||
{attachmentCount != null && attachmentCount > 0 && (
|
||||
<Badge variant="outline">
|
||||
<Paperclip className="h-3 w-3 mr-1" />
|
||||
{attachmentCount} {attachmentCount === 1 ? 'underlag' : 'underlag'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Debit/Credit table */}
|
||||
|
||||
Reference in New Issue
Block a user