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:
@@ -17,7 +17,8 @@ import { Separator } from '@/components/ui/separator'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { getVatRules, getVatTreatmentLabel } from '@/lib/invoice/vat-rules'
|
||||
import { Loader2, Plus, Trash2, ArrowLeft } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Loader2, Plus, Trash2, ArrowLeft, Send } from 'lucide-react'
|
||||
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
|
||||
import { InvoiceReviewContent } from '@/components/invoices/InvoiceReviewContent'
|
||||
import type { Customer, Currency, CreateInvoiceInput } from '@/types'
|
||||
@@ -58,6 +59,9 @@ export default function NewInvoicePage() {
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null)
|
||||
const [showReview, setShowReview] = useState(false)
|
||||
const [pendingData, setPendingData] = useState<FormData | null>(null)
|
||||
const [createdInvoiceId, setCreatedInvoiceId] = useState<string | null>(null)
|
||||
const [showSendPrompt, setShowSendPrompt] = useState(false)
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -168,7 +172,14 @@ export default function NewInvoicePage() {
|
||||
})
|
||||
|
||||
setShowReview(false)
|
||||
router.push(`/invoices/${result.data.id}`)
|
||||
|
||||
// If customer has email, offer to send immediately
|
||||
if (selectedCustomer?.email) {
|
||||
setCreatedInvoiceId(result.data.id)
|
||||
setShowSendPrompt(true)
|
||||
} else {
|
||||
router.push(`/invoices/${result.data.id}`)
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
@@ -180,6 +191,37 @@ export default function NewInvoicePage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendNow() {
|
||||
if (!createdInvoiceId) return
|
||||
setIsSending(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/invoices/${createdInvoiceId}/send`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Kunde inte skicka faktura')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Faktura skickad',
|
||||
description: `Fakturan har skickats till ${selectedCustomer?.email}`,
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel vid skickning',
|
||||
description: error instanceof Error ? error.message : 'Något gick fel',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
setShowSendPrompt(false)
|
||||
router.push(`/invoices/${createdInvoiceId}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -465,6 +507,43 @@ export default function NewInvoicePage() {
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
|
||||
{/* Send now prompt dialog */}
|
||||
<Dialog open={showSendPrompt} onOpenChange={(open) => {
|
||||
if (!open && createdInvoiceId) {
|
||||
setShowSendPrompt(false)
|
||||
router.push(`/invoices/${createdInvoiceId}`)
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Skicka fakturan nu?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Fakturan skapades. Vill du skicka den till {selectedCustomer?.email} direkt?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowSendPrompt(false)
|
||||
if (createdInvoiceId) router.push(`/invoices/${createdInvoiceId}`)
|
||||
}}
|
||||
disabled={isSending}
|
||||
>
|
||||
Skicka senare
|
||||
</Button>
|
||||
<Button onClick={handleSendNow} disabled={isSending}>
|
||||
{isSending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isSending ? 'Skickar...' : 'Skicka nu'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ import { Download, FileText, FileDown, TrendingUp, Scale, AlertCircle, Receipt,
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { NEDeclarationView } from '@/extensions/ne-bilaga/NEDeclarationView'
|
||||
import { SRUExportView } from '@/extensions/sru-export/SRUExportView'
|
||||
import { TrialBalanceChart } from '@/components/reports/TrialBalanceChart'
|
||||
import { VatCompositionChart } from '@/components/reports/VatCompositionChart'
|
||||
import { IncomeExpenseChart } from '@/components/reports/IncomeExpenseChart'
|
||||
import type { MonthlyDataPoint } from '@/components/reports/IncomeExpenseChart'
|
||||
import type {
|
||||
FiscalPeriod,
|
||||
TrialBalanceRow,
|
||||
@@ -98,8 +102,9 @@ export default function ReportsPage() {
|
||||
|
||||
{selectedPeriod ? (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="trial-balance">
|
||||
<div className="relative">
|
||||
<TabsList className="w-full justify-start overflow-x-auto flex-nowrap scrollbar-hide">
|
||||
<TabsTrigger value="trial-balance">
|
||||
<Scale className="h-4 w-4 mr-1" />
|
||||
Saldobalans
|
||||
</TabsTrigger>
|
||||
@@ -141,7 +146,9 @@ export default function ReportsPage() {
|
||||
<Building2 className="h-4 w-4 mr-1" />
|
||||
Lev.reskontra
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</TabsList>
|
||||
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none md:hidden" />
|
||||
</div>
|
||||
|
||||
<TabsContent value="trial-balance">
|
||||
<TrialBalanceView periodId={selectedPeriod} />
|
||||
@@ -248,22 +255,24 @@ function TrialBalanceView({ periodId }: { periodId: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Saldobalans</CardTitle>
|
||||
{data.isBalanced ? (
|
||||
<Badge className="bg-green-100 text-green-800">Balanserad</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">Ej balanserad</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 w-20">Konto</th>
|
||||
<div className="space-y-4">
|
||||
<TrialBalanceChart rows={data.rows} />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Saldobalans</CardTitle>
|
||||
{data.isBalanced ? (
|
||||
<Badge className="bg-green-100 text-green-800">Balanserad</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">Ej balanserad</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 w-20">Konto</th>
|
||||
<th className="py-2">Namn</th>
|
||||
<th className="py-2 w-28 text-right">Period debet</th>
|
||||
<th className="py-2 w-28 text-right">Period kredit</th>
|
||||
@@ -311,17 +320,22 @@ function TrialBalanceView({ periodId }: { periodId: string }) {
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IncomeStatementView({ periodId }: { periodId: string }) {
|
||||
const [data, setData] = useState<IncomeStatementReport | null>(null)
|
||||
const [monthlyData, setMonthlyData] = useState<MonthlyDataPoint[]>([])
|
||||
const [monthlyLoading, setMonthlyLoading] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setMonthlyLoading(true)
|
||||
|
||||
fetch(`/api/reports/income-statement?period_id=${periodId}`)
|
||||
.then((res) => res.json())
|
||||
.then((result) => {
|
||||
@@ -336,6 +350,18 @@ function IncomeStatementView({ periodId }: { periodId: string }) {
|
||||
setError('Kunde inte hämta resultaträkning')
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
fetch(`/api/reports/monthly-breakdown?period_id=${periodId}`)
|
||||
.then((res) => res.json())
|
||||
.then((result) => {
|
||||
if (result.data?.months) {
|
||||
setMonthlyData(result.data.months)
|
||||
}
|
||||
setMonthlyLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setMonthlyLoading(false)
|
||||
})
|
||||
}, [periodId])
|
||||
|
||||
if (loading) {
|
||||
@@ -371,6 +397,10 @@ function IncomeStatementView({ periodId }: { periodId: string }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!monthlyLoading && monthlyData.length > 0 && (
|
||||
<IncomeExpenseChart months={monthlyData} />
|
||||
)}
|
||||
|
||||
{/* Revenue */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -741,6 +771,8 @@ function VatDeclarationView() {
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<VatCompositionChart rutor={data.rutor} />
|
||||
|
||||
{/* Summary */}
|
||||
<Card className="border-2">
|
||||
<CardHeader>
|
||||
|
||||
+198
-173
@@ -317,191 +317,216 @@ export default function SettingsPage() {
|
||||
|
||||
{/* Company settings */}
|
||||
<TabsContent value="company">
|
||||
<form onSubmit={handleSaveSettings}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Företagsuppgifter</CardTitle>
|
||||
<CardDescription>
|
||||
Dessa uppgifter visas på dina fakturor
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company_name">Företagsnamn</Label>
|
||||
<Input
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
defaultValue={settings?.company_name || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="org_number">Organisationsnummer</Label>
|
||||
<Input
|
||||
id="org_number"
|
||||
name="org_number"
|
||||
defaultValue={settings?.org_number || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveSettings} className="space-y-6">
|
||||
{/* Företagsuppgifter */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Företagsuppgifter</CardTitle>
|
||||
<CardDescription>
|
||||
Namn, organisationsnummer och adress
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address_line1">Adress</Label>
|
||||
<Label htmlFor="company_name">Företagsnamn</Label>
|
||||
<Input
|
||||
id="address_line1"
|
||||
name="address_line1"
|
||||
defaultValue={settings?.address_line1 || ''}
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
defaultValue={settings?.company_name || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postal_code">Postnummer</Label>
|
||||
<Input
|
||||
id="postal_code"
|
||||
name="postal_code"
|
||||
defaultValue={settings?.postal_code || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
<Input
|
||||
id="city"
|
||||
name="city"
|
||||
defaultValue={settings?.city || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="org_number">Organisationsnummer</Label>
|
||||
<Input
|
||||
id="org_number"
|
||||
name="org_number"
|
||||
defaultValue={settings?.org_number || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h3 className="font-medium mb-4">Bankuppgifter för fakturor</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bank_name">Bank</Label>
|
||||
<Input
|
||||
id="bank_name"
|
||||
name="bank_name"
|
||||
defaultValue={settings?.bank_name || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clearing_number">Clearing</Label>
|
||||
<Input
|
||||
id="clearing_number"
|
||||
name="clearing_number"
|
||||
defaultValue={settings?.clearing_number || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account_number">Kontonummer</Label>
|
||||
<Input
|
||||
id="account_number"
|
||||
name="account_number"
|
||||
defaultValue={settings?.account_number || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address_line1">Adress</Label>
|
||||
<Input
|
||||
id="address_line1"
|
||||
name="address_line1"
|
||||
defaultValue={settings?.address_line1 || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postal_code">Postnummer</Label>
|
||||
<Input
|
||||
id="postal_code"
|
||||
name="postal_code"
|
||||
defaultValue={settings?.postal_code || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h3 className="font-medium mb-4">Fakturainställningar</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
|
||||
<Input
|
||||
id="invoice_prefix"
|
||||
name="invoice_prefix"
|
||||
placeholder="t.ex. F-"
|
||||
defaultValue={settings?.invoice_prefix || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="next_invoice_number">Nästa fakturanummer</Label>
|
||||
<Input
|
||||
id="next_invoice_number"
|
||||
name="next_invoice_number"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue={settings?.next_invoice_number || 1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_default_days">Betalningsvillkor (dagar)</Label>
|
||||
<Input
|
||||
id="invoice_default_days"
|
||||
name="invoice_default_days"
|
||||
type="number"
|
||||
min="0"
|
||||
defaultValue={settings?.invoice_default_days || 30}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label htmlFor="accounting_method">Bokföringsmetod</Label>
|
||||
{settings?.entity_type === 'aktiebolag' ? (
|
||||
<>
|
||||
<input type="hidden" name="accounting_method" value="accrual" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value="Faktureringsmetoden"
|
||||
disabled
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Obligatorisk för aktiebolag
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<select
|
||||
id="accounting_method"
|
||||
name="accounting_method"
|
||||
defaultValue={settings?.accounting_method || 'accrual'}
|
||||
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="accrual">Faktureringsmetoden</option>
|
||||
<option value="cash">Kontantmetoden</option>
|
||||
</select>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{settings?.entity_type === 'aktiebolag'
|
||||
? 'Aktiebolag måste använda faktureringsmetoden enligt BFL.'
|
||||
: 'Kontantmetoden är tillgänglig för enskild firma med omsättning under 3 MSEK.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
<Input
|
||||
id="city"
|
||||
name="city"
|
||||
defaultValue={settings?.city || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h3 className="font-medium mb-4">Skatteinställningar</h3>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preliminary_tax_monthly">
|
||||
Månatlig preliminärskatt (F-skatt)
|
||||
</Label>
|
||||
<Input
|
||||
id="preliminary_tax_monthly"
|
||||
name="preliminary_tax_monthly"
|
||||
type="number"
|
||||
defaultValue={settings?.preliminary_tax_monthly || ''}
|
||||
/>
|
||||
</div>
|
||||
{/* Bankuppgifter */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bankuppgifter</CardTitle>
|
||||
<CardDescription>
|
||||
Betalningsuppgifter som visas på dina fakturor
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bank_name">Bank</Label>
|
||||
<Input
|
||||
id="bank_name"
|
||||
name="bank_name"
|
||||
defaultValue={settings?.bank_name || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clearing_number">Clearing</Label>
|
||||
<Input
|
||||
id="clearing_number"
|
||||
name="clearing_number"
|
||||
defaultValue={settings?.clearing_number || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account_number">Kontonummer</Label>
|
||||
<Input
|
||||
id="account_number"
|
||||
name="account_number"
|
||||
defaultValue={settings?.account_number || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara ändringar'
|
||||
)}
|
||||
</Button>
|
||||
{/* Fakturainställningar */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Fakturainställningar</CardTitle>
|
||||
<CardDescription>
|
||||
Numrering, betalningsvillkor och bokföringsmetod
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
|
||||
<Input
|
||||
id="invoice_prefix"
|
||||
name="invoice_prefix"
|
||||
placeholder="t.ex. F-"
|
||||
defaultValue={settings?.invoice_prefix || ''}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="next_invoice_number">Nästa fakturanummer</Label>
|
||||
<Input
|
||||
id="next_invoice_number"
|
||||
name="next_invoice_number"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue={settings?.next_invoice_number || 1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_default_days">Betalningsvillkor (dagar)</Label>
|
||||
<Input
|
||||
id="invoice_default_days"
|
||||
name="invoice_default_days"
|
||||
type="number"
|
||||
min="0"
|
||||
defaultValue={settings?.invoice_default_days || 30}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accounting_method">Bokföringsmetod</Label>
|
||||
{settings?.entity_type === 'aktiebolag' ? (
|
||||
<>
|
||||
<input type="hidden" name="accounting_method" value="accrual" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value="Faktureringsmetoden"
|
||||
disabled
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Obligatorisk för aktiebolag
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<select
|
||||
id="accounting_method"
|
||||
name="accounting_method"
|
||||
defaultValue={settings?.accounting_method || 'accrual'}
|
||||
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="accrual">Faktureringsmetoden</option>
|
||||
<option value="cash">Kontantmetoden</option>
|
||||
</select>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{settings?.entity_type === 'aktiebolag'
|
||||
? 'Aktiebolag måste använda faktureringsmetoden enligt BFL.'
|
||||
: 'Kontantmetoden är tillgänglig för enskild firma med omsättning under 3 MSEK.'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Skatteinställningar */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Skatteinställningar</CardTitle>
|
||||
<CardDescription>
|
||||
Preliminärskatt och F-skatt
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preliminary_tax_monthly">
|
||||
Månatlig preliminärskatt (F-skatt)
|
||||
</Label>
|
||||
<Input
|
||||
id="preliminary_tax_monthly"
|
||||
name="preliminary_tax_monthly"
|
||||
type="number"
|
||||
defaultValue={settings?.preliminary_tax_monthly || ''}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara ändringar'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</TabsContent>
|
||||
|
||||
{/* Banking settings (only shown when extension is active or connections exist) */}
|
||||
|
||||
@@ -12,9 +12,11 @@ import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getCategoryDisplayName } from '@/lib/tax/expense-warnings'
|
||||
import Link from 'next/link'
|
||||
import { Plus, Search, ArrowLeftRight, ArrowUpRight, ArrowDownRight, Sparkles, Check, FileText, Link2, Upload } from 'lucide-react'
|
||||
import { Plus, Search, ArrowLeftRight, ArrowUpRight, ArrowDownRight, Sparkles, Check, FileText, Link2, Upload, CheckSquare, X } from 'lucide-react'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import TransactionForm from '@/components/transactions/TransactionForm'
|
||||
import SwipeCategorizationView from '@/components/transactions/SwipeCategorizationView'
|
||||
import BatchCategorySelector from '@/components/transactions/BatchCategorySelector'
|
||||
import type { Transaction, TransactionCategory, CreateTransactionInput, Invoice, Customer } from '@/types'
|
||||
import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
|
||||
|
||||
@@ -35,6 +37,15 @@ export default function TransactionsPage() {
|
||||
const [isConfirmingMatch, setIsConfirmingMatch] = useState(false)
|
||||
const [categorySuggestions, setCategorySuggestions] = useState<Record<string, SuggestedCategory[]>>({})
|
||||
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false)
|
||||
const [isBatchMode, setIsBatchMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [showBatchSelector, setShowBatchSelector] = useState(false)
|
||||
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
const [hideCategorizationHint, setHideCategorizationHint] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setHideCategorizationHint(localStorage.getItem('hideCategorizationHint') === 'true')
|
||||
}, [])
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
@@ -357,6 +368,58 @@ export default function TransactionsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBatchSelect(id: string) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function exitBatchMode() {
|
||||
setIsBatchMode(false)
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
async function handleBatchMarkPrivate() {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchProgress({ done: 0, total: ids.length })
|
||||
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
await handleCategorize(ids[i], false, 'private')
|
||||
setBatchProgress({ done: i + 1, total: ids.length })
|
||||
}
|
||||
|
||||
setBatchProgress(null)
|
||||
toast({
|
||||
title: 'Klart',
|
||||
description: `${ids.length} transaktioner markerade som privat`,
|
||||
})
|
||||
exitBatchMode()
|
||||
}
|
||||
|
||||
async function handleBatchCategorize(category: TransactionCategory) {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchProgress({ done: 0, total: ids.length })
|
||||
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
await handleCategorize(ids[i], true, category)
|
||||
setBatchProgress({ done: i + 1, total: ids.length })
|
||||
}
|
||||
|
||||
setBatchProgress(null)
|
||||
setShowBatchSelector(false)
|
||||
toast({
|
||||
title: 'Klart',
|
||||
description: `${ids.length} transaktioner kategoriserade`,
|
||||
})
|
||||
exitBatchMode()
|
||||
}
|
||||
|
||||
async function openSwipeView() {
|
||||
// Run batch invoice matching for income transactions first
|
||||
await runBatchInvoiceMatching()
|
||||
@@ -416,10 +479,19 @@ export default function TransactionsPage() {
|
||||
</Link>
|
||||
</Button>
|
||||
{uncategorizedTransactions.length > 0 && (
|
||||
<Button variant="outline" onClick={openSwipeView} disabled={isLoadingSuggestions}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
{isLoadingSuggestions ? 'Laddar...' : `Kategorisera (${uncategorizedTransactions.length})`}
|
||||
</Button>
|
||||
<>
|
||||
<Button variant="outline" onClick={openSwipeView} disabled={isLoadingSuggestions}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
{isLoadingSuggestions ? 'Laddar...' : `Kategorisera (${uncategorizedTransactions.length})`}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isBatchMode ? 'default' : 'outline'}
|
||||
onClick={() => isBatchMode ? exitBatchMode() : setIsBatchMode(true)}
|
||||
>
|
||||
<CheckSquare className="mr-2 h-4 w-4" />
|
||||
{isBatchMode ? 'Avsluta' : 'Välj flera'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -474,6 +546,26 @@ export default function TransactionsPage() {
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Feature discovery hint */}
|
||||
{!hideCategorizationHint && uncategorizedTransactions.length > 0 && (
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg border border-primary/20 bg-primary/[0.03]">
|
||||
<Sparkles className="h-4 w-4 text-primary flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-muted-foreground flex-1">
|
||||
<span className="font-medium text-foreground">Tips:</span> Klicka "Kategorisera" ovan för att snabbt svepkategorisera transaktioner en i taget. Använd "Välj flera" för att hantera flera samtidigt.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.setItem('hideCategorizationHint', 'true')
|
||||
setHideCategorizationHint(true)
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
|
||||
aria-label="Stäng tips"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transaction list */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
@@ -519,16 +611,31 @@ export default function TransactionsPage() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredTransactions.map((transaction) => (
|
||||
{filteredTransactions.map((transaction) => {
|
||||
const isUncategorized = transaction.is_business === null
|
||||
const isSelected = selectedIds.has(transaction.id)
|
||||
const showCheckbox = isBatchMode && isUncategorized
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={transaction.id}
|
||||
className={`hover:border-primary/50 transition-colors ${
|
||||
transaction.is_business === null ? 'border-warning/50' : ''
|
||||
} ${transaction.potential_invoice && !transaction.invoice_id ? 'border-blue-500/50' : ''}`}
|
||||
isUncategorized ? 'border-warning/50' : ''
|
||||
} ${transaction.potential_invoice && !transaction.invoice_id ? 'border-blue-500/50' : ''} ${
|
||||
isSelected ? 'border-primary bg-primary/[0.02]' : ''
|
||||
}`}
|
||||
onClick={showCheckbox ? () => toggleBatchSelect(transaction.id) : undefined}
|
||||
>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{showCheckbox && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleBatchSelect(transaction.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`h-10 w-10 rounded-full flex items-center justify-center ${
|
||||
transaction.amount > 0
|
||||
@@ -618,10 +725,48 @@ export default function TransactionsPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch mode floating action bar */}
|
||||
{isBatchMode && selectedIds.size > 0 && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 bg-background border rounded-xl shadow-lg px-4 py-3">
|
||||
<Badge variant="secondary">{selectedIds.size} valda</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
Avmarkera
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBatchMarkPrivate}
|
||||
>
|
||||
Markera som privat
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowBatchSelector(true)}
|
||||
>
|
||||
Kategorisera {selectedIds.size} st
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch Category Selector */}
|
||||
<BatchCategorySelector
|
||||
open={showBatchSelector}
|
||||
onOpenChange={setShowBatchSelector}
|
||||
selectedCount={selectedIds.size}
|
||||
onSelectCategory={handleBatchCategorize}
|
||||
progress={batchProgress}
|
||||
/>
|
||||
|
||||
{/* Invoice Match Confirmation Dialog */}
|
||||
<Dialog open={matchDialogOpen} onOpenChange={setMatchDialogOpen}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/documents/counts?journal_entry_ids=id1,id2,...
|
||||
* Returns attachment counts per journal entry ID.
|
||||
* Max 50 IDs per request.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const idsParam = searchParams.get('journal_entry_ids')
|
||||
|
||||
if (!idsParam) {
|
||||
return NextResponse.json({ error: 'journal_entry_ids is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const ids = idsParam.split(',').filter(Boolean)
|
||||
|
||||
if (ids.length === 0) {
|
||||
return NextResponse.json({ data: {} })
|
||||
}
|
||||
|
||||
if (ids.length > 50) {
|
||||
return NextResponse.json({ error: 'Maximum 50 IDs per request' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('journal_entry_id')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_current_version', true)
|
||||
.in('journal_entry_id', ids)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Group and count by journal_entry_id
|
||||
const counts: Record<string, number> = {}
|
||||
for (const row of data || []) {
|
||||
if (row.journal_entry_id) {
|
||||
counts[row.journal_entry_id] = (counts[row.journal_entry_id] || 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: counts })
|
||||
}
|
||||
@@ -65,12 +65,14 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
// WORM archive copy (non-blocking — receipt flow continues even if this fails)
|
||||
let wormDocumentId: string | null = null
|
||||
try {
|
||||
await uploadDocument(user.id, {
|
||||
const wormDoc = await uploadDocument(user.id, {
|
||||
name: imageFile.name,
|
||||
buffer: arrayBuffer,
|
||||
type: imageFile.type,
|
||||
}, { upload_source: 'camera' })
|
||||
wormDocumentId = wormDoc.id
|
||||
} catch (archiveErr) {
|
||||
console.error('[receipt-upload] WORM archive copy failed:', archiveErr)
|
||||
}
|
||||
@@ -86,6 +88,7 @@ export async function POST(request: Request) {
|
||||
user_id: user.id,
|
||||
image_url: imageUrl,
|
||||
status: 'processing',
|
||||
document_id: wormDocumentId,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
@@ -178,7 +181,7 @@ export async function POST(request: Request) {
|
||||
type: 'receipt.extracted',
|
||||
payload: {
|
||||
receipt: completeReceipt,
|
||||
documentId: null,
|
||||
documentId: wormDocumentId,
|
||||
confidence: extraction.confidence,
|
||||
userId: user.id,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const periodId = searchParams.get('period_id')
|
||||
|
||||
if (!periodId) {
|
||||
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await generateMonthlyBreakdown(user.id, periodId)
|
||||
return NextResponse.json({ data })
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed to generate monthly breakdown' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -209,6 +209,27 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
// Link receipt document to journal entry if both exist
|
||||
if (journalEntryId && transaction.receipt_id) {
|
||||
try {
|
||||
const { data: receipt } = await supabase
|
||||
.from('receipts')
|
||||
.select('document_id')
|
||||
.eq('id', transaction.receipt_id)
|
||||
.single()
|
||||
|
||||
if (receipt?.document_id) {
|
||||
await supabase
|
||||
.from('document_attachments')
|
||||
.update({ journal_entry_id: journalEntryId })
|
||||
.eq('id', receipt.document_id)
|
||||
.eq('user_id', user.id)
|
||||
}
|
||||
} catch (linkErr) {
|
||||
console.error('[categorize] Failed to link receipt document:', linkErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Update the transaction
|
||||
const { error: updateError } = await supabase
|
||||
.from('transactions')
|
||||
|
||||
@@ -278,6 +278,15 @@ h1, h2, h3 {
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
}
|
||||
|
||||
/* Hide scrollbar utility */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Selection color */
|
||||
::selection {
|
||||
background: hsl(var(--primary) / 0.15);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createMockSupabase } from '@/tests/helpers'
|
||||
|
||||
// Mock Supabase server client
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(() => supabase),
|
||||
}))
|
||||
|
||||
import { generateMonthlyBreakdown } from '../monthly-breakdown'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('generateMonthlyBreakdown', () => {
|
||||
it('returns empty months when no fiscal period found', async () => {
|
||||
mockResult({ data: null, error: { message: 'not found' } })
|
||||
|
||||
const result = await generateMonthlyBreakdown('user-1', 'period-1')
|
||||
expect(result.months).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty months when no journal entries exist', async () => {
|
||||
// First call: fiscal period
|
||||
mockResult({
|
||||
data: { start_date: '2024-01-01', end_date: '2024-12-31' },
|
||||
error: null,
|
||||
})
|
||||
|
||||
// We need two sequential calls with different results.
|
||||
// The proxy-based mock returns the same result for all calls,
|
||||
// so we re-mock after the first await completes.
|
||||
// Instead, test that an empty lines result returns initialized months.
|
||||
|
||||
// For this test, override at the supabase.from level to return different chains
|
||||
let callCount = 0
|
||||
supabase.from.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
// fiscal_periods query
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
eq: () => ({
|
||||
single: () =>
|
||||
Promise.resolve({
|
||||
data: { start_date: '2024-01-01', end_date: '2024-12-31' },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
// journal_entry_lines query
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
eq: () => ({
|
||||
eq: () =>
|
||||
Promise.resolve({
|
||||
data: [],
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const result = await generateMonthlyBreakdown('user-1', 'period-1')
|
||||
expect(result.months.length).toBe(12)
|
||||
expect(result.months[0].label).toBe('Jan')
|
||||
expect(result.months[0].income).toBe(0)
|
||||
expect(result.months[0].expenses).toBe(0)
|
||||
expect(result.months[11].label).toBe('Dec')
|
||||
})
|
||||
|
||||
it('correctly classifies revenue (class 3) and expense (class 4-7) accounts', async () => {
|
||||
let callCount = 0
|
||||
supabase.from.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
eq: () => ({
|
||||
single: () =>
|
||||
Promise.resolve({
|
||||
data: { start_date: '2024-01-01', end_date: '2024-03-31' },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
eq: () => ({
|
||||
eq: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
account_number: '3001',
|
||||
debit: 0,
|
||||
credit: 10000,
|
||||
journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
{
|
||||
account_number: '5010',
|
||||
debit: 3000,
|
||||
credit: 0,
|
||||
journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
{
|
||||
account_number: '3001',
|
||||
debit: 0,
|
||||
credit: 5000,
|
||||
journal_entry: { entry_date: '2024-02-10', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
{
|
||||
account_number: '6200',
|
||||
debit: 1500,
|
||||
credit: 0,
|
||||
journal_entry: { entry_date: '2024-02-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const result = await generateMonthlyBreakdown('user-1', 'period-1')
|
||||
|
||||
// January
|
||||
const jan = result.months.find((m) => m.label === 'Jan')!
|
||||
expect(jan.income).toBe(10000)
|
||||
expect(jan.expenses).toBe(3000)
|
||||
expect(jan.net).toBe(7000)
|
||||
|
||||
// February
|
||||
const feb = result.months.find((m) => m.label === 'Feb')!
|
||||
expect(feb.income).toBe(5000)
|
||||
expect(feb.expenses).toBe(1500)
|
||||
expect(feb.net).toBe(3500)
|
||||
|
||||
// March should be zero
|
||||
const mar = result.months.find((m) => m.label === 'Mar')!
|
||||
expect(mar.income).toBe(0)
|
||||
expect(mar.expenses).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores non-revenue/expense accounts (class 1, 2, 8)', async () => {
|
||||
let callCount = 0
|
||||
supabase.from.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
eq: () => ({
|
||||
single: () =>
|
||||
Promise.resolve({
|
||||
data: { start_date: '2024-01-01', end_date: '2024-01-31' },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
eq: () => ({
|
||||
eq: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
account_number: '1930',
|
||||
debit: 10000,
|
||||
credit: 0,
|
||||
journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
{
|
||||
account_number: '2611',
|
||||
debit: 0,
|
||||
credit: 2500,
|
||||
journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
{
|
||||
account_number: '8999',
|
||||
debit: 500,
|
||||
credit: 0,
|
||||
journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const result = await generateMonthlyBreakdown('user-1', 'period-1')
|
||||
const jan = result.months.find((m) => m.label === 'Jan')!
|
||||
expect(jan.income).toBe(0)
|
||||
expect(jan.expenses).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
export interface MonthlyBreakdownMonth {
|
||||
label: string
|
||||
income: number
|
||||
expenses: number
|
||||
net: number
|
||||
}
|
||||
|
||||
export interface MonthlyBreakdown {
|
||||
months: MonthlyBreakdownMonth[]
|
||||
}
|
||||
|
||||
const MONTH_LABELS = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'Maj', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dec',
|
||||
]
|
||||
|
||||
/**
|
||||
* Generate monthly income vs expenses breakdown for a fiscal period.
|
||||
*
|
||||
* Groups posted journal entry lines by month and account class:
|
||||
* - Class 3 (30xx) = revenue (credit side)
|
||||
* - Class 4-7 (40xx-79xx) = expenses (debit side)
|
||||
*/
|
||||
export async function generateMonthlyBreakdown(
|
||||
userId: string,
|
||||
fiscalPeriodId: string
|
||||
): Promise<MonthlyBreakdown> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Get the fiscal period date range
|
||||
const { data: period, error: periodError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('start_date, end_date')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (periodError || !period) {
|
||||
return { months: [] }
|
||||
}
|
||||
|
||||
// Get all posted journal entry lines for this period with their entry dates
|
||||
const { data: lines, error: linesError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(`
|
||||
account_number,
|
||||
debit,
|
||||
credit,
|
||||
journal_entry:journal_entries!inner(
|
||||
entry_date,
|
||||
status,
|
||||
user_id,
|
||||
fiscal_period_id
|
||||
)
|
||||
`)
|
||||
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
|
||||
.eq('journal_entries.user_id', userId)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
|
||||
if (linesError || !lines) {
|
||||
return { months: [] }
|
||||
}
|
||||
|
||||
// Build monthly aggregates
|
||||
const monthMap = new Map<number, { income: number; expenses: number }>()
|
||||
|
||||
// Initialize all months in the period range
|
||||
const startDate = new Date(period.start_date)
|
||||
const endDate = new Date(period.end_date)
|
||||
const startMonth = startDate.getMonth()
|
||||
const endMonth = endDate.getMonth() + (endDate.getFullYear() - startDate.getFullYear()) * 12
|
||||
|
||||
for (let m = startMonth; m <= endMonth; m++) {
|
||||
monthMap.set(m % 12, { income: 0, expenses: 0 })
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const entry = line.journal_entry as unknown as {
|
||||
entry_date: string
|
||||
status: string
|
||||
user_id: string
|
||||
fiscal_period_id: string
|
||||
}
|
||||
const accountClass = parseInt(line.account_number.charAt(0))
|
||||
const entryDate = new Date(entry.entry_date)
|
||||
const month = entryDate.getMonth()
|
||||
|
||||
if (!monthMap.has(month)) {
|
||||
monthMap.set(month, { income: 0, expenses: 0 })
|
||||
}
|
||||
|
||||
const bucket = monthMap.get(month)!
|
||||
|
||||
if (accountClass === 3) {
|
||||
// Revenue accounts: credit side represents revenue
|
||||
bucket.income = Math.round((bucket.income + line.credit - line.debit) * 100) / 100
|
||||
} else if (accountClass >= 4 && accountClass <= 7) {
|
||||
// Expense accounts: debit side represents expenses
|
||||
bucket.expenses = Math.round((bucket.expenses + line.debit - line.credit) * 100) / 100
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to sorted array
|
||||
const months: MonthlyBreakdownMonth[] = []
|
||||
const sortedMonths = Array.from(monthMap.entries()).sort((a, b) => {
|
||||
// Handle year boundaries (e.g., Nov-Dec-Jan for broken fiscal year)
|
||||
const aAdj = a[0] < startMonth ? a[0] + 12 : a[0]
|
||||
const bAdj = b[0] < startMonth ? b[0] + 12 : b[0]
|
||||
return aAdj - bAdj
|
||||
})
|
||||
|
||||
for (const [month, data] of sortedMonths) {
|
||||
months.push({
|
||||
label: MONTH_LABELS[month],
|
||||
income: data.income,
|
||||
expenses: data.expenses,
|
||||
net: Math.round((data.income - data.expenses) * 100) / 100,
|
||||
})
|
||||
}
|
||||
|
||||
return { months }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Add document_id to receipts for linking receipt images to WORM archive documents
|
||||
ALTER TABLE public.receipts
|
||||
ADD COLUMN document_id uuid REFERENCES public.document_attachments(id) ON DELETE SET NULL;
|
||||
|
||||
-- Index for efficient lookups
|
||||
CREATE INDEX idx_receipts_document_id ON public.receipts (document_id);
|
||||
Reference in New Issue
Block a user