Bug/resend and invoices (#1192)

* fix(invoices): anchor the PDF logo to the top-left of its header cell

The logo box is always the full 240x80pt reserved area (any larger logo is
clamped to exactly that), so objectFit: 'contain' placed the image inside it
with the default 50% 50% centering. A wide banner logo fills the width and
lands on the left margin, but a near-square logo scaled down to the 80pt
height cap is only ~117pt wide and got pushed ~60pt in from the margin, which
reads as a misaligned logo and forced companies to reshape their artwork.

Anchor the image top-left so every aspect ratio starts at the margin.

Covered by a test that renders the real PDF and reads the image placement
matrix out of the content stream, for both a wide and a near-square logo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(invoices): show the real delivery outcome in the send history

"Skickad" only meant the email provider accepted the message, so a bounced
invoice looked identical to one that arrived. Resend reports the outcome
asynchronously; that report now lands on the delivery row and drives the
history: green is reserved for a confirmed delivery, bounce/blocked reads
red, delayed and spam-marked read amber, and an accepted-but-unconfirmed
send is neutral instead of falsely green.

The report arrives on a signed webhook and may only touch the three new
provider status columns of an already sent, unredacted row: the WORM trigger
proves nothing else changed, and a lower ranked or older report can never
downgrade an observed failure. The provider reason text can quote the failing
address, so it is masked on read and cleared by the daily PII redaction job.

Timestamps also formatted in Europe/Stockholm instead of falling back to the
runtime zone, which rendered a 14:05 send as 12:05 on Vercel.

Delivery reports are per message, never per recipient: Resend sends one event
for the whole message, so splitting a send per recipient would be the only way
to get finer granularity, at the cost of CC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(stripe): make the integration feed-only

Stripe sync now only imports balance transactions into the transactions
inbox, like any bank feed; nothing auto-books. The event/settlement sync
(lib/sync.ts, lib/payouts.ts) stays in the repo but is no longer wired to
any route or cron: the 15-min sync cron is removed from vercel.json.
Payment links on invoice send are unchanged; their payments arrive as
feed rows and are matched manually.

- /sync runs only syncStripeBalanceTransactions; response is { success,
  transactions }
- connecting via OAuth enables the nightly feed by default (toggle stays
  as opt-out)
- panel: needs-review section and plumbing removed, copy rewritten to
  transactions-first (sv + en), toast reports fetched/imported/linked
  and calls out an empty result instead of silent all-zeros

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): return the article currency from the v1 article list

The dashboard, importer, export and MCP article surfaces all learned to
carry a non-SEK article price (#1166, #1183, #1184), but the v1
projection still omitted currency. An API or agent caller therefore read
price_excl_vat with nothing marking it as EUR and would copy the number
straight onto a SEK invoice line, at a nine-to-one error.

Adds currency to the projection, the response shape and the example, plus
a pitfall stating the price is not always SEK and that this endpoint does
no FX conversion.

Additive field only; no migration (articles.currency already exists).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(settings): replace the settings modal with a routed panel sheet

Settings now renders as a sheet that fills the main panel, sliding up over the
page the user came from and back down on close, with the sidebar and frame left
visible and usable. Behind it sits one shared master-detail surface: underline
search across every section and subsection, the grouped section rail, and the
active section as a direct-editing accordion. All 11 sections are decomposed
into subsections, and the legacy *SettingsContent components compose the same
pieces so the stacked and accordion layouts cannot drift.

The sheet is the only presentation, on every entry path. The intercepting route
handles in-app navigation and closes by popping the history entry, landing back
on the page underneath. @settingsModal/default.tsx handles cold loads (refresh,
deep link, new tab), where interception never fires; nothing is mounted
underneath there, so it closes to the dashboard. Both branch on one shared
predicate, isSheetSection, together with the settings layout, which must render
nothing for those sections or the surface would stack twice behind the sheet
and run every section's fetches twice.

Closing is deliberate rather than incidental: the X, Esc, or navigating away.
The dialog is non-modal so the sidebar's account popover and company switcher
keep working with settings up, and an outside click no longer dismisses it.
Sections land fully collapsed, and the scroll position of the page behind
survives opening and closing the sheet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: enhance article management and settings UI

- Add PATCH test for toggling article active state without other fields.
- Remove unused MessageCircle icon from DashboardContent.
- Refactor AccountingFrameworkForm to use SettingsFieldRow for better help text display.
- Update CompanyInfoForm, DimensionsToggle, and various settings forms to replace description with help text.
- Remove redundant headings and intros in several settings components to streamline UI.
- Improve help text for various settings in English and Swedish translations.
- Update structured error messages for better clarity on article deletion.

* refactor(ArticleDetailPage): remove unused imports and duplicate state variable

* fix(settings): own deep-linked settings routes by route list, not nav visibility

Review fixes from the settings panel sheet work:
* isSheetSection reads the full settings route list so a hidden-but-deep-linked
  section (assistant before BankID, banking in sandbox, api without MCP) is
  claimed by the sheet instead of rendering the legacy shell around an empty panel
* keep 503 on the Resend delivery webhook when the signing secret is unset, with
  a test pinning the behaviour
* stripe callback route test coverage

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: update salary, tax, and templates settings components

- Refactored SalarySettingsContent to use a form wrapper and improved payment settings UI.
- Enhanced TaxSettingsContent with new signals for EU sales, KU obligations, and ROT/RUT deductions.
- Updated TemplatesSettingsContent to remove legacy comments and improve readability.
- Simplified navigation items by removing unnecessary constants and directly using hrefs.
- Cleaned up translation files by removing deprecated keys and adding new descriptions for clarity.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-25 22:56:17 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent f07a34c51b
commit d54b43f80f
89 changed files with 6399 additions and 4682 deletions
+1 -4
View File
@@ -12,7 +12,7 @@ import AttGoraSection from '@/components/dashboard/AttGoraSection'
import ResumePane from '@/components/dashboard/ResumePane'
import BackupHealthBanner from '@/components/dashboard/BackupHealthBanner'
import { SkatteverketPromoCard } from '@/components/dashboard/SkatteverketPromoCard'
import { ArrowRight, MessageCircle } from 'lucide-react'
import { ArrowRight } from 'lucide-react'
import type { InitialSetupState, OnboardingProgress } from '@/types'
import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types'
import type { ResumeItem } from '@/lib/worklist/resume'
@@ -111,9 +111,6 @@ export default function DashboardContent({
<Link href={hasAi ? '/onboarding/agent' : '/settings/billing'} className="block group">
<Card className="transition-colors hover:border-primary/50">
<CardContent className="p-6 flex items-center gap-4">
<div className="flex-shrink-0 h-10 w-10 rounded-lg flex items-center justify-center bg-foreground text-background">
<MessageCircle className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-display text-xl leading-tight">Bygg din bokföringsassistent</p>
+67 -6
View File
@@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import type { InvoiceDelivery } from '@/types'
import type { InvoiceDelivery, InvoiceDeliveryProviderStatus } from '@/types'
export type InvoiceDeliveryView = Pick<
InvoiceDelivery,
@@ -15,6 +15,9 @@ export type InvoiceDeliveryView = Pick<
| 'to_addresses'
| 'cc_addresses'
| 'provider'
| 'provider_status'
| 'provider_status_at'
| 'provider_status_detail'
| 'error_code'
| 'document_attachment_id'
| 'attachment_filename'
@@ -30,12 +33,37 @@ interface InvoiceDeliveryHistoryProps {
showLegacyEmptyState: boolean
}
const statusVariant = {
/**
* What the row actually says happened. The send status alone stops at
* "handed to the provider", which is why an accepted-but-bounced invoice used
* to read as a plain success. When the provider has reported back, its
* outcome is what the row shows.
*/
type DeliveryOutcome =
| 'pending'
| 'sent'
| 'failed'
| 'marked_sent'
| InvoiceDeliveryProviderStatus
function outcomeOf(delivery: InvoiceDeliveryView): DeliveryOutcome {
if (delivery.status === 'sent' && delivery.provider_status) return delivery.provider_status
return delivery.status
}
// Green is reserved for a confirmed arrival. "Skickad" without a delivery
// report is a neutral, honest in-between state.
const outcomeVariant: Record<DeliveryOutcome, 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'> = {
pending: 'secondary',
sent: 'success',
sent: 'secondary',
delivered: 'success',
delayed: 'warning',
complained: 'warning',
bounced: 'destructive',
failed: 'destructive',
suppressed: 'destructive',
marked_sent: 'outline',
} as const
}
export function InvoiceDeliveryHistory({
deliveries,
@@ -76,6 +104,10 @@ export function InvoiceDeliveryHistory({
{deliveries.map((delivery) => {
const occurredAt = delivery.sent_at || delivery.failed_at || delivery.created_at
const isManual = delivery.channel === 'manual'
const outcome = outcomeOf(delivery)
const isEmailSend = !isManual && delivery.status === 'sent'
const recipientCount =
delivery.to_addresses.length + delivery.cc_addresses.length
return (
<details key={delivery.id} className="group rounded-lg border bg-card">
@@ -91,8 +123,8 @@ export function InvoiceDeliveryHistory({
{formatTimestamp(occurredAt)}
</span>
</span>
<Badge variant={statusVariant[delivery.status]}>
{t(`delivery_status_${delivery.status}`)}
<Badge variant={outcomeVariant[outcome]}>
{t(`delivery_status_${outcome}`)}
</Badge>
</summary>
@@ -115,6 +147,35 @@ export function InvoiceDeliveryHistory({
)}
</dl>
{isEmailSend && (
<div className="rounded-lg border bg-muted/30 p-3">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">
{t('delivery_provider_status_label')}
</span>
<span>{t(`delivery_status_${outcome}`)}</span>
{delivery.provider_status_at && (
<span className="text-xs text-muted-foreground tabular-nums">
{formatTimestamp(delivery.provider_status_at)}
</span>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground">
{t(`delivery_status_explanation_${outcome}`)}
</p>
{delivery.provider_status_detail && (
<p className="mt-2 break-words text-xs text-muted-foreground">
{t('delivery_provider_reason_label')}: {delivery.provider_status_detail}
</p>
)}
{recipientCount > 1 && delivery.provider_status && (
<p className="mt-2 text-xs text-muted-foreground">
{t('delivery_status_whole_send_note')}
</p>
)}
</div>
)}
{delivery.document_attachment_id && (
<Button asChild variant="outline" size="sm" className="max-w-full">
<a
+14 -6
View File
@@ -74,13 +74,21 @@ export function TaxTableStatus({ year, compact = false }: Props) {
)
}
// Flat row presentation (Fönster settings language): muted status line
// with a quiet right-aligned recheck, no box.
return (
<div className="flex items-center justify-between gap-3 rounded-md border bg-muted/30 px-3 py-2">
<div className="flex items-center gap-2 text-sm">
<Icon className={`h-4 w-4 ${iconColor}`} />
<span>{label}</span>
</div>
<Button variant="ghost" size="sm" onClick={check} disabled={loading} aria-label={t('recheck')}>
<div className="flex w-full min-w-0 items-center gap-2">
<Icon className={`h-3.5 w-3.5 shrink-0 ${iconColor}`} />
<span className="min-w-0 text-xs text-muted-foreground">{label}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={check}
disabled={loading}
aria-label={t('recheck')}
className="ml-auto shrink-0"
>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCcw className="h-3.5 w-3.5" />}
</Button>
</div>
+58 -50
View File
@@ -20,6 +20,12 @@ import { RetentionNotice } from '@/components/ui/retention-notice'
import { ExternalLink, Loader2 } from 'lucide-react'
import { SupportLink } from '@/components/ui/support-link'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import {
SettingsDangerZone,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
interface Blocker {
id: string
@@ -28,6 +34,7 @@ interface Blocker {
export function AccountDangerZone() {
const t = useTranslations('settings_account_danger')
const tRetention = useTranslations('retention_notice')
const router = useRouter()
const [email, setEmail] = useState<string | null>(null)
const [blockers, setBlockers] = useState<Blocker[]>([])
@@ -103,61 +110,62 @@ export function AccountDangerZone() {
return (
<>
<section className="space-y-4 border-t border-border pt-8">
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive">
{t('heading')}
</h2>
<SettingsDangerZone label={t('heading')}>
{/* Owned companies block deletion: functional state, kept visible as
rows (only the first row carries the label and the why-help). */}
{hasBlockers &&
blockers.map((b, i) => (
<SettingsRow
key={b.id}
label={i === 0 ? t('blockers_title') : ''}
help={i === 0 ? t('blockers_description') : undefined}
>
<span className="text-sm">{b.name}</span>
<SettingsRowEnd>
<Button variant="ghost" size="sm" asChild>
<Link href="/settings/company">{t('blockers_manage')}</Link>
</Button>
</SettingsRowEnd>
</SettingsRow>
))}
{hasBlockers && (
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-3">
<p className="text-sm font-medium">{t('blockers_title')}</p>
<p className="text-sm text-muted-foreground">
{t('blockers_description')}
</p>
<ul className="space-y-2">
{blockers.map((b) => (
<li
key={b.id}
className="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2"
>
<span className="text-sm font-medium">{b.name}</span>
<Button variant="ghost" size="sm" asChild>
<Link href="/settings/company">{t('blockers_manage')}</Link>
</Button>
</li>
))}
</ul>
</div>
)}
<RetentionNotice variant="account" />
<p className="text-sm text-muted-foreground">
{t('support_question')}{' '}
<SupportLink variant="inline" subject={t('support_subject')} />
</p>
<SettingsRow
label={t('delete_button')}
borderless
// The full anonymization/BFL retention copy (incl. the backup link)
// lives behind the "?": the visible row stays one quiet line.
help={<RetentionNotice variant="account" className="border-0 bg-transparent p-0" />}
>
<SettingsRowNote>{tRetention('account_title')}</SettingsRowNote>
<SettingsRowEnd>
<Button variant="ghost" size="sm" asChild>
<Link href="/reports?type=sie">
<ExternalLink className="mr-2 h-3.5 w-3.5" />
{t('export_sie')}
</Link>
</Button>
<button
type="button"
onClick={() => setShowDialog(true)}
disabled={!canDelete}
className="text-sm font-medium text-destructive underline underline-offset-2 transition-colors duration-150 hover:text-destructive/80 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('delete_button')}
</button>
</SettingsRowEnd>
</SettingsRow>
{error && !showDialog && (
<p className="text-sm text-destructive">{error}</p>
<p className="px-1 text-sm text-destructive">{error}</p>
)}
<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" />
{t('export_sie')}
</Link>
</Button>
<Button
variant="destructive"
className="w-full sm:w-auto"
onClick={() => setShowDialog(true)}
disabled={!canDelete}
>
{t('delete_button')}
</Button>
</div>
</section>
<p className="px-1 pt-3">
<SettingsRowNote>
{t('support_question')}{' '}
<SupportLink variant="inline" subject={t('support_subject')} />
</SettingsRowNote>
</p>
</SettingsDangerZone>
<Dialog
open={showDialog}
+36 -35
View File
@@ -1,15 +1,7 @@
'use client'
import { useState } from 'react'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
@@ -19,6 +11,11 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import {
SettingsRow,
SettingsRowNote,
SettingsSelect,
} from '@/components/settings/SettingsRows'
import type { AccountingFramework } from '@/types'
import { Loader2 } from 'lucide-react'
@@ -30,8 +27,9 @@ interface AccountingFrameworkFormProps {
}
/**
* K2/K3 selector for AB. Lives on the bookkeeping settings page. Renders nothing
* for non-AB entities: the parent gates this component by entity_type.
* K2/K3 selector row for AB. Lives in the Grunder group on the bookkeeping
* settings page. Renders nothing for non-AB entities: the parent gates this
* component by entity_type.
*
* UX rules (regulatory area: kept in Swedish):
* - Default is K2 (matches the column default and BFNAR 2016:10 baseline).
@@ -100,33 +98,36 @@ export function AccountingFrameworkForm({ current, onSaved }: AccountingFramewor
}
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Redovisningsregelverk
</h2>
<div className="space-y-2">
<Label htmlFor="accounting_framework">Regelverk</Label>
<Select
<>
<SettingsRow
label="Regelverk"
htmlFor="accounting_framework"
help={
<>
K2 är standard för mindre bolag och innebär förenklade regler. K3 krävs när
bolaget når två av tre tröskelvärden (nettoomsättning &gt; 80 MSEK, tillgångar
&gt; 40 MSEK, eller fler än 50 anställda). K3 ställer högre krav: kassaflödesanalys,
komponentavskrivning på materiella anläggningstillgångar och redovisning av
uppskjuten skatt på obeskattade reserver (79,4 % eget kapital / 20,6 % skuld).
</>
}
>
<SettingsSelect
id="accounting_framework"
value={selected}
onValueChange={handleChange}
onChange={(e) => handleChange(e.target.value)}
// Saves via its own PATCH; the row sits inside the page's
// SettingsFormWrapper form, so keep the wrapper's dirty tracking
// (onInput on the form) from reacting to this select. No `name`
// either, so the wrapper's FormData never picks it up.
onInput={(e) => e.stopPropagation()}
disabled={saving}
>
<SelectTrigger id="accounting_framework" className="w-full max-w-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="k2">K2 (BFNAR 2016:10): mindre företag</SelectItem>
<SelectItem value="k3">K3 (BFNAR 2012:1): större företag</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
K2 är standard för mindre bolag och innebär förenklade regler. K3 krävs när
bolaget når två av tre tröskelvärden (nettoomsättning &gt; 80 MSEK, tillgångar
&gt; 40 MSEK, eller fler än 50 anställda). K3 ställer högre krav: kassaflödesanalys,
komponentavskrivning på materiella anläggningstillgångar och redovisning av
uppskjuten skatt på obeskattade reserver (79,4 % eget kapital / 20,6 % skuld).
</p>
</div>
<option value="k2">K2 (BFNAR 2016:10): mindre företag</option>
<option value="k3">K3 (BFNAR 2012:1): större företag</option>
</SettingsSelect>
{saving && <SettingsRowNote>Sparar…</SettingsRowNote>}
</SettingsRow>
<Dialog
open={pending !== null}
@@ -174,6 +175,6 @@ export function AccountingFrameworkForm({ current, onSaved }: AccountingFramewor
</DialogFooter>
</DialogContent>
</Dialog>
</section>
</>
)
}
+204 -196
View File
@@ -2,16 +2,24 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Brain, Loader2, Pin, PinOff, Pencil, Plus, RotateCcw, Trash2, X } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { EmptyState } from '@/components/ui/empty-state'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import {
SettingsGroup,
SettingsReveal,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
SettingsSeg,
SettingsSelect,
SettingsTextarea,
} from '@/components/settings/SettingsRows'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatDateLong } from '@/lib/utils'
import { cn, formatDateLong } from '@/lib/utils'
type Kind = 'fact' | 'preference' | 'pattern' | 'correction'
type Source = 'composer' | 'user_taught' | 'agent_learned' | 'derived'
@@ -53,7 +61,7 @@ const KIND_FILTER: { value: 'all' | Kind; label: string }[] = [
]
// The API returns errors either as a plain string (legacy/validation) or as
// the canonical { code, message } envelope — extract something renderable.
// the canonical { code, message } envelope; extract something renderable.
function apiErrorText(error: unknown): string | undefined {
if (typeof error === 'string') return error
if (error && typeof error === 'object' && 'message' in error) {
@@ -159,19 +167,31 @@ export function AgentMemoryPanel() {
setEditingId(null)
}
// The view wrapper in AssistantSettingsContent already provides the gap
// under the segmented control, so the group starts flush (pt-0).
return (
<Card>
<CardHeader className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle className="text-base">Vad min assistent kommer ihåg</CardTitle>
<CardDescription>
Bokföringsassistenten använder dessa anteckningar för att ge dig rätt råd. Fäst det som
alltid ska vara med, redigera fel, eller dölj det som inte längre stämmer.
</CardDescription>
</div>
<SettingsGroup
label="Vad min assistent kommer ihåg"
help={
<>
Bokföringsassistenten använder dessa anteckningar för att ge dig rätt råd. Fäst det som
alltid ska vara med, redigera fel, eller dölj det som inte längre stämmer. Upp till 30
minnen ingår i samtal per tur.
</>
}
className="pt-0 first:pt-0"
>
{/* Toolbar row: kind filter + the add-memory entry point. */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-1 py-3">
<SettingsSeg
value={kindFilter}
onChange={setKindFilter}
options={KIND_FILTER}
aria-label="Filtrera minnen"
/>
{canWrite && (
<Button
variant="outline"
variant="ghost"
size="sm"
onClick={() => setShowAdd((v) => !v)}
disabled={adding}
@@ -180,31 +200,32 @@ export function AgentMemoryPanel() {
Lägg till minne
</Button>
)}
</CardHeader>
</div>
<CardContent className="space-y-6">
{showAdd && canWrite && (
<div className="rounded-lg border border-border p-4 space-y-3">
<Textarea
{canWrite && (
<SettingsReveal open={showAdd}>
<div className="space-y-3 py-3">
<SettingsTextarea
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
placeholder="T.ex. Vi använder Stripe för B2C-betalningar; utbetalningar landar på 1930 var måndag."
rows={3}
maxLength={2000}
aria-label="Nytt minne"
className="w-full border-border"
/>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Typ</span>
<Select value={newKind} onValueChange={(v) => setNewKind(v as Kind)}>
<SelectTrigger className="h-8 w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(KIND_LABEL) as Kind[]).map((k) => (
<SelectItem key={k} value={k}>{KIND_LABEL[k]}</SelectItem>
))}
</SelectContent>
</Select>
<SettingsSelect
value={newKind}
onChange={(e) => setNewKind(e.target.value as Kind)}
aria-label="Typ"
>
{(Object.keys(KIND_LABEL) as Kind[]).map((k) => (
<option key={k} value={k}>{KIND_LABEL[k]}</option>
))}
</SettingsSelect>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => { setShowAdd(false); setNewContent('') }}>
@@ -217,194 +238,181 @@ export function AgentMemoryPanel() {
</div>
</div>
</div>
)}
</SettingsReveal>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap gap-1">
{KIND_FILTER.map((f) => {
const active = kindFilter === f.value
return (
<button
key={f.value}
onClick={() => setKindFilter(f.value)}
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
active
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground'
}`}
>
{f.label}
</button>
)
})}
</div>
<label className="inline-flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={includeDismissed}
onChange={(e) => setIncludeDismissed(e.target.checked)}
className="h-3.5 w-3.5"
/>
Visa dolda
</label>
</div>
<SettingsRow label="Visa dolda">
<SettingsRowEnd>
<Switch
checked={includeDismissed}
onCheckedChange={setIncludeDismissed}
aria-label="Visa dolda"
/>
</SettingsRowEnd>
</SettingsRow>
{rows && (
<div className="text-xs text-muted-foreground tabular-nums">
{/* Dynamic status stays visible; the static "how it's used" copy lives
in the group help above. */}
{rows && (
<p className="px-1 pt-3">
<SettingsRowNote className="tabular-nums">
{counts.active} aktiva · {counts.pinned} fästa
{includeDismissed && counts.dismissed > 0 ? ` · ${counts.dismissed} dolda` : ''}
<span className="ml-1">(upp till 30 ingår i samtal per tur)</span>
</div>
)}
</SettingsRowNote>
</p>
)}
{rows === null && (
<div className="space-y-3">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-20 w-full rounded-lg" />
))}
</div>
)}
{rows === null && (
<div className="space-y-3 pt-3">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
)}
{rows && rows.length === 0 && (
<EmptyState
icon={Brain}
title="Inga minnen ännu"
description="När du lär assistenten saker (eller när den noterar saker själv med ditt godkännande) dyker de upp här."
/>
)}
{rows && rows.length === 0 && (
<EmptyState
icon={Brain}
title="Inga minnen ännu"
description="När du lär assistenten saker (eller när den noterar saker själv med ditt godkännande) dyker de upp här."
/>
)}
{rows && rows.length > 0 && (
<ul className="space-y-3">
{rows.map((row) => {
const isEditing = editingId === row.id
const isBusy = busyId === row.id
const dimmed = !row.is_active
return (
<li
key={row.id}
className={`rounded-lg border border-border p-4 transition-colors ${
dimmed ? 'bg-muted/30 opacity-70' : 'bg-card'
}`}
>
<div className="flex items-start gap-3">
{canWrite && row.is_active ? (
<button
onClick={() => patch(row.id, { is_pinned: !row.is_pinned })}
disabled={isBusy}
className={`mt-0.5 shrink-0 rounded-md p-1.5 transition-colors ${
row.is_pinned
? 'text-foreground bg-secondary'
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground'
}`}
aria-label={row.is_pinned ? 'Lossa' : 'Fäst'}
title={row.is_pinned ? 'Lossa' : 'Fäst: säkerställer att minnet alltid skickas med'}
>
{row.is_pinned ? <Pin className="h-4 w-4 fill-current" /> : <PinOff className="h-4 w-4" />}
</button>
{rows && rows.length > 0 && (
<ul>
{rows.map((row) => {
const isEditing = editingId === row.id
const isBusy = busyId === row.id
const dimmed = !row.is_active
return (
<li
key={row.id}
className={cn(
'border-b border-border px-1 py-3 transition-colors',
dimmed && 'opacity-70',
)}
>
<div className="flex items-start gap-3">
{canWrite && row.is_active ? (
<button
onClick={() => patch(row.id, { is_pinned: !row.is_pinned })}
disabled={isBusy}
className={cn(
'mt-0.5 shrink-0 rounded-md p-1.5 transition-colors duration-150',
row.is_pinned
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
)}
aria-label={row.is_pinned ? 'Lossa' : 'Fäst'}
title={row.is_pinned ? 'Lossa' : 'Fäst: säkerställer att minnet alltid skickas med'}
>
{row.is_pinned ? <Pin className="h-4 w-4 fill-current" /> : <PinOff className="h-4 w-4" />}
</button>
) : (
<div className="mt-0.5 shrink-0 p-1.5">
{row.is_pinned && <Pin className="h-4 w-4 fill-current text-foreground" />}
</div>
)}
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] text-muted-foreground">
{KIND_LABEL[row.kind]} · {SOURCE_LABEL[row.source]}
</span>
{dimmed && <Badge variant="secondary">Dold</Badge>}
</div>
{isEditing ? (
<SettingsTextarea
value={editDraft}
onChange={(e) => setEditDraft(e.target.value)}
rows={3}
maxLength={2000}
autoFocus
aria-label="Redigera minne"
className="w-full border-border"
/>
) : (
<div className="mt-0.5 shrink-0 p-1.5">
{row.is_pinned && <Pin className="h-4 w-4 fill-current text-foreground" />}
</div>
<p className="whitespace-pre-wrap break-words text-sm text-foreground">{row.content}</p>
)}
<div className="flex-1 min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] text-muted-foreground">
{KIND_LABEL[row.kind]} · {SOURCE_LABEL[row.source]}
</span>
{dimmed && <Badge variant="secondary">Dold</Badge>}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 pt-1">
<p className="text-[11px] text-muted-foreground tabular-nums">
Skapad {formatDateLong(row.created_at)}
{row.updated_at !== row.created_at && ` · uppdaterad ${formatDateLong(row.updated_at)}`}
</p>
{isEditing ? (
<Textarea
value={editDraft}
onChange={(e) => setEditDraft(e.target.value)}
rows={3}
maxLength={2000}
autoFocus
/>
) : (
<p className="text-sm text-foreground whitespace-pre-wrap break-words">{row.content}</p>
)}
<div className="flex flex-wrap items-center justify-between gap-2 pt-1">
<p className="text-[11px] text-muted-foreground tabular-nums">
Skapad {formatDateLong(row.created_at)}
{row.updated_at !== row.created_at && ` · uppdaterad ${formatDateLong(row.updated_at)}`}
</p>
{canWrite && (
<div className="flex items-center gap-1">
{isEditing ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setEditingId(null)}
disabled={isBusy}
>
<X className="h-4 w-4" />
<span className="sr-only">Avbryt</span>
</Button>
<Button
size="sm"
onClick={() => saveEdit(row)}
disabled={isBusy || editDraft.trim().length < 2}
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Spara'}
</Button>
</>
) : row.is_active ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => startEdit(row)}
disabled={isBusy}
>
<Pencil className="mr-1 h-3.5 w-3.5" />
Redigera
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => patch(row.id, { is_active: false })}
disabled={isBusy}
>
{isBusy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="mr-1 h-3.5 w-3.5" />
)}
Dölj
</Button>
</>
) : (
{canWrite && (
<div className="flex items-center gap-1">
{isEditing ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => patch(row.id, { is_active: true })}
onClick={() => setEditingId(null)}
disabled={isBusy}
>
<X className="h-4 w-4" />
<span className="sr-only">Avbryt</span>
</Button>
<Button
size="sm"
onClick={() => saveEdit(row)}
disabled={isBusy || editDraft.trim().length < 2}
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Spara'}
</Button>
</>
) : row.is_active ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => startEdit(row)}
disabled={isBusy}
>
<Pencil className="mr-1 h-3.5 w-3.5" />
Redigera
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => patch(row.id, { is_active: false })}
disabled={isBusy}
>
{isBusy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="mr-1 h-3.5 w-3.5" />
<Trash2 className="mr-1 h-3.5 w-3.5" />
)}
Återställ
Dölj
</Button>
)}
</div>
)}
</div>
</>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => patch(row.id, { is_active: true })}
disabled={isBusy}
>
{isBusy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="mr-1 h-3.5 w-3.5" />
)}
Återställ
</Button>
)}
</div>
)}
</div>
</div>
</li>
)
})}
</ul>
)}
</CardContent>
</Card>
</div>
</li>
)
})}
</ul>
)}
</SettingsGroup>
)
}
+98 -101
View File
@@ -4,11 +4,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { ChevronDown, GraduationCap, Loader2 } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { EmptyState } from '@/components/ui/empty-state'
import { HelpPopover } from '@/components/ui/help-popover'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import { SettingsGroup, SettingsRowNote } from '@/components/settings/SettingsRows'
import { cn } from '@/lib/utils'
type Tier = 'horizontal' | 'vertical' | 'modifier'
@@ -53,7 +55,7 @@ const PROSE =
'[&_td]:border-b [&_td]:border-border [&_td]:py-1.5 [&_td]:px-2 [&_td]:align-top'
// The API returns errors either as a plain string (legacy/validation) or as
// the canonical { code, message } envelope — extract something renderable.
// the canonical { code, message } envelope; extract something renderable.
function apiErrorText(error: unknown): string | undefined {
if (typeof error === 'string') return error
if (error && typeof error === 'object' && 'message' in error) {
@@ -118,112 +120,107 @@ export function AgentSkillsPanel() {
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Vad min assistent kan</CardTitle>
<CardDescription>
Utöver vad den minns om ditt företag bygger assistenten på en uppsättning kunskapsområden
om svensk bokföring och skatt. Kärnkompetensen gäller alla; bransch- och bolagsanpassningen
väljs utifrån ditt företag. Klicka för att läsa hela kunskapen.
</CardDescription>
</CardHeader>
<CardContent className="space-y-8">
{atoms && (
<div className="text-xs text-muted-foreground tabular-nums">
<div>
{/* Dynamic status stays visible; the static "what this is" copy sits
behind the "?" next to it. */}
{atoms && (
<div className="flex items-center gap-2 px-1">
<SettingsRowNote className="tabular-nums">
{counts.total} kunskapsområden · {counts.active} aktiva för ditt företag
</div>
)}
</SettingsRowNote>
<HelpPopover className="shrink-0">
Utöver vad den minns om ditt företag bygger assistenten på en uppsättning
kunskapsområden om svensk bokföring och skatt. Kärnkompetensen gäller alla;
bransch- och bolagsanpassningen väljs utifrån ditt företag. Klicka på ett
område för att läsa hela kunskapen.
</HelpPopover>
</div>
)}
{atoms === null && (
<div className="space-y-3">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 w-full rounded-lg" />
))}
</div>
)}
{atoms === null && (
<div className="space-y-3">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{atoms && atoms.length === 0 && (
<EmptyState
icon={GraduationCap}
title="Inga kunskapsområden ännu"
description="När din assistent har komponerats dyker dess kunskapsområden upp här."
/>
)}
{atoms && atoms.length === 0 && (
<EmptyState
icon={GraduationCap}
title="Inga kunskapsområden ännu"
description="När din assistent har komponerats dyker dess kunskapsområden upp här."
/>
)}
{atoms && atoms.length > 0 &&
TIER_ORDER.filter((tier) => grouped[tier].length > 0).map((tier) => (
<section key={tier} className="space-y-3">
<div className="space-y-1">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{TIER_SECTION[tier].title}
</h2>
<p className="text-xs text-muted-foreground">{TIER_SECTION[tier].blurb}</p>
</div>
<ul className="space-y-2">
{grouped[tier].map((atom) => {
const isOpen = expandedId === atom.id
const isLoading = loadingBody === atom.id
const body = bodies[atom.id]
const dormant = !atom.active
return (
<li
key={atom.id}
className={`rounded-lg border border-border transition-colors ${
dormant ? 'bg-muted/30' : 'bg-card'
}`}
>
<button
onClick={() => toggle(atom)}
aria-expanded={isOpen}
className="flex w-full items-start gap-3 p-4 text-left"
{atoms && atoms.length > 0 &&
TIER_ORDER.filter((tier) => grouped[tier].length > 0).map((tier) => (
<SettingsGroup key={tier} label={TIER_SECTION[tier].title} help={TIER_SECTION[tier].blurb}>
{grouped[tier].map((atom) => {
const isOpen = expandedId === atom.id
const isLoading = loadingBody === atom.id
const body = bodies[atom.id]
const dormant = !atom.active
return (
<div key={atom.id} className="border-b border-border">
<button
onClick={() => toggle(atom)}
aria-expanded={isOpen}
className="flex w-full items-start gap-3 px-1 py-3 text-left"
>
<ChevronDown
className={cn(
'mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform',
!isOpen && '-rotate-90',
)}
/>
<span className="min-w-0 flex-1">
<span
className={cn(
'block text-sm font-medium',
dormant ? 'text-muted-foreground' : 'text-foreground',
)}
>
<ChevronDown
className={`mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform ${
isOpen ? '' : '-rotate-90'
}`}
/>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-foreground">{atom.title}</span>
{tier !== 'horizontal' && (
<Badge variant={atom.active ? 'success' : 'secondary'}>
{atom.active ? 'Aktiv' : 'Vilande'}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{atom.description}</p>
</div>
</button>
{atom.title}
</span>
<span className="block text-xs text-muted-foreground">{atom.description}</span>
</span>
{/* Chips mark exceptions: active is the normal state and
renders as muted text, only dormant gets a Badge. */}
{tier !== 'horizontal' && (
atom.active ? (
<span className="mt-0.5 shrink-0 text-xs text-muted-foreground">Aktiv</span>
) : (
<Badge variant="outline" className="mt-0.5 shrink-0">Vilande</Badge>
)
)}
</button>
{isOpen && (
<div className="border-t border-border px-4 py-4 pl-11">
{isLoading && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Läser in…
</div>
)}
{!isLoading && body !== undefined && body.length > 0 && (
<div className={PROSE}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
</div>
)}
{!isLoading && body !== undefined && body.length === 0 && (
<p className="text-xs text-muted-foreground">
Innehållet kunde inte läsas in.
</p>
)}
{isOpen && (
<div className="px-1 pb-4 pl-8">
{isLoading && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Läser in…
</div>
)}
</li>
)
})}
</ul>
</section>
))}
</CardContent>
</Card>
{!isLoading && body !== undefined && body.length > 0 && (
<div className={PROSE}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
</div>
)}
{!isLoading && body !== undefined && body.length === 0 && (
<p className="text-xs text-muted-foreground">
Innehållet kunde inte läsas in.
</p>
)}
</div>
)}
</div>
)
})}
</SettingsGroup>
))}
</div>
)
}
+144 -151
View File
@@ -2,7 +2,6 @@
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -18,9 +17,16 @@ import {
import { Checkbox } from '@/components/ui/checkbox'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { EmptyState } from '@/components/ui/empty-state'
import { HelpPopover } from '@/components/ui/help-popover'
import { useToast } from '@/components/ui/use-toast'
import {
SettingsGroup,
SettingsReveal,
SettingsRow,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
import { Loader2, Plus, Copy, Check, Trash2, Key, ChevronDown, AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { cn, formatDateLong } from '@/lib/utils'
import { getBranding } from '@/lib/branding/service'
import { STAGING_SCOPES } from '@/lib/auth/api-keys'
import type { ApiKeyScope } from '@/lib/auth/api-keys'
@@ -366,15 +372,6 @@ export function ApiKeysPanel() {
setTimeout(() => setCopied(false), 2000)
}
function formatDate(iso: string | null) {
if (!iso) return '-'
return new Date(iso).toLocaleDateString('sv-SE', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
const mcpBase = typeof window !== 'undefined'
? `${window.location.origin}/api/extensions/ext/mcp-server/mcp`
: '/api/extensions/ext/mcp-server/mcp'
@@ -383,140 +380,137 @@ export function ApiKeysPanel() {
const mcpUrl = (client: string) => `${mcpBase}?client=${client}`
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">{t('title')}</CardTitle>
<CardDescription>
{t('description')}
</CardDescription>
</div>
<Button
size="sm"
onClick={() => setShowCreateDialog(true)}
disabled={keys.length >= 10}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
{t('create_key')}
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : keys.length === 0 ? (
<EmptyState
icon={Key}
title={t('empty_title')}
description={t('empty_help')}
/>
) : (
<div className="space-y-3">
{keys.map((key) => {
const scopeCount = key.scopes?.length ?? 0
return (
<div
key={key.id}
className="flex items-center justify-between rounded-md border px-4 py-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{key.name}</p>
{key.mode === 'test' && (
<Badge variant="secondary" className="shrink-0 text-[10px] font-normal px-1.5 py-0">
{t('badge_test')}
</Badge>
)}
<span className="text-xs text-muted-foreground">
{scopeCount === ALL_SCOPES.length
? t('all_permissions')
: scopeCount === 0
? t('no_permissions')
: t('permissions_count', { count: scopeCount })}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
<code className="text-xs text-muted-foreground font-mono">
{key.key_prefix}...
</code>
<span className="text-xs text-muted-foreground">
{t('created')} {formatDate(key.created_at)}
</span>
<span className="text-xs text-muted-foreground">
{key.last_used_at
? t('used_on', { date: formatDate(key.last_used_at) })
: t('never_used')}
</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRevoke(key.id, key.name)}
aria-label={t('revoke_aria', { name: key.name })}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
<>
<SettingsGroup>
{/* Group eyebrow with the group's primary action on the right. Styling
mirrors SettingsGroup's label line; the "?" holds the old panel
description. */}
<div className="flex items-center justify-between gap-4 px-1">
<p className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<span>{t('title')}</span>
<HelpPopover className="shrink-0">{t('description')}</HelpPopover>
</p>
<Button
size="sm"
onClick={() => setShowCreateDialog(true)}
disabled={keys.length >= 10}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
{t('create_key')}
</Button>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('connect_mcp_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div>
<div className="flex items-center gap-2 mb-2">
<p className="text-sm font-medium">Claude.ai</p>
<span className="text-xs text-muted-foreground">{t('recommended_badge')}</span>
</div>
<p className="text-xs text-muted-foreground mb-2">
{t.rich('claude_ai_instructions', {
connectorName,
path: (chunks) => <strong>{chunks}</strong>,
})}
</p>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : keys.length === 0 ? (
<EmptyState
icon={Key}
title={t('empty_title')}
description={t('empty_help')}
/>
) : (
keys.map((key) => {
const scopeCount = key.scopes?.length ?? 0
const permissionSummary =
scopeCount === ALL_SCOPES.length
? t('all_permissions')
: scopeCount === 0
? t('no_permissions')
: t('permissions_count', { count: scopeCount })
return (
<div
key={key.id}
className="flex items-center gap-3 border-b border-border px-1 py-3"
>
<div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm">{key.name}</span>
{key.mode === 'test' && (
<Badge variant="secondary" className="shrink-0 px-1.5 py-0 text-[10px] font-normal">
{t('badge_test')}
</Badge>
)}
</span>
<span className="min-w-0 truncate text-xs text-muted-foreground">
{permissionSummary}
{' · '}
<span className="font-mono">{key.key_prefix}...</span>
</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{t('created')} {formatDateLong(key.created_at)}
{' · '}
{key.last_used_at
? t('used_on', { date: formatDateLong(key.last_used_at) })
: t('never_used')}
</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleRevoke(key.id, key.name)}
aria-label={t('revoke_aria', { name: key.name })}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)
})
)}
</SettingsGroup>
<SettingsGroup label={t('connect_mcp_title')}>
<SettingsRow
label="Claude.ai"
align="baseline"
help={t.rich('claude_ai_instructions', {
connectorName,
path: (chunks) => <strong>{chunks}</strong>,
})}
>
<SettingsRowNote>{t('recommended_badge')}</SettingsRowNote>
<div className="w-full min-w-0">
<CopyBlock text={mcpUrl('claude-connector')} copyAriaLabel={t('copy_aria')} />
</div>
</SettingsRow>
<div>
<p className="text-sm font-medium mb-2">{t('claude_code_cursor')}</p>
<p className="text-xs text-muted-foreground mb-2">
{t('terminal_runs_browser_login')}
</p>
{/* URL is quoted: unquoted `?` in the query string trips zsh globbing. */}
<SettingsRow
label={t('claude_code_cursor')}
align="baseline"
help={t('terminal_runs_browser_login')}
>
{/* URL is quoted: unquoted `?` in the query string trips zsh globbing. */}
<div className="w-full min-w-0">
<CopyBlock text={`claude mcp add ${connectorName} --transport http "${mcpUrl('claude-code')}"`} copyAriaLabel={t('copy_aria')} />
</div>
</SettingsRow>
<div className="border-t pt-4">
<button
type="button"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setShowApiKeyMethods(!showApiKeyMethods)}
>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${showApiKeyMethods ? '' : '-rotate-90'}`} />
{t('connect_with_api_key')}
</button>
{showApiKeyMethods && (
<div className="space-y-6 pt-4 animate-in slide-in-from-top-1 duration-150">
<div>
<p className="text-sm font-medium mb-1">Claude Desktop</p>
<p className="text-xs text-muted-foreground mb-2">
{t.rich('claude_desktop_instructions', {
code: (chunks) => <code className="text-xs">{chunks}</code>,
})}
</p>
<CopyBlock text={`{
<button
type="button"
aria-expanded={showApiKeyMethods}
onClick={() => setShowApiKeyMethods(!showApiKeyMethods)}
className="flex items-center gap-2 px-1 py-3 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<ChevronDown
className={cn(
'h-3.5 w-3.5 transition-transform duration-150',
!showApiKeyMethods && '-rotate-90',
)}
/>
{t('connect_with_api_key')}
</button>
<SettingsReveal open={showApiKeyMethods}>
<div className="space-y-6 pb-3 pt-1">
<div>
<p className="mb-1 text-sm">Claude Desktop</p>
<p className="mb-2 text-xs text-muted-foreground">
{t.rich('claude_desktop_instructions', {
code: (chunks) => <code className="text-xs">{chunks}</code>,
})}
</p>
<CopyBlock text={`{
"mcpServers": {
"${connectorName}": {
"command": "npx",
@@ -528,22 +522,20 @@ export function ApiKeysPanel() {
}
}
}`} copyAriaLabel={t('copy_aria')} />
</div>
</div>
<div>
<p className="text-sm font-medium mb-1">{t('claude_code_cursor')}</p>
<p className="text-xs text-muted-foreground mb-2">
{t('terminal_with_api_key')}
</p>
<CopyBlock text={`claude mcp add ${connectorName} --transport http \\
<div>
<p className="mb-1 text-sm">{t('claude_code_cursor')}</p>
<p className="mb-2 text-xs text-muted-foreground">
{t('terminal_with_api_key')}
</p>
<CopyBlock text={`claude mcp add ${connectorName} --transport http \\
--url "${mcpUrl('claude-code')}" \\
--header "Authorization: Bearer gnubok_sk_..."`} copyAriaLabel={t('copy_aria')} />
</div>
</div>
)}
</div>
</div>
</CardContent>
</Card>
</SettingsReveal>
</SettingsGroup>
{/* Create key dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
@@ -697,6 +689,7 @@ export function ApiKeysPanel() {
size="sm"
className="absolute right-2 top-2"
onClick={handleCopy}
aria-label={t('copy_aria')}
>
{copied ? (
<Check className="h-4 w-4 text-success" />
@@ -716,6 +709,6 @@ export function ApiKeysPanel() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</>
)
}
+53 -63
View File
@@ -6,10 +6,14 @@ import { createClient } from '@/lib/supabase/client'
import { BankIdAuth } from '@/components/auth/BankIdAuth'
import type { BankIdResult } from '@/components/auth/BankIdAuth'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Shield, ShieldCheck, Loader2 } from 'lucide-react'
import { Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { formatDateLong } from '@/lib/utils'
import {
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
interface BankIdIdentity {
given_name: string | null
@@ -78,73 +82,59 @@ export function BankIdSettings() {
if (isLoading) {
return (
<Card>
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</CardContent>
</Card>
)
}
if (isLinking) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('link_bankid_title')}</CardTitle>
<CardDescription>{t('link_bankid_description')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center">
<BankIdAuth mode="link" onComplete={handleLinkComplete} />
</CardContent>
</Card>
<SettingsRow label={t('title')}>
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</SettingsRow>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
{identity ? (
<ShieldCheck className="h-4 w-4 text-success" />
) : (
<Shield className="h-4 w-4 text-muted-foreground" />
)}
{t('title')}
</CardTitle>
<CardDescription>
{identity ? t('linked_description') : t('not_linked_description')}
</CardDescription>
</CardHeader>
<CardContent>
<>
<SettingsRow
label={t('title')}
help={identity ? t('linked_description') : t('not_linked_description')}
borderless={isLinking}
>
{identity ? (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{identity.given_name} {identity.surname}
</span>
<span className="ml-2">
{t('linked_on', { date: formatDateLong(identity.linked_at) })}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleUnlink}
disabled={isUnlinking}
className="text-destructive hover:text-destructive"
>
{isUnlinking ? t('unlinking') : t('unlink_button')}
</Button>
</div>
<>
<span className="text-sm font-medium">
{identity.given_name} {identity.surname}
</span>
<SettingsRowNote>
{t('linked_on', { date: formatDateLong(identity.linked_at) })}
</SettingsRowNote>
<SettingsRowEnd>
<Button
variant="ghost"
size="sm"
onClick={handleUnlink}
disabled={isUnlinking}
className="text-destructive hover:text-destructive"
>
{isUnlinking ? t('unlinking') : t('unlink_button')}
</Button>
</SettingsRowEnd>
</>
) : isLinking ? (
// Active flow: the scan instruction is the actionable content and
// stays visible while the QR block below is open.
<SettingsRowNote>{t('link_bankid_description')}</SettingsRowNote>
) : (
<Button
variant="outline"
onClick={() => setIsLinking(true)}
>
{t('link_button')}
</Button>
<SettingsRowEnd>
<Button variant="ghost" size="sm" onClick={() => setIsLinking(true)}>
{t('link_button')}
</Button>
</SettingsRowEnd>
)}
</CardContent>
</Card>
</SettingsRow>
{/* QR flow: an expanding block below the row. Mounted only while
linking so the BankID session starts exactly when requested. */}
{isLinking && (
<div className="flex flex-col items-center border-b border-border px-1 py-4">
<BankIdAuth mode="link" onComplete={handleLinkComplete} />
</div>
)}
</>
)
}
+38 -39
View File
@@ -1,9 +1,10 @@
'use client'
import { useState, type ReactNode } from 'react'
import { useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { SettingsSeg } from '@/components/settings/SettingsRows'
import { formatDateLong } from '@/lib/utils'
import type { BillingPlan } from '@/lib/stripe/client'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -25,8 +26,9 @@ const PRICE: Record<BillingPlan, { amount: string; suffix: string; sub: string;
/**
* Interactive billing CTA. Paying companies get the Stripe Customer Portal
* (manage/cancel); everyone else gets a reactive plan picker + Checkout. Both
* POST to a route that returns a hosted Stripe URL we redirect to.
* (manage/cancel) as a quiet row action; everyone else gets a plan picker
* (SettingsSeg) + Checkout. Both POST to a route that returns a hosted Stripe
* URL we redirect to.
*
* `firstChargeAt`: when the checkout route will defer the first charge to the
* trial's end (see billing/checkout), the date it lands. Shifts the CTA from
@@ -72,7 +74,7 @@ export function BillingActions({
if (isPaying) {
return (
<Button size="lg" onClick={() => go('/api/billing/portal')} disabled={loading} className="w-full sm:w-auto">
<Button variant="outline" size="sm" onClick={() => go('/api/billing/portal')} disabled={loading}>
Hantera abonnemang
</Button>
)
@@ -80,58 +82,55 @@ export function BillingActions({
if (!configured) {
return (
<Button size="lg" disabled className="w-full">
<Button size="lg" disabled className="w-full sm:w-auto">
Uppgradering öppnar snart
</Button>
)
}
const segment = (p: BillingPlan, label: ReactNode) => (
<button
type="button"
onClick={() => setPlan(p)}
className={`flex items-center rounded-md px-3 py-2 transition-colors ${
plan === p ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
{label}
</button>
)
return (
<div className="space-y-4">
<div>
<div className="flex items-baseline gap-2">
<span className="font-display text-3xl tracking-tight tabular-nums">{PRICE[plan].amount}</span>
<span className="text-muted-foreground">{PRICE[plan].suffix}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{PRICE[plan].sub}</p>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<SettingsSeg
value={plan}
onChange={setPlan}
aria-label="Betalningsintervall"
options={[
{ value: 'monthly', label: 'Månadsvis' },
{
value: 'yearly',
label: (
<>
Årsvis
<span className="ml-2 text-muted-foreground">Spara 2 mån</span>
</>
),
},
]}
/>
<p className="text-sm text-muted-foreground">
<span className="tabular-nums text-foreground">{PRICE[plan].amount}</span> {PRICE[plan].suffix}
</p>
</div>
<p className="text-xs text-muted-foreground tabular-nums">{PRICE[plan].sub}</p>
<div className="inline-flex rounded-lg border border-border p-1 text-sm">
{segment('monthly', 'Månadsvis')}
{segment(
'yearly',
<>
Årsvis
<span className="ml-2 text-xs text-muted-foreground">Spara 2 mån</span>
</>,
)}
</div>
<Button size="lg" onClick={() => go('/api/billing/checkout', { plan })} disabled={loading} className="w-full">
<Button
size="lg"
onClick={() => go('/api/billing/checkout', { plan })}
disabled={loading}
className="w-full sm:w-auto"
>
{loading ? 'Öppnar…' : firstChargeAt ? 'Starta abonnemanget: 0 kr idag' : PRICE[plan].cta}
{!loading && <ChevronRight className="h-4 w-4" />}
</Button>
{firstChargeAt && (
<p className="text-xs text-muted-foreground text-center">
// The one deferred-charge line that stays visible (the rest of the
// legal/marketing copy lives behind the group-level "?").
<p className="text-xs text-muted-foreground">
Första debiteringen sker {formatDateLong(firstChargeAt)}, när provperioden slutar. Avslutar du innan dess
kostar det ingenting.
</p>
)}
<p className="text-xs text-muted-foreground text-center">
Ingen bindningstid · Avsluta när du vill · Säker betalning via Stripe
</p>
</div>
)
}
+175 -164
View File
@@ -2,9 +2,9 @@
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { HelpPopover } from '@/components/ui/help-popover'
import { useToast } from '@/components/ui/use-toast'
import {
Dialog,
@@ -13,10 +13,12 @@ import {
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Loader2, Trash2, Plus, ChevronDown, Download, Upload, Building2, Users, Globe, Pencil, Copy } from 'lucide-react'
import { SettingsGroup } from '@/components/settings/SettingsRows'
import { Loader2, Trash2, Plus, ChevronDown, Download, Upload, Pencil, Copy } from 'lucide-react'
import { TEMPLATE_CATEGORY_LABELS, convertLibraryToBookingTemplate } from '@/lib/bookkeeping/template-library'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { TemplateForm } from '@/components/settings/TemplateForm'
import { cn } from '@/lib/utils'
import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types'
export function BookingTemplatesPanel() {
@@ -125,124 +127,131 @@ export function BookingTemplatesPanel() {
return (
<>
<Card>
<CardHeader>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle className="text-base">{t('title')}</CardTitle>
<CardDescription>
{t('description')}
</CardDescription>
</div>
{canWrite && (
<div className="flex gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-3.5 w-3.5 mr-1.5" />
{t('export')}
</Button>
<Button variant="outline" size="sm" onClick={() => importRef.current?.click()}>
<Upload className="h-3.5 w-3.5 mr-1.5" />
{t('import')}
</Button>
<input
ref={importRef}
type="file"
accept=".json"
className="hidden"
onChange={handleImport}
/>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="h-3.5 w-3.5 mr-1.5" />
{t('new_template')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('create_dialog_title')}</DialogTitle>
</DialogHeader>
<TemplateForm
mode="create"
entityLabels={ENTITY_LABELS}
duplicateNamePool={companyTemplateNames}
onSaved={() => {
setShowCreate(false)
fetchTemplates()
}}
/>
</DialogContent>
</Dialog>
</div>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : templates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-12">
{t('empty_state')}
</p>
) : (
<div className="space-y-6">
{/* System templates */}
{systemTemplates.length > 0 && (
<TemplateSection
title={t('section_system')}
icon={Globe}
templates={systemTemplates}
expandedId={expandedId}
onToggle={setExpandedId}
deletingId={deletingId}
onDelete={handleDelete}
canDelete={false}
canEdit={false}
canCustomize={canWrite}
onCustomize={setActiveTemplate}
entityLabels={ENTITY_LABELS}
/>
)}
{/* Team templates */}
{teamTemplates.length > 0 && (
<TemplateSection
title={t('section_team')}
icon={Users}
templates={teamTemplates}
expandedId={expandedId}
onToggle={setExpandedId}
deletingId={deletingId}
onDelete={handleDelete}
canDelete={canWrite}
canEdit={canWrite}
onEdit={setActiveTemplate}
entityLabels={ENTITY_LABELS}
/>
)}
{/* Company templates */}
{companyTemplates.length > 0 && (
<TemplateSection
title={t('section_company')}
icon={Building2}
templates={companyTemplates}
expandedId={expandedId}
onToggle={setExpandedId}
deletingId={deletingId}
onDelete={handleDelete}
canDelete={canWrite}
canEdit={canWrite}
onEdit={setActiveTemplate}
entityLabels={ENTITY_LABELS}
/>
)}
<SettingsGroup>
{/* Group eyebrow with the panel's actions on the right: export/import as
quiet buttons, "Ny mall" as the one pill. The old card description
lives behind the "?". */}
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-1">
<p className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<span>{t('title')}</span>
<HelpPopover className="shrink-0">{t('description')}</HelpPopover>
</p>
{canWrite && (
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={handleExport}
className="text-muted-foreground hover:text-foreground"
>
<Download className="mr-1.5 h-3.5 w-3.5" />
{t('export')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => importRef.current?.click()}
className="text-muted-foreground hover:text-foreground"
>
<Upload className="mr-1.5 h-3.5 w-3.5" />
{t('import')}
</Button>
<input
ref={importRef}
type="file"
accept=".json"
className="hidden"
onChange={handleImport}
/>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="mr-1.5 h-3.5 w-3.5" />
{t('new_template')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('create_dialog_title')}</DialogTitle>
</DialogHeader>
<TemplateForm
mode="create"
entityLabels={ENTITY_LABELS}
duplicateNamePool={companyTemplateNames}
onSaved={() => {
setShowCreate(false)
fetchTemplates()
}}
/>
</DialogContent>
</Dialog>
</div>
)}
</CardContent>
</Card>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : templates.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
{t('empty_state')}
</p>
) : null}
</SettingsGroup>
{!isLoading && templates.length > 0 && (
<>
{/* System templates */}
{systemTemplates.length > 0 && (
<TemplateSection
title={t('section_system')}
templates={systemTemplates}
expandedId={expandedId}
onToggle={setExpandedId}
deletingId={deletingId}
onDelete={handleDelete}
canDelete={false}
canEdit={false}
canCustomize={canWrite}
onCustomize={setActiveTemplate}
entityLabels={ENTITY_LABELS}
/>
)}
{/* Team templates */}
{teamTemplates.length > 0 && (
<TemplateSection
title={t('section_team')}
templates={teamTemplates}
expandedId={expandedId}
onToggle={setExpandedId}
deletingId={deletingId}
onDelete={handleDelete}
canDelete={canWrite}
canEdit={canWrite}
onEdit={setActiveTemplate}
entityLabels={ENTITY_LABELS}
/>
)}
{/* Company templates */}
{companyTemplates.length > 0 && (
<TemplateSection
title={t('section_company')}
templates={companyTemplates}
expandedId={expandedId}
onToggle={setExpandedId}
deletingId={deletingId}
onDelete={handleDelete}
canDelete={canWrite}
canEdit={canWrite}
onEdit={setActiveTemplate}
entityLabels={ENTITY_LABELS}
/>
)}
</>
)}
{/* Shared edit / customize dialog. Editing a company or team template uses
PUT; customizing a read-only system template creates a company-scoped
@@ -276,7 +285,6 @@ export function BookingTemplatesPanel() {
function TemplateSection({
title,
icon: Icon,
templates,
expandedId,
onToggle,
@@ -290,7 +298,6 @@ function TemplateSection({
entityLabels,
}: {
title: string
icon: React.ComponentType<{ className?: string }>
templates: BookingTemplateLibrary[]
expandedId: string | null
onToggle: (id: string | null) => void
@@ -304,52 +311,54 @@ function TemplateSection({
entityLabels: Record<string, string>
}) {
const t = useTranslations('settings_booking_templates')
const tCommon = useTranslations('common')
return (
<div>
<div className="flex items-center gap-2 mb-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">{title}</h3>
<span className="text-xs text-muted-foreground tabular-nums">{templates.length}</span>
</div>
<div className="space-y-1">
<SettingsGroup>
{/* Origin eyebrow with count; mirrors SettingsGroup's label line. */}
<p className="flex items-center gap-2 px-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<span>{title}</span>
<span className="tabular-nums">{templates.length}</span>
</p>
<div>
{templates.map((tt) => {
const isExpanded = expandedId === tt.id
const isConvertible = convertLibraryToBookingTemplate(tt) !== null
return (
<div
key={tt.id}
className="rounded-lg border"
>
<div className="flex items-center gap-3 p-3 hover:bg-muted/50 transition-colors">
<div key={tt.id} className="border-b border-border">
<div className="flex items-center gap-3 px-1 py-3 transition-colors duration-150 hover:bg-secondary/60">
<button
type="button"
onClick={() => onToggle(isExpanded ? null : tt.id)}
className="flex items-center gap-3 flex-1 min-w-0 text-left"
aria-expanded={isExpanded}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
>
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-0' : '-rotate-90'}`} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{tt.name}</span>
<div className="flex items-center gap-1.5 mt-0.5 flex-wrap">
<span className="text-xs text-muted-foreground">
{TEMPLATE_CATEGORY_LABELS[tt.category]}
{tt.entity_type !== 'all' && ` · ${entityLabels[tt.entity_type]}`}
</span>
{!isConvertible && (
<Badge variant="warning" className="text-[10px] px-1.5 py-0">
{t('unconvertible_badge')}
</Badge>
)}
</div>
</div>
<ChevronDown
className={cn(
'h-4 w-4 shrink-0 text-muted-foreground transition-transform',
!isExpanded && '-rotate-90',
)}
/>
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="truncate text-sm">{tt.name}</span>
<span className="text-xs text-muted-foreground">
{TEMPLATE_CATEGORY_LABELS[tt.category]}
{tt.entity_type !== 'all' && ` · ${entityLabels[tt.entity_type]}`}
</span>
{!isConvertible && (
<Badge variant="warning" className="px-1.5 py-0 text-[10px]">
{t('unconvertible_badge')}
</Badge>
)}
</span>
</button>
{canCustomize && onCustomize && (
<Button
variant="ghost"
size="sm"
size="icon"
onClick={() => onCustomize(tt)}
aria-label={t('customize')}
title={t('customize')}
className="h-8 w-8 p-0 shrink-0"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
>
<Copy className="h-3.5 w-3.5" />
</Button>
@@ -357,11 +366,11 @@ function TemplateSection({
{canEdit && onEdit && (
<Button
variant="ghost"
size="sm"
size="icon"
onClick={() => onEdit(tt)}
aria-label={t('edit')}
title={t('edit')}
className="h-8 w-8 p-0 shrink-0"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
@@ -369,10 +378,12 @@ function TemplateSection({
{canDelete && (
<Button
variant="ghost"
size="sm"
size="icon"
onClick={() => onDelete(tt.id)}
disabled={deletingId === tt.id}
className="h-8 w-8 p-0 shrink-0"
aria-label={tCommon('delete')}
title={tCommon('delete')}
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
>
{deletingId === tt.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
@@ -383,23 +394,23 @@ function TemplateSection({
)}
</div>
{isExpanded && (
<div className="px-3 pb-3 pt-0">
<div className="px-1 pb-3">
{tt.description && (
<p className="text-xs text-muted-foreground mb-2">{tt.description}</p>
<p className="mb-2 text-xs text-muted-foreground">{tt.description}</p>
)}
<table className="w-full text-xs">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
<th className="text-left py-1 w-14">{t('th_account')}</th>
<th className="text-left py-1">{t('th_description')}</th>
<th className="text-center py-1 w-16">{t('th_type')}</th>
<th className="text-right py-1 w-12">{t('th_debit')}</th>
<th className="text-right py-1 w-12">{t('th_credit')}</th>
<tr className="border-b border-border">
<th className="w-14 py-1 text-left">{t('th_account')}</th>
<th className="py-1 text-left">{t('th_description')}</th>
<th className="w-16 py-1 text-center">{t('th_type')}</th>
<th className="w-12 py-1 text-right">{t('th_debit')}</th>
<th className="w-12 py-1 text-right">{t('th_credit')}</th>
</tr>
</thead>
<tbody>
{tt.lines.map((line: BookingTemplateLibraryLine, i: number) => (
<tr key={i} className="border-b last:border-0">
<tr key={i} className="border-b border-border last:border-0">
<td className="py-1 font-mono">{line.account}</td>
<td className="py-1">{line.label}</td>
<td className="py-1 text-center">
@@ -419,6 +430,6 @@ function TemplateSection({
)
})}
</div>
</div>
</SettingsGroup>
)
}
+97 -138
View File
@@ -2,14 +2,18 @@
import { useTranslations } from 'next-intl'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import { Calendar, Copy, RefreshCw, Loader2, ExternalLink, Check } from 'lucide-react'
import { Calendar, Copy, RefreshCw, Loader2, Check } from 'lucide-react'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import {
SettingsGroup,
SettingsInput,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
import type { CalendarFeed } from '@/types'
interface CalendarFeedWithUrls extends CalendarFeed {
@@ -30,7 +34,6 @@ export function CalendarFeedSettings() {
useEffect(() => {
fetchFeed()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const fetchFeed = async () => {
@@ -162,7 +165,7 @@ export function CalendarFeedSettings() {
if (isLoading) {
return (
<div className="flex items-center justify-center h-32">
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)
@@ -170,177 +173,133 @@ export function CalendarFeedSettings() {
if (!feed) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
{t('title')}
</CardTitle>
<CardDescription>
{t('description')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center space-y-4 py-4">
<p className="text-muted-foreground">
{t('empty_intro')}
</p>
<Button onClick={createFeed} disabled={isSaving}>
<SettingsGroup label={t('title')} help={t('description')}>
<SettingsRow label={t('activate_sync')} help={t('empty_intro')}>
<SettingsRowEnd>
<Button variant="ghost" size="sm" onClick={createFeed} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
{t('creating')}
</>
) : (
<>
<Calendar className="mr-2 h-4 w-4" />
<Calendar className="mr-2 h-3.5 w-3.5" />
{t('activate_sync')}
</>
)}
</Button>
</div>
</CardContent>
</Card>
</SettingsRowEnd>
</SettingsRow>
</SettingsGroup>
)
}
return (
<div className="space-y-6">
<>
{/* Feed URL */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
{t('title')}
</CardTitle>
<CardDescription>
{t('subscribe_description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Quick add for Apple Calendar */}
<div className="flex gap-2">
<Button onClick={openWebcal} className="flex-1">
<Calendar className="mr-2 h-4 w-4" />
<SettingsGroup label={t('title')} help={t('subscribe_description')}>
<SettingsRow
label={t('calendar_link_label')}
htmlFor="calendar-feed-url"
help={t('calendar_link_help')}
align="baseline"
>
<SettingsInput
id="calendar-feed-url"
value={feed.httpsUrl}
readOnly
className="font-mono text-xs"
/>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(feed.httpsUrl)}
aria-label={t('calendar_link_help')}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
<SettingsRowEnd>
<Button variant="ghost" size="sm" onClick={openWebcal}>
<Calendar className="mr-2 h-3.5 w-3.5" />
{t('add_to_apple_calendar')}
</Button>
<Button variant="outline" onClick={() => copyToClipboard(feed.httpsUrl)}>
{copied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</SettingsRowEnd>
</SettingsRow>
{/* URL display */}
<div className="space-y-2">
<Label>{t('calendar_link_label')}</Label>
<div className="flex gap-2">
<Input
value={feed.httpsUrl}
readOnly
className="font-mono text-xs"
/>
</div>
<p className="text-xs text-muted-foreground">
{t('calendar_link_help')}
</p>
</div>
{/* Stats */}
<SettingsRow label={t('create_new_link')} help={t('regen_help')}>
{/* Live feed stats stay visible: they are state, not instructions */}
{feed.last_accessed_at && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{t('last_fetched')}</span>
<span>
{new Date(feed.last_accessed_at).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})}
</span>
<span className="text-muted-foreground/30">·</span>
<span className="tabular-nums">
{t('times_count', { count: feed.access_count })}
</span>
</div>
<SettingsRowNote className="tabular-nums">
{t('last_fetched')}{' '}
{new Date(feed.last_accessed_at).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})}
{' · '}
{t('times_count', { count: feed.access_count })}
</SettingsRowNote>
)}
{/* Regenerate link */}
<div className="pt-2 border-t">
<SettingsRowEnd>
<Button
variant="outline"
variant="ghost"
size="sm"
onClick={regenerateToken}
disabled={isRegenerating}
>
{isRegenerating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
{t('creating_new_link')}
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
<RefreshCw className="mr-2 h-3.5 w-3.5" />
{t('create_new_link')}
</>
)}
</Button>
<p className="text-xs text-muted-foreground mt-1">
{t('regen_help')}
</p>
</div>
</CardContent>
</Card>
</SettingsRowEnd>
</SettingsRow>
</SettingsGroup>
{/* Content settings */}
<Card>
<CardHeader>
<CardTitle>{t('content_title')}</CardTitle>
<CardDescription>
{t('content_description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="include-tax">{t('tax_deadlines_label')}</Label>
<p className="text-sm text-muted-foreground">
{t('tax_deadlines_help')}
</p>
</div>
<Switch
id="include-tax"
checked={feed.include_tax_deadlines}
onCheckedChange={(checked) =>
updateFeed('include_tax_deadlines', checked)
}
disabled={isSaving}
/>
</div>
<SettingsGroup label={t('content_title')} help={t('content_description')}>
<SettingsRow
label={t('tax_deadlines_label')}
htmlFor="include-tax"
help={t('tax_deadlines_help')}
>
<Switch
id="include-tax"
checked={feed.include_tax_deadlines}
onCheckedChange={(checked) =>
updateFeed('include_tax_deadlines', checked)
}
disabled={isSaving}
/>
</SettingsRow>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="include-invoices">{t('invoices_label')}</Label>
<p className="text-sm text-muted-foreground">
{t('invoices_help')}
</p>
</div>
<Switch
id="include-invoices"
checked={feed.include_invoices}
onCheckedChange={(checked) =>
updateFeed('include_invoices', checked)
}
disabled={isSaving}
/>
</div>
</CardContent>
</Card>
<SettingsRow
label={t('invoices_label')}
htmlFor="include-invoices"
help={t('invoices_help')}
>
<Switch
id="include-invoices"
checked={feed.include_invoices}
onCheckedChange={(checked) =>
updateFeed('include_invoices', checked)
}
disabled={isSaving}
/>
</SettingsRow>
</SettingsGroup>
<DestructiveConfirmDialog {...confirmDialogProps} />
</div>
</>
)
}
+27 -17
View File
@@ -16,6 +16,12 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { RetentionNotice } from '@/components/ui/retention-notice'
import {
SettingsDangerZone,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
import { Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getBranding } from '@/lib/branding/service'
@@ -36,6 +42,7 @@ const branding = getBranding()
*/
export function CompanyDangerZone() {
const t = useTranslations('settings_company')
const tRetention = useTranslations('retention_notice')
const router = useRouter()
const { toast } = useToast()
const { company, role } = useCompany()
@@ -82,23 +89,26 @@ export function CompanyDangerZone() {
return (
<>
<section className="space-y-4 border-t border-border pt-8">
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive">
{t('danger_heading')}
</h2>
<RetentionNotice variant="company" />
<div className="flex justify-end">
<Button
variant="destructive"
className="w-full sm:w-auto"
onClick={() => setShowDialog(true)}
>
{t('danger_button')}
</Button>
</div>
</section>
<SettingsDangerZone label={t('danger_heading')}>
<SettingsRow
label={t('danger_button')}
borderless
// The full BFL retention copy (incl. the backup link) lives behind
// the "?": the visible row stays one quiet line.
help={<RetentionNotice variant="company" className="border-0 bg-transparent p-0" />}
>
<SettingsRowNote>{tRetention('company_title')}</SettingsRowNote>
<SettingsRowEnd>
<button
type="button"
onClick={() => setShowDialog(true)}
className="text-sm font-medium text-destructive underline underline-offset-2 transition-colors duration-150 hover:text-destructive/80"
>
{t('danger_button')}
</button>
</SettingsRowEnd>
</SettingsRow>
</SettingsDangerZone>
<Dialog
open={showDialog}
+66 -83
View File
@@ -1,8 +1,11 @@
'use client'
import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
SettingsGroup,
SettingsInput,
SettingsRow,
} from '@/components/settings/SettingsRows'
import type { CompanySettings } from '@/types'
interface CompanyInfoFormProps {
@@ -11,96 +14,76 @@ interface CompanyInfoFormProps {
export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
const t = useTranslations('settings_company')
const orgLocked = settings.onboarding_complete === true
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('company_info_heading')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="company_name">{t('company_name_label')}</Label>
<Input
id="company_name"
name="company_name"
defaultValue={settings.company_name || ''}
/>
<p className="text-xs text-muted-foreground">
{t('company_name_help')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="org_number">{t('org_number_label')}</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">{t('org_number_locked')}</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="address_line1">{t('address_label')}</Label>
<Input
<SettingsGroup label={t('company_info_heading')}>
<SettingsRow
label={t('company_name_label')}
htmlFor="company_name"
help={t('company_name_help')}
align="baseline"
>
<SettingsInput
id="company_name"
name="company_name"
defaultValue={settings.company_name || ''}
/>
</SettingsRow>
<SettingsRow
label={t('org_number_label')}
htmlFor="org_number"
help={orgLocked ? t('org_number_locked') : undefined}
align="baseline"
>
<SettingsInput
id="org_number"
name="org_number"
defaultValue={settings.org_number || ''}
disabled={orgLocked}
/>
</SettingsRow>
<SettingsRow label={t('address_label')} htmlFor="address_line1" align="baseline">
<SettingsInput
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">{t('postal_code_label')}</Label>
<Input
id="postal_code"
name="postal_code"
defaultValue={settings.postal_code || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="city">{t('city_label')}</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">{t('phone_label')}</Label>
<Input
id="phone"
name="phone"
type="tel"
defaultValue={settings.phone || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">{t('email_label')}</Label>
<Input
id="email"
name="email"
type="email"
defaultValue={settings.email || ''}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="website">{t('website_label')}</Label>
<Input
</SettingsRow>
<SettingsRow label={t('postal_code_label')} htmlFor="postal_code" align="baseline">
<SettingsInput
id="postal_code"
name="postal_code"
defaultValue={settings.postal_code || ''}
className="max-w-24 flex-none"
/>
</SettingsRow>
<SettingsRow label={t('city_label')} htmlFor="city" align="baseline">
<SettingsInput id="city" name="city" defaultValue={settings.city || ''} />
</SettingsRow>
<SettingsRow label={t('phone_label')} htmlFor="phone" align="baseline">
<SettingsInput
id="phone"
name="phone"
type="tel"
defaultValue={settings.phone || ''}
/>
</SettingsRow>
<SettingsRow label={t('email_label')} htmlFor="email" align="baseline">
<SettingsInput
id="email"
name="email"
type="email"
defaultValue={settings.email || ''}
/>
</SettingsRow>
<SettingsRow label={t('website_label')} htmlFor="website" align="baseline">
<SettingsInput
id="website"
name="website"
defaultValue={settings.website || ''}
placeholder="https://"
/>
</div>
</section>
</SettingsRow>
</SettingsGroup>
)
}
+126 -161
View File
@@ -2,15 +2,17 @@
import { useState, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import { useCompany } from '@/contexts/CompanyContext'
import {
SettingsGroup,
SettingsInput,
SettingsRowNote,
SettingsSelect,
} from '@/components/settings/SettingsRows'
import { formatDateLong } from '@/lib/utils'
import { Loader2, Plus, Trash2, Mail, Clock, Users } from 'lucide-react'
import { Loader2, Plus, Trash2, Mail } from 'lucide-react'
interface CompanyMemberItem {
id: string
@@ -159,164 +161,127 @@ export function CompanyMembersSection() {
}
return (
<div className="space-y-6">
{/* Invite form */}
{canInvite && (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('members_invite_title', { companyName: company?.name ?? '' })}</CardTitle>
<CardDescription>
{t('members_invite_description')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleInvite} className="flex flex-col gap-3 sm:flex-row">
<div className="flex-1">
<Label htmlFor="company-invite-email" className="sr-only">{t('members_invite_email_label')}</Label>
<Input
id="company-invite-email"
type="email"
placeholder={t('members_invite_email_placeholder')}
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
disabled={isSending}
required
/>
</div>
<Select value={inviteRole} onValueChange={setInviteRole}>
<SelectTrigger className="w-full sm:w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="viewer">{t('members_role_viewer')}</SelectItem>
<SelectItem value="member">{t('members_role_member')}</SelectItem>
<SelectItem value="admin">{t('members_role_admin')}</SelectItem>
</SelectContent>
</Select>
<Button type="submit" disabled={isSending || !inviteEmail.trim()}>
{isSending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Plus className="h-4 w-4 mr-1.5" />
{t('members_invite_button')}
</>
)}
</Button>
</form>
</CardContent>
</Card>
)}
{/* Members list */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-4 w-4" />
{t('members_title')}
</CardTitle>
<CardDescription>
{t('members_count', { count: members.length, companyName: company?.name ?? '' })}
</CardDescription>
</CardHeader>
<CardContent>
<div className="divide-y divide-border/40">
{members.map((member) => (
<div key={member.id} className="flex items-center justify-between py-3 first:pt-0 last:pb-0">
<div className="flex items-center gap-3 min-w-0">
<div className="h-8 w-8 rounded-full bg-muted/60 flex items-center justify-center flex-shrink-0">
<span className="text-xs font-medium text-muted-foreground">
{member.email.charAt(0).toUpperCase()}
</span>
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{member.email}
{member.is_current_user && (
<span className="text-muted-foreground font-normal ml-1">{t('members_you')}</span>
)}
</p>
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">
{roleLabels[member.role] || member.role}
</span>
{member.source === 'team' && (
<span className="text-xs text-muted-foreground">· {t('members_team_badge')}</span>
)}
</div>
</div>
</div>
{canInvite && !member.is_current_user && member.role !== 'owner' && member.source !== 'team' && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveMember(member.id)}
disabled={removingId === member.id}
>
{removingId === member.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
))}
<SettingsGroup label={t('members_title')} help={t('members_invite_description')}>
{/* Member rows: flat hairline list, no cards. */}
{members.map((member) => (
<div
key={member.id}
className="flex items-center gap-3 border-b border-border px-1 py-3"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted/60">
<span className="text-xs font-medium text-muted-foreground">
{member.email.charAt(0).toUpperCase()}
</span>
</div>
</CardContent>
</Card>
<p className="min-w-0 flex-1 truncate text-sm">
{member.email}
{member.is_current_user && (
<span className="ml-1 text-muted-foreground">{t('members_you')}</span>
)}
</p>
<span className="shrink-0 text-xs text-muted-foreground">
{roleLabels[member.role] || member.role}
{member.source === 'team' && <> · {t('members_team_badge')}</>}
</span>
{canInvite && !member.is_current_user && member.role !== 'owner' && member.source !== 'team' && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
aria-label={t('members_removed')}
onClick={() => handleRemoveMember(member.id)}
disabled={removingId === member.id}
>
{removingId === member.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
))}
{/* Pending invitations */}
{invitations.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('invitations_pending_title')}</CardTitle>
</CardHeader>
<CardContent>
<div className="divide-y divide-border/40">
{invitations.map((inv) => (
<div key={inv.id} className="flex items-center justify-between py-3 first:pt-0 last:pb-0">
<div className="flex items-center gap-3 min-w-0">
<div className="h-8 w-8 rounded-full bg-muted/60 flex items-center justify-center flex-shrink-0">
<Mail className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{inv.email}</p>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>
{t('invitations_expires', { date: formatDateLong(inv.expires_at) })}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{roleLabels[inv.role] || inv.role}
</span>
{canInvite && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => handleRevokeInvite(inv.id)}
disabled={revokingId === inv.id}
>
{revokingId === inv.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Pending invitations continue the same list, visually quieter. */}
{invitations.map((inv) => (
<div
key={inv.id}
className="flex items-center gap-3 border-b border-border px-1 py-3"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-dashed border-border">
<Mail className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<p className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
{inv.email}
<span className="ml-1 text-xs">
· {t('invitations_expires', { date: formatDateLong(inv.expires_at) })}
</span>
</p>
<span className="shrink-0 text-xs text-muted-foreground">
{roleLabels[inv.role] || inv.role}
</span>
{canInvite && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
aria-label={t('members_invite_revoked')}
onClick={() => handleRevokeInvite(inv.id)}
disabled={revokingId === inv.id}
>
{revokingId === inv.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
))}
{/* Inline invite: the list's own last row instead of a separate card. */}
{canInvite && (
<form onSubmit={handleInvite} className="flex flex-col gap-3 px-1 pt-3 sm:flex-row sm:items-center">
<label htmlFor="company-invite-email" className="sr-only">
{t('members_invite_email_label')}
</label>
<SettingsInput
id="company-invite-email"
type="email"
placeholder={t('members_invite_email_placeholder')}
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
disabled={isSending}
required
className="border-border sm:flex-1"
/>
<SettingsSelect
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value)}
aria-label={t('members_invite_email_label')}
>
<option value="viewer">{t('members_role_viewer')}</option>
<option value="member">{t('members_role_member')}</option>
<option value="admin">{t('members_role_admin')}</option>
</SettingsSelect>
<Button type="submit" size="sm" disabled={isSending || !inviteEmail.trim()}>
{isSending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Plus className="mr-2 h-4 w-4" />
{t('members_invite_button')}
</>
)}
</Button>
</form>
)}
</div>
<p className="px-1 pt-3">
<SettingsRowNote>
{t('members_count', { count: members.length, companyName: company?.name ?? '' })}
</SettingsRowNote>
</p>
</SettingsGroup>
)
}
+48 -49
View File
@@ -7,10 +7,14 @@ import { useCompany } from '@/contexts/CompanyContext'
import { CompanyProfileView } from '@/components/settings/CompanyProfileView'
import { refreshCompanyProfileAction } from '@/lib/company/tic-refresh'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import {
SettingsGroup,
SettingsInput,
SettingsRow,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
import { formatDateLong } from '@/lib/utils'
type Snapshot = Parameters<typeof CompanyProfileView>[0]['snapshot']
@@ -24,9 +28,9 @@ const ERROR_MESSAGES: Record<string, string> = {
// Företagsprofil: the cached TIC company snapshot (Bolagsuppgifter), rendered
// as a read-only section on the Företag tab. Fetched client-side (low-traffic
// settings) so it sits alongside the client-rendered company form. RLS scopes
// the read to the user's own company. The "Hämta" form lets the user (re)fetch
// live when the snapshot is missing or wrong: the recovery path for an enskild
// firma whose personnummer previously resolved to the wrong entity.
// the read to the user's own company. The trailing "Hämta" row lets the user
// (re)fetch live when the snapshot is missing or wrong: the recovery path for
// an enskild firma whose personnummer previously resolved to the wrong entity.
export function CompanyProfileSection() {
const { company } = useCompany()
const [snapshot, setSnapshot] = useState<Snapshot>(null)
@@ -76,49 +80,44 @@ export function CompanyProfileSection() {
if (loading) return <Skeleton className="h-48 w-full rounded-lg" />
return (
<div className="space-y-4">
<CompanyProfileView snapshot={snapshot} fetchedAt={fetchedAt} />
<SettingsGroup label="Bolagsuppgifter">
<CompanyProfileView snapshot={snapshot} />
<Card>
<CardHeader>
<CardTitle className="text-base">
{snapshot ? 'Uppdatera bolagsuppgifter' : 'Hämta bolagsuppgifter'}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleFetch} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="tic_org_number">Organisationsnummer eller personnummer</Label>
<div className="flex gap-2">
<Input
id="tic_org_number"
value={orgInput}
onChange={(e) => setOrgInput(e.target.value)}
placeholder="XXXXXX-XXXX"
inputMode="numeric"
autoComplete="off"
className="max-w-xs tabular-nums"
/>
<Button type="submit" disabled={submitting || !orgInput.trim()}>
{submitting ? (
<>
<Loader2 className="animate-spin" />
Hämtar…
</>
) : (
'Hämta'
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Uppgifterna hämtas från Bolagsverket. För enskild firma anges
personnumret.
</p>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
</form>
</CardContent>
</Card>
</div>
<SettingsRow
label={snapshot ? 'Uppdatera bolagsuppgifter' : 'Hämta bolagsuppgifter'}
help="Uppgifterna hämtas från Bolagsverket. För enskild firma anges personnumret."
borderless
>
<form
onSubmit={handleFetch}
className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1"
>
<SettingsInput
id="tic_org_number"
aria-label="Organisationsnummer eller personnummer"
value={orgInput}
onChange={(e) => setOrgInput(e.target.value)}
placeholder="XXXXXX-XXXX"
inputMode="numeric"
autoComplete="off"
className="max-w-xs tabular-nums"
/>
<Button type="submit" size="sm" disabled={submitting || !orgInput.trim()}>
{submitting ? (
<>
<Loader2 className="animate-spin" />
Hämtar…
</>
) : (
'Hämta'
)}
</Button>
{fetchedAt && (
<SettingsRowNote>Uppdaterad {formatDateLong(fetchedAt)}</SettingsRowNote>
)}
{error && <span className="basis-full text-xs text-destructive">{error}</span>}
</form>
</SettingsRow>
</SettingsGroup>
)
}
+147 -177
View File
@@ -1,9 +1,10 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { formatDate, formatDateLong } from '@/lib/utils'
import { formatDate } from '@/lib/utils'
import { SettingsRow, SettingsRowNote } from '@/components/settings/SettingsRows'
// Read-only "Bolagsuppgifter" view of the cached TIC company profile
// (companies.tic_snapshot). Lives in core: reads the snapshot as plain
// (companies.tic_snapshot), rendered as flat settings rows inside the
// section's SettingsGroup. Lives in core: reads the snapshot as plain
// JSON rather than importing the TIC extension's types, so the
// core-build CI boundary (no core → @/extensions/) stays intact.
//
@@ -65,37 +66,14 @@ function cleanSignatory(raw: string): string[] {
.filter((s) => s.length > 0)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-2">
{title}
</h2>
{children}
</section>
)
}
export function CompanyProfileView({
snapshot,
fetchedAt,
}: {
snapshot: SnapshotShape | null
fetchedAt: string | null
}) {
export function CompanyProfileView({ snapshot }: { snapshot: SnapshotShape | null }) {
if (!snapshot) {
// Dynamic status (nothing fetched yet): stays visible as a quiet line.
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Bolagsuppgifter</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Inga företagsuppgifter hämtade ännu. Uppgifterna hämtas automatiskt
från Bolagsverket via organisationsnumret.
</p>
</CardContent>
</Card>
<p className="border-b border-border px-1 py-3 text-sm text-muted-foreground">
Inga företagsuppgifter hämtade ännu. Uppgifterna hämtas automatiskt
från Bolagsverket via organisationsnumret.
</p>
)
}
@@ -118,164 +96,156 @@ export function CompanyProfileView({
? `${snapshot.fiscalYear.startMonthDay} till ${snapshot.fiscalYear.endMonthDay}`
: null
// Only show dated status entries: Bolagsverket emits informational
// flags like "Har aldrig varit verksam" with no date that read as
// noise next to the real ones. Plain text, no colour: per the
// design system, semantic colour is data-only and never chrome.
const datedStatuses = (snapshot.statuses ?? []).filter((s) => s.statusDate)
// Flatten every signatory row, clean ">" markers, split run-on
// clauses, and dedupe: the source repeats "Firman tecknas av
// styrelsen" across rows.
const signatoryRules = Array.from(
new Set(
(snapshot.signatory ?? []).flatMap((s) => cleanSignatory(s.description)),
),
)
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Bolagsuppgifter</CardTitle>
{fetchedAt && (
<p className="text-xs text-muted-foreground">
Uppdaterad {formatDateLong(fetchedAt)}
</p>
)}
</CardHeader>
<CardContent className="space-y-8">
{/* Identity */}
<div>
<p className="font-display text-xl tracking-tight">
{snapshot.companyName ?? 'Okänt företag'}
</p>
<p className="text-sm text-muted-foreground tabular-nums">
<>
<SettingsRow label="Företag">
<span className="text-foreground">{snapshot.companyName ?? 'Okänt företag'}</span>
{(snapshot.orgNumber || entityLabel) && (
<SettingsRowNote className="tabular-nums">
{[snapshot.orgNumber, entityLabel].filter(Boolean).join(' · ')}
</p>
{snapshot.address && (
<p className="text-sm text-muted-foreground mt-2">
{[
snapshot.address.street,
[snapshot.address.postalCode, snapshot.address.city].filter(Boolean).join(' '),
]
.filter(Boolean)
.join(', ')}
</p>
)}
</div>
{regBadges.length > 0 && (
<Section title="Registrerat för">
<div className="flex flex-wrap gap-2">
{regBadges.map((b) => (
<Badge key={b} variant="secondary" className="font-normal">{b}</Badge>
))}
</div>
</Section>
</SettingsRowNote>
)}
</SettingsRow>
{Array.isArray(snapshot.sniCodes) && snapshot.sniCodes.length > 0 && (
<Section title="SNI-koder">
<ul className="space-y-1">
{snapshot.sniCodes.map((s) => (
<li key={s.code} className="text-sm tabular-nums">
<span className="text-foreground">{s.code}</span>{' '}
<span className="text-muted-foreground">{s.name}</span>
</li>
))}
</ul>
</Section>
)}
{snapshot.address && (
<SettingsRow label="Adress">
<span className="text-muted-foreground">
{[
snapshot.address.street,
[snapshot.address.postalCode, snapshot.address.city].filter(Boolean).join(' '),
]
.filter(Boolean)
.join(', ')}
</span>
</SettingsRow>
)}
{Array.isArray(snapshot.bankAccounts) && snapshot.bankAccounts.length > 0 && (
<Section title="Bankuppgifter">
<ul className="space-y-1">
{snapshot.bankAccounts.map((b, i) => (
<li key={`${b.type}-${b.accountNumber}-${i}`} className="text-sm tabular-nums">
<span className="text-muted-foreground">{b.type}:</span>{' '}
<span className="text-foreground">{b.accountNumber}</span>
</li>
))}
</ul>
</Section>
)}
{regBadges.length > 0 && (
<SettingsRow label="Registrerat för">
{regBadges.map((b) => (
<Badge key={b} variant="secondary" className="font-normal">{b}</Badge>
))}
</SettingsRow>
)}
{snapshot.purpose && (
<Section title="Verksamhet">
<p className="text-sm leading-6 text-muted-foreground">{snapshot.purpose}</p>
</Section>
)}
{Array.isArray(snapshot.sniCodes) && snapshot.sniCodes.length > 0 && (
<SettingsRow label="SNI-koder" align="baseline">
<ul className="w-full space-y-1">
{snapshot.sniCodes.map((s) => (
<li key={s.code} className="text-sm tabular-nums">
<span className="text-foreground">{s.code}</span>{' '}
<span className="text-muted-foreground">{s.name}</span>
</li>
))}
</ul>
</SettingsRow>
)}
<Section title="Anställda">
<p className="text-sm text-muted-foreground">
{snapshot.employeeRange ?? 'Inga anställda'}
</p>
</Section>
{Array.isArray(snapshot.bankAccounts) && snapshot.bankAccounts.length > 0 && (
<SettingsRow label="Bankuppgifter" align="baseline">
<ul className="w-full space-y-1">
{snapshot.bankAccounts.map((b, i) => (
<li key={`${b.type}-${b.accountNumber}-${i}`} className="text-sm tabular-nums">
<span className="text-muted-foreground">{b.type}:</span>{' '}
<span className="text-foreground">{b.accountNumber}</span>
</li>
))}
</ul>
</SettingsRow>
)}
<Section title="Senaste bokslut">
{snapshot.financials ? (
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
<dt className="text-muted-foreground">Nettoomsättning</dt>
<dd className="text-right tabular-nums">
{snapshot.purpose && (
<SettingsRow label="Verksamhet" align="baseline">
<p className="text-sm leading-6 text-muted-foreground">{snapshot.purpose}</p>
</SettingsRow>
)}
<SettingsRow label="Anställda">
<span className="text-muted-foreground">
{snapshot.employeeRange ?? 'Inga anställda'}
</span>
</SettingsRow>
<SettingsRow label="Senaste bokslut">
{snapshot.financials ? (
<>
<span>
<span className="text-muted-foreground">Nettoomsättning </span>
<span className="tabular-nums">
{snapshot.financials.netSalesK != null
? `${snapshot.financials.netSalesK.toLocaleString('sv-SE')} tkr`
: '-'}
</dd>
<dt className="text-muted-foreground">Rörelseresultat</dt>
<dd className="text-right tabular-nums">
</span>
</span>
<span>
<span className="text-muted-foreground">Rörelseresultat </span>
<span className="tabular-nums">
{snapshot.financials.operatingProfitK != null
? `${snapshot.financials.operatingProfitK.toLocaleString('sv-SE')} tkr`
: '-'}
</dd>
</dl>
) : (
<p className="text-sm text-muted-foreground">Inga finansiella uppgifter tillgängliga.</p>
)}
</Section>
{(() => {
// Only show dated status entries: Bolagsverket emits informational
// flags like "Har aldrig varit verksam" with no date that read as
// noise next to the real ones. Plain text, no colour: per the
// design system, semantic colour is data-only and never chrome.
const datedStatuses = (snapshot.statuses ?? []).filter((s) => s.statusDate)
if (datedStatuses.length === 0) return null
return (
<Section title="Status">
<dl className="space-y-1">
{datedStatuses.map((s, i) => (
<div key={`${s.code}-${i}`} className="flex items-center justify-between gap-3 text-sm">
<dt className={s.isCeased ? 'text-destructive' : 'text-foreground'}>
{s.description ?? s.code ?? '-'}
</dt>
<dd className="text-xs text-muted-foreground tabular-nums">
{formatDate(s.statusDate!)}
</dd>
</div>
))}
</dl>
</Section>
)
})()}
{fyLabel && (
<Section title="Räkenskapsår">
<p className="text-sm tabular-nums text-muted-foreground">Nuvarande: {fyLabel}</p>
</Section>
</span>
</span>
</>
) : (
<span className="text-muted-foreground">Inga finansiella uppgifter tillgängliga.</span>
)}
</SettingsRow>
{(() => {
// Flatten every signatory row, clean ">" markers, split run-on
// clauses, and dedupe: the source repeats "Firman tecknas av
// styrelsen" across rows.
const rules = Array.from(
new Set(
(snapshot.signatory ?? []).flatMap((s) => cleanSignatory(s.description)),
),
)
if (rules.length === 0) return null
return (
<Section title="Firmateckning">
<ul className="space-y-1.5">
{rules.map((rule, i) => (
<li key={i} className="text-sm leading-6 text-muted-foreground">
{rule}
</li>
))}
</ul>
</Section>
)
})()}
{datedStatuses.length > 0 && (
<SettingsRow label="Status" align="baseline">
<dl className="w-full space-y-1">
{datedStatuses.map((s, i) => (
<div key={`${s.code}-${i}`} className="flex items-center justify-between gap-3 text-sm">
<dt className={s.isCeased ? 'text-destructive' : 'text-foreground'}>
{s.description ?? s.code ?? '-'}
</dt>
<dd className="text-xs text-muted-foreground tabular-nums">
{formatDate(s.statusDate!)}
</dd>
</div>
))}
</dl>
</SettingsRow>
)}
{Array.isArray(snapshot.representatives) && snapshot.representatives.length > 0 && (
<Section title="Företrädare">
{fyLabel && (
<SettingsRow label="Räkenskapsår">
<span className="tabular-nums text-muted-foreground">Nuvarande: {fyLabel}</span>
</SettingsRow>
)}
{signatoryRules.length > 0 && (
<SettingsRow label="Firmateckning" align="baseline">
<ul className="w-full space-y-1">
{signatoryRules.map((rule, i) => (
<li key={i} className="text-sm leading-6 text-muted-foreground">
{rule}
</li>
))}
</ul>
</SettingsRow>
)}
{Array.isArray(snapshot.representatives) && snapshot.representatives.length > 0 && (
<SettingsRow label="Företrädare" align="baseline">
<div className="w-full">
{snapshot.board && (
<p className="text-xs text-muted-foreground mb-2">
<p className="mb-2 text-xs text-muted-foreground">
{[
snapshot.board.numberOfBoardMembers != null
? `${snapshot.board.numberOfBoardMembers} styrelseledamot/-ledamöter`
@@ -292,7 +262,7 @@ export function CompanyProfileView({
{snapshot.representatives.map((r, i) => (
<li key={`${r.name}-${i}`} className="flex items-center justify-between gap-3 text-sm">
<span className="text-foreground">{r.name ?? '-'}</span>
<span className="text-xs text-muted-foreground text-right">
<span className="text-right text-xs text-muted-foreground">
{[r.positionType, r.positionStart ? formatDate(r.positionStart) : null]
.filter(Boolean)
.join(' · ')}
@@ -300,9 +270,9 @@ export function CompanyProfileView({
</li>
))}
</ul>
</Section>
)}
</CardContent>
</Card>
</div>
</SettingsRow>
)}
</>
)
}
+138 -154
View File
@@ -2,10 +2,10 @@
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { EmptyState } from '@/components/ui/empty-state'
import { useToast } from '@/components/ui/use-toast'
import { SettingsGroup } from '@/components/settings/SettingsRows'
import { Loader2, Trash2, Users, ChevronDown } from 'lucide-react'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import { formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
@@ -92,166 +92,150 @@ export function CounterpartyTemplatesPanel() {
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
<CardDescription>
{t('description')}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : templates.length === 0 ? (
<EmptyState
icon={Users}
title={t('empty_title')}
description={t('empty_help')}
/>
) : (
<div className="space-y-1">
{templates.map((tt) => {
const isExpanded = expandedId === tt.id
const isMultiLine = tt.line_pattern && tt.line_pattern.length > 0
<SettingsGroup label={t('title')} help={t('description')}>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : templates.length === 0 ? (
<EmptyState
icon={Users}
title={t('empty_title')}
description={t('empty_help')}
/>
) : (
templates.map((tt) => {
const isExpanded = expandedId === tt.id
const isMultiLine = tt.line_pattern && tt.line_pattern.length > 0
return (
<div key={tt.id} className="rounded-md border overflow-hidden">
{/* Clickable summary row */}
<button
type="button"
onClick={() => setExpandedId(isExpanded ? null : tt.id)}
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-muted/50 transition-colors"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{formatCounterpartyName(tt.counterparty_name)}</p>
</div>
<div className="flex items-center gap-1.5 mt-0.5 text-xs text-muted-foreground">
{isMultiLine ? (
<span className="font-mono">
{tt.line_pattern!.filter(lp => lp.type === 'business').map(lp => lp.account).join(', ')}
return (
<div key={tt.id} className="border-b border-border">
{/* Clickable summary row: flat hairline, one line. */}
<button
type="button"
onClick={() => setExpandedId(isExpanded ? null : tt.id)}
aria-expanded={isExpanded}
className="flex w-full items-center gap-3 px-1 py-3 text-left transition-colors duration-150 hover:bg-secondary/60"
>
<div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="truncate text-sm">{formatCounterpartyName(tt.counterparty_name)}</span>
<span className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
{isMultiLine ? (
<span className="font-mono">
{tt.line_pattern!.filter(lp => lp.type === 'business').map(lp => lp.account).join(', ')}
</span>
) : (
<span className="font-mono">
{tt.debit_account}
<span className="text-muted-foreground/50"> → </span>
{tt.credit_account}
</span>
)}
{tt.vat_treatment && (
<span>· {VAT_LABELS[tt.vat_treatment] || tt.vat_treatment}</span>
)}
<span>· {t('times_count', { count: tt.occurrence_count })}</span>
<span className={`tabular-nums ${confidenceColor(Number(tt.confidence))}`}>
{Math.round(Number(tt.confidence) * 100)}%
</span>
<span>· {SOURCE_LABELS[tt.source] || tt.source}</span>
</span>
</div>
<ChevronDown
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-180' : ''}`}
/>
</button>
{/* Expanded detail */}
{isExpanded && (
<div className="space-y-3 px-1 pb-3">
{/* Account lines */}
<div>
<p className="mb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{t('booking_label')}</p>
{isMultiLine ? (
<div className="space-y-1">
{tt.line_pattern!.map((lp, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className="w-14 shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">
{lp.side === 'debit' ? t('debit_label') : t('credit_label')}
</span>
) : (
<>
<span className="font-mono">{tt.debit_account}</span>
<span className="text-muted-foreground/50">→</span>
<span className="font-mono">{tt.credit_account}</span>
</>
)}
{tt.vat_treatment && (
<>
<span className="text-muted-foreground/30">·</span>
<span>{VAT_LABELS[tt.vat_treatment] || tt.vat_treatment}</span>
</>
)}
<span className="text-muted-foreground/30">·</span>
<span>{t('times_count', { count: tt.occurrence_count })}</span>
<span className={`tabular-nums ${confidenceColor(Number(tt.confidence))}`}>
{Math.round(Number(tt.confidence) * 100)}%
</span>
<span className="text-muted-foreground/30">·</span>
<span>{SOURCE_LABELS[tt.source] || tt.source}</span>
</div>
</div>
<ChevronDown className={`h-4 w-4 text-muted-foreground shrink-0 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
</button>
{/* Expanded detail */}
{isExpanded && (
<div className="border-t bg-muted/30 px-4 py-3 space-y-3">
{/* Account lines */}
<div>
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">{t('booking_label')}</p>
{isMultiLine ? (
<div className="space-y-1">
{tt.line_pattern!.map((lp, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className="w-14 shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">
{lp.side === 'debit' ? t('debit_label') : t('credit_label')}
</span>
<span className="font-mono">{formatAccountWithName(lp.account)}</span>
{lp.type === 'vat' && lp.vat_rate && (
<span className="text-muted-foreground">{t('vat_paren', { rate: Math.round(lp.vat_rate * 100) })}</span>
)}
{lp.ratio !== undefined && (
<span className="text-muted-foreground">({Math.round(lp.ratio * 100)}%)</span>
)}
</div>
))}
</div>
) : (
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs">
<span className="w-14 shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">{t('debit_label')}</span>
<span className="font-mono">{formatAccountWithName(tt.debit_account)}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="w-14 shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">{t('credit_label')}</span>
<span className="font-mono">{formatAccountWithName(tt.credit_account)}</span>
</div>
</div>
)}
</div>
{/* Metadata */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs">
{tt.vat_treatment && (
<div className="flex justify-between">
<span className="text-muted-foreground">{t('vat_label')}</span>
<span>{VAT_LABELS[tt.vat_treatment] || tt.vat_treatment}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">{t('occurrence_count_label')}</span>
<span className="tabular-nums">{tt.occurrence_count}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('confidence_label')}</span>
<span className={`tabular-nums ${confidenceColor(Number(tt.confidence))}`}>
{Math.round(Number(tt.confidence) * 100)}%
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('last_seen_label')}</span>
<span>{formatDate(tt.last_seen_date)}</span>
</div>
{tt.counterparty_aliases && tt.counterparty_aliases.length > 1 && (
<div className="col-span-2 flex justify-between">
<span className="text-muted-foreground">{t('aliases_label')}</span>
<span className="text-right truncate ml-4">{tt.counterparty_aliases.slice(0, 3).join(', ')}{tt.counterparty_aliases.length > 3 ? ` +${tt.counterparty_aliases.length - 3}` : ''}</span>
</div>
)}
</div>
{/* Delete */}
<div className="flex justify-end pt-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(tt.id)}
disabled={deletingId === tt.id}
className="text-destructive hover:text-destructive text-xs h-7"
>
{deletingId === tt.id ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : (
<Trash2 className="mr-1.5 h-3 w-3" />
<span className="font-mono">{formatAccountWithName(lp.account)}</span>
{lp.type === 'vat' && lp.vat_rate && (
<span className="text-muted-foreground">{t('vat_paren', { rate: Math.round(lp.vat_rate * 100) })}</span>
)}
{t('delete_button')}
</Button>
{lp.ratio !== undefined && (
<span className="text-muted-foreground">({Math.round(lp.ratio * 100)}%)</span>
)}
</div>
))}
</div>
) : (
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs">
<span className="w-14 shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">{t('debit_label')}</span>
<span className="font-mono">{formatAccountWithName(tt.debit_account)}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="w-14 shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">{t('credit_label')}</span>
<span className="font-mono">{formatAccountWithName(tt.credit_account)}</span>
</div>
</div>
)}
</div>
)
})}
{/* Metadata */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs">
{tt.vat_treatment && (
<div className="flex justify-between">
<span className="text-muted-foreground">{t('vat_label')}</span>
<span>{VAT_LABELS[tt.vat_treatment] || tt.vat_treatment}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">{t('occurrence_count_label')}</span>
<span className="tabular-nums">{tt.occurrence_count}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('confidence_label')}</span>
<span className={`tabular-nums ${confidenceColor(Number(tt.confidence))}`}>
{Math.round(Number(tt.confidence) * 100)}%
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('last_seen_label')}</span>
<span>{formatDate(tt.last_seen_date)}</span>
</div>
{tt.counterparty_aliases && tt.counterparty_aliases.length > 1 && (
<div className="col-span-2 flex justify-between">
<span className="text-muted-foreground">{t('aliases_label')}</span>
<span className="ml-4 truncate text-right">{tt.counterparty_aliases.slice(0, 3).join(', ')}{tt.counterparty_aliases.length > 3 ? ` +${tt.counterparty_aliases.length - 3}` : ''}</span>
</div>
)}
</div>
{/* Delete */}
<div className="flex justify-end pt-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(tt.id)}
disabled={deletingId === tt.id}
className="h-7 text-xs text-destructive hover:text-destructive"
>
{deletingId === tt.id ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : (
<Trash2 className="mr-1.5 h-3 w-3" />
)}
{t('delete_button')}
</Button>
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
)
})
)}
</SettingsGroup>
)
}
+30 -29
View File
@@ -5,10 +5,14 @@ import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl'
import { ExternalLink } from 'lucide-react'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import {
SettingsRow,
SettingsRowEnd,
} from '@/components/settings/SettingsRows'
import { useSettings } from '@/components/settings/useSettings'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { cn } from '@/lib/utils'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
/**
@@ -88,36 +92,33 @@ export function DimensionsToggle() {
}
}
const locked = isSaving || !canWrite
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('settings_heading')}
</h2>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="dimensions-enabled" className="text-sm">
{t('settings_toggle_label')}
</Label>
<p className="text-xs text-muted-foreground max-w-md">
{t('settings_toggle_help')}
</p>
</div>
<Switch
id="dimensions-enabled"
checked={enabled}
onCheckedChange={(next) => void handleChange(next)}
disabled={isSaving || !canWrite}
/>
</div>
<SettingsRow label={t('settings_heading')} help={t('settings_toggle_help')}>
<Switch
id="dimensions-enabled"
checked={enabled}
onCheckedChange={(next) => void handleChange(next)}
disabled={locked}
/>
<label
htmlFor="dimensions-enabled"
className={cn('text-sm', locked ? 'text-muted-foreground' : 'cursor-pointer')}
>
{t('settings_toggle_label')}
</label>
{enabled && (
<Link
href="/dimensions"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('settings_open_register')}
</Link>
<SettingsRowEnd>
<Link
href="/dimensions"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('settings_open_register')}
</Link>
</SettingsRowEnd>
)}
</section>
</SettingsRow>
)
}
+90 -81
View File
@@ -4,19 +4,20 @@ import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useCompany } from '@/contexts/CompanyContext'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { Loader2, Info, Lock } from 'lucide-react'
import { Loader2, Lock } from 'lucide-react'
import { parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
import { validateFirstPeriod } from '@/components/bookkeeping/FiscalPeriodDateFields'
import {
FiscalPeriodDateFields,
validateFirstPeriod,
} from '@/components/bookkeeping/FiscalPeriodDateFields'
SettingsGroup,
SettingsInput,
SettingsRow,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
import type { FiscalPeriod } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -175,76 +176,83 @@ export function FiscalPeriodEditor() {
return (
<>
<section className="space-y-4 border-t border-border pt-8">
<div className="space-y-1">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('fp_heading')}
</h2>
<p className="text-sm text-muted-foreground">
{t('fp_intro')}
</p>
</div>
<SettingsGroup label={t('fp_heading')} help={t('fp_intro')}>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2 px-1 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('fp_loading')}
</div>
) : loadError ? (
<p className="text-sm text-destructive">{loadError}</p>
<p className="px-1 py-3 text-sm text-destructive">{loadError}</p>
) : !period ? (
<p className="text-sm text-muted-foreground">{t('fp_none')}</p>
<p className="px-1 py-3 text-sm text-muted-foreground">{t('fp_none')}</p>
) : isBlocked ? (
<BlockedState
period={period}
postedCount={postedCount ?? 0}
/>
<BlockedRow period={period} postedCount={postedCount ?? 0} />
) : (
<div className="space-y-4">
<div className="rounded-lg border border-warning/20 bg-warning/5 p-3 text-sm flex gap-2">
<Info className="h-4 w-4 text-warning flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="font-medium">{t('fp_warning_title')}</p>
<p className="text-muted-foreground">
{t('fp_warning_body')}
{isEF && t('fp_warning_ef_suffix')}
</p>
</div>
</div>
<>
{/* Consequential warning: stays visible as one quiet ochre sentence. */}
<p className="px-1 py-3 text-[12.5px] text-attn">
{t('fp_warning_title')} {t('fp_warning_body')}
{isEF ? t('fp_warning_ef_suffix') : null}
</p>
<FiscalPeriodDateFields
startDate={startDate}
onStartDateChange={setStartDate}
endDate={endDate}
entityType={company?.entity_type}
summaryTitle={t('fp_summary_title')}
endDateSlot={
<div className="space-y-2">
<Label htmlFor="fp_end">{t('fp_end_date_label')}</Label>
<Input
id="fp_end"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{t('fp_end_date_help')}
</p>
</div>
}
/>
<SettingsRow
label="Startdatum"
htmlFor="fiscal-period-start"
help="Första räkenskapsåret kan börja valfri dag."
align="baseline"
>
<SettingsInput
id="fiscal-period-start"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="max-w-44 flex-none tabular-nums"
/>
</SettingsRow>
<div className="flex justify-end gap-2">
<SettingsRow
label={t('fp_end_date_label')}
htmlFor="fp_end"
help={t('fp_end_date_help')}
align="baseline"
>
<SettingsInput
id="fp_end"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="max-w-44 flex-none tabular-nums"
/>
</SettingsRow>
{validation.canSummarise && (
<p className="px-1 pt-3 text-xs text-muted-foreground">
{t('fp_summary_title')}:{' '}
<span className="tabular-nums">
{formatSwedishDate(startDate)} till {formatSwedishDate(endDate)}
</span>
{validation.months !== null && <> · {validation.months} månader</>}
</p>
)}
{validation.error && (
<p className="px-1 pt-1 text-xs text-destructive">{validation.error}</p>
)}
<div className="flex justify-end gap-2 px-1 pt-3">
<Button
type="button"
variant="outline"
variant="ghost"
size="sm"
onClick={handleReset}
disabled={!isDirty || isSaving}
className="text-muted-foreground hover:text-foreground"
>
{t('fp_reset')}
</Button>
<Button
type="button"
size="sm"
onClick={handleSave}
disabled={
!isDirty ||
@@ -264,15 +272,15 @@ export function FiscalPeriodEditor() {
)}
</Button>
</div>
</div>
</>
)}
</section>
</SettingsGroup>
<DestructiveConfirmDialog {...dialogProps} />
</>
)
}
function BlockedState({
function BlockedRow({
period,
postedCount,
}: {
@@ -287,26 +295,27 @@ function BlockedState({
: t('fp_blocked_reason_posted', { count: postedCount })
return (
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-3">
<div className="flex gap-2">
<Lock className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium">{t('fp_blocked_title')}</p>
<p className="text-sm text-muted-foreground">{reason}</p>
</div>
</div>
<div className="text-sm text-muted-foreground space-y-1">
<p>
{t('fp_blocked_first_year')}{' '}
<span className="font-medium text-foreground">
{formatSwedishDate(period.period_start)} &ndash; {formatSwedishDate(period.period_end)}
</span>
{isCalendarYear(period) ? t('fp_blocked_calendar_year') : t('fp_blocked_broken_year')}
</p>
<p>
{t('fp_blocked_explainer')}
</p>
</div>
</div>
<SettingsRow
// The key carries a trailing colon from its old inline usage; strip it
// for the micro-label position.
label={t('fp_blocked_first_year').replace(/:$/, '')}
help={
<>
<p>
{t('fp_blocked_title')}. {reason}
</p>
<p className="mt-2">{t('fp_blocked_explainer')}</p>
</>
}
borderless
>
<Lock aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="tabular-nums">
{formatSwedishDate(period.period_start)} till {formatSwedishDate(period.period_end)}
</span>
<SettingsRowNote>
{(isCalendarYear(period) ? t('fp_blocked_calendar_year') : t('fp_blocked_broken_year')).trim()}
</SettingsRowNote>
</SettingsRow>
)
}
+80 -76
View File
@@ -9,6 +9,7 @@ import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { SettingsGroup } from '@/components/settings/SettingsRows'
import { useToast } from '@/components/ui/use-toast'
import { useCompany } from '@/contexts/CompanyContext'
import { Plus, Lock, Unlock, Loader2 } from 'lucide-react'
@@ -25,10 +26,11 @@ function periodStatus(p: FiscalPeriod): 'closed' | 'locked' | 'open' {
return 'open'
}
const STATUS_VARIANT: Record<'closed' | 'locked' | 'open', 'secondary' | 'warning' | 'success'> = {
// Open is the normal state and renders as muted text; only the deviations
// (locked/closed) get a chip (UI-migration convention 5).
const STATUS_VARIANT: Record<'closed' | 'locked', 'secondary' | 'warning'> = {
closed: 'secondary',
locked: 'warning',
open: 'success',
}
export function FiscalYearsManager() {
@@ -113,14 +115,83 @@ export function FiscalYearsManager() {
}
return (
<section className="space-y-4">
<div className="flex items-center justify-between gap-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('fy_heading')}
</h2>
<SettingsGroup label={t('fy_heading')} help={t('fy_help')}>
{isLoading ? (
<div className="space-y-2 px-1 py-3">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-40" />
</div>
) : hasError ? (
<p className="px-1 py-3 text-sm text-muted-foreground">{t('fy_load_error')}</p>
) : sorted.length === 0 ? (
<p className="px-1 py-3 text-sm text-muted-foreground">{t('fy_empty')}</p>
) : (
// Period rows: flat hairline list, no cards.
sorted.map((p) => {
const status = periodStatus(p)
const isMutating = mutatingId === p.id
return (
<div key={p.id} className="flex items-center gap-3 border-b border-border px-1 py-3">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium">{p.name}</span>
<span className="ml-2 text-sm text-muted-foreground tabular-nums">
{formatDate(p.period_start)} - {formatDate(p.period_end)}
</span>
</div>
<div className="flex shrink-0 items-center gap-3">
{status === 'open' ? (
<span className="text-xs text-muted-foreground">{t('fy_status_open')}</span>
) : (
<Badge variant={STATUS_VARIANT[status]}>{t(`fy_status_${status}`)}</Badge>
)}
{canManage && status === 'open' && (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
disabled={isMutating}
onClick={() => handleLock(p)}
>
{isMutating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Lock className="mr-1.5 h-4 w-4" />
{t('fy_action_lock')}
</>
)}
</Button>
)}
{canManage && status === 'locked' && (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
disabled={isMutating}
onClick={() => handleUnlock(p)}
>
{isMutating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Unlock className="mr-1.5 h-4 w-4" />
{t('fy_action_unlock')}
</>
)}
</Button>
)}
</div>
</div>
)
})
)}
{/* Trailing quiet action: create the next fiscal year. */}
<div className="px-1 pt-3">
<Button
variant="outline"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => setDialogOpen(true)}
disabled={isLoading}
>
@@ -129,73 +200,6 @@ export function FiscalYearsManager() {
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('fy_help')}</p>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-40" />
</div>
) : hasError ? (
<p className="text-sm text-muted-foreground">{t('fy_load_error')}</p>
) : sorted.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('fy_empty')}</p>
) : (
<div className="divide-y divide-border">
{sorted.map((p) => {
const status = periodStatus(p)
const isMutating = mutatingId === p.id
return (
<div key={p.id} className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0">
<span className="text-sm font-medium">{p.name}</span>
<span className="ml-2 text-sm text-muted-foreground tabular-nums">
{formatDate(p.period_start)} - {formatDate(p.period_end)}
</span>
</div>
<div className="flex items-center gap-3 shrink-0">
<Badge variant={STATUS_VARIANT[status]}>{t(`fy_status_${status}`)}</Badge>
{canManage && status === 'open' && (
<Button
variant="outline"
size="sm"
disabled={isMutating}
onClick={() => handleLock(p)}
>
{isMutating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Lock className="mr-1.5 h-4 w-4" />
{t('fy_action_lock')}
</>
)}
</Button>
)}
{canManage && status === 'locked' && (
<Button
variant="ghost"
size="sm"
disabled={isMutating}
onClick={() => handleUnlock(p)}
>
{isMutating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Unlock className="mr-1.5 h-4 w-4" />
{t('fy_action_unlock')}
</>
)}
</Button>
)}
</div>
</div>
)
})}
</div>
)}
<CreatePeriodDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
@@ -205,6 +209,6 @@ export function FiscalYearsManager() {
/>
<DestructiveConfirmDialog {...dialogProps} />
</section>
</SettingsGroup>
)
}
+22 -18
View File
@@ -4,6 +4,11 @@ import { useEffect, useState, useSyncExternalStore } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { MonitorDown } from 'lucide-react'
import {
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>
@@ -25,14 +30,15 @@ function getIsStandalone() {
}
/**
* "Install as app" section for account settings. Chromium fires
* "Install as app" row for account settings. Chromium fires
* beforeinstallprompt when the PWA is installable; we capture it and offer a
* real install button. Other browsers get per-platform instructions. Hidden
* entirely when already running standalone (installed) or after installing.
* real install button. Other browsers get per-platform instructions as the
* row's visible note (that text IS the action). Hidden entirely when already
* running standalone (installed) or after installing.
*/
export function InstallAppSection() {
const t = useTranslations('settings')
// Server snapshot says standalone so the section is absent from server HTML
// Server snapshot says standalone so the row is absent from server HTML
// and only appears client-side when actually running in a browser tab.
const isStandalone = useSyncExternalStore(subscribeDisplayMode, getIsStandalone, () => true)
const [installed, setInstalled] = useState(false)
@@ -74,21 +80,19 @@ export function InstallAppSection() {
: t('install_app_hint_generic')
return (
<section className="space-y-4 border-t border-border pt-8">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('install_app_title')}
</h2>
<div className="flex items-center justify-between gap-4 p-4 border rounded-lg">
<p className="text-sm text-muted-foreground max-w-md">
{installPrompt ? t('install_app_description') : hint}
</p>
{installPrompt && (
<Button variant="outline" onClick={handleInstall}>
<MonitorDown className="mr-2 h-4 w-4" />
<SettingsRow label={t('install_app_title')} help={t('install_app_description')}>
{installPrompt ? (
<SettingsRowEnd>
<Button variant="ghost" size="sm" onClick={handleInstall}>
<MonitorDown className="mr-2 h-3.5 w-3.5" />
{t('install_app_button')}
</Button>
)}
</div>
</section>
</SettingsRowEnd>
) : (
// No captured prompt: the platform-specific instruction is the only
// way to act, so it stays visible instead of hiding behind the "?".
<SettingsRowNote>{hint}</SettingsRowNote>
)}
</SettingsRow>
)
}
@@ -3,9 +3,12 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/components/ui/use-toast'
import {
SettingsGroup,
SettingsRow,
SettingsTextarea,
} from '@/components/settings/SettingsRows'
import {
EMAIL_PATTERN,
MAX_INVOICE_EMAIL_COPY_RECIPIENTS,
@@ -107,44 +110,41 @@ export function InvoiceEmailRecipientsSettings({
}
return (
<section className="space-y-4">
<div className="space-y-2">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('heading')}
</h2>
<p className="text-sm text-muted-foreground">{t('description')}</p>
</div>
<SettingsGroup label={t('heading')} help={t('description')}>
<SettingsRow
label={t('cc_label')}
htmlFor="invoice-email-cc"
help={t('cc_hint')}
align="baseline"
>
<SettingsTextarea
id="invoice-email-cc"
value={ccText}
onChange={(event) => setCcText(event.target.value)}
placeholder={t('cc_placeholder')}
rows={3}
/>
</SettingsRow>
<SettingsRow
label={t('bcc_label')}
htmlFor="invoice-email-bcc"
help={t('bcc_hint')}
align="baseline"
>
<SettingsTextarea
id="invoice-email-bcc"
value={bccText}
onChange={(event) => setBccText(event.target.value)}
placeholder={t('bcc_placeholder')}
rows={3}
/>
</SettingsRow>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="invoice-email-cc">{t('cc_label')}</Label>
<Textarea
id="invoice-email-cc"
value={ccText}
onChange={(event) => setCcText(event.target.value)}
placeholder={t('cc_placeholder')}
rows={3}
/>
<p className="text-xs text-muted-foreground">{t('cc_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="invoice-email-bcc">{t('bcc_label')}</Label>
<Textarea
id="invoice-email-bcc"
value={bccText}
onChange={(event) => setBccText(event.target.value)}
placeholder={t('bcc_placeholder')}
rows={3}
/>
<p className="text-xs text-muted-foreground">{t('bcc_hint')}</p>
</div>
</div>
<div className="flex justify-end">
<Button type="button" onClick={save} disabled={isSaving}>
<div className="flex justify-end px-1 pt-4">
<Button type="button" size="sm" onClick={save} disabled={isSaving}>
{isSaving ? t('saving') : t('save')}
</Button>
</div>
</section>
</SettingsGroup>
)
}
@@ -2,12 +2,17 @@
import { useCallback, useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import {
SettingsGroup,
SettingsInput,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
SettingsSeg,
SettingsTextarea,
} from '@/components/settings/SettingsRows'
import {
INVOICE_EMAIL_DEFAULT_TEXTS,
INVOICE_EMAIL_PLACEHOLDER_KEYS,
@@ -86,6 +91,7 @@ export function InvoiceEmailTextsSettings({ settings, onUpdate }: InvoiceEmailTe
const t = useTranslations('settings_email_texts')
const { toast } = useToast()
const { canWrite } = useCanWrite()
const [lang, setLang] = useState<Lang>('sv')
const [texts, setTexts] = useState<DisplayTexts>(() => buildDisplay(settings.invoice_email_texts))
// Serialized last-persisted overrides: skips no-op PUTs on blur without
// edits. toOverrides() builds keys in a fixed order, so comparison is stable.
@@ -98,7 +104,8 @@ export function InvoiceEmailTextsSettings({ settings, onUpdate }: InvoiceEmailTe
}
// Whole-object save: a JSONB column update replaces the stored value, and
// the inactive language tab is unmounted, so per-field PATCHes can't work.
// the inactive language's fields are unmounted (conditional render below),
// so per-field PATCHes can't work.
const persist = useCallback(async (display: DisplayTexts) => {
const overrides = toOverrides(display)
const serialized = JSON.stringify(overrides)
@@ -133,78 +140,74 @@ export function InvoiceEmailTextsSettings({ settings, onUpdate }: InvoiceEmailTe
}
return (
<section className="space-y-6">
<div>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('heading')}
</h2>
<p className="mt-1 text-xs text-muted-foreground">{t('description')}</p>
</div>
<Tabs defaultValue="sv">
<TabsList>
<TabsTrigger value="sv">{t('tab_sv')}</TabsTrigger>
<TabsTrigger value="en">{t('tab_en')}</TabsTrigger>
</TabsList>
{LANGS.map((lang) => (
<TabsContent key={lang} value={lang} className="mt-4 space-y-4">
{lang === 'en' && (
<p className="text-xs text-muted-foreground">{t('en_tab_hint')}</p>
)}
{FIELD_CONFIG.map(({ field, labelKey, multiline }) => {
const id = `invoice-email-${field}-${lang}`
const modified =
texts[lang][field].trim() !== INVOICE_EMAIL_DEFAULT_TEXTS[lang][field]
const common = {
id,
value: texts[lang][field],
onBlur: handleBlur,
disabled: !canWrite,
}
return (
<div key={field} className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor={id}>{t(labelKey)}</Label>
{modified && canWrite && (
<button
type="button"
onClick={() => resetField(lang, field)}
className="text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
{t('reset_label')}
</button>
)}
</div>
{multiline ? (
<Textarea
{...common}
rows={4}
onChange={(e) => setField(lang, field, e.target.value)}
/>
) : (
<Input
{...common}
onChange={(e) => setField(lang, field, e.target.value)}
/>
)}
</div>
)
})}
</TabsContent>
))}
</Tabs>
<div className="space-y-1">
{/* Legend is rendered from code, not messages/*.json: ICU message
syntax treats literal braces as interpolation. */}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{t('placeholders_help')}</span>
{INVOICE_EMAIL_PLACEHOLDER_KEYS.map((key) => (
<code key={key} className="rounded bg-muted px-1 text-xs">{`{${key}}`}</code>
))}
<SettingsGroup
label={t('heading')}
help={
<div className="space-y-2">
<p>{t('description')}</p>
{/* Legend is rendered from code, not messages/*.json: ICU message
syntax treats literal braces as interpolation. */}
<p>
{t('placeholders_help')}{' '}
{INVOICE_EMAIL_PLACEHOLDER_KEYS.map((key) => (
<code key={key} className="mr-1 rounded bg-muted px-1 text-xs">{`{${key}}`}</code>
))}
</p>
<p>{t('firstname_note')}</p>
</div>
<p className="text-xs text-muted-foreground">{t('firstname_note')}</p>
}
>
<div className="flex flex-wrap items-center gap-3 border-b border-border px-1 py-3">
<SettingsSeg
value={lang}
onChange={setLang}
options={[
{ value: 'sv', label: t('tab_sv') },
{ value: 'en', label: t('tab_en') },
]}
aria-label={t('heading')}
/>
{lang === 'en' && <SettingsRowNote>{t('en_tab_hint')}</SettingsRowNote>}
</div>
</section>
{FIELD_CONFIG.map(({ field, labelKey, multiline }) => {
const id = `invoice-email-${field}-${lang}`
const modified =
texts[lang][field].trim() !== INVOICE_EMAIL_DEFAULT_TEXTS[lang][field]
const common = {
id,
value: texts[lang][field],
onBlur: handleBlur,
disabled: !canWrite,
}
return (
<SettingsRow key={`${lang}-${field}`} label={t(labelKey)} htmlFor={id} align="baseline">
{multiline ? (
<SettingsTextarea
{...common}
rows={4}
onChange={(e) => setField(lang, field, e.target.value)}
/>
) : (
<SettingsInput
{...common}
onChange={(e) => setField(lang, field, e.target.value)}
/>
)}
{modified && canWrite && (
<SettingsRowEnd>
<button
type="button"
onClick={() => resetField(lang, field)}
className="text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
{t('reset_label')}
</button>
</SettingsRowEnd>
)}
</SettingsRow>
)
})}
</SettingsGroup>
)
}
@@ -3,16 +3,16 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
SettingsGroup,
SettingsInput,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
SettingsSeg,
SettingsSelect,
} from '@/components/settings/SettingsRows'
import { useToast } from '@/components/ui/use-toast'
import { useCompany } from '@/contexts/CompanyContext'
import { validateBankgiroNumber, validatePlusgiroNumber } from '@/lib/bankgiro/luhn'
@@ -282,18 +282,11 @@ export function InvoicePaymentAccountsSettings({
}
return (
<section className="space-y-5">
<div className="space-y-2">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('heading')}
</h2>
<p className="text-sm text-muted-foreground">{t('description')}</p>
</div>
<SettingsGroup label={t('heading')} help={t('description')}>
{hasExternalUpdate && (
<div
role="alert"
className="flex flex-col gap-3 rounded-lg border border-border bg-muted/40 p-4 sm:flex-row sm:items-center sm:justify-between"
className="mt-3 flex flex-col gap-3 rounded-lg border border-border bg-muted/40 p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="space-y-1">
<p className="text-sm font-medium">{t('conflict_title')}</p>
@@ -305,146 +298,165 @@ export function InvoicePaymentAccountsSettings({
</div>
)}
<div className="flex flex-wrap gap-2" aria-label={t('currency_tabs_label')}>
{configuredCurrencies.map((currency) => (
<Button
key={currency}
type="button"
size="sm"
variant={activeCurrency === currency ? 'default' : 'outline'}
onClick={() => setActiveCurrency(currency)}
>
{currency}
</Button>
))}
</div>
<SettingsRow label={t('currency_tabs_label')}>
<SettingsSeg
value={activeCurrency}
onChange={(currency) => setActiveCurrency(currency)}
options={configuredCurrencies.map((currency) => ({ value: currency, label: currency }))}
aria-label={t('currency_tabs_label')}
/>
{activeCurrency !== 'SEK' && (
<SettingsRowNote>
{t('foreign_account_hint', { currency: activeCurrency })}
</SettingsRowNote>
)}
{activeCurrency !== 'SEK' && (
<SettingsRowEnd>
<button
type="button"
onClick={removeActiveCurrency}
className="text-xs text-muted-foreground transition-colors duration-150 hover:text-destructive"
>
{t('remove_currency')}
</button>
</SettingsRowEnd>
)}
</SettingsRow>
{availableCurrencies.length > 0 && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="w-full space-y-2 sm:max-w-52">
<Label>{t('add_currency_label')}</Label>
<Select
value={currencyToAdd}
onValueChange={(next) => setCurrencyToAdd(next as Currency)}
>
<SelectTrigger>
<SelectValue placeholder={t('add_currency_placeholder')} />
</SelectTrigger>
<SelectContent>
{availableCurrencies.map((currency) => (
<SelectItem key={currency} value={currency}>{currency}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="button" variant="outline" onClick={addCurrency} disabled={!currencyToAdd}>
<SettingsRow label={t('add_currency_label')}>
<SettingsSelect
value={currencyToAdd}
onChange={(event) => setCurrencyToAdd(event.target.value as Currency | '')}
aria-label={t('add_currency_label')}
>
<option value="">{t('add_currency_placeholder')}</option>
{availableCurrencies.map((currency) => (
<option key={currency} value={currency}>{currency}</option>
))}
</SettingsSelect>
<Button
type="button"
variant="outline"
size="sm"
onClick={addCurrency}
disabled={!currencyToAdd}
>
{t('add_currency')}
</Button>
</div>
</SettingsRow>
)}
<div className="space-y-4 rounded-lg border border-border p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="font-medium">{t('account_heading', { currency: activeCurrency })}</h3>
{activeCurrency !== 'SEK' && (
<p className="text-xs text-muted-foreground">{t('foreign_account_hint')}</p>
)}
</div>
{activeCurrency !== 'SEK' && (
<Button type="button" variant="ghost" size="sm" onClick={removeActiveCurrency}>
{t('remove_currency')}
</Button>
)}
<SettingsRow label={t('bank_label')}>
{/* Typeahead combobox stays boxed on purpose: it is a picker, not a field. */}
<div className="min-w-0 flex-1 sm:max-w-64">
<BankNameCombobox
value={value(activeAccount, 'bank_name')}
onChange={(next) => updateField('bank_name', next)}
enableBankingEnabled={hasBankingExtension}
/>
</div>
</SettingsRow>
<SettingsRow
label={t('clearing_label')}
htmlFor={`payment-clearing-${activeCurrency}`}
align="baseline"
>
<SettingsInput
id={`payment-clearing-${activeCurrency}`}
inputMode="numeric"
maxLength={5}
value={value(activeAccount, 'clearing_number')}
onChange={(event) => updateField('clearing_number', event.target.value.replace(/\D/g, ''))}
className="max-w-24 flex-none tabular-nums"
/>
</SettingsRow>
<SettingsRow
label={t('account_number_label')}
htmlFor={`payment-account-${activeCurrency}`}
align="baseline"
>
<SettingsInput
id={`payment-account-${activeCurrency}`}
inputMode="numeric"
maxLength={12}
value={value(activeAccount, 'account_number')}
onChange={(event) => updateField('account_number', event.target.value.replace(/\D/g, ''))}
className="max-w-40 flex-none tabular-nums"
/>
</SettingsRow>
<SettingsRow
label={t('bankgiro_label')}
htmlFor={`payment-bankgiro-${activeCurrency}`}
align="baseline"
>
<SettingsInput
id={`payment-bankgiro-${activeCurrency}`}
value={value(activeAccount, 'bankgiro')}
onChange={(event) => updateField('bankgiro', event.target.value)}
className="max-w-40 flex-none tabular-nums"
/>
</SettingsRow>
<SettingsRow
label={t('plusgiro_label')}
htmlFor={`payment-plusgiro-${activeCurrency}`}
align="baseline"
>
<SettingsInput
id={`payment-plusgiro-${activeCurrency}`}
value={value(activeAccount, 'plusgiro')}
onChange={(event) => updateField('plusgiro', event.target.value)}
className="max-w-40 flex-none tabular-nums"
/>
</SettingsRow>
<SettingsRow
label={t('swish_label')}
htmlFor={`payment-swish-${activeCurrency}`}
align="baseline"
>
<SettingsInput
id={`payment-swish-${activeCurrency}`}
value={value(activeAccount, 'swish')}
onChange={(event) => updateField('swish', event.target.value)}
className="max-w-40 flex-none tabular-nums"
/>
</SettingsRow>
<SettingsRow
label={
activeCurrency !== 'SEK'
? `${t('iban_label')} ${t('required_suffix')}`
: t('iban_label')
}
htmlFor={`payment-iban-${activeCurrency}`}
align="baseline"
>
<SettingsInput
id={`payment-iban-${activeCurrency}`}
value={value(activeAccount, 'iban')}
onChange={(event) => updateField('iban', event.target.value.toUpperCase())}
placeholder="SE00 0000 0000 0000 0000 0000"
className="tabular-nums"
/>
</SettingsRow>
<SettingsRow
label={t('bic_label')}
htmlFor={`payment-bic-${activeCurrency}`}
align="baseline"
>
<SettingsInput
id={`payment-bic-${activeCurrency}`}
maxLength={11}
value={value(activeAccount, 'bic')}
onChange={(event) => updateField('bic', event.target.value.toUpperCase())}
className="max-w-32 flex-none tabular-nums"
/>
</SettingsRow>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label>{t('bank_label')}</Label>
<BankNameCombobox
value={value(activeAccount, 'bank_name')}
onChange={(next) => updateField('bank_name', next)}
enableBankingEnabled={hasBankingExtension}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`payment-clearing-${activeCurrency}`}>{t('clearing_label')}</Label>
<Input
id={`payment-clearing-${activeCurrency}`}
inputMode="numeric"
maxLength={5}
value={value(activeAccount, 'clearing_number')}
onChange={(event) => updateField('clearing_number', event.target.value.replace(/\D/g, ''))}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`payment-account-${activeCurrency}`}>{t('account_number_label')}</Label>
<Input
id={`payment-account-${activeCurrency}`}
inputMode="numeric"
maxLength={12}
value={value(activeAccount, 'account_number')}
onChange={(event) => updateField('account_number', event.target.value.replace(/\D/g, ''))}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor={`payment-bankgiro-${activeCurrency}`}>{t('bankgiro_label')}</Label>
<Input
id={`payment-bankgiro-${activeCurrency}`}
value={value(activeAccount, 'bankgiro')}
onChange={(event) => updateField('bankgiro', event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`payment-plusgiro-${activeCurrency}`}>{t('plusgiro_label')}</Label>
<Input
id={`payment-plusgiro-${activeCurrency}`}
value={value(activeAccount, 'plusgiro')}
onChange={(event) => updateField('plusgiro', event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`payment-swish-${activeCurrency}`}>{t('swish_label')}</Label>
<Input
id={`payment-swish-${activeCurrency}`}
value={value(activeAccount, 'swish')}
onChange={(event) => updateField('swish', event.target.value)}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor={`payment-iban-${activeCurrency}`}>
{t('iban_label')}{activeCurrency !== 'SEK' ? ` ${t('required_suffix')}` : ''}
</Label>
<Input
id={`payment-iban-${activeCurrency}`}
value={value(activeAccount, 'iban')}
onChange={(event) => updateField('iban', event.target.value.toUpperCase())}
placeholder="SE00 0000 0000 0000 0000 0000"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`payment-bic-${activeCurrency}`}>{t('bic_label')}</Label>
<Input
id={`payment-bic-${activeCurrency}`}
maxLength={11}
value={value(activeAccount, 'bic')}
onChange={(event) => updateField('bic', event.target.value.toUpperCase())}
/>
</div>
</div>
</div>
<div className="flex justify-end">
<Button type="button" onClick={save} disabled={isSaving || hasExternalUpdate}>
<div className="flex justify-end px-1 pt-4">
<Button type="button" size="sm" onClick={save} disabled={isSaving || hasExternalUpdate}>
{isSaving ? t('saving') : t('save')}
</Button>
</div>
</section>
</SettingsGroup>
)
}
@@ -2,9 +2,13 @@
import { useTranslations } from 'next-intl'
import { useCallback, useState } from 'react'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { useToast } from '@/components/ui/use-toast'
import {
SettingsGroup,
SettingsRow,
SettingsRowEnd,
} from '@/components/settings/SettingsRows'
import type { CompanySettings } from '@/types'
interface InvoicePaymentLinkSettingsProps {
@@ -40,23 +44,17 @@ export function InvoicePaymentLinkSettings({ settings, onUpdate }: InvoicePaymen
}, [onUpdate, toast, t])
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('heading')}
</h2>
<div className="flex items-center justify-between">
<div>
<Label>{t('enable_label')}</Label>
<p className="text-xs text-muted-foreground">{t('enable_help')}</p>
</div>
<Switch
checked={settings.invoice_payment_links_enabled ?? false}
onCheckedChange={saveToggle}
disabled={isSaving}
aria-label={t('enable_label')}
/>
</div>
</section>
<SettingsGroup label={t('heading')}>
<SettingsRow label={t('enable_label')} help={t('enable_help')}>
<SettingsRowEnd>
<Switch
checked={settings.invoice_payment_links_enabled ?? false}
onCheckedChange={saveToggle}
disabled={isSaving}
aria-label={t('enable_label')}
/>
</SettingsRowEnd>
</SettingsRow>
</SettingsGroup>
)
}
+8 -4
View File
@@ -3,7 +3,6 @@
import { useEffect, useRef, useState } from 'react'
import { Eye } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
@@ -128,11 +127,16 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
return (
<Dialog open={open} onOpenChange={setOpen}>
{/* Quiet header-action trigger (Fönster): the preview lives in the
section header's action slot, not as a card in the page flow. */}
<DialogTrigger asChild>
<Button variant="outline">
<Eye className="h-4 w-4" />
<button
type="button"
className="inline-flex items-center gap-2 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<Eye className="h-3.5 w-3.5" />
{t('preview_button')}
</Button>
</button>
</DialogTrigger>
<DialogContent className="max-w-3xl">
<DialogHeader>
+113 -99
View File
@@ -1,9 +1,12 @@
'use client'
import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
SettingsGroup,
SettingsInput,
SettingsRow,
SettingsTextarea,
} from '@/components/settings/SettingsRows'
import type { CompanySettings } from '@/types'
interface InvoiceSettingsFormProps {
@@ -13,125 +16,136 @@ interface InvoiceSettingsFormProps {
export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
const t = useTranslations('settings_invoice_form')
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('heading')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end">
<div className="space-y-2">
<Label htmlFor="invoice_prefix">{t('prefix_label')}</Label>
<Input
<>
<SettingsGroup label={t('heading')}>
<SettingsRow label={t('prefix_label')} htmlFor="invoice_prefix" align="baseline">
<SettingsInput
id="invoice_prefix"
name="invoice_prefix"
placeholder={t('prefix_placeholder')}
defaultValue={settings.invoice_prefix || ''}
className="max-w-32 flex-none"
/>
</div>
<div className="space-y-2">
<Label htmlFor="next_invoice_number">{t('next_number_label')}</Label>
<Input
</SettingsRow>
<SettingsRow
label={t('next_number_label')}
htmlFor="next_invoice_number"
align="baseline"
>
<SettingsInput
id="next_invoice_number"
name="next_invoice_number"
type="number"
min="1"
defaultValue={settings.next_invoice_number || 1}
className="max-w-32 flex-none tabular-nums"
/>
</div>
<div className="space-y-2">
<Label htmlFor="invoice_default_days">{t('default_days_label')}</Label>
<Input
</SettingsRow>
<SettingsRow
label={t('default_days_label')}
htmlFor="invoice_default_days"
align="baseline"
>
<SettingsInput
id="invoice_default_days"
name="invoice_default_days"
type="number"
min="0"
defaultValue={settings.invoice_default_days || 30}
className="max-w-24 flex-none tabular-nums"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="next_arrival_number">{t('arrival_start_label')}</Label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Input
</SettingsRow>
<SettingsRow
label={t('arrival_start_label')}
htmlFor="next_arrival_number"
help={t('arrival_start_help')}
align="baseline"
>
<SettingsInput
id="next_arrival_number"
name="next_arrival_number"
type="number"
min="1"
defaultValue={settings.next_arrival_number || 1}
className="max-w-32 flex-none tabular-nums"
/>
</div>
<p className="text-xs text-muted-foreground">
{t('arrival_start_help')}
</p>
</div>
</SettingsRow>
<SettingsRow
label={t('default_notes_label')}
htmlFor="invoice_default_notes"
help={t('default_notes_help')}
align="baseline"
>
<SettingsTextarea
id="invoice_default_notes"
name="invoice_default_notes"
rows={3}
placeholder={t('default_notes_placeholder')}
defaultValue={settings.invoice_default_notes || ''}
/>
</SettingsRow>
<SettingsRow
label={t('default_our_reference_label')}
htmlFor="default_our_reference"
help={t('default_our_reference_help')}
align="baseline"
>
<SettingsInput
id="default_our_reference"
name="default_our_reference"
placeholder={t('default_our_reference_placeholder')}
defaultValue={settings.default_our_reference || ''}
/>
</SettingsRow>
</SettingsGroup>
<div className="space-y-2">
<Label htmlFor="invoice_default_notes">{t('default_notes_label')}</Label>
<Textarea
id="invoice_default_notes"
name="invoice_default_notes"
rows={3}
placeholder={t('default_notes_placeholder')}
defaultValue={settings.invoice_default_notes || ''}
/>
<p className="text-xs text-muted-foreground">
{t('default_notes_help')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="default_our_reference">{t('default_our_reference_label')}</Label>
<Input
id="default_our_reference"
name="default_our_reference"
placeholder={t('default_our_reference_placeholder')}
defaultValue={settings.default_our_reference || ''}
/>
<p className="text-xs text-muted-foreground">
{t('default_our_reference_help')}
</p>
</div>
<fieldset className="space-y-4 border-t border-border pt-4">
<legend className="text-sm font-medium">{t('reminder_days_heading')}</legend>
<p className="text-xs text-muted-foreground">{t('reminder_days_help')}</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="reminder_days_level_1">{t('reminder_days_level_1')}</Label>
<Input
id="reminder_days_level_1"
name="reminder_days_level_1"
type="number"
min="1"
max="365"
defaultValue={settings.reminder_days_level_1 ?? 15}
/>
</div>
<div className="space-y-2">
<Label htmlFor="reminder_days_level_2">{t('reminder_days_level_2')}</Label>
<Input
id="reminder_days_level_2"
name="reminder_days_level_2"
type="number"
min="1"
max="365"
defaultValue={settings.reminder_days_level_2 ?? 30}
/>
</div>
<div className="space-y-2">
<Label htmlFor="reminder_days_level_3">{t('reminder_days_level_3')}</Label>
<Input
id="reminder_days_level_3"
name="reminder_days_level_3"
type="number"
min="1"
max="365"
defaultValue={settings.reminder_days_level_3 ?? 45}
/>
</div>
</div>
</fieldset>
</section>
<SettingsGroup label={t('reminder_days_heading')} help={t('reminder_days_help')}>
<SettingsRow
label={t('reminder_days_level_1')}
htmlFor="reminder_days_level_1"
align="baseline"
>
<SettingsInput
id="reminder_days_level_1"
name="reminder_days_level_1"
type="number"
min="1"
max="365"
defaultValue={settings.reminder_days_level_1 ?? 15}
className="max-w-24 flex-none tabular-nums"
/>
</SettingsRow>
<SettingsRow
label={t('reminder_days_level_2')}
htmlFor="reminder_days_level_2"
align="baseline"
>
<SettingsInput
id="reminder_days_level_2"
name="reminder_days_level_2"
type="number"
min="1"
max="365"
defaultValue={settings.reminder_days_level_2 ?? 30}
className="max-w-24 flex-none tabular-nums"
/>
</SettingsRow>
<SettingsRow
label={t('reminder_days_level_3')}
htmlFor="reminder_days_level_3"
align="baseline"
>
<SettingsInput
id="reminder_days_level_3"
name="reminder_days_level_3"
type="number"
min="1"
max="365"
defaultValue={settings.reminder_days_level_3 ?? 45}
className="max-w-24 flex-none tabular-nums"
/>
</SettingsRow>
</SettingsGroup>
</>
)
}
+42 -47
View File
@@ -3,9 +3,12 @@
import { useState, useRef } from 'react'
import { useTranslations } from 'next-intl'
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'
import {
SettingsGroup,
SettingsRow,
} from '@/components/settings/SettingsRows'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { LOGO_UPLOAD_MAX_BYTES } from '@/lib/invoices/branding-constants'
@@ -109,32 +112,26 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
}
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('logo_heading')}
</h2>
<p className="text-xs text-muted-foreground -mt-2">
{t('logo_help')}
</p>
{preview ? (
<div className="space-y-3">
<div className="inline-block rounded-lg border border-border bg-muted/30 p-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={preview}
alt={t('logo_alt')}
className="max-h-16 max-w-[200px] object-contain"
/>
</div>
<div className="flex gap-2">
<SettingsGroup>
<SettingsRow label={t('logo_heading')} help={t('logo_help')}>
{preview ? (
<>
<span className="inline-flex rounded-lg border border-border bg-muted/30 p-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={preview}
alt={t('logo_alt')}
className="max-h-10 max-w-32 object-contain"
/>
</span>
<Button
variant="outline"
variant="ghost"
size="sm"
onClick={() => inputRef.current?.click()}
disabled={isUploading}
className="text-muted-foreground hover:text-foreground"
>
{isUploading ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Upload className="mr-2 h-3.5 w-3.5" />}
{isUploading ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
{t('logo_change')}
</Button>
<Button
@@ -147,31 +144,29 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
{isDeleting ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Trash2 className="mr-2 h-3.5 w-3.5" />}
{t('logo_remove')}
</Button>
</div>
</div>
) : (
<button
type="button"
onClick={() => inputRef.current?.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
disabled={isUploading}
className={`flex flex-col items-center justify-center w-full max-w-xs rounded-lg border-2 border-dashed py-8 px-4 text-center transition-colors disabled:opacity-50 ${
isDragging ? 'border-foreground bg-muted/40' : 'border-border hover:border-border hover:bg-muted/20'
}`}
>
{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">
</>
) : (
<button
type="button"
onClick={() => inputRef.current?.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
disabled={isUploading}
className={`inline-flex min-h-10 items-center gap-2 rounded-lg border border-dashed px-4 py-2 text-sm text-muted-foreground transition-colors duration-150 disabled:opacity-50 ${
isDragging ? 'border-foreground bg-muted/40' : 'border-border hover:bg-muted/20'
}`}
>
{isUploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4 text-muted-foreground/60" />
)}
{isUploading ? t('logo_uploading') : t('logo_pick_or_drop')}
</Label>
</button>
)}
</button>
)}
</SettingsRow>
<input
ref={inputRef}
@@ -180,6 +175,6 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
className="hidden"
onChange={handleFileChange}
/>
</section>
</SettingsGroup>
)
}
+56 -68
View File
@@ -2,7 +2,6 @@
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -16,7 +15,10 @@ import {
} from '@/components/ui/dialog'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { EmptyState } from '@/components/ui/empty-state'
import { HelpPopover } from '@/components/ui/help-popover'
import { useToast } from '@/components/ui/use-toast'
import { SettingsGroup } from '@/components/settings/SettingsRows'
import { formatDateLong } from '@/lib/utils'
import { Loader2, Plus, Trash2, Globe } from 'lucide-react'
interface OAuthClient {
@@ -111,75 +113,61 @@ export function OAuthClientsPanel() {
}
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('sv-SE', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">{t('title')}</CardTitle>
<CardDescription>
{t('description')}
</CardDescription>
</div>
<Button size="sm" onClick={() => setShowCreateDialog(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
{t('register_uri')}
</Button>
<>
<SettingsGroup>
{/* Group eyebrow with the group's primary action on the right. Styling
mirrors SettingsGroup's label line; the "?" holds the old panel
description. */}
<div className="flex items-center justify-between gap-4 px-1">
<p className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<span>{t('title')}</span>
<HelpPopover className="shrink-0">{t('description')}</HelpPopover>
</p>
<Button size="sm" onClick={() => setShowCreateDialog(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
{t('register_uri')}
</Button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
) : clients.length === 0 ? (
<EmptyState
icon={Globe}
title={t('empty_title')}
description={t('empty_help')}
/>
) : (
clients.map((c) => (
<div
key={c.id}
className="flex items-center gap-3 border-b border-border px-1 py-3"
>
<div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="truncate text-sm">{c.client_name}</span>
<code className="min-w-0 truncate font-mono text-xs text-muted-foreground">
{c.redirect_uri}
</code>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{t('registered_on')} {formatDateLong(c.created_at)}
</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleRevoke(c.id, c.client_name)}
aria-label={t('revoke_aria', { name: c.client_name })}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
) : clients.length === 0 ? (
<EmptyState
icon={Globe}
title={t('empty_title')}
description={t('empty_help')}
/>
) : (
<div className="space-y-3">
{clients.map((c) => (
<div
key={c.id}
className="flex items-center justify-between rounded-md border px-4 py-3"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{c.client_name}</p>
<div className="flex items-center gap-3 mt-1">
<code className="text-xs text-muted-foreground font-mono truncate">
{c.redirect_uri}
</code>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{t('registered_on')} {formatDate(c.created_at)}
</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRevoke(c.id, c.client_name)}
aria-label={t('revoke_aria', { name: c.client_name })}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
))
)}
</SettingsGroup>
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent>
@@ -227,6 +215,6 @@ export function OAuthClientsPanel() {
</Dialog>
<DestructiveConfirmDialog {...revokeDialogProps} />
</div>
</>
)
}
+149 -187
View File
@@ -3,12 +3,19 @@
import { useTranslations } from 'next-intl'
import { useState, useCallback, useRef, type ChangeEvent } from 'react'
import { Loader2, Trash2, Upload } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/components/ui/use-toast'
import { HelpPopover } from '@/components/ui/help-popover'
import {
SettingsGroup,
SettingsReveal,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
SettingsSeg,
SettingsSelect,
SettingsTextarea,
} from '@/components/settings/SettingsRows'
import { INVOICE_FONT_FAMILIES } from '@/lib/invoices/branding-constants'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import type { CompanySettings, InvoiceFontFamily } from '@/types'
@@ -18,6 +25,42 @@ interface PdfPrintSettingsProps {
onUpdate: (updates: Partial<CompanySettings>) => void
}
type PdfToggleField =
| 'ore_rounding'
| 'invoice_show_ocr'
| 'invoice_show_bankgiro'
| 'invoice_show_plusgiro'
| 'invoice_show_swish'
| 'invoice_show_logo'
| 'invoice_show_company_name'
/** Compact switch row for the show/hide grid: small label, "?", Switch. */
function PdfToggleRow({
id,
label,
help,
checked,
onCheckedChange,
}: {
id: string
label: string
help?: React.ReactNode
checked: boolean
onCheckedChange: (value: boolean) => void
}) {
return (
<div className="flex items-center justify-between gap-3 border-b border-border px-1 py-3">
<span className="flex min-w-0 items-center gap-2">
<label htmlFor={id} className="truncate text-sm">
{label}
</label>
{help ? <HelpPopover className="shrink-0">{help}</HelpPopover> : null}
</span>
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} />
</div>
)
}
export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps) {
const t = useTranslations('settings_pdf_print')
const { toast } = useToast()
@@ -148,75 +191,79 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
Custom: t('font_custom'),
}
return (
<section className="space-y-6">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('heading')}
</h2>
const toggleRows: Array<{ field: PdfToggleField; label: string; help: string; defaultOn: boolean }> = [
{ field: 'ore_rounding', label: t('ore_rounding_label'), help: t('ore_rounding_help'), defaultOn: true },
{ field: 'invoice_show_ocr', label: t('show_ocr_label'), help: t('show_ocr_help'), defaultOn: true },
{ field: 'invoice_show_bankgiro', label: t('show_bankgiro_label'), help: t('show_bankgiro_help'), defaultOn: true },
{ field: 'invoice_show_plusgiro', label: t('show_plusgiro_label'), help: t('show_plusgiro_help'), defaultOn: true },
{ field: 'invoice_show_swish', label: t('show_swish_label'), help: t('show_swish_help'), defaultOn: false },
{ field: 'invoice_show_logo', label: t('show_logo_label'), help: t('show_logo_help'), defaultOn: true },
{ field: 'invoice_show_company_name', label: t('show_company_name_label'), help: t('show_company_name_help'), defaultOn: true },
]
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="invoice_font_family">{t('font_label')}</Label>
<p className="text-xs text-muted-foreground">{t('font_help')}</p>
</div>
<Select
return (
<SettingsGroup label={t('heading')}>
<SettingsRow
label={t('font_label')}
htmlFor="invoice_font_family"
help={
<div className="space-y-2">
<p>{t('font_help')}</p>
<p>{t('font_file_help')}</p>
</div>
}
>
<SettingsSelect
id="invoice_font_family"
value={settings.invoice_font_family ?? 'Helvetica'}
onValueChange={(value) => {
if (value) void saveFont(value as InvoiceFontFamily)
onChange={(event) => {
if (event.target.value) void saveFont(event.target.value as InvoiceFontFamily)
}}
disabled={isSavingFont || isUploadingFont || isDeletingFont}
>
<SelectTrigger id="invoice_font_family" className="max-w-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INVOICE_FONT_FAMILIES
.filter((family) => family !== 'Custom' || settings.invoice_custom_font_path)
.map((family) => (
<SelectItem key={family} value={family}>
{fontLabels[family]}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex flex-wrap items-center gap-3">
<Button
{INVOICE_FONT_FAMILIES
.filter((family) => family !== 'Custom' || settings.invoice_custom_font_path)
.map((family) => (
<option key={family} value={family}>
{fontLabels[family]}
</option>
))}
</SettingsSelect>
{settings.invoice_custom_font_name && (
<SettingsRowNote>
{t('font_uploaded_name', { name: settings.invoice_custom_font_name })}
</SettingsRowNote>
)}
<SettingsRowEnd>
<button
type="button"
variant="outline"
onClick={() => fontInputRef.current?.click()}
disabled={isUploadingFont || isDeletingFont}
className="inline-flex items-center gap-2 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
>
{isUploadingFont ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
<Upload className="h-3.5 w-3.5" />
)}
{settings.invoice_custom_font_path ? t('font_replace') : t('font_upload')}
</Button>
</button>
{settings.invoice_custom_font_path && (
<Button
<button
type="button"
variant="ghost"
onClick={() => void deleteFont()}
disabled={isUploadingFont || isDeletingFont}
className="text-muted-foreground hover:text-destructive"
className="inline-flex items-center gap-2 text-xs text-muted-foreground transition-colors duration-150 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-60"
>
{isDeletingFont ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
<Trash2 className="h-3.5 w-3.5" />
)}
{t('font_remove')}
</Button>
</button>
)}
</div>
{settings.invoice_custom_font_name && (
<p className="text-xs text-muted-foreground">
{t('font_uploaded_name', { name: settings.invoice_custom_font_name })}
</p>
)}
<p className="text-xs text-muted-foreground">{t('font_file_help')}</p>
</SettingsRowEnd>
<input
ref={fontInputRef}
type="file"
@@ -224,146 +271,61 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
className="hidden"
onChange={handleFontChange}
/>
</SettingsRow>
{/* Show/hide switches: compact two-column grid of small switch rows. */}
<div className="grid gap-x-8 md:grid-cols-2">
{toggleRows.map(({ field, label, help, defaultOn }) => (
<PdfToggleRow
key={field}
id={`pdf-toggle-${field}`}
label={label}
help={help}
checked={settings[field] ?? defaultOn}
onCheckedChange={(v) => void saveToggle(field, v)}
/>
))}
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>{t('ore_rounding_label')}</Label>
<p className="text-xs text-muted-foreground">{t('ore_rounding_help')}</p>
</div>
<Switch
checked={settings.ore_rounding ?? true}
onCheckedChange={(v) => saveToggle('ore_rounding', v)}
{/* Placement only applies while the company name is shown. */}
<SettingsReveal open={settings.invoice_show_company_name ?? true}>
<SettingsRow label={t('placement_label')} borderless>
<SettingsSeg
value={settings.invoice_company_name_position ?? 'header'}
onChange={(pos) => void savePosition(pos)}
options={[
{ value: 'header', label: t('placement_header') },
{ value: 'footer', label: t('placement_footer') },
]}
aria-label={t('placement_aria_label')}
/>
</div>
</SettingsRow>
</SettingsReveal>
<div className="flex items-center justify-between">
<div>
<Label>{t('show_ocr_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_ocr_help')}</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>{t('show_bankgiro_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_bankgiro_help')}</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>{t('show_plusgiro_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_plusgiro_help')}</p>
</div>
<Switch
checked={settings.invoice_show_plusgiro ?? true}
onCheckedChange={(v) => saveToggle('invoice_show_plusgiro', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>{t('show_swish_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_swish_help')}</p>
</div>
<Switch
checked={settings.invoice_show_swish ?? false}
onCheckedChange={(v) => saveToggle('invoice_show_swish', v)}
aria-label={t('show_swish_label')}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>{t('show_logo_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_logo_help')}</p>
</div>
<Switch
checked={settings.invoice_show_logo ?? true}
onCheckedChange={(v) => saveToggle('invoice_show_logo', v)}
/>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label>{t('show_company_name_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_company_name_help')}</p>
</div>
<Switch
checked={settings.invoice_show_company_name ?? true}
onCheckedChange={(v) => saveToggle('invoice_show_company_name', v)}
/>
</div>
{(settings.invoice_show_company_name ?? true) && (
<div className="flex items-center justify-between pl-0">
<p className="text-xs text-muted-foreground">{t('placement_label')}</p>
<div
role="group"
aria-label={t('placement_aria_label')}
className="inline-flex rounded-md border border-border p-1"
>
{(['header', 'footer'] as const).map((pos) => {
const active = (settings.invoice_company_name_position ?? 'header') === pos
return (
<button
key={pos}
type="button"
aria-pressed={active}
onClick={() => savePosition(pos)}
className={
'h-10 px-4 text-sm rounded-sm transition-colors ' +
(active
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:text-foreground')
}
>
{pos === 'header' ? t('placement_header') : t('placement_footer')}
</button>
)
})}
</div>
</div>
)}
</div>
</div>
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label htmlFor="invoice_late_fee_text">{t('late_fee_label')}</Label>
<Textarea
id="invoice_late_fee_text"
rows={2}
placeholder={t('late_fee_placeholder')}
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">{t('credit_terms_label')}</Label>
<Textarea
id="invoice_credit_terms_text"
rows={2}
placeholder={t('credit_terms_placeholder')}
value={creditTermsText}
onChange={(e) => setCreditTermsText(e.target.value)}
onBlur={() => saveText('invoice_credit_terms_text', creditTermsText)}
/>
</div>
</div>
</section>
<SettingsRow label={t('late_fee_label')} htmlFor="invoice_late_fee_text" align="baseline">
<SettingsTextarea
id="invoice_late_fee_text"
rows={2}
placeholder={t('late_fee_placeholder')}
value={lateFeeText}
onChange={(e) => setLateFeeText(e.target.value)}
onBlur={() => saveText('invoice_late_fee_text', lateFeeText)}
/>
</SettingsRow>
<SettingsRow
label={t('credit_terms_label')}
htmlFor="invoice_credit_terms_text"
align="baseline"
>
<SettingsTextarea
id="invoice_credit_terms_text"
rows={2}
placeholder={t('credit_terms_placeholder')}
value={creditTermsText}
onChange={(e) => setCreditTermsText(e.target.value)}
onBlur={() => saveText('invoice_credit_terms_text', creditTermsText)}
/>
</SettingsRow>
</SettingsGroup>
)
}
+42 -44
View File
@@ -1,58 +1,56 @@
'use client'
import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
SettingsGroup,
SettingsInput,
SettingsRow,
SettingsSelect,
} from '@/components/settings/SettingsRows'
import type { CompanySettings } from '@/types'
interface PeriodLockingSettingsProps {
settings: CompanySettings
}
/**
* Period-lock rows. Uncontrolled on purpose: the values are read via FormData
* by the surrounding SettingsFormWrapper on the bookkeeping settings page.
*/
export function PeriodLockingSettings({ settings }: PeriodLockingSettingsProps) {
const t = useTranslations('settings_period_locking')
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('heading')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="bookkeeping_locked_through">{t('locked_through_label')}</Label>
<Input
id="bookkeeping_locked_through"
name="bookkeeping_locked_through"
type="date"
defaultValue={settings.bookkeeping_locked_through || ''}
/>
<p className="text-xs text-muted-foreground">
{t('locked_through_help')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="auto_lock_period_days">{t('auto_lock_label')}</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">{t('auto_lock_none')}</SelectItem>
<SelectItem value="30">{t('auto_lock_30')}</SelectItem>
<SelectItem value="60">{t('auto_lock_60')}</SelectItem>
<SelectItem value="90">{t('auto_lock_90')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t('auto_lock_help')}
</p>
</div>
</div>
</section>
<SettingsGroup label={t('heading')}>
<SettingsRow
label={t('locked_through_label')}
htmlFor="bookkeeping_locked_through"
help={t('locked_through_help')}
align="baseline"
>
<SettingsInput
id="bookkeeping_locked_through"
name="bookkeeping_locked_through"
type="date"
defaultValue={settings.bookkeeping_locked_through || ''}
className="max-w-44 flex-none tabular-nums"
/>
</SettingsRow>
<SettingsRow
label={t('auto_lock_label')}
htmlFor="auto_lock_period_days"
help={t('auto_lock_help')}
>
<SettingsSelect
id="auto_lock_period_days"
name="auto_lock_period_days"
defaultValue={settings.auto_lock_period_days?.toString() || 'none'}
>
<option value="none">{t('auto_lock_none')}</option>
<option value="30">{t('auto_lock_30')}</option>
<option value="60">{t('auto_lock_60')}</option>
<option value="90">{t('auto_lock_90')}</option>
</SettingsSelect>
</SettingsRow>
</SettingsGroup>
)
}
@@ -4,7 +4,10 @@ import { useCallback, useSyncExternalStore } from 'react'
import Link from 'next/link'
import { ExternalLink } from 'lucide-react'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import {
SettingsRow,
SettingsRowEnd,
} from '@/components/settings/SettingsRows'
/**
* Per-user toggle for the periodisering wizard's auto-detection step.
@@ -76,33 +79,27 @@ export function PeriodiseringAutoDetectToggle() {
}, [])
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Periodisering
</h2>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="periodisering-autodetect" className="text-sm">
Aktivera automatisk periodiseringsdetektering
</Label>
<p className="text-xs text-muted-foreground max-w-md">
Skannar fakturor i bokslutet efter datumintervall som sträcker sig
in i nästa räkenskapsår och föreslår periodiseringar i bokslut-wizarden.
</p>
</div>
<Switch
id="periodisering-autodetect"
checked={enabled}
onCheckedChange={handleChange}
/>
</div>
<Link
href="/bookkeeping/year-end/periodisering"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
Öppna periodiserings-wizarden
</Link>
</section>
<SettingsRow
label="Periodisering"
help="Skannar fakturor i bokslutet efter datumintervall som sträcker sig in i nästa räkenskapsår och föreslår periodiseringar i bokslut-wizarden."
>
<Switch
id="periodisering-autodetect"
checked={enabled}
onCheckedChange={handleChange}
/>
<label htmlFor="periodisering-autodetect" className="cursor-pointer text-sm">
Aktivera automatisk periodiseringsdetektering
</label>
<SettingsRowEnd>
<Link
href="/bookkeeping/year-end/periodisering"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
Öppna periodiserings-wizarden
</Link>
</SettingsRowEnd>
</SettingsRow>
)
}
+119 -133
View File
@@ -4,17 +4,22 @@ import { useTranslations } from 'next-intl'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, ShieldCheck, ShieldOff, KeyRound } from 'lucide-react'
import { Loader2, ShieldCheck, ShieldOff } from 'lucide-react'
import { isMfaRequired } from '@/lib/auth/mfa'
import { isBankIdEnabled } from '@/lib/auth/bankid'
import { BankIdSettings } from '@/components/settings/BankIdSettings'
import { userHasPassword } from '@/lib/auth/has-password'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import {
SettingsGroup,
SettingsInput,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true'
const mfaRequired = isMfaRequired()
@@ -166,181 +171,162 @@ export function SecuritySettings() {
}
return (
<div className="space-y-6">
<SettingsGroup label={t('group_security')}>
{bankIdEnabled && <BankIdSettings />}
{/* BankID-only users with no password: banner above everything else */}
{/* BankID-only users with no password: set-password row before the rest */}
{hasPassword === false && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<KeyRound className="h-4 w-4" />
Sätt ett lösenord
</CardTitle>
<CardDescription>
Du loggade in med BankID och har inget lösenord ännu. Sätt ett
lösenord för att kunna aktivera 2FA eller logga in när BankID
inte är tillgängligt.
</CardDescription>
</CardHeader>
<CardContent>
<SettingsRow
label="Sätt ett lösenord"
help="Du loggade in med BankID och har inget lösenord ännu. Sätt ett lösenord för att kunna aktivera 2FA eller logga in när BankID inte är tillgängligt."
>
<SettingsRowEnd>
<Button
size="sm"
onClick={() =>
router.push('/account/set-password?returnTo=/settings/account')
}
>
Sätt lösenord
</Button>
</CardContent>
</Card>
</SettingsRowEnd>
</SettingsRow>
)}
{/* Change password: hidden when the user has no password (the banner
{/* Change password: hidden when the user has no password (the row
above handles the set-initial-password flow). */}
{hasPassword !== false && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="h-5 w-5" />
{t('change_password_title')}
</CardTitle>
<CardDescription>
{t('change_password_description')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleChangePassword} className="space-y-4 max-w-md">
<div className="space-y-2">
<Label htmlFor="new_password">{t('new_password_label')}</Label>
<Input
id="new_password"
type="password"
autoComplete="new-password"
placeholder={t('new_password_placeholder')}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
disabled={isChangingPassword}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm_new_password">{t('confirm_password_label')}</Label>
<Input
id="confirm_new_password"
type="password"
autoComplete="new-password"
placeholder={t('confirm_password_placeholder')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
disabled={isChangingPassword}
/>
</div>
<Button type="submit" disabled={isChangingPassword}>
{isChangingPassword ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('saving')}
</>
) : (
t('update_password_button')
)}
</Button>
</form>
</CardContent>
</Card>
<form onSubmit={handleChangePassword}>
<SettingsRow
label={t('new_password_label')}
htmlFor="new_password"
help={t('change_password_description')}
align="baseline"
>
<SettingsInput
id="new_password"
type="password"
autoComplete="new-password"
placeholder={t('new_password_placeholder')}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
disabled={isChangingPassword}
/>
</SettingsRow>
<SettingsRow
label={t('confirm_password_label')}
htmlFor="confirm_new_password"
align="baseline"
>
<SettingsInput
id="confirm_new_password"
type="password"
autoComplete="new-password"
placeholder={t('confirm_password_placeholder')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
disabled={isChangingPassword}
/>
<SettingsRowEnd>
<Button type="submit" size="sm" disabled={isChangingPassword}>
{isChangingPassword ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
{t('saving')}
</>
) : (
t('update_password_button')
)}
</Button>
</SettingsRowEnd>
</SettingsRow>
</form>
)}
{/* MFA: hidden for self-hosted */}
{!isSelfHosted && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5" />
{t('mfa_title')}
</CardTitle>
<CardDescription>
{t('mfa_description')}
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingMfa ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('loading')}
</div>
) : hasMfa ? (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 rounded-lg border border-border bg-secondary">
<ShieldCheck className="h-5 w-5 text-success" />
<div>
<p className="font-medium text-foreground">{t('mfa_active_title')}</p>
<p className="text-sm text-muted-foreground">
{t('mfa_active_description')}
</p>
</div>
</div>
{!mfaRequired && (
<SettingsRow
label={t('mfa_title')}
help={
<>
<p>{t('mfa_description')}</p>
{!isLoadingMfa && !hasMfa && (
<p className="mt-2">{t('mfa_inactive_description')}</p>
)}
</>
}
>
{isLoadingMfa ? (
<span className="inline-flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t('loading')}
</span>
) : hasMfa ? (
<>
<Badge variant="success">{t('mfa_active_title')}</Badge>
<SettingsRowNote>{t('mfa_active_description')}</SettingsRowNote>
<SettingsRowEnd>
{mfaRequired ? (
// Required by the hosted config: no disable action exists,
// so the reason stays visible as the row's status.
<SettingsRowNote>{t('mfa_required_note')}</SettingsRowNote>
) : (
<Button
variant="outline"
variant="ghost"
size="sm"
onClick={handleUnenrollMfa}
disabled={isUnenrolling}
>
{isUnenrolling ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
{t('disabling')}
</>
) : (
<>
<ShieldOff className="mr-2 h-4 w-4" />
<ShieldOff className="mr-2 h-3.5 w-3.5" />
{t('disable_mfa')}
</>
)}
</Button>
)}
{mfaRequired && (
<p className="text-xs text-muted-foreground">
{t('mfa_required_note')}
</p>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 rounded-lg border">
<ShieldOff className="h-5 w-5 text-muted-foreground" />
<div>
<p className="font-medium">{t('mfa_inactive_title')}</p>
<p className="text-sm text-muted-foreground">
{t('mfa_inactive_description')}
</p>
</div>
</div>
</SettingsRowEnd>
</>
) : (
<>
<SettingsRowNote>{t('mfa_inactive_title')}</SettingsRowNote>
<SettingsRowEnd>
{hasPassword === false ? (
<Button
variant="ghost"
size="sm"
onClick={() =>
router.push(
'/account/set-password?returnTo=/mfa/enroll',
)
router.push('/account/set-password?returnTo=/mfa/enroll')
}
>
{t('set_password_first')}
</Button>
) : (
<Button
onClick={() => router.push(`/mfa/enroll?returnTo=${encodeURIComponent('/settings/account')}`)}
variant="ghost"
size="sm"
onClick={() =>
router.push(`/mfa/enroll?returnTo=${encodeURIComponent('/settings/account')}`)
}
>
<ShieldCheck className="mr-2 h-4 w-4" />
<ShieldCheck className="mr-2 h-3.5 w-3.5" />
{t('enable_mfa')}
</Button>
)}
</div>
)}
</CardContent>
</Card>
</SettingsRowEnd>
</>
)}
</SettingsRow>
)}
</div>
</SettingsGroup>
)
}
+34 -7
View File
@@ -7,6 +7,7 @@ import { Loader2, Check, Lock } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { cn } from '@/lib/utils'
type SaveResult =
| Record<string, unknown>
@@ -18,12 +19,18 @@ interface SettingsFormWrapperProps {
className?: string
}
/**
* Form shell for settings sections saving to PUT /api/settings. The save
* affordance is a sticky bar that fades in only when the form is dirty
* (Fönster concept): a quiet page until you actually change something.
*/
export function SettingsFormWrapper({ children, onSave, className }: SettingsFormWrapperProps) {
const t = useTranslations('settings_company')
const { toast } = useToast()
const { canWrite } = useCanWrite()
const [isSaving, setIsSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [dirty, setDirty] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
useEffect(() => {
@@ -78,6 +85,7 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
}
onSuccess?.(result.data ?? updates)
setDirty(false)
setSaved(true)
timerRef.current = setTimeout(() => setSaved(false), 2000)
} catch (error) {
@@ -91,21 +99,32 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
setIsSaving(false)
}, [onSave, toast, t])
const barVisible = dirty || isSaving || saved
return (
<form onSubmit={handleSubmit} className={className}>
<form
onSubmit={handleSubmit}
onInput={() => setDirty(true)}
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" />
{t('wrapper_saved')}
</span>
{/* Sticky save bar: invisible until the form is dirty. The gradient
lets rows scroll away underneath without a hard edge. */}
<div
className={cn(
'sticky bottom-0 flex items-center gap-4 bg-gradient-to-t from-background via-background/95 to-transparent px-1 transition-opacity duration-200',
barVisible
? 'mt-2 pb-3 pt-6 opacity-100'
: 'pointer-events-none h-0 overflow-hidden py-0 opacity-0',
)}
aria-hidden={!barVisible}
>
<Button
type="submit"
disabled={isSaving || !canWrite}
size="sm"
tabIndex={barVisible ? 0 : -1}
title={!canWrite ? t('wrapper_readonly_tooltip') : undefined}
>
{isSaving ? (
@@ -122,6 +141,14 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
t('wrapper_save_changes')
)}
</Button>
{saved ? (
<span className="flex items-center gap-2 text-sm text-muted-foreground animate-in fade-in duration-200">
<Check className="h-3.5 w-3.5" />
{t('wrapper_saved')}
</span>
) : (
<span className="text-xs text-muted-foreground">{t('wrapper_unsaved')}</span>
)}
</div>
</form>
)
+20 -9
View File
@@ -11,7 +11,6 @@ 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
@@ -20,6 +19,9 @@ import { ActiveCompanyBadge } from './ActiveCompanyBadge'
* returning the user to the page they came from (which stayed mounted in the
* `children` slot behind the scrim). On hard load / refresh / deep-link the
* interceptor doesn't fire and the real full-page settings render instead.
*
* Chrome follows the Fönster concept: company kicker over a serif title,
* fixed-height window so the rail never jumps between sections.
*/
export function SettingsModal({ sectionId }: { sectionId?: string }) {
const router = useRouter()
@@ -54,16 +56,25 @@ export function SettingsModal({ sectionId }: { sectionId?: string }) {
return (
<Dialog open onOpenChange={onOpenChange}>
{/* Escape with an open "?" help popover closes the popover (its own
listener), not the whole settings window. */}
<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"
className="flex h-[100dvh] max-h-[100dvh] max-w-none flex-col gap-0 overflow-hidden rounded-none p-0 md:h-[min(680px,88dvh)] md:max-h-[88dvh] md:max-w-[920px] md:rounded-xl"
onEscapeKeyDown={(e) => {
if (document.querySelector('[data-help-popover]')) e.preventDefault()
}}
>
<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 className="flex shrink-0 items-center gap-3 border-b border-border px-6 py-3">
<div className="min-w-0">
{/* The modal covers the sidebar's CompanySwitcher, so the active
company must stay visible here as the kicker over the title. */}
{company ? (
<p className="truncate text-xs text-muted-foreground">{company.name}</p>
) : null}
<DialogTitle className="font-display text-lg tracking-tight">
{t('title')}
</DialogTitle>
</div>
</div>
<DialogDescription className="sr-only">{t('description')}</DialogDescription>
<SettingsShell variant="modal" activeSection={resolved} />
+258
View File
@@ -0,0 +1,258 @@
'use client'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { HelpPopover } from '@/components/ui/help-popover'
/**
* The Fönster settings language (founder-approved concept 2026-07-25):
* flat hairline rows with an uppercase micro-label on the left and the
* control on the right, section titles in the display serif, groups under
* eyebrow labels, and every explanation collapsed behind a "?" popover
* (UI-migration convention 7 applied at row level).
*
* All settings sections compose these primitives; do not hand-roll row
* layouts or inline help paragraphs in section components.
*/
interface SettingsSectionHeaderProps {
title: string
/** One quiet line under the title. Longer guidance belongs in row help. */
intro?: React.ReactNode
/** Right-aligned header action (e.g. "Förhandsvisa faktura", "Skapa nyckel"). */
action?: React.ReactNode
}
export function SettingsSectionHeader({ title, intro, action }: SettingsSectionHeaderProps) {
return (
<header>
<div className="flex items-baseline justify-between gap-4">
<h2 className="font-display text-xl tracking-tight">{title}</h2>
{action ? <div className="flex shrink-0 items-center gap-3">{action}</div> : null}
</div>
{intro ? (
<p className="mt-1 max-w-[56ch] text-xs leading-relaxed text-muted-foreground">{intro}</p>
) : null}
</header>
)
}
interface SettingsGroupProps {
label?: string
/** Group-level help ("?" right after the eyebrow) for guidance that spans the rows. */
help?: React.ReactNode
children: React.ReactNode
className?: string
}
export function SettingsGroup({ label, help, children, className }: SettingsGroupProps) {
return (
<section className={cn('pt-8 first:pt-6', className)}>
{label ? (
<p className="flex items-center gap-2 px-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<span>{label}</span>
{help ? <HelpPopover className="shrink-0">{help}</HelpPopover> : null}
</p>
) : null}
<div>{children}</div>
</section>
)
}
interface SettingsRowProps {
label: React.ReactNode
/** Ties the label to a control; renders a <label> instead of a <span>. */
htmlFor?: string
/** Row help content: goes behind a "?" next to the label, never inline. */
help?: React.ReactNode
/** 'center' for toggles/selects/chips, 'baseline' for text inputs. */
align?: 'center' | 'baseline'
/** Drop the hairline (last row before a fold/reveal). */
borderless?: boolean
children: React.ReactNode
className?: string
}
export function SettingsRow({
label,
htmlFor,
help,
align = 'center',
borderless = false,
children,
className,
}: SettingsRowProps) {
const labelClass = 'text-[11px] font-medium uppercase tracking-wider text-muted-foreground'
return (
<div
className={cn(
'flex flex-col gap-1 px-1 py-3 md:flex-row md:gap-4',
align === 'center' ? 'md:items-center' : 'md:items-baseline',
!borderless && 'border-b border-border',
className,
)}
>
<div className="flex w-full shrink-0 items-center gap-2 md:w-44">
{htmlFor ? (
<label htmlFor={htmlFor} className={labelClass}>
{label}
</label>
) : (
<span className={labelClass}>{label}</span>
)}
{help ? <HelpPopover className="shrink-0">{help}</HelpPopover> : null}
</div>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1 text-sm">
{children}
</div>
</div>
)
}
/** Right-aligned slot inside a row (quiet actions, secondary values). */
export function SettingsRowEnd({ children, className }: { children: React.ReactNode; className?: string }) {
return <div className={cn('ml-auto flex shrink-0 items-center gap-3', className)}>{children}</div>
}
/** Muted secondary text inside a row. */
export function SettingsRowNote({ children, className }: { children: React.ReactNode; className?: string }) {
return <span className={cn('text-xs text-muted-foreground', className)}>{children}</span>
}
/**
* Flat text input: borderless with a dashed underline on hover and a solid
* one on focus. Sized by the row, not by a box.
*/
export function SettingsInput({ className, ...rest }: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
{...rest}
className={cn(
'min-w-0 flex-1 rounded-none border-0 border-b border-dashed border-transparent bg-transparent px-0 py-1 text-sm text-foreground',
'placeholder:text-muted-foreground/60 hover:border-border',
'focus:outline-none focus:border-solid focus:border-foreground/50',
'disabled:cursor-not-allowed disabled:border-transparent disabled:opacity-60',
className,
)}
/>
)
}
/** Flat textarea sibling of SettingsInput. */
export function SettingsTextarea({ className, ...rest }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
return (
<textarea
{...rest}
className={cn(
'min-w-0 flex-1 resize-y rounded-none border-0 border-b border-dashed border-transparent bg-transparent px-0 py-1 text-sm text-foreground',
'placeholder:text-muted-foreground/60 hover:border-border',
'focus:outline-none focus:border-solid focus:border-foreground/50',
'disabled:cursor-not-allowed disabled:border-transparent disabled:opacity-60',
className,
)}
/>
)
}
/** Flat native select with a quiet chevron. */
export function SettingsSelect({
className,
wrapperClassName,
children,
...rest
}: React.SelectHTMLAttributes<HTMLSelectElement> & { wrapperClassName?: string }) {
return (
<span className={cn('relative inline-flex max-w-full items-center', wrapperClassName)}>
<select
{...rest}
className={cn(
'max-w-full cursor-pointer appearance-none truncate rounded-none border-0 border-b border-dashed border-transparent bg-transparent py-1 pl-0 pr-6 text-sm text-foreground',
'hover:border-border focus:outline-none focus-visible:border-solid focus-visible:border-foreground/50',
'disabled:cursor-not-allowed disabled:opacity-60',
className,
)}
>
{children}
</select>
<ChevronDown
aria-hidden="true"
className="pointer-events-none absolute right-1 h-3.5 w-3.5 text-muted-foreground"
/>
</span>
)
}
interface SettingsRevealProps {
open: boolean
/** Indent revealed rows behind a left hairline (gated sub-settings). */
indent?: boolean
children: React.ReactNode
}
/** Animated reveal for settings gated behind a toggle (momsreg → momsblock). */
export function SettingsReveal({ open, indent = true, children }: SettingsRevealProps) {
return (
<div
inert={!open}
className={cn(
'grid transition-[grid-template-rows] duration-300 motion-reduce:transition-none',
open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
)}
>
<div className="min-h-0 overflow-hidden">
<div className={cn(indent && 'ml-3 border-l border-border pl-4')}>{children}</div>
</div>
</div>
)
}
interface SettingsSegProps<T extends string> {
value: T
onChange: (value: T) => void
options: Array<{ value: T; label: React.ReactNode }>
'aria-label': string
disabled?: boolean
}
/** Quiet segmented control (theme, language, plan interval, sv/en texts). */
export function SettingsSeg<T extends string>({
value,
onChange,
options,
'aria-label': ariaLabel,
disabled,
}: SettingsSegProps<T>) {
return (
<div role="group" aria-label={ariaLabel} className="inline-flex items-center gap-1 rounded-lg bg-muted/70 p-1">
{options.map((o) => (
<button
key={o.value}
type="button"
disabled={disabled}
aria-pressed={o.value === value}
onClick={() => onChange(o.value)}
className={cn(
'rounded-md px-3 py-1 text-xs transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-60',
o.value === value
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{o.label}
</button>
))}
</div>
)
}
/** Terracotta-tinted trailing block for destructive actions. */
export function SettingsDangerZone({ label, children }: { label: string; children: React.ReactNode }) {
return (
<section className="mt-10 border-t border-destructive/30 pt-3">
<p className="px-1 text-[11px] font-medium uppercase tracking-wider text-destructive/80">
{label}
</p>
<div>{children}</div>
</section>
)
}
+54 -46
View File
@@ -2,8 +2,13 @@
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
SettingsGroup,
SettingsInput,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
import { roundOre } from '@/lib/money'
import { formatCurrency } from '@/lib/utils'
@@ -40,49 +45,52 @@ export function ShareCapitalForm({ settings }: ShareCapitalFormProps) {
: null
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('share_capital_heading')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="aktiekapital">{t('aktiekapital_label')}</Label>
<Input
id="aktiekapital"
name="aktiekapital"
type="number"
inputMode="numeric"
min="1"
step="1"
value={aktiekapital}
onChange={(e) => setAktiekapital(e.target.value)}
required={antalAktier.trim() !== ''}
/>
<p className="text-xs text-muted-foreground">{t('aktiekapital_help')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="antal_aktier">{t('antal_aktier_label')}</Label>
<Input
id="antal_aktier"
name="antal_aktier"
type="number"
inputMode="numeric"
min="1"
step="1"
value={antalAktier}
onChange={(e) => setAntalAktier(e.target.value)}
required={aktiekapital.trim() !== ''}
/>
<p className="text-xs text-muted-foreground">{t('antal_aktier_help')}</p>
</div>
</div>
{kvotvarde !== null && (
<p className="text-xs text-muted-foreground tabular-nums">
{t('kvotvarde_display', { value: formatCurrency(kvotvarde) })}
</p>
)}
</section>
<SettingsGroup label={t('share_capital_heading')}>
<SettingsRow
label={t('aktiekapital_label')}
htmlFor="aktiekapital"
help={t('aktiekapital_help')}
align="baseline"
>
<SettingsInput
id="aktiekapital"
name="aktiekapital"
type="number"
inputMode="numeric"
min="1"
step="1"
value={aktiekapital}
onChange={(e) => setAktiekapital(e.target.value)}
required={antalAktier.trim() !== ''}
className="max-w-32 flex-none tabular-nums"
/>
</SettingsRow>
<SettingsRow
label={t('antal_aktier_label')}
htmlFor="antal_aktier"
help={t('antal_aktier_help')}
align="baseline"
>
<SettingsInput
id="antal_aktier"
name="antal_aktier"
type="number"
inputMode="numeric"
min="1"
step="1"
value={antalAktier}
onChange={(e) => setAntalAktier(e.target.value)}
required={aktiekapital.trim() !== ''}
className="max-w-32 flex-none tabular-nums"
/>
{kvotvarde !== null && (
<SettingsRowEnd>
<SettingsRowNote className="tabular-nums">
{t('kvotvarde_display', { value: formatCurrency(kvotvarde) })}
</SettingsRowNote>
</SettingsRowEnd>
)}
</SettingsRow>
</SettingsGroup>
)
}
+162 -187
View File
@@ -2,7 +2,6 @@
import { useTranslations } from 'next-intl'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
@@ -10,6 +9,12 @@ import { useCapability } from '@/contexts/CompanyContext'
import { isAllowedSkvPopupOrigin } from '@/lib/skatteverket/popup-origin'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import {
SettingsGroup,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
import { CheckCircle2, ExternalLink, Loader2, ShieldOff, FlaskConical, ShieldAlert } from 'lucide-react'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -29,12 +34,21 @@ type Status =
disabled?: boolean
}
/** Live warning inside a settings group: one warning-tone line, no banner. */
function WarningLine({ children }: { children: React.ReactNode }) {
return (
<p className="border-b border-border px-1 py-3 text-[12.5px] leading-relaxed text-attn">
{children}
</p>
)
}
export function SkatteverketConnectPanel() {
return (
<div className="space-y-4">
<>
<SkatteverketPersonalConnectionCard />
<SkatteverketSystemConnectionCard />
</div>
</>
)
}
@@ -83,9 +97,10 @@ function SkatteverketPersonalConnectionCard() {
agd: t('scope_agd'),
}
// Only the first load blanks the card to the loading state: later refetches
// (postMessage, closed-tab watcher, delayed sync refetch, visibility) update
// in the background so the panel doesn't flash on every signal.
// Only the first load blanks the section to the loading state: later
// refetches (postMessage, closed-tab watcher, delayed sync refetch,
// visibility) update in the background so the panel doesn't flash on every
// signal.
const hasLoadedRef = useRef(false)
const loadStatus = useCallback(async () => {
if (!hasLoadedRef.current) setLoading(true)
@@ -236,64 +251,61 @@ function SkatteverketPersonalConnectionCard() {
}
}
// Static connect guidance lives behind the "?": what the connection is
// used for, plus the consent-page instructions ("godkänn alla
// behörigheter", the ska/skahmst explainer). The consent notes only matter
// when the user can actually reach the consent page: hidden while the
// feature is entitlement-gated.
const connectHelp = (
<div className="space-y-2">
<p>{t('connect_intro')}</p>
{hasSkatteverket && (
<>
<p>{t('connect_approve_all')}</p>
<p>
{t.rich('skahmst_note', {
code: (chunks) => <span className="font-mono">{chunks}</span>,
})}
</p>
</>
)}
</div>
)
if (loading) {
return (
<Card>
<CardContent className="py-8 text-sm text-muted-foreground">
{t('loading_status')}
</CardContent>
</Card>
<SettingsGroup>
<SettingsRow label={t('title')} help={connectHelp} borderless>
<SettingsRowNote>{t('loading_status')}</SettingsRowNote>
</SettingsRow>
</SettingsGroup>
)
}
if (!status?.connected) {
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">{t('title')}</CardTitle>
<EnvironmentBadge environment={status?.environment} disabled={status?.disabled} />
</div>
</CardHeader>
<CardContent className="space-y-4">
{status?.disabled && (
<div className="flex gap-2 rounded-md border border-border bg-secondary/40 p-3 text-sm text-foreground">
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
<p>{t('disabled_message')}</p>
</div>
)}
<p className="text-sm text-muted-foreground">
{t('connect_intro')}
</p>
{/* The consent-page notes only matter when the user can actually
reach that page: hidden while the feature is gated. */}
{hasSkatteverket && (
<div className="space-y-2 rounded-md border border-border bg-secondary/40 p-3 text-xs text-muted-foreground">
{/* Pre-empt the most common broken connect: a behörighet left
unticked on SKV's consent page. Before this note the
"godkänn alla" guidance only appeared AFTER a failed
attempt (missing_scope_message). */}
<p className="text-foreground">{t('connect_approve_all')}</p>
<p>
{t.rich('skahmst_note', {
code: (chunks) => <span className="font-mono">{chunks}</span>,
})}
</p>
</div>
)}
{!hasSkatteverket && (
<SettingsGroup>
{status?.disabled && <WarningLine>{t('disabled_message')}</WarningLine>}
<SettingsRow label={t('title')} help={connectHelp} borderless={!hasSkatteverket}>
<EnvironmentBadge environment={status?.environment} disabled={status?.disabled} />
<SettingsRowEnd>
<Button
size="sm"
onClick={startConnect}
disabled={status?.disabled || !hasSkatteverket || connecting}
title={!hasSkatteverket ? 'Anslutning till Skatteverket kräver ett abonnemang' : undefined}
>
<ExternalLink className="mr-2 h-4 w-4" />
{connecting ? t('connect_waiting') : t('connect_with_bankid')}
</Button>
</SettingsRowEnd>
</SettingsRow>
{!hasSkatteverket && (
<div className="px-1 py-3">
<UpgradeNote>Anslutning till Skatteverket kräver ett abonnemang.</UpgradeNote>
)}
<Button
onClick={startConnect}
disabled={status?.disabled || !hasSkatteverket || connecting}
title={!hasSkatteverket ? 'Anslutning till Skatteverket kräver ett abonnemang' : undefined}
>
<ExternalLink className="mr-2 h-4 w-4" />
{connecting ? t('connect_waiting') : t('connect_with_bankid')}
</Button>
</CardContent>
</Card>
</div>
)}
</SettingsGroup>
)
}
@@ -304,95 +316,35 @@ function SkatteverketPersonalConnectionCard() {
)
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
{t('title')}
{status.expired ? (
<Badge variant="destructive">{t('expired')}</Badge>
) : (
<Badge variant="secondary">
<CheckCircle2 className="mr-1 h-3 w-3" />
{t('connected')}
</Badge>
)}
</CardTitle>
<EnvironmentBadge environment={status.environment} disabled={status.disabled} />
</div>
</CardHeader>
<CardContent className="space-y-4">
{status.needsReconsent && (
<div className="flex gap-2 rounded-md border border-border bg-secondary/40 p-3 text-sm text-foreground">
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
{/* MISSING_SCOPE right after a connect means the user skipped a
behörighet on SKV's consent page; tell them exactly that
instead of the generic "session expired" prompt. */}
<p>
{status.lastErrorCode === 'MISSING_SCOPE'
? t('missing_scope_message')
: t('needs_reconsent_message')}
</p>
</div>
<SettingsGroup>
{status.needsReconsent && (
<WarningLine>
{/* MISSING_SCOPE right after a connect means the user skipped a
behörighet on SKV's consent page; tell them exactly that
instead of the generic "session expired" prompt. */}
{status.lastErrorCode === 'MISSING_SCOPE'
? t('missing_scope_message')
: t('needs_reconsent_message')}
</WarningLine>
)}
<SettingsRow label={t('title')} help={connectHelp}>
{status.expired ? (
<Badge variant="warning">{t('expired')}</Badge>
) : (
<Badge variant="success">
<CheckCircle2 className="mr-1 h-3 w-3" />
{t('connected')}
</Badge>
)}
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">{t('token_expires_label')}</dt>
<dd className="font-medium tabular-nums">
{expiresAtDate.toLocaleString('sv-SE')}
{!status.expired && expiresInMinutes > 0 && (
<span className="ml-2 text-muted-foreground">
{t('expires_in_minutes', { minutes: expiresInMinutes })}
</span>
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">{t('refresh_label')}</dt>
<dd className="font-medium">
{status.canRefresh ? t('refresh_auto') : t('refresh_exhausted')}
</dd>
</div>
</dl>
<div>
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">
{t('permissions_label')}
</p>
<div className="flex flex-wrap gap-2">
{scopes.map(s => (
<Badge key={s} variant="outline">
{SCOPE_LABELS[s] ?? s}
</Badge>
))}
</div>
{/* `ska` is the scope the interactive skattekonto API enforces;
skahmst (bulk E-transport service) does not substitute for it. */}
{!scopes.includes('ska') && (
<p className="mt-3 text-sm text-foreground">
{t('missing_skattekonto')}
</p>
)}
{!scopes.includes('agd') && (
<p className="mt-3 text-sm text-foreground">
{t('missing_agd')}
</p>
)}
</div>
{status.disabled && (
<div className="flex gap-2 rounded-md border border-border bg-secondary/40 p-3 text-sm text-foreground">
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
<p>{t('disabled_filings_message')}</p>
</div>
)}
<div className="flex gap-2 pt-2">
<EnvironmentBadge environment={status.environment} disabled={status.disabled} />
<SettingsRowEnd>
{/* `ska` gates the interactive skattekonto API (saldo +
transaktioner); a grant without it cannot sync, so offer the
reconnect even while the token is otherwise healthy. */}
{(status.expired || status.needsReconsent || !status.canRefresh || !scopes.includes('ska') || !scopes.includes('agd')) && (
<Button
size="sm"
onClick={startConnect}
disabled={status.disabled || !hasSkatteverket || connecting}
title={!hasSkatteverket ? 'Anslutning till Skatteverket kräver ett abonnemang' : undefined}
@@ -403,15 +355,48 @@ function SkatteverketPersonalConnectionCard() {
)}
<Button
variant="outline"
size="sm"
onClick={disconnect}
disabled={disconnecting || connecting}
>
<ShieldOff className="mr-2 h-4 w-4" />
{disconnecting ? t('disconnecting') : t('disconnect')}
</Button>
</SettingsRowEnd>
</SettingsRow>
<SettingsRow label={t('token_expires_label')}>
<SettingsRowNote className="tabular-nums">
{expiresAtDate.toLocaleString('sv-SE')}
{!status.expired && expiresInMinutes > 0 && (
<> {t('expires_in_minutes', { minutes: expiresInMinutes })}</>
)}
</SettingsRowNote>
</SettingsRow>
<SettingsRow label={t('refresh_label')}>
<SettingsRowNote>
{status.canRefresh ? t('refresh_auto') : t('refresh_exhausted')}
</SettingsRowNote>
</SettingsRow>
<SettingsRow label={t('permissions_label')}>
<div className="flex flex-wrap gap-2">
{scopes.map(s => (
<Badge key={s} variant="outline">
{SCOPE_LABELS[s] ?? s}
</Badge>
))}
</div>
</CardContent>
</Card>
</SettingsRow>
{/* `ska` is the scope the interactive skattekonto API enforces;
skahmst (bulk E-transport service) does not substitute for it.
Missing scopes are actionable state: keep them visible. */}
{!scopes.includes('ska') && <WarningLine>{t('missing_skattekonto')}</WarningLine>}
{!scopes.includes('agd') && <WarningLine>{t('missing_agd')}</WarningLine>}
{status.disabled && <WarningLine>{t('disabled_filings_message')}</WarningLine>}
</SettingsGroup>
)
}
@@ -510,54 +495,44 @@ function SkatteverketSystemConnectionCard() {
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('system_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{t('system_intro')}</p>
<SettingsGroup label={t('system_title')} help={t('system_intro')}>
{state.ombud_org_number && (
<SettingsRow label={t('system_org_label')}>
<span className="font-mono text-sm tabular-nums">{state.ombud_org_number}</span>
</SettingsRow>
)}
{state.ombud_org_number && (
<div className="rounded-md border border-border bg-secondary/40 p-3 text-sm">
<p className="text-muted-foreground">{t('system_org_label')}</p>
<p className="font-mono font-medium tabular-nums">{state.ombud_org_number}</p>
</div>
<SettingsRow label={t('system_behorighet_lasombud')}>
{grantBadge(state.connection?.lasombud_status)}
</SettingsRow>
<SettingsRow label={t('system_behorighet_moms')}>
{grantBadge(state.connection?.moms_ombud_status)}
</SettingsRow>
{state.cert?.expiresSoon && (
<WarningLine>
{t('system_cert_expires_soon', { days: state.cert.daysUntilExpiry })}
</WarningLine>
)}
<div className="flex flex-wrap items-center gap-4 px-1 py-3">
{state.grant_url && (
<a
href={state.grant_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('system_open_ombud')}
</a>
)}
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div className="flex items-center justify-between gap-2 rounded-md border border-border p-3">
<span>{t('system_behorighet_lasombud')}</span>
{grantBadge(state.connection?.lasombud_status)}
</div>
<div className="flex items-center justify-between gap-2 rounded-md border border-border p-3">
<span>{t('system_behorighet_moms')}</span>
{grantBadge(state.connection?.moms_ombud_status)}
</div>
</div>
{state.cert?.expiresSoon && (
<div className="flex gap-2 rounded-md border border-border bg-secondary/40 p-3 text-sm text-foreground">
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
<p>{t('system_cert_expires_soon', { days: state.cert.daysUntilExpiry })}</p>
</div>
)}
<div className="flex flex-wrap gap-2 pt-1">
{state.grant_url && (
<Button variant="outline" asChild>
<a href={state.grant_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
{t('system_open_ombud')}
</a>
</Button>
)}
<Button onClick={verify} disabled={verifying}>
{verifying && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{verifying ? t('system_verifying') : t('system_verify')}
</Button>
</div>
</CardContent>
</Card>
<Button size="sm" onClick={verify} disabled={verifying}>
{verifying && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{verifying ? t('system_verifying') : t('system_verify')}
</Button>
</div>
</SettingsGroup>
)
}
@@ -2,18 +2,16 @@
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { CalendarClock, Pencil, Trash2 } from 'lucide-react'
import { Pencil, Trash2 } from 'lucide-react'
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
SettingsGroup,
SettingsInput,
SettingsRow,
SettingsRowNote,
SettingsSelect,
} from '@/components/settings/SettingsRows'
import { useToast } from '@/components/ui/use-toast'
import { useErrorToast } from '@/lib/hooks/use-error-toast'
import type {
@@ -142,102 +140,98 @@ export function TaxAssessmentNoticesPanel() {
}
return (
<section className="border-t border-border pt-8" aria-labelledby="tax-assessment-notices-title">
<div className="max-w-3xl">
<div className="flex items-start gap-3">
<CalendarClock className="mt-0.5 h-5 w-5" />
<div>
<h2 id="tax-assessment-notices-title" className="font-display text-xl tracking-tight">
{t('title')}
</h2>
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
{t('description')}
</p>
</div>
</div>
<form onSubmit={saveNotice} className="mt-6 grid gap-4 rounded-lg border border-border p-4 sm:grid-cols-2 sm:p-6">
// What this panel is for ("enter the exact payment date from the
// slutskattebesked") is static guidance: it lives behind the group "?".
<SettingsGroup label={t('title')} help={t('description')}>
<form onSubmit={saveNotice}>
<SettingsRow label={t('fiscal_period')}>
<FiscalYearSelector
value={selectedPeriodId}
onChange={(periodId) => setSelectedPeriodId(periodId)}
includeAllOption={false}
label={t('fiscal_period')}
className="sm:col-span-2"
label={null}
className="w-full max-w-72"
/>
<div className="space-y-1.5">
<Label htmlFor="tax-assessment-decision-type">{t('decision_type')}</Label>
<Select value={decisionType} onValueChange={(value) => setDecisionType(value as TaxAssessmentDecisionType)}>
<SelectTrigger id="tax-assessment-decision-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="final">{t('decision_final')}</SelectItem>
<SelectItem value="reassessment">{t('decision_reassessment')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="tax-assessment-decision-date">{t('decision_date')}</Label>
<Input
id="tax-assessment-decision-date"
type="date"
required
value={decisionDate}
onChange={(event) => setDecisionDate(event.target.value)}
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="tax-assessment-payment-due-date">{t('payment_due_date')}</Label>
<Input
id="tax-assessment-payment-due-date"
type="date"
required
min={decisionDate}
value={paymentDueDate}
onChange={(event) => setPaymentDueDate(event.target.value)}
/>
<p className="text-xs text-muted-foreground">{t('payment_due_date_help')}</p>
</div>
<div className="flex flex-wrap gap-2 sm:col-span-2">
<Button type="submit" disabled={saving || !selectedPeriodId || !paymentDueDate}>
{saving ? t('saving') : t(editingId ? 'update_action' : 'save_action')}
</SettingsRow>
<SettingsRow label={t('decision_type')} htmlFor="tax-assessment-decision-type">
<SettingsSelect
id="tax-assessment-decision-type"
value={decisionType}
onChange={(event) => setDecisionType(event.target.value as TaxAssessmentDecisionType)}
>
<option value="final">{t('decision_final')}</option>
<option value="reassessment">{t('decision_reassessment')}</option>
</SettingsSelect>
</SettingsRow>
<SettingsRow
label={t('decision_date')}
htmlFor="tax-assessment-decision-date"
align="baseline"
>
<SettingsInput
id="tax-assessment-decision-date"
type="date"
required
value={decisionDate}
onChange={(event) => setDecisionDate(event.target.value)}
className="max-w-40 flex-none tabular-nums"
/>
</SettingsRow>
<SettingsRow
label={t('payment_due_date')}
htmlFor="tax-assessment-payment-due-date"
help={t('payment_due_date_help')}
align="baseline"
>
<SettingsInput
id="tax-assessment-payment-due-date"
type="date"
required
min={decisionDate}
value={paymentDueDate}
onChange={(event) => setPaymentDueDate(event.target.value)}
className="max-w-40 flex-none tabular-nums"
/>
</SettingsRow>
<div className="flex flex-wrap gap-2 px-1 py-3">
<Button type="submit" size="sm" disabled={saving || !selectedPeriodId || !paymentDueDate}>
{saving ? t('saving') : t(editingId ? 'update_action' : 'save_action')}
</Button>
{editingId && (
<Button type="button" variant="ghost" size="sm" onClick={resetForm} disabled={saving}>
{t('cancel')}
</Button>
{editingId && (
<Button type="button" variant="ghost" onClick={resetForm} disabled={saving}>
{t('cancel')}
</Button>
)}
</div>
</form>
)}
</div>
</form>
{!loading && notices.length > 0 && (
<div className="mt-6 divide-y divide-border border-y border-border">
{notices.map((notice) => (
<div key={notice.id} className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium">
{notice.decision_type === 'final' ? t('decision_final') : t('decision_reassessment')}
{notice.fiscal_period?.name ? `: ${notice.fiscal_period.name}` : ''}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('due_summary', { date: notice.payment_due_date })}
</p>
</div>
<div className="flex gap-2">
<Button type="button" variant="ghost" size="sm" onClick={() => editNotice(notice)} disabled={saving}>
<Pencil className="mr-2 h-4 w-4" />
{t('edit')}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => void archiveNotice(notice)} disabled={saving}>
<Trash2 className="mr-2 h-4 w-4" />
{t('archive')}
</Button>
</div>
</div>
))}
{/* Registered notices continue as flat hairline rows under the form. */}
{!loading && notices.length > 0 && notices.map((notice) => (
<div
key={notice.id}
className="flex flex-col gap-3 border-t border-border px-1 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<p className="text-sm">
{notice.decision_type === 'final' ? t('decision_final') : t('decision_reassessment')}
{notice.fiscal_period?.name ? `: ${notice.fiscal_period.name}` : ''}
</p>
<SettingsRowNote className="tabular-nums">
{t('due_summary', { date: notice.payment_due_date })}
</SettingsRowNote>
</div>
)}
</div>
</section>
<div className="flex shrink-0 gap-2">
<Button type="button" variant="ghost" size="sm" onClick={() => editNotice(notice)} disabled={saving}>
<Pencil className="mr-2 h-4 w-4" />
{t('edit')}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => void archiveNotice(notice)} disabled={saving}>
<Trash2 className="mr-2 h-4 w-4" />
{t('archive')}
</Button>
</div>
</div>
))}
</SettingsGroup>
)
}
+330 -435
View File
@@ -2,10 +2,15 @@
import { useTranslations } from 'next-intl'
import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { HelpPopover } from '@/components/ui/help-popover'
import {
SettingsGroup,
SettingsInput,
SettingsReveal,
SettingsRow,
SettingsSelect,
} from '@/components/settings/SettingsRows'
import type { CompanySettings } from '@/types'
interface TaxSettingsFormProps {
@@ -18,6 +23,20 @@ interface TaxSettingsFormProps {
rotRutSignalDetected?: boolean
}
/**
* Ledger/invoice-derived suggestion (EU sales, KU, ROT/RUT) as one visible
* warning-tone sentence. The full body, including the legal deadlines and
* late-fee amounts, lives behind the "?" so the signal stays a single line.
*/
function SignalLine({ text, help }: { text: string; help: React.ReactNode }) {
return (
<p className="flex items-center gap-2 border-b border-border px-1 py-3 text-[12.5px] leading-relaxed text-attn">
<span>{text}</span>
<HelpPopover className="shrink-0">{help}</HelpPopover>
</p>
)
}
export function TaxSettingsForm({
settings,
euSalesDetected = false,
@@ -59,320 +78,267 @@ export function TaxSettingsForm({
]
return (
<div className="space-y-8">
{/* Entity type: read-only */}
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('entity_form_heading')}
</h2>
<div className="flex items-center gap-3">
<span className="text-sm font-medium">
<div>
<SettingsGroup label={t('tax_vat_heading')}>
{/* Entity type: read-only. Changing it is a support operation. */}
<SettingsRow label={t('entity_form_heading')} help={t('entity_form_help')}>
<span className="text-sm">
{settings.entity_type === 'aktiebolag' ? t('entity_aktiebolag') : t('entity_enskild_firma')}
</span>
<p className="text-xs text-muted-foreground">
{t('entity_form_help')}
</p>
</div>
</section>
</SettingsRow>
{/* F-skatt */}
<section className="border-t border-border pt-8 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('tax_vat_heading')}
</h2>
<SettingsRow label={t('f_skatt_label')} htmlFor="f_skatt" help={t('f_skatt_help')}>
<Switch
id="f_skatt"
checked={fSkatt}
onCheckedChange={(v) => setFSkatt(v === true)}
/>
<input type="hidden" name="f_skatt" value={fSkatt ? 'true' : 'false'} />
</SettingsRow>
<div className="space-y-4">
<div className="flex items-start space-x-3">
<Checkbox
id="f_skatt"
checked={fSkatt}
onCheckedChange={(v) => setFSkatt(v === true)}
<SettingsRow
label={t('vat_registered_label')}
htmlFor="vat_registered"
help={t('vat_registered_help')}
borderless={vatRegistered}
>
<Switch
id="vat_registered"
checked={vatRegistered}
onCheckedChange={(value) => {
const checked = value === true
setVatRegistered(checked)
if (checked && vatTaxableBaseOver40m) setMomsPeriod('monthly')
}}
/>
<input type="hidden" name="vat_registered" value={vatRegistered ? 'true' : 'false'} />
</SettingsRow>
{euSalesDetected && vatRegistered && (!hasEuTrade || !psEnabled) && (
<SignalLine
text={t('eu_trade_suggestion_title')}
help={t('eu_trade_suggestion_help')}
/>
)}
{/* The VAT sub-block. The reveal keeps the fields mounted while
hidden (inert): the handleSave gates on vat_registered in
TaxSettingsContent make the saved result identical to the old
unmount behavior. */}
<SettingsReveal open={vatRegistered} indent>
<SettingsRow
label={t('vat_number_label')}
htmlFor="vat_number"
help={t('vat_number_help')}
align="baseline"
>
<SettingsInput
id="vat_number"
name="vat_number"
placeholder="SE123456789001"
defaultValue={settings.vat_number || ''}
/>
<input type="hidden" name="f_skatt" value={fSkatt ? 'true' : 'false'} />
<div className="space-y-1">
<Label htmlFor="f_skatt" className="cursor-pointer">{t('f_skatt_label')}</Label>
<p className="text-xs text-muted-foreground">
{t('f_skatt_help')}
</p>
</div>
</div>
</SettingsRow>
<div className="flex items-start space-x-3">
<Checkbox
id="vat_registered"
checked={vatRegistered}
<SettingsRow
label={t('moms_period_label')}
htmlFor="moms_period"
help={t('moms_period_help')}
>
<SettingsSelect
id="moms_period"
name="moms_period"
value={momsPeriod}
onChange={(e) => setMomsPeriod(e.target.value)}
>
<option value="" disabled>
{t('select_period_placeholder')}
</option>
<option value="monthly">{t('period_monthly')}</option>
<option value="quarterly" disabled={vatTaxableBaseOver40m}>
{t('period_quarterly')}
</option>
<option value="yearly" disabled={vatTaxableBaseOver40m}>
{t('period_yearly')}
</option>
</SettingsSelect>
</SettingsRow>
<SettingsRow
label={t('vat_taxable_base_over_40m_label')}
htmlFor="vat_taxable_base_over_40m"
help={t('vat_taxable_base_over_40m_help')}
>
<Switch
id="vat_taxable_base_over_40m"
checked={vatTaxableBaseOver40m}
onCheckedChange={(value) => {
const checked = value === true
setVatRegistered(checked)
if (checked && vatTaxableBaseOver40m) setMomsPeriod('monthly')
setVatTaxableBaseOver40m(checked)
// Over 40 MSEK forces monthly VAT reporting.
if (checked) setMomsPeriod('monthly')
}}
/>
<input type="hidden" name="vat_registered" value={vatRegistered ? 'true' : 'false'} />
<div className="space-y-1">
<Label htmlFor="vat_registered" className="cursor-pointer">{t('vat_registered_label')}</Label>
<p className="text-xs text-muted-foreground">
{t('vat_registered_help')}
</p>
</div>
</div>
<input
type="hidden"
name="vat_taxable_base_over_40m"
value={vatTaxableBaseOver40m ? 'true' : 'false'}
/>
</SettingsRow>
{euSalesDetected && vatRegistered && (!hasEuTrade || !psEnabled) && (
<div className="rounded-lg border border-border p-4">
<p className="text-sm">{t('eu_trade_suggestion_title')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('eu_trade_suggestion_help')}
</p>
</div>
<SettingsRow
label={t('vat_has_eu_trade_label')}
htmlFor="vat_has_eu_trade"
help={t('vat_has_eu_trade_help')}
borderless={hasEuTrade}
>
<Switch
id="vat_has_eu_trade"
checked={hasEuTrade}
onCheckedChange={(value) => {
const checked = value === true
setHasEuTrade(checked)
// No EU trade means no periodisk sammanställning.
if (!checked) setPsEnabled(false)
}}
/>
<input type="hidden" name="vat_has_eu_trade" value={hasEuTrade ? 'true' : 'false'} />
</SettingsRow>
{momsPeriod === 'yearly' && !hasEuTrade && !isEnskildFirma && (
<SettingsRow
label={t('vat_filing_method_label')}
htmlFor="vat_filing_method"
help={t('vat_filing_method_help')}
borderless
>
<SettingsSelect
id="vat_filing_method"
name="vat_filing_method"
defaultValue={settings.vat_filing_method || 'electronic'}
>
<option value="electronic">{t('filing_method_electronic')}</option>
<option value="paper">{t('filing_method_paper')}</option>
</SettingsSelect>
</SettingsRow>
)}
{vatRegistered && (
<div className="space-y-4 pl-7">
<div className="max-w-xs space-y-2">
<Label htmlFor="vat_number">{t('vat_number_label')}</Label>
<Input
id="vat_number"
name="vat_number"
placeholder="SE123456789001"
defaultValue={settings.vat_number || ''}
/>
<p className="text-xs text-muted-foreground">
{t('vat_number_help')}
</p>
</div>
<SettingsReveal open={hasEuTrade}>
<SettingsRow
label={t('periodisk_enabled_label')}
htmlFor="periodisk_sammanstallning_enabled"
help={t('periodisk_enabled_help')}
borderless={psEnabled}
>
<Switch
id="periodisk_sammanstallning_enabled"
checked={psEnabled}
onCheckedChange={(value) => setPsEnabled(value === true)}
/>
<input
type="hidden"
name="periodisk_sammanstallning_enabled"
value={psEnabled ? 'true' : 'false'}
/>
</SettingsRow>
<div className="max-w-xs space-y-2">
<Label>{t('moms_period_label')}</Label>
<Select
name="moms_period"
value={momsPeriod || undefined}
onValueChange={setMomsPeriod}
<SettingsReveal open={psEnabled}>
<SettingsRow
label={t('periodisk_label')}
htmlFor="periodisk_sammanstallning_period"
help={t('periodisk_help')}
>
<SettingsSelect
id="periodisk_sammanstallning_period"
name="periodisk_sammanstallning_period"
defaultValue={settings.periodisk_sammanstallning_period || 'monthly'}
>
<SelectTrigger>
<SelectValue placeholder={t('select_period_placeholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">{t('period_monthly')}</SelectItem>
<SelectItem value="quarterly" disabled={vatTaxableBaseOver40m}>
{t('period_quarterly')}
</SelectItem>
<SelectItem value="yearly" disabled={vatTaxableBaseOver40m}>
{t('period_yearly')}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t('moms_period_help')}
</p>
</div>
<option value="monthly">{t('period_monthly')}</option>
<option value="quarterly">{t('period_quarterly')}</option>
</SettingsSelect>
</SettingsRow>
<div className="flex items-start space-x-3">
<Checkbox
id="vat_taxable_base_over_40m"
checked={vatTaxableBaseOver40m}
onCheckedChange={(value) => {
const checked = value === true
setVatTaxableBaseOver40m(checked)
if (checked) setMomsPeriod('monthly')
}}
/>
<input
type="hidden"
name="vat_taxable_base_over_40m"
value={vatTaxableBaseOver40m ? 'true' : 'false'}
/>
<div className="space-y-1">
<Label htmlFor="vat_taxable_base_over_40m" className="cursor-pointer">
{t('vat_taxable_base_over_40m_label')}
</Label>
<p className="text-xs text-muted-foreground">
{t('vat_taxable_base_over_40m_help')}
</p>
</div>
</div>
<SettingsRow
label={t('periodisk_filing_method_label')}
htmlFor="periodisk_sammanstallning_filing_method"
help={t('periodisk_filing_method_help')}
borderless
>
<SettingsSelect
id="periodisk_sammanstallning_filing_method"
name="periodisk_sammanstallning_filing_method"
defaultValue={settings.periodisk_sammanstallning_filing_method || 'electronic'}
>
<option value="electronic">{t('filing_method_electronic')}</option>
<option value="paper">{t('filing_method_paper')}</option>
</SettingsSelect>
</SettingsRow>
</SettingsReveal>
</SettingsReveal>
</SettingsReveal>
</SettingsGroup>
<div className="flex items-start space-x-3">
<Checkbox
id="vat_has_eu_trade"
checked={hasEuTrade}
onCheckedChange={(value) => {
const checked = value === true
setHasEuTrade(checked)
if (!checked) setPsEnabled(false)
}}
/>
<input type="hidden" name="vat_has_eu_trade" value={hasEuTrade ? 'true' : 'false'} />
<div className="space-y-1">
<Label htmlFor="vat_has_eu_trade" className="cursor-pointer">
{t('vat_has_eu_trade_label')}
</Label>
<p className="text-xs text-muted-foreground">
{t('vat_has_eu_trade_help')}
</p>
</div>
</div>
{momsPeriod === 'yearly' && !hasEuTrade && !isEnskildFirma && (
<div className="max-w-xs space-y-2">
<Label>{t('vat_filing_method_label')}</Label>
<Select
name="vat_filing_method"
defaultValue={settings.vat_filing_method || 'electronic'}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="electronic">{t('filing_method_electronic')}</SelectItem>
<SelectItem value="paper">{t('filing_method_paper')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t('vat_filing_method_help')}</p>
</div>
)}
{hasEuTrade && (
<div className="space-y-4">
<div className="flex items-start space-x-3">
<Checkbox
id="periodisk_sammanstallning_enabled"
checked={psEnabled}
onCheckedChange={(value) => setPsEnabled(value === true)}
/>
<input
type="hidden"
name="periodisk_sammanstallning_enabled"
value={psEnabled ? 'true' : 'false'}
/>
<div className="space-y-1">
<Label htmlFor="periodisk_sammanstallning_enabled" className="cursor-pointer">
{t('periodisk_enabled_label')}
</Label>
<p className="text-xs text-muted-foreground">{t('periodisk_enabled_help')}</p>
</div>
</div>
{psEnabled && (
<div className="grid max-w-2xl grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('periodisk_label')}</Label>
<Select
name="periodisk_sammanstallning_period"
defaultValue={settings.periodisk_sammanstallning_period || 'monthly'}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="monthly">{t('period_monthly')}</SelectItem>
<SelectItem value="quarterly">{t('period_quarterly')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t('periodisk_help')}</p>
</div>
<div className="space-y-2">
<Label>{t('periodisk_filing_method_label')}</Label>
<Select
name="periodisk_sammanstallning_filing_method"
defaultValue={settings.periodisk_sammanstallning_filing_method || 'electronic'}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="electronic">{t('filing_method_electronic')}</SelectItem>
<SelectItem value="paper">{t('filing_method_paper')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t('periodisk_filing_method_help')}
</p>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
</section>
{/* Tax contact: required for SKV-filings */}
<section className="border-t border-border pt-8 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('tax_contact_heading')}
</h2>
<p className="text-xs text-muted-foreground -mt-2">
{t('tax_contact_help')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-2xl">
<div className="space-y-2">
<Label htmlFor="tax_contact_name">{t('tax_contact_name_label')}</Label>
<Input
id="tax_contact_name"
name="tax_contact_name"
defaultValue={settings.tax_contact_name || ''}
placeholder={t('tax_contact_name_placeholder')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tax_contact_phone">{t('tax_contact_phone_label')}</Label>
<Input
id="tax_contact_phone"
name="tax_contact_phone"
defaultValue={settings.tax_contact_phone || ''}
placeholder="08-123 45 67"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="tax_contact_email">{t('tax_contact_email_label')}</Label>
<Input
id="tax_contact_email"
name="tax_contact_email"
type="email"
defaultValue={settings.tax_contact_email || ''}
placeholder="anna@foretaget.se"
/>
</div>
</div>
</section>
{/* Tax contact: required for SKV filings. */}
<SettingsGroup label={t('tax_contact_heading')} help={t('tax_contact_help')}>
<SettingsRow label={t('tax_contact_name_label')} htmlFor="tax_contact_name" align="baseline">
<SettingsInput
id="tax_contact_name"
name="tax_contact_name"
defaultValue={settings.tax_contact_name || ''}
placeholder={t('tax_contact_name_placeholder')}
/>
</SettingsRow>
<SettingsRow label={t('tax_contact_phone_label')} htmlFor="tax_contact_phone" align="baseline">
<SettingsInput
id="tax_contact_phone"
name="tax_contact_phone"
defaultValue={settings.tax_contact_phone || ''}
placeholder="08-123 45 67"
/>
</SettingsRow>
<SettingsRow label={t('tax_contact_email_label')} htmlFor="tax_contact_email" align="baseline">
<SettingsInput
id="tax_contact_email"
name="tax_contact_email"
type="email"
defaultValue={settings.tax_contact_email || ''}
placeholder="anna@foretaget.se"
/>
</SettingsRow>
</SettingsGroup>
{/* Fiscal year & salaries */}
<section className="border-t border-border pt-8 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('fiscal_year_salaries_heading')}
</h2>
<div className="max-w-xs space-y-2">
<Label>{t('fiscal_year_start_label')}</Label>
<SettingsGroup label={t('fiscal_year_salaries_heading')}>
<SettingsRow
label={t('fiscal_year_start_label')}
htmlFor="fiscal_year_start_month"
help={isEnskildFirma ? t('fiscal_year_ef_help') : t('fiscal_year_change_help')}
>
{isEnskildFirma ? (
<>
<Input value={t('month_jan')} disabled />
<SettingsInput
id="fiscal_year_start_month"
value={t('month_jan')}
disabled
className="max-w-32 flex-none"
/>
<input type="hidden" name="fiscal_year_start_month" value="1" />
<p className="text-xs text-muted-foreground">
{t('fiscal_year_ef_help')}
</p>
</>
) : (
<>
<Select
name="fiscal_year_start_month"
defaultValue={String(settings.fiscal_year_start_month || 1)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{months.map((month, i) => (
<SelectItem key={i + 1} value={String(i + 1)}>{month}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t('fiscal_year_change_help')}
</p>
</>
<SettingsSelect
id="fiscal_year_start_month"
name="fiscal_year_start_month"
defaultValue={String(settings.fiscal_year_start_month || 1)}
>
{months.map((month, i) => (
<option key={i + 1} value={String(i + 1)}>{month}</option>
))}
</SettingsSelect>
)}
</div>
</SettingsRow>
<div className="flex items-start space-x-3">
<Checkbox
<SettingsRow label={t('pays_salaries_label')} htmlFor="pays_salaries" help={t('pays_salaries_help')}>
<Switch
id="pays_salaries"
checked={paysSalaries}
onCheckedChange={(v) => {
@@ -383,16 +349,15 @@ export function TaxSettingsForm({
}}
/>
<input type="hidden" name="pays_salaries" value={paysSalaries ? 'true' : 'false'} />
<div className="space-y-1">
<Label htmlFor="pays_salaries" className="cursor-pointer">{t('pays_salaries_label')}</Label>
<p className="text-xs text-muted-foreground">
{t('pays_salaries_help')}
</p>
</div>
</div>
</SettingsRow>
<div className="flex items-start space-x-3">
<Checkbox
<SettingsRow
label={t('employer_registered_label')}
htmlFor="employer_registered"
help={t('employer_registered_help')}
borderless={employerRegistered}
>
<Switch
id="employer_registered"
checked={employerRegistered}
onCheckedChange={(v) => {
@@ -406,19 +371,16 @@ export function TaxSettingsForm({
name="employer_registered"
value={employerRegistered ? 'true' : 'false'}
/>
<div className="space-y-1">
<Label htmlFor="employer_registered" className="cursor-pointer">
{t('employer_registered_label')}
</Label>
<p className="text-xs text-muted-foreground">
{t('employer_registered_help')}
</p>
</div>
</div>
</SettingsRow>
{employerRegistered && (
<div className="flex items-start space-x-3 pl-7">
<Checkbox
<SettingsReveal open={employerRegistered}>
<SettingsRow
label={t('employer_seasonal_label')}
htmlFor="employer_seasonal"
help={t('employer_seasonal_help')}
borderless
>
<Switch
id="employer_seasonal"
checked={employerSeasonal}
onCheckedChange={(v) => setEmployerSeasonal(v === true)}
@@ -428,35 +390,22 @@ export function TaxSettingsForm({
name="employer_seasonal"
value={employerSeasonal ? 'true' : 'false'}
/>
<div className="space-y-1">
<Label htmlFor="employer_seasonal" className="cursor-pointer">
{t('employer_seasonal_label')}
</Label>
<p className="text-xs text-muted-foreground">
{t('employer_seasonal_help')}
</p>
</div>
</div>
)}
</section>
</SettingsRow>
</SettingsReveal>
</SettingsGroup>
{/* Kontrolluppgifter (KU) */}
<section className="border-t border-border pt-8 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('kontrolluppgifter_heading')}
</h2>
<SettingsGroup label={t('kontrolluppgifter_heading')}>
{kuSignalDetected && !kuEnabled && (
<div className="rounded-lg border border-border p-4">
<p className="text-sm">{t('ku_suggestion_title')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('ku_suggestion_help')}
</p>
</div>
<SignalLine text={t('ku_suggestion_title')} help={t('ku_suggestion_help')} />
)}
<div className="flex items-start space-x-3">
<Checkbox
<SettingsRow
label={t('kontrolluppgifter_label')}
htmlFor="kontrolluppgifter_enabled"
help={t('kontrolluppgifter_help')}
>
<Switch
id="kontrolluppgifter_enabled"
checked={kuEnabled}
onCheckedChange={(v) => setKuEnabled(v === true)}
@@ -466,34 +415,21 @@ export function TaxSettingsForm({
name="kontrolluppgifter_enabled"
value={kuEnabled ? 'true' : 'false'}
/>
<div className="space-y-1">
<Label htmlFor="kontrolluppgifter_enabled" className="cursor-pointer">
{t('kontrolluppgifter_label')}
</Label>
<p className="text-xs text-muted-foreground">
{t('kontrolluppgifter_help')}
</p>
</div>
</div>
</section>
</SettingsRow>
</SettingsGroup>
{/* ROT/RUT */}
<section className="border-t border-border pt-8 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('rot_rut_heading')}
</h2>
<SettingsGroup label={t('rot_rut_heading')}>
{rotRutSignalDetected && !rotRutEnabled && (
<div className="rounded-lg border border-border p-4">
<p className="text-sm">{t('rot_rut_suggestion_title')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('rot_rut_suggestion_help')}
</p>
</div>
<SignalLine text={t('rot_rut_suggestion_title')} help={t('rot_rut_suggestion_help')} />
)}
<div className="flex items-start space-x-3">
<Checkbox
<SettingsRow
label={t('rot_rut_label')}
htmlFor="rot_rut_enabled"
help={t('rot_rut_help')}
>
<Switch
id="rot_rut_enabled"
checked={rotRutEnabled}
onCheckedChange={(v) => setRotRutEnabled(v === true)}
@@ -503,78 +439,51 @@ export function TaxSettingsForm({
name="rot_rut_enabled"
value={rotRutEnabled ? 'true' : 'false'}
/>
<div className="space-y-1">
<Label htmlFor="rot_rut_enabled" className="cursor-pointer">
{t('rot_rut_label')}
</Label>
<p className="text-xs text-muted-foreground">
{t('rot_rut_help')}
</p>
</div>
</div>
</section>
</SettingsRow>
</SettingsGroup>
{/* Preliminary tax */}
<section className="border-t border-border pt-8 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('preliminary_tax_heading')}
</h2>
<div className="max-w-xs space-y-2">
<Label htmlFor="preliminary_tax_monthly">
{t('preliminary_tax_monthly_label')}
</Label>
<Input
<SettingsGroup label={t('preliminary_tax_heading')}>
<SettingsRow
label={t('preliminary_tax_monthly_label')}
htmlFor="preliminary_tax_monthly"
help={t('preliminary_tax_monthly_help')}
align="baseline"
>
<SettingsInput
id="preliminary_tax_monthly"
name="preliminary_tax_monthly"
type="number"
defaultValue={settings.preliminary_tax_monthly || ''}
className="max-w-32 flex-none tabular-nums"
/>
<p className="text-xs text-muted-foreground">
{t('preliminary_tax_monthly_help')}
</p>
</div>
</section>
</SettingsRow>
</SettingsGroup>
{/* Long-tail deadlines: explicit opt-in only */}
<section className="border-t border-border pt-8 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('more_deadlines_heading')}
</h2>
<p className="text-xs text-muted-foreground -mt-2">
{t('more_deadlines_help')}
</p>
<SettingsGroup label={t('more_deadlines_heading')} help={t('more_deadlines_help')}>
{vatRegistered && (
<>
<div className="flex items-start space-x-3">
<Checkbox
<SettingsRow label={t('oss_label')} htmlFor="oss_enabled" help={t('oss_help')}>
<Switch
id="oss_enabled"
checked={ossEnabled}
onCheckedChange={(v) => setOssEnabled(v === true)}
/>
<input type="hidden" name="oss_enabled" value={ossEnabled ? 'true' : 'false'} />
<div className="space-y-1">
<Label htmlFor="oss_enabled" className="cursor-pointer">{t('oss_label')}</Label>
<p className="text-xs text-muted-foreground">{t('oss_help')}</p>
</div>
</div>
</SettingsRow>
<div className="flex items-start space-x-3">
<Checkbox
<SettingsRow label={t('ioss_label')} htmlFor="ioss_enabled" help={t('ioss_help')}>
<Switch
id="ioss_enabled"
checked={iossEnabled}
onCheckedChange={(v) => setIossEnabled(v === true)}
/>
<input type="hidden" name="ioss_enabled" value={iossEnabled ? 'true' : 'false'} />
<div className="space-y-1">
<Label htmlFor="ioss_enabled" className="cursor-pointer">{t('ioss_label')}</Label>
<p className="text-xs text-muted-foreground">{t('ioss_help')}</p>
</div>
</div>
</SettingsRow>
<div className="flex items-start space-x-3">
<Checkbox
<SettingsRow label={t('intrastat_label')} htmlFor="intrastat_enabled" help={t('intrastat_help')}>
<Switch
id="intrastat_enabled"
checked={intrastatEnabled}
onCheckedChange={(v) => setIntrastatEnabled(v === true)}
@@ -584,18 +493,12 @@ export function TaxSettingsForm({
name="intrastat_enabled"
value={intrastatEnabled ? 'true' : 'false'}
/>
<div className="space-y-1">
<Label htmlFor="intrastat_enabled" className="cursor-pointer">
{t('intrastat_label')}
</Label>
<p className="text-xs text-muted-foreground">{t('intrastat_help')}</p>
</div>
</div>
</SettingsRow>
</>
)}
<div className="flex items-start space-x-3">
<Checkbox
<SettingsRow label={t('punktskatt_label')} htmlFor="punktskatt_enabled" help={t('punktskatt_help')}>
<Switch
id="punktskatt_enabled"
checked={punktskattEnabled}
onCheckedChange={(v) => setPunktskattEnabled(v === true)}
@@ -605,16 +508,14 @@ export function TaxSettingsForm({
name="punktskatt_enabled"
value={punktskattEnabled ? 'true' : 'false'}
/>
<div className="space-y-1">
<Label htmlFor="punktskatt_enabled" className="cursor-pointer">
{t('punktskatt_label')}
</Label>
<p className="text-xs text-muted-foreground">{t('punktskatt_help')}</p>
</div>
</div>
</SettingsRow>
<div className="flex items-start space-x-3">
<Checkbox
<SettingsRow
label={t('fyllnadsinbetalning_label')}
htmlFor="fyllnadsinbetalning_enabled"
help={t('fyllnadsinbetalning_help')}
>
<Switch
id="fyllnadsinbetalning_enabled"
checked={fyllnadEnabled}
onCheckedChange={(v) => setFyllnadEnabled(v === true)}
@@ -624,14 +525,8 @@ export function TaxSettingsForm({
name="fyllnadsinbetalning_enabled"
value={fyllnadEnabled ? 'true' : 'false'}
/>
<div className="space-y-1">
<Label htmlFor="fyllnadsinbetalning_enabled" className="cursor-pointer">
{t('fyllnadsinbetalning_label')}
</Label>
<p className="text-xs text-muted-foreground">{t('fyllnadsinbetalning_help')}</p>
</div>
</div>
</section>
</SettingsRow>
</SettingsGroup>
</div>
)
}
+25 -37
View File
@@ -2,8 +2,8 @@
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Loader2, Users } from 'lucide-react'
import { Loader2 } from 'lucide-react'
import { SettingsGroup } from '@/components/settings/SettingsRows'
interface TeamMember {
id: string
@@ -57,41 +57,29 @@ export function TeamPanel() {
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-4 w-4" />
{teamName || t('team_fallback')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="divide-y divide-border/40">
{members.map((member) => (
<div key={member.id} className="flex items-center justify-between py-3 first:pt-0 last:pb-0">
<div className="flex items-center gap-3 min-w-0">
<div className="h-8 w-8 rounded-full bg-muted/60 flex items-center justify-center flex-shrink-0">
<span className="text-xs font-medium text-muted-foreground">
{member.email.charAt(0).toUpperCase()}
</span>
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{member.email}
{member.is_current_user && (
<span className="text-muted-foreground font-normal ml-1">{t('you_suffix')}</span>
)}
</p>
<p className="text-xs text-muted-foreground">
{roleLabel(member.role)}
</p>
</div>
</div>
</div>
))}
<SettingsGroup label={teamName || t('team_fallback')}>
{/* Read-only member roster: flat hairline rows, no cards. */}
{members.map((member) => (
<div
key={member.id}
className="flex items-center gap-3 border-b border-border px-1 py-3"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted/60">
<span className="text-xs font-medium text-muted-foreground">
{member.email.charAt(0).toUpperCase()}
</span>
</div>
</CardContent>
</Card>
</div>
<p className="min-w-0 flex-1 truncate text-sm">
{member.email}
{member.is_current_user && (
<span className="ml-1 text-muted-foreground">{t('you_suffix')}</span>
)}
</p>
<span className="shrink-0 text-xs text-muted-foreground">
{roleLabel(member.role)}
</span>
</div>
))}
</SettingsGroup>
)
}
+20 -32
View File
@@ -4,9 +4,8 @@ import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import { Label } from '@/components/ui/label'
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { SettingsGroup, SettingsRowNote } from '@/components/settings/SettingsRows'
interface VoucherSeries {
voucher_series: string
@@ -18,6 +17,7 @@ interface VoucherSeriesManagerProps {
defaultSeries?: string
}
/** Read-only list of the series that have actually been used. */
export function VoucherSeriesManager({ defaultSeries }: VoucherSeriesManagerProps) {
const t = useTranslations('settings_voucher_series')
const { company } = useCompany()
@@ -48,44 +48,32 @@ export function VoucherSeriesManager({ defaultSeries }: VoucherSeriesManagerProp
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">
{t('heading')}
</h2>
<SettingsGroup label={t('heading')} help={t('footnote')}>
{isLoading ? (
<div className="space-y-2">
<div className="space-y-2 px-1 py-3">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
</div>
) : seriesEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">
<p className="px-1 py-3 text-sm text-muted-foreground">
{t('empty_state', { series: defaultSeries || 'A' })}
</p>
) : (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('active_series_label')}</Label>
<div className="divide-y divide-border/8">
{seriesEntries.map(([letter, lastNum]) => (
<div key={letter} className="flex items-center justify-between py-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium tabular-nums">{t('series_prefix')} {letter}</span>
{letter === (defaultSeries || 'A') && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">{t('default_badge')}</Badge>
)}
</div>
<span className="text-sm text-muted-foreground tabular-nums">
{t('latest_number')}: {lastNum}
</span>
</div>
))}
seriesEntries.map(([letter, lastNum]) => (
<div key={letter} className="flex items-center gap-3 border-b border-border px-1 py-3">
<span className="text-sm font-medium tabular-nums">
{t('series_prefix')} {letter}
</span>
{/* The default marker is a normal state: muted text, not a chip. */}
{letter === (defaultSeries || 'A') && (
<SettingsRowNote>{t('default_badge')}</SettingsRowNote>
)}
<span className="ml-auto shrink-0 text-sm text-muted-foreground tabular-nums">
{t('latest_number')}: {lastNum}
</span>
</div>
</div>
))
)}
<p className="text-xs text-muted-foreground">
{t('footnote')}
</p>
</section>
</SettingsGroup>
)
}
@@ -1,17 +1,17 @@
'use client'
import { useState } from 'react'
import { Label } from '@/components/ui/label'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
import { ChevronDown, Loader2 } from 'lucide-react'
import {
SettingsGroup,
SettingsReveal,
SettingsRow,
SettingsSelect,
} from '@/components/settings/SettingsRows'
import { cn } from '@/lib/utils'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import type { CompanySettings, JournalEntrySourceType } from '@/types'
@@ -39,6 +39,10 @@ const VISIBLE_SOURCE_TYPES: Array<{ key: JournalEntrySourceType; labelKey: strin
{ key: 'year_end', labelKey: 'year_end' },
]
// The everyday types stay visible; the long tail folds behind "Visa alla".
// Keeps the map's iteration order intact: we only split it, never reorder.
const ALWAYS_VISIBLE_COUNT = 3
// Swedish labels. Kept inline so this component is self-contained: these
// labels are bookkeeping-domain terms that intentionally stay Swedish across
// locales (see CLAUDE.md i18n table).
@@ -65,12 +69,16 @@ interface Props {
}
export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }: Props) {
// Generic fold labels ("Visa alla (n)" / "Visa färre") shared with the
// dashboard widgets; the domain labels themselves stay hardcoded Swedish.
const tCommon = useTranslations('dashboard')
const { toast } = useToast()
const initialMap = settings.default_voucher_series_per_source_type || {}
const [draft, setDraft] = useState<Partial<Record<JournalEntrySourceType, string>>>(
initialMap as Partial<Record<JournalEntrySourceType, string>>,
)
const [isSaving, setIsSaving] = useState(false)
const [showAll, setShowAll] = useState(false)
const handleChange = (sourceType: JournalEntrySourceType, value: string) => {
setDraft((prev) => ({ ...prev, [sourceType]: value }))
@@ -116,54 +124,66 @@ export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }:
}
}
return (
<section className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Verifikationsserier per typ
</h2>
<p className="text-xs text-muted-foreground">
Tilldela en standardserie per typ av verifikat. Vanlig svensk
praxis: leverantörsfakturor på serie B, löner på serie C, övrigt på
serie A. Kan alltid ändras per verifikat när du bokför.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{VISIBLE_SOURCE_TYPES.map(({ key, labelKey }) => (
<div key={key} className="flex items-center justify-between gap-3">
<Label
htmlFor={`series-${key}`}
className="text-sm text-foreground flex-1 cursor-pointer"
>
{SV_LABELS[labelKey] ?? key}
</Label>
<Select
value={(draft[key] as string | undefined) || 'A'}
onValueChange={(v) => handleChange(key, v)}
>
<SelectTrigger
id={`series-${key}`}
className="w-16 font-mono"
aria-label={SV_LABELS[labelKey] ?? key}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SERIES_OPTIONS.map((letter) => (
<SelectItem key={letter} value={letter} className="font-mono">
{letter}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
const renderRow = (
{ key, labelKey }: (typeof VISIBLE_SOURCE_TYPES)[number],
borderless = false,
) => (
<SettingsRow
key={key}
label={SV_LABELS[labelKey] ?? key}
htmlFor={`series-${key}`}
borderless={borderless}
>
<SettingsSelect
id={`series-${key}`}
value={(draft[key] as string | undefined) || 'A'}
onChange={(e) => handleChange(key, e.target.value)}
className="font-mono"
>
{SERIES_OPTIONS.map((letter) => (
<option key={letter} value={letter}>
{letter}
</option>
))}
</div>
</SettingsSelect>
</SettingsRow>
)
<div className="flex justify-end pt-2">
const alwaysVisible = VISIBLE_SOURCE_TYPES.slice(0, ALWAYS_VISIBLE_COUNT)
const folded = VISIBLE_SOURCE_TYPES.slice(ALWAYS_VISIBLE_COUNT)
return (
<SettingsGroup
label="Verifikationsserier per typ"
help="Tilldela en standardserie per typ av verifikat. Vanlig svensk praxis: leverantörsfakturor på serie B, löner på serie C, övrigt på serie A. Kan alltid ändras per verifikat när du bokför."
>
{alwaysVisible.map((entry, i) =>
// The last always-visible row sits right above the fold toggle:
// drop its hairline so the fold reads as part of the same list.
renderRow(entry, i === alwaysVisible.length - 1),
)}
<button
type="button"
onClick={() => setShowAll((v) => !v)}
aria-expanded={showAll}
className="flex items-center gap-1.5 px-1 py-2 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<ChevronDown
aria-hidden="true"
className={cn('h-3.5 w-3.5 transition-transform duration-150', showAll && 'rotate-180')}
/>
{showAll ? tCommon('show_less') : tCommon('show_all', { count: folded.length })}
</button>
<SettingsReveal open={showAll} indent={false}>
{folded.map((entry, i) => renderRow(entry, i === folded.length - 1))}
</SettingsReveal>
<div className="flex justify-end px-1 pt-4">
<Button
type="button"
size="sm"
onClick={handleSave}
disabled={!hasChanges || isSaving}
>
@@ -171,6 +191,6 @@ export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }:
Spara serier
</Button>
</div>
</section>
</SettingsGroup>
)
}
@@ -5,15 +5,21 @@ import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Sun, Moon, Monitor, LogOut, Languages, ExternalLink } from 'lucide-react'
import { Sun, Moon, Monitor, LogOut, ExternalLink } from 'lucide-react'
import { useTheme } from 'next-themes'
import { createClient } from '@/lib/supabase/client'
import { SecuritySettings } from '@/components/settings/SecuritySettings'
import { InstallAppSection } from '@/components/settings/InstallAppSection'
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
import { AccountDangerZone } from '@/components/settings/AccountDangerZone'
import {
SettingsGroup,
SettingsInput,
SettingsRow,
SettingsRowEnd,
SettingsSectionHeader,
SettingsSeg,
} from '@/components/settings/SettingsRows'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { useSettings } from '@/components/settings/useSettings'
import { clearRecaptIdentity } from '@/lib/recapt'
@@ -31,6 +37,8 @@ export function AccountSettingsContent() {
const activeLocale = useLocale() as Locale
const tCommon = useTranslations('common')
const tSettings = useTranslations('settings')
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const [savingLocale, setSavingLocale] = useState(false)
const [fullName, setFullName] = useState('')
const [initialName, setInitialName] = useState('')
@@ -115,155 +123,139 @@ export function AccountSettingsContent() {
en: tCommon('language_english'),
}
const nameUnchanged = !fullName.trim() || fullName.trim() === initialName
return (
<div className="space-y-8">
{/* Name */}
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{tSettings('section_name')}
</h2>
<p className="text-sm text-muted-foreground max-w-md">
{tSettings('name_description')}
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex-1 space-y-2 sm:max-w-sm">
<Label htmlFor="full_name">{tSettings('name_label')}</Label>
<Input
id="full_name"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder={tSettings('name_placeholder')}
disabled={nameLoading || savingName}
maxLength={100}
/>
</div>
<Button
onClick={handleSaveName}
disabled={
nameLoading || savingName || !fullName.trim() || fullName.trim() === initialName
}
>
{savingName ? tCommon('saving') : tCommon('save')}
</Button>
</div>
</section>
<div>
<SettingsSectionHeader title={tNav('account')} intro={tIntro('account')} />
{/* Appearance */}
<section className="space-y-4 border-t border-border pt-8">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{tSettings('section_appearance')}
</h2>
{mounted && (
<div className="flex gap-3">
{([
{ value: 'light', labelKey: 'theme_light', icon: Sun },
{ value: 'dark', labelKey: 'theme_dark', icon: Moon },
{ value: 'system', labelKey: 'theme_system', icon: Monitor },
] as const).map(({ value, labelKey, icon: Icon }) => (
<button
key={value}
type="button"
onClick={() => setTheme(value)}
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-3 text-sm font-medium transition-colors ${
theme === value
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40'
}`}
>
<Icon className="h-4 w-4 text-muted-foreground" />
{tCommon(labelKey)}
</button>
))}
</div>
)}
</section>
{/* Language */}
<section className="space-y-4 border-t border-border pt-8">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{tSettings('section_language')}
</h2>
<p className="text-sm text-muted-foreground max-w-md">
{tSettings('language_description')}
</p>
<div className="flex gap-3">
{SUPPORTED_LOCALES.map((value) => (
<button
key={value}
type="button"
onClick={() => handleLocaleChange(value)}
disabled={savingLocale}
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-3 text-sm font-medium transition-colors disabled:opacity-50 ${
activeLocale === value
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40'
}`}
{/* Profile: name, appearance, language, install-as-app */}
<SettingsGroup label={tSettings('group_profile')}>
<SettingsRow
label={tSettings('name_label')}
htmlFor="full_name"
help={tSettings('name_description')}
align="baseline"
>
<SettingsInput
id="full_name"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder={tSettings('name_placeholder')}
disabled={nameLoading || savingName}
maxLength={100}
/>
<SettingsRowEnd>
<Button
size="sm"
onClick={handleSaveName}
disabled={nameLoading || savingName || nameUnchanged}
>
<Languages className="h-4 w-4 text-muted-foreground" />
{localeLabels[value]}
</button>
))}
</div>
</section>
{savingName ? tCommon('saving') : tCommon('save')}
</Button>
</SettingsRowEnd>
</SettingsRow>
{/* Install as app: renders nothing when already running installed */}
<InstallAppSection />
<SettingsRow label={tSettings('section_appearance')}>
{mounted && (
<SettingsSeg
value={theme ?? 'system'}
onChange={setTheme}
aria-label={tSettings('section_appearance')}
options={[
{
value: 'light',
label: (
<span className="inline-flex items-center gap-1.5">
<Sun className="h-3.5 w-3.5" />
{tCommon('theme_light')}
</span>
),
},
{
value: 'dark',
label: (
<span className="inline-flex items-center gap-1.5">
<Moon className="h-3.5 w-3.5" />
{tCommon('theme_dark')}
</span>
),
},
{
value: 'system',
label: (
<span className="inline-flex items-center gap-1.5">
<Monitor className="h-3.5 w-3.5" />
{tCommon('theme_system')}
</span>
),
},
]}
/>
)}
</SettingsRow>
{/* Security */}
<div className="border-t border-border pt-8">
<SecuritySettings />
</div>
<SettingsRow
label={tSettings('section_language')}
help={tSettings('language_description')}
>
<SettingsSeg
value={activeLocale}
onChange={(next) => void handleLocaleChange(next)}
disabled={savingLocale}
aria-label={tSettings('section_language')}
options={SUPPORTED_LOCALES.map((value) => ({
value,
label: localeLabels[value],
}))}
/>
</SettingsRow>
{/* Calendar feed */}
{hasCalendarExtension && (
<div className="border-t border-border pt-8">
<CalendarFeedSettings />
</div>
)}
{/* Install as app: renders nothing when already running installed */}
<InstallAppSection />
</SettingsGroup>
{/* Logout */}
<section className="space-y-4 border-t border-border pt-8">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{tCommon('account_settings')}
</h2>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<p className="font-medium">{tCommon('logout')}</p>
<p className="text-sm text-muted-foreground">{tCommon('logout_description')}</p>
</div>
<Button variant="outline" onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
{tCommon('logout')}
</Button>
</div>
</section>
{/* Security: BankID, password, 2FA (renders its own group) */}
<SecuritySettings />
{/* Calendar feed (extension-gated) */}
{hasCalendarExtension && <CalendarFeedSettings />}
{/* Privacy & agreements: surface the otherwise-unlinked DPA + privacy policy */}
<section className="space-y-4 border-t border-border pt-8">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{tSettings('legal_title')}
</h2>
<div className="space-y-2">
<Link
href="/privacy"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between rounded-lg border p-4 transition-colors hover:bg-secondary/60"
>
<span className="font-medium">{tSettings('legal_privacy')}</span>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</Link>
<Link
href="/dpa"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between rounded-lg border p-4 transition-colors hover:bg-secondary/60"
>
<span className="font-medium">{tSettings('legal_dpa')}</span>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</Link>
</div>
</section>
<SettingsGroup label={tSettings('legal_title')}>
<SettingsRow label={tSettings('legal_privacy')}>
<SettingsRowEnd>
<Button variant="ghost" size="sm" asChild>
<Link href="/privacy" target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-3.5 w-3.5" />
{tCommon('open')}
</Link>
</Button>
</SettingsRowEnd>
</SettingsRow>
<SettingsRow label={tSettings('legal_dpa')}>
<SettingsRowEnd>
<Button variant="ghost" size="sm" asChild>
<Link href="/dpa" target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-3.5 w-3.5" />
{tCommon('open')}
</Link>
</Button>
</SettingsRowEnd>
</SettingsRow>
</SettingsGroup>
{/* Sign out */}
<SettingsGroup>
<SettingsRow label={tCommon('logout')} help={tCommon('logout_description')}>
<SettingsRowEnd>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<LogOut className="mr-2 h-3.5 w-3.5" />
{tCommon('logout')}
</Button>
</SettingsRowEnd>
</SettingsRow>
</SettingsGroup>
{/* Delete account: only for non-sandbox */}
{!settings?.is_sandbox && <AccountDangerZone />}
@@ -1,11 +1,17 @@
'use client'
import { useTranslations } from 'next-intl'
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
import { OAuthClientsPanel } from '@/components/settings/OAuthClientsPanel'
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
export function ApiSettingsContent() {
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
return (
<div className="space-y-8">
<div>
<SettingsSectionHeader title={tNav('api')} intro={tIntro('api')} />
<ApiKeysPanel />
<OAuthClientsPanel />
</div>
@@ -3,18 +3,23 @@
import { useEffect, useState } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Card, CardContent } from '@/components/ui/card'
import { Switch } from '@/components/ui/switch'
import { AgentMemoryPanel } from '@/components/settings/AgentMemoryPanel'
import { AgentSkillsPanel } from '@/components/settings/AgentSkillsPanel'
import { AgentKnowledgePanel } from '@/components/agent-knowledge/AgentKnowledgePanel'
import {
SettingsGroup,
SettingsRow,
SettingsRowEnd,
SettingsSectionHeader,
SettingsSeg,
} from '@/components/settings/SettingsRows'
// "Assistenten": the ledger profile the agent reads before booking (Kunskap =
// "Vad din agent vet", opens on the konteringskarta and is the default view),
// what the assistant remembers about this company (Minne, editable), and the
// domain knowledge it ships with (Kompetens, read-only). Tabs keep all three
// one click away instead of stacked.
// domain knowledge it ships with (Kompetens, read-only). The segmented control
// keeps all three one click away instead of stacked.
type View = 'knowledge' | 'memory' | 'skills'
const VIEW_ROUTE: Record<View, string> = {
@@ -23,40 +28,43 @@ const VIEW_ROUTE: Record<View, string> = {
skills: '/settings/assistant?view=skills',
}
const VIEW_OPTIONS: Array<{ value: View; label: string }> = [
{ value: 'knowledge', label: 'Kunskap' },
{ value: 'memory', label: 'Minne' },
{ value: 'skills', label: 'Kompetens' },
]
export function AssistantSettingsContent() {
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const searchParams = useSearchParams()
const router = useRouter()
const raw = searchParams.get('view')
const view: View = raw === 'skills' ? 'skills' : raw === 'memory' ? 'memory' : 'knowledge'
function setView(next: string) {
function setView(next: View) {
// 'knowledge' is the default: keep its URL clean (no query string).
router.replace(VIEW_ROUTE[next as View] ?? VIEW_ROUTE.knowledge, { scroll: false })
router.replace(VIEW_ROUTE[next] ?? VIEW_ROUTE.knowledge, { scroll: false })
}
return (
<div className="space-y-8">
<Tabs value={view} onValueChange={setView} className="space-y-6">
<TabsList>
<TabsTrigger value="knowledge">Kunskap</TabsTrigger>
<TabsTrigger value="memory">Minne</TabsTrigger>
<TabsTrigger value="skills">Kompetens</TabsTrigger>
</TabsList>
<div>
<SettingsSectionHeader title={tNav('assistant')} intro={tIntro('assistant')} />
{/* Radix unmounts the inactive panel, so each panel's data is fetched
lazily the first time its tab is opened. */}
<TabsContent value="knowledge">
<AgentKnowledgePanel />
</TabsContent>
<TabsContent value="memory">
<AgentMemoryPanel />
</TabsContent>
<TabsContent value="skills">
<AgentSkillsPanel />
</TabsContent>
</Tabs>
<div className="mt-6">
<SettingsSeg value={view} onChange={setView} options={VIEW_OPTIONS} aria-label="Välj vy" />
</div>
<FabVisibilityCard />
{/* Only the active view mounts, so each panel's data is fetched lazily
the first time its view is opened (same behavior as when Radix Tabs
unmounted the inactive panels). */}
<div className="mt-6">
{view === 'knowledge' && <AgentKnowledgePanel />}
{view === 'memory' && <AgentMemoryPanel />}
{view === 'skills' && <AgentSkillsPanel />}
</div>
<FabVisibilityRow />
</div>
)
}
@@ -65,7 +73,7 @@ export function AssistantSettingsContent() {
// lives on user_preferences (server-rendered into the dashboard layout), so
// a successful save triggers router.refresh() to make the button react
// immediately instead of on next navigation.
function FabVisibilityCard() {
function FabVisibilityRow() {
const t = useTranslations('settings_assistant')
const router = useRouter()
// null = not yet loaded (switch disabled meanwhile)
@@ -108,19 +116,17 @@ function FabVisibilityCard() {
}
return (
<Card>
<CardContent className="p-6 flex items-center justify-between gap-4">
<div className="space-y-1">
<p className="text-sm font-medium">{t('fab_title')}</p>
<p className="text-sm text-muted-foreground">{t('fab_description')}</p>
</div>
<Switch
checked={hideFab === null ? true : !hideFab}
onCheckedChange={handleToggle}
disabled={hideFab === null || saving}
aria-label={t('fab_title')}
/>
</CardContent>
</Card>
<SettingsGroup>
<SettingsRow label={t('fab_title')} help={t('fab_description')}>
<SettingsRowEnd>
<Switch
checked={hideFab === null ? true : !hideFab}
onCheckedChange={handleToggle}
disabled={hideFab === null || saving}
aria-label={t('fab_title')}
/>
</SettingsRowEnd>
</SettingsRow>
</SettingsGroup>
)
}
@@ -4,7 +4,6 @@ import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { useSearchParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { EmptyState } from '@/components/ui/empty-state'
import { useToast } from '@/components/ui/use-toast'
@@ -12,11 +11,14 @@ import { AlertTriangle, CreditCard, ExternalLink } from 'lucide-react'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
const BankingPanel = getSettingsPanel('enable-banking')
export function BankingSettingsContent() {
const t = useTranslations('settings_banking')
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const searchParams = useSearchParams()
const router = useRouter()
const { toast } = useToast()
@@ -74,32 +76,36 @@ export function BankingSettingsContent() {
}, [searchParams, router, toast, t])
return (
<div className="space-y-8">
<div>
<SettingsSectionHeader title={tNav('banking')} intro={tIntro('banking')} />
{/* OAuth bounce-back failure: a live warning, so it stays visible in the
page flow, as compact warning-tone lines instead of a bordered box. */}
{bankConnectionError && (
<div className="flex items-start gap-3 rounded-lg border border-destructive p-4">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div className="flex-1">
<p className="text-sm font-medium text-destructive">{bankConnectionError}</p>
<div role="alert" className="mt-6 flex items-start gap-2 px-1">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-attn" />
<div className="min-w-0 flex-1 space-y-1 text-[12.5px] leading-relaxed">
<p className="text-attn">{bankConnectionError}</p>
{isAccessDenied && failedBankName && (
<p className="mt-1 text-sm text-muted-foreground">
<p className="text-muted-foreground">
{t('access_denied_hint', { bankName: failedBankName })}
</p>
)}
{showHbPoaHint && (
<p className="mt-1 text-sm text-muted-foreground">
<p className="text-muted-foreground">
{t('hb_business_poa_hint')}{' '}
<a
href="https://tilisy.enablebanking.com/guides/SE/Handelsbanken/"
target="_blank"
rel="noreferrer"
className="underline hover:text-foreground"
className="underline underline-offset-2 hover:text-foreground"
>
{t('hb_business_poa_link')}
</a>
</p>
)}
<p className="mt-1 text-sm text-muted-foreground">
{t('import_fallback_text')}<Link href="/import?mode=bank" className="underline hover:text-foreground">{t('import_fallback_link')}</Link>{t('import_fallback_suffix')}
<p className="text-muted-foreground">
{t('import_fallback_text')}<Link href="/import?mode=bank" className="underline underline-offset-2 hover:text-foreground">{t('import_fallback_link')}</Link>{t('import_fallback_suffix')}
</p>
</div>
<button
@@ -109,7 +115,7 @@ export function BankingSettingsContent() {
setIsAccessDenied(false)
setShowHbPoaHint(false)
}}
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors duration-150 hover:text-foreground"
aria-label={t('dismiss_aria')}
>
<span className="text-lg leading-none">&times;</span>
@@ -119,26 +125,28 @@ export function BankingSettingsContent() {
{hasBankingExtension && BankingPanel ? (
<>
<BankSyncStatusChip />
{/* The chip renders null when there are no connections; empty:hidden
keeps its margin from leaving a stray gap in that case. */}
<div className="mt-6 empty:hidden">
<BankSyncStatusChip />
</div>
<BankingPanel />
</>
) : (
<Card>
<CardContent className="p-0">
<EmptyState
icon={CreditCard}
title={t('not_enabled_title')}
description={t('not_enabled_description')}
>
<Button variant="outline" asChild>
<Link href="/extensions">
<ExternalLink className="mr-2 h-4 w-4" />
{t('go_to_extensions')}
</Link>
</Button>
</EmptyState>
</CardContent>
</Card>
<div className="pt-8">
<EmptyState
icon={CreditCard}
title={t('not_enabled_title')}
description={t('not_enabled_description')}
>
<Button variant="outline" asChild>
<Link href="/extensions">
<ExternalLink className="mr-2 h-4 w-4" />
{t('go_to_extensions')}
</Link>
</Button>
</EmptyState>
</div>
)}
</div>
)
@@ -1,10 +1,17 @@
'use client'
import { useEffect, useState } from 'react'
import { Check, Clock, Minus } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { useTranslations } from 'next-intl'
import { Check } from 'lucide-react'
import { AttnLine } from '@/components/ui/attn-line'
import { Skeleton } from '@/components/ui/skeleton'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import {
SettingsGroup,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
SettingsSectionHeader,
} from '@/components/settings/SettingsRows'
import { formatDateLong } from '@/lib/utils'
import { BillingActions } from '@/components/settings/BillingActions'
@@ -16,15 +23,14 @@ const INCLUDED = [
'E-postutskick av fakturor, påminnelser och lönebesked',
]
// Free vs paid, shown as a comparison table: what the paid tier adds reads
// strongest next to what stays free forever (freeze-and-retain, nothing is
// taken away). Free rows mirror the old ALWAYS_FREE copy.
const FEATURE_MATRIX: { label: string; free: boolean }[] = [
{ label: 'Bokföring och rapporter', free: true },
{ label: 'Fakturering', free: true },
{ label: 'SIE-export', free: true },
{ label: 'Org.nr-uppslag och momsnummerkontroll', free: true },
...INCLUDED.map((label) => ({ label, free: false })),
// What stays free forever (freeze-and-retain, nothing is taken away). Shown
// as the second column of the flat feature list so the paid tier reads
// strongest right next to it. Mirrors the old ALWAYS_FREE copy.
const ALWAYS_FREE = [
'Bokföring och rapporter',
'Fakturering',
'SIE-export',
'Org.nr-uppslag och momsnummerkontroll',
]
// Mirrors the checkout route's deferred-first-charge condition (Stripe's 48h
@@ -42,12 +48,30 @@ interface BillingView {
isDemo: boolean
}
function FeatureList({ heading, items }: { heading: string; items: string[] }) {
return (
<div>
<p className="text-xs font-medium text-muted-foreground">{heading}</p>
<ul className="mt-2 space-y-2">
{items.map((item) => (
<li key={item} className="flex items-start gap-2 text-sm">
<Check aria-hidden="true" className="mt-1 h-3.5 w-3.5 shrink-0 text-foreground" />
<span>{item}</span>
</li>
))}
</ul>
</div>
)
}
/**
* Settings → Abonnemang. Rendered both as the full page (thin wrapper) and
* inside the settings modal (via SETTINGS_SECTIONS), so it's a client component
* that reads its state from GET /api/billing/status.
*/
export function BillingSettingsContent() {
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const [view, setView] = useState<BillingView | null>(null)
useEffect(() => {
@@ -80,11 +104,16 @@ export function BillingSettingsContent() {
return () => { active = false }
}, [])
const header = <SettingsSectionHeader title={tNav('billing')} intro={tIntro('billing')} />
if (!view) {
return (
<div className="space-y-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
<div>
{header}
<div className="mt-6 space-y-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
</div>
</div>
)
}
@@ -93,42 +122,46 @@ export function BillingSettingsContent() {
// them to creating a real account instead of a pay button that would 403.
if (view.isDemo) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Abonnemang</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Du provkör Accounted i en demo. Skapa ett riktigt konto för att aktivera
abonnemang, AI-assistent, bankkoppling och inlämning till Skatteverket.
</p>
<ul className="space-y-2">
<div>
{header}
<SettingsGroup label="Ditt abonnemang">
<SettingsRow label="Status" borderless>
<span>Demo</span>
<SettingsRowNote>
Du provkör Accounted i en demo. Skapa ett riktigt konto för att aktivera
abonnemang, AI-assistent, bankkoppling och inlämning till Skatteverket.
</SettingsRowNote>
</SettingsRow>
</SettingsGroup>
<SettingsGroup label="I abonnemanget">
<ul className="space-y-2 px-1 pt-3">
{INCLUDED.map((item) => (
<li key={item} className="flex items-start gap-2 text-sm">
<Check className="h-4 w-4 mt-1 shrink-0 text-foreground" />
<Check aria-hidden="true" className="mt-1 h-3.5 w-3.5 shrink-0 text-foreground" />
<span>{item}</span>
</li>
))}
</ul>
</CardContent>
</Card>
</SettingsGroup>
</div>
)
}
// Paying company → manage view.
if (view.isPaying) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Abonnemang</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Ditt abonnemang är aktivt. Du kan hantera eller avsluta det när som helst.
</p>
<BillingActions isPaying configured={view.configured} />
</CardContent>
</Card>
<div>
{header}
<SettingsGroup label="Ditt abonnemang">
<SettingsRow label="Status" borderless>
<span>Aktivt</span>
<SettingsRowNote>Du kan hantera eller avsluta det när som helst.</SettingsRowNote>
<SettingsRowEnd>
<BillingActions isPaying configured={view.configured} />
</SettingsRowEnd>
</SettingsRow>
</SettingsGroup>
</div>
)
}
@@ -136,18 +169,18 @@ export function BillingSettingsContent() {
// confirm instead of re-showing the sell pitch to someone who already paid.
if (view.paidJustNow) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Abonnemang</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="flex items-start gap-2 text-sm">
<Check className="h-4 w-4 mt-0.5 shrink-0 text-foreground" />
<span>Klart! Ditt abonnemang är aktiverat och alla funktioner låses upp inom någon minut.</span>
</p>
<p className="text-sm text-muted-foreground">Ladda om sidan om du inte ser ändringen.</p>
</CardContent>
</Card>
<div>
{header}
<SettingsGroup label="Ditt abonnemang">
<SettingsRow label="Status" borderless>
<span className="flex items-start gap-2">
<Check aria-hidden="true" className="mt-1 h-3.5 w-3.5 shrink-0 text-foreground" />
<span>Klart! Ditt abonnemang är aktiverat och alla funktioner låses upp inom någon minut.</span>
</span>
<SettingsRowNote>Ladda om sidan om du inte ser ändringen.</SettingsRowNote>
</SettingsRow>
</SettingsGroup>
</div>
)
}
@@ -156,84 +189,60 @@ export function BillingSettingsContent() {
const deferredTo = view.chargeDeferred ? trialEndsAt : null
return (
<div className="space-y-6">
<div>
{header}
{/* Consequential trial countdown: one attn-tone sentence, not a banner. */}
{daysLeft !== null && (
<div className="flex items-center gap-3 rounded-lg border border-border bg-secondary px-4 py-3 text-sm">
<Clock className="h-4 w-4 shrink-0 text-foreground" />
<span>
{daysLeft > 0
? `Din provperiod löper ut om ${daysLeft} ${daysLeft === 1 ? 'dag' : 'dagar'}${
trialEndsAt ? ` (${formatDateLong(trialEndsAt)})` : ''
}. ${
deferredTo
? 'Lägg till ditt kort nu: inget dras förrän provperioden är slut.'
: 'Lägg till betalning nu så fortsätter allt utan avbrott.'
}`
: 'Din provperiod har löpt ut. Aktivera abonnemanget för att få tillbaka AI, bankkoppling och inlämning.'}
</span>
</div>
<AttnLine className="mt-3">
{daysLeft > 0
? `Din provperiod löper ut om ${daysLeft} ${daysLeft === 1 ? 'dag' : 'dagar'}${
trialEndsAt ? ` (${formatDateLong(trialEndsAt)})` : ''
}. ${
deferredTo
? 'Lägg till ditt kort nu: inget dras förrän provperioden är slut.'
: 'Lägg till betalning nu så fortsätter allt utan avbrott.'
}`
: 'Din provperiod har löpt ut. Aktivera abonnemanget för att få tillbaka AI, bankkoppling och inlämning.'}
</AttnLine>
)}
<Card>
<CardHeader>
<CardTitle>Allt du behöver för att sköta bokföringen själv</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<SettingsGroup
label="Allt du behöver för att sköta bokföringen själv"
help="Ingen bindningstid · Avsluta när du vill · Säker betalning via Stripe"
>
<div className="px-1 pt-3">
<BillingActions isPaying={false} configured={view.configured} firstChargeAt={deferredTo} />
</div>
</SettingsGroup>
{deferredTo && (
<div className="space-y-3">
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">Så funkar det</h3>
<ol className="space-y-2 text-sm">
<li className="flex gap-3">
<span className="w-28 shrink-0 text-muted-foreground">Idag</span>
<span>Du lägger till ditt kort. Inget dras nu.</span>
</li>
<li className="flex gap-3">
<span className="w-28 shrink-0 text-muted-foreground tabular-nums">{formatDateLong(deferredTo)}</span>
<span>Provperioden slutar och den första debiteringen sker.</span>
</li>
<li className="flex gap-3">
<span className="w-28 shrink-0 text-muted-foreground">När som helst</span>
<span>Avsluta direkt via Stripe. Före {formatDateLong(deferredTo)} kostar det ingenting.</span>
</li>
</ol>
</div>
)}
{deferredTo && (
<SettingsGroup label="Så funkar det">
<SettingsRow label="Idag">
<span>Du lägger till ditt kort. Inget dras nu.</span>
</SettingsRow>
<SettingsRow label={<span className="tabular-nums">{formatDateLong(deferredTo)}</span>}>
<span>Provperioden slutar och den första debiteringen sker.</span>
</SettingsRow>
<SettingsRow label="När som helst" borderless>
<span>
Avsluta direkt via Stripe. Före {formatDateLong(deferredTo)} kostar det ingenting.
</span>
</SettingsRow>
</SettingsGroup>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-full">Funktion</TableHead>
<TableHead className="text-center">Gratis</TableHead>
<TableHead className="text-center">Abonnemang</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{FEATURE_MATRIX.map((f) => (
<TableRow key={f.label}>
<TableCell className="text-sm">{f.label}</TableCell>
<TableCell className="text-center">
{f.free ? (
<Check role="img" aria-label="Ingår" className="h-4 w-4 mx-auto text-foreground" />
) : (
<Minus role="img" aria-label="Ingår inte" className="h-4 w-4 mx-auto text-muted-foreground/50" />
)}
</TableCell>
<TableCell className="text-center">
<Check role="img" aria-label="Ingår" className="h-4 w-4 mx-auto text-foreground" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<p className="text-sm text-muted-foreground leading-relaxed">
Utan abonnemang behåller du bokföringen, fakturorna, rapporterna och all din data utan kostnad. Ingenting
raderas: räkenskapsinformation bevaras i sju år enligt bokföringslagen, oavsett abonnemang.
</p>
<SettingsGroup
label="Funktioner"
// The 7-year retention reassurance moved behind the "?": long legal
// copy stays out of the page flow.
help="Utan abonnemang behåller du bokföringen, fakturorna, rapporterna och all din data utan kostnad. Ingenting raderas: räkenskapsinformation bevaras i sju år enligt bokföringslagen, oavsett abonnemang."
>
<div className="grid gap-6 px-1 pt-3 sm:grid-cols-2">
<FeatureList heading="I abonnemanget" items={INCLUDED} />
<FeatureList heading="Alltid gratis" items={ALWAYS_FREE} />
</div>
</SettingsGroup>
</div>
)
}
@@ -14,9 +14,14 @@ import { applyDefaultSeriesToMap } from '@/lib/bookkeeping/voucher-series-resolv
import { PeriodiseringAutoDetectToggle } from '@/components/settings/PeriodiseringAutoDetectToggle'
import { DimensionsToggle } from '@/components/settings/DimensionsToggle'
import { AccountingFrameworkForm } from '@/components/settings/AccountingFrameworkForm'
import {
SettingsGroup,
SettingsRow,
SettingsSectionHeader,
SettingsSelect,
} from '@/components/settings/SettingsRows'
import { useSettings } from '@/components/settings/useSettings'
import { useCompany } from '@/contexts/CompanyContext'
import { Label } from '@/components/ui/label'
import { ExternalLink } from 'lucide-react'
import type { AccountingFramework, CompanySettings } from '@/types'
@@ -24,6 +29,8 @@ const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
export function BookkeepingSettingsContent() {
const t = useTranslations('settings_bookkeeping')
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const { settings, isLoading, updateSettings, refetch } = useSettings()
const { company } = useCompany()
// Local mirror of the company-level accounting_framework so the K2/K3
@@ -88,127 +95,98 @@ export function BookkeepingSettingsContent() {
const isAktiebolag = company?.entity_type === 'aktiebolag'
return (
<div className="space-y-8">
{isAktiebolag && (
<AccountingFrameworkForm
current={framework}
onSaved={(next) => setFramework(next)}
/>
)}
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
{/* Accounting method */}
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('method_heading')}
</h2>
<div className="space-y-2">
<Label htmlFor="accounting_method">{t('method_label')}</Label>
<select
<div>
<SettingsSectionHeader title={tNav('bookkeeping')} intro={tIntro('bookkeeping')} />
<SettingsFormWrapper onSave={handleSave}>
{/* Grunder: framework (AB only), method, deferred booking, default
series. The framework row saves through its own PATCH and opts out
of this wrapper's dirty tracking; the rest read via FormData. */}
<SettingsGroup label={t('group_basics')}>
{isAktiebolag && (
<AccountingFrameworkForm
current={framework}
onSaved={(next) => setFramework(next)}
/>
)}
<SettingsRow
label={t('method_label')}
htmlFor="accounting_method"
help={t('method_help')}
>
<SettingsSelect
id="accounting_method"
name="accounting_method"
defaultValue={settings.accounting_method || 'accrual'}
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="accrual">{t('method_accrual')}</option>
<option value="cash">{t('method_cash')}</option>
</select>
<p className="text-xs text-muted-foreground">
{t('method_help')}
</p>
</div>
</SettingsSelect>
</SettingsRow>
{/* #967: register/send without booking; ekonomi books in a separate
explicit step. Only meaningful under faktureringsmetoden. */}
<div className="space-y-2">
<Label htmlFor="defer_invoice_booking">{t('defer_booking_label')}</Label>
<select
<SettingsRow
label={t('defer_booking_label')}
htmlFor="defer_invoice_booking"
help={t('defer_booking_help')}
>
<SettingsSelect
id="defer_invoice_booking"
name="defer_invoice_booking"
defaultValue={settings.defer_invoice_booking ? 'true' : 'false'}
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="false">{t('defer_booking_off')}</option>
<option value="true">{t('defer_booking_on')}</option>
</select>
<p className="text-xs text-muted-foreground">
{t('defer_booking_help')}
</p>
</div>
</section>
</SettingsSelect>
</SettingsRow>
<SettingsRow
label={t('series_label')}
htmlFor="default_voucher_series"
help={t('series_help')}
>
<SettingsSelect
id="default_voucher_series"
name="default_voucher_series"
defaultValue={settings.default_voucher_series || 'A'}
className="font-mono"
>
{SERIES_OPTIONS.map((letter) => (
<option key={letter} value={letter}>
{letter}
</option>
))}
</SettingsSelect>
</SettingsRow>
</SettingsGroup>
{/* Default voucher series */}
<div className="border-t border-border pt-8">
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('series_heading')}
</h2>
<div className="space-y-2">
<Label htmlFor="default_voucher_series">{t('series_label')}</Label>
<select
id="default_voucher_series"
name="default_voucher_series"
defaultValue={settings.default_voucher_series || 'A'}
className="flex h-10 w-16 rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{SERIES_OPTIONS.map((letter) => (
<option key={letter} value={letter}>{letter}</option>
))}
</select>
<p className="text-xs text-muted-foreground">
{t('series_help')}
</p>
</div>
</section>
</div>
{/* Period locking */}
<div className="border-t border-border pt-8">
<PeriodLockingSettings settings={settings} />
</div>
<PeriodLockingSettings settings={settings} />
</SettingsFormWrapper>
{/* Fiscal years */}
<div className="border-t border-border pt-8">
<FiscalYearsManager />
</div>
<FiscalYearsManager />
{/* Voucher series: per-source-type mapping */}
<div className="border-t border-border pt-8">
<VoucherSeriesPerSourceTypeForm
settings={settings}
onSettingsUpdated={updateSettings}
/>
</div>
<VoucherSeriesPerSourceTypeForm
settings={settings}
onSettingsUpdated={updateSettings}
/>
{/* Voucher series: read-only display */}
<div className="border-t border-border pt-8">
<VoucherSeriesManager defaultSeries={settings.default_voucher_series || 'A'} />
</div>
<VoucherSeriesManager defaultSeries={settings.default_voucher_series || 'A'} />
{/* Periodisering auto-detect toggle */}
<div className="border-t border-border pt-8">
<SettingsGroup label={t('group_automation')}>
<PeriodiseringAutoDetectToggle />
</div>
{/* Kostnadsställen & projekt (dimensions) toggle */}
<div className="border-t border-border pt-8">
<DimensionsToggle />
</div>
</SettingsGroup>
{/* Cross-links */}
<div className="border-t border-border pt-8 space-y-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('related_heading')}
</h2>
<div className="flex flex-col gap-2">
<SettingsGroup>
<SettingsRow label={t('related_heading')} borderless>
<Link
href="/bookkeeping?tab=accounts"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('related_chart_of_accounts')}
</Link>
</div>
</div>
</SettingsRow>
</SettingsGroup>
</div>
)
}
@@ -1,6 +1,7 @@
'use client'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { CompanyDangerZone } from '@/components/settings/CompanyDangerZone'
import { CompanyInfoForm } from '@/components/settings/CompanyInfoForm'
import { CompanyMembersSection } from '@/components/settings/CompanyMembersSection'
@@ -10,12 +11,15 @@ import { LogoUpload } from '@/components/settings/LogoUpload'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
import { ShareCapitalForm } from '@/components/settings/ShareCapitalForm'
import { useSettings } from '@/components/settings/useSettings'
import type { CompanySettings } from '@/types'
export function CompanySettingsContent() {
const router = useRouter()
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const { settings, isLoading, updateSettings, refetch } = useSettings()
if (isLoading) return <SettingsLoadingSkeleton />
@@ -56,8 +60,10 @@ export function CompanySettingsContent() {
}
return (
<div className="space-y-8">
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
<div>
<SettingsSectionHeader title={tNav('company')} intro={tIntro('company')} />
<SettingsFormWrapper onSave={handleSave}>
<CompanyInfoForm settings={settings} />
{settings.entity_type === 'aktiebolag' && (
<ShareCapitalForm
@@ -66,16 +72,12 @@ export function CompanySettingsContent() {
)}
</SettingsFormWrapper>
<div className="border-t border-border pt-8">
<LogoUpload
logoUrl={settings.logo_url}
onUpdate={(url) => updateSettings({ logo_url: url })}
/>
</div>
<LogoUpload
logoUrl={settings.logo_url}
onUpdate={(url) => updateSettings({ logo_url: url })}
/>
<div className="border-t border-border pt-8">
<CompanyMembersSection />
</div>
<CompanyMembersSection />
<FiscalPeriodEditor />
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { InvoiceSettingsForm } from '@/components/settings/InvoiceSettingsForm'
import { InvoicePaymentLinkSettings } from '@/components/settings/InvoicePaymentLinkSettings'
import { InvoicePaymentAccountsSettings } from '@/components/settings/InvoicePaymentAccountsSettings'
@@ -10,10 +11,13 @@ import { PdfPrintSettings } from '@/components/settings/PdfPrintSettings'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
import { useSettings } from '@/components/settings/useSettings'
import type { CompanySettings } from '@/types'
export function InvoicingSettingsContent() {
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const { settings, isLoading, updateSettings, refetch } = useSettings()
if (isLoading) return <SettingsLoadingSkeleton />
@@ -43,36 +47,31 @@ export function InvoicingSettingsContent() {
}
return (
<div className="space-y-8">
<div className="flex justify-end">
<InvoicePreviewCard settings={settings} />
</div>
<div>
<SettingsSectionHeader
title={tNav('invoicing')}
intro={tIntro('invoicing')}
action={<InvoicePreviewCard settings={settings} />}
/>
{/* Owner/admin only: the component gates itself on role. */}
<InvoicePaymentAccountsSettings settings={settings} onUpdate={updateSettings} />
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
<SettingsFormWrapper onSave={handleSave}>
<InvoiceSettingsForm settings={settings} />
</SettingsFormWrapper>
{/* Payment link opt-in: saves individually via toggle switch */}
<div className="border-t border-border pt-8">
<InvoicePaymentLinkSettings settings={settings} onUpdate={updateSettings} />
</div>
<InvoicePaymentLinkSettings settings={settings} onUpdate={updateSettings} />
{/* PDF settings: saves individually via toggle switches */}
<div className="border-t border-border pt-8">
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
</div>
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
{/* Fixed invoice email recipients: explicit save */}
<div className="border-t border-border pt-8">
<InvoiceEmailRecipientsSettings settings={settings} onUpdate={updateSettings} />
</div>
{/* Fixed invoice email recipients: explicit save (owner/admin only) */}
<InvoiceEmailRecipientsSettings settings={settings} onUpdate={updateSettings} />
{/* Invoice email texts: autosaves on blur */}
<div className="border-t border-border pt-8">
<InvoiceEmailTextsSettings settings={settings} onUpdate={updateSettings} />
</div>
<InvoiceEmailTextsSettings settings={settings} onUpdate={updateSettings} />
</div>
)
}
@@ -3,15 +3,19 @@
import Link from 'next/link'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import {
SettingsGroup,
SettingsInput,
SettingsRow,
SettingsSectionHeader,
SettingsSelect,
} from '@/components/settings/SettingsRows'
import { TaxTableStatus } from '@/components/salary/TaxTableStatus'
import { useSettings } from '@/components/settings/useSettings'
import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
import { AlertTriangle } from 'lucide-react'
import type { CompanySettings } from '@/types'
const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
@@ -24,11 +28,11 @@ const BANK_LABEL: Record<(typeof BANK_OPTIONS)[number], string> = {
nordea: 'Nordea',
}
const selectClassName =
'flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
export function SalarySettingsContent() {
const t = useTranslations('settings_salary')
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const tSalary = useTranslations('salary')
const { settings, isLoading, updateSettings, refetch } = useSettings()
// Controlled so the LB sunset note reacts to the selection before save.
const [format, setFormat] = useState<'bg_lb' | 'pain001' | null>(null)
@@ -54,7 +58,7 @@ export function SalarySettingsContent() {
// The booking engine resolves the series from the per-source-type map;
// salary entries pass run.voucher_series explicitly, seeded from this
// entry at run creation. Merge — never replace — the map so other
// entry at run creation. Merge, never replace, the map so other
// source-type overrides survive.
if (series !== currentSeries) {
updates.default_voucher_series_per_source_type = {
@@ -72,135 +76,108 @@ export function SalarySettingsContent() {
}
return (
<div className="space-y-8">
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
{/* Payment */}
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('payments_heading')}
</h2>
<div>
<SettingsSectionHeader title={tNav('salary')} intro={tIntro('salary')} />
<div className="space-y-2">
<Label htmlFor="salary_pay_day">{t('pay_day_label')}</Label>
<Input
<SettingsFormWrapper onSave={handleSave}>
<SettingsGroup label={t('payments_heading')} help={t('info_payroll_scope')}>
<SettingsRow
label={t('pay_day_label')}
htmlFor="salary_pay_day"
help={t('pay_day_help')}
align="baseline"
>
<SettingsInput
id="salary_pay_day"
name="salary_pay_day"
type="number"
inputMode="numeric"
min={1}
max={28}
defaultValue={settings.salary_pay_day ?? 25}
className="w-24 tabular-nums"
className="max-w-24 flex-none tabular-nums"
/>
<p className="text-xs text-muted-foreground">{t('pay_day_help')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="preferred_payment_format">{t('format_label')}</Label>
<select
</SettingsRow>
<SettingsRow
label={t('format_label')}
htmlFor="preferred_payment_format"
help={t('format_help')}
borderless={effectiveFormat === 'bg_lb'}
>
<SettingsSelect
id="preferred_payment_format"
name="preferred_payment_format"
value={effectiveFormat}
onChange={(e) => setFormat(e.target.value as 'bg_lb' | 'pain001')}
className={selectClassName}
>
<option value="pain001">{t('format_pain001')}</option>
<option value="bg_lb">{t('format_bg_lb')}</option>
</select>
<p className="text-xs text-muted-foreground">{t('format_help')}</p>
{effectiveFormat === 'bg_lb' && (
<div className="flex items-start gap-2 rounded-md border border-border p-3 text-xs max-w-xl">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span className="text-muted-foreground">
{t('sunset_warning')}
</span>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="salary_default_bank">{t('bank_label')}</Label>
<select
</SettingsSelect>
</SettingsRow>
{effectiveFormat === 'bg_lb' && (
<p className="border-b border-border px-1 pb-3 text-[12.5px] leading-relaxed text-attn">
{t('sunset_warning')}
</p>
)}
<SettingsRow label={t('bank_label')} htmlFor="salary_default_bank" help={t('bank_help')}>
<SettingsSelect
id="salary_default_bank"
name="salary_default_bank"
defaultValue={settings.salary_default_bank ?? 'none'}
className={selectClassName}
>
<option value="none">{t('bank_none')}</option>
{BANK_OPTIONS.map((key) => (
<option key={key} value={key}>{BANK_LABEL[key]}</option>
))}
<option value="other">{t('bank_other')}</option>
</select>
<p className="text-xs text-muted-foreground">{t('bank_help')}</p>
</div>
</section>
</SettingsSelect>
</SettingsRow>
</SettingsGroup>
{/* Accounting */}
<div className="border-t border-border pt-8">
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('accounting_heading')}
</h2>
<div className="space-y-2">
<Label htmlFor="salary_voucher_series">{t('voucher_series_label')}</Label>
<select
id="salary_voucher_series"
name="salary_voucher_series"
defaultValue={currentSeries}
className="flex h-10 w-16 rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{SERIES_OPTIONS.map((letter) => (
<option key={letter} value={letter}>{letter}</option>
))}
</select>
<p className="text-xs text-muted-foreground">{t('voucher_series_help')}</p>
</div>
</section>
</div>
<SettingsGroup label={t('accounting_heading')}>
<SettingsRow
label={t('voucher_series_label')}
htmlFor="salary_voucher_series"
help={t('voucher_series_help')}
>
<SettingsSelect
id="salary_voucher_series"
name="salary_voucher_series"
defaultValue={currentSeries}
className="font-mono"
>
{SERIES_OPTIONS.map((letter) => (
<option key={letter} value={letter}>{letter}</option>
))}
</SettingsSelect>
</SettingsRow>
</SettingsGroup>
</SettingsFormWrapper>
{/* Tax tables (read-only status) */}
<div className="border-t border-border pt-8">
<section className="space-y-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('tax_tables_heading')}
</h2>
{/* Tax tables: automatic, read-only status. Lives outside the form so
the recheck action never interacts with the save flow. */}
<SettingsGroup
label={t('tax_tables_heading')}
help={t.rich('info_current_year', {
strong: (chunks) => <strong>{chunks}</strong>,
})}
>
<SettingsRow label={tSalary('th_status')} help={t('tax_tables_help')}>
<TaxTableStatus />
<p className="text-xs text-muted-foreground">{t('tax_tables_help')}</p>
</section>
</div>
</SettingsRow>
</SettingsGroup>
{/* Vacation (informational — the rule is per-employee) */}
<div className="border-t border-border pt-8">
<section className="space-y-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('vacation_heading')}
</h2>
<p className="text-sm text-muted-foreground">
{t('vacation_info')}{' '}
<Link href="/salary/employees" className="underline underline-offset-2 hover:text-foreground">
{t('vacation_info_link')}
</Link>
</p>
</section>
</div>
{/* Info */}
<div className="border-t border-border pt-8">
<section className="space-y-2">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('info_heading')}
</h2>
<div className="text-sm text-muted-foreground space-y-2">
<p>{t('info_payroll_scope')}</p>
<p>
{t.rich('info_current_year', {
strong: (chunks) => <strong>{chunks}</strong>,
})}
</p>
</div>
</section>
</div>
{/* Vacation is configured per employee; this row only points there. */}
<SettingsGroup label={t('vacation_heading')}>
<SettingsRow label={t('vacation_rule_label')} help={t('vacation_info')}>
<Link
href="/salary/employees"
className="text-sm text-muted-foreground underline underline-offset-2 transition-colors duration-150 hover:text-foreground"
>
{t('vacation_info_link')}
</Link>
</SettingsRow>
</SettingsGroup>
</div>
)
}
@@ -8,6 +8,7 @@ import { TaxAssessmentNoticesPanel } from '@/components/settings/TaxAssessmentNo
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
import { SkatteverketConnectPanel } from '@/components/settings/SkatteverketConnectPanel'
import { useSettings } from '@/components/settings/useSettings'
import { useToast } from '@/components/ui/use-toast'
@@ -17,6 +18,8 @@ import type { CompanySettings } from '@/types'
export function TaxSettingsContent() {
const { settings, isLoading, updateSettings, refetch } = useSettings()
const t = useTranslations('settings_skatteverket')
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const searchParams = useSearchParams()
const router = useRouter()
const { toast } = useToast()
@@ -125,8 +128,8 @@ export function TaxSettingsContent() {
fiscal_year_start_month: parseInt(formData.get('fiscal_year_start_month') as string) || 1,
pays_salaries: paysSalaries,
employer_registered: employerRegistered,
// The seasonal checkbox is unmounted when not registered; absence
// means false rather than "keep stored value".
// The seasonal switch stays mounted inside its reveal, but the
// employer_registered gate still forces false when not registered.
employer_seasonal: employerRegistered && formData.get('employer_seasonal') === 'true',
preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null,
kontrolluppgifter_enabled: formData.get('kontrolluppgifter_enabled') === 'true',
@@ -152,9 +155,11 @@ export function TaxSettingsContent() {
const showSkatteverket = hasSkatteverketExtension && !settings.is_sandbox
return (
<div className="space-y-8">
<div>
<SettingsSectionHeader title={tNav('tax')} intro={tIntro('tax')} />
{/* Connection panel first: the skattekonto and momsdeklaration pages
send users here specifically to (re)connect — below the long tax
send users here specifically to (re)connect; below the long tax
form it sat out of view. */}
{showSkatteverket && <SkatteverketConnectPanel />}
@@ -1,11 +1,16 @@
'use client'
import { useTranslations } from 'next-intl'
import { BookingTemplatesPanel } from '@/components/settings/BookingTemplatesPanel'
import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel'
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
export function TemplatesSettingsContent() {
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
return (
<div className="space-y-8">
<div>
<SettingsSectionHeader title={tNav('templates')} intro={tIntro('templates')} />
<BookingTemplatesPanel />
<CounterpartyTemplatesPanel />
</div>
+1
View File
@@ -85,6 +85,7 @@ export function HelpPopover({ children, className }: HelpPopoverProps) {
<div
ref={panelRef}
role="note"
data-help-popover=""
className="fixed z-[60] w-[300px] rounded-lg border border-border bg-popover p-4 text-[13px] leading-relaxed text-foreground shadow-lg animate-in fade-in slide-in-from-top-1 duration-150"
style={{ top: pos.top, left: pos.left }}
>