feat(ux): company context in settings, Kundfakturor rename, compact verifikat view (#1071)
* feat(ux): company context in settings, Kundfakturor rename, compact verifikat view Support feedback (2026-07-19): active company invisible in settings, menu said Fakturor next to Leverantorsfakturor, no compact verifikat view. - ActiveCompanyBadge chip in the settings modal header and the full-page settings header; the modal covers the sidebar CompanySwitcher - nav + page title Fakturor -> Kundfakturor (sv), Invoices -> Customer invoices (en); command palette gets a Kundfakturor page entry - verifikat list density toggle (comfortable/compact), persisted per company like the existing sort/page-size choices Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: decision log for scoped Kundfakturor rename Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): badge hover reveals full company name; pure density state updater Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ebf69d2933
commit
90e7c7f47f
@@ -220,3 +220,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-17] Removed 'bokslut' deadline type (replaced by statutory 'arsstamma', ABL 7:10, 6 months): the 3-month milestone had no legal basis and its broken-FY date math was off by one month (May-start FY got 31 Aug; Nov-start rolled "Feb 31" into March). Completed bokslut rows kept for history; type removed from the union like the earlier 'moms'/'inkomstdeklaration' retirements.
|
||||
[2026-07-17] EU-trade/PS settings stay opt-in flags; a ledger-derived signal (postings on 3108/3308/3107, 15 months) only renders a suggestion callout in tax settings. Auto-flipping registration flags from ledger data would assert a Skatteverket registration we cannot know.
|
||||
[2026-07-19] Voucher-sequence resync run on prod via execute_sql BEFORE the migration merges: data-only idempotent DML (no schema_migrations orphan risk) and a user was hard-blocked on year-end; migration file 20260719100000 ships the same SQL so every environment replays it as a no-op.
|
||||
[2026-07-20] Menu rename Fakturor -> Kundfakturor scoped to nav label + /invoices page title + command palette only (support tip 2026-07-19): API-key scope labels, AR-ledger xlsx sheet name, and customer/supplier detail section headings keep "Fakturor" since their surrounding context already disambiguates, and renaming API scope labels would churn a stable admin surface.
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { SettingsShell } from '@/components/settings/SettingsShell'
|
||||
import { ActiveCompanyBadge } from '@/components/settings/ActiveCompanyBadge'
|
||||
|
||||
const TAB_TO_ROUTE: Record<string, string> = {
|
||||
company: '/settings/company',
|
||||
@@ -36,7 +37,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader title={t('aria_label')} />
|
||||
<PageHeader title={t('aria_label')} action={<ActiveCompanyBadge />} />
|
||||
<SettingsShell variant="page">{children}</SettingsShell>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
STORAGE_KEY_PREFIX as FISCAL_YEAR_STORAGE_KEY_PREFIX,
|
||||
ALL_YEARS_VALUE as FISCAL_YEAR_ALL_VALUE,
|
||||
} from '@/components/common/FiscalYearSelector'
|
||||
import { ChevronDown, ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Paperclip, AlertTriangle, CircleSlash, Loader2, BookOpen, X, Copy, Lock, Search, SlidersHorizontal, RotateCcw } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Paperclip, AlertTriangle, CircleSlash, Loader2, BookOpen, X, Copy, Lock, Search, SlidersHorizontal, RotateCcw, Rows3 } from 'lucide-react'
|
||||
import { formatDate, formatCurrency } from '@/lib/utils'
|
||||
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { resolveCurrentPeriodId } from '@/lib/bookkeeping/suggest-fiscal-period'
|
||||
@@ -67,6 +67,10 @@ type SortBy = 'date_desc' | 'date_asc' | 'voucher_asc' | 'voucher_desc'
|
||||
const SORT_STORAGE_KEY_PREFIX = 'Accounted:journal-sort:'
|
||||
const SORT_VALUES = new Set<SortBy>(['date_desc', 'date_asc', 'voucher_asc', 'voucher_desc'])
|
||||
|
||||
// Compact row density (support feedback: "kompakt visning av verifikat").
|
||||
// Persisted per company, mirroring the sort key convention.
|
||||
const DENSITY_STORAGE_KEY_PREFIX = 'Accounted:journal-density:'
|
||||
|
||||
// Page-size selector. Persisted per company, mirroring the sort key convention.
|
||||
// 'all' fetches everything in the current scope (capped server-side at MAX_LIMIT);
|
||||
// the numeric options paginate normally.
|
||||
@@ -124,6 +128,7 @@ export default function JournalEntryList() {
|
||||
const [draftCount, setDraftCount] = useState(0)
|
||||
const [pageSizeChoice, setPageSizeChoice] = useState<PageSizeChoice>('20')
|
||||
const [pageSizeHydrated, setPageSizeHydrated] = useState(false)
|
||||
const [compact, setCompact] = useState(false)
|
||||
const showingAll = pageSizeChoice === 'all'
|
||||
const pageSize = showingAll ? ALL_PAGE_SIZE : Number(pageSizeChoice)
|
||||
|
||||
@@ -230,6 +235,26 @@ export default function JournalEntryList() {
|
||||
setSortHydrated(true)
|
||||
}, [company?.id])
|
||||
|
||||
// Restore the persisted row density (per company). Purely visual, so it
|
||||
// doesn't gate the first fetch; the effect-read avoids an SSR mismatch.
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = window.localStorage.getItem(DENSITY_STORAGE_KEY_PREFIX + (company?.id ?? 'default'))
|
||||
setCompact(stored === 'compact')
|
||||
}
|
||||
}, [company?.id])
|
||||
|
||||
const toggleDensity = () => {
|
||||
const next = !compact
|
||||
setCompact(next)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(
|
||||
DENSITY_STORAGE_KEY_PREFIX + (company?.id ?? 'default'),
|
||||
next ? 'compact' : 'comfortable',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the persisted page-size choice (per company). Same hydration pattern
|
||||
// as the sort order, read in an effect to avoid an SSR mismatch, and gate the
|
||||
// first fetch so the list is fetched once at the saved size.
|
||||
@@ -876,6 +901,19 @@ export default function JournalEntryList() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Row density: comfortable vs compact, persisted per company. A toggle
|
||||
(aria-pressed) rather than two modes buried in the filter dialog. */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleDensity}
|
||||
aria-pressed={compact}
|
||||
aria-label={t('density_compact')}
|
||||
title={t('density_compact')}
|
||||
className={`h-8 w-8 shrink-0 p-0 ${compact ? 'bg-secondary text-foreground' : 'text-muted-foreground'}`}
|
||||
>
|
||||
<Rows3 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{/* Active fiscal-year scope as a direct one-click picker, pushed to the
|
||||
right of the control bar, keeps the räkenskapsår visible per BFL and
|
||||
changeable in one click. Distinct from Filtrera (which now holds only
|
||||
@@ -1003,6 +1041,7 @@ export default function JournalEntryList() {
|
||||
key={entry.id}
|
||||
selected={selectedIds.has(entry.id)}
|
||||
expanded={isExpanded}
|
||||
rowClassName={compact ? 'py-1' : undefined}
|
||||
onClick={() => toggleExpand(entry.id)}
|
||||
leading={
|
||||
selectable ? (
|
||||
|
||||
@@ -48,6 +48,7 @@ const ACTION_ENTRIES: Entry[] = [
|
||||
const PAGE_ENTRIES: Entry[] = [
|
||||
{ id: 'kunder', label: 'Kunder', icon: Users, href: '/customers' },
|
||||
{ id: 'leverantörer', label: 'Leverantörer', icon: Building2, href: '/suppliers' },
|
||||
{ id: 'kundfakturor', label: 'Kundfakturor', icon: ReceiptText, href: '/invoices', keywords: 'fakturor fakturering invoices kundfaktura' },
|
||||
{ id: 'leverantörsfakturor', label: 'Leverantörsfakturor', icon: Wallet, href: '/supplier-invoices' },
|
||||
{ id: 'bokföring', label: 'Bokföring', icon: BookOpen, href: '/bookkeeping', keywords: 'verifikat journal ledger' },
|
||||
{ id: 'kontoplan', label: 'Kontoplan', icon: ListTree, href: '/chart-of-accounts', keywords: 'kontoplan konton bas chart of accounts konto' },
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import { Building2 } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
/**
|
||||
* Quiet chip naming the active company. Settings surfaces (the routed modal
|
||||
* and the full-page fallback) cover or dim the sidebar's CompanySwitcher, so
|
||||
* without this the user edits company-scoped settings with no visible answer
|
||||
* to "which company am I on?" (support feedback 2026-07-19).
|
||||
*/
|
||||
export function ActiveCompanyBadge({ className }: { className?: string }) {
|
||||
const { company } = useCompany()
|
||||
const t = useTranslations('common')
|
||||
|
||||
if (!company) return null
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'max-w-full min-w-0 gap-1.5 font-normal text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
title={`${t('active_company')}: ${company.name}`}
|
||||
>
|
||||
<Building2 className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
<span className="sr-only">{t('active_company')}: </span>
|
||||
<span className="truncate">{company.name}</span>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { SETTINGS_SECTIONS } from './sections'
|
||||
import { SettingsShell } from './SettingsShell'
|
||||
import { ActiveCompanyBadge } from './ActiveCompanyBadge'
|
||||
|
||||
/**
|
||||
* The settings popup. Rendered only by the intercepting route
|
||||
@@ -52,10 +53,13 @@ export function SettingsModal({ sectionId }: { sectionId?: string }) {
|
||||
<DialogContent
|
||||
className="flex h-[100dvh] max-h-[100dvh] max-w-none flex-col gap-0 overflow-hidden rounded-none p-0 md:h-auto md:max-h-[85dvh] md:max-w-4xl md:rounded-lg"
|
||||
>
|
||||
<div className="flex shrink-0 items-center border-b border-border px-6 py-4">
|
||||
<div className="flex shrink-0 items-center gap-3 border-b border-border px-6 py-4">
|
||||
<DialogTitle className="font-display text-lg tracking-tight">
|
||||
{t('title')}
|
||||
</DialogTitle>
|
||||
{/* The modal covers the sidebar's CompanySwitcher, so the active
|
||||
company must stay visible here (mr-6 clears the close button). */}
|
||||
<ActiveCompanyBadge className="ml-auto mr-6" />
|
||||
</div>
|
||||
<DialogDescription className="sr-only">{t('description')}</DialogDescription>
|
||||
<SettingsShell variant="modal" activeSection={resolved} />
|
||||
|
||||
+4
-2
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"active_company": "Active company",
|
||||
"update_available": "A new version is available.",
|
||||
"reload": "Reload",
|
||||
"save": "Save",
|
||||
@@ -71,7 +72,7 @@
|
||||
"bureau": "Bureau",
|
||||
"kpi": "KPIs",
|
||||
"invoice_inbox": "Documents",
|
||||
"invoices": "Invoices",
|
||||
"invoices": "Customer invoices",
|
||||
"sales_orders": "Orders",
|
||||
"customers": "Customers",
|
||||
"articles": "Articles",
|
||||
@@ -3490,6 +3491,7 @@
|
||||
"journal_list": {
|
||||
"mode_vouchers": "Vouchers",
|
||||
"mode_drafts": "Drafts",
|
||||
"density_compact": "Compact view",
|
||||
"show_correction_chain": "Show storno & corrected entries",
|
||||
"loading": "Loading journal entries...",
|
||||
"empty_title": "No journal entries",
|
||||
@@ -4683,7 +4685,7 @@
|
||||
"validation_min_one_row": "At least one row is required"
|
||||
},
|
||||
"invoices": {
|
||||
"title": "Invoices",
|
||||
"title": "Customer invoices",
|
||||
"recurring": "Recurring",
|
||||
"new_invoice": "New invoice",
|
||||
"new_self_billed": "Self-billing",
|
||||
|
||||
+4
-2
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"active_company": "Aktivt företag",
|
||||
"update_available": "En ny version finns tillgänglig.",
|
||||
"reload": "Ladda om",
|
||||
"save": "Spara",
|
||||
@@ -71,7 +72,7 @@
|
||||
"bureau": "Byrå",
|
||||
"kpi": "Nyckeltal",
|
||||
"invoice_inbox": "Underlag",
|
||||
"invoices": "Fakturor",
|
||||
"invoices": "Kundfakturor",
|
||||
"sales_orders": "Order",
|
||||
"customers": "Kunder",
|
||||
"articles": "Artiklar",
|
||||
@@ -3490,6 +3491,7 @@
|
||||
"journal_list": {
|
||||
"mode_vouchers": "Verifikat",
|
||||
"mode_drafts": "Utkast",
|
||||
"density_compact": "Kompakt visning",
|
||||
"show_correction_chain": "Visa storno- och rättade poster",
|
||||
"loading": "Laddar verifikationer...",
|
||||
"empty_title": "Inga verifikationer",
|
||||
@@ -4683,7 +4685,7 @@
|
||||
"validation_min_one_row": "Minst en rad krävs"
|
||||
},
|
||||
"invoices": {
|
||||
"title": "Fakturor",
|
||||
"title": "Kundfakturor",
|
||||
"recurring": "Återkommande",
|
||||
"new_invoice": "Ny faktura",
|
||||
"new_self_billed": "Självfaktura",
|
||||
|
||||
Reference in New Issue
Block a user