feat: UX improvements — nav, reports tabs, dashboard alerts, transaction hints, settings layout
- Move Reports to Finans nav group and auto-expand Övrigt on its pages - Make report tabs horizontally scrollable with gradient fade on mobile - Surface deadlines and alerts above the fold on dashboard - Add dismissible categorization hint card on transactions page - Split settings company form into 4 separate Cards for scannability - Add monthly breakdown report, document upload zone, journal entry attachments - Add batch category selector, receipt document linking, invoice form improvements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
ba94f60d06
commit
d0c0d8a7d2
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user