feat: remove AI extensions, restructure settings, and add atomic voucher commits (#157)
Remove AI-dependent extensions (ai-chat, ai-categorization, receipt-ocr, invoice-inbox) and their infrastructure (lib/ai/*, ai-consent, LangChain/ Anthropic/OpenAI deps) to simplify core and reduce bundle size. Restructure monolithic settings page into dedicated sub-pages (company, bookkeeping, invoicing, tax, banking, api, account, team, templates) with shared layout and sidebar navigation. Add atomic commit_journal_entry RPC so voucher number increment and status update happen in a single transaction — prevents burned numbers on constraint failures. Add continuity check report and voucher gap explanation tracking. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e89f2c402d
commit
d0b3f21bde
@@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { AlertTriangle, ExternalLink, Loader2 } from 'lucide-react'
|
||||
import { SupportLink } from '@/components/ui/support-link'
|
||||
|
||||
export function AccountDangerZone() {
|
||||
const router = useRouter()
|
||||
const [showDialog, setShowDialog] = useState(false)
|
||||
const [confirmText, setConfirmText] = useState('')
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
async function handleDelete() {
|
||||
if (confirmText !== 'RADERA') return
|
||||
setIsDeleting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/account/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ confirm: 'RADERA' }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Kunde inte radera kontot')
|
||||
}
|
||||
|
||||
router.push('/login')
|
||||
} catch {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-4 border-t border-border/8 pt-8">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive/80">
|
||||
Radera konto
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4">
|
||||
<div className="flex gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="font-medium text-destructive">Denna åtgärd kan inte ångras</p>
|
||||
<p className="text-muted-foreground">
|
||||
All din data raderas permanent — bokföring, fakturor, verifikationer, dokument och inställningar.
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Enligt bokföringslagen (BFL 7 kap. 2§) ska räkenskapsinformation bevaras i 7 år.
|
||||
Du ansvarar själv för att exportera och arkivera din bokföringsdata.
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Har du frågor?{' '}
|
||||
<SupportLink variant="inline" subject="Fråga om kontoradering" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button variant="outline" className="w-full sm:w-auto" asChild>
|
||||
<Link href="/reports?type=sie">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Exportera bokföringsdata (SIE)
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setShowDialog(true)}
|
||||
>
|
||||
Radera mitt konto
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Dialog open={showDialog} onOpenChange={(open) => {
|
||||
setShowDialog(open)
|
||||
if (!open) setConfirmText('')
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Radera konto permanent</DialogTitle>
|
||||
<DialogDescription>
|
||||
All din data raderas permanent. Skriv <strong>RADERA</strong> nedan för att bekräfta.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delete-confirm">Bekräfta genom att skriva RADERA</Label>
|
||||
<Input
|
||||
id="delete-confirm"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder="RADERA"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => { setShowDialog(false); setConfirmText('') }}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={confirmText !== 'RADERA' || isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Raderar...
|
||||
</>
|
||||
) : (
|
||||
'Radera permanent'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
|
||||
import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface BankDetailsFormProps {
|
||||
settings: CompanySettings
|
||||
}
|
||||
|
||||
export function BankDetailsForm({ settings }: BankDetailsFormProps) {
|
||||
const [bankgiroError, setBankgiroError] = useState<string | null>(null)
|
||||
const [clearingError, setClearingError] = useState<string | null>(null)
|
||||
const [accountNumberError, setAccountNumberError] = useState<string | null>(null)
|
||||
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Bankuppgifter
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
Visas på dina fakturor
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Bank</Label>
|
||||
<BankNameCombobox
|
||||
defaultValue={settings.bank_name || ''}
|
||||
enableBankingEnabled={hasBankingExtension}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clearing_number">Clearing</Label>
|
||||
<Input
|
||||
id="clearing_number"
|
||||
name="clearing_number"
|
||||
inputMode="numeric"
|
||||
placeholder="XXXX"
|
||||
maxLength={5}
|
||||
defaultValue={settings.clearing_number || ''}
|
||||
onChange={(e) => {
|
||||
e.target.value = e.target.value.replace(/\D/g, '')
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim()
|
||||
if (!val) { setClearingError(null); return }
|
||||
setClearingError(!/^\d{4,5}$/.test(val) ? 'Måste vara 4-5 siffror' : null)
|
||||
}}
|
||||
/>
|
||||
{clearingError && <p className="text-xs text-destructive">{clearingError}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account_number">Kontonummer</Label>
|
||||
<Input
|
||||
id="account_number"
|
||||
name="account_number"
|
||||
inputMode="numeric"
|
||||
placeholder="XXXXXXX"
|
||||
maxLength={12}
|
||||
defaultValue={settings.account_number || ''}
|
||||
onChange={(e) => {
|
||||
e.target.value = e.target.value.replace(/\D/g, '')
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim()
|
||||
if (!val) { setAccountNumberError(null); return }
|
||||
setAccountNumberError(!/^\d{6,12}$/.test(val) ? 'Måste vara 6-12 siffror' : null)
|
||||
}}
|
||||
/>
|
||||
{accountNumberError && <p className="text-xs text-destructive">{accountNumberError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs space-y-2">
|
||||
<Label htmlFor="bankgiro">Bankgiro</Label>
|
||||
<Input
|
||||
id="bankgiro"
|
||||
name="bankgiro"
|
||||
placeholder="XXX-XXXX"
|
||||
defaultValue={settings.bankgiro || ''}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim()
|
||||
if (!val) { setBankgiroError(null); return }
|
||||
if (validateBankgiroNumber(val)) {
|
||||
e.target.value = formatBankgiroNumber(val)
|
||||
setBankgiroError(null)
|
||||
} else {
|
||||
setBankgiroError('Ogiltigt bankgironummer')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{bankgiroError && <p className="text-xs text-destructive">{bankgiroError}</p>}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** Validate bank fields from FormData. Returns error messages or null. */
|
||||
export function validateBankFields(formData: FormData): { field: string; message: string }[] {
|
||||
const errors: { field: string; message: string }[] = []
|
||||
const clearing = (formData.get('clearing_number') as string || '').trim()
|
||||
const account = (formData.get('account_number') as string || '').trim()
|
||||
const bankgiro = (formData.get('bankgiro') as string || '').trim()
|
||||
|
||||
if (clearing && !/^\d{4,5}$/.test(clearing)) {
|
||||
errors.push({ field: 'clearing_number', message: 'Clearingnummer måste vara 4-5 siffror' })
|
||||
}
|
||||
if (account && !/^\d{6,12}$/.test(account)) {
|
||||
errors.push({ field: 'account_number', message: 'Kontonummer måste vara 6-12 siffror' })
|
||||
}
|
||||
if (bankgiro && !validateBankgiroNumber(bankgiro)) {
|
||||
errors.push({ field: 'bankgiro', message: 'Ogiltigt bankgironummer' })
|
||||
}
|
||||
return errors
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface CompanyInfoFormProps {
|
||||
settings: CompanySettings
|
||||
}
|
||||
|
||||
export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Företagsuppgifter
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm: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 || ''}
|
||||
disabled={settings.onboarding_complete === true}
|
||||
/>
|
||||
{settings.onboarding_complete && (
|
||||
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="org_number">Organisationsnummer</Label>
|
||||
<Input
|
||||
id="org_number"
|
||||
name="org_number"
|
||||
defaultValue={settings.org_number || ''}
|
||||
disabled={settings.onboarding_complete === true}
|
||||
/>
|
||||
{settings.onboarding_complete && (
|
||||
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
|
||||
)}
|
||||
</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-1 sm: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>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Telefon</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
defaultValue={settings.phone || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-post</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
defaultValue={settings.email || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website">Webbplats</Label>
|
||||
<Input
|
||||
id="website"
|
||||
name="website"
|
||||
defaultValue={settings.website || ''}
|
||||
placeholder="https://"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface InvoiceSettingsFormProps {
|
||||
settings: CompanySettings
|
||||
}
|
||||
|
||||
export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Fakturainställningar
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end">
|
||||
<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="space-y-2">
|
||||
<Label htmlFor="invoice_default_notes">Standardtext på fakturor</Label>
|
||||
<Textarea
|
||||
id="invoice_default_notes"
|
||||
name="invoice_default_notes"
|
||||
rows={3}
|
||||
placeholder="T.ex. betalningsvillkor, leveransinfo..."
|
||||
defaultValue={settings.invoice_default_notes || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Föreslås automatiskt vid ny faktura.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, Upload, Trash2 } from 'lucide-react'
|
||||
|
||||
interface LogoUploadProps {
|
||||
logoUrl: string | null
|
||||
onUpdate: (logoUrl: string | null) => void
|
||||
}
|
||||
|
||||
export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
|
||||
const { toast } = useToast()
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [preview, setPreview] = useState<string | null>(logoUrl)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
setIsUploading(true)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings/logo', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Uppladdning misslyckades')
|
||||
}
|
||||
|
||||
setPreview(result.data.logo_url)
|
||||
onUpdate(result.data.logo_url)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Kunde inte ladda upp',
|
||||
description: error instanceof Error ? error.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setIsUploading(false)
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setIsDeleting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings/logo', { method: 'DELETE' })
|
||||
if (!response.ok) throw new Error()
|
||||
|
||||
setPreview(null)
|
||||
onUpdate(null)
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte ta bort logotyp', variant: 'destructive' })
|
||||
}
|
||||
|
||||
setIsDeleting(false)
|
||||
}
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast({ title: 'Filen är för stor (max 2 MB)', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
handleUpload(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Logotyp
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
Visas i sidhuvudet på dina fakturor. Max 2 MB, PNG/JPG/SVG.
|
||||
</p>
|
||||
|
||||
{preview ? (
|
||||
<div className="space-y-3">
|
||||
<div className="inline-block rounded-lg border border-border/60 bg-muted/30 p-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={preview}
|
||||
alt="Företagslogotyp"
|
||||
className="max-h-16 max-w-[200px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Upload className="mr-2 h-3.5 w-3.5" />}
|
||||
Byt logotyp
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
{isDeleting ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Trash2 className="mr-2 h-3.5 w-3.5" />}
|
||||
Ta bort
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="flex flex-col items-center justify-center w-full max-w-xs rounded-lg border-2 border-dashed border-border/60 py-8 px-4 text-center transition-colors hover:border-border hover:bg-muted/20 disabled:opacity-50"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-6 w-6 text-muted-foreground animate-spin mb-2" />
|
||||
) : (
|
||||
<Upload className="h-6 w-6 text-muted-foreground/50 mb-2" />
|
||||
)}
|
||||
<Label className="text-sm text-muted-foreground cursor-pointer">
|
||||
{isUploading ? 'Laddar upp...' : 'Välj fil eller dra hit'}
|
||||
</Label>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface PdfPrintSettingsProps {
|
||||
settings: CompanySettings
|
||||
onUpdate: (updates: Partial<CompanySettings>) => void
|
||||
}
|
||||
|
||||
export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps) {
|
||||
const { toast } = useToast()
|
||||
const [lateFeeText, setLateFeeText] = useState(settings.invoice_late_fee_text || '')
|
||||
const [creditTermsText, setCreditTermsText] = useState(settings.invoice_credit_terms_text || '')
|
||||
|
||||
const saveToggle = useCallback(async (field: string, value: boolean) => {
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: value }),
|
||||
})
|
||||
if (!response.ok) throw new Error()
|
||||
onUpdate({ [field]: value } as Partial<CompanySettings>)
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte spara', variant: 'destructive' })
|
||||
}
|
||||
}, [onUpdate, toast])
|
||||
|
||||
const saveText = useCallback(async (field: string, value: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: value || null }),
|
||||
})
|
||||
if (!response.ok) throw new Error()
|
||||
onUpdate({ [field]: value || null } as Partial<CompanySettings>)
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte spara', variant: 'destructive' })
|
||||
}
|
||||
}, [onUpdate, toast])
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Utskrift & PDF
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Öresavrundning</Label>
|
||||
<p className="text-xs text-muted-foreground">Avrunda fakturatotal till hel krona</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.ore_rounding ?? true}
|
||||
onCheckedChange={(v) => saveToggle('ore_rounding', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Visa OCR-referens</Label>
|
||||
<p className="text-xs text-muted-foreground">Visa OCR-nummer på fakturautskrift</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_ocr ?? true}
|
||||
onCheckedChange={(v) => saveToggle('invoice_show_ocr', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Visa bankgiro</Label>
|
||||
<p className="text-xs text-muted-foreground">Visa bankgironummer på fakturautskrift</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_bankgiro ?? true}
|
||||
onCheckedChange={(v) => saveToggle('invoice_show_bankgiro', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Visa plusgiro</Label>
|
||||
<p className="text-xs text-muted-foreground">Visa plusgironummer på fakturautskrift</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_plusgiro ?? true}
|
||||
onCheckedChange={(v) => saveToggle('invoice_show_plusgiro', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_late_fee_text">Dröjsmålsränta</Label>
|
||||
<Textarea
|
||||
id="invoice_late_fee_text"
|
||||
rows={2}
|
||||
placeholder="T.ex. Vid betalning efter förfallodagen debiteras ränta enligt räntelagen."
|
||||
value={lateFeeText}
|
||||
onChange={(e) => setLateFeeText(e.target.value)}
|
||||
onBlur={() => saveText('invoice_late_fee_text', lateFeeText)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_credit_terms_text">Betalningsvillkor (fotnot)</Label>
|
||||
<Textarea
|
||||
id="invoice_credit_terms_text"
|
||||
rows={2}
|
||||
placeholder="T.ex. Betalning sker till angivet bankgiro."
|
||||
value={creditTermsText}
|
||||
onChange={(e) => setCreditTermsText(e.target.value)}
|
||||
onBlur={() => saveText('invoice_credit_terms_text', creditTermsText)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface PeriodLockingSettingsProps {
|
||||
settings: CompanySettings
|
||||
}
|
||||
|
||||
export function PeriodLockingSettings({ settings }: PeriodLockingSettingsProps) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Periodlåsning
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bookkeeping_locked_through">Bokföring låst t.o.m.</Label>
|
||||
<Input
|
||||
id="bookkeeping_locked_through"
|
||||
name="bookkeeping_locked_through"
|
||||
type="date"
|
||||
defaultValue={settings.bookkeeping_locked_through || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Verifikationer med datum före detta datum kan inte skapas eller ändras.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auto_lock_period_days">Automatisk låsning efter</Label>
|
||||
<Select
|
||||
name="auto_lock_period_days"
|
||||
defaultValue={settings.auto_lock_period_days?.toString() || 'none'}
|
||||
>
|
||||
<SelectTrigger id="auto_lock_period_days">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Ingen automatisk låsning</SelectItem>
|
||||
<SelectItem value="30">30 dagar efter periodens slut</SelectItem>
|
||||
<SelectItem value="60">60 dagar efter periodens slut</SelectItem>
|
||||
<SelectItem value="90">90 dagar efter periodens slut</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Låser automatiskt perioder efter valt antal dagar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -254,7 +254,7 @@ export function SecuritySettings() {
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => router.push(`/mfa/enroll?returnTo=${encodeURIComponent('/settings?tab=security')}`)}
|
||||
onClick={() => router.push(`/mfa/enroll?returnTo=${encodeURIComponent('/settings/account')}`)}
|
||||
>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
Aktivera 2FA
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2, Check } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
|
||||
interface SettingsFormWrapperProps {
|
||||
children: React.ReactNode
|
||||
onSave?: (formData: FormData) => Record<string, unknown>
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SettingsFormWrapper({ children, onSave, className }: SettingsFormWrapperProps) {
|
||||
const { toast } = useToast()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (!onSave) return
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const updates = onSave(formData)
|
||||
|
||||
if (!updates || Object.keys(updates).length === 0) return
|
||||
|
||||
setIsSaving(true)
|
||||
setSaved(false)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Kunde inte spara inställningar')
|
||||
}
|
||||
|
||||
setSaved(true)
|
||||
timerRef.current = setTimeout(() => setSaved(false), 2000)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Kunde inte spara',
|
||||
description: error instanceof Error ? error.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setIsSaving(false)
|
||||
}, [onSave, toast])
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={className}>
|
||||
{children}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 mt-8">
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1.5 text-sm text-muted-foreground animate-in fade-in duration-200">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Sparat
|
||||
</span>
|
||||
)}
|
||||
<Button type="submit" disabled={isSaving} size="sm">
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara ändringar'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export function SettingsLoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-300">
|
||||
{[1, 2].map(i => (
|
||||
<div key={i} className="space-y-4">
|
||||
<div className="h-3.5 bg-muted rounded w-24 animate-pulse" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="h-3.5 bg-muted rounded w-20 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3.5 bg-muted rounded w-28 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3.5 bg-muted rounded w-16 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
|
||||
interface NavItem {
|
||||
href: string
|
||||
label: string
|
||||
show: boolean
|
||||
}
|
||||
|
||||
export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const { company, isTeamMember } = useCompany()
|
||||
|
||||
const hasCompany = !!company
|
||||
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
|
||||
const hasMcpExtension = ENABLED_EXTENSION_IDS.has('mcp-server')
|
||||
|
||||
const items: NavItem[] = [
|
||||
{ href: '/settings/company', label: 'Företag', show: hasCompany },
|
||||
{ href: '/settings/invoicing', label: 'Fakturering', show: hasCompany },
|
||||
{ href: '/settings/bookkeeping', label: 'Bokföring', show: hasCompany },
|
||||
{ href: '/settings/tax', label: 'Skatt', show: hasCompany },
|
||||
{ href: '/settings/team', label: 'Lag', show: isTeamMember },
|
||||
{ href: '/settings/banking', label: 'Bank (PSD2)', show: hasCompany && !isSandbox && hasBankingExtension },
|
||||
{ href: '/settings/templates', label: 'Mallar', show: hasCompany },
|
||||
{ href: '/settings/account', label: 'Konto', show: true },
|
||||
{ href: '/settings/api', label: 'API', show: hasCompany && hasMcpExtension },
|
||||
].filter(item => item.show)
|
||||
|
||||
const activeHref = items.find(item => pathname.startsWith(item.href))?.href || items[0]?.href
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile: select dropdown */}
|
||||
<div className="sm:hidden">
|
||||
<Select value={activeHref} onValueChange={(v) => router.push(v)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map(item => (
|
||||
<SelectItem key={item.href} value={item.href}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Desktop: horizontal tabs with bottom border */}
|
||||
<nav
|
||||
className="hidden sm:block overflow-x-auto scrollbar-none border-b border-border"
|
||||
aria-label="Inställningar"
|
||||
>
|
||||
<ul className="flex gap-0 -mb-px">
|
||||
{items.map(item => {
|
||||
const isActive = pathname.startsWith(item.href)
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`block whitespace-nowrap px-3 py-2 text-sm transition-colors border-b-2 ${
|
||||
isActive
|
||||
? 'font-medium text-foreground border-foreground'
|
||||
: 'text-muted-foreground border-transparent hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Keep old name as alias for backward compat during transition
|
||||
export const SettingsSidebar = SettingsNav
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface TaxSettingsFormProps {
|
||||
settings: CompanySettings
|
||||
}
|
||||
|
||||
export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Preliminärskatt
|
||||
</h2>
|
||||
|
||||
<div className="max-w-xs 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 || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Belopp i SEK som betalas varje månad.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface VoucherSeries {
|
||||
voucher_series: string
|
||||
last_number: number
|
||||
fiscal_period_id: string
|
||||
}
|
||||
|
||||
export function VoucherSeriesManager() {
|
||||
const { company } = useCompany()
|
||||
const [series, setSeries] = useState<VoucherSeries[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const fetchSeries = useCallback(async () => {
|
||||
if (!company?.id) return
|
||||
const supabase = createClient()
|
||||
const { data } = await supabase
|
||||
.from('voucher_sequences')
|
||||
.select('voucher_series, last_number, fiscal_period_id')
|
||||
.eq('company_id', company.id)
|
||||
.order('voucher_series')
|
||||
setSeries(data || [])
|
||||
setIsLoading(false)
|
||||
}, [company?.id])
|
||||
|
||||
useEffect(() => { fetchSeries() }, [fetchSeries])
|
||||
|
||||
// Group by series letter, show the highest last_number
|
||||
const grouped = series.reduce<Record<string, number>>((acc, s) => {
|
||||
const existing = acc[s.voucher_series] || 0
|
||||
acc[s.voucher_series] = Math.max(existing, s.last_number)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const seriesEntries = Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Verifikationsserier
|
||||
</h2>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-32 animate-pulse" />
|
||||
<div className="h-4 bg-muted rounded w-24 animate-pulse" />
|
||||
</div>
|
||||
) : seriesEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Inga verifikationsserier ännu. Serie A skapas automatiskt vid första verifikationen.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Aktiva serier</Label>
|
||||
<div className="divide-y divide-border/8">
|
||||
{seriesEntries.map(([letter, lastNum]) => (
|
||||
<div key={letter} className="flex items-center justify-between py-2">
|
||||
<span className="text-sm font-medium tabular-nums">Serie {letter}</span>
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
Senaste nr: {lastNum}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
export function useSettings() {
|
||||
const router = useRouter()
|
||||
const { company } = useCompany()
|
||||
const [settings, setSettings] = useState<CompanySettings | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
const supabase = createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) { router.push('/login'); return }
|
||||
|
||||
if (company?.id) {
|
||||
const { data } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('company_id', company.id)
|
||||
.single()
|
||||
setSettings(data)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}, [company?.id, router])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
}, [fetchSettings])
|
||||
|
||||
const updateSettings = useCallback((updates: Partial<CompanySettings>) => {
|
||||
setSettings(prev => prev ? { ...prev, ...updates } as CompanySettings : null)
|
||||
}, [])
|
||||
|
||||
return { settings, isLoading, updateSettings, refetch: fetchSettings }
|
||||
}
|
||||
Reference in New Issue
Block a user