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:
co-authored by
Claude Opus 5
parent
f07a34c51b
commit
d54b43f80f
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 > 80 MSEK, tillgångar
|
||||
> 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 > 80 MSEK, tillgångar
|
||||
> 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)} – {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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">×</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>
|
||||
|
||||
@@ -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 }}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user