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 */}
|
||||
|
||||
@@ -20,9 +20,13 @@ import {
|
||||
Receipt,
|
||||
ArrowLeftRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ArrowRight,
|
||||
Camera,
|
||||
Users,
|
||||
Landmark,
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
} from 'lucide-react'
|
||||
import type { CompanySettings, EntityType, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
|
||||
|
||||
@@ -48,6 +52,7 @@ interface DashboardContentProps {
|
||||
|
||||
export default function DashboardContent({ firstName, settings, summary, onboardingProgress }: DashboardContentProps) {
|
||||
const [showAllAlerts, setShowAllAlerts] = useState(false)
|
||||
const [showMore, setShowMore] = useState(false)
|
||||
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
const preliminaryTaxMonthly = settings?.preliminary_tax_monthly || 0
|
||||
@@ -253,12 +258,116 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Upcoming deadlines */}
|
||||
{summary.deadlines && summary.deadlines.length > 0 && (
|
||||
<section className="mb-10">
|
||||
<UpcomingDeadlinesWidget deadlines={summary.deadlines} maxItems={8} />
|
||||
</section>
|
||||
)}
|
||||
{/* 4 Key Summary Cards */}
|
||||
{(() => {
|
||||
const passedDeadlinesCount = summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length
|
||||
const pendingReceiptsCount = summary.receiptQueue
|
||||
? summary.receiptQueue.pending_review_count + summary.receiptQueue.unmatched_receipts_count
|
||||
: 0
|
||||
const todoCount = summary.uncategorizedCount + summary.overdueInvoicesCount + pendingReceiptsCount + passedDeadlinesCount
|
||||
|
||||
return (
|
||||
<section className="mb-10">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{/* Card 1: Resultat */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Resultat</span>
|
||||
</div>
|
||||
<p className={cn(
|
||||
'font-display text-xl font-medium tabular-nums leading-tight',
|
||||
summary.mtd.net >= 0 ? 'text-success' : 'text-destructive'
|
||||
)}>
|
||||
{formatLargeNumber(summary.mtd.net)}
|
||||
<span className="text-sm ml-0.5 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
{formatCurrency(summary.ytd.net)} i år
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card 2: Att få betalt */}
|
||||
<Link href="/invoices?status=unpaid">
|
||||
<Card className="h-full hover:border-primary/50 transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Receipt className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Att få betalt</span>
|
||||
</div>
|
||||
<p className="font-display text-xl font-medium tabular-nums leading-tight">
|
||||
{summary.unpaidInvoicesCount}
|
||||
<span className="text-sm ml-0.5 text-muted-foreground font-normal">st</span>
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
{formatCurrency(summary.unpaidInvoicesTotal)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Card 3: Banksaldo */}
|
||||
{summary.bankBalance !== null ? (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Landmark className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Banksaldo</span>
|
||||
</div>
|
||||
<p className="font-display text-xl font-medium tabular-nums leading-tight">
|
||||
{formatLargeNumber(summary.bankBalance)}
|
||||
<span className="text-sm ml-0.5 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Link href="/import">
|
||||
<Card className="h-full hover:border-primary/50 transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Landmark className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Banksaldo</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-primary">Koppla bank</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">Importera transaktioner</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Card 4: Att göra */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ClipboardList className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Att göra</span>
|
||||
</div>
|
||||
{todoCount > 0 ? (
|
||||
<>
|
||||
<p className="font-display text-xl font-medium tabular-nums leading-tight text-warning-foreground">
|
||||
{todoCount}
|
||||
<span className="text-sm ml-0.5 text-muted-foreground font-normal">st</span>
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
Åtgärder att hantera
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||
<p className="text-sm font-medium text-success">Allt klart!</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Quick actions */}
|
||||
<section id="quick-actions" className="mb-10">
|
||||
@@ -294,15 +403,14 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* F-skatt warning */}
|
||||
<section id="fskatt-section" className="mb-10">
|
||||
<FSkattWarningCard
|
||||
warningStatus={taxWarning}
|
||||
onAdjustClick={() => { window.location.href = '/settings' }}
|
||||
/>
|
||||
</section>
|
||||
{/* Upcoming deadlines — always visible */}
|
||||
{summary.deadlines && summary.deadlines.length > 0 && (
|
||||
<section className="mb-10">
|
||||
<UpcomingDeadlinesWidget deadlines={summary.deadlines} maxItems={8} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Alerts section */}
|
||||
{/* Alerts section — always visible */}
|
||||
{alertItems.length > 0 && (
|
||||
<section id="alerts-section" className="mb-10">
|
||||
<h2 className="font-display text-lg font-medium mb-4">Att hantera</h2>
|
||||
@@ -321,82 +429,112 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Uncategorized transactions warning */}
|
||||
{summary.uncategorizedCount > 0 && (summary.uncategorizedIncome > 0 || summary.uncategorizedExpenses > 0) && (
|
||||
<section className="mb-10">
|
||||
<Link href="/transactions?tab=uncategorized" className="group">
|
||||
<div className="flex items-start gap-3 px-4 py-3.5 rounded-xl border border-warning/30 bg-warning/[0.03] hover:bg-warning/[0.06] transition-colors">
|
||||
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm">
|
||||
{summary.uncategorizedCount} okategoriserade transaktioner
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{summary.uncategorizedIncome > 0 && (
|
||||
<span>{formatCurrency(summary.uncategorizedIncome)} intäkter</span>
|
||||
)}
|
||||
{summary.uncategorizedIncome > 0 && summary.uncategorizedExpenses > 0 && ', '}
|
||||
{summary.uncategorizedExpenses > 0 && (
|
||||
<span>{formatCurrency(summary.uncategorizedExpenses)} kostnader</span>
|
||||
)}
|
||||
{' '}saknas i resultatet
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground flex-shrink-0 mt-0.5 transition-colors" />
|
||||
{/* Collapsible details section */}
|
||||
<button
|
||||
onClick={() => setShowMore(!showMore)}
|
||||
className="mb-6 text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
{showMore ? (
|
||||
<>
|
||||
Dölj detaljer
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Visa detaljer
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showMore && (
|
||||
<div>
|
||||
{/* F-skatt warning */}
|
||||
<section id="fskatt-section" className="mb-10">
|
||||
<FSkattWarningCard
|
||||
warningStatus={taxWarning}
|
||||
onAdjustClick={() => { window.location.href = '/settings' }}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Uncategorized transactions warning */}
|
||||
{summary.uncategorizedCount > 0 && (summary.uncategorizedIncome > 0 || summary.uncategorizedExpenses > 0) && (
|
||||
<section className="mb-10">
|
||||
<Link href="/transactions?tab=uncategorized" className="group">
|
||||
<div className="flex items-start gap-3 px-4 py-3.5 rounded-xl border border-warning/30 bg-warning/[0.03] hover:bg-warning/[0.06] transition-colors">
|
||||
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm">
|
||||
{summary.uncategorizedCount} okategoriserade transaktioner
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{summary.uncategorizedIncome > 0 && (
|
||||
<span>{formatCurrency(summary.uncategorizedIncome)} intäkter</span>
|
||||
)}
|
||||
{summary.uncategorizedIncome > 0 && summary.uncategorizedExpenses > 0 && ', '}
|
||||
{summary.uncategorizedExpenses > 0 && (
|
||||
<span>{formatCurrency(summary.uncategorizedExpenses)} kostnader</span>
|
||||
)}
|
||||
{' '}saknas i resultatet
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground flex-shrink-0 mt-0.5 transition-colors" />
|
||||
</div>
|
||||
</Link>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Income/Expenses */}
|
||||
<section className="mb-10">
|
||||
<h2 className="font-display text-lg font-medium mb-4">Resultat</h2>
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<TrendingUp className="h-3.5 w-3.5 text-success" />
|
||||
<span className="text-sm text-muted-foreground">Intäkter</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
|
||||
{formatLargeNumber(summary.mtd.income)}
|
||||
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-border/30">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="text-xs text-muted-foreground">I år</p>
|
||||
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.income)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<TrendingDown className="h-3.5 w-3.5 text-destructive" />
|
||||
<span className="text-sm text-muted-foreground">Kostnader</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
|
||||
{formatLargeNumber(summary.mtd.expenses)}
|
||||
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-border/30">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="text-xs text-muted-foreground">I år</p>
|
||||
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.expenses)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Link>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Income/Expenses */}
|
||||
<section className="mb-10">
|
||||
<h2 className="font-display text-lg font-medium mb-4">Resultat</h2>
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<TrendingUp className="h-3.5 w-3.5 text-success" />
|
||||
<span className="text-sm text-muted-foreground">Intäkter</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
|
||||
{formatLargeNumber(summary.mtd.income)}
|
||||
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-border/30">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="text-xs text-muted-foreground">I år</p>
|
||||
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.income)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<TrendingDown className="h-3.5 w-3.5 text-destructive" />
|
||||
<span className="text-sm text-muted-foreground">Kostnader</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
|
||||
{formatLargeNumber(summary.mtd.expenses)}
|
||||
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-border/30">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="text-xs text-muted-foreground">I år</p>
|
||||
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.expenses)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ const navItems: NavItem[] = [
|
||||
{ href: '/supplier-invoices', label: 'Lev.fakturor', icon: FileInput, group: 'finans' },
|
||||
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'finans' },
|
||||
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'finans' },
|
||||
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'finans' },
|
||||
{ href: '/import', label: 'Importera', icon: Upload, group: 'övrigt' },
|
||||
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'övrigt' },
|
||||
{ href: '/help', label: 'Hjälp', icon: HelpCircle, group: 'övrigt' },
|
||||
{ href: '/settings', label: 'Inställningar', icon: Settings, group: 'övrigt' },
|
||||
]
|
||||
@@ -66,7 +66,10 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
|
||||
const router = useRouter()
|
||||
const supabase = createClient()
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
const [isOvrigtExpanded, setIsOvrigtExpanded] = useState(false)
|
||||
// Auto-expand Övrigt when the user is on one of its pages, or when manually toggled
|
||||
const isOnOvrigtPage = ['/import', '/help', '/settings'].some(p => pathname.startsWith(p))
|
||||
const [manualOvrigtExpanded, setManualOvrigtExpanded] = useState(false)
|
||||
const isOvrigtExpanded = isOnOvrigtPage || manualOvrigtExpanded
|
||||
|
||||
const handleLogout = async () => {
|
||||
await supabase.auth.signOut()
|
||||
@@ -177,7 +180,7 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
|
||||
{/* Övrigt group - collapsible */}
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={() => setIsOvrigtExpanded(!isOvrigtExpanded)}
|
||||
onClick={() => setManualOvrigtExpanded(!isOvrigtExpanded)}
|
||||
className="w-full flex items-center justify-between px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em] hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<span>{groupLabels.övrigt}</span>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
export interface MonthlyDataPoint {
|
||||
label: string
|
||||
income: number
|
||||
expenses: number
|
||||
}
|
||||
|
||||
interface IncomeExpenseChartProps {
|
||||
months: MonthlyDataPoint[]
|
||||
}
|
||||
|
||||
export function IncomeExpenseChart({ months }: IncomeExpenseChartProps) {
|
||||
if (months.length === 0) return null
|
||||
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Intäkter vs Kostnader per månad</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart
|
||||
data={months}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
|
||||
<YAxis
|
||||
tickFormatter={(v) => new Intl.NumberFormat('sv-SE', { notation: 'compact' }).format(v)}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value, name) => [
|
||||
new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(value)) + ' kr',
|
||||
name === 'income' ? 'Intäkter' : 'Kostnader',
|
||||
]}
|
||||
/>
|
||||
<Legend
|
||||
formatter={(value) => (value === 'income' ? 'Intäkter' : 'Kostnader')}
|
||||
/>
|
||||
<Bar dataKey="income" fill="hsl(var(--chart-1))" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="expenses" fill="hsl(var(--chart-2))" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import type { TrialBalanceRow } from '@/types'
|
||||
|
||||
interface TrialBalanceChartProps {
|
||||
rows: TrialBalanceRow[]
|
||||
}
|
||||
|
||||
export function TrialBalanceChart({ rows }: TrialBalanceChartProps) {
|
||||
const chartData = useMemo(() => {
|
||||
return rows
|
||||
.map((row) => ({
|
||||
name: `${row.account_number} ${row.account_name}`,
|
||||
account: row.account_number,
|
||||
net: Math.round((row.closing_debit - row.closing_credit) * 100) / 100,
|
||||
}))
|
||||
.sort((a, b) => Math.abs(b.net) - Math.abs(a.net))
|
||||
.slice(0, 10)
|
||||
}, [rows])
|
||||
|
||||
if (chartData.length === 0) return null
|
||||
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Topp 10 konton (nettosaldo)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tickFormatter={(v) => new Intl.NumberFormat('sv-SE', { notation: 'compact' }).format(v)}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="account"
|
||||
width={50}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => [
|
||||
new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(value)) + ' kr',
|
||||
'Netto',
|
||||
]}
|
||||
labelFormatter={(label) => chartData.find((d) => d.account === String(label))?.name || String(label)}
|
||||
/>
|
||||
<Bar dataKey="net" radius={[0, 4, 4, 0]}>
|
||||
{chartData.map((entry) => (
|
||||
<Cell
|
||||
key={entry.account}
|
||||
fill={entry.net >= 0 ? 'hsl(var(--chart-1))' : 'hsl(var(--chart-2))'}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import type { VatDeclarationRutor } from '@/types'
|
||||
|
||||
interface VatCompositionChartProps {
|
||||
rutor: VatDeclarationRutor
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'hsl(var(--chart-1))',
|
||||
'hsl(var(--chart-2))',
|
||||
'hsl(var(--chart-3))',
|
||||
'hsl(var(--chart-4))',
|
||||
]
|
||||
|
||||
export function VatCompositionChart({ rutor }: VatCompositionChartProps) {
|
||||
const chartData = useMemo(() => {
|
||||
const segments = [
|
||||
{ name: 'Utgående 25%', value: rutor.ruta05 },
|
||||
{ name: 'Utgående 12%', value: rutor.ruta06 },
|
||||
{ name: 'Utgående 6%', value: rutor.ruta07 },
|
||||
{ name: 'Ingående moms', value: rutor.ruta48 },
|
||||
]
|
||||
return segments.filter((s) => s.value > 0)
|
||||
}, [rutor])
|
||||
|
||||
if (chartData.length === 0) return null
|
||||
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Momsfördelning</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={90}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{chartData.map((_, index) => (
|
||||
<Cell key={index} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value) => [
|
||||
new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(value)) + ' kr',
|
||||
]}
|
||||
/>
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import type { TransactionCategory } from '@/types'
|
||||
|
||||
const expenseCategories: { value: TransactionCategory; label: string }[] = [
|
||||
{ value: 'expense_equipment', label: 'Utrustning' },
|
||||
{ value: 'expense_software', label: 'Programvara' },
|
||||
{ value: 'expense_travel', label: 'Resor' },
|
||||
{ value: 'expense_office', label: 'Kontor' },
|
||||
{ value: 'expense_marketing', label: 'Marknadsföring' },
|
||||
{ value: 'expense_professional_services', label: 'Konsulter' },
|
||||
{ value: 'expense_education', label: 'Utbildning' },
|
||||
{ value: 'expense_bank_fees', label: 'Bankavgift' },
|
||||
{ value: 'expense_card_fees', label: 'Kortavgift' },
|
||||
{ value: 'expense_currency_exchange', label: 'Valutaväxling' },
|
||||
{ value: 'expense_other', label: 'Övrigt' },
|
||||
]
|
||||
|
||||
const incomeCategories: { value: TransactionCategory; label: string }[] = [
|
||||
{ value: 'income_services', label: 'Tjänster' },
|
||||
{ value: 'income_products', label: 'Produkter' },
|
||||
{ value: 'income_other', label: 'Övrigt' },
|
||||
]
|
||||
|
||||
interface BatchCategorySelectorProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
selectedCount: number
|
||||
onSelectCategory: (category: TransactionCategory) => void
|
||||
progress: { done: number; total: number } | null
|
||||
}
|
||||
|
||||
export default function BatchCategorySelector({
|
||||
open,
|
||||
onOpenChange,
|
||||
selectedCount,
|
||||
onSelectCategory,
|
||||
progress,
|
||||
}: BatchCategorySelectorProps) {
|
||||
const isProcessing = progress !== null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={isProcessing ? undefined : onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isProcessing
|
||||
? `Kategoriserar ${progress.done}/${progress.total}...`
|
||||
: `Kategorisera ${selectedCount} transaktioner`}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isProcessing
|
||||
? 'Vänta medan transaktionerna kategoriseras'
|
||||
: 'Välj en kategori som ska tillämpas på alla valda transaktioner'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isProcessing ? (
|
||||
<div className="py-4">
|
||||
<Progress value={(progress.done / progress.total) * 100} />
|
||||
<p className="text-sm text-muted-foreground mt-2 text-center">
|
||||
{progress.done} av {progress.total} klara
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-2">Kostnader</h4>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{expenseCategories.map((cat) => (
|
||||
<Button
|
||||
key={cat.value}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start text-xs"
|
||||
onClick={() => onSelectCategory(cat.value)}
|
||||
>
|
||||
{cat.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-2">Intäkter</h4>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{incomeCategories.map((cat) => (
|
||||
<Button
|
||||
key={cat.value}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start text-xs"
|
||||
onClick={() => onSelectCategory(cat.value)}
|
||||
>
|
||||
{cat.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user