feat: UI polish, lint fixes, onboarding redesign, help page expansion, and test improvements
Broad update across dashboard pages, components, extensions, and lib code. Includes ESLint config additions, onboarding flow redesign, settings page refactor, help page content expansion, dead code removal, and test mock fixes. Adds dev docs and public assets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
02ebe50f52
commit
091d043c85
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, Mail, ArrowLeft } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
export default function LoginPage() {
|
||||
@@ -100,9 +101,14 @@ export default function LoginPage() {
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
|
||||
<div className="w-full max-w-sm animate-slide-up">
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="font-display text-[2.75rem] leading-none font-medium tracking-tight text-foreground">
|
||||
Gnubok
|
||||
</h1>
|
||||
<Image
|
||||
src="/gnubokiceon-removebg-preview.png"
|
||||
alt="Gnubok"
|
||||
width={240}
|
||||
height={240}
|
||||
className="mx-auto mb-2"
|
||||
priority
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm mt-3">
|
||||
Logga in med din e-post för att hantera din ekonomi
|
||||
</p>
|
||||
|
||||
@@ -47,10 +47,6 @@ export default function CustomersPage() {
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers()
|
||||
}, [])
|
||||
|
||||
async function fetchCustomers() {
|
||||
setIsLoading(true)
|
||||
const { data, error } = await supabase
|
||||
@@ -70,6 +66,10 @@ export default function CustomersPage() {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers()
|
||||
}, [])
|
||||
|
||||
async function handleCreateCustomer(data: CreateCustomerInput) {
|
||||
setIsCreating(true)
|
||||
|
||||
@@ -167,7 +167,7 @@ export default function CustomersPage() {
|
||||
<Users className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">Inga träffar</h3>
|
||||
<p className="text-muted-foreground text-center mt-1">
|
||||
Inga kunder matchar "{searchTerm}"
|
||||
Inga kunder matchar "{searchTerm}"
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -13,7 +13,6 @@ import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2 } from 'lucide-rea
|
||||
import Link from 'next/link'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, EntityType } from '@/types'
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||
@@ -35,18 +34,13 @@ export default function ExpenseDetailPage() {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [invoice, setInvoice] = useState<SupplierInvoice | null>(null)
|
||||
const [entityType, setEntityType] = useState<EntityType>('enskild_firma')
|
||||
const [, setEntityType] = useState<EntityType>('enskild_firma')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isPayDialogOpen, setIsPayDialogOpen] = useState(false)
|
||||
const [payAmount, setPayAmount] = useState('')
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoice()
|
||||
fetchEntityType()
|
||||
}, [params.id])
|
||||
|
||||
async function fetchInvoice() {
|
||||
setIsLoading(true)
|
||||
const res = await fetch(`/api/supplier-invoices/${params.id}`)
|
||||
@@ -72,6 +66,11 @@ export default function ExpenseDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoice()
|
||||
fetchEntityType()
|
||||
}, [params.id])
|
||||
|
||||
async function handleApprove() {
|
||||
setIsProcessing(true)
|
||||
const res = await fetch(`/api/supplier-invoices/${params.id}/approve`, { method: 'POST' })
|
||||
|
||||
@@ -67,10 +67,6 @@ export default function ExpensesPage() {
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
useEffect(() => {
|
||||
fetchExpenses()
|
||||
}, [])
|
||||
|
||||
async function fetchExpenses() {
|
||||
setIsLoading(true)
|
||||
const { data, error } = await supabase
|
||||
@@ -86,6 +82,10 @@ export default function ExpensesPage() {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchExpenses()
|
||||
}, [])
|
||||
|
||||
const filteredInvoices = invoices.filter((inv) => {
|
||||
const matchesSearch =
|
||||
(inv.supplier?.name || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getExtensionDefinition, getSector } from '@/lib/extensions/sectors'
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
import type { SectorSlug } from '@/lib/extensions/types'
|
||||
import CategoryBadge from '@/components/extensions/CategoryBadge'
|
||||
import ExtensionToggleButton from '@/components/extensions/ExtensionToggleButton'
|
||||
import Link from 'next/link'
|
||||
@@ -15,7 +16,8 @@ export default async function ExtensionDetailPage({
|
||||
const definition = getExtensionDefinition(sectorSlug, extensionSlug)
|
||||
if (!definition) notFound()
|
||||
|
||||
const sector = getSector(sectorSlug as any)
|
||||
const sector = getSector(sectorSlug as SectorSlug)
|
||||
|
||||
const Icon = resolveIcon(definition.icon)
|
||||
|
||||
const dataPatternLabels: Record<string, string> = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getSector } from '@/lib/extensions/sectors'
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
import type { SectorSlug } from '@/lib/extensions/types'
|
||||
import ExtensionCard from '@/components/extensions/ExtensionCard'
|
||||
import Link from 'next/link'
|
||||
|
||||
@@ -10,10 +11,11 @@ export default async function SectorExtensionsPage({
|
||||
params: Promise<{ sector: string }>
|
||||
}) {
|
||||
const { sector: sectorSlug } = await params
|
||||
const sector = getSector(sectorSlug as any)
|
||||
const sector = getSector(sectorSlug as SectorSlug)
|
||||
|
||||
if (!sector) notFound()
|
||||
|
||||
|
||||
const Icon = resolveIcon(sector.icon)
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Calculator,
|
||||
Building2,
|
||||
FileText,
|
||||
FileDown,
|
||||
ExternalLink,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
@@ -372,7 +373,7 @@ export default function HelpPage() {
|
||||
<CardContent className="py-12 text-center">
|
||||
<Search className="h-8 w-8 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="text-muted-foreground">
|
||||
Inga termer hittades för "{searchQuery}"
|
||||
Inga termer hittades för "{searchQuery}"
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -388,6 +389,44 @@ export default function HelpPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document templates */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Dokument & Mallar</CardTitle>
|
||||
<CardDescription>Lagstadgade mallar för din bokföring — ladda ner, fyll i och spara</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<a
|
||||
href="/docs/arkivplan-mall.md"
|
||||
download
|
||||
className="p-3 rounded-lg border border-border hover:border-primary/50 transition-colors block"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileDown className="h-4 w-4" />
|
||||
<span className="font-medium">Arkivplan</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Mall enligt BFNAR 2013:2 — beskriver var räkenskapsinformation förvaras
|
||||
</p>
|
||||
</a>
|
||||
<a
|
||||
href="/docs/systemdokumentation-mall.md"
|
||||
download
|
||||
className="p-3 rounded-lg border border-border hover:border-primary/50 transition-colors block"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileDown className="h-4 w-4" />
|
||||
<span className="font-medium">Systemdokumentation</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Mall enligt BFL 5 kap. 11 § — beskriver bokföringssystemets uppbyggnad
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* External resources */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -311,13 +311,13 @@ function SIEImportWizard() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [_parsed, setParsed] = useState<ParsedSIEFile | null>(null)
|
||||
const [, setParsed] = useState<ParsedSIEFile | null>(null)
|
||||
const [mappings, setMappings] = useState<AccountMapping[]>([])
|
||||
const [basAccounts, setBasAccounts] = useState<BASAccount[]>([])
|
||||
const [preview, setPreview] = useState<ImportPreview | null>(null)
|
||||
const [issues, setIssues] = useState<ParseIssue[]>([])
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null)
|
||||
const [_sieAccounts, setSieAccounts] = useState<{ number: string; name: string }[]>([])
|
||||
const [, setSieAccounts] = useState<{ number: string; name: string }[]>([])
|
||||
const [isCreatingAccounts, setIsCreatingAccounts] = useState(false)
|
||||
|
||||
// Skip the mapping step when all accounts are already mapped
|
||||
|
||||
@@ -353,8 +353,6 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
const isProforma = docType === 'proforma'
|
||||
const isDeliveryNote = docType === 'delivery_note'
|
||||
const isRealInvoice = docType === 'invoice'
|
||||
const docLabel = isProforma ? 'Proformafaktura' : isDeliveryNote ? 'Följesedel' : 'Faktura'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useForm, useFieldArray, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
@@ -55,8 +55,6 @@ export default function NewInvoicePage() {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
const searchParams = useSearchParams()
|
||||
const preselectedCustomerId = searchParams.get('customer_id')
|
||||
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -68,7 +66,7 @@ export default function NewInvoicePage() {
|
||||
const [showSendPrompt, setShowSendPrompt] = useState(false)
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const [isPreviewing, setIsPreviewing] = useState(false)
|
||||
const [defaultNotes, setDefaultNotes] = useState<string | null>(null)
|
||||
const [, setDefaultNotes] = useState<string | null>(null)
|
||||
const [isCreateCustomerOpen, setIsCreateCustomerOpen] = useState(false)
|
||||
const [isCreatingCustomer, setIsCreatingCustomer] = useState(false)
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { Plus, Search, Receipt, FileText, Send, CheckCircle, Clock, XCircle, ReceiptText, AlertTriangle, FileQuestion, Truck } from 'lucide-react'
|
||||
import { Plus, Search, Receipt, FileText, Send, CheckCircle, Clock, XCircle, ReceiptText, FileQuestion, Truck } from 'lucide-react'
|
||||
import { EmptyInvoices } from '@/components/ui/empty-state'
|
||||
import type { Invoice, InvoiceStatus } from '@/types'
|
||||
|
||||
@@ -53,10 +53,6 @@ export default function InvoicesPage() {
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoices()
|
||||
}, [])
|
||||
|
||||
async function fetchInvoices() {
|
||||
setIsLoading(true)
|
||||
const { data, error } = await supabase
|
||||
@@ -76,6 +72,10 @@ export default function InvoicesPage() {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoices()
|
||||
}, [])
|
||||
|
||||
const filteredInvoices = invoices.filter((invoice) => {
|
||||
const matchesSearch =
|
||||
invoice.invoice_number.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
|
||||
@@ -177,8 +177,9 @@ export default async function DashboardPage() {
|
||||
}))
|
||||
|
||||
// Fetch upcoming deadlines (next 7 days + overdue)
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const nextWeek = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
const now = new Date()
|
||||
const today = now.toISOString().split('T')[0]
|
||||
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
|
||||
const { data: deadlines } = await supabase
|
||||
.from('deadlines')
|
||||
@@ -258,7 +259,7 @@ export default async function DashboardPage() {
|
||||
recentReceiptActivity.map((r) => new Date(r.created_at).toISOString().split('T')[0])
|
||||
)
|
||||
|
||||
let checkDate = new Date(todayDate)
|
||||
const checkDate = new Date(todayDate)
|
||||
while (activityDates.has(checkDate.toISOString().split('T')[0])) {
|
||||
streakCount++
|
||||
checkDate.setDate(checkDate.getDate() - 1)
|
||||
|
||||
@@ -34,11 +34,6 @@ export default function ReportsPage() {
|
||||
const [activeTab, setActiveTab] = useState('trial-balance')
|
||||
const [entityType, setEntityType] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchPeriods()
|
||||
fetchEntityType()
|
||||
}, [])
|
||||
|
||||
async function fetchPeriods() {
|
||||
const res = await fetch('/api/bookkeeping/fiscal-periods')
|
||||
const { data } = await res.json()
|
||||
@@ -60,6 +55,11 @@ export default function ReportsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPeriods()
|
||||
fetchEntityType()
|
||||
}, [])
|
||||
|
||||
const isEnskildFirma = entityType === 'enskild_firma'
|
||||
const isAktiebolag = entityType === 'aktiebolag'
|
||||
|
||||
|
||||
@@ -60,52 +60,6 @@ export default function SettingsPage() {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
|
||||
// Handle callback messages
|
||||
const bankConnected = searchParams.get('bank_connected')
|
||||
const bankError = searchParams.get('bank_error')
|
||||
|
||||
if (bankConnected === 'true') {
|
||||
toast({
|
||||
title: 'Bank ansluten!',
|
||||
description: 'Din bank är nu kopplad. Transaktioner hämtas...',
|
||||
})
|
||||
|
||||
// Auto-sync transactions after connection
|
||||
const connectionId = searchParams.get('connection_id')
|
||||
if (connectionId) {
|
||||
fetch('/api/extensions/ext/enable-banking/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connection_id: connectionId, days_back: 90 }),
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.imported > 0) {
|
||||
toast({
|
||||
title: 'Transaktioner hämtade',
|
||||
description: `${data.imported} transaktioner importerade`,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
router.replace('/settings?tab=banking')
|
||||
}
|
||||
|
||||
if (bankError) {
|
||||
toast({
|
||||
title: 'Anslutning misslyckades',
|
||||
description: decodeURIComponent(bankError),
|
||||
variant: 'destructive',
|
||||
})
|
||||
router.replace('/settings')
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
async function fetchData() {
|
||||
setIsLoading(true)
|
||||
|
||||
@@ -152,6 +106,52 @@ export default function SettingsPage() {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
|
||||
// Handle callback messages
|
||||
const bankConnected = searchParams.get('bank_connected')
|
||||
const bankError = searchParams.get('bank_error')
|
||||
|
||||
if (bankConnected === 'true') {
|
||||
toast({
|
||||
title: 'Bank ansluten!',
|
||||
description: 'Din bank är nu kopplad. Transaktioner hämtas...',
|
||||
})
|
||||
|
||||
// Auto-sync transactions after connection
|
||||
const connectionId = searchParams.get('connection_id')
|
||||
if (connectionId) {
|
||||
fetch('/api/extensions/ext/enable-banking/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connection_id: connectionId, days_back: 90 }),
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.imported > 0) {
|
||||
toast({
|
||||
title: 'Transaktioner hämtade',
|
||||
description: `${data.imported} transaktioner importerade`,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
router.replace('/settings?tab=banking')
|
||||
}
|
||||
|
||||
if (bankError) {
|
||||
toast({
|
||||
title: 'Anslutning misslyckades',
|
||||
description: decodeURIComponent(bankError),
|
||||
variant: 'destructive',
|
||||
})
|
||||
router.replace('/settings')
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
async function handleSaveSettings(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
if (!settings) return
|
||||
|
||||
@@ -50,10 +50,6 @@ export default function SupplierInvoiceDetailPage() {
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoice()
|
||||
}, [params.id])
|
||||
|
||||
async function fetchInvoice() {
|
||||
setIsLoading(true)
|
||||
const res = await fetch(`/api/supplier-invoices/${params.id}`)
|
||||
@@ -67,6 +63,10 @@ export default function SupplierInvoiceDetailPage() {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoice()
|
||||
}, [params.id])
|
||||
|
||||
async function handleApprove() {
|
||||
setIsProcessing(true)
|
||||
const res = await fetch(`/api/supplier-invoices/${params.id}/approve`, { method: 'POST' })
|
||||
|
||||
@@ -38,10 +38,6 @@ export default function SupplierInvoicesPage() {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoices()
|
||||
}, [])
|
||||
|
||||
async function fetchInvoices() {
|
||||
setIsLoading(true)
|
||||
const res = await fetch('/api/supplier-invoices?status=all')
|
||||
@@ -50,6 +46,10 @@ export default function SupplierInvoicesPage() {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoices()
|
||||
}, [])
|
||||
|
||||
const filteredInvoices = invoices.filter((inv) => {
|
||||
switch (activeTab) {
|
||||
case 'registered': return inv.status === 'registered'
|
||||
|
||||
@@ -34,11 +34,6 @@ export default function SupplierDetailPage() {
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
|
||||
|
||||
useEffect(() => {
|
||||
fetchSupplier()
|
||||
fetchInvoices()
|
||||
}, [params.id])
|
||||
|
||||
async function fetchSupplier() {
|
||||
setIsLoading(true)
|
||||
const res = await fetch(`/api/suppliers/${params.id}`)
|
||||
@@ -59,6 +54,11 @@ export default function SupplierDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchSupplier()
|
||||
fetchInvoices()
|
||||
}, [params.id])
|
||||
|
||||
async function handleUpdate(data: CreateSupplierInput) {
|
||||
setIsSaving(true)
|
||||
const res = await fetch(`/api/suppliers/${params.id}`, {
|
||||
|
||||
@@ -34,10 +34,6 @@ export default function SuppliersPage() {
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
useEffect(() => {
|
||||
fetchSuppliers()
|
||||
}, [])
|
||||
|
||||
async function fetchSuppliers() {
|
||||
setIsLoading(true)
|
||||
const { data, error } = await supabase
|
||||
@@ -57,6 +53,10 @@ export default function SuppliersPage() {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchSuppliers()
|
||||
}, [])
|
||||
|
||||
async function handleCreateSupplier(data: CreateSupplierInput) {
|
||||
setIsCreating(true)
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
|
||||
import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
|
||||
|
||||
@@ -15,12 +16,12 @@ import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
|
||||
import Step4PreliminaryTax from '@/components/onboarding/Step4PreliminaryTax'
|
||||
import Step5ConnectBank from '@/components/onboarding/Step6ConnectBank'
|
||||
|
||||
const STEP_TITLES = [
|
||||
'Verksamhetsform',
|
||||
'Företagsuppgifter',
|
||||
'Skatteregistrering',
|
||||
'F-skatt',
|
||||
'Anslut bank',
|
||||
const STEP_INFO = [
|
||||
{ title: 'Välkommen', subtitle: 'Välj din företagsform för att komma igång.', label: 'Företagsform' },
|
||||
{ title: 'Ditt företag', subtitle: 'Uppgifterna visas på fakturor och dokument.', label: 'Uppgifter' },
|
||||
{ title: 'Skatt & bokföring', subtitle: 'F-skatt, räkenskapsår och momsregistrering.', label: 'Skatt' },
|
||||
{ title: 'Preliminärskatt', subtitle: 'Frivilligt — hjälper dig hålla koll på skatten.', label: 'F-skatt' },
|
||||
{ title: 'Bankuppgifter', subtitle: 'Dessa visas på dina fakturor.', label: 'Bank' },
|
||||
]
|
||||
|
||||
function translatePeriodError(msg: string): string {
|
||||
@@ -33,7 +34,11 @@ function translatePeriodError(msg: string): string {
|
||||
|
||||
export default function OnboardingPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex items-center justify-center h-64"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>}>
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}>
|
||||
<OnboardingPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
@@ -51,7 +56,6 @@ function OnboardingPageContent() {
|
||||
const [settings, setSettings] = useState<Partial<CompanySettings>>({})
|
||||
|
||||
const totalSteps = 5
|
||||
const stepTitles = STEP_TITLES
|
||||
|
||||
// Load existing settings on mount
|
||||
useEffect(() => {
|
||||
@@ -63,7 +67,7 @@ function OnboardingPageContent() {
|
||||
return
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
const { data } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
@@ -82,26 +86,6 @@ function OnboardingPageContent() {
|
||||
loadSettings()
|
||||
}, [supabase, router, toast])
|
||||
|
||||
// Handle bank_connected callback from PSD2 flow
|
||||
useEffect(() => {
|
||||
if (searchParams.get('bank_connected') === 'true') {
|
||||
toast({
|
||||
title: 'Bank ansluten!',
|
||||
description: 'Din bank har kopplats.',
|
||||
})
|
||||
// Complete onboarding after bank connection
|
||||
saveSettings({ onboarding_complete: true }, totalSteps).then((success) => {
|
||||
if (success) {
|
||||
toast({
|
||||
title: 'Välkommen!',
|
||||
description: 'Din profil är nu redo.',
|
||||
})
|
||||
router.push('/')
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const saveSettings = async (updates: Partial<CompanySettings>, nextStep?: number) => {
|
||||
setIsSaving(true)
|
||||
|
||||
@@ -146,6 +130,26 @@ function OnboardingPageContent() {
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle bank_connected callback from PSD2 flow
|
||||
useEffect(() => {
|
||||
if (searchParams.get('bank_connected') === 'true') {
|
||||
toast({
|
||||
title: 'Bank ansluten!',
|
||||
description: 'Din bank har kopplats.',
|
||||
})
|
||||
// Complete onboarding after bank connection
|
||||
saveSettings({ onboarding_complete: true }, totalSteps).then((success) => {
|
||||
if (success) {
|
||||
toast({
|
||||
title: 'Välkommen!',
|
||||
description: 'Din profil är nu redo.',
|
||||
})
|
||||
router.push('/')
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const handleNext = async (stepData: Partial<CompanySettings>) => {
|
||||
// Fix org number bug: clear dependent fields when entity type changes
|
||||
if (currentStep === 1 && stepData.entity_type && stepData.entity_type !== settings.entity_type) {
|
||||
@@ -268,7 +272,7 @@ function OnboardingPageContent() {
|
||||
onConflict: 'user_id,period_start,period_end',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Kunde inte skapa räkenskapsår',
|
||||
description: 'Ett fel uppstod när räkenskapsåret skulle skapas. Försök igen.',
|
||||
@@ -307,13 +311,13 @@ function OnboardingPageContent() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const progressPercent = ((currentStep - 1) / (totalSteps - 1)) * 100
|
||||
const stepInfo = STEP_INFO[currentStep - 1]
|
||||
|
||||
const renderSteps = () => (
|
||||
<>
|
||||
@@ -408,26 +412,77 @@ function OnboardingPageContent() {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary/5 via-background to-primary/10">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 bg-background/80 backdrop-blur-sm border-b">
|
||||
<div className="max-w-2xl mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Steg {currentStep} av {totalSteps}
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{stepTitles[currentStep - 1]}
|
||||
<div className="min-h-screen flex flex-col bg-background">
|
||||
{/* ── Branded Header ── */}
|
||||
<header className="relative bg-[#141414] text-white overflow-hidden">
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute inset-0 pointer-events-none" aria-hidden>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse at 30% -20%, rgba(255,255,255,0.04) 0%, transparent 50%)',
|
||||
}}
|
||||
/>
|
||||
<span className="absolute -bottom-4 right-4 md:right-10 text-[120px] md:text-[160px] font-serif font-bold text-white/[0.02] leading-none select-none">
|
||||
{String(currentStep).padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-2xl mx-auto w-full px-6 md:px-10 pt-5 pb-6 md:pt-6 md:pb-8">
|
||||
{/* Top row: Logo + step indicator + counter */}
|
||||
<div className="flex items-center justify-between mb-5 md:mb-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Image
|
||||
src="/gnubokiceon-removebg-preview.png"
|
||||
alt="Gnubok"
|
||||
width={30}
|
||||
height={30}
|
||||
className="invert opacity-90"
|
||||
/>
|
||||
<span className="font-serif text-base tracking-tight">gnubok</span>
|
||||
</div>
|
||||
{/* Step indicator — inline with logo row */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{STEP_INFO.map((_, i) => {
|
||||
const num = i + 1
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'h-[3px] rounded-full transition-all duration-500',
|
||||
num === currentStep && 'w-7 bg-white',
|
||||
num < currentStep && 'w-4 bg-white/50',
|
||||
num > currentStep && 'w-4 bg-white/[0.1]',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<span className="text-[10px] text-white/30 tracking-[0.15em] uppercase">
|
||||
{currentStep} / {totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progressPercent} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||
{renderSteps()}
|
||||
</div>
|
||||
{/* Step title — compact */}
|
||||
<div key={`title-${currentStep}`} className="animate-fade-in">
|
||||
<h1 className="font-serif text-2xl md:text-3xl font-medium tracking-tight leading-[1.1]">
|
||||
{stepInfo.title}
|
||||
</h1>
|
||||
<p className="text-white/40 mt-1.5 text-sm max-w-sm leading-relaxed">
|
||||
{stepInfo.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Form Content ── */}
|
||||
<main className="flex-1">
|
||||
<div className="max-w-lg mx-auto px-6 md:px-10 py-6 md:py-8">
|
||||
<div key={`step-${currentStep}`} className="animate-slide-up">
|
||||
{renderSteps()}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ function mockAuth(userId: string | null) {
|
||||
data: { user: userId ? { id: userId } : null },
|
||||
}),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ function mockAuth(userId: string | null) {
|
||||
data: { user: userId ? { id: userId } : null },
|
||||
}),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
}
|
||||
|
||||
@@ -45,9 +46,11 @@ describe('GET /api/audit-trail', () => {
|
||||
{ id: '1', action: 'INSERT', table_name: 'journal_entries', created_at: '2024-01-01T00:00:00Z' },
|
||||
{ id: '2', action: 'COMMIT', table_name: 'journal_entries', created_at: '2024-01-02T00:00:00Z' },
|
||||
]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGetAuditLog.mockResolvedValue({ data: entries as any, count: 2 })
|
||||
|
||||
const req = createMockRequest('/api/audit-trail')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { status, body } = await parseJsonResponse<{ data: any[]; count: number }>(await GET(req))
|
||||
|
||||
expect(status).toBe(200)
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function PUT(
|
||||
const body: Partial<CreateDeadlineInput> = await request.json()
|
||||
|
||||
// First, get existing deadline to verify ownership
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
const { data: _existing, error: fetchError } = await supabase
|
||||
.from('deadlines')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const body: ExecuteRequest = await request.json()
|
||||
const { transactions, format, filename, file_hash, skip_duplicates = true, auto_categorize = true } = body
|
||||
const { transactions, format, filename, file_hash, skip_duplicates: _skip_duplicates = true, auto_categorize: _auto_categorize = true } = body
|
||||
|
||||
if (!transactions || transactions.length === 0) {
|
||||
return NextResponse.json({ error: 'No transactions to import' }, { status: 400 })
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
const { status, body: _body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { CreateInvoiceSchema, CreateCreditNoteSchema } from '@/lib/api/schemas'
|
||||
import type { EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocumentType } from '@/types'
|
||||
import { getVatRules, calculateVat, calculateTotal, getAvailableVatRates, getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
|
||||
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import {
|
||||
createCreditNoteJournalEntry,
|
||||
|
||||
@@ -23,6 +23,7 @@ function mockAuth(userId: string | null) {
|
||||
data: { user: userId ? { id: userId } : null },
|
||||
}),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
}
|
||||
|
||||
@@ -67,6 +68,7 @@ describe('GET /api/reports/audit-trail', () => {
|
||||
|
||||
it('returns CSV format with correct headers', async () => {
|
||||
mockAuth('user-1')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGetAuditLog.mockResolvedValue({ data: sampleEntries as any, count: 2 })
|
||||
|
||||
const req = createMockRequest('/api/reports/audit-trail', {
|
||||
@@ -88,6 +90,7 @@ describe('GET /api/reports/audit-trail', () => {
|
||||
|
||||
it('returns JSON format as downloadable file', async () => {
|
||||
mockAuth('user-1')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGetAuditLog.mockResolvedValue({ data: sampleEntries as any, count: 2 })
|
||||
|
||||
const req = createMockRequest('/api/reports/audit-trail', {
|
||||
@@ -118,7 +121,9 @@ describe('GET /api/reports/audit-trail', () => {
|
||||
}))
|
||||
|
||||
mockGetAuditLog
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.mockResolvedValueOnce({ data: bigPage as any, count: 600 })
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.mockResolvedValueOnce({ data: lastPage as any, count: 600 })
|
||||
|
||||
const req = createMockRequest('/api/reports/audit-trail', {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import {
|
||||
createSupplierInvoicePaymentEntry,
|
||||
createSupplierInvoiceCashEntry,
|
||||
@@ -9,41 +8,6 @@ import { validateBody } from '@/lib/api/validate'
|
||||
import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
/**
|
||||
* Ensure a fiscal period exists for the given date
|
||||
*/
|
||||
async function ensureFiscalPeriod(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
date: string
|
||||
): Promise<string | null> {
|
||||
const existingPeriodId = await findFiscalPeriod(supabase, userId, date)
|
||||
if (existingPeriodId) return existingPeriodId
|
||||
|
||||
const transactionDate = new Date(date)
|
||||
const year = transactionDate.getFullYear()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.upsert({
|
||||
user_id: userId,
|
||||
name: `Räkenskapsår ${year}`,
|
||||
period_start: `${year}-01-01`,
|
||||
period_end: `${year}-12-31`,
|
||||
}, {
|
||||
onConflict: 'user_id,period_start,period_end',
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to create fiscal period:', error)
|
||||
return null
|
||||
}
|
||||
|
||||
return data?.id || null
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/transactions/[id]/match-supplier-invoice
|
||||
*
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
import type { Transaction, TransactionCategory, EntityType } from '@/types'
|
||||
import type { Transaction, EntityType } from '@/types'
|
||||
|
||||
/**
|
||||
* POST /api/transactions/suggest-categories
|
||||
|
||||
+1
-1
@@ -183,7 +183,7 @@ export default async function RootPage() {
|
||||
recentReceiptActivity.map((r) => new Date(r.created_at).toISOString().split('T')[0])
|
||||
)
|
||||
|
||||
let checkDate = new Date(todayDate)
|
||||
const checkDate = new Date(todayDate)
|
||||
while (activityDates.has(checkDate.toISOString().split('T')[0])) {
|
||||
streakCount++
|
||||
checkDate.setDate(checkDate.getDate() - 1)
|
||||
|
||||
Reference in New Issue
Block a user