feat: add user locale preference to user_preferences table (#555)

* feat: add user locale preference to user_preferences table

- Introduced a new column 'locale' in the user_preferences table to store per-user UI language preferences.
- Added a CHECK constraint to ensure only supported locales ('sv', 'en') are allowed.
- Triggered a schema reload notification for the changes.

chore: declare CSS module support in TypeScript

- Added a declaration for CSS modules in globals.d.ts to enable TypeScript support for importing CSS files.

* feat: add Swish as an invoice payment method in company settings
This commit is contained in:
Mattsson
2026-05-21 21:33:22 +02:00
committed by GitHub
parent cc351158f8
commit e4488a900b
144 changed files with 12304 additions and 3853 deletions
+8 -6
View File
@@ -1,21 +1,23 @@
'use client'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import type { ExtensionCategory } from '@/lib/extensions/types'
const CATEGORY_CONFIG: Record<ExtensionCategory, { label: string; className: string }> = {
accounting: { label: 'Bokföring & Skatt', className: 'bg-destructive/10 text-destructive border-destructive/30' },
reports: { label: 'Branschrapporter', className: 'bg-primary/10 text-primary border-primary/30' },
import: { label: 'Smart Import', className: 'bg-success/10 text-success border-success/30' },
operations: { label: 'Verktyg', className: 'bg-muted text-muted-foreground border-border' },
const CATEGORY_CONFIG: Record<ExtensionCategory, { labelKey: string; className: string }> = {
accounting: { labelKey: 'category_accounting', className: 'bg-destructive/10 text-destructive border-destructive/30' },
reports: { labelKey: 'category_reports', className: 'bg-primary/10 text-primary border-primary/30' },
import: { labelKey: 'category_import', className: 'bg-success/10 text-success border-success/30' },
operations: { labelKey: 'category_operations', className: 'bg-muted text-muted-foreground border-border' },
}
export default function CategoryBadge({ category }: { category: ExtensionCategory }) {
const t = useTranslations('extensions')
const config = CATEGORY_CONFIG[category]
return (
<Badge variant="outline" className={cn('text-[10px] font-medium', config.className)}>
{config.label}
{t(config.labelKey)}
</Badge>
)
}
+11 -3
View File
@@ -1,10 +1,18 @@
import { getTranslations } from 'next-intl/server'
import { Card, CardContent } from '@/components/ui/card'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { extensionNameKey, extensionDescriptionKey } from '@/lib/extensions/i18n'
import type { ExtensionDefinition } from '@/lib/extensions/types'
import CategoryBadge from './CategoryBadge'
import Link from 'next/link'
export default function ExtensionCard({ extension }: { extension: ExtensionDefinition }) {
export default async function ExtensionCard({ extension }: { extension: ExtensionDefinition }) {
const t = await getTranslations('extensions')
const nameKey = extensionNameKey(extension.slug)
const descriptionKey = extensionDescriptionKey(extension.slug)
const name = nameKey ? t(nameKey) : extension.name
const description = descriptionKey ? t(descriptionKey) : extension.description
const Icon = resolveIcon(extension.icon)
@@ -20,10 +28,10 @@ export default function ExtensionCard({ extension }: { extension: ExtensionDefin
href={`/extensions/${extension.sector}/${extension.slug}`}
className="text-sm font-medium hover:underline"
>
{extension.name}
{name}
</Link>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{extension.description}
{description}
</p>
<div className="mt-2">
<CategoryBadge category={extension.category} />
+13 -5
View File
@@ -1,10 +1,18 @@
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
import { Card, CardContent } from '@/components/ui/card'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { sectorNameKey, sectorDescriptionKey } from '@/lib/extensions/i18n'
import type { Sector } from '@/lib/extensions/types'
export default function SectorCard({ sector }: { sector: Sector }) {
export default async function SectorCard({ sector }: { sector: Sector }) {
const t = await getTranslations('extensions')
const nameKey = sectorNameKey(sector.slug)
const descriptionKey = sectorDescriptionKey(sector.slug)
const name = nameKey ? t(nameKey) : sector.name
const description = descriptionKey ? t(descriptionKey) : sector.description
const Icon = resolveIcon(sector.icon)
return (
@@ -17,11 +25,11 @@ export default function SectorCard({ sector }: { sector: Sector }) {
</div>
<div>
<h3 className="text-sm font-medium group-hover:text-primary transition-colors">
{sector.name}
{name}
</h3>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{sector.description}</p>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{description}</p>
<p className="text-xs text-muted-foreground mt-1.5">
{sector.extensions.length} tillägg
{t('extension_count', { count: sector.extensions.length })}
</p>
</div>
</div>
+43 -41
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
@@ -50,15 +51,15 @@ function formatPeriod(start: number, end: number): string {
return `${fmt(s)} – ${fmt(e)}`
}
function timeAgo(isoDate: string): string {
function timeAgo(isoDate: string, t: (key: string, values?: Record<string, string | number>) => string): string {
const diff = Date.now() - new Date(isoDate).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return 'just nu'
if (minutes < 60) return `${minutes} min sedan`
if (minutes < 1) return t('time_just_now')
if (minutes < 60) return t('time_minutes_ago', { n: minutes })
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} tim sedan`
if (hours < 24) return t('time_hours_ago', { n: hours })
const days = Math.floor(hours / 24)
return `${days} dag${days > 1 ? 'ar' : ''} sedan`
return t('time_days_ago', { n: days })
}
// Mirrors the live layout (two cards: company info + financials) so the
@@ -122,6 +123,7 @@ function ProfileSkeleton() {
export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
const { getByKey, save, isLoading: isDataLoading } = useExtensionData('general', 'tic')
const { toast } = useToast()
const t = useTranslations('tic_workspace')
const [profile, setProfile] = useState<TICCompanyProfile | null>(null)
const [isFetching, setIsFetching] = useState(false)
const [noOrgNumber, setNoOrgNumber] = useState(false)
@@ -147,7 +149,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
// Get org_number from company settings
const settingsRes = await fetch('/api/settings')
if (!settingsRes.ok) {
toast({ title: 'Kunde inte hämta inställningar', variant: 'destructive' })
toast({ title: t('toast_settings_failed'), variant: 'destructive' })
return
}
const { data: settings } = await settingsRes.json()
@@ -164,7 +166,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
if (!res.ok) {
const { error } = await res.json()
toast({ title: error ?? 'Kunde inte hämta företagsprofil', variant: 'destructive' })
toast({ title: error ?? t('toast_profile_failed'), variant: 'destructive' })
setFetchFailed(true)
return
}
@@ -173,12 +175,12 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
setProfile(data)
await save('company_profile', data)
} catch {
toast({ title: 'Ett oväntat fel inträffade', variant: 'destructive' })
toast({ title: t('toast_unexpected_error'), variant: 'destructive' })
setFetchFailed(true)
} finally {
setIsFetching(false)
}
}, [save, toast])
}, [save, toast, t])
// Auto-fetch on first visit when no cached data
useEffect(() => {
@@ -196,13 +198,13 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
<div className="flex flex-col items-center justify-center py-16 text-center">
<Settings className="h-12 w-12 text-muted-foreground/40 mb-4" />
<h3 className="text-lg font-medium text-foreground">
Inget organisationsnummer
{t('no_org_number_title')}
</h3>
<p className="text-sm text-muted-foreground mt-1 max-w-md">
Ange organisationsnummer under Inställningar för att visa företagsprofilen.
{t('no_org_number_description')}
</p>
<Button variant="outline" className="mt-4" asChild>
<Link href="/settings">Gå till Inställningar</Link>
<Link href="/settings">{t('go_to_settings')}</Link>
</Button>
</div>
)
@@ -217,17 +219,17 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
<div className="flex flex-col items-center justify-center py-16 text-center">
<XCircle className="h-12 w-12 text-muted-foreground/40 mb-4" />
<h3 className="text-lg font-medium text-foreground">
Kunde inte hämta företagsprofil
{t('fetch_failed_title')}
</h3>
<p className="text-sm text-muted-foreground mt-1 max-w-md">
Kontrollera att organisationsnumret i inställningarna är korrekt och försök igen.
{t('fetch_failed_description')}
</p>
<div className="flex gap-3 mt-4">
<Button variant="outline" asChild>
<Link href="/settings">Inställningar</Link>
<Link href="/settings">{t('settings')}</Link>
</Button>
<Button variant="outline" onClick={fetchProfile} disabled={isFetching}>
Försök igen
{t('retry')}
</Button>
</div>
</div>
@@ -238,9 +240,9 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
const isActive = profile.activityStatus !== 'ceased'
const registrations = [
profile.registration.fTax && 'F-skatt',
profile.registration.vat && 'Moms',
profile.registration.payroll && 'Arbetsgivare',
profile.registration.fTax && t('reg_f_tax'),
profile.registration.vat && t('reg_vat'),
profile.registration.payroll && t('reg_employer'),
].filter((label): label is string => Boolean(label))
return (
@@ -256,7 +258,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
<CardDescription>
{profile.orgNumber} &middot; {profile.legalEntityType}
{!isActive && (
<span className="ml-2 text-destructive">&middot; Avregistrerat</span>
<span className="ml-2 text-destructive">&middot; {t('deregistered')}</span>
)}
</CardDescription>
</CardHeader>
@@ -285,13 +287,13 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
)}
{registrations.length > 0 && (
<div className="pt-2 border-t">
<p className="text-xs font-medium text-muted-foreground mb-1">Registrerat för</p>
<p className="text-xs font-medium text-muted-foreground mb-1">{t('registered_for')}</p>
<p className="text-xs text-muted-foreground">{registrations.join(' · ')}</p>
</div>
)}
{profile.sniCodes.length > 0 && (
<div className="pt-2 border-t">
<p className="text-xs font-medium text-muted-foreground mb-1">SNI-koder</p>
<p className="text-xs font-medium text-muted-foreground mb-1">{t('sni_codes')}</p>
<div className="space-y-0.5">
{profile.sniCodes
.filter((sni, i, arr) => arr.findIndex(s => s.code === sni.code) === i)
@@ -306,7 +308,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
)}
{profile.bankAccounts.length > 0 && (
<div className="pt-2 border-t">
<p className="text-xs font-medium text-muted-foreground mb-1">Bankuppgifter</p>
<p className="text-xs font-medium text-muted-foreground mb-1">{t('bank_accounts')}</p>
<div className="space-y-0.5">
{profile.bankAccounts.map((ba, i) => (
<p key={i} className="text-xs text-muted-foreground">
@@ -319,7 +321,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
)}
{profile.purpose && (
<div className="pt-2 border-t">
<p className="text-xs font-medium text-muted-foreground mb-1">Verksamhet</p>
<p className="text-xs font-medium text-muted-foreground mb-1">{t('purpose')}</p>
<p className="text-xs text-muted-foreground">{profile.purpose}</p>
</div>
)}
@@ -327,18 +329,18 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
<div className="pt-2 border-t">
{profile.employeeRange && (
<p className="text-xs text-muted-foreground">
Anställda: {profile.employeeRange}
{t('employees_range', { range: profile.employeeRange })}
</p>
)}
{profile.turnoverRange && (
<p className="text-xs text-muted-foreground">
Omsättning: {profile.turnoverRange}
{t('turnover_range', { range: profile.turnoverRange })}
</p>
)}
</div>
)}
<p className="pt-2 text-xs text-muted-foreground/70">
Uppdaterad {timeAgo(profile.fetchedAt)}
{t('updated_ago', { ago: timeAgo(profile.fetchedAt, t) })}
</p>
</CardContent>
</Card>
@@ -346,7 +348,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
{/* Financials card */}
<Card>
<CardHeader>
<CardTitle className="text-base">Senaste bokslut</CardTitle>
<CardTitle className="text-base">{t('latest_closing')}</CardTitle>
{profile.financials && (
<CardDescription>
{formatPeriod(profile.financials.periodStart, profile.financials.periodEnd)}
@@ -356,31 +358,31 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
<CardContent>
{profile.financials ? (
<div className="grid grid-cols-2 gap-4">
<FinancialCell label="Omsättning" value={formatKSEK(profile.financials.netSalesK)} />
<FinancialCell label={t('net_sales')} value={formatKSEK(profile.financials.netSalesK)} />
<FinancialCell
label="Rörelseresultat"
label={t('operating_profit')}
value={formatKSEK(profile.financials.operatingProfitK)}
negative={(profile.financials.operatingProfitK ?? 0) < 0}
/>
<FinancialCell label="Totala tillgångar" value={formatKSEK(profile.financials.totalAssetsK)} />
<FinancialCell label={t('total_assets')} value={formatKSEK(profile.financials.totalAssetsK)} />
<FinancialCell
label="Anställda"
label={t('employees')}
value={profile.financials.numberOfEmployees !== null
? String(profile.financials.numberOfEmployees)
: '—'}
/>
<FinancialCell
label="Rörelsemarginal"
label={t('operating_margin')}
value={formatPercent(profile.financials.operatingMargin)}
negative={(profile.financials.operatingMargin ?? 0) < 0}
/>
<FinancialCell
label="Soliditet"
label={t('equity_ratio')}
value={formatPercent(profile.financials.equityAssetsRatio)}
/>
</div>
) : (
<p className="text-sm text-muted-foreground">Inga finansiella uppgifter tillgängliga.</p>
<p className="text-sm text-muted-foreground">{t('no_financials')}</p>
)}
</CardContent>
</Card>
@@ -390,17 +392,17 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
{profile.financialReports.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Årsredovisningar</CardTitle>
<CardTitle className="text-base">{t('annual_reports')}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Period</TableHead>
<TableHead>Titel</TableHead>
<TableHead>Inlämnad</TableHead>
<TableHead>Reviderad</TableHead>
<TableHead>Revisionsutlåtande</TableHead>
<TableHead>{t('col_period')}</TableHead>
<TableHead>{t('col_title')}</TableHead>
<TableHead>{t('col_filed')}</TableHead>
<TableHead>{t('col_audited')}</TableHead>
<TableHead>{t('col_audit_opinion')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>