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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} · {profile.legalEntityType}
|
||||
{!isActive && (
|
||||
<span className="ml-2 text-destructive">· Avregistrerat</span>
|
||||
<span className="ml-2 text-destructive">· {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>
|
||||
|
||||
Reference in New Issue
Block a user