Bug/resend and invoices (#1192)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat: enhance article management and settings UI

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-25 22:56:17 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent f07a34c51b
commit d54b43f80f
89 changed files with 6399 additions and 4682 deletions
+21 -5
View File
@@ -3,6 +3,7 @@
Classification: Confidential Classification: Confidential
Date: 2026-07-22 Date: 2026-07-22
Updated: 2026-07-24 (provider delivery outcome)
Owner: Accounted controller Owner: Accounted controller
Status: Screening completed Status: Screening completed
@@ -19,9 +20,19 @@ and legal obligations under Article 6(1)(c) and BFL 7 kap.
The exact payload is needed server-side to resolve delivery disputes and retain The exact payload is needed server-side to resolve delivery disputes and retain
the sent accounting document. It is not necessary in the routine browser list. the sent accounting document. It is not necessary in the routine browser list.
The list therefore exposes only status, timestamps, masked recipient domains, The list therefore exposes only status, timestamps, masked recipient domains,
provider name, error code, and an active-company-scoped link to the archived provider name, the provider delivery outcome with its masked reason text, error
PDF. Subjects, bodies, full addresses, reply-to addresses, provider message IDs, code, and an active-company-scoped link to the archived PDF. Subjects, bodies,
BCC recipients, filenames, and checksums are excluded. full addresses, reply-to addresses, provider message IDs, BCC recipients,
filenames, and checksums are excluded.
The provider delivery outcome (`provider_status`, `provider_status_at`,
`provider_status_detail`) is received from the email provider over a signed
webhook after the send. The outcome and its timestamp are delivery metadata.
The reason text is provider-authored and routinely quotes the recipient address
that failed, so it is treated as recipient personal data: local parts are masked
before it leaves the server, the stored text is capped at 500 characters, and it
is cleared by the same daily redaction job as the rest of the delivery PII. No
open or click tracking is enabled, so no recipient behaviour is recorded.
The owner/admin full statutory archive has a different legal and operational The owner/admin full statutory archive has a different legal and operational
purpose from the routine list, so it intentionally does not apply the list's purpose from the routine list, so it intentionally does not apply the list's
@@ -49,8 +60,13 @@ server-generated export.
statutory exports are owner/admin-only server operations. Their exact payload statutory exports are owner/admin-only server operations. Their exact payload
exception is limited to the downloadable statutory archive purpose described exception is limited to the downloadable statutory archive purpose described
above and is not reused by the routine history endpoint. above and is not reused by the routine history endpoint.
The summary function is defined in migration `20260723003000` and the route The summary function is defined in migration `20260724160000` and the route
applies domain masking again before returning its allow-listed fields. applies domain masking again before returning its allow-listed fields,
including inside the provider reason text.
- Forged delivery outcome: the provider webhook is Svix-signature verified
before anything is written, and the applying function is service-role only.
It matches on the provider's own message identifier, may only touch an
already sent, unredacted row, and can never downgrade an observed failure.
- Forged delivery evidence: authenticated PostgREST INSERT and UPDATE access is - Forged delivery evidence: authenticated PostgREST INSERT and UPDATE access is
removed. Server-only functions bind reservations and state transitions to a removed. Server-only functions bind reservations and state transitions to a
verified writable company member. Payload-free crashed reservations may be verified writable company member. Payload-free crashed reservations may be
+2
View File
@@ -52,6 +52,8 @@ processing_activities:
- exact_sent_pdf_worm_protection - exact_sent_pdf_worm_protection
- metadata_only_immutable_audit_log - metadata_only_immutable_audit_log
- daily_post_retention_pii_redaction - daily_post_retention_pii_redaction
- signed_provider_delivery_webhook_only
- masked_recipient_addresses_in_provider_reason_text
- id: customer.private_identity - id: customer.private_identity
name: Personnummer för privatkund name: Personnummer för privatkund
+8
View File
@@ -368,6 +368,11 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-24] Declined compliance-bot ask to restore internal check codes (RC_BASIS_MISSING et al) in VAT check rows (#1161): the codes are local pre-flight rule IDs, not statutory references; the Swedish messages already cite the rutor and SKV felkod (FK004), and the founder explicitly flagged the raw codes as visual noise. [2026-07-24] Declined compliance-bot ask to restore internal check codes (RC_BASIS_MISSING et al) in VAT check rows (#1161): the codes are local pre-flight rule IDs, not statutory references; the Swedish messages already cite the rutor and SKV felkod (FK004), and the founder explicitly flagged the raw codes as visual noise.
[2026-07-24] Declined compliance-bot ask to re-box the bolagsskattMissing warning (#1161): text-attn IS the locked house attention idiom (one ochre sentence, banners forbidden by design.md); PreviewStep keeps the inline action to the dispositions step, so salience + remediation path both remain. [2026-07-24] Declined compliance-bot ask to re-box the bolagsskattMissing warning (#1161): text-attn IS the locked house attention idiom (one ochre sentence, banners forbidden by design.md); PreviewStep keeps the inline action to the dispositions step, so salience + remediation path both remain.
[2026-07-24] VAT RC checks proportional (0.5% + 1 kr tolerance) + latched stepper landing: the binary present/absent RC_BASIS_MISSING check cleared after one korrigering and hid a 38-voucher worklist behind "klart"; tolerance absorbs per-voucher basis rounding (moms/sats vs invoiced amount) without hiding a missing voucher; landing step latches once per period so a mid-work refetch cannot navigate the user off Kontrollera. [2026-07-24] VAT RC checks proportional (0.5% + 1 kr tolerance) + latched stepper landing: the binary present/absent RC_BASIS_MISSING check cleared after one korrigering and hid a 38-voucher worklist behind "klart"; tolerance absorbs per-voucher basis rounding (moms/sats vs invoiced amount) without hiding a missing voucher; landing step latches once per period so a mid-work refetch cannot navigate the user off Kontrollera.
[2026-07-24] Stripe for Arcim's own books = self-connection row, not Connect OAuth: the Connect platform account IS Arcim Technology AB (acct_1TQrB0Qj4cYcnWY9), and Stripe refuses to let an account connect to itself. Verified that Stripe accepts a self-referencing Stripe-Account header as a no-op, so the whole sync path works unchanged against the platform account; only the OAuth handshake that creates the stripe_connections row is blocked. Row inserted by hand in prod (company ed461bc1, connection b77b6619) as an interim unblock; the durable fix is a gated STRIPE_PLATFORM_OWNER_COMPANY_ID self-connect path, which also unblocks self-hosted installs whose operator owns the platform key. The row must stay pinned to that one company: it grants read access to the platform account's live Stripe data.
[2026-07-24] Invoice delivery outcome stored per message on invoice_deliveries, not in a per-recipient events table: Resend reports delivered/bounced/complained per message (one event carries the whole to[] and names the failing address only in free text), so per-recipient status would require one email per recipient and would break CC. The three columns are the only mutation allowed on a sent row; the WORM trigger proves nothing else changed by subtracting them from the row image, and the provider reason text is treated as recipient PII (masked on read, cleared by the daily redaction job).
[2026-07-24] next-intl timeZone pinned to Europe/Stockholm globally (i18n/request.ts + explicit prop on NextIntlClientProvider) rather than per call site: unset, formatting falls back to the runtime zone, which is UTC on Vercel and the visitor's zone in the browser, so timestamps both lied and disagreed across hydration.
[2026-07-24] Stripe integration is feed-only (Emil's product call): "Synka nu" and the crons import balance transactions to the inbox, nothing auto-books. The event/settlement sync (lib/sync.ts, lib/payouts.ts + tests) stays in the repo dormant: not deleted, not wired to any cron or route; the 15-min sync cron was removed from vercel.json. Payment links on invoice send stay (payments arrive as feed rows, matched manually). transaction_sync_enabled defaults true at OAuth activation. Also parked leg 1 on Arcim's prod connection (last_event_created_at=2100-01-01) so the still-deployed old cron cannot auto-book payouts before this change ships; the field is dead code after deploy. Side effect: the contested reverse-charge-vs-exempt VAT question on Stripe fees no longer has an automated code path deciding it; the inbox flow (template: momsfri) owns fee booking.
[2026-07-25] /api/v1 articles list now returns `currency`: the dashboard, importer, export and MCP article surfaces all learned about a non-SEK article price (#1166, #1183, #1184), but the v1 projection still omitted it, so an API or agent caller read a EUR price with nothing marking it as EUR and would copy the number straight onto a SEK invoice line. Additive field plus a pitfall on the endpoint; no FX conversion is implied.
[2026-07-25] Removed invented 6-month minimum for first räkenskapsår: BFL 3 kap 3 § sets no floor (Bolagsverket: "hur kort som helst", max 18 months); the check only existed for isFirstPeriod, exactly the case the law exempts, and blocked a customer shortening an autumn-registered first year to Dec 31. [2026-07-25] Removed invented 6-month minimum for first räkenskapsår: BFL 3 kap 3 § sets no floor (Bolagsverket: "hur kort som helst", max 18 months); the check only existed for isFirstPeriod, exactly the case the law exempts, and blocked a customer shortening an autumn-registered first year to Dec 31.
[2026-07-25] Article EUR-price support bug: root cause was the edit dialog omitting currency from initialData (form defaulted SEK and PATCHed it back) plus kr-hardcoded formatCurrency calls; export gets a Valuta column + suffix-free decimalColumn instead of extending CURRENCY_FORMAT, importer Valuta detection deferred as follow-up to keep the diff scoped. [2026-07-25] Article EUR-price support bug: root cause was the edit dialog omitting currency from initialData (form defaulted SEK and PATCHed it back) plus kr-hardcoded formatCurrency calls; export gets a Valuta column + suffix-free decimalColumn instead of extending CURRENCY_FORMAT, importer Valuta detection deferred as follow-up to keep the diff scoped.
[2026-07-25] Reinstated article deactivation as an explicit PATCH active-toggle button on the detail page (support: odinaero.se) instead of reverting DELETE to soft-delete: 8a9a930f intentionally made DELETE hard-delete for unused articles, but that left invoice-referenced articles (ARTICLE_IN_USE) with no retire path; the old deactivate i18n keys were still in messages/ and are reused. [2026-07-25] Reinstated article deactivation as an explicit PATCH active-toggle button on the detail page (support: odinaero.se) instead of reverting DELETE to soft-delete: 8a9a930f intentionally made DELETE hard-delete for unused articles, but that left invoice-referenced articles (ARTICLE_IN_USE) with no retire path; the old deactivate i18n keys were still in messages/ and are reused.
@@ -376,3 +381,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-25] Editing a draft ROT/RUT invoice keeps the stored encrypted personnummer when the field is left empty and deduction lines remain (#1186): the plaintext is not client-rehydratable by design, so empty-means-keep is the only edit semantics that neither blocks the edit nor wipes the ciphertext; typed value replaces, removing all deduction lines clears. [2026-07-25] Editing a draft ROT/RUT invoice keeps the stored encrypted personnummer when the field is left empty and deduction lines remain (#1186): the plaintext is not client-rehydratable by design, so empty-means-keep is the only edit semantics that neither blocks the edit nor wipes the ciphertext; typed value replaces, removing all deduction lines clears.
[2026-07-25] Article delete was broken globally by a phantom invoice_items.company_id filter (42703 -> ARTICLE_DELETE_FAILED) that mocked route tests cannot catch; fixed in #1188 with a source-pin test. Lesson: supabase-mock tests validate flow, never schema: any new filtered column needs a schema-level check or pg-real coverage. [2026-07-25] Article delete was broken globally by a phantom invoice_items.company_id filter (42703 -> ARTICLE_DELETE_FAILED) that mocked route tests cannot catch; fixed in #1188 with a source-pin test. Lesson: supabase-mock tests validate flow, never schema: any new filtered column needs a schema-level check or pg-real coverage.
[2026-07-25] Popup-after-await fix uses a pre-opened tab (AGIPanel pattern) via lib/browser/deferred-tab, not an anchor-download fallback: pre-opening about:blank keeps the user gesture and works for blob and signed URLs alike; the helper severs window.opener, except the Arcim OAuth popup which keeps it for postMessage. [2026-07-25] Popup-after-await fix uses a pre-opened tab (AGIPanel pattern) via lib/browser/deferred-tab, not an anchor-download fallback: pre-opening about:blank keeps the user gesture and works for blob and signed URLs alike; the helper severs window.opener, except the Arcim OAuth popup which keeps it for postMessage.
[2026-07-25] Declined the review suggestion to 200-ack the Resend delivery webhook when RESEND_DELIVERY_WEBHOOK_SECRET is unset; kept 503. The endpoint is only ever called because an operator pointed Resend at it, so a missing secret at that moment is a live misconfiguration: Svix retry then endpoint-disable is a visible signal, whereas a silent 200 loses every delivery outcome with only a log line. The "optional" wording in docs/WHITELABEL.md describes not wiring the webhook at all, not wiring it half way.
[2026-07-25] Reverted the settings panel-sheet redesign on bug/resend-and-invoices back to main: Emil prefers the settings UI as it stands on main. The routed sheet, the sheet/ primitives (SettingsMasterDetail, SettingsAccordion, SettingsFieldRow), the *Subsections.tsx decompositions, the cold-load sheet and the settings_sheet i18n namespace were removed; every app/(dashboard)/settings/* page, components/settings/** file and MainContainer scroll exception now matches origin/main byte for byte. Unrelated branch work (invoice delivery outcomes, Stripe feed-only, article currency/deactivation, PDF logo) is untouched.
[2026-07-25] Settings UI on bug/resend-and-invoices now comes from feat/settings-fonster-redesign (dbae8792, Jakob) instead of the panel-sheet work reverted earlier the same day: Emil chose the Fonster concept (flat hairline rows, help behind "?", sticky dirty-only save bar, 920x680 modal, switches instead of checkboxes). Applied as a patch rather than a merge because the redesign branch forks from b5e3c476 and merging would have dragged that older main in; every file applied cleanly since no settings file changed on main since that fork point. The 10 settings_payments keys the redesign still carries (needs_review_*, reason_*, sync_done_description/transactions) were deliberately NOT restored: the Stripe feed-only commit on this branch deleted both them and their call sites.
+17 -11
View File
@@ -57,8 +57,8 @@ export default function ArticleDetailPage({
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isEditOpen, setIsEditOpen] = useState(false) const [isEditOpen, setIsEditOpen] = useState(false)
const [isUpdating, setIsUpdating] = useState(false) const [isUpdating, setIsUpdating] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isTogglingActive, setIsTogglingActive] = useState(false) const [isTogglingActive, setIsTogglingActive] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm() const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
useEffect(() => { useEffect(() => {
@@ -131,17 +131,21 @@ export default function ArticleDetailPage({
} }
} }
// Soft retire/restore: the only path for articles already used on invoices, // Soft deactivation is the answer for an article that has already been used
// where hard delete is refused (ARTICLE_IN_USE) to keep invoice history. // on an invoice: the delete path refuses those (ARTICLE_IN_USE), while
// active=false hides it from the invoice picker, the export and the MCP
// listing without touching invoice history. Reactivation is not destructive,
// so only the deactivate direction confirms.
async function handleToggleActive() { async function handleToggleActive() {
if (!article) return if (!article) return
const deactivating = article.active const nextActive = !article.active
if (deactivating) {
if (!nextActive) {
const ok = await confirmAction({ const ok = await confirmAction({
title: t('deactivate_confirm_title', { name: article.name }), title: t('deactivate_confirm_title', { name: article.name }),
description: t('deactivate_confirm_description'), description: t('deactivate_confirm_description'),
confirmLabel: t('deactivate_confirm_label'), confirmLabel: t('deactivate_confirm_label'),
variant: 'destructive', variant: 'warning',
}) })
if (!ok) return if (!ok) return
} }
@@ -151,18 +155,19 @@ export default function ArticleDetailPage({
const response = await fetch(`/api/articles/${id}`, { const response = await fetch(`/api/articles/${id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active: !article.active }), body: JSON.stringify({ active: nextActive }),
}) })
await throwOnStructuredError(response) const { data } = (await throwOnStructuredError(response)) as { data: Article }
setArticle(data)
toast({ toast({
title: deactivating ? t('deactivated_title') : t('activated_title'), title: nextActive ? t('activated_title') : t('deactivated_title'),
description: article.name, description: article.name,
}) })
fetchArticle()
} catch (err) { } catch (err) {
const body = (err as { body?: unknown }).body const body = (err as { body?: unknown }).body
toast({ toast({
title: deactivating ? t('deactivate_failed_title') : t('activate_failed_title'), title: nextActive ? t('activate_failed_title') : t('deactivate_failed_title'),
description: getErrorMessage(body ?? err, { context: 'article', locale: errorLocale }), description: getErrorMessage(body ?? err, { context: 'article', locale: errorLocale }),
variant: 'destructive', variant: 'destructive',
}) })
@@ -264,6 +269,7 @@ export default function ArticleDetailPage({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleToggleActive} onClick={handleToggleActive}
className="min-h-10"
disabled={isTogglingActive || !canWrite} disabled={isTogglingActive || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined} title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
> >
+21
View File
@@ -63,6 +63,27 @@ describe('GET/PATCH/DELETE /api/articles/[id]', () => {
expect(body.data.price_excl_vat).toBe(1500) expect(body.data.price_excl_vat).toBe(1500)
}) })
// The article detail page's Inaktivera/Aktivera button sends nothing but the
// flag, so an active-only body must be a valid sparse update on its own.
it('PATCH toggles active on its own without any other field', async () => {
enqueue({ data: { id: 'a1', name: 'Konsulttimme', active: false } })
const request = createMockRequest('/api/articles/a1', {
method: 'PATCH',
body: { active: false },
})
const response = await PATCH(request, createMockRouteParams({ id: 'a1' }))
const { status, body } = await parseJsonResponse<{ data: { active: boolean } }>(response)
expect(status).toBe(200)
expect(body.data.active).toBe(false)
// Only the articles update: no revenue-account lookup is triggered by a
// body that carries nothing but the flag.
expect(supabase.from).toHaveBeenCalledTimes(1)
expect(supabase.from).toHaveBeenCalledWith('articles')
})
it('PATCH answers ACCOUNTS_NOT_IN_CHART for a BAS class-3 account missing from the chart', async () => { it('PATCH answers ACCOUNTS_NOT_IN_CHART for a BAS class-3 account missing from the chart', async () => {
// chart_of_accounts lookup: no row, but 3999 is a known BAS class-3 // chart_of_accounts lookup: no row, but 3999 is a known BAS class-3
// account → activatable via the activate-and-retry dialog flow. // account → activatable via the activate-and-retry dialog flow.
@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
// Mock dependencies: factory must not reference outer variables
const mockExchangeCodeForAccount = vi.fn()
const mockFetchAccountDisplayName = vi.fn()
vi.mock('@/extensions/general/stripe/lib/connect', () => ({
exchangeCodeForAccount: (...args: unknown[]) => mockExchangeCodeForAccount(...args),
fetchAccountDisplayName: (...args: unknown[]) => mockFetchAccountDisplayName(...args),
}))
const { mockFrom } = vi.hoisted(() => ({ mockFrom: vi.fn() }))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn().mockResolvedValue({ from: mockFrom }),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000')
import { GET } from '../route'
const CONNECTION_ID = 'connection-1'
const OAUTH_STATE = 'state-token-1'
function makeRequest(params: Record<string, string>) {
const url = new URL('http://localhost:3000/api/extensions/stripe/callback')
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, v)
}
return new Request(url.toString())
}
function mockChain(result: { data?: unknown; error?: unknown }) {
const chain: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'update', 'insert']) {
chain[m] = vi.fn().mockReturnValue(chain)
}
chain.single = vi
.fn()
.mockResolvedValue({ data: result.data ?? null, error: result.error ?? null })
// For chains ending without .single() (the insert and the error-path updates)
chain.then = (resolve: (v: unknown) => void) =>
resolve({ data: result.data ?? null, error: result.error ?? null })
return chain
}
describe('GET /api/extensions/stripe/callback', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
mockExchangeCodeForAccount.mockResolvedValue({
stripeAccountId: 'acct_123',
livemode: false,
})
mockFetchAccountDisplayName.mockResolvedValue('Test Shop')
})
it('activates the connection and turns the transaction feed on by default', async () => {
const findChain = mockChain({
data: { id: CONNECTION_ID, user_id: 'user-1', company_id: 'company-1' },
})
const replayChain = mockChain({ error: null })
const activateChain = mockChain({
data: {
id: CONNECTION_ID,
company_id: 'company-1',
user_id: 'user-1',
stripe_account_id: 'acct_123',
livemode: false,
},
})
mockFrom
.mockReturnValueOnce(findChain)
.mockReturnValueOnce(replayChain)
.mockReturnValueOnce(activateChain)
const response = await GET(makeRequest({ code: 'ac_123', state: OAUTH_STATE }))
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe(
'http://localhost:3000/import?mode=stripe&stripe_connected=true',
)
// Feed-only product: a completed OAuth must leave the nightly sync armed,
// otherwise a connected account silently ingests nothing.
const activatePayload = (activateChain.update as ReturnType<typeof vi.fn>).mock.calls[0][0]
expect(activatePayload).toMatchObject({
stripe_account_id: 'acct_123',
livemode: false,
display_name: 'Test Shop',
status: 'active',
oauth_state: null,
transaction_sync_enabled: true,
})
})
it('redirects with an error and never activates when the state is unknown', async () => {
mockFrom.mockReturnValueOnce(mockChain({ data: null, error: { code: 'PGRST116' } }))
const response = await GET(makeRequest({ code: 'ac_123', state: 'unknown-state' }))
expect(response.headers.get('location')).toBe(
'http://localhost:3000/import?mode=stripe&stripe_error=invalid_state',
)
expect(mockExchangeCodeForAccount).not.toHaveBeenCalled()
})
it('redirects with an error when the authorization code was already used', async () => {
mockFrom
.mockReturnValueOnce(
mockChain({ data: { id: CONNECTION_ID, user_id: 'user-1', company_id: 'company-1' } }),
)
.mockReturnValueOnce(mockChain({ error: { code: '23505' } }))
const response = await GET(makeRequest({ code: 'ac_123', state: OAUTH_STATE }))
expect(response.headers.get('location')).toBe(
'http://localhost:3000/import?mode=stripe&stripe_error=invalid_state',
)
expect(mockExchangeCodeForAccount).not.toHaveBeenCalled()
})
it('reports the conflict when the Stripe account is already connected', async () => {
mockFrom
.mockReturnValueOnce(
mockChain({ data: { id: CONNECTION_ID, user_id: 'user-1', company_id: 'company-1' } }),
)
.mockReturnValueOnce(mockChain({ error: null }))
.mockReturnValueOnce(mockChain({ data: null, error: { code: '23505', message: 'dup' } }))
.mockReturnValueOnce(mockChain({ error: null }))
const response = await GET(makeRequest({ code: 'ac_123', state: OAUTH_STATE }))
expect(response.headers.get('location')).toBe(
'http://localhost:3000/import?mode=stripe&stripe_error=account_already_connected',
)
})
it('redirects without touching Stripe when parameters are missing', async () => {
const response = await GET(makeRequest({ state: OAUTH_STATE }))
expect(response.headers.get('location')).toBe(
'http://localhost:3000/import?mode=stripe&stripe_error=missing_parameters',
)
expect(mockFrom).not.toHaveBeenCalled()
})
})
@@ -118,6 +118,10 @@ export async function GET(request: Request) {
connected_at: new Date().toISOString(), connected_at: new Date().toISOString(),
error_message: null, error_message: null,
oauth_state: null, // Clear to prevent replay oauth_state: null, // Clear to prevent replay
// Feed-only product: connecting Stripe means fetching its
// transactions, so the nightly feed starts on by default. The panel
// toggle remains as the opt-out.
transaction_sync_enabled: true,
}) })
.eq('id', pendingConnection.id) .eq('id', pendingConnection.id)
.select('id, company_id, user_id, stripe_account_id, livemode') .select('id, company_id, user_id, stripe_account_id, livemode')
@@ -79,6 +79,9 @@ describe('GET /api/invoices/[id]/deliveries', () => {
body_text: 'Hej! Här kommer fakturan.', body_text: 'Hej! Här kommer fakturan.',
provider: 'resend', provider: 'resend',
provider_message_id: 'provider-1', provider_message_id: 'provider-1',
provider_status: 'delivered',
provider_status_at: '2026-07-22T10:30:04.000Z',
provider_status_detail: null,
error_code: null, error_code: null,
document_attachment_id: 'document-1', document_attachment_id: 'document-1',
attachment_filename: 'faktura-f-1001.pdf', attachment_filename: 'faktura-f-1001.pdf',
@@ -105,6 +108,9 @@ describe('GET /api/invoices/[id]/deliveries', () => {
to_addresses: ['***@example.com'], to_addresses: ['***@example.com'],
cc_addresses: ['***@example.com'], cc_addresses: ['***@example.com'],
provider: 'resend', provider: 'resend',
provider_status: 'delivered',
provider_status_at: '2026-07-22T10:30:04.000Z',
provider_status_detail: null,
error_code: null, error_code: null,
document_attachment_id: 'document-1', document_attachment_id: 'document-1',
attachment_filename: 'faktura-f-1001.pdf', attachment_filename: 'faktura-f-1001.pdf',
@@ -127,4 +133,85 @@ describe('GET /api/invoices/[id]/deliveries', () => {
p_invoice_id: INVOICE_ID, p_invoice_id: INVOICE_ID,
}) })
}) })
it('masks recipient addresses quoted inside the provider reason text', async () => {
enqueue({ data: { id: INVOICE_ID }, error: null })
enqueue({
data: [{
id: 'delivery-2',
channel: 'email',
status: 'sent',
to_addresses: ['customer@example.com'],
cc_addresses: [],
provider: 'resend',
provider_status: 'bounced',
provider_status_at: '2026-07-22T10:31:00.000Z',
provider_status_detail:
'550 5.1.1 <customer@example.com>: Recipient address rejected Permanent/General',
error_code: null,
document_attachment_id: 'document-1',
attachment_filename: 'faktura-f-1001.pdf',
sent_at: '2026-07-22T10:30:00.000Z',
failed_at: null,
created_at: '2026-07-22T10:29:59.000Z',
}],
error: null,
})
const response = await GET(
createMockRequest(`/api/invoices/${INVOICE_ID}/deliveries`),
createMockRouteParams({ id: INVOICE_ID }),
)
const { body } = await parseJsonResponse<{ data: Array<Record<string, unknown>> }>(response)
expect(body.data[0].provider_status).toBe('bounced')
expect(body.data[0].provider_status_detail).toBe(
'550 5.1.1 <***@example.com>: Recipient address rejected Permanent/General',
)
})
// An ASCII allow-list stops at the first character it cannot spell and leaks
// the head of the address ("anna.bergstr" out of anna.bergström@). Each of
// these forms is a real local part a provider can quote back at us.
it.each([
['non-ASCII local part', 'anna.bergström@example.se avvisad', '***@example.se avvisad'],
['quoted local part', '"anna berg"@example.com bounced', '***@example.com bounced'],
['apostrophe in local part', "o'brien@example.se hard bounce", '***@example.se hard bounce'],
[
'several addresses in one reason',
'delivered to anna@example.se but not bob@example.com',
'delivered to ***@example.se but not ***@example.com',
],
])('masks the %s in the provider reason text', async (_label, detail, expected) => {
enqueue({ data: { id: INVOICE_ID }, error: null })
enqueue({
data: [{
id: 'delivery-3',
channel: 'email',
status: 'sent',
to_addresses: ['customer@example.com'],
cc_addresses: [],
provider: 'resend',
provider_status: 'bounced',
provider_status_at: '2026-07-22T10:31:00.000Z',
provider_status_detail: detail,
error_code: null,
document_attachment_id: null,
attachment_filename: null,
sent_at: '2026-07-22T10:30:00.000Z',
failed_at: null,
created_at: '2026-07-22T10:29:59.000Z',
}],
error: null,
})
const response = await GET(
createMockRequest(`/api/invoices/${INVOICE_ID}/deliveries`),
createMockRouteParams({ id: INVOICE_ID }),
)
const { body } = await parseJsonResponse<{ data: Array<Record<string, unknown>> }>(response)
expect(body.data[0].provider_status_detail).toBe(expected)
expect(body.data[0].provider_status_detail).not.toContain('anna')
})
}) })
+37 -2
View File
@@ -2,7 +2,11 @@ import { NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { InvoiceDeliveryChannel, InvoiceDeliveryStatus } from '@/types' import type {
InvoiceDeliveryChannel,
InvoiceDeliveryProviderStatus,
InvoiceDeliveryStatus,
} from '@/types'
interface InvoiceDeliverySummaryRow { interface InvoiceDeliverySummaryRow {
id: string id: string
@@ -11,6 +15,9 @@ interface InvoiceDeliverySummaryRow {
to_addresses: string[] to_addresses: string[]
cc_addresses: string[] cc_addresses: string[]
provider: string | null provider: string | null
provider_status: InvoiceDeliveryProviderStatus | null
provider_status_at: string | null
provider_status_detail: string | null
error_code: string | null error_code: string | null
document_attachment_id: string | null document_attachment_id: string | null
attachment_filename: string | null attachment_filename: string | null
@@ -35,8 +42,12 @@ interface MaskedInvoiceDeliverySummaryRow
* addresses stay server-side. The attachment filename passes through: it is * addresses stay server-side. The attachment filename passes through: it is
* derived from data the invoice already exposes to every company member. The * derived from data the invoice already exposes to every company member. The
* database allow-list and masking boundary is defined by * database allow-list and masking boundary is defined by
* list_invoice_delivery_summaries in migration 20260723150000; this route * list_invoice_delivery_summaries in migration 20260724160000; this route
* masks returned addresses again as defense in depth. * masks returned addresses again as defense in depth.
*
* The provider delivery outcome is message-level, never per recipient: the
* provider reports one result for the whole send, and its reason text can
* quote the failing address, so that text is masked the same way.
*/ */
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.deliveries.list', 'invoice.deliveries.list',
@@ -79,6 +90,9 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
to_addresses: delivery.to_addresses.map(maskRecipientDomain), to_addresses: delivery.to_addresses.map(maskRecipientDomain),
cc_addresses: delivery.cc_addresses.map(maskRecipientDomain), cc_addresses: delivery.cc_addresses.map(maskRecipientDomain),
provider: delivery.provider, provider: delivery.provider,
provider_status: delivery.provider_status,
provider_status_at: delivery.provider_status_at,
provider_status_detail: maskAddressesInText(delivery.provider_status_detail),
error_code: delivery.error_code, error_code: delivery.error_code,
document_attachment_id: delivery.document_attachment_id, document_attachment_id: delivery.document_attachment_id,
attachment_filename: delivery.attachment_filename, attachment_filename: delivery.attachment_filename,
@@ -94,6 +108,27 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
}, },
) )
/**
* Provider reason texts routinely quote the address that failed
* ("550 5.1.1 <anna@example.se>: user unknown"). Keep the diagnostic value,
* drop the local part, matching how the recipient list itself is masked.
*
* The local part is matched by exclusion, not by an allow-list of ASCII mail
* characters: an allow-list stops at the first character it does not know, so
* it leaks the head of every address it cannot spell. `anna.bergström@` would
* mask only the `m`, and a quoted local part ("anna berg"@example.com) would
* not match at all. Anything up to the delimiters that genuinely cannot sit
* inside an address (whitespace, the angle brackets and punctuation providers
* wrap addresses in) is treated as local part, so over-masking is the failure
* mode rather than a partial disclosure.
*/
const ADDRESS_LOCAL_PART = /"[^"]*"@|[^\s<>()[\],;:"@]+@/gu
function maskAddressesInText(text: string | null): string | null {
if (!text) return null
return text.replace(ADDRESS_LOCAL_PART, '***@')
}
function maskRecipientDomain(address: string): MaskedRecipientAddress { function maskRecipientDomain(address: string): MaskedRecipientAddress {
const separator = address.lastIndexOf('@') const separator = address.lastIndexOf('@')
if (separator <= 0 || separator === address.length - 1) { if (separator <= 0 || separator === address.length - 1) {
@@ -34,6 +34,7 @@ const SAMPLE_ARTICLE = {
type: 'tjanst', type: 'tjanst',
unit: 'tim', unit: 'tim',
price_excl_vat: 850, price_excl_vat: 850,
currency: 'SEK',
vat_rate: 25, vat_rate: 25,
revenue_account: null, revenue_account: null,
cost_price: null, cost_price: null,
@@ -111,6 +112,26 @@ describe('GET /api/v1/companies/:companyId/articles', () => {
expect(client.eqCalls).toContainEqual(['articles', 'active', true]) expect(client.eqCalls).toContainEqual(['articles', 'active', true])
}) })
it('exposes the article currency so a caller can tell a non-SEK price apart', async () => {
// price_excl_vat alone is ambiguous: without currency an agent copies a EUR
// price onto a SEK invoice line with no FX conversion.
const client = makeSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
articles: { data: [{ ...SAMPLE_ARTICLE, price_excl_vat: 95, currency: 'EUR' }], error: null },
})
mockServiceClient.mockReturnValue(client)
const res = await listArticles(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/articles`),
routeParams,
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.articles[0].currency).toBe('EUR')
expect(body.data.articles[0].price_excl_vat).toBe(95)
})
it('includes inactive articles with ?include_inactive=true', async () => { it('includes inactive articles with ?include_inactive=true', async () => {
const client = makeSupabase({ const client = makeSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
@@ -23,6 +23,7 @@ const ArticleShape = z.object({
type: z.enum(['vara', 'tjanst']), type: z.enum(['vara', 'tjanst']),
unit: z.string(), unit: z.string(),
price_excl_vat: z.number(), price_excl_vat: z.number(),
currency: z.string(),
vat_rate: z.number(), vat_rate: z.number(),
revenue_account: z.string().nullable(), revenue_account: z.string().nullable(),
cost_price: z.number().nullable(), cost_price: z.number().nullable(),
@@ -36,7 +37,7 @@ const ArticleShape = z.object({
// Explicit projection: excludes user_id, company_id (internal scoping). // Explicit projection: excludes user_id, company_id (internal scoping).
const ARTICLE_COLUMNS = const ARTICLE_COLUMNS =
'id, article_number, name, name_en, type, unit, price_excl_vat, vat_rate, revenue_account, cost_price, ean, housework_type, notes, active, created_at, updated_at' 'id, article_number, name, name_en, type, unit, price_excl_vat, currency, vat_rate, revenue_account, cost_price, ean, housework_type, notes, active, created_at, updated_at'
registerEndpoint({ registerEndpoint({
operation: 'articles.list', operation: 'articles.list',
@@ -52,6 +53,7 @@ registerEndpoint({
pitfalls: [ pitfalls: [
'Linking article_id does NOT auto-fill the invoice line: send description, unit_price, vat_rate etc. explicitly on the item (copy them from this response).', 'Linking article_id does NOT auto-fill the invoice line: send description, unit_price, vat_rate etc. explicitly on the item (copy them from this response).',
'price_excl_vat always excludes VAT.', 'price_excl_vat always excludes VAT.',
'price_excl_vat is denominated in the article\'s own currency, which is NOT always SEK. Check currency before copying the price onto an invoice line: the invoice carries a single currency for all its lines and there is no FX conversion here.',
'housework_type is an arbetstypskod hint (e.g. BYGG, STAD); the invoice line still needs deduction_type + labor_hours + work_type set explicitly for ROT/RUT.', 'housework_type is an arbetstypskod hint (e.g. BYGG, STAD); the invoice line still needs deduction_type + labor_hours + work_type set explicitly for ROT/RUT.',
'Inactive articles (active=false) are hidden by default but remain linkable for historical reads.', 'Inactive articles (active=false) are hidden by default but remain linkable for historical reads.',
], ],
@@ -67,6 +69,7 @@ registerEndpoint({
type: 'tjanst', type: 'tjanst',
unit: 'tim', unit: 'tim',
price_excl_vat: 850, price_excl_vat: 850,
currency: 'SEK',
vat_rate: 25, vat_rate: 25,
revenue_account: null, revenue_account: null,
cost_price: null, cost_price: null,
+4 -1
View File
@@ -14,6 +14,7 @@ import { RecaptHideWidget } from "@/components/RecaptHideWidget";
import { ScrollbarReveal } from "@/components/ScrollbarReveal"; import { ScrollbarReveal } from "@/components/ScrollbarReveal";
import { ensureInitialized } from "@/lib/init"; import { ensureInitialized } from "@/lib/init";
import { getBranding } from "@/lib/branding/service"; import { getBranding } from "@/lib/branding/service";
import { APP_TIME_ZONE } from "@/i18n/config";
import "./globals.css"; import "./globals.css";
// Load extensions before metadata/viewport functions read the branding service. // Load extensions before metadata/viewport functions read the branding service.
@@ -81,7 +82,9 @@ export default async function RootLayout({
<body <body
className="antialiased" className="antialiased"
> >
<NextIntlClientProvider locale={locale} messages={messages}> {/* timeZone is passed explicitly: client components must format in the
same zone the server rendered with, or timestamps shift on hydration. */}
<NextIntlClientProvider locale={locale} messages={messages} timeZone={APP_TIME_ZONE}>
<ThemeProvider <ThemeProvider
attribute="class" attribute="class"
defaultTheme="light" defaultTheme="light"
+1 -4
View File
@@ -12,7 +12,7 @@ import AttGoraSection from '@/components/dashboard/AttGoraSection'
import ResumePane from '@/components/dashboard/ResumePane' import ResumePane from '@/components/dashboard/ResumePane'
import BackupHealthBanner from '@/components/dashboard/BackupHealthBanner' import BackupHealthBanner from '@/components/dashboard/BackupHealthBanner'
import { SkatteverketPromoCard } from '@/components/dashboard/SkatteverketPromoCard' 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 { InitialSetupState, OnboardingProgress } from '@/types'
import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types' import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types'
import type { ResumeItem } from '@/lib/worklist/resume' 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"> <Link href={hasAi ? '/onboarding/agent' : '/settings/billing'} className="block group">
<Card className="transition-colors hover:border-primary/50"> <Card className="transition-colors hover:border-primary/50">
<CardContent className="p-6 flex items-center gap-4"> <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-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="font-display text-xl leading-tight">Bygg din bokföringsassistent</p> <p className="font-display text-xl leading-tight">Bygg din bokföringsassistent</p>
+67 -6
View File
@@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import type { InvoiceDelivery } from '@/types' import type { InvoiceDelivery, InvoiceDeliveryProviderStatus } from '@/types'
export type InvoiceDeliveryView = Pick< export type InvoiceDeliveryView = Pick<
InvoiceDelivery, InvoiceDelivery,
@@ -15,6 +15,9 @@ export type InvoiceDeliveryView = Pick<
| 'to_addresses' | 'to_addresses'
| 'cc_addresses' | 'cc_addresses'
| 'provider' | 'provider'
| 'provider_status'
| 'provider_status_at'
| 'provider_status_detail'
| 'error_code' | 'error_code'
| 'document_attachment_id' | 'document_attachment_id'
| 'attachment_filename' | 'attachment_filename'
@@ -30,12 +33,37 @@ interface InvoiceDeliveryHistoryProps {
showLegacyEmptyState: boolean 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', pending: 'secondary',
sent: 'success', sent: 'secondary',
delivered: 'success',
delayed: 'warning',
complained: 'warning',
bounced: 'destructive',
failed: 'destructive', failed: 'destructive',
suppressed: 'destructive',
marked_sent: 'outline', marked_sent: 'outline',
} as const }
export function InvoiceDeliveryHistory({ export function InvoiceDeliveryHistory({
deliveries, deliveries,
@@ -76,6 +104,10 @@ export function InvoiceDeliveryHistory({
{deliveries.map((delivery) => { {deliveries.map((delivery) => {
const occurredAt = delivery.sent_at || delivery.failed_at || delivery.created_at const occurredAt = delivery.sent_at || delivery.failed_at || delivery.created_at
const isManual = delivery.channel === 'manual' 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 ( return (
<details key={delivery.id} className="group rounded-lg border bg-card"> <details key={delivery.id} className="group rounded-lg border bg-card">
@@ -91,8 +123,8 @@ export function InvoiceDeliveryHistory({
{formatTimestamp(occurredAt)} {formatTimestamp(occurredAt)}
</span> </span>
</span> </span>
<Badge variant={statusVariant[delivery.status]}> <Badge variant={outcomeVariant[outcome]}>
{t(`delivery_status_${delivery.status}`)} {t(`delivery_status_${outcome}`)}
</Badge> </Badge>
</summary> </summary>
@@ -115,6 +147,35 @@ export function InvoiceDeliveryHistory({
)} )}
</dl> </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 && ( {delivery.document_attachment_id && (
<Button asChild variant="outline" size="sm" className="max-w-full"> <Button asChild variant="outline" size="sm" className="max-w-full">
<a <a
+14 -6
View File
@@ -74,13 +74,21 @@ export function TaxTableStatus({ year, compact = false }: Props) {
) )
} }
// Flat row presentation (Fönster settings language): muted status line
// with a quiet right-aligned recheck, no box.
return ( return (
<div className="flex items-center justify-between gap-3 rounded-md border bg-muted/30 px-3 py-2"> <div className="flex w-full min-w-0 items-center gap-2">
<div className="flex items-center gap-2 text-sm"> <Icon className={`h-3.5 w-3.5 shrink-0 ${iconColor}`} />
<Icon className={`h-4 w-4 ${iconColor}`} /> <span className="min-w-0 text-xs text-muted-foreground">{label}</span>
<span>{label}</span> <Button
</div> type="button"
<Button variant="ghost" size="sm" onClick={check} disabled={loading} aria-label={t('recheck')}> 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" />} {loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCcw className="h-3.5 w-3.5" />}
</Button> </Button>
</div> </div>
+58 -50
View File
@@ -20,6 +20,12 @@ import { RetentionNotice } from '@/components/ui/retention-notice'
import { ExternalLink, Loader2 } from 'lucide-react' import { ExternalLink, Loader2 } from 'lucide-react'
import { SupportLink } from '@/components/ui/support-link' import { SupportLink } from '@/components/ui/support-link'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import {
SettingsDangerZone,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
interface Blocker { interface Blocker {
id: string id: string
@@ -28,6 +34,7 @@ interface Blocker {
export function AccountDangerZone() { export function AccountDangerZone() {
const t = useTranslations('settings_account_danger') const t = useTranslations('settings_account_danger')
const tRetention = useTranslations('retention_notice')
const router = useRouter() const router = useRouter()
const [email, setEmail] = useState<string | null>(null) const [email, setEmail] = useState<string | null>(null)
const [blockers, setBlockers] = useState<Blocker[]>([]) const [blockers, setBlockers] = useState<Blocker[]>([])
@@ -103,61 +110,62 @@ export function AccountDangerZone() {
return ( return (
<> <>
<section className="space-y-4 border-t border-border pt-8"> <SettingsDangerZone label={t('heading')}>
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive"> {/* Owned companies block deletion: functional state, kept visible as
{t('heading')} rows (only the first row carries the label and the why-help). */}
</h2> {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 && ( <SettingsRow
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-3"> label={t('delete_button')}
<p className="text-sm font-medium">{t('blockers_title')}</p> borderless
<p className="text-sm text-muted-foreground"> // The full anonymization/BFL retention copy (incl. the backup link)
{t('blockers_description')} // lives behind the "?": the visible row stays one quiet line.
</p> help={<RetentionNotice variant="account" className="border-0 bg-transparent p-0" />}
<ul className="space-y-2"> >
{blockers.map((b) => ( <SettingsRowNote>{tRetention('account_title')}</SettingsRowNote>
<li <SettingsRowEnd>
key={b.id} <Button variant="ghost" size="sm" asChild>
className="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2" <Link href="/reports?type=sie">
> <ExternalLink className="mr-2 h-3.5 w-3.5" />
<span className="text-sm font-medium">{b.name}</span> {t('export_sie')}
<Button variant="ghost" size="sm" asChild> </Link>
<Link href="/settings/company">{t('blockers_manage')}</Link> </Button>
</Button> <button
</li> type="button"
))} onClick={() => setShowDialog(true)}
</ul> disabled={!canDelete}
</div> 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')}
<RetentionNotice variant="account" /> </button>
</SettingsRowEnd>
<p className="text-sm text-muted-foreground"> </SettingsRow>
{t('support_question')}{' '}
<SupportLink variant="inline" subject={t('support_subject')} />
</p>
{error && !showDialog && ( {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"> <p className="px-1 pt-3">
<Button variant="outline" className="w-full sm:w-auto" asChild> <SettingsRowNote>
<Link href="/reports?type=sie"> {t('support_question')}{' '}
<ExternalLink className="mr-2 h-4 w-4" /> <SupportLink variant="inline" subject={t('support_subject')} />
{t('export_sie')} </SettingsRowNote>
</Link> </p>
</Button> </SettingsDangerZone>
<Button
variant="destructive"
className="w-full sm:w-auto"
onClick={() => setShowDialog(true)}
disabled={!canDelete}
>
{t('delete_button')}
</Button>
</div>
</section>
<Dialog <Dialog
open={showDialog} open={showDialog}
+36 -35
View File
@@ -1,15 +1,7 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -19,6 +11,11 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import {
SettingsRow,
SettingsRowNote,
SettingsSelect,
} from '@/components/settings/SettingsRows'
import type { AccountingFramework } from '@/types' import type { AccountingFramework } from '@/types'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
@@ -30,8 +27,9 @@ interface AccountingFrameworkFormProps {
} }
/** /**
* K2/K3 selector for AB. Lives on the bookkeeping settings page. Renders nothing * K2/K3 selector row for AB. Lives in the Grunder group on the bookkeeping
* for non-AB entities: the parent gates this component by entity_type. * settings page. Renders nothing for non-AB entities: the parent gates this
* component by entity_type.
* *
* UX rules (regulatory area: kept in Swedish): * UX rules (regulatory area: kept in Swedish):
* - Default is K2 (matches the column default and BFNAR 2016:10 baseline). * - Default is K2 (matches the column default and BFNAR 2016:10 baseline).
@@ -100,33 +98,36 @@ export function AccountingFrameworkForm({ current, onSaved }: AccountingFramewor
} }
return ( return (
<section className="space-y-4"> <>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> <SettingsRow
Redovisningsregelverk label="Regelverk"
</h2> htmlFor="accounting_framework"
<div className="space-y-2"> help={
<Label htmlFor="accounting_framework">Regelverk</Label> <>
<Select K2 är standard för mindre bolag och innebär förenklade regler. K3 krävs när
bolaget når två av tre tröskelvärden (nettoomsättning &gt; 80 MSEK, tillgångar
&gt; 40 MSEK, eller fler än 50 anställda). K3 ställer högre krav: kassaflödesanalys,
komponentavskrivning på materiella anläggningstillgångar och redovisning av
uppskjuten skatt på obeskattade reserver (79,4 % eget kapital / 20,6 % skuld).
</>
}
>
<SettingsSelect
id="accounting_framework"
value={selected} 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} disabled={saving}
> >
<SelectTrigger id="accounting_framework" className="w-full max-w-sm"> <option value="k2">K2 (BFNAR 2016:10): mindre företag</option>
<SelectValue /> <option value="k3">K3 (BFNAR 2012:1): större företag</option>
</SelectTrigger> </SettingsSelect>
<SelectContent> {saving && <SettingsRowNote>Sparar…</SettingsRowNote>}
<SelectItem value="k2">K2 (BFNAR 2016:10): mindre företag</SelectItem> </SettingsRow>
<SelectItem value="k3">K3 (BFNAR 2012:1): större företag</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
K2 är standard för mindre bolag och innebär förenklade regler. K3 krävs när
bolaget når två av tre tröskelvärden (nettoomsättning &gt; 80 MSEK, tillgångar
&gt; 40 MSEK, eller fler än 50 anställda). K3 ställer högre krav: kassaflödesanalys,
komponentavskrivning på materiella anläggningstillgångar och redovisning av
uppskjuten skatt på obeskattade reserver (79,4 % eget kapital / 20,6 % skuld).
</p>
</div>
<Dialog <Dialog
open={pending !== null} open={pending !== null}
@@ -174,6 +175,6 @@ export function AccountingFrameworkForm({ current, onSaved }: AccountingFramewor
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</section> </>
) )
} }
+204 -196
View File
@@ -2,16 +2,24 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { Brain, Loader2, Pin, PinOff, Pencil, Plus, RotateCcw, Trash2, X } from 'lucide-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 { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea' import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { EmptyState } from '@/components/ui/empty-state' import { EmptyState } from '@/components/ui/empty-state'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast' 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 { 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 Kind = 'fact' | 'preference' | 'pattern' | 'correction'
type Source = 'composer' | 'user_taught' | 'agent_learned' | 'derived' 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 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 { function apiErrorText(error: unknown): string | undefined {
if (typeof error === 'string') return error if (typeof error === 'string') return error
if (error && typeof error === 'object' && 'message' in error) { if (error && typeof error === 'object' && 'message' in error) {
@@ -159,19 +167,31 @@ export function AgentMemoryPanel() {
setEditingId(null) setEditingId(null)
} }
// The view wrapper in AssistantSettingsContent already provides the gap
// under the segmented control, so the group starts flush (pt-0).
return ( return (
<Card> <SettingsGroup
<CardHeader className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between"> label="Vad min assistent kommer ihåg"
<div> help={
<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
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
alltid ska vara med, redigera fel, eller dölj det som inte längre stämmer. minnen ingår i samtal per tur.
</CardDescription> </>
</div> }
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 && ( {canWrite && (
<Button <Button
variant="outline" variant="ghost"
size="sm" size="sm"
onClick={() => setShowAdd((v) => !v)} onClick={() => setShowAdd((v) => !v)}
disabled={adding} disabled={adding}
@@ -180,31 +200,32 @@ export function AgentMemoryPanel() {
Lägg till minne Lägg till minne
</Button> </Button>
)} )}
</CardHeader> </div>
<CardContent className="space-y-6"> {canWrite && (
{showAdd && canWrite && ( <SettingsReveal open={showAdd}>
<div className="rounded-lg border border-border p-4 space-y-3"> <div className="space-y-3 py-3">
<Textarea <SettingsTextarea
value={newContent} value={newContent}
onChange={(e) => setNewContent(e.target.value)} onChange={(e) => setNewContent(e.target.value)}
placeholder="T.ex. Vi använder Stripe för B2C-betalningar; utbetalningar landar på 1930 var måndag." placeholder="T.ex. Vi använder Stripe för B2C-betalningar; utbetalningar landar på 1930 var måndag."
rows={3} rows={3}
maxLength={2000} 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 flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Typ</span> <span className="text-xs text-muted-foreground">Typ</span>
<Select value={newKind} onValueChange={(v) => setNewKind(v as Kind)}> <SettingsSelect
<SelectTrigger className="h-8 w-[160px]"> value={newKind}
<SelectValue /> onChange={(e) => setNewKind(e.target.value as Kind)}
</SelectTrigger> aria-label="Typ"
<SelectContent> >
{(Object.keys(KIND_LABEL) as Kind[]).map((k) => ( {(Object.keys(KIND_LABEL) as Kind[]).map((k) => (
<SelectItem key={k} value={k}>{KIND_LABEL[k]}</SelectItem> <option key={k} value={k}>{KIND_LABEL[k]}</option>
))} ))}
</SelectContent> </SettingsSelect>
</Select>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => { setShowAdd(false); setNewContent('') }}> <Button variant="ghost" size="sm" onClick={() => { setShowAdd(false); setNewContent('') }}>
@@ -217,194 +238,181 @@ export function AgentMemoryPanel() {
</div> </div>
</div> </div>
</div> </div>
)} </SettingsReveal>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <SettingsRow label="Visa dolda">
<div className="flex flex-wrap gap-1"> <SettingsRowEnd>
{KIND_FILTER.map((f) => { <Switch
const active = kindFilter === f.value checked={includeDismissed}
return ( onCheckedChange={setIncludeDismissed}
<button aria-label="Visa dolda"
key={f.value} />
onClick={() => setKindFilter(f.value)} </SettingsRowEnd>
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${ </SettingsRow>
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>
{rows && ( {/* Dynamic status stays visible; the static "how it's used" copy lives
<div className="text-xs text-muted-foreground tabular-nums"> in the group help above. */}
{rows && (
<p className="px-1 pt-3">
<SettingsRowNote className="tabular-nums">
{counts.active} aktiva · {counts.pinned} fästa {counts.active} aktiva · {counts.pinned} fästa
{includeDismissed && counts.dismissed > 0 ? ` · ${counts.dismissed} dolda` : ''} {includeDismissed && counts.dismissed > 0 ? ` · ${counts.dismissed} dolda` : ''}
<span className="ml-1">(upp till 30 ingår i samtal per tur)</span> </SettingsRowNote>
</div> </p>
)} )}
{rows === null && ( {rows === null && (
<div className="space-y-3"> <div className="space-y-3 pt-3">
{[0, 1, 2].map((i) => ( {[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-20 w-full rounded-lg" /> <Skeleton key={i} className="h-16 w-full" />
))} ))}
</div> </div>
)} )}
{rows && rows.length === 0 && ( {rows && rows.length === 0 && (
<EmptyState <EmptyState
icon={Brain} icon={Brain}
title="Inga minnen ännu" 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." 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 && ( {rows && rows.length > 0 && (
<ul className="space-y-3"> <ul>
{rows.map((row) => { {rows.map((row) => {
const isEditing = editingId === row.id const isEditing = editingId === row.id
const isBusy = busyId === row.id const isBusy = busyId === row.id
const dimmed = !row.is_active const dimmed = !row.is_active
return ( return (
<li <li
key={row.id} key={row.id}
className={`rounded-lg border border-border p-4 transition-colors ${ className={cn(
dimmed ? 'bg-muted/30 opacity-70' : 'bg-card' 'border-b border-border px-1 py-3 transition-colors',
}`} dimmed && 'opacity-70',
> )}
<div className="flex items-start gap-3"> >
{canWrite && row.is_active ? ( <div className="flex items-start gap-3">
<button {canWrite && row.is_active ? (
onClick={() => patch(row.id, { is_pinned: !row.is_pinned })} <button
disabled={isBusy} onClick={() => patch(row.id, { is_pinned: !row.is_pinned })}
className={`mt-0.5 shrink-0 rounded-md p-1.5 transition-colors ${ disabled={isBusy}
row.is_pinned className={cn(
? 'text-foreground bg-secondary' 'mt-0.5 shrink-0 rounded-md p-1.5 transition-colors duration-150',
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground' row.is_pinned
}`} ? 'bg-secondary text-foreground'
aria-label={row.is_pinned ? 'Lossa' : 'Fäst'} : 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
title={row.is_pinned ? 'Lossa' : 'Fäst: säkerställer att minnet alltid skickas med'} )}
> aria-label={row.is_pinned ? 'Lossa' : 'Fäst'}
{row.is_pinned ? <Pin className="h-4 w-4 fill-current" /> : <PinOff className="h-4 w-4" />} title={row.is_pinned ? 'Lossa' : 'Fäst: säkerställer att minnet alltid skickas med'}
</button> >
{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"> <p className="whitespace-pre-wrap break-words text-sm text-foreground">{row.content}</p>
{row.is_pinned && <Pin className="h-4 w-4 fill-current text-foreground" />}
</div>
)} )}
<div className="flex-1 min-w-0 space-y-2"> <div className="flex flex-wrap items-center justify-between gap-2 pt-1">
<div className="flex flex-wrap items-center gap-2"> <p className="text-[11px] text-muted-foreground tabular-nums">
<span className="text-[11px] text-muted-foreground"> Skapad {formatDateLong(row.created_at)}
{KIND_LABEL[row.kind]} · {SOURCE_LABEL[row.source]} {row.updated_at !== row.created_at && ` · uppdaterad ${formatDateLong(row.updated_at)}`}
</span> </p>
{dimmed && <Badge variant="secondary">Dold</Badge>}
</div>
{isEditing ? ( {canWrite && (
<Textarea <div className="flex items-center gap-1">
value={editDraft} {isEditing ? (
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>
</>
) : (
<Button <Button
variant="ghost" variant="ghost"
size="sm" 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} disabled={isBusy}
> >
{isBusy ? ( {isBusy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> <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> </Button>
)} </>
</div> ) : (
)} <Button
</div> 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>
</div> </div>
</li> </div>
) </li>
})} )
</ul> })}
)} </ul>
</CardContent> )}
</Card> </SettingsGroup>
) )
} }
+98 -101
View File
@@ -4,11 +4,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { ChevronDown, GraduationCap, Loader2 } from 'lucide-react' import { ChevronDown, GraduationCap, Loader2 } from 'lucide-react'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { EmptyState } from '@/components/ui/empty-state' import { EmptyState } from '@/components/ui/empty-state'
import { HelpPopover } from '@/components/ui/help-popover'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { SettingsGroup, SettingsRowNote } from '@/components/settings/SettingsRows'
import { cn } from '@/lib/utils'
type Tier = 'horizontal' | 'vertical' | 'modifier' 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' '[&_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 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 { function apiErrorText(error: unknown): string | undefined {
if (typeof error === 'string') return error if (typeof error === 'string') return error
if (error && typeof error === 'object' && 'message' in error) { if (error && typeof error === 'object' && 'message' in error) {
@@ -118,112 +120,107 @@ export function AgentSkillsPanel() {
} }
return ( return (
<Card> <div>
<CardHeader> {/* Dynamic status stays visible; the static "what this is" copy sits
<CardTitle className="text-base">Vad min assistent kan</CardTitle> behind the "?" next to it. */}
<CardDescription> {atoms && (
Utöver vad den minns om ditt företag bygger assistenten på en uppsättning kunskapsområden <div className="flex items-center gap-2 px-1">
om svensk bokföring och skatt. Kärnkompetensen gäller alla; bransch- och bolagsanpassningen <SettingsRowNote className="tabular-nums">
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">
{counts.total} kunskapsområden · {counts.active} aktiva för ditt företag {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 && ( {atoms === null && (
<div className="space-y-3"> <div className="space-y-3">
{[0, 1, 2, 3].map((i) => ( {[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 w-full rounded-lg" /> <Skeleton key={i} className="h-12 w-full" />
))} ))}
</div> </div>
)} )}
{atoms && atoms.length === 0 && ( {atoms && atoms.length === 0 && (
<EmptyState <EmptyState
icon={GraduationCap} icon={GraduationCap}
title="Inga kunskapsområden ännu" title="Inga kunskapsområden ännu"
description="När din assistent har komponerats dyker dess kunskapsområden upp här." description="När din assistent har komponerats dyker dess kunskapsområden upp här."
/> />
)} )}
{atoms && atoms.length > 0 && {atoms && atoms.length > 0 &&
TIER_ORDER.filter((tier) => grouped[tier].length > 0).map((tier) => ( TIER_ORDER.filter((tier) => grouped[tier].length > 0).map((tier) => (
<section key={tier} className="space-y-3"> <SettingsGroup key={tier} label={TIER_SECTION[tier].title} help={TIER_SECTION[tier].blurb}>
<div className="space-y-1"> {grouped[tier].map((atom) => {
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> const isOpen = expandedId === atom.id
{TIER_SECTION[tier].title} const isLoading = loadingBody === atom.id
</h2> const body = bodies[atom.id]
<p className="text-xs text-muted-foreground">{TIER_SECTION[tier].blurb}</p> const dormant = !atom.active
</div> return (
<div key={atom.id} className="border-b border-border">
<ul className="space-y-2"> <button
{grouped[tier].map((atom) => { onClick={() => toggle(atom)}
const isOpen = expandedId === atom.id aria-expanded={isOpen}
const isLoading = loadingBody === atom.id className="flex w-full items-start gap-3 px-1 py-3 text-left"
const body = bodies[atom.id] >
const dormant = !atom.active <ChevronDown
return ( className={cn(
<li 'mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform',
key={atom.id} !isOpen && '-rotate-90',
className={`rounded-lg border border-border transition-colors ${ )}
dormant ? 'bg-muted/30' : 'bg-card' />
}`} <span className="min-w-0 flex-1">
> <span
<button className={cn(
onClick={() => toggle(atom)} 'block text-sm font-medium',
aria-expanded={isOpen} dormant ? 'text-muted-foreground' : 'text-foreground',
className="flex w-full items-start gap-3 p-4 text-left" )}
> >
<ChevronDown {atom.title}
className={`mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform ${ </span>
isOpen ? '' : '-rotate-90' <span className="block text-xs text-muted-foreground">{atom.description}</span>
}`} </span>
/> {/* Chips mark exceptions: active is the normal state and
<div className="flex-1 min-w-0 space-y-1"> renders as muted text, only dormant gets a Badge. */}
<div className="flex flex-wrap items-center gap-2"> {tier !== 'horizontal' && (
<span className="text-sm font-medium text-foreground">{atom.title}</span> atom.active ? (
{tier !== 'horizontal' && ( <span className="mt-0.5 shrink-0 text-xs text-muted-foreground">Aktiv</span>
<Badge variant={atom.active ? 'success' : 'secondary'}> ) : (
{atom.active ? 'Aktiv' : 'Vilande'} <Badge variant="outline" className="mt-0.5 shrink-0">Vilande</Badge>
</Badge> )
)} )}
</div> </button>
<p className="text-xs text-muted-foreground">{atom.description}</p>
</div>
</button>
{isOpen && ( {isOpen && (
<div className="border-t border-border px-4 py-4 pl-11"> <div className="px-1 pb-4 pl-8">
{isLoading && ( {isLoading && (
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
Läser in… 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>
)}
</div> </div>
)} )}
</li> {!isLoading && body !== undefined && body.length > 0 && (
) <div className={PROSE}>
})} <ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
</ul> </div>
</section> )}
))} {!isLoading && body !== undefined && body.length === 0 && (
</CardContent> <p className="text-xs text-muted-foreground">
</Card> Innehållet kunde inte läsas in.
</p>
)}
</div>
)}
</div>
)
})}
</SettingsGroup>
))}
</div>
) )
} }
+144 -151
View File
@@ -2,7 +2,6 @@
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
@@ -18,9 +17,16 @@ import {
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { EmptyState } from '@/components/ui/empty-state' import { EmptyState } from '@/components/ui/empty-state'
import { HelpPopover } from '@/components/ui/help-popover'
import { useToast } from '@/components/ui/use-toast' 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 { 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 { getBranding } from '@/lib/branding/service'
import { STAGING_SCOPES } from '@/lib/auth/api-keys' import { STAGING_SCOPES } from '@/lib/auth/api-keys'
import type { ApiKeyScope } from '@/lib/auth/api-keys' import type { ApiKeyScope } from '@/lib/auth/api-keys'
@@ -366,15 +372,6 @@ export function ApiKeysPanel() {
setTimeout(() => setCopied(false), 2000) 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' const mcpBase = typeof window !== 'undefined'
? `${window.location.origin}/api/extensions/ext/mcp-server/mcp` ? `${window.location.origin}/api/extensions/ext/mcp-server/mcp`
: '/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}` const mcpUrl = (client: string) => `${mcpBase}?client=${client}`
return ( return (
<div className="space-y-6"> <>
<Card> <SettingsGroup>
<CardHeader> {/* Group eyebrow with the group's primary action on the right. Styling
<div className="flex items-center justify-between"> mirrors SettingsGroup's label line; the "?" holds the old panel
<div> description. */}
<CardTitle className="text-base">{t('title')}</CardTitle> <div className="flex items-center justify-between gap-4 px-1">
<CardDescription> <p className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('description')} <span>{t('title')}</span>
</CardDescription> <HelpPopover className="shrink-0">{t('description')}</HelpPopover>
</div> </p>
<Button <Button
size="sm" size="sm"
onClick={() => setShowCreateDialog(true)} onClick={() => setShowCreateDialog(true)}
disabled={keys.length >= 10} disabled={keys.length >= 10}
> >
<Plus className="mr-1.5 h-3.5 w-3.5" /> <Plus className="mr-1.5 h-3.5 w-3.5" />
{t('create_key')} {t('create_key')}
</Button> </Button>
</div> </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>
<Card> {isLoading ? (
<CardHeader> <div className="flex items-center justify-center py-8">
<CardTitle className="text-base">{t('connect_mcp_title')}</CardTitle> <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</CardHeader> </div>
<CardContent className="space-y-6"> ) : keys.length === 0 ? (
<div> <EmptyState
<div className="flex items-center gap-2 mb-2"> icon={Key}
<p className="text-sm font-medium">Claude.ai</p> title={t('empty_title')}
<span className="text-xs text-muted-foreground">{t('recommended_badge')}</span> description={t('empty_help')}
</div> />
<p className="text-xs text-muted-foreground mb-2"> ) : (
{t.rich('claude_ai_instructions', { keys.map((key) => {
connectorName, const scopeCount = key.scopes?.length ?? 0
path: (chunks) => <strong>{chunks}</strong>, const permissionSummary =
})} scopeCount === ALL_SCOPES.length
</p> ? 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')} /> <CopyBlock text={mcpUrl('claude-connector')} copyAriaLabel={t('copy_aria')} />
</div> </div>
</SettingsRow>
<div> <SettingsRow
<p className="text-sm font-medium mb-2">{t('claude_code_cursor')}</p> label={t('claude_code_cursor')}
<p className="text-xs text-muted-foreground mb-2"> align="baseline"
{t('terminal_runs_browser_login')} help={t('terminal_runs_browser_login')}
</p> >
{/* URL is quoted: unquoted `?` in the query string trips zsh globbing. */} {/* 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')} /> <CopyBlock text={`claude mcp add ${connectorName} --transport http "${mcpUrl('claude-code')}"`} copyAriaLabel={t('copy_aria')} />
</div> </div>
</SettingsRow>
<div className="border-t pt-4"> <button
<button type="button"
type="button" aria-expanded={showApiKeyMethods}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors" onClick={() => setShowApiKeyMethods(!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={`h-3.5 w-3.5 transition-transform ${showApiKeyMethods ? '' : '-rotate-90'}`} /> <ChevronDown
{t('connect_with_api_key')} className={cn(
</button> 'h-3.5 w-3.5 transition-transform duration-150',
{showApiKeyMethods && ( !showApiKeyMethods && '-rotate-90',
<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> {t('connect_with_api_key')}
<p className="text-xs text-muted-foreground mb-2"> </button>
{t.rich('claude_desktop_instructions', { <SettingsReveal open={showApiKeyMethods}>
code: (chunks) => <code className="text-xs">{chunks}</code>, <div className="space-y-6 pb-3 pt-1">
})} <div>
</p> <p className="mb-1 text-sm">Claude Desktop</p>
<CopyBlock text={`{ <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": { "mcpServers": {
"${connectorName}": { "${connectorName}": {
"command": "npx", "command": "npx",
@@ -528,22 +522,20 @@ export function ApiKeysPanel() {
} }
} }
}`} copyAriaLabel={t('copy_aria')} /> }`} copyAriaLabel={t('copy_aria')} />
</div> </div>
<div> <div>
<p className="text-sm font-medium mb-1">{t('claude_code_cursor')}</p> <p className="mb-1 text-sm">{t('claude_code_cursor')}</p>
<p className="text-xs text-muted-foreground mb-2"> <p className="mb-2 text-xs text-muted-foreground">
{t('terminal_with_api_key')} {t('terminal_with_api_key')}
</p> </p>
<CopyBlock text={`claude mcp add ${connectorName} --transport http \\ <CopyBlock text={`claude mcp add ${connectorName} --transport http \\
--url "${mcpUrl('claude-code')}" \\ --url "${mcpUrl('claude-code')}" \\
--header "Authorization: Bearer gnubok_sk_..."`} copyAriaLabel={t('copy_aria')} /> --header "Authorization: Bearer gnubok_sk_..."`} copyAriaLabel={t('copy_aria')} />
</div> </div>
</div>
)}
</div> </div>
</CardContent> </SettingsReveal>
</Card> </SettingsGroup>
{/* Create key dialog */} {/* Create key dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}> <Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
@@ -697,6 +689,7 @@ export function ApiKeysPanel() {
size="sm" size="sm"
className="absolute right-2 top-2" className="absolute right-2 top-2"
onClick={handleCopy} onClick={handleCopy}
aria-label={t('copy_aria')}
> >
{copied ? ( {copied ? (
<Check className="h-4 w-4 text-success" /> <Check className="h-4 w-4 text-success" />
@@ -716,6 +709,6 @@ export function ApiKeysPanel() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </>
) )
} }
+53 -63
View File
@@ -6,10 +6,14 @@ import { createClient } from '@/lib/supabase/client'
import { BankIdAuth } from '@/components/auth/BankIdAuth' import { BankIdAuth } from '@/components/auth/BankIdAuth'
import type { BankIdResult } from '@/components/auth/BankIdAuth' import type { BankIdResult } from '@/components/auth/BankIdAuth'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Loader2 } from 'lucide-react'
import { Shield, ShieldCheck, Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { formatDateLong } from '@/lib/utils' import { formatDateLong } from '@/lib/utils'
import {
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
interface BankIdIdentity { interface BankIdIdentity {
given_name: string | null given_name: string | null
@@ -78,73 +82,59 @@ export function BankIdSettings() {
if (isLoading) { if (isLoading) {
return ( return (
<Card> <SettingsRow label={t('title')}>
<CardContent className="flex items-center justify-center py-8"> <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /> </SettingsRow>
</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>
) )
} }
return ( return (
<Card> <>
<CardHeader> <SettingsRow
<CardTitle className="flex items-center gap-2 text-base"> label={t('title')}
{identity ? ( help={identity ? t('linked_description') : t('not_linked_description')}
<ShieldCheck className="h-4 w-4 text-success" /> borderless={isLinking}
) : ( >
<Shield className="h-4 w-4 text-muted-foreground" />
)}
{t('title')}
</CardTitle>
<CardDescription>
{identity ? t('linked_description') : t('not_linked_description')}
</CardDescription>
</CardHeader>
<CardContent>
{identity ? ( {identity ? (
<div className="flex items-center justify-between"> <>
<div className="text-sm text-muted-foreground"> <span className="text-sm font-medium">
<span className="font-medium text-foreground"> {identity.given_name} {identity.surname}
{identity.given_name} {identity.surname} </span>
</span> <SettingsRowNote>
<span className="ml-2"> {t('linked_on', { date: formatDateLong(identity.linked_at) })}
{t('linked_on', { date: formatDateLong(identity.linked_at) })} </SettingsRowNote>
</span> <SettingsRowEnd>
</div> <Button
<Button variant="ghost"
variant="ghost" size="sm"
size="sm" onClick={handleUnlink}
onClick={handleUnlink} disabled={isUnlinking}
disabled={isUnlinking} className="text-destructive hover:text-destructive"
className="text-destructive hover:text-destructive" >
> {isUnlinking ? t('unlinking') : t('unlink_button')}
{isUnlinking ? t('unlinking') : t('unlink_button')} </Button>
</Button> </SettingsRowEnd>
</div> </>
) : 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 <SettingsRowEnd>
variant="outline" <Button variant="ghost" size="sm" onClick={() => setIsLinking(true)}>
onClick={() => setIsLinking(true)} {t('link_button')}
> </Button>
{t('link_button')} </SettingsRowEnd>
</Button>
)} )}
</CardContent> </SettingsRow>
</Card>
{/* QR flow: an expanding block below the row. Mounted only while
linking so the BankID session starts exactly when requested. */}
{isLinking && (
<div className="flex flex-col items-center border-b border-border px-1 py-4">
<BankIdAuth mode="link" onComplete={handleLinkComplete} />
</div>
)}
</>
) )
} }
+38 -39
View File
@@ -1,9 +1,10 @@
'use client' 'use client'
import { useState, type ReactNode } from 'react' import { useState } from 'react'
import { ChevronRight } from 'lucide-react' import { ChevronRight } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { SettingsSeg } from '@/components/settings/SettingsRows'
import { formatDateLong } from '@/lib/utils' import { formatDateLong } from '@/lib/utils'
import type { BillingPlan } from '@/lib/stripe/client' import type { BillingPlan } from '@/lib/stripe/client'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' 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 * Interactive billing CTA. Paying companies get the Stripe Customer Portal
* (manage/cancel); everyone else gets a reactive plan picker + Checkout. Both * (manage/cancel) as a quiet row action; everyone else gets a plan picker
* POST to a route that returns a hosted Stripe URL we redirect to. * (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 * `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 * trial's end (see billing/checkout), the date it lands. Shifts the CTA from
@@ -72,7 +74,7 @@ export function BillingActions({
if (isPaying) { if (isPaying) {
return ( 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 Hantera abonnemang
</Button> </Button>
) )
@@ -80,58 +82,55 @@ export function BillingActions({
if (!configured) { if (!configured) {
return ( return (
<Button size="lg" disabled className="w-full"> <Button size="lg" disabled className="w-full sm:w-auto">
Uppgradering öppnar snart Uppgradering öppnar snart
</Button> </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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex items-baseline gap-2"> <SettingsSeg
<span className="font-display text-3xl tracking-tight tabular-nums">{PRICE[plan].amount}</span> value={plan}
<span className="text-muted-foreground">{PRICE[plan].suffix}</span> onChange={setPlan}
</div> aria-label="Betalningsintervall"
<p className="text-sm text-muted-foreground mt-1">{PRICE[plan].sub}</p> 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> </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"> <Button
{segment('monthly', 'Månadsvis')} size="lg"
{segment( onClick={() => go('/api/billing/checkout', { plan })}
'yearly', disabled={loading}
<> className="w-full sm:w-auto"
Å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">
{loading ? 'Öppnar…' : firstChargeAt ? 'Starta abonnemanget: 0 kr idag' : PRICE[plan].cta} {loading ? 'Öppnar…' : firstChargeAt ? 'Starta abonnemanget: 0 kr idag' : PRICE[plan].cta}
{!loading && <ChevronRight className="h-4 w-4" />} {!loading && <ChevronRight className="h-4 w-4" />}
</Button> </Button>
{firstChargeAt && ( {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 Första debiteringen sker {formatDateLong(firstChargeAt)}, när provperioden slutar. Avslutar du innan dess
kostar det ingenting. kostar det ingenting.
</p> </p>
)} )}
<p className="text-xs text-muted-foreground text-center">
Ingen bindningstid · Avsluta när du vill · Säker betalning via Stripe
</p>
</div> </div>
) )
} }
+175 -164
View File
@@ -2,9 +2,9 @@
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { HelpPopover } from '@/components/ui/help-popover'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { import {
Dialog, Dialog,
@@ -13,10 +13,12 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@/components/ui/dialog' } 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 { TEMPLATE_CATEGORY_LABELS, convertLibraryToBookingTemplate } from '@/lib/bookkeeping/template-library'
import { useCanWrite } from '@/lib/hooks/use-can-write' import { useCanWrite } from '@/lib/hooks/use-can-write'
import { TemplateForm } from '@/components/settings/TemplateForm' import { TemplateForm } from '@/components/settings/TemplateForm'
import { cn } from '@/lib/utils'
import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types' import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types'
export function BookingTemplatesPanel() { export function BookingTemplatesPanel() {
@@ -125,124 +127,131 @@ export function BookingTemplatesPanel() {
return ( return (
<> <>
<Card> <SettingsGroup>
<CardHeader> {/* Group eyebrow with the panel's actions on the right: export/import as
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> quiet buttons, "Ny mall" as the one pill. The old card description
<div> lives behind the "?". */}
<CardTitle className="text-base">{t('title')}</CardTitle> <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-1">
<CardDescription> <p className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('description')} <span>{t('title')}</span>
</CardDescription> <HelpPopover className="shrink-0">{t('description')}</HelpPopover>
</div> </p>
{canWrite && ( {canWrite && (
<div className="flex gap-2 shrink-0"> <div className="flex shrink-0 items-center gap-1">
<Button variant="outline" size="sm" onClick={handleExport}> <Button
<Download className="h-3.5 w-3.5 mr-1.5" /> variant="ghost"
{t('export')} size="sm"
</Button> onClick={handleExport}
<Button variant="outline" size="sm" onClick={() => importRef.current?.click()}> className="text-muted-foreground hover:text-foreground"
<Upload className="h-3.5 w-3.5 mr-1.5" /> >
{t('import')} <Download className="mr-1.5 h-3.5 w-3.5" />
</Button> {t('export')}
<input </Button>
ref={importRef} <Button
type="file" variant="ghost"
accept=".json" size="sm"
className="hidden" onClick={() => importRef.current?.click()}
onChange={handleImport} className="text-muted-foreground hover:text-foreground"
/> >
<Dialog open={showCreate} onOpenChange={setShowCreate}> <Upload className="mr-1.5 h-3.5 w-3.5" />
<DialogTrigger asChild> {t('import')}
<Button size="sm"> </Button>
<Plus className="h-3.5 w-3.5 mr-1.5" /> <input
{t('new_template')} ref={importRef}
</Button> type="file"
</DialogTrigger> accept=".json"
<DialogContent className="max-w-lg"> className="hidden"
<DialogHeader> onChange={handleImport}
<DialogTitle>{t('create_dialog_title')}</DialogTitle> />
</DialogHeader> <Dialog open={showCreate} onOpenChange={setShowCreate}>
<TemplateForm <DialogTrigger asChild>
mode="create" <Button size="sm">
entityLabels={ENTITY_LABELS} <Plus className="mr-1.5 h-3.5 w-3.5" />
duplicateNamePool={companyTemplateNames} {t('new_template')}
onSaved={() => { </Button>
setShowCreate(false) </DialogTrigger>
fetchTemplates() <DialogContent className="max-w-lg">
}} <DialogHeader>
/> <DialogTitle>{t('create_dialog_title')}</DialogTitle>
</DialogContent> </DialogHeader>
</Dialog> <TemplateForm
</div> mode="create"
)} entityLabels={ENTITY_LABELS}
</div> duplicateNamePool={companyTemplateNames}
</CardHeader> onSaved={() => {
<CardContent> setShowCreate(false)
{isLoading ? ( fetchTemplates()
<div className="flex items-center justify-center py-12"> }}
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /> />
</div> </DialogContent>
) : templates.length === 0 ? ( </Dialog>
<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}
/>
)}
</div> </div>
)} )}
</CardContent> </div>
</Card>
{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 {/* Shared edit / customize dialog. Editing a company or team template uses
PUT; customizing a read-only system template creates a company-scoped PUT; customizing a read-only system template creates a company-scoped
@@ -276,7 +285,6 @@ export function BookingTemplatesPanel() {
function TemplateSection({ function TemplateSection({
title, title,
icon: Icon,
templates, templates,
expandedId, expandedId,
onToggle, onToggle,
@@ -290,7 +298,6 @@ function TemplateSection({
entityLabels, entityLabels,
}: { }: {
title: string title: string
icon: React.ComponentType<{ className?: string }>
templates: BookingTemplateLibrary[] templates: BookingTemplateLibrary[]
expandedId: string | null expandedId: string | null
onToggle: (id: string | null) => void onToggle: (id: string | null) => void
@@ -304,52 +311,54 @@ function TemplateSection({
entityLabels: Record<string, string> entityLabels: Record<string, string>
}) { }) {
const t = useTranslations('settings_booking_templates') const t = useTranslations('settings_booking_templates')
const tCommon = useTranslations('common')
return ( return (
<div> <SettingsGroup>
<div className="flex items-center gap-2 mb-2"> {/* Origin eyebrow with count; mirrors SettingsGroup's label line. */}
<Icon className="h-4 w-4 text-muted-foreground" /> <p className="flex items-center gap-2 px-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<h3 className="text-sm font-medium">{title}</h3> <span>{title}</span>
<span className="text-xs text-muted-foreground tabular-nums">{templates.length}</span> <span className="tabular-nums">{templates.length}</span>
</div> </p>
<div className="space-y-1"> <div>
{templates.map((tt) => { {templates.map((tt) => {
const isExpanded = expandedId === tt.id const isExpanded = expandedId === tt.id
const isConvertible = convertLibraryToBookingTemplate(tt) !== null const isConvertible = convertLibraryToBookingTemplate(tt) !== null
return ( return (
<div <div key={tt.id} className="border-b border-border">
key={tt.id} <div className="flex items-center gap-3 px-1 py-3 transition-colors duration-150 hover:bg-secondary/60">
className="rounded-lg border"
>
<div className="flex items-center gap-3 p-3 hover:bg-muted/50 transition-colors">
<button <button
type="button" type="button"
onClick={() => onToggle(isExpanded ? null : tt.id)} 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'}`} /> <ChevronDown
<div className="flex-1 min-w-0"> className={cn(
<span className="text-sm font-medium">{tt.name}</span> 'h-4 w-4 shrink-0 text-muted-foreground transition-transform',
<div className="flex items-center gap-1.5 mt-0.5 flex-wrap"> !isExpanded && '-rotate-90',
<span className="text-xs text-muted-foreground"> )}
{TEMPLATE_CATEGORY_LABELS[tt.category]} />
{tt.entity_type !== 'all' && ` · ${entityLabels[tt.entity_type]}`} <span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-3 gap-y-1">
</span> <span className="truncate text-sm">{tt.name}</span>
{!isConvertible && ( <span className="text-xs text-muted-foreground">
<Badge variant="warning" className="text-[10px] px-1.5 py-0"> {TEMPLATE_CATEGORY_LABELS[tt.category]}
{t('unconvertible_badge')} {tt.entity_type !== 'all' && ` · ${entityLabels[tt.entity_type]}`}
</Badge> </span>
)} {!isConvertible && (
</div> <Badge variant="warning" className="px-1.5 py-0 text-[10px]">
</div> {t('unconvertible_badge')}
</Badge>
)}
</span>
</button> </button>
{canCustomize && onCustomize && ( {canCustomize && onCustomize && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="icon"
onClick={() => onCustomize(tt)} onClick={() => onCustomize(tt)}
aria-label={t('customize')} aria-label={t('customize')}
title={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" /> <Copy className="h-3.5 w-3.5" />
</Button> </Button>
@@ -357,11 +366,11 @@ function TemplateSection({
{canEdit && onEdit && ( {canEdit && onEdit && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="icon"
onClick={() => onEdit(tt)} onClick={() => onEdit(tt)}
aria-label={t('edit')} aria-label={t('edit')}
title={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" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
@@ -369,10 +378,12 @@ function TemplateSection({
{canDelete && ( {canDelete && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="icon"
onClick={() => onDelete(tt.id)} onClick={() => onDelete(tt.id)}
disabled={deletingId === 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 ? ( {deletingId === tt.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
@@ -383,23 +394,23 @@ function TemplateSection({
)} )}
</div> </div>
{isExpanded && ( {isExpanded && (
<div className="px-3 pb-3 pt-0"> <div className="px-1 pb-3">
{tt.description && ( {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"> <table className="w-full text-xs">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground"> <thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b"> <tr className="border-b border-border">
<th className="text-left py-1 w-14">{t('th_account')}</th> <th className="w-14 py-1 text-left">{t('th_account')}</th>
<th className="text-left py-1">{t('th_description')}</th> <th className="py-1 text-left">{t('th_description')}</th>
<th className="text-center py-1 w-16">{t('th_type')}</th> <th className="w-16 py-1 text-center">{t('th_type')}</th>
<th className="text-right py-1 w-12">{t('th_debit')}</th> <th className="w-12 py-1 text-right">{t('th_debit')}</th>
<th className="text-right py-1 w-12">{t('th_credit')}</th> <th className="w-12 py-1 text-right">{t('th_credit')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{tt.lines.map((line: BookingTemplateLibraryLine, i: number) => ( {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 font-mono">{line.account}</td>
<td className="py-1">{line.label}</td> <td className="py-1">{line.label}</td>
<td className="py-1 text-center"> <td className="py-1 text-center">
@@ -419,6 +430,6 @@ function TemplateSection({
) )
})} })}
</div> </div>
</div> </SettingsGroup>
) )
} }
+97 -138
View File
@@ -2,14 +2,18 @@
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useState, useEffect } from 'react' 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 { Switch } from '@/components/ui/switch'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast' 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 { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import {
SettingsGroup,
SettingsInput,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
import type { CalendarFeed } from '@/types' import type { CalendarFeed } from '@/types'
interface CalendarFeedWithUrls extends CalendarFeed { interface CalendarFeedWithUrls extends CalendarFeed {
@@ -30,7 +34,6 @@ export function CalendarFeedSettings() {
useEffect(() => { useEffect(() => {
fetchFeed() fetchFeed()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
const fetchFeed = async () => { const fetchFeed = async () => {
@@ -162,7 +165,7 @@ export function CalendarFeedSettings() {
if (isLoading) { if (isLoading) {
return ( 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" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div> </div>
) )
@@ -170,177 +173,133 @@ export function CalendarFeedSettings() {
if (!feed) { if (!feed) {
return ( return (
<Card> <SettingsGroup label={t('title')} help={t('description')}>
<CardHeader> <SettingsRow label={t('activate_sync')} help={t('empty_intro')}>
<CardTitle className="flex items-center gap-2"> <SettingsRowEnd>
<Calendar className="h-5 w-5" /> <Button variant="ghost" size="sm" onClick={createFeed} disabled={isSaving}>
{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}>
{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')} {t('creating')}
</> </>
) : ( ) : (
<> <>
<Calendar className="mr-2 h-4 w-4" /> <Calendar className="mr-2 h-3.5 w-3.5" />
{t('activate_sync')} {t('activate_sync')}
</> </>
)} )}
</Button> </Button>
</div> </SettingsRowEnd>
</CardContent> </SettingsRow>
</Card> </SettingsGroup>
) )
} }
return ( return (
<div className="space-y-6"> <>
{/* Feed URL */} {/* Feed URL */}
<Card> <SettingsGroup label={t('title')} help={t('subscribe_description')}>
<CardHeader> <SettingsRow
<CardTitle className="flex items-center gap-2"> label={t('calendar_link_label')}
<Calendar className="h-5 w-5" /> htmlFor="calendar-feed-url"
{t('title')} help={t('calendar_link_help')}
</CardTitle> align="baseline"
<CardDescription> >
{t('subscribe_description')} <SettingsInput
</CardDescription> id="calendar-feed-url"
</CardHeader> value={feed.httpsUrl}
<CardContent className="space-y-4"> readOnly
{/* Quick add for Apple Calendar */} className="font-mono text-xs"
<div className="flex gap-2"> />
<Button onClick={openWebcal} className="flex-1"> <Button
<Calendar className="mr-2 h-4 w-4" /> 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')} {t('add_to_apple_calendar')}
</Button> </Button>
<Button variant="outline" onClick={() => copyToClipboard(feed.httpsUrl)}> </SettingsRowEnd>
{copied ? ( </SettingsRow>
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
{/* URL display */} <SettingsRow label={t('create_new_link')} help={t('regen_help')}>
<div className="space-y-2"> {/* Live feed stats stay visible: they are state, not instructions */}
<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 */}
{feed.last_accessed_at && ( {feed.last_accessed_at && (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <SettingsRowNote className="tabular-nums">
<span>{t('last_fetched')}</span> {t('last_fetched')}{' '}
<span> {new Date(feed.last_accessed_at).toLocaleDateString('sv-SE', {
{new Date(feed.last_accessed_at).toLocaleDateString('sv-SE', { day: 'numeric',
day: 'numeric', month: 'short',
month: 'short', hour: '2-digit',
hour: '2-digit', minute: '2-digit',
minute: '2-digit', })}
})} {' · '}
</span> {t('times_count', { count: feed.access_count })}
<span className="text-muted-foreground/30">·</span> </SettingsRowNote>
<span className="tabular-nums">
{t('times_count', { count: feed.access_count })}
</span>
</div>
)} )}
<SettingsRowEnd>
{/* Regenerate link */}
<div className="pt-2 border-t">
<Button <Button
variant="outline" variant="ghost"
size="sm" size="sm"
onClick={regenerateToken} onClick={regenerateToken}
disabled={isRegenerating} disabled={isRegenerating}
> >
{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')} {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')} {t('create_new_link')}
</> </>
)} )}
</Button> </Button>
<p className="text-xs text-muted-foreground mt-1"> </SettingsRowEnd>
{t('regen_help')} </SettingsRow>
</p> </SettingsGroup>
</div>
</CardContent>
</Card>
{/* Content settings */} {/* Content settings */}
<Card> <SettingsGroup label={t('content_title')} help={t('content_description')}>
<CardHeader> <SettingsRow
<CardTitle>{t('content_title')}</CardTitle> label={t('tax_deadlines_label')}
<CardDescription> htmlFor="include-tax"
{t('content_description')} help={t('tax_deadlines_help')}
</CardDescription> >
</CardHeader> <Switch
<CardContent className="space-y-4"> id="include-tax"
<div className="flex items-center justify-between"> checked={feed.include_tax_deadlines}
<div className="space-y-0.5"> onCheckedChange={(checked) =>
<Label htmlFor="include-tax">{t('tax_deadlines_label')}</Label> updateFeed('include_tax_deadlines', checked)
<p className="text-sm text-muted-foreground"> }
{t('tax_deadlines_help')} disabled={isSaving}
</p> />
</div> </SettingsRow>
<Switch
id="include-tax"
checked={feed.include_tax_deadlines}
onCheckedChange={(checked) =>
updateFeed('include_tax_deadlines', checked)
}
disabled={isSaving}
/>
</div>
<div className="flex items-center justify-between"> <SettingsRow
<div className="space-y-0.5"> label={t('invoices_label')}
<Label htmlFor="include-invoices">{t('invoices_label')}</Label> htmlFor="include-invoices"
<p className="text-sm text-muted-foreground"> help={t('invoices_help')}
{t('invoices_help')} >
</p> <Switch
</div> id="include-invoices"
<Switch checked={feed.include_invoices}
id="include-invoices" onCheckedChange={(checked) =>
checked={feed.include_invoices} updateFeed('include_invoices', checked)
onCheckedChange={(checked) => }
updateFeed('include_invoices', checked) disabled={isSaving}
} />
disabled={isSaving} </SettingsRow>
/> </SettingsGroup>
</div>
</CardContent>
</Card>
<DestructiveConfirmDialog {...confirmDialogProps} /> <DestructiveConfirmDialog {...confirmDialogProps} />
</div> </>
) )
} }
+27 -17
View File
@@ -16,6 +16,12 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { RetentionNotice } from '@/components/ui/retention-notice' import { RetentionNotice } from '@/components/ui/retention-notice'
import {
SettingsDangerZone,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { getBranding } from '@/lib/branding/service' import { getBranding } from '@/lib/branding/service'
@@ -36,6 +42,7 @@ const branding = getBranding()
*/ */
export function CompanyDangerZone() { export function CompanyDangerZone() {
const t = useTranslations('settings_company') const t = useTranslations('settings_company')
const tRetention = useTranslations('retention_notice')
const router = useRouter() const router = useRouter()
const { toast } = useToast() const { toast } = useToast()
const { company, role } = useCompany() const { company, role } = useCompany()
@@ -82,23 +89,26 @@ export function CompanyDangerZone() {
return ( return (
<> <>
<section className="space-y-4 border-t border-border pt-8"> <SettingsDangerZone label={t('danger_heading')}>
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive"> <SettingsRow
{t('danger_heading')} label={t('danger_button')}
</h2> borderless
// The full BFL retention copy (incl. the backup link) lives behind
<RetentionNotice variant="company" /> // the "?": the visible row stays one quiet line.
help={<RetentionNotice variant="company" className="border-0 bg-transparent p-0" />}
<div className="flex justify-end"> >
<Button <SettingsRowNote>{tRetention('company_title')}</SettingsRowNote>
variant="destructive" <SettingsRowEnd>
className="w-full sm:w-auto" <button
onClick={() => setShowDialog(true)} type="button"
> onClick={() => setShowDialog(true)}
{t('danger_button')} className="text-sm font-medium text-destructive underline underline-offset-2 transition-colors duration-150 hover:text-destructive/80"
</Button> >
</div> {t('danger_button')}
</section> </button>
</SettingsRowEnd>
</SettingsRow>
</SettingsDangerZone>
<Dialog <Dialog
open={showDialog} open={showDialog}
+66 -83
View File
@@ -1,8 +1,11 @@
'use client' 'use client'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input' import {
import { Label } from '@/components/ui/label' SettingsGroup,
SettingsInput,
SettingsRow,
} from '@/components/settings/SettingsRows'
import type { CompanySettings } from '@/types' import type { CompanySettings } from '@/types'
interface CompanyInfoFormProps { interface CompanyInfoFormProps {
@@ -11,96 +14,76 @@ interface CompanyInfoFormProps {
export function CompanyInfoForm({ settings }: CompanyInfoFormProps) { export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
const t = useTranslations('settings_company') const t = useTranslations('settings_company')
const orgLocked = settings.onboarding_complete === true
return ( return (
<section className="space-y-4"> <SettingsGroup label={t('company_info_heading')}>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> <SettingsRow
{t('company_info_heading')} label={t('company_name_label')}
</h2> htmlFor="company_name"
help={t('company_name_help')}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> align="baseline"
<div className="space-y-2"> >
<Label htmlFor="company_name">{t('company_name_label')}</Label> <SettingsInput
<Input id="company_name"
id="company_name" name="company_name"
name="company_name" defaultValue={settings.company_name || ''}
defaultValue={settings.company_name || ''} />
/> </SettingsRow>
<p className="text-xs text-muted-foreground"> <SettingsRow
{t('company_name_help')} label={t('org_number_label')}
</p> htmlFor="org_number"
</div> help={orgLocked ? t('org_number_locked') : undefined}
<div className="space-y-2"> align="baseline"
<Label htmlFor="org_number">{t('org_number_label')}</Label> >
<Input <SettingsInput
id="org_number" id="org_number"
name="org_number" name="org_number"
defaultValue={settings.org_number || ''} defaultValue={settings.org_number || ''}
disabled={settings.onboarding_complete === true} disabled={orgLocked}
/> />
{settings.onboarding_complete && ( </SettingsRow>
<p className="text-xs text-muted-foreground">{t('org_number_locked')}</p> <SettingsRow label={t('address_label')} htmlFor="address_line1" align="baseline">
)} <SettingsInput
</div>
</div>
<div className="space-y-2">
<Label htmlFor="address_line1">{t('address_label')}</Label>
<Input
id="address_line1" id="address_line1"
name="address_line1" name="address_line1"
defaultValue={settings.address_line1 || ''} defaultValue={settings.address_line1 || ''}
/> />
</div> </SettingsRow>
<SettingsRow label={t('postal_code_label')} htmlFor="postal_code" align="baseline">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <SettingsInput
<div className="space-y-2"> id="postal_code"
<Label htmlFor="postal_code">{t('postal_code_label')}</Label> name="postal_code"
<Input defaultValue={settings.postal_code || ''}
id="postal_code" className="max-w-24 flex-none"
name="postal_code" />
defaultValue={settings.postal_code || ''} </SettingsRow>
/> <SettingsRow label={t('city_label')} htmlFor="city" align="baseline">
</div> <SettingsInput id="city" name="city" defaultValue={settings.city || ''} />
<div className="space-y-2"> </SettingsRow>
<Label htmlFor="city">{t('city_label')}</Label> <SettingsRow label={t('phone_label')} htmlFor="phone" align="baseline">
<Input <SettingsInput
id="city" id="phone"
name="city" name="phone"
defaultValue={settings.city || ''} type="tel"
/> defaultValue={settings.phone || ''}
</div> />
</div> </SettingsRow>
<SettingsRow label={t('email_label')} htmlFor="email" align="baseline">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <SettingsInput
<div className="space-y-2"> id="email"
<Label htmlFor="phone">{t('phone_label')}</Label> name="email"
<Input type="email"
id="phone" defaultValue={settings.email || ''}
name="phone" />
type="tel" </SettingsRow>
defaultValue={settings.phone || ''} <SettingsRow label={t('website_label')} htmlFor="website" align="baseline">
/> <SettingsInput
</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
id="website" id="website"
name="website" name="website"
defaultValue={settings.website || ''} defaultValue={settings.website || ''}
placeholder="https://" placeholder="https://"
/> />
</div> </SettingsRow>
</section> </SettingsGroup>
) )
} }
+126 -161
View File
@@ -2,15 +2,17 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' 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 { useToast } from '@/components/ui/use-toast'
import { useCompany } from '@/contexts/CompanyContext' import { useCompany } from '@/contexts/CompanyContext'
import {
SettingsGroup,
SettingsInput,
SettingsRowNote,
SettingsSelect,
} from '@/components/settings/SettingsRows'
import { formatDateLong } from '@/lib/utils' 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 { interface CompanyMemberItem {
id: string id: string
@@ -159,164 +161,127 @@ export function CompanyMembersSection() {
} }
return ( return (
<div className="space-y-6"> <SettingsGroup label={t('members_title')} help={t('members_invite_description')}>
{/* Invite form */} {/* Member rows: flat hairline list, no cards. */}
{canInvite && ( {members.map((member) => (
<Card> <div
<CardHeader> key={member.id}
<CardTitle className="text-base">{t('members_invite_title', { companyName: company?.name ?? '' })}</CardTitle> className="flex items-center gap-3 border-b border-border px-1 py-3"
<CardDescription> >
{t('members_invite_description')} <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted/60">
</CardDescription> <span className="text-xs font-medium text-muted-foreground">
</CardHeader> {member.email.charAt(0).toUpperCase()}
<CardContent> </span>
<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>
))}
</div> </div>
</CardContent> <p className="min-w-0 flex-1 truncate text-sm">
</Card> {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 */} {/* Pending invitations continue the same list, visually quieter. */}
{invitations.length > 0 && ( {invitations.map((inv) => (
<Card> <div
<CardHeader> key={inv.id}
<CardTitle className="text-base">{t('invitations_pending_title')}</CardTitle> className="flex items-center gap-3 border-b border-border px-1 py-3"
</CardHeader> >
<CardContent> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-dashed border-border">
<div className="divide-y divide-border/40"> <Mail className="h-3.5 w-3.5 text-muted-foreground" />
{invitations.map((inv) => ( </div>
<div key={inv.id} className="flex items-center justify-between py-3 first:pt-0 last:pb-0"> <p className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
<div className="flex items-center gap-3 min-w-0"> {inv.email}
<div className="h-8 w-8 rounded-full bg-muted/60 flex items-center justify-center flex-shrink-0"> <span className="ml-1 text-xs">
<Mail className="h-3.5 w-3.5 text-muted-foreground" /> · {t('invitations_expires', { date: formatDateLong(inv.expires_at) })}
</div> </span>
<div className="min-w-0"> </p>
<p className="text-sm font-medium truncate">{inv.email}</p> <span className="shrink-0 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> {roleLabels[inv.role] || inv.role}
<Clock className="h-3 w-3" /> </span>
<span> {canInvite && (
{t('invitations_expires', { date: formatDateLong(inv.expires_at) })} <Button
</span> variant="ghost"
</div> size="icon"
</div> className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
</div> aria-label={t('members_invite_revoked')}
<div className="flex items-center gap-2"> onClick={() => handleRevokeInvite(inv.id)}
<span className="text-xs text-muted-foreground"> disabled={revokingId === inv.id}
{roleLabels[inv.role] || inv.role} >
</span> {revokingId === inv.id ? (
{canInvite && ( <Loader2 className="h-3.5 w-3.5 animate-spin" />
<Button ) : (
variant="ghost" <Trash2 className="h-3.5 w-3.5" />
size="icon" )}
className="h-8 w-8 text-muted-foreground hover:text-destructive" </Button>
onClick={() => handleRevokeInvite(inv.id)} )}
disabled={revokingId === inv.id} </div>
> ))}
{revokingId === inv.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {/* Inline invite: the list's own last row instead of a separate card. */}
) : ( {canInvite && (
<Trash2 className="h-3.5 w-3.5" /> <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">
</Button> {t('members_invite_email_label')}
)} </label>
</div> <SettingsInput
</div> id="company-invite-email"
))} type="email"
</div> placeholder={t('members_invite_email_placeholder')}
</CardContent> value={inviteEmail}
</Card> onChange={(e) => setInviteEmail(e.target.value)}
disabled={isSending}
required
className="border-border sm:flex-1"
/>
<SettingsSelect
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value)}
aria-label={t('members_invite_email_label')}
>
<option value="viewer">{t('members_role_viewer')}</option>
<option value="member">{t('members_role_member')}</option>
<option value="admin">{t('members_role_admin')}</option>
</SettingsSelect>
<Button type="submit" size="sm" disabled={isSending || !inviteEmail.trim()}>
{isSending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Plus className="mr-2 h-4 w-4" />
{t('members_invite_button')}
</>
)}
</Button>
</form>
)} )}
</div>
<p className="px-1 pt-3">
<SettingsRowNote>
{t('members_count', { count: members.length, companyName: company?.name ?? '' })}
</SettingsRowNote>
</p>
</SettingsGroup>
) )
} }
+48 -49
View File
@@ -7,10 +7,14 @@ import { useCompany } from '@/contexts/CompanyContext'
import { CompanyProfileView } from '@/components/settings/CompanyProfileView' import { CompanyProfileView } from '@/components/settings/CompanyProfileView'
import { refreshCompanyProfileAction } from '@/lib/company/tic-refresh' import { refreshCompanyProfileAction } from '@/lib/company/tic-refresh'
import { Button } from '@/components/ui/button' 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 { 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'] 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 // Företagsprofil: the cached TIC company snapshot (Bolagsuppgifter), rendered
// as a read-only section on the Företag tab. Fetched client-side (low-traffic // 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 // 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 // the read to the user's own company. The trailing "Hämta" row lets the user
// live when the snapshot is missing or wrong: the recovery path for an enskild // (re)fetch live when the snapshot is missing or wrong: the recovery path for
// firma whose personnummer previously resolved to the wrong entity. // an enskild firma whose personnummer previously resolved to the wrong entity.
export function CompanyProfileSection() { export function CompanyProfileSection() {
const { company } = useCompany() const { company } = useCompany()
const [snapshot, setSnapshot] = useState<Snapshot>(null) const [snapshot, setSnapshot] = useState<Snapshot>(null)
@@ -76,49 +80,44 @@ export function CompanyProfileSection() {
if (loading) return <Skeleton className="h-48 w-full rounded-lg" /> if (loading) return <Skeleton className="h-48 w-full rounded-lg" />
return ( return (
<div className="space-y-4"> <SettingsGroup label="Bolagsuppgifter">
<CompanyProfileView snapshot={snapshot} fetchedAt={fetchedAt} /> <CompanyProfileView snapshot={snapshot} />
<Card> <SettingsRow
<CardHeader> label={snapshot ? 'Uppdatera bolagsuppgifter' : 'Hämta bolagsuppgifter'}
<CardTitle className="text-base"> help="Uppgifterna hämtas från Bolagsverket. För enskild firma anges personnumret."
{snapshot ? 'Uppdatera bolagsuppgifter' : 'Hämta bolagsuppgifter'} borderless
</CardTitle> >
</CardHeader> <form
<CardContent> onSubmit={handleFetch}
<form onSubmit={handleFetch} className="space-y-4"> className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1"
<div className="space-y-2"> >
<Label htmlFor="tic_org_number">Organisationsnummer eller personnummer</Label> <SettingsInput
<div className="flex gap-2"> id="tic_org_number"
<Input aria-label="Organisationsnummer eller personnummer"
id="tic_org_number" value={orgInput}
value={orgInput} onChange={(e) => setOrgInput(e.target.value)}
onChange={(e) => setOrgInput(e.target.value)} placeholder="XXXXXX-XXXX"
placeholder="XXXXXX-XXXX" inputMode="numeric"
inputMode="numeric" autoComplete="off"
autoComplete="off" className="max-w-xs tabular-nums"
className="max-w-xs tabular-nums" />
/> <Button type="submit" size="sm" disabled={submitting || !orgInput.trim()}>
<Button type="submit" disabled={submitting || !orgInput.trim()}> {submitting ? (
{submitting ? ( <>
<> <Loader2 className="animate-spin" />
<Loader2 className="animate-spin" /> Hämtar…
Hämtar… </>
</> ) : (
) : ( 'Hämta'
'Hämta' )}
)} </Button>
</Button> {fetchedAt && (
</div> <SettingsRowNote>Uppdaterad {formatDateLong(fetchedAt)}</SettingsRowNote>
<p className="text-xs text-muted-foreground"> )}
Uppgifterna hämtas från Bolagsverket. För enskild firma anges {error && <span className="basis-full text-xs text-destructive">{error}</span>}
personnumret. </form>
</p> </SettingsRow>
{error && <p className="text-sm text-destructive">{error}</p>} </SettingsGroup>
</div>
</form>
</CardContent>
</Card>
</div>
) )
} }
+147 -177
View File
@@ -1,9 +1,10 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' 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 // 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 // JSON rather than importing the TIC extension's types, so the
// core-build CI boundary (no core → @/extensions/) stays intact. // core-build CI boundary (no core → @/extensions/) stays intact.
// //
@@ -65,37 +66,14 @@ function cleanSignatory(raw: string): string[] {
.filter((s) => s.length > 0) .filter((s) => s.length > 0)
} }
function Section({ title, children }: { title: string; children: React.ReactNode }) { export function CompanyProfileView({ snapshot }: { snapshot: SnapshotShape | null }) {
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
}) {
if (!snapshot) { if (!snapshot) {
// Dynamic status (nothing fetched yet): stays visible as a quiet line.
return ( return (
<Card> <p className="border-b border-border px-1 py-3 text-sm text-muted-foreground">
<CardHeader> Inga företagsuppgifter hämtade ännu. Uppgifterna hämtas automatiskt
<CardTitle className="text-base">Bolagsuppgifter</CardTitle> från Bolagsverket via organisationsnumret.
</CardHeader> </p>
<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>
) )
} }
@@ -118,164 +96,156 @@ export function CompanyProfileView({
? `${snapshot.fiscalYear.startMonthDay} till ${snapshot.fiscalYear.endMonthDay}` ? `${snapshot.fiscalYear.startMonthDay} till ${snapshot.fiscalYear.endMonthDay}`
: null : 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 ( return (
<Card> <>
<CardHeader> <SettingsRow label="Företag">
<CardTitle className="text-base">Bolagsuppgifter</CardTitle> <span className="text-foreground">{snapshot.companyName ?? 'Okänt företag'}</span>
{fetchedAt && ( {(snapshot.orgNumber || entityLabel) && (
<p className="text-xs text-muted-foreground"> <SettingsRowNote className="tabular-nums">
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">
{[snapshot.orgNumber, entityLabel].filter(Boolean).join(' · ')} {[snapshot.orgNumber, entityLabel].filter(Boolean).join(' · ')}
</p> </SettingsRowNote>
{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>
)} )}
</SettingsRow>
{Array.isArray(snapshot.sniCodes) && snapshot.sniCodes.length > 0 && ( {snapshot.address && (
<Section title="SNI-koder"> <SettingsRow label="Adress">
<ul className="space-y-1"> <span className="text-muted-foreground">
{snapshot.sniCodes.map((s) => ( {[
<li key={s.code} className="text-sm tabular-nums"> snapshot.address.street,
<span className="text-foreground">{s.code}</span>{' '} [snapshot.address.postalCode, snapshot.address.city].filter(Boolean).join(' '),
<span className="text-muted-foreground">{s.name}</span> ]
</li> .filter(Boolean)
))} .join(', ')}
</ul> </span>
</Section> </SettingsRow>
)} )}
{Array.isArray(snapshot.bankAccounts) && snapshot.bankAccounts.length > 0 && ( {regBadges.length > 0 && (
<Section title="Bankuppgifter"> <SettingsRow label="Registrerat för">
<ul className="space-y-1"> {regBadges.map((b) => (
{snapshot.bankAccounts.map((b, i) => ( <Badge key={b} variant="secondary" className="font-normal">{b}</Badge>
<li key={`${b.type}-${b.accountNumber}-${i}`} className="text-sm tabular-nums"> ))}
<span className="text-muted-foreground">{b.type}:</span>{' '} </SettingsRow>
<span className="text-foreground">{b.accountNumber}</span> )}
</li>
))}
</ul>
</Section>
)}
{snapshot.purpose && ( {Array.isArray(snapshot.sniCodes) && snapshot.sniCodes.length > 0 && (
<Section title="Verksamhet"> <SettingsRow label="SNI-koder" align="baseline">
<p className="text-sm leading-6 text-muted-foreground">{snapshot.purpose}</p> <ul className="w-full space-y-1">
</Section> {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"> {Array.isArray(snapshot.bankAccounts) && snapshot.bankAccounts.length > 0 && (
<p className="text-sm text-muted-foreground"> <SettingsRow label="Bankuppgifter" align="baseline">
{snapshot.employeeRange ?? 'Inga anställda'} <ul className="w-full space-y-1">
</p> {snapshot.bankAccounts.map((b, i) => (
</Section> <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.purpose && (
{snapshot.financials ? ( <SettingsRow label="Verksamhet" align="baseline">
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm"> <p className="text-sm leading-6 text-muted-foreground">{snapshot.purpose}</p>
<dt className="text-muted-foreground">Nettoomsättning</dt> </SettingsRow>
<dd className="text-right tabular-nums"> )}
<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 != null
? `${snapshot.financials.netSalesK.toLocaleString('sv-SE')} tkr` ? `${snapshot.financials.netSalesK.toLocaleString('sv-SE')} tkr`
: '-'} : '-'}
</dd> </span>
<dt className="text-muted-foreground">Rörelseresultat</dt> </span>
<dd className="text-right tabular-nums"> <span>
<span className="text-muted-foreground">Rörelseresultat </span>
<span className="tabular-nums">
{snapshot.financials.operatingProfitK != null {snapshot.financials.operatingProfitK != null
? `${snapshot.financials.operatingProfitK.toLocaleString('sv-SE')} tkr` ? `${snapshot.financials.operatingProfitK.toLocaleString('sv-SE')} tkr`
: '-'} : '-'}
</dd> </span>
</dl> </span>
) : ( </>
<p className="text-sm text-muted-foreground">Inga finansiella uppgifter tillgängliga.</p> ) : (
)} <span className="text-muted-foreground">Inga finansiella uppgifter tillgängliga.</span>
</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>
)} )}
</SettingsRow>
{(() => { {datedStatuses.length > 0 && (
// Flatten every signatory row, clean ">" markers, split run-on <SettingsRow label="Status" align="baseline">
// clauses, and dedupe: the source repeats "Firman tecknas av <dl className="w-full space-y-1">
// styrelsen" across rows. {datedStatuses.map((s, i) => (
const rules = Array.from( <div key={`${s.code}-${i}`} className="flex items-center justify-between gap-3 text-sm">
new Set( <dt className={s.isCeased ? 'text-destructive' : 'text-foreground'}>
(snapshot.signatory ?? []).flatMap((s) => cleanSignatory(s.description)), {s.description ?? s.code ?? '-'}
), </dt>
) <dd className="text-xs text-muted-foreground tabular-nums">
if (rules.length === 0) return null {formatDate(s.statusDate!)}
return ( </dd>
<Section title="Firmateckning"> </div>
<ul className="space-y-1.5"> ))}
{rules.map((rule, i) => ( </dl>
<li key={i} className="text-sm leading-6 text-muted-foreground"> </SettingsRow>
{rule} )}
</li>
))}
</ul>
</Section>
)
})()}
{Array.isArray(snapshot.representatives) && snapshot.representatives.length > 0 && ( {fyLabel && (
<Section title="Företrädare"> <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 && ( {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 != null
? `${snapshot.board.numberOfBoardMembers} styrelseledamot/-ledamöter` ? `${snapshot.board.numberOfBoardMembers} styrelseledamot/-ledamöter`
@@ -292,7 +262,7 @@ export function CompanyProfileView({
{snapshot.representatives.map((r, i) => ( {snapshot.representatives.map((r, i) => (
<li key={`${r.name}-${i}`} className="flex items-center justify-between gap-3 text-sm"> <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-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] {[r.positionType, r.positionStart ? formatDate(r.positionStart) : null]
.filter(Boolean) .filter(Boolean)
.join(' · ')} .join(' · ')}
@@ -300,9 +270,9 @@ export function CompanyProfileView({
</li> </li>
))} ))}
</ul> </ul>
</Section> </div>
)} </SettingsRow>
</CardContent> )}
</Card> </>
) )
} }
+138 -154
View File
@@ -2,10 +2,10 @@
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { EmptyState } from '@/components/ui/empty-state' import { EmptyState } from '@/components/ui/empty-state'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { SettingsGroup } from '@/components/settings/SettingsRows'
import { Loader2, Trash2, Users, ChevronDown } from 'lucide-react' import { Loader2, Trash2, Users, ChevronDown } from 'lucide-react'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names' import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import { formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates' import { formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
@@ -92,166 +92,150 @@ export function CounterpartyTemplatesPanel() {
} }
return ( return (
<div className="space-y-6"> <SettingsGroup label={t('title')} help={t('description')}>
<Card> {isLoading ? (
<CardHeader> <div className="flex items-center justify-center py-8">
<CardTitle className="text-base">{t('title')}</CardTitle> <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<CardDescription> </div>
{t('description')} ) : templates.length === 0 ? (
</CardDescription> <EmptyState
</CardHeader> icon={Users}
<CardContent> title={t('empty_title')}
{isLoading ? ( description={t('empty_help')}
<div className="flex items-center justify-center py-8"> />
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /> ) : (
</div> templates.map((tt) => {
) : templates.length === 0 ? ( const isExpanded = expandedId === tt.id
<EmptyState const isMultiLine = tt.line_pattern && tt.line_pattern.length > 0
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
return ( return (
<div key={tt.id} className="rounded-md border overflow-hidden"> <div key={tt.id} className="border-b border-border">
{/* Clickable summary row */} {/* Clickable summary row: flat hairline, one line. */}
<button <button
type="button" type="button"
onClick={() => setExpandedId(isExpanded ? null : tt.id)} 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" 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="min-w-0 flex-1"> >
<div className="flex items-center gap-2"> <div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-3 gap-y-1">
<p className="text-sm font-medium truncate">{formatCounterpartyName(tt.counterparty_name)}</p> <span className="truncate text-sm">{formatCounterpartyName(tt.counterparty_name)}</span>
</div> <span className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5 mt-0.5 text-xs text-muted-foreground"> {isMultiLine ? (
{isMultiLine ? ( <span className="font-mono">
<span className="font-mono"> {tt.line_pattern!.filter(lp => lp.type === 'business').map(lp => lp.account).join(', ')}
{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>
) : ( <span className="font-mono">{formatAccountWithName(lp.account)}</span>
<> {lp.type === 'vat' && lp.vat_rate && (
<span className="font-mono">{tt.debit_account}</span> <span className="text-muted-foreground">{t('vat_paren', { rate: Math.round(lp.vat_rate * 100) })}</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" />
)} )}
{t('delete_button')} {lp.ratio !== undefined && (
</Button> <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> </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> </div>
)} )
</CardContent> })
</Card> )}
</div> </SettingsGroup>
) )
} }
+30 -29
View File
@@ -5,10 +5,14 @@ import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl' import { useLocale, useTranslations } from 'next-intl'
import { ExternalLink } from 'lucide-react' import { ExternalLink } from 'lucide-react'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import {
SettingsRow,
SettingsRowEnd,
} from '@/components/settings/SettingsRows'
import { useSettings } from '@/components/settings/useSettings' import { useSettings } from '@/components/settings/useSettings'
import { useCanWrite } from '@/lib/hooks/use-can-write' import { useCanWrite } from '@/lib/hooks/use-can-write'
import { cn } from '@/lib/utils'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
/** /**
@@ -88,36 +92,33 @@ export function DimensionsToggle() {
} }
} }
const locked = isSaving || !canWrite
return ( return (
<section className="space-y-4"> <SettingsRow label={t('settings_heading')} help={t('settings_toggle_help')}>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> <Switch
{t('settings_heading')} id="dimensions-enabled"
</h2> checked={enabled}
<div className="flex items-start justify-between gap-4"> onCheckedChange={(next) => void handleChange(next)}
<div className="space-y-1"> disabled={locked}
<Label htmlFor="dimensions-enabled" className="text-sm"> />
{t('settings_toggle_label')} <label
</Label> htmlFor="dimensions-enabled"
<p className="text-xs text-muted-foreground max-w-md"> className={cn('text-sm', locked ? 'text-muted-foreground' : 'cursor-pointer')}
{t('settings_toggle_help')} >
</p> {t('settings_toggle_label')}
</div> </label>
<Switch
id="dimensions-enabled"
checked={enabled}
onCheckedChange={(next) => void handleChange(next)}
disabled={isSaving || !canWrite}
/>
</div>
{enabled && ( {enabled && (
<Link <SettingsRowEnd>
href="/dimensions" <Link
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors" 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')} <ExternalLink className="h-3.5 w-3.5" />
</Link> {t('settings_open_register')}
</Link>
</SettingsRowEnd>
)} )}
</section> </SettingsRow>
) )
} }
+90 -81
View File
@@ -4,19 +4,20 @@ import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useCompany } from '@/contexts/CompanyContext' import { useCompany } from '@/contexts/CompanyContext'
import { Button } from '@/components/ui/button' 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 { useToast } from '@/components/ui/use-toast'
import { import {
DestructiveConfirmDialog, DestructiveConfirmDialog,
useDestructiveConfirm, useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog' } 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 { parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
import { validateFirstPeriod } from '@/components/bookkeeping/FiscalPeriodDateFields'
import { import {
FiscalPeriodDateFields, SettingsGroup,
validateFirstPeriod, SettingsInput,
} from '@/components/bookkeeping/FiscalPeriodDateFields' SettingsRow,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
import type { FiscalPeriod } from '@/types' import type { FiscalPeriod } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -175,76 +176,83 @@ export function FiscalPeriodEditor() {
return ( return (
<> <>
<section className="space-y-4 border-t border-border pt-8"> <SettingsGroup label={t('fp_heading')} help={t('fp_intro')}>
<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>
{isLoading ? ( {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" /> <Loader2 className="h-4 w-4 animate-spin" />
{t('fp_loading')} {t('fp_loading')}
</div> </div>
) : loadError ? ( ) : loadError ? (
<p className="text-sm text-destructive">{loadError}</p> <p className="px-1 py-3 text-sm text-destructive">{loadError}</p>
) : !period ? ( ) : !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 ? ( ) : isBlocked ? (
<BlockedState <BlockedRow period={period} postedCount={postedCount ?? 0} />
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"> {/* Consequential warning: stays visible as one quiet ochre sentence. */}
<Info className="h-4 w-4 text-warning flex-shrink-0 mt-0.5" /> <p className="px-1 py-3 text-[12.5px] text-attn">
<div className="space-y-1"> {t('fp_warning_title')} {t('fp_warning_body')}
<p className="font-medium">{t('fp_warning_title')}</p> {isEF ? t('fp_warning_ef_suffix') : null}
<p className="text-muted-foreground"> </p>
{t('fp_warning_body')}
{isEF && t('fp_warning_ef_suffix')}
</p>
</div>
</div>
<FiscalPeriodDateFields <SettingsRow
startDate={startDate} label="Startdatum"
onStartDateChange={setStartDate} htmlFor="fiscal-period-start"
endDate={endDate} help="Första räkenskapsåret kan börja valfri dag."
entityType={company?.entity_type} align="baseline"
summaryTitle={t('fp_summary_title')} >
endDateSlot={ <SettingsInput
<div className="space-y-2"> id="fiscal-period-start"
<Label htmlFor="fp_end">{t('fp_end_date_label')}</Label> type="date"
<Input value={startDate}
id="fp_end" onChange={(e) => setStartDate(e.target.value)}
type="date" className="max-w-44 flex-none tabular-nums"
value={endDate} />
onChange={(e) => setEndDate(e.target.value)} </SettingsRow>
/>
<p className="text-xs text-muted-foreground">
{t('fp_end_date_help')}
</p>
</div>
}
/>
<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 <Button
type="button" type="button"
variant="outline" variant="ghost"
size="sm"
onClick={handleReset} onClick={handleReset}
disabled={!isDirty || isSaving} disabled={!isDirty || isSaving}
className="text-muted-foreground hover:text-foreground"
> >
{t('fp_reset')} {t('fp_reset')}
</Button> </Button>
<Button <Button
type="button" type="button"
size="sm"
onClick={handleSave} onClick={handleSave}
disabled={ disabled={
!isDirty || !isDirty ||
@@ -264,15 +272,15 @@ export function FiscalPeriodEditor() {
)} )}
</Button> </Button>
</div> </div>
</div> </>
)} )}
</section> </SettingsGroup>
<DestructiveConfirmDialog {...dialogProps} /> <DestructiveConfirmDialog {...dialogProps} />
</> </>
) )
} }
function BlockedState({ function BlockedRow({
period, period,
postedCount, postedCount,
}: { }: {
@@ -287,26 +295,27 @@ function BlockedState({
: t('fp_blocked_reason_posted', { count: postedCount }) : t('fp_blocked_reason_posted', { count: postedCount })
return ( return (
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-3"> <SettingsRow
<div className="flex gap-2"> // The key carries a trailing colon from its old inline usage; strip it
<Lock className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" /> // for the micro-label position.
<div className="space-y-1"> label={t('fp_blocked_first_year').replace(/:$/, '')}
<p className="text-sm font-medium">{t('fp_blocked_title')}</p> help={
<p className="text-sm text-muted-foreground">{reason}</p> <>
</div> <p>
</div> {t('fp_blocked_title')}. {reason}
<div className="text-sm text-muted-foreground space-y-1"> </p>
<p> <p className="mt-2">{t('fp_blocked_explainer')}</p>
{t('fp_blocked_first_year')}{' '} </>
<span className="font-medium text-foreground"> }
{formatSwedishDate(period.period_start)} &ndash; {formatSwedishDate(period.period_end)} borderless
</span> >
{isCalendarYear(period) ? t('fp_blocked_calendar_year') : t('fp_blocked_broken_year')} <Lock aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</p> <span className="tabular-nums">
<p> {formatSwedishDate(period.period_start)} till {formatSwedishDate(period.period_end)}
{t('fp_blocked_explainer')} </span>
</p> <SettingsRowNote>
</div> {(isCalendarYear(period) ? t('fp_blocked_calendar_year') : t('fp_blocked_broken_year')).trim()}
</div> </SettingsRowNote>
</SettingsRow>
) )
} }
+80 -76
View File
@@ -9,6 +9,7 @@ import {
DestructiveConfirmDialog, DestructiveConfirmDialog,
useDestructiveConfirm, useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog' } from '@/components/ui/destructive-confirm-dialog'
import { SettingsGroup } from '@/components/settings/SettingsRows'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { useCompany } from '@/contexts/CompanyContext' import { useCompany } from '@/contexts/CompanyContext'
import { Plus, Lock, Unlock, Loader2 } from 'lucide-react' import { Plus, Lock, Unlock, Loader2 } from 'lucide-react'
@@ -25,10 +26,11 @@ function periodStatus(p: FiscalPeriod): 'closed' | 'locked' | 'open' {
return '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', closed: 'secondary',
locked: 'warning', locked: 'warning',
open: 'success',
} }
export function FiscalYearsManager() { export function FiscalYearsManager() {
@@ -113,14 +115,83 @@ export function FiscalYearsManager() {
} }
return ( return (
<section className="space-y-4"> <SettingsGroup label={t('fy_heading')} help={t('fy_help')}>
<div className="flex items-center justify-between gap-4"> {isLoading ? (
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> <div className="space-y-2 px-1 py-3">
{t('fy_heading')} <Skeleton className="h-4 w-48" />
</h2> <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 <Button
variant="outline" variant="ghost"
size="sm" size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => setDialogOpen(true)} onClick={() => setDialogOpen(true)}
disabled={isLoading} disabled={isLoading}
> >
@@ -129,73 +200,6 @@ export function FiscalYearsManager() {
</Button> </Button>
</div> </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 <CreatePeriodDialog
open={dialogOpen} open={dialogOpen}
onOpenChange={setDialogOpen} onOpenChange={setDialogOpen}
@@ -205,6 +209,6 @@ export function FiscalYearsManager() {
/> />
<DestructiveConfirmDialog {...dialogProps} /> <DestructiveConfirmDialog {...dialogProps} />
</section> </SettingsGroup>
) )
} }
+22 -18
View File
@@ -4,6 +4,11 @@ import { useEffect, useState, useSyncExternalStore } from 'react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { MonitorDown } from 'lucide-react' import { MonitorDown } from 'lucide-react'
import {
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
type BeforeInstallPromptEvent = Event & { type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void> 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 * beforeinstallprompt when the PWA is installable; we capture it and offer a
* real install button. Other browsers get per-platform instructions. Hidden * real install button. Other browsers get per-platform instructions as the
* entirely when already running standalone (installed) or after installing. * row's visible note (that text IS the action). Hidden entirely when already
* running standalone (installed) or after installing.
*/ */
export function InstallAppSection() { export function InstallAppSection() {
const t = useTranslations('settings') 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. // and only appears client-side when actually running in a browser tab.
const isStandalone = useSyncExternalStore(subscribeDisplayMode, getIsStandalone, () => true) const isStandalone = useSyncExternalStore(subscribeDisplayMode, getIsStandalone, () => true)
const [installed, setInstalled] = useState(false) const [installed, setInstalled] = useState(false)
@@ -74,21 +80,19 @@ export function InstallAppSection() {
: t('install_app_hint_generic') : t('install_app_hint_generic')
return ( return (
<section className="space-y-4 border-t border-border pt-8"> <SettingsRow label={t('install_app_title')} help={t('install_app_description')}>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> {installPrompt ? (
{t('install_app_title')} <SettingsRowEnd>
</h2> <Button variant="ghost" size="sm" onClick={handleInstall}>
<div className="flex items-center justify-between gap-4 p-4 border rounded-lg"> <MonitorDown className="mr-2 h-3.5 w-3.5" />
<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" />
{t('install_app_button')} {t('install_app_button')}
</Button> </Button>
)} </SettingsRowEnd>
</div> ) : (
</section> // 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 { useEffect, useRef, useState } from 'react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button' 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 { useToast } from '@/components/ui/use-toast'
import {
SettingsGroup,
SettingsRow,
SettingsTextarea,
} from '@/components/settings/SettingsRows'
import { import {
EMAIL_PATTERN, EMAIL_PATTERN,
MAX_INVOICE_EMAIL_COPY_RECIPIENTS, MAX_INVOICE_EMAIL_COPY_RECIPIENTS,
@@ -107,44 +110,41 @@ export function InvoiceEmailRecipientsSettings({
} }
return ( return (
<section className="space-y-4"> <SettingsGroup label={t('heading')} help={t('description')}>
<div className="space-y-2"> <SettingsRow
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> label={t('cc_label')}
{t('heading')} htmlFor="invoice-email-cc"
</h2> help={t('cc_hint')}
<p className="text-sm text-muted-foreground">{t('description')}</p> align="baseline"
</div> >
<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="flex justify-end px-1 pt-4">
<div className="space-y-2"> <Button type="button" size="sm" onClick={save} disabled={isSaving}>
<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}>
{isSaving ? t('saving') : t('save')} {isSaving ? t('saving') : t('save')}
</Button> </Button>
</div> </div>
</section> </SettingsGroup>
) )
} }
@@ -2,12 +2,17 @@
import { useCallback, useRef, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { useTranslations } from 'next-intl' 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 { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write' import { useCanWrite } from '@/lib/hooks/use-can-write'
import {
SettingsGroup,
SettingsInput,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
SettingsSeg,
SettingsTextarea,
} from '@/components/settings/SettingsRows'
import { import {
INVOICE_EMAIL_DEFAULT_TEXTS, INVOICE_EMAIL_DEFAULT_TEXTS,
INVOICE_EMAIL_PLACEHOLDER_KEYS, INVOICE_EMAIL_PLACEHOLDER_KEYS,
@@ -86,6 +91,7 @@ export function InvoiceEmailTextsSettings({ settings, onUpdate }: InvoiceEmailTe
const t = useTranslations('settings_email_texts') const t = useTranslations('settings_email_texts')
const { toast } = useToast() const { toast } = useToast()
const { canWrite } = useCanWrite() const { canWrite } = useCanWrite()
const [lang, setLang] = useState<Lang>('sv')
const [texts, setTexts] = useState<DisplayTexts>(() => buildDisplay(settings.invoice_email_texts)) const [texts, setTexts] = useState<DisplayTexts>(() => buildDisplay(settings.invoice_email_texts))
// Serialized last-persisted overrides: skips no-op PUTs on blur without // Serialized last-persisted overrides: skips no-op PUTs on blur without
// edits. toOverrides() builds keys in a fixed order, so comparison is stable. // 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 // 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 persist = useCallback(async (display: DisplayTexts) => {
const overrides = toOverrides(display) const overrides = toOverrides(display)
const serialized = JSON.stringify(overrides) const serialized = JSON.stringify(overrides)
@@ -133,78 +140,74 @@ export function InvoiceEmailTextsSettings({ settings, onUpdate }: InvoiceEmailTe
} }
return ( return (
<section className="space-y-6"> <SettingsGroup
<div> label={t('heading')}
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> help={
{t('heading')} <div className="space-y-2">
</h2> <p>{t('description')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('description')}</p> {/* Legend is rendered from code, not messages/*.json: ICU message
</div> syntax treats literal braces as interpolation. */}
<p>
<Tabs defaultValue="sv"> {t('placeholders_help')}{' '}
<TabsList> {INVOICE_EMAIL_PLACEHOLDER_KEYS.map((key) => (
<TabsTrigger value="sv">{t('tab_sv')}</TabsTrigger> <code key={key} className="mr-1 rounded bg-muted px-1 text-xs">{`{${key}}`}</code>
<TabsTrigger value="en">{t('tab_en')}</TabsTrigger> ))}
</TabsList> </p>
{LANGS.map((lang) => ( <p>{t('firstname_note')}</p>
<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>
))}
</div> </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> </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 { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button' 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 { BankNameCombobox } from '@/components/settings/BankNameCombobox'
import { import {
Select, SettingsGroup,
SelectContent, SettingsInput,
SelectItem, SettingsRow,
SelectTrigger, SettingsRowEnd,
SelectValue, SettingsRowNote,
} from '@/components/ui/select' SettingsSeg,
SettingsSelect,
} from '@/components/settings/SettingsRows'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { useCompany } from '@/contexts/CompanyContext' import { useCompany } from '@/contexts/CompanyContext'
import { validateBankgiroNumber, validatePlusgiroNumber } from '@/lib/bankgiro/luhn' import { validateBankgiroNumber, validatePlusgiroNumber } from '@/lib/bankgiro/luhn'
@@ -282,18 +282,11 @@ export function InvoicePaymentAccountsSettings({
} }
return ( return (
<section className="space-y-5"> <SettingsGroup label={t('heading')} help={t('description')}>
<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>
{hasExternalUpdate && ( {hasExternalUpdate && (
<div <div
role="alert" 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"> <div className="space-y-1">
<p className="text-sm font-medium">{t('conflict_title')}</p> <p className="text-sm font-medium">{t('conflict_title')}</p>
@@ -305,146 +298,165 @@ export function InvoicePaymentAccountsSettings({
</div> </div>
)} )}
<div className="flex flex-wrap gap-2" aria-label={t('currency_tabs_label')}> <SettingsRow label={t('currency_tabs_label')}>
{configuredCurrencies.map((currency) => ( <SettingsSeg
<Button value={activeCurrency}
key={currency} onChange={(currency) => setActiveCurrency(currency)}
type="button" options={configuredCurrencies.map((currency) => ({ value: currency, label: currency }))}
size="sm" aria-label={t('currency_tabs_label')}
variant={activeCurrency === currency ? 'default' : 'outline'} />
onClick={() => setActiveCurrency(currency)} {activeCurrency !== 'SEK' && (
> <SettingsRowNote>
{currency} {t('foreign_account_hint', { currency: activeCurrency })}
</Button> </SettingsRowNote>
))} )}
</div> {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 && ( {availableCurrencies.length > 0 && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-end"> <SettingsRow label={t('add_currency_label')}>
<div className="w-full space-y-2 sm:max-w-52"> <SettingsSelect
<Label>{t('add_currency_label')}</Label> value={currencyToAdd}
<Select onChange={(event) => setCurrencyToAdd(event.target.value as Currency | '')}
value={currencyToAdd} aria-label={t('add_currency_label')}
onValueChange={(next) => setCurrencyToAdd(next as Currency)} >
> <option value="">{t('add_currency_placeholder')}</option>
<SelectTrigger> {availableCurrencies.map((currency) => (
<SelectValue placeholder={t('add_currency_placeholder')} /> <option key={currency} value={currency}>{currency}</option>
</SelectTrigger> ))}
<SelectContent> </SettingsSelect>
{availableCurrencies.map((currency) => ( <Button
<SelectItem key={currency} value={currency}>{currency}</SelectItem> type="button"
))} variant="outline"
</SelectContent> size="sm"
</Select> onClick={addCurrency}
</div> disabled={!currencyToAdd}
<Button type="button" variant="outline" onClick={addCurrency} disabled={!currencyToAdd}> >
{t('add_currency')} {t('add_currency')}
</Button> </Button>
</div> </SettingsRow>
)} )}
<div className="space-y-4 rounded-lg border border-border p-4"> <SettingsRow label={t('bank_label')}>
<div className="flex items-center justify-between gap-3"> {/* Typeahead combobox stays boxed on purpose: it is a picker, not a field. */}
<div> <div className="min-w-0 flex-1 sm:max-w-64">
<h3 className="font-medium">{t('account_heading', { currency: activeCurrency })}</h3> <BankNameCombobox
{activeCurrency !== 'SEK' && ( value={value(activeAccount, 'bank_name')}
<p className="text-xs text-muted-foreground">{t('foreign_account_hint')}</p> onChange={(next) => updateField('bank_name', next)}
)} enableBankingEnabled={hasBankingExtension}
</div> />
{activeCurrency !== 'SEK' && (
<Button type="button" variant="ghost" size="sm" onClick={removeActiveCurrency}>
{t('remove_currency')}
</Button>
)}
</div> </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="flex justify-end px-1 pt-4">
<div className="space-y-2"> <Button type="button" size="sm" onClick={save} disabled={isSaving || hasExternalUpdate}>
<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}>
{isSaving ? t('saving') : t('save')} {isSaving ? t('saving') : t('save')}
</Button> </Button>
</div> </div>
</section> </SettingsGroup>
) )
} }
@@ -2,9 +2,13 @@
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import {
SettingsGroup,
SettingsRow,
SettingsRowEnd,
} from '@/components/settings/SettingsRows'
import type { CompanySettings } from '@/types' import type { CompanySettings } from '@/types'
interface InvoicePaymentLinkSettingsProps { interface InvoicePaymentLinkSettingsProps {
@@ -40,23 +44,17 @@ export function InvoicePaymentLinkSettings({ settings, onUpdate }: InvoicePaymen
}, [onUpdate, toast, t]) }, [onUpdate, toast, t])
return ( return (
<section className="space-y-4"> <SettingsGroup label={t('heading')}>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> <SettingsRow label={t('enable_label')} help={t('enable_help')}>
{t('heading')} <SettingsRowEnd>
</h2> <Switch
checked={settings.invoice_payment_links_enabled ?? false}
<div className="flex items-center justify-between"> onCheckedChange={saveToggle}
<div> disabled={isSaving}
<Label>{t('enable_label')}</Label> aria-label={t('enable_label')}
<p className="text-xs text-muted-foreground">{t('enable_help')}</p> />
</div> </SettingsRowEnd>
<Switch </SettingsRow>
checked={settings.invoice_payment_links_enabled ?? false} </SettingsGroup>
onCheckedChange={saveToggle}
disabled={isSaving}
aria-label={t('enable_label')}
/>
</div>
</section>
) )
} }
+8 -4
View File
@@ -3,7 +3,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Eye } from 'lucide-react' import { Eye } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl' import { useLocale, useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -128,11 +127,16 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <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> <DialogTrigger asChild>
<Button variant="outline"> <button
<Eye className="h-4 w-4" /> 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')} {t('preview_button')}
</Button> </button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-3xl"> <DialogContent className="max-w-3xl">
<DialogHeader> <DialogHeader>
+113 -99
View File
@@ -1,9 +1,12 @@
'use client' 'use client'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input' import {
import { Label } from '@/components/ui/label' SettingsGroup,
import { Textarea } from '@/components/ui/textarea' SettingsInput,
SettingsRow,
SettingsTextarea,
} from '@/components/settings/SettingsRows'
import type { CompanySettings } from '@/types' import type { CompanySettings } from '@/types'
interface InvoiceSettingsFormProps { interface InvoiceSettingsFormProps {
@@ -13,125 +16,136 @@ interface InvoiceSettingsFormProps {
export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) { export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
const t = useTranslations('settings_invoice_form') const t = useTranslations('settings_invoice_form')
return ( return (
<section className="space-y-4"> <>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> <SettingsGroup label={t('heading')}>
{t('heading')} <SettingsRow label={t('prefix_label')} htmlFor="invoice_prefix" align="baseline">
</h2> <SettingsInput
<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
id="invoice_prefix" id="invoice_prefix"
name="invoice_prefix" name="invoice_prefix"
placeholder={t('prefix_placeholder')} placeholder={t('prefix_placeholder')}
defaultValue={settings.invoice_prefix || ''} defaultValue={settings.invoice_prefix || ''}
className="max-w-32 flex-none"
/> />
</div> </SettingsRow>
<div className="space-y-2"> <SettingsRow
<Label htmlFor="next_invoice_number">{t('next_number_label')}</Label> label={t('next_number_label')}
<Input htmlFor="next_invoice_number"
align="baseline"
>
<SettingsInput
id="next_invoice_number" id="next_invoice_number"
name="next_invoice_number" name="next_invoice_number"
type="number" type="number"
min="1" min="1"
defaultValue={settings.next_invoice_number || 1} defaultValue={settings.next_invoice_number || 1}
className="max-w-32 flex-none tabular-nums"
/> />
</div> </SettingsRow>
<div className="space-y-2"> <SettingsRow
<Label htmlFor="invoice_default_days">{t('default_days_label')}</Label> label={t('default_days_label')}
<Input htmlFor="invoice_default_days"
align="baseline"
>
<SettingsInput
id="invoice_default_days" id="invoice_default_days"
name="invoice_default_days" name="invoice_default_days"
type="number" type="number"
min="0" min="0"
defaultValue={settings.invoice_default_days || 30} defaultValue={settings.invoice_default_days || 30}
className="max-w-24 flex-none tabular-nums"
/> />
</div> </SettingsRow>
</div> <SettingsRow
label={t('arrival_start_label')}
<div className="space-y-2"> htmlFor="next_arrival_number"
<Label htmlFor="next_arrival_number">{t('arrival_start_label')}</Label> help={t('arrival_start_help')}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> align="baseline"
<Input >
<SettingsInput
id="next_arrival_number" id="next_arrival_number"
name="next_arrival_number" name="next_arrival_number"
type="number" type="number"
min="1" min="1"
defaultValue={settings.next_arrival_number || 1} defaultValue={settings.next_arrival_number || 1}
className="max-w-32 flex-none tabular-nums"
/> />
</div> </SettingsRow>
<p className="text-xs text-muted-foreground"> <SettingsRow
{t('arrival_start_help')} label={t('default_notes_label')}
</p> htmlFor="invoice_default_notes"
</div> 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"> <SettingsGroup label={t('reminder_days_heading')} help={t('reminder_days_help')}>
<Label htmlFor="invoice_default_notes">{t('default_notes_label')}</Label> <SettingsRow
<Textarea label={t('reminder_days_level_1')}
id="invoice_default_notes" htmlFor="reminder_days_level_1"
name="invoice_default_notes" align="baseline"
rows={3} >
placeholder={t('default_notes_placeholder')} <SettingsInput
defaultValue={settings.invoice_default_notes || ''} id="reminder_days_level_1"
/> name="reminder_days_level_1"
<p className="text-xs text-muted-foreground"> type="number"
{t('default_notes_help')} min="1"
</p> max="365"
</div> defaultValue={settings.reminder_days_level_1 ?? 15}
className="max-w-24 flex-none tabular-nums"
<div className="space-y-2"> />
<Label htmlFor="default_our_reference">{t('default_our_reference_label')}</Label> </SettingsRow>
<Input <SettingsRow
id="default_our_reference" label={t('reminder_days_level_2')}
name="default_our_reference" htmlFor="reminder_days_level_2"
placeholder={t('default_our_reference_placeholder')} align="baseline"
defaultValue={settings.default_our_reference || ''} >
/> <SettingsInput
<p className="text-xs text-muted-foreground"> id="reminder_days_level_2"
{t('default_our_reference_help')} name="reminder_days_level_2"
</p> type="number"
</div> min="1"
max="365"
<fieldset className="space-y-4 border-t border-border pt-4"> defaultValue={settings.reminder_days_level_2 ?? 30}
<legend className="text-sm font-medium">{t('reminder_days_heading')}</legend> className="max-w-24 flex-none tabular-nums"
<p className="text-xs text-muted-foreground">{t('reminder_days_help')}</p> />
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> </SettingsRow>
<div className="space-y-2"> <SettingsRow
<Label htmlFor="reminder_days_level_1">{t('reminder_days_level_1')}</Label> label={t('reminder_days_level_3')}
<Input htmlFor="reminder_days_level_3"
id="reminder_days_level_1" align="baseline"
name="reminder_days_level_1" >
type="number" <SettingsInput
min="1" id="reminder_days_level_3"
max="365" name="reminder_days_level_3"
defaultValue={settings.reminder_days_level_1 ?? 15} type="number"
/> min="1"
</div> max="365"
<div className="space-y-2"> defaultValue={settings.reminder_days_level_3 ?? 45}
<Label htmlFor="reminder_days_level_2">{t('reminder_days_level_2')}</Label> className="max-w-24 flex-none tabular-nums"
<Input />
id="reminder_days_level_2" </SettingsRow>
name="reminder_days_level_2" </SettingsGroup>
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>
) )
} }
+42 -47
View File
@@ -3,9 +3,12 @@
import { useState, useRef } from 'react' import { useState, useRef } from 'react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { Loader2, Upload, Trash2 } from 'lucide-react' 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 { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { LOGO_UPLOAD_MAX_BYTES } from '@/lib/invoices/branding-constants' import { LOGO_UPLOAD_MAX_BYTES } from '@/lib/invoices/branding-constants'
@@ -109,32 +112,26 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
} }
return ( return (
<section className="space-y-4"> <SettingsGroup>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> <SettingsRow label={t('logo_heading')} help={t('logo_help')}>
{t('logo_heading')} {preview ? (
</h2> <>
<p className="text-xs text-muted-foreground -mt-2"> <span className="inline-flex rounded-lg border border-border bg-muted/30 p-2">
{t('logo_help')} {/* eslint-disable-next-line @next/next/no-img-element */}
</p> <img
src={preview}
{preview ? ( alt={t('logo_alt')}
<div className="space-y-3"> className="max-h-10 max-w-32 object-contain"
<div className="inline-block rounded-lg border border-border bg-muted/30 p-4"> />
{/* eslint-disable-next-line @next/next/no-img-element */} </span>
<img
src={preview}
alt={t('logo_alt')}
className="max-h-16 max-w-[200px] object-contain"
/>
</div>
<div className="flex gap-2">
<Button <Button
variant="outline" variant="ghost"
size="sm" size="sm"
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
disabled={isUploading} 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')} {t('logo_change')}
</Button> </Button>
<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" />} {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')} {t('logo_remove')}
</Button> </Button>
</div> </>
</div> ) : (
) : ( <button
<button type="button"
type="button" onClick={() => inputRef.current?.click()}
onClick={() => inputRef.current?.click()} onDrop={handleDrop}
onDrop={handleDrop} onDragOver={handleDragOver}
onDragOver={handleDragOver} onDragEnter={handleDragOver}
onDragEnter={handleDragOver} onDragLeave={handleDragLeave}
onDragLeave={handleDragLeave} disabled={isUploading}
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 ${
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:bg-muted/20'
isDragging ? 'border-foreground bg-muted/40' : 'border-border hover:border-border hover:bg-muted/20' }`}
}`} >
> {isUploading ? (
{isUploading ? ( <Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-6 w-6 text-muted-foreground animate-spin mb-2" /> ) : (
) : ( <Upload className="h-4 w-4 text-muted-foreground/60" />
<Upload className="h-6 w-6 text-muted-foreground/50 mb-2" /> )}
)}
<Label className="text-sm text-muted-foreground cursor-pointer">
{isUploading ? t('logo_uploading') : t('logo_pick_or_drop')} {isUploading ? t('logo_uploading') : t('logo_pick_or_drop')}
</Label> </button>
</button> )}
)} </SettingsRow>
<input <input
ref={inputRef} ref={inputRef}
@@ -180,6 +175,6 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
className="hidden" className="hidden"
onChange={handleFileChange} onChange={handleFileChange}
/> />
</section> </SettingsGroup>
) )
} }
+56 -68
View File
@@ -2,7 +2,6 @@
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
@@ -16,7 +15,10 @@ import {
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { EmptyState } from '@/components/ui/empty-state' import { EmptyState } from '@/components/ui/empty-state'
import { HelpPopover } from '@/components/ui/help-popover'
import { useToast } from '@/components/ui/use-toast' 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' import { Loader2, Plus, Trash2, Globe } from 'lucide-react'
interface OAuthClient { 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 ( return (
<div className="space-y-6"> <>
<Card> <SettingsGroup>
<CardHeader> {/* Group eyebrow with the group's primary action on the right. Styling
<div className="flex items-center justify-between"> mirrors SettingsGroup's label line; the "?" holds the old panel
<div> description. */}
<CardTitle className="text-base">{t('title')}</CardTitle> <div className="flex items-center justify-between gap-4 px-1">
<CardDescription> <p className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('description')} <span>{t('title')}</span>
</CardDescription> <HelpPopover className="shrink-0">{t('description')}</HelpPopover>
</div> </p>
<Button size="sm" onClick={() => setShowCreateDialog(true)}> <Button size="sm" onClick={() => setShowCreateDialog(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" /> <Plus className="mr-1.5 h-3.5 w-3.5" />
{t('register_uri')} {t('register_uri')}
</Button> </Button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div> </div>
</CardHeader> ) : clients.length === 0 ? (
<CardContent> <EmptyState
{isLoading ? ( icon={Globe}
<div className="flex items-center justify-center py-8"> title={t('empty_title')}
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /> 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> </div>
) : clients.length === 0 ? ( ))
<EmptyState )}
icon={Globe} </SettingsGroup>
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>
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}> <Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent> <DialogContent>
@@ -227,6 +215,6 @@ export function OAuthClientsPanel() {
</Dialog> </Dialog>
<DestructiveConfirmDialog {...revokeDialogProps} /> <DestructiveConfirmDialog {...revokeDialogProps} />
</div> </>
) )
} }
+149 -187
View File
@@ -3,12 +3,19 @@
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useState, useCallback, useRef, type ChangeEvent } from 'react' import { useState, useCallback, useRef, type ChangeEvent } from 'react'
import { Loader2, Trash2, Upload } from 'lucide-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 { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/components/ui/use-toast' 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 { INVOICE_FONT_FAMILIES } from '@/lib/invoices/branding-constants'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import type { CompanySettings, InvoiceFontFamily } from '@/types' import type { CompanySettings, InvoiceFontFamily } from '@/types'
@@ -18,6 +25,42 @@ interface PdfPrintSettingsProps {
onUpdate: (updates: Partial<CompanySettings>) => void 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) { export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps) {
const t = useTranslations('settings_pdf_print') const t = useTranslations('settings_pdf_print')
const { toast } = useToast() const { toast } = useToast()
@@ -148,75 +191,79 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
Custom: t('font_custom'), Custom: t('font_custom'),
} }
return ( const toggleRows: Array<{ field: PdfToggleField; label: string; help: string; defaultOn: boolean }> = [
<section className="space-y-6"> { field: 'ore_rounding', label: t('ore_rounding_label'), help: t('ore_rounding_help'), defaultOn: true },
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> { field: 'invoice_show_ocr', label: t('show_ocr_label'), help: t('show_ocr_help'), defaultOn: true },
{t('heading')} { field: 'invoice_show_bankgiro', label: t('show_bankgiro_label'), help: t('show_bankgiro_help'), defaultOn: true },
</h2> { 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"> return (
<div className="space-y-1"> <SettingsGroup label={t('heading')}>
<Label htmlFor="invoice_font_family">{t('font_label')}</Label> <SettingsRow
<p className="text-xs text-muted-foreground">{t('font_help')}</p> label={t('font_label')}
</div> htmlFor="invoice_font_family"
<Select 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'} value={settings.invoice_font_family ?? 'Helvetica'}
onValueChange={(value) => { onChange={(event) => {
if (value) void saveFont(value as InvoiceFontFamily) if (event.target.value) void saveFont(event.target.value as InvoiceFontFamily)
}} }}
disabled={isSavingFont || isUploadingFont || isDeletingFont} disabled={isSavingFont || isUploadingFont || isDeletingFont}
> >
<SelectTrigger id="invoice_font_family" className="max-w-sm"> {INVOICE_FONT_FAMILIES
<SelectValue /> .filter((family) => family !== 'Custom' || settings.invoice_custom_font_path)
</SelectTrigger> .map((family) => (
<SelectContent> <option key={family} value={family}>
{INVOICE_FONT_FAMILIES {fontLabels[family]}
.filter((family) => family !== 'Custom' || settings.invoice_custom_font_path) </option>
.map((family) => ( ))}
<SelectItem key={family} value={family}> </SettingsSelect>
{fontLabels[family]} {settings.invoice_custom_font_name && (
</SelectItem> <SettingsRowNote>
))} {t('font_uploaded_name', { name: settings.invoice_custom_font_name })}
</SelectContent> </SettingsRowNote>
</Select> )}
<SettingsRowEnd>
<div className="flex flex-wrap items-center gap-3"> <button
<Button
type="button" type="button"
variant="outline"
onClick={() => fontInputRef.current?.click()} onClick={() => fontInputRef.current?.click()}
disabled={isUploadingFont || isDeletingFont} 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 ? ( {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')} {settings.invoice_custom_font_path ? t('font_replace') : t('font_upload')}
</Button> </button>
{settings.invoice_custom_font_path && ( {settings.invoice_custom_font_path && (
<Button <button
type="button" type="button"
variant="ghost"
onClick={() => void deleteFont()} onClick={() => void deleteFont()}
disabled={isUploadingFont || isDeletingFont} 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 ? ( {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')} {t('font_remove')}
</Button> </button>
)} )}
</div> </SettingsRowEnd>
{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>
<input <input
ref={fontInputRef} ref={fontInputRef}
type="file" type="file"
@@ -224,146 +271,61 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
className="hidden" className="hidden"
onChange={handleFontChange} 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>
<div className="space-y-4"> {/* Placement only applies while the company name is shown. */}
<div className="flex items-center justify-between"> <SettingsReveal open={settings.invoice_show_company_name ?? true}>
<div> <SettingsRow label={t('placement_label')} borderless>
<Label>{t('ore_rounding_label')}</Label> <SettingsSeg
<p className="text-xs text-muted-foreground">{t('ore_rounding_help')}</p> value={settings.invoice_company_name_position ?? 'header'}
</div> onChange={(pos) => void savePosition(pos)}
<Switch options={[
checked={settings.ore_rounding ?? true} { value: 'header', label: t('placement_header') },
onCheckedChange={(v) => saveToggle('ore_rounding', v)} { value: 'footer', label: t('placement_footer') },
]}
aria-label={t('placement_aria_label')}
/> />
</div> </SettingsRow>
</SettingsReveal>
<div className="flex items-center justify-between"> <SettingsRow label={t('late_fee_label')} htmlFor="invoice_late_fee_text" align="baseline">
<div> <SettingsTextarea
<Label>{t('show_ocr_label')}</Label> id="invoice_late_fee_text"
<p className="text-xs text-muted-foreground">{t('show_ocr_help')}</p> rows={2}
</div> placeholder={t('late_fee_placeholder')}
<Switch value={lateFeeText}
checked={settings.invoice_show_ocr ?? true} onChange={(e) => setLateFeeText(e.target.value)}
onCheckedChange={(v) => saveToggle('invoice_show_ocr', v)} onBlur={() => saveText('invoice_late_fee_text', lateFeeText)}
/> />
</div> </SettingsRow>
<SettingsRow
<div className="flex items-center justify-between"> label={t('credit_terms_label')}
<div> htmlFor="invoice_credit_terms_text"
<Label>{t('show_bankgiro_label')}</Label> align="baseline"
<p className="text-xs text-muted-foreground">{t('show_bankgiro_help')}</p> >
</div> <SettingsTextarea
<Switch id="invoice_credit_terms_text"
checked={settings.invoice_show_bankgiro ?? true} rows={2}
onCheckedChange={(v) => saveToggle('invoice_show_bankgiro', v)} placeholder={t('credit_terms_placeholder')}
/> value={creditTermsText}
</div> onChange={(e) => setCreditTermsText(e.target.value)}
onBlur={() => saveText('invoice_credit_terms_text', creditTermsText)}
<div className="flex items-center justify-between"> />
<div> </SettingsRow>
<Label>{t('show_plusgiro_label')}</Label> </SettingsGroup>
<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>
) )
} }
+42 -44
View File
@@ -1,58 +1,56 @@
'use client' 'use client'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input' import {
import { Label } from '@/components/ui/label' SettingsGroup,
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' SettingsInput,
SettingsRow,
SettingsSelect,
} from '@/components/settings/SettingsRows'
import type { CompanySettings } from '@/types' import type { CompanySettings } from '@/types'
interface PeriodLockingSettingsProps { interface PeriodLockingSettingsProps {
settings: CompanySettings 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) { export function PeriodLockingSettings({ settings }: PeriodLockingSettingsProps) {
const t = useTranslations('settings_period_locking') const t = useTranslations('settings_period_locking')
return ( return (
<section className="space-y-4"> <SettingsGroup label={t('heading')}>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> <SettingsRow
{t('heading')} label={t('locked_through_label')}
</h2> htmlFor="bookkeeping_locked_through"
help={t('locked_through_help')}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6"> align="baseline"
<div className="space-y-2"> >
<Label htmlFor="bookkeeping_locked_through">{t('locked_through_label')}</Label> <SettingsInput
<Input id="bookkeeping_locked_through"
id="bookkeeping_locked_through" name="bookkeeping_locked_through"
name="bookkeeping_locked_through" type="date"
type="date" defaultValue={settings.bookkeeping_locked_through || ''}
defaultValue={settings.bookkeeping_locked_through || ''} className="max-w-44 flex-none tabular-nums"
/> />
<p className="text-xs text-muted-foreground"> </SettingsRow>
{t('locked_through_help')} <SettingsRow
</p> label={t('auto_lock_label')}
</div> htmlFor="auto_lock_period_days"
help={t('auto_lock_help')}
<div className="space-y-2"> >
<Label htmlFor="auto_lock_period_days">{t('auto_lock_label')}</Label> <SettingsSelect
<Select id="auto_lock_period_days"
name="auto_lock_period_days" name="auto_lock_period_days"
defaultValue={settings.auto_lock_period_days?.toString() || 'none'} defaultValue={settings.auto_lock_period_days?.toString() || 'none'}
> >
<SelectTrigger id="auto_lock_period_days"> <option value="none">{t('auto_lock_none')}</option>
<SelectValue /> <option value="30">{t('auto_lock_30')}</option>
</SelectTrigger> <option value="60">{t('auto_lock_60')}</option>
<SelectContent> <option value="90">{t('auto_lock_90')}</option>
<SelectItem value="none">{t('auto_lock_none')}</SelectItem> </SettingsSelect>
<SelectItem value="30">{t('auto_lock_30')}</SelectItem> </SettingsRow>
<SelectItem value="60">{t('auto_lock_60')}</SelectItem> </SettingsGroup>
<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>
) )
} }
@@ -4,7 +4,10 @@ import { useCallback, useSyncExternalStore } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { ExternalLink } from 'lucide-react' import { ExternalLink } from 'lucide-react'
import { Switch } from '@/components/ui/switch' 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. * Per-user toggle for the periodisering wizard's auto-detection step.
@@ -76,33 +79,27 @@ export function PeriodiseringAutoDetectToggle() {
}, []) }, [])
return ( return (
<section className="space-y-4"> <SettingsRow
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> label="Periodisering"
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."
</h2> >
<div className="flex items-start justify-between gap-4"> <Switch
<div className="space-y-1"> id="periodisering-autodetect"
<Label htmlFor="periodisering-autodetect" className="text-sm"> checked={enabled}
Aktivera automatisk periodiseringsdetektering onCheckedChange={handleChange}
</Label> />
<p className="text-xs text-muted-foreground max-w-md"> <label htmlFor="periodisering-autodetect" className="cursor-pointer text-sm">
Skannar fakturor i bokslutet efter datumintervall som sträcker sig Aktivera automatisk periodiseringsdetektering
in i nästa räkenskapsår och föreslår periodiseringar i bokslut-wizarden. </label>
</p> <SettingsRowEnd>
</div> <Link
<Switch href="/bookkeeping/year-end/periodisering"
id="periodisering-autodetect" className="inline-flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
checked={enabled} >
onCheckedChange={handleChange} <ExternalLink className="h-3.5 w-3.5" />
/> Öppna periodiserings-wizarden
</div> </Link>
<Link </SettingsRowEnd>
href="/bookkeeping/year-end/periodisering" </SettingsRow>
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>
) )
} }
+119 -133
View File
@@ -4,17 +4,22 @@ import { useTranslations } from 'next-intl'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client' 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 { 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 { 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 { isMfaRequired } from '@/lib/auth/mfa'
import { isBankIdEnabled } from '@/lib/auth/bankid' import { isBankIdEnabled } from '@/lib/auth/bankid'
import { BankIdSettings } from '@/components/settings/BankIdSettings' import { BankIdSettings } from '@/components/settings/BankIdSettings'
import { userHasPassword } from '@/lib/auth/has-password' import { userHasPassword } from '@/lib/auth/has-password'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' 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 isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true'
const mfaRequired = isMfaRequired() const mfaRequired = isMfaRequired()
@@ -166,181 +171,162 @@ export function SecuritySettings() {
} }
return ( return (
<div className="space-y-6"> <SettingsGroup label={t('group_security')}>
{bankIdEnabled && <BankIdSettings />} {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 && ( {hasPassword === false && (
<Card> <SettingsRow
<CardHeader> label="Sätt ett lösenord"
<CardTitle className="flex items-center gap-2 text-base"> 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."
<KeyRound className="h-4 w-4" /> >
Sätt ett lösenord <SettingsRowEnd>
</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>
<Button <Button
size="sm"
onClick={() => onClick={() =>
router.push('/account/set-password?returnTo=/settings/account') router.push('/account/set-password?returnTo=/settings/account')
} }
> >
Sätt lösenord Sätt lösenord
</Button> </Button>
</CardContent> </SettingsRowEnd>
</Card> </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). */} above handles the set-initial-password flow). */}
{hasPassword !== false && ( {hasPassword !== false && (
<Card> <form onSubmit={handleChangePassword}>
<CardHeader> <SettingsRow
<CardTitle className="flex items-center gap-2"> label={t('new_password_label')}
<KeyRound className="h-5 w-5" /> htmlFor="new_password"
{t('change_password_title')} help={t('change_password_description')}
</CardTitle> align="baseline"
<CardDescription> >
{t('change_password_description')} <SettingsInput
</CardDescription> id="new_password"
</CardHeader> type="password"
<CardContent> autoComplete="new-password"
<form onSubmit={handleChangePassword} className="space-y-4 max-w-md"> placeholder={t('new_password_placeholder')}
<div className="space-y-2"> value={newPassword}
<Label htmlFor="new_password">{t('new_password_label')}</Label> onChange={(e) => setNewPassword(e.target.value)}
<Input required
id="new_password" minLength={8}
type="password" disabled={isChangingPassword}
autoComplete="new-password" />
placeholder={t('new_password_placeholder')} </SettingsRow>
value={newPassword} <SettingsRow
onChange={(e) => setNewPassword(e.target.value)} label={t('confirm_password_label')}
required htmlFor="confirm_new_password"
minLength={8} align="baseline"
disabled={isChangingPassword} >
/> <SettingsInput
</div> id="confirm_new_password"
<div className="space-y-2"> type="password"
<Label htmlFor="confirm_new_password">{t('confirm_password_label')}</Label> autoComplete="new-password"
<Input placeholder={t('confirm_password_placeholder')}
id="confirm_new_password" value={confirmPassword}
type="password" onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password" required
placeholder={t('confirm_password_placeholder')} minLength={8}
value={confirmPassword} disabled={isChangingPassword}
onChange={(e) => setConfirmPassword(e.target.value)} />
required <SettingsRowEnd>
minLength={8} <Button type="submit" size="sm" disabled={isChangingPassword}>
disabled={isChangingPassword} {isChangingPassword ? (
/> <>
</div> <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
<Button type="submit" disabled={isChangingPassword}> {t('saving')}
{isChangingPassword ? ( </>
<> ) : (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> t('update_password_button')
{t('saving')} )}
</> </Button>
) : ( </SettingsRowEnd>
t('update_password_button') </SettingsRow>
)} </form>
</Button>
</form>
</CardContent>
</Card>
)} )}
{/* MFA: hidden for self-hosted */} {/* MFA: hidden for self-hosted */}
{!isSelfHosted && ( {!isSelfHosted && (
<Card> <SettingsRow
<CardHeader> label={t('mfa_title')}
<CardTitle className="flex items-center gap-2"> help={
<ShieldCheck className="h-5 w-5" /> <>
{t('mfa_title')} <p>{t('mfa_description')}</p>
</CardTitle> {!isLoadingMfa && !hasMfa && (
<CardDescription> <p className="mt-2">{t('mfa_inactive_description')}</p>
{t('mfa_description')} )}
</CardDescription> </>
</CardHeader> }
<CardContent> >
{isLoadingMfa ? ( {isLoadingMfa ? (
<div className="flex items-center gap-2 text-muted-foreground"> <span className="inline-flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
{t('loading')} {t('loading')}
</div> </span>
) : hasMfa ? ( ) : hasMfa ? (
<div className="space-y-4"> <>
<div className="flex items-center gap-3 p-4 rounded-lg border border-border bg-secondary"> <Badge variant="success">{t('mfa_active_title')}</Badge>
<ShieldCheck className="h-5 w-5 text-success" /> <SettingsRowNote>{t('mfa_active_description')}</SettingsRowNote>
<div> <SettingsRowEnd>
<p className="font-medium text-foreground">{t('mfa_active_title')}</p> {mfaRequired ? (
<p className="text-sm text-muted-foreground"> // Required by the hosted config: no disable action exists,
{t('mfa_active_description')} // so the reason stays visible as the row's status.
</p> <SettingsRowNote>{t('mfa_required_note')}</SettingsRowNote>
</div> ) : (
</div>
{!mfaRequired && (
<Button <Button
variant="outline" variant="ghost"
size="sm"
onClick={handleUnenrollMfa} onClick={handleUnenrollMfa}
disabled={isUnenrolling} disabled={isUnenrolling}
> >
{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')} {t('disabling')}
</> </>
) : ( ) : (
<> <>
<ShieldOff className="mr-2 h-4 w-4" /> <ShieldOff className="mr-2 h-3.5 w-3.5" />
{t('disable_mfa')} {t('disable_mfa')}
</> </>
)} )}
</Button> </Button>
)} )}
{mfaRequired && ( </SettingsRowEnd>
<p className="text-xs text-muted-foreground"> </>
{t('mfa_required_note')} ) : (
</p> <>
)} <SettingsRowNote>{t('mfa_inactive_title')}</SettingsRowNote>
</div> <SettingsRowEnd>
) : (
<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>
{hasPassword === false ? ( {hasPassword === false ? (
<Button <Button
variant="ghost"
size="sm"
onClick={() => onClick={() =>
router.push( router.push('/account/set-password?returnTo=/mfa/enroll')
'/account/set-password?returnTo=/mfa/enroll',
)
} }
> >
{t('set_password_first')} {t('set_password_first')}
</Button> </Button>
) : ( ) : (
<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')} {t('enable_mfa')}
</Button> </Button>
)} )}
</div> </SettingsRowEnd>
)} </>
</CardContent> )}
</Card> </SettingsRow>
)} )}
</div> </SettingsGroup>
) )
} }
+34 -7
View File
@@ -7,6 +7,7 @@ import { Loader2, Check, Lock } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write' import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { cn } from '@/lib/utils'
type SaveResult = type SaveResult =
| Record<string, unknown> | Record<string, unknown>
@@ -18,12 +19,18 @@ interface SettingsFormWrapperProps {
className?: string 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) { export function SettingsFormWrapper({ children, onSave, className }: SettingsFormWrapperProps) {
const t = useTranslations('settings_company') const t = useTranslations('settings_company')
const { toast } = useToast() const { toast } = useToast()
const { canWrite } = useCanWrite() const { canWrite } = useCanWrite()
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [dirty, setDirty] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined) const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
useEffect(() => { useEffect(() => {
@@ -78,6 +85,7 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
} }
onSuccess?.(result.data ?? updates) onSuccess?.(result.data ?? updates)
setDirty(false)
setSaved(true) setSaved(true)
timerRef.current = setTimeout(() => setSaved(false), 2000) timerRef.current = setTimeout(() => setSaved(false), 2000)
} catch (error) { } catch (error) {
@@ -91,21 +99,32 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
setIsSaving(false) setIsSaving(false)
}, [onSave, toast, t]) }, [onSave, toast, t])
const barVisible = dirty || isSaving || saved
return ( return (
<form onSubmit={handleSubmit} className={className}> <form
onSubmit={handleSubmit}
onInput={() => setDirty(true)}
className={className}
>
{children} {children}
<div className="flex items-center justify-end gap-3 mt-8"> {/* Sticky save bar: invisible until the form is dirty. The gradient
{saved && ( lets rows scroll away underneath without a hard edge. */}
<span className="flex items-center gap-1.5 text-sm text-muted-foreground animate-in fade-in duration-200"> <div
<Check className="h-3.5 w-3.5" /> className={cn(
{t('wrapper_saved')} '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',
</span> barVisible
? 'mt-2 pb-3 pt-6 opacity-100'
: 'pointer-events-none h-0 overflow-hidden py-0 opacity-0',
)} )}
aria-hidden={!barVisible}
>
<Button <Button
type="submit" type="submit"
disabled={isSaving || !canWrite} disabled={isSaving || !canWrite}
size="sm" size="sm"
tabIndex={barVisible ? 0 : -1}
title={!canWrite ? t('wrapper_readonly_tooltip') : undefined} title={!canWrite ? t('wrapper_readonly_tooltip') : undefined}
> >
{isSaving ? ( {isSaving ? (
@@ -122,6 +141,14 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
t('wrapper_save_changes') t('wrapper_save_changes')
)} )}
</Button> </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> </div>
</form> </form>
) )
+20 -9
View File
@@ -11,7 +11,6 @@ import {
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { SETTINGS_SECTIONS } from './sections' import { SETTINGS_SECTIONS } from './sections'
import { SettingsShell } from './SettingsShell' import { SettingsShell } from './SettingsShell'
import { ActiveCompanyBadge } from './ActiveCompanyBadge'
/** /**
* The settings popup. Rendered only by the intercepting route * 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 * 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 * `children` slot behind the scrim). On hard load / refresh / deep-link the
* interceptor doesn't fire and the real full-page settings render instead. * 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 }) { export function SettingsModal({ sectionId }: { sectionId?: string }) {
const router = useRouter() const router = useRouter()
@@ -54,16 +56,25 @@ export function SettingsModal({ sectionId }: { sectionId?: string }) {
return ( return (
<Dialog open onOpenChange={onOpenChange}> <Dialog open onOpenChange={onOpenChange}>
{/* Escape with an open "?" help popover closes the popover (its own
listener), not the whole settings window. */}
<DialogContent <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"> <div className="flex shrink-0 items-center gap-3 border-b border-border px-6 py-3">
<DialogTitle className="font-display text-lg tracking-tight"> <div className="min-w-0">
{t('title')} {/* The modal covers the sidebar's CompanySwitcher, so the active
</DialogTitle> company must stay visible here as the kicker over the title. */}
{/* The modal covers the sidebar's CompanySwitcher, so the active {company ? (
company must stay visible here (mr-6 clears the close button). */} <p className="truncate text-xs text-muted-foreground">{company.name}</p>
<ActiveCompanyBadge className="ml-auto mr-6" /> ) : null}
<DialogTitle className="font-display text-lg tracking-tight">
{t('title')}
</DialogTitle>
</div>
</div> </div>
<DialogDescription className="sr-only">{t('description')}</DialogDescription> <DialogDescription className="sr-only">{t('description')}</DialogDescription>
<SettingsShell variant="modal" activeSection={resolved} /> <SettingsShell variant="modal" activeSection={resolved} />
+258
View File
@@ -0,0 +1,258 @@
'use client'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { HelpPopover } from '@/components/ui/help-popover'
/**
* The Fönster settings language (founder-approved concept 2026-07-25):
* flat hairline rows with an uppercase micro-label on the left and the
* control on the right, section titles in the display serif, groups under
* eyebrow labels, and every explanation collapsed behind a "?" popover
* (UI-migration convention 7 applied at row level).
*
* All settings sections compose these primitives; do not hand-roll row
* layouts or inline help paragraphs in section components.
*/
interface SettingsSectionHeaderProps {
title: string
/** One quiet line under the title. Longer guidance belongs in row help. */
intro?: React.ReactNode
/** Right-aligned header action (e.g. "Förhandsvisa faktura", "Skapa nyckel"). */
action?: React.ReactNode
}
export function SettingsSectionHeader({ title, intro, action }: SettingsSectionHeaderProps) {
return (
<header>
<div className="flex items-baseline justify-between gap-4">
<h2 className="font-display text-xl tracking-tight">{title}</h2>
{action ? <div className="flex shrink-0 items-center gap-3">{action}</div> : null}
</div>
{intro ? (
<p className="mt-1 max-w-[56ch] text-xs leading-relaxed text-muted-foreground">{intro}</p>
) : null}
</header>
)
}
interface SettingsGroupProps {
label?: string
/** Group-level help ("?" right after the eyebrow) for guidance that spans the rows. */
help?: React.ReactNode
children: React.ReactNode
className?: string
}
export function SettingsGroup({ label, help, children, className }: SettingsGroupProps) {
return (
<section className={cn('pt-8 first:pt-6', className)}>
{label ? (
<p className="flex items-center gap-2 px-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<span>{label}</span>
{help ? <HelpPopover className="shrink-0">{help}</HelpPopover> : null}
</p>
) : null}
<div>{children}</div>
</section>
)
}
interface SettingsRowProps {
label: React.ReactNode
/** Ties the label to a control; renders a <label> instead of a <span>. */
htmlFor?: string
/** Row help content: goes behind a "?" next to the label, never inline. */
help?: React.ReactNode
/** 'center' for toggles/selects/chips, 'baseline' for text inputs. */
align?: 'center' | 'baseline'
/** Drop the hairline (last row before a fold/reveal). */
borderless?: boolean
children: React.ReactNode
className?: string
}
export function SettingsRow({
label,
htmlFor,
help,
align = 'center',
borderless = false,
children,
className,
}: SettingsRowProps) {
const labelClass = 'text-[11px] font-medium uppercase tracking-wider text-muted-foreground'
return (
<div
className={cn(
'flex flex-col gap-1 px-1 py-3 md:flex-row md:gap-4',
align === 'center' ? 'md:items-center' : 'md:items-baseline',
!borderless && 'border-b border-border',
className,
)}
>
<div className="flex w-full shrink-0 items-center gap-2 md:w-44">
{htmlFor ? (
<label htmlFor={htmlFor} className={labelClass}>
{label}
</label>
) : (
<span className={labelClass}>{label}</span>
)}
{help ? <HelpPopover className="shrink-0">{help}</HelpPopover> : null}
</div>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1 text-sm">
{children}
</div>
</div>
)
}
/** Right-aligned slot inside a row (quiet actions, secondary values). */
export function SettingsRowEnd({ children, className }: { children: React.ReactNode; className?: string }) {
return <div className={cn('ml-auto flex shrink-0 items-center gap-3', className)}>{children}</div>
}
/** Muted secondary text inside a row. */
export function SettingsRowNote({ children, className }: { children: React.ReactNode; className?: string }) {
return <span className={cn('text-xs text-muted-foreground', className)}>{children}</span>
}
/**
* Flat text input: borderless with a dashed underline on hover and a solid
* one on focus. Sized by the row, not by a box.
*/
export function SettingsInput({ className, ...rest }: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
{...rest}
className={cn(
'min-w-0 flex-1 rounded-none border-0 border-b border-dashed border-transparent bg-transparent px-0 py-1 text-sm text-foreground',
'placeholder:text-muted-foreground/60 hover:border-border',
'focus:outline-none focus:border-solid focus:border-foreground/50',
'disabled:cursor-not-allowed disabled:border-transparent disabled:opacity-60',
className,
)}
/>
)
}
/** Flat textarea sibling of SettingsInput. */
export function SettingsTextarea({ className, ...rest }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
return (
<textarea
{...rest}
className={cn(
'min-w-0 flex-1 resize-y rounded-none border-0 border-b border-dashed border-transparent bg-transparent px-0 py-1 text-sm text-foreground',
'placeholder:text-muted-foreground/60 hover:border-border',
'focus:outline-none focus:border-solid focus:border-foreground/50',
'disabled:cursor-not-allowed disabled:border-transparent disabled:opacity-60',
className,
)}
/>
)
}
/** Flat native select with a quiet chevron. */
export function SettingsSelect({
className,
wrapperClassName,
children,
...rest
}: React.SelectHTMLAttributes<HTMLSelectElement> & { wrapperClassName?: string }) {
return (
<span className={cn('relative inline-flex max-w-full items-center', wrapperClassName)}>
<select
{...rest}
className={cn(
'max-w-full cursor-pointer appearance-none truncate rounded-none border-0 border-b border-dashed border-transparent bg-transparent py-1 pl-0 pr-6 text-sm text-foreground',
'hover:border-border focus:outline-none focus-visible:border-solid focus-visible:border-foreground/50',
'disabled:cursor-not-allowed disabled:opacity-60',
className,
)}
>
{children}
</select>
<ChevronDown
aria-hidden="true"
className="pointer-events-none absolute right-1 h-3.5 w-3.5 text-muted-foreground"
/>
</span>
)
}
interface SettingsRevealProps {
open: boolean
/** Indent revealed rows behind a left hairline (gated sub-settings). */
indent?: boolean
children: React.ReactNode
}
/** Animated reveal for settings gated behind a toggle (momsreg → momsblock). */
export function SettingsReveal({ open, indent = true, children }: SettingsRevealProps) {
return (
<div
inert={!open}
className={cn(
'grid transition-[grid-template-rows] duration-300 motion-reduce:transition-none',
open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
)}
>
<div className="min-h-0 overflow-hidden">
<div className={cn(indent && 'ml-3 border-l border-border pl-4')}>{children}</div>
</div>
</div>
)
}
interface SettingsSegProps<T extends string> {
value: T
onChange: (value: T) => void
options: Array<{ value: T; label: React.ReactNode }>
'aria-label': string
disabled?: boolean
}
/** Quiet segmented control (theme, language, plan interval, sv/en texts). */
export function SettingsSeg<T extends string>({
value,
onChange,
options,
'aria-label': ariaLabel,
disabled,
}: SettingsSegProps<T>) {
return (
<div role="group" aria-label={ariaLabel} className="inline-flex items-center gap-1 rounded-lg bg-muted/70 p-1">
{options.map((o) => (
<button
key={o.value}
type="button"
disabled={disabled}
aria-pressed={o.value === value}
onClick={() => onChange(o.value)}
className={cn(
'rounded-md px-3 py-1 text-xs transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-60',
o.value === value
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{o.label}
</button>
))}
</div>
)
}
/** Terracotta-tinted trailing block for destructive actions. */
export function SettingsDangerZone({ label, children }: { label: string; children: React.ReactNode }) {
return (
<section className="mt-10 border-t border-destructive/30 pt-3">
<p className="px-1 text-[11px] font-medium uppercase tracking-wider text-destructive/80">
{label}
</p>
<div>{children}</div>
</section>
)
}
+54 -46
View File
@@ -2,8 +2,13 @@
import { useState } from 'react' import { useState } from 'react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input' import {
import { Label } from '@/components/ui/label' SettingsGroup,
SettingsInput,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
import { roundOre } from '@/lib/money' import { roundOre } from '@/lib/money'
import { formatCurrency } from '@/lib/utils' import { formatCurrency } from '@/lib/utils'
@@ -40,49 +45,52 @@ export function ShareCapitalForm({ settings }: ShareCapitalFormProps) {
: null : null
return ( return (
<section className="space-y-4"> <SettingsGroup label={t('share_capital_heading')}>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> <SettingsRow
{t('share_capital_heading')} label={t('aktiekapital_label')}
</h2> htmlFor="aktiekapital"
help={t('aktiekapital_help')}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> align="baseline"
<div className="space-y-2"> >
<Label htmlFor="aktiekapital">{t('aktiekapital_label')}</Label> <SettingsInput
<Input id="aktiekapital"
id="aktiekapital" name="aktiekapital"
name="aktiekapital" type="number"
type="number" inputMode="numeric"
inputMode="numeric" min="1"
min="1" step="1"
step="1" value={aktiekapital}
value={aktiekapital} onChange={(e) => setAktiekapital(e.target.value)}
onChange={(e) => setAktiekapital(e.target.value)} required={antalAktier.trim() !== ''}
required={antalAktier.trim() !== ''} className="max-w-32 flex-none tabular-nums"
/> />
<p className="text-xs text-muted-foreground">{t('aktiekapital_help')}</p> </SettingsRow>
</div> <SettingsRow
<div className="space-y-2"> label={t('antal_aktier_label')}
<Label htmlFor="antal_aktier">{t('antal_aktier_label')}</Label> htmlFor="antal_aktier"
<Input help={t('antal_aktier_help')}
id="antal_aktier" align="baseline"
name="antal_aktier" >
type="number" <SettingsInput
inputMode="numeric" id="antal_aktier"
min="1" name="antal_aktier"
step="1" type="number"
value={antalAktier} inputMode="numeric"
onChange={(e) => setAntalAktier(e.target.value)} min="1"
required={aktiekapital.trim() !== ''} step="1"
/> value={antalAktier}
<p className="text-xs text-muted-foreground">{t('antal_aktier_help')}</p> onChange={(e) => setAntalAktier(e.target.value)}
</div> required={aktiekapital.trim() !== ''}
</div> className="max-w-32 flex-none tabular-nums"
/>
{kvotvarde !== null && ( {kvotvarde !== null && (
<p className="text-xs text-muted-foreground tabular-nums"> <SettingsRowEnd>
{t('kvotvarde_display', { value: formatCurrency(kvotvarde) })} <SettingsRowNote className="tabular-nums">
</p> {t('kvotvarde_display', { value: formatCurrency(kvotvarde) })}
)} </SettingsRowNote>
</section> </SettingsRowEnd>
)}
</SettingsRow>
</SettingsGroup>
) )
} }
+162 -187
View File
@@ -2,7 +2,6 @@
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
@@ -10,6 +9,12 @@ import { useCapability } from '@/contexts/CompanyContext'
import { isAllowedSkvPopupOrigin } from '@/lib/skatteverket/popup-origin' import { isAllowedSkvPopupOrigin } from '@/lib/skatteverket/popup-origin'
import { CAPABILITY } from '@/lib/entitlements/keys' import { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote' 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 { CheckCircle2, ExternalLink, Loader2, ShieldOff, FlaskConical, ShieldAlert } from 'lucide-react'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -29,12 +34,21 @@ type Status =
disabled?: boolean 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() { export function SkatteverketConnectPanel() {
return ( return (
<div className="space-y-4"> <>
<SkatteverketPersonalConnectionCard /> <SkatteverketPersonalConnectionCard />
<SkatteverketSystemConnectionCard /> <SkatteverketSystemConnectionCard />
</div> </>
) )
} }
@@ -83,9 +97,10 @@ function SkatteverketPersonalConnectionCard() {
agd: t('scope_agd'), agd: t('scope_agd'),
} }
// Only the first load blanks the card to the loading state: later refetches // Only the first load blanks the section to the loading state: later
// (postMessage, closed-tab watcher, delayed sync refetch, visibility) update // refetches (postMessage, closed-tab watcher, delayed sync refetch,
// in the background so the panel doesn't flash on every signal. // visibility) update in the background so the panel doesn't flash on every
// signal.
const hasLoadedRef = useRef(false) const hasLoadedRef = useRef(false)
const loadStatus = useCallback(async () => { const loadStatus = useCallback(async () => {
if (!hasLoadedRef.current) setLoading(true) 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) { if (loading) {
return ( return (
<Card> <SettingsGroup>
<CardContent className="py-8 text-sm text-muted-foreground"> <SettingsRow label={t('title')} help={connectHelp} borderless>
{t('loading_status')} <SettingsRowNote>{t('loading_status')}</SettingsRowNote>
</CardContent> </SettingsRow>
</Card> </SettingsGroup>
) )
} }
if (!status?.connected) { if (!status?.connected) {
return ( return (
<Card> <SettingsGroup>
<CardHeader> {status?.disabled && <WarningLine>{t('disabled_message')}</WarningLine>}
<div className="flex items-center justify-between"> <SettingsRow label={t('title')} help={connectHelp} borderless={!hasSkatteverket}>
<CardTitle className="text-base">{t('title')}</CardTitle> <EnvironmentBadge environment={status?.environment} disabled={status?.disabled} />
<EnvironmentBadge environment={status?.environment} disabled={status?.disabled} /> <SettingsRowEnd>
</div> <Button
</CardHeader> size="sm"
<CardContent className="space-y-4"> onClick={startConnect}
{status?.disabled && ( disabled={status?.disabled || !hasSkatteverket || connecting}
<div className="flex gap-2 rounded-md border border-border bg-secondary/40 p-3 text-sm text-foreground"> title={!hasSkatteverket ? 'Anslutning till Skatteverket kräver ett abonnemang' : undefined}
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" /> >
<p>{t('disabled_message')}</p> <ExternalLink className="mr-2 h-4 w-4" />
</div> {connecting ? t('connect_waiting') : t('connect_with_bankid')}
)} </Button>
<p className="text-sm text-muted-foreground"> </SettingsRowEnd>
{t('connect_intro')} </SettingsRow>
</p> {!hasSkatteverket && (
{/* The consent-page notes only matter when the user can actually <div className="px-1 py-3">
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 && (
<UpgradeNote>Anslutning till Skatteverket kräver ett abonnemang.</UpgradeNote> <UpgradeNote>Anslutning till Skatteverket kräver ett abonnemang.</UpgradeNote>
)} </div>
<Button )}
onClick={startConnect} </SettingsGroup>
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>
) )
} }
@@ -304,95 +316,35 @@ function SkatteverketPersonalConnectionCard() {
) )
return ( return (
<Card> <SettingsGroup>
<CardHeader> {status.needsReconsent && (
<div className="flex items-center justify-between"> <WarningLine>
<CardTitle className="flex items-center gap-2 text-base"> {/* MISSING_SCOPE right after a connect means the user skipped a
{t('title')} behörighet on SKV's consent page; tell them exactly that
{status.expired ? ( instead of the generic "session expired" prompt. */}
<Badge variant="destructive">{t('expired')}</Badge> {status.lastErrorCode === 'MISSING_SCOPE'
) : ( ? t('missing_scope_message')
<Badge variant="secondary"> : t('needs_reconsent_message')}
<CheckCircle2 className="mr-1 h-3 w-3" /> </WarningLine>
{t('connected')} )}
</Badge>
)} <SettingsRow label={t('title')} help={connectHelp}>
</CardTitle> {status.expired ? (
<EnvironmentBadge environment={status.environment} disabled={status.disabled} /> <Badge variant="warning">{t('expired')}</Badge>
</div> ) : (
</CardHeader> <Badge variant="success">
<CardContent className="space-y-4"> <CheckCircle2 className="mr-1 h-3 w-3" />
{status.needsReconsent && ( {t('connected')}
<div className="flex gap-2 rounded-md border border-border bg-secondary/40 p-3 text-sm text-foreground"> </Badge>
<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>
)} )}
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2"> <EnvironmentBadge environment={status.environment} disabled={status.disabled} />
<div> <SettingsRowEnd>
<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">
{/* `ska` gates the interactive skattekonto API (saldo + {/* `ska` gates the interactive skattekonto API (saldo +
transaktioner); a grant without it cannot sync, so offer the transaktioner); a grant without it cannot sync, so offer the
reconnect even while the token is otherwise healthy. */} reconnect even while the token is otherwise healthy. */}
{(status.expired || status.needsReconsent || !status.canRefresh || !scopes.includes('ska') || !scopes.includes('agd')) && ( {(status.expired || status.needsReconsent || !status.canRefresh || !scopes.includes('ska') || !scopes.includes('agd')) && (
<Button <Button
size="sm"
onClick={startConnect} onClick={startConnect}
disabled={status.disabled || !hasSkatteverket || connecting} disabled={status.disabled || !hasSkatteverket || connecting}
title={!hasSkatteverket ? 'Anslutning till Skatteverket kräver ett abonnemang' : undefined} title={!hasSkatteverket ? 'Anslutning till Skatteverket kräver ett abonnemang' : undefined}
@@ -403,15 +355,48 @@ function SkatteverketPersonalConnectionCard() {
)} )}
<Button <Button
variant="outline" variant="outline"
size="sm"
onClick={disconnect} onClick={disconnect}
disabled={disconnecting || connecting} disabled={disconnecting || connecting}
> >
<ShieldOff className="mr-2 h-4 w-4" /> <ShieldOff className="mr-2 h-4 w-4" />
{disconnecting ? t('disconnecting') : t('disconnect')} {disconnecting ? t('disconnecting') : t('disconnect')}
</Button> </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> </div>
</CardContent> </SettingsRow>
</Card>
{/* `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 ( return (
<Card> <SettingsGroup label={t('system_title')} help={t('system_intro')}>
<CardHeader> {state.ombud_org_number && (
<CardTitle className="text-base">{t('system_title')}</CardTitle> <SettingsRow label={t('system_org_label')}>
</CardHeader> <span className="font-mono text-sm tabular-nums">{state.ombud_org_number}</span>
<CardContent className="space-y-4"> </SettingsRow>
<p className="text-sm text-muted-foreground">{t('system_intro')}</p> )}
{state.ombud_org_number && ( <SettingsRow label={t('system_behorighet_lasombud')}>
<div className="rounded-md border border-border bg-secondary/40 p-3 text-sm"> {grantBadge(state.connection?.lasombud_status)}
<p className="text-muted-foreground">{t('system_org_label')}</p> </SettingsRow>
<p className="font-mono font-medium tabular-nums">{state.ombud_org_number}</p> <SettingsRow label={t('system_behorighet_moms')}>
</div> {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>
)} )}
<Button size="sm" onClick={verify} disabled={verifying}>
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2"> {verifying && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<div className="flex items-center justify-between gap-2 rounded-md border border-border p-3"> {verifying ? t('system_verifying') : t('system_verify')}
<span>{t('system_behorighet_lasombud')}</span> </Button>
{grantBadge(state.connection?.lasombud_status)} </div>
</div> </SettingsGroup>
<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>
) )
} }
@@ -2,18 +2,16 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl' 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 { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { import {
Select, SettingsGroup,
SelectContent, SettingsInput,
SelectItem, SettingsRow,
SelectTrigger, SettingsRowNote,
SelectValue, SettingsSelect,
} from '@/components/ui/select' } from '@/components/settings/SettingsRows'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { useErrorToast } from '@/lib/hooks/use-error-toast' import { useErrorToast } from '@/lib/hooks/use-error-toast'
import type { import type {
@@ -142,102 +140,98 @@ export function TaxAssessmentNoticesPanel() {
} }
return ( return (
<section className="border-t border-border pt-8" aria-labelledby="tax-assessment-notices-title"> // What this panel is for ("enter the exact payment date from the
<div className="max-w-3xl"> // slutskattebesked") is static guidance: it lives behind the group "?".
<div className="flex items-start gap-3"> <SettingsGroup label={t('title')} help={t('description')}>
<CalendarClock className="mt-0.5 h-5 w-5" /> <form onSubmit={saveNotice}>
<div> <SettingsRow label={t('fiscal_period')}>
<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">
<FiscalYearSelector <FiscalYearSelector
value={selectedPeriodId} value={selectedPeriodId}
onChange={(periodId) => setSelectedPeriodId(periodId)} onChange={(periodId) => setSelectedPeriodId(periodId)}
includeAllOption={false} includeAllOption={false}
label={t('fiscal_period')} label={null}
className="sm:col-span-2" className="w-full max-w-72"
/> />
<div className="space-y-1.5"> </SettingsRow>
<Label htmlFor="tax-assessment-decision-type">{t('decision_type')}</Label> <SettingsRow label={t('decision_type')} htmlFor="tax-assessment-decision-type">
<Select value={decisionType} onValueChange={(value) => setDecisionType(value as TaxAssessmentDecisionType)}> <SettingsSelect
<SelectTrigger id="tax-assessment-decision-type"> id="tax-assessment-decision-type"
<SelectValue /> value={decisionType}
</SelectTrigger> onChange={(event) => setDecisionType(event.target.value as TaxAssessmentDecisionType)}
<SelectContent> >
<SelectItem value="final">{t('decision_final')}</SelectItem> <option value="final">{t('decision_final')}</option>
<SelectItem value="reassessment">{t('decision_reassessment')}</SelectItem> <option value="reassessment">{t('decision_reassessment')}</option>
</SelectContent> </SettingsSelect>
</Select> </SettingsRow>
</div> <SettingsRow
<div className="space-y-1.5"> label={t('decision_date')}
<Label htmlFor="tax-assessment-decision-date">{t('decision_date')}</Label> htmlFor="tax-assessment-decision-date"
<Input align="baseline"
id="tax-assessment-decision-date" >
type="date" <SettingsInput
required id="tax-assessment-decision-date"
value={decisionDate} type="date"
onChange={(event) => setDecisionDate(event.target.value)} required
/> value={decisionDate}
</div> onChange={(event) => setDecisionDate(event.target.value)}
<div className="space-y-1.5 sm:col-span-2"> className="max-w-40 flex-none tabular-nums"
<Label htmlFor="tax-assessment-payment-due-date">{t('payment_due_date')}</Label> />
<Input </SettingsRow>
id="tax-assessment-payment-due-date" <SettingsRow
type="date" label={t('payment_due_date')}
required htmlFor="tax-assessment-payment-due-date"
min={decisionDate} help={t('payment_due_date_help')}
value={paymentDueDate} align="baseline"
onChange={(event) => setPaymentDueDate(event.target.value)} >
/> <SettingsInput
<p className="text-xs text-muted-foreground">{t('payment_due_date_help')}</p> id="tax-assessment-payment-due-date"
</div> type="date"
<div className="flex flex-wrap gap-2 sm:col-span-2"> required
<Button type="submit" disabled={saving || !selectedPeriodId || !paymentDueDate}> min={decisionDate}
{saving ? t('saving') : t(editingId ? 'update_action' : 'save_action')} 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> </Button>
{editingId && ( )}
<Button type="button" variant="ghost" onClick={resetForm} disabled={saving}> </div>
{t('cancel')} </form>
</Button>
)}
</div>
</form>
{!loading && notices.length > 0 && ( {/* Registered notices continue as flat hairline rows under the form. */}
<div className="mt-6 divide-y divide-border border-y border-border"> {!loading && notices.length > 0 && notices.map((notice) => (
{notices.map((notice) => ( <div
<div key={notice.id} className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between"> key={notice.id}
<div> className="flex flex-col gap-3 border-t border-border px-1 py-3 sm:flex-row sm:items-center sm:justify-between"
<p className="text-sm font-medium"> >
{notice.decision_type === 'final' ? t('decision_final') : t('decision_reassessment')} <div className="min-w-0">
{notice.fiscal_period?.name ? `: ${notice.fiscal_period.name}` : ''} <p className="text-sm">
</p> {notice.decision_type === 'final' ? t('decision_final') : t('decision_reassessment')}
<p className="mt-1 text-xs text-muted-foreground"> {notice.fiscal_period?.name ? `: ${notice.fiscal_period.name}` : ''}
{t('due_summary', { date: notice.payment_due_date })} </p>
</p> <SettingsRowNote className="tabular-nums">
</div> {t('due_summary', { date: notice.payment_due_date })}
<div className="flex gap-2"> </SettingsRowNote>
<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>
))}
</div> </div>
)} <div className="flex shrink-0 gap-2">
</div> <Button type="button" variant="ghost" size="sm" onClick={() => editNotice(notice)} disabled={saving}>
</section> <Pencil className="mr-2 h-4 w-4" />
{t('edit')}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => void archiveNotice(notice)} disabled={saving}>
<Trash2 className="mr-2 h-4 w-4" />
{t('archive')}
</Button>
</div>
</div>
))}
</SettingsGroup>
) )
} }
+330 -435
View File
@@ -2,10 +2,15 @@
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useState } from 'react' import { useState } from 'react'
import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label' import { HelpPopover } from '@/components/ui/help-popover'
import { Checkbox } from '@/components/ui/checkbox' import {
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' SettingsGroup,
SettingsInput,
SettingsReveal,
SettingsRow,
SettingsSelect,
} from '@/components/settings/SettingsRows'
import type { CompanySettings } from '@/types' import type { CompanySettings } from '@/types'
interface TaxSettingsFormProps { interface TaxSettingsFormProps {
@@ -18,6 +23,20 @@ interface TaxSettingsFormProps {
rotRutSignalDetected?: boolean 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({ export function TaxSettingsForm({
settings, settings,
euSalesDetected = false, euSalesDetected = false,
@@ -59,320 +78,267 @@ export function TaxSettingsForm({
] ]
return ( return (
<div className="space-y-8"> <div>
{/* Entity type: read-only */} <SettingsGroup label={t('tax_vat_heading')}>
<section className="space-y-4"> {/* Entity type: read-only. Changing it is a support operation. */}
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> <SettingsRow label={t('entity_form_heading')} help={t('entity_form_help')}>
{t('entity_form_heading')} <span className="text-sm">
</h2>
<div className="flex items-center gap-3">
<span className="text-sm font-medium">
{settings.entity_type === 'aktiebolag' ? t('entity_aktiebolag') : t('entity_enskild_firma')} {settings.entity_type === 'aktiebolag' ? t('entity_aktiebolag') : t('entity_enskild_firma')}
</span> </span>
<p className="text-xs text-muted-foreground"> </SettingsRow>
{t('entity_form_help')}
</p>
</div>
</section>
{/* F-skatt */} <SettingsRow label={t('f_skatt_label')} htmlFor="f_skatt" help={t('f_skatt_help')}>
<section className="border-t border-border pt-8 space-y-4"> <Switch
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> id="f_skatt"
{t('tax_vat_heading')} checked={fSkatt}
</h2> onCheckedChange={(v) => setFSkatt(v === true)}
/>
<input type="hidden" name="f_skatt" value={fSkatt ? 'true' : 'false'} />
</SettingsRow>
<div className="space-y-4"> <SettingsRow
<div className="flex items-start space-x-3"> label={t('vat_registered_label')}
<Checkbox htmlFor="vat_registered"
id="f_skatt" help={t('vat_registered_help')}
checked={fSkatt} borderless={vatRegistered}
onCheckedChange={(v) => setFSkatt(v === true)} >
<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'} /> </SettingsRow>
<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>
<div className="flex items-start space-x-3"> <SettingsRow
<Checkbox label={t('moms_period_label')}
id="vat_registered" htmlFor="moms_period"
checked={vatRegistered} 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) => { onCheckedChange={(value) => {
const checked = value === true const checked = value === true
setVatRegistered(checked) setVatTaxableBaseOver40m(checked)
if (checked && vatTaxableBaseOver40m) setMomsPeriod('monthly') // Over 40 MSEK forces monthly VAT reporting.
if (checked) setMomsPeriod('monthly')
}} }}
/> />
<input type="hidden" name="vat_registered" value={vatRegistered ? 'true' : 'false'} /> <input
<div className="space-y-1"> type="hidden"
<Label htmlFor="vat_registered" className="cursor-pointer">{t('vat_registered_label')}</Label> name="vat_taxable_base_over_40m"
<p className="text-xs text-muted-foreground"> value={vatTaxableBaseOver40m ? 'true' : 'false'}
{t('vat_registered_help')} />
</p> </SettingsRow>
</div>
</div>
{euSalesDetected && vatRegistered && (!hasEuTrade || !psEnabled) && ( <SettingsRow
<div className="rounded-lg border border-border p-4"> label={t('vat_has_eu_trade_label')}
<p className="text-sm">{t('eu_trade_suggestion_title')}</p> htmlFor="vat_has_eu_trade"
<p className="mt-1 text-xs text-muted-foreground"> help={t('vat_has_eu_trade_help')}
{t('eu_trade_suggestion_help')} borderless={hasEuTrade}
</p> >
</div> <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 && ( <SettingsReveal open={hasEuTrade}>
<div className="space-y-4 pl-7"> <SettingsRow
<div className="max-w-xs space-y-2"> label={t('periodisk_enabled_label')}
<Label htmlFor="vat_number">{t('vat_number_label')}</Label> htmlFor="periodisk_sammanstallning_enabled"
<Input help={t('periodisk_enabled_help')}
id="vat_number" borderless={psEnabled}
name="vat_number" >
placeholder="SE123456789001" <Switch
defaultValue={settings.vat_number || ''} id="periodisk_sammanstallning_enabled"
/> checked={psEnabled}
<p className="text-xs text-muted-foreground"> onCheckedChange={(value) => setPsEnabled(value === true)}
{t('vat_number_help')} />
</p> <input
</div> type="hidden"
name="periodisk_sammanstallning_enabled"
value={psEnabled ? 'true' : 'false'}
/>
</SettingsRow>
<div className="max-w-xs space-y-2"> <SettingsReveal open={psEnabled}>
<Label>{t('moms_period_label')}</Label> <SettingsRow
<Select label={t('periodisk_label')}
name="moms_period" htmlFor="periodisk_sammanstallning_period"
value={momsPeriod || undefined} help={t('periodisk_help')}
onValueChange={setMomsPeriod} >
<SettingsSelect
id="periodisk_sammanstallning_period"
name="periodisk_sammanstallning_period"
defaultValue={settings.periodisk_sammanstallning_period || 'monthly'}
> >
<SelectTrigger> <option value="monthly">{t('period_monthly')}</option>
<SelectValue placeholder={t('select_period_placeholder')} /> <option value="quarterly">{t('period_quarterly')}</option>
</SelectTrigger> </SettingsSelect>
<SelectContent> </SettingsRow>
<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>
<div className="flex items-start space-x-3"> <SettingsRow
<Checkbox label={t('periodisk_filing_method_label')}
id="vat_taxable_base_over_40m" htmlFor="periodisk_sammanstallning_filing_method"
checked={vatTaxableBaseOver40m} help={t('periodisk_filing_method_help')}
onCheckedChange={(value) => { borderless
const checked = value === true >
setVatTaxableBaseOver40m(checked) <SettingsSelect
if (checked) setMomsPeriod('monthly') id="periodisk_sammanstallning_filing_method"
}} name="periodisk_sammanstallning_filing_method"
/> defaultValue={settings.periodisk_sammanstallning_filing_method || 'electronic'}
<input >
type="hidden" <option value="electronic">{t('filing_method_electronic')}</option>
name="vat_taxable_base_over_40m" <option value="paper">{t('filing_method_paper')}</option>
value={vatTaxableBaseOver40m ? 'true' : 'false'} </SettingsSelect>
/> </SettingsRow>
<div className="space-y-1"> </SettingsReveal>
<Label htmlFor="vat_taxable_base_over_40m" className="cursor-pointer"> </SettingsReveal>
{t('vat_taxable_base_over_40m_label')} </SettingsReveal>
</Label> </SettingsGroup>
<p className="text-xs text-muted-foreground">
{t('vat_taxable_base_over_40m_help')}
</p>
</div>
</div>
<div className="flex items-start space-x-3"> {/* Tax contact: required for SKV filings. */}
<Checkbox <SettingsGroup label={t('tax_contact_heading')} help={t('tax_contact_help')}>
id="vat_has_eu_trade" <SettingsRow label={t('tax_contact_name_label')} htmlFor="tax_contact_name" align="baseline">
checked={hasEuTrade} <SettingsInput
onCheckedChange={(value) => { id="tax_contact_name"
const checked = value === true name="tax_contact_name"
setHasEuTrade(checked) defaultValue={settings.tax_contact_name || ''}
if (!checked) setPsEnabled(false) placeholder={t('tax_contact_name_placeholder')}
}} />
/> </SettingsRow>
<input type="hidden" name="vat_has_eu_trade" value={hasEuTrade ? 'true' : 'false'} /> <SettingsRow label={t('tax_contact_phone_label')} htmlFor="tax_contact_phone" align="baseline">
<div className="space-y-1"> <SettingsInput
<Label htmlFor="vat_has_eu_trade" className="cursor-pointer"> id="tax_contact_phone"
{t('vat_has_eu_trade_label')} name="tax_contact_phone"
</Label> defaultValue={settings.tax_contact_phone || ''}
<p className="text-xs text-muted-foreground"> placeholder="08-123 45 67"
{t('vat_has_eu_trade_help')} />
</p> </SettingsRow>
</div> <SettingsRow label={t('tax_contact_email_label')} htmlFor="tax_contact_email" align="baseline">
</div> <SettingsInput
id="tax_contact_email"
{momsPeriod === 'yearly' && !hasEuTrade && !isEnskildFirma && ( name="tax_contact_email"
<div className="max-w-xs space-y-2"> type="email"
<Label>{t('vat_filing_method_label')}</Label> defaultValue={settings.tax_contact_email || ''}
<Select placeholder="anna@foretaget.se"
name="vat_filing_method" />
defaultValue={settings.vat_filing_method || 'electronic'} </SettingsRow>
> </SettingsGroup>
<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>
{/* Fiscal year & salaries */} {/* Fiscal year & salaries */}
<section className="border-t border-border pt-8 space-y-4"> <SettingsGroup label={t('fiscal_year_salaries_heading')}>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> <SettingsRow
{t('fiscal_year_salaries_heading')} label={t('fiscal_year_start_label')}
</h2> htmlFor="fiscal_year_start_month"
help={isEnskildFirma ? t('fiscal_year_ef_help') : t('fiscal_year_change_help')}
<div className="max-w-xs space-y-2"> >
<Label>{t('fiscal_year_start_label')}</Label>
{isEnskildFirma ? ( {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" /> <input type="hidden" name="fiscal_year_start_month" value="1" />
<p className="text-xs text-muted-foreground">
{t('fiscal_year_ef_help')}
</p>
</> </>
) : ( ) : (
<> <SettingsSelect
<Select id="fiscal_year_start_month"
name="fiscal_year_start_month" name="fiscal_year_start_month"
defaultValue={String(settings.fiscal_year_start_month || 1)} defaultValue={String(settings.fiscal_year_start_month || 1)}
> >
<SelectTrigger> {months.map((month, i) => (
<SelectValue /> <option key={i + 1} value={String(i + 1)}>{month}</option>
</SelectTrigger> ))}
<SelectContent> </SettingsSelect>
{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>
</>
)} )}
</div> </SettingsRow>
<div className="flex items-start space-x-3"> <SettingsRow label={t('pays_salaries_label')} htmlFor="pays_salaries" help={t('pays_salaries_help')}>
<Checkbox <Switch
id="pays_salaries" id="pays_salaries"
checked={paysSalaries} checked={paysSalaries}
onCheckedChange={(v) => { onCheckedChange={(v) => {
@@ -383,16 +349,15 @@ export function TaxSettingsForm({
}} }}
/> />
<input type="hidden" name="pays_salaries" value={paysSalaries ? 'true' : 'false'} /> <input type="hidden" name="pays_salaries" value={paysSalaries ? 'true' : 'false'} />
<div className="space-y-1"> </SettingsRow>
<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>
<div className="flex items-start space-x-3"> <SettingsRow
<Checkbox label={t('employer_registered_label')}
htmlFor="employer_registered"
help={t('employer_registered_help')}
borderless={employerRegistered}
>
<Switch
id="employer_registered" id="employer_registered"
checked={employerRegistered} checked={employerRegistered}
onCheckedChange={(v) => { onCheckedChange={(v) => {
@@ -406,19 +371,16 @@ export function TaxSettingsForm({
name="employer_registered" name="employer_registered"
value={employerRegistered ? 'true' : 'false'} value={employerRegistered ? 'true' : 'false'}
/> />
<div className="space-y-1"> </SettingsRow>
<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>
{employerRegistered && ( <SettingsReveal open={employerRegistered}>
<div className="flex items-start space-x-3 pl-7"> <SettingsRow
<Checkbox label={t('employer_seasonal_label')}
htmlFor="employer_seasonal"
help={t('employer_seasonal_help')}
borderless
>
<Switch
id="employer_seasonal" id="employer_seasonal"
checked={employerSeasonal} checked={employerSeasonal}
onCheckedChange={(v) => setEmployerSeasonal(v === true)} onCheckedChange={(v) => setEmployerSeasonal(v === true)}
@@ -428,35 +390,22 @@ export function TaxSettingsForm({
name="employer_seasonal" name="employer_seasonal"
value={employerSeasonal ? 'true' : 'false'} value={employerSeasonal ? 'true' : 'false'}
/> />
<div className="space-y-1"> </SettingsRow>
<Label htmlFor="employer_seasonal" className="cursor-pointer"> </SettingsReveal>
{t('employer_seasonal_label')} </SettingsGroup>
</Label>
<p className="text-xs text-muted-foreground">
{t('employer_seasonal_help')}
</p>
</div>
</div>
)}
</section>
{/* Kontrolluppgifter (KU) */} {/* Kontrolluppgifter (KU) */}
<section className="border-t border-border pt-8 space-y-4"> <SettingsGroup label={t('kontrolluppgifter_heading')}>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('kontrolluppgifter_heading')}
</h2>
{kuSignalDetected && !kuEnabled && ( {kuSignalDetected && !kuEnabled && (
<div className="rounded-lg border border-border p-4"> <SignalLine text={t('ku_suggestion_title')} help={t('ku_suggestion_help')} />
<p className="text-sm">{t('ku_suggestion_title')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('ku_suggestion_help')}
</p>
</div>
)} )}
<div className="flex items-start space-x-3"> <SettingsRow
<Checkbox label={t('kontrolluppgifter_label')}
htmlFor="kontrolluppgifter_enabled"
help={t('kontrolluppgifter_help')}
>
<Switch
id="kontrolluppgifter_enabled" id="kontrolluppgifter_enabled"
checked={kuEnabled} checked={kuEnabled}
onCheckedChange={(v) => setKuEnabled(v === true)} onCheckedChange={(v) => setKuEnabled(v === true)}
@@ -466,34 +415,21 @@ export function TaxSettingsForm({
name="kontrolluppgifter_enabled" name="kontrolluppgifter_enabled"
value={kuEnabled ? 'true' : 'false'} value={kuEnabled ? 'true' : 'false'}
/> />
<div className="space-y-1"> </SettingsRow>
<Label htmlFor="kontrolluppgifter_enabled" className="cursor-pointer"> </SettingsGroup>
{t('kontrolluppgifter_label')}
</Label>
<p className="text-xs text-muted-foreground">
{t('kontrolluppgifter_help')}
</p>
</div>
</div>
</section>
{/* ROT/RUT */} {/* ROT/RUT */}
<section className="border-t border-border pt-8 space-y-4"> <SettingsGroup label={t('rot_rut_heading')}>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('rot_rut_heading')}
</h2>
{rotRutSignalDetected && !rotRutEnabled && ( {rotRutSignalDetected && !rotRutEnabled && (
<div className="rounded-lg border border-border p-4"> <SignalLine text={t('rot_rut_suggestion_title')} help={t('rot_rut_suggestion_help')} />
<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>
)} )}
<div className="flex items-start space-x-3"> <SettingsRow
<Checkbox label={t('rot_rut_label')}
htmlFor="rot_rut_enabled"
help={t('rot_rut_help')}
>
<Switch
id="rot_rut_enabled" id="rot_rut_enabled"
checked={rotRutEnabled} checked={rotRutEnabled}
onCheckedChange={(v) => setRotRutEnabled(v === true)} onCheckedChange={(v) => setRotRutEnabled(v === true)}
@@ -503,78 +439,51 @@ export function TaxSettingsForm({
name="rot_rut_enabled" name="rot_rut_enabled"
value={rotRutEnabled ? 'true' : 'false'} value={rotRutEnabled ? 'true' : 'false'}
/> />
<div className="space-y-1"> </SettingsRow>
<Label htmlFor="rot_rut_enabled" className="cursor-pointer"> </SettingsGroup>
{t('rot_rut_label')}
</Label>
<p className="text-xs text-muted-foreground">
{t('rot_rut_help')}
</p>
</div>
</div>
</section>
{/* Preliminary tax */} {/* Preliminary tax */}
<section className="border-t border-border pt-8 space-y-4"> <SettingsGroup label={t('preliminary_tax_heading')}>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> <SettingsRow
{t('preliminary_tax_heading')} label={t('preliminary_tax_monthly_label')}
</h2> htmlFor="preliminary_tax_monthly"
help={t('preliminary_tax_monthly_help')}
<div className="max-w-xs space-y-2"> align="baseline"
<Label htmlFor="preliminary_tax_monthly"> >
{t('preliminary_tax_monthly_label')} <SettingsInput
</Label>
<Input
id="preliminary_tax_monthly" id="preliminary_tax_monthly"
name="preliminary_tax_monthly" name="preliminary_tax_monthly"
type="number" type="number"
defaultValue={settings.preliminary_tax_monthly || ''} defaultValue={settings.preliminary_tax_monthly || ''}
className="max-w-32 flex-none tabular-nums"
/> />
<p className="text-xs text-muted-foreground"> </SettingsRow>
{t('preliminary_tax_monthly_help')} </SettingsGroup>
</p>
</div>
</section>
{/* Long-tail deadlines: explicit opt-in only */} {/* Long-tail deadlines: explicit opt-in only */}
<section className="border-t border-border pt-8 space-y-4"> <SettingsGroup label={t('more_deadlines_heading')} help={t('more_deadlines_help')}>
<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>
{vatRegistered && ( {vatRegistered && (
<> <>
<div className="flex items-start space-x-3"> <SettingsRow label={t('oss_label')} htmlFor="oss_enabled" help={t('oss_help')}>
<Checkbox <Switch
id="oss_enabled" id="oss_enabled"
checked={ossEnabled} checked={ossEnabled}
onCheckedChange={(v) => setOssEnabled(v === true)} onCheckedChange={(v) => setOssEnabled(v === true)}
/> />
<input type="hidden" name="oss_enabled" value={ossEnabled ? 'true' : 'false'} /> <input type="hidden" name="oss_enabled" value={ossEnabled ? 'true' : 'false'} />
<div className="space-y-1"> </SettingsRow>
<Label htmlFor="oss_enabled" className="cursor-pointer">{t('oss_label')}</Label>
<p className="text-xs text-muted-foreground">{t('oss_help')}</p>
</div>
</div>
<div className="flex items-start space-x-3"> <SettingsRow label={t('ioss_label')} htmlFor="ioss_enabled" help={t('ioss_help')}>
<Checkbox <Switch
id="ioss_enabled" id="ioss_enabled"
checked={iossEnabled} checked={iossEnabled}
onCheckedChange={(v) => setIossEnabled(v === true)} onCheckedChange={(v) => setIossEnabled(v === true)}
/> />
<input type="hidden" name="ioss_enabled" value={iossEnabled ? 'true' : 'false'} /> <input type="hidden" name="ioss_enabled" value={iossEnabled ? 'true' : 'false'} />
<div className="space-y-1"> </SettingsRow>
<Label htmlFor="ioss_enabled" className="cursor-pointer">{t('ioss_label')}</Label>
<p className="text-xs text-muted-foreground">{t('ioss_help')}</p>
</div>
</div>
<div className="flex items-start space-x-3"> <SettingsRow label={t('intrastat_label')} htmlFor="intrastat_enabled" help={t('intrastat_help')}>
<Checkbox <Switch
id="intrastat_enabled" id="intrastat_enabled"
checked={intrastatEnabled} checked={intrastatEnabled}
onCheckedChange={(v) => setIntrastatEnabled(v === true)} onCheckedChange={(v) => setIntrastatEnabled(v === true)}
@@ -584,18 +493,12 @@ export function TaxSettingsForm({
name="intrastat_enabled" name="intrastat_enabled"
value={intrastatEnabled ? 'true' : 'false'} value={intrastatEnabled ? 'true' : 'false'}
/> />
<div className="space-y-1"> </SettingsRow>
<Label htmlFor="intrastat_enabled" className="cursor-pointer">
{t('intrastat_label')}
</Label>
<p className="text-xs text-muted-foreground">{t('intrastat_help')}</p>
</div>
</div>
</> </>
)} )}
<div className="flex items-start space-x-3"> <SettingsRow label={t('punktskatt_label')} htmlFor="punktskatt_enabled" help={t('punktskatt_help')}>
<Checkbox <Switch
id="punktskatt_enabled" id="punktskatt_enabled"
checked={punktskattEnabled} checked={punktskattEnabled}
onCheckedChange={(v) => setPunktskattEnabled(v === true)} onCheckedChange={(v) => setPunktskattEnabled(v === true)}
@@ -605,16 +508,14 @@ export function TaxSettingsForm({
name="punktskatt_enabled" name="punktskatt_enabled"
value={punktskattEnabled ? 'true' : 'false'} value={punktskattEnabled ? 'true' : 'false'}
/> />
<div className="space-y-1"> </SettingsRow>
<Label htmlFor="punktskatt_enabled" className="cursor-pointer">
{t('punktskatt_label')}
</Label>
<p className="text-xs text-muted-foreground">{t('punktskatt_help')}</p>
</div>
</div>
<div className="flex items-start space-x-3"> <SettingsRow
<Checkbox label={t('fyllnadsinbetalning_label')}
htmlFor="fyllnadsinbetalning_enabled"
help={t('fyllnadsinbetalning_help')}
>
<Switch
id="fyllnadsinbetalning_enabled" id="fyllnadsinbetalning_enabled"
checked={fyllnadEnabled} checked={fyllnadEnabled}
onCheckedChange={(v) => setFyllnadEnabled(v === true)} onCheckedChange={(v) => setFyllnadEnabled(v === true)}
@@ -624,14 +525,8 @@ export function TaxSettingsForm({
name="fyllnadsinbetalning_enabled" name="fyllnadsinbetalning_enabled"
value={fyllnadEnabled ? 'true' : 'false'} value={fyllnadEnabled ? 'true' : 'false'}
/> />
<div className="space-y-1"> </SettingsRow>
<Label htmlFor="fyllnadsinbetalning_enabled" className="cursor-pointer"> </SettingsGroup>
{t('fyllnadsinbetalning_label')}
</Label>
<p className="text-xs text-muted-foreground">{t('fyllnadsinbetalning_help')}</p>
</div>
</div>
</section>
</div> </div>
) )
} }
+25 -37
View File
@@ -2,8 +2,8 @@
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Loader2 } from 'lucide-react'
import { Loader2, Users } from 'lucide-react' import { SettingsGroup } from '@/components/settings/SettingsRows'
interface TeamMember { interface TeamMember {
id: string id: string
@@ -57,41 +57,29 @@ export function TeamPanel() {
} }
return ( return (
<div className="space-y-6"> <SettingsGroup label={teamName || t('team_fallback')}>
<Card> {/* Read-only member roster: flat hairline rows, no cards. */}
<CardHeader> {members.map((member) => (
<CardTitle className="text-base flex items-center gap-2"> <div
<Users className="h-4 w-4" /> key={member.id}
{teamName || t('team_fallback')} className="flex items-center gap-3 border-b border-border px-1 py-3"
</CardTitle> >
</CardHeader> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted/60">
<CardContent> <span className="text-xs font-medium text-muted-foreground">
<div className="divide-y divide-border/40"> {member.email.charAt(0).toUpperCase()}
{members.map((member) => ( </span>
<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>
))}
</div> </div>
</CardContent> <p className="min-w-0 flex-1 truncate text-sm">
</Card> {member.email}
</div> {member.is_current_user && (
<span className="ml-1 text-muted-foreground">{t('you_suffix')}</span>
)}
</p>
<span className="shrink-0 text-xs text-muted-foreground">
{roleLabel(member.role)}
</span>
</div>
))}
</SettingsGroup>
) )
} }
+20 -32
View File
@@ -4,9 +4,8 @@ import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client' import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext' import { useCompany } from '@/contexts/CompanyContext'
import { Label } from '@/components/ui/label' import { Skeleton } from '@/components/ui/skeleton'
import { Skeleton } from "@/components/ui/skeleton" import { SettingsGroup, SettingsRowNote } from '@/components/settings/SettingsRows'
import { Badge } from '@/components/ui/badge'
interface VoucherSeries { interface VoucherSeries {
voucher_series: string voucher_series: string
@@ -18,6 +17,7 @@ interface VoucherSeriesManagerProps {
defaultSeries?: string defaultSeries?: string
} }
/** Read-only list of the series that have actually been used. */
export function VoucherSeriesManager({ defaultSeries }: VoucherSeriesManagerProps) { export function VoucherSeriesManager({ defaultSeries }: VoucherSeriesManagerProps) {
const t = useTranslations('settings_voucher_series') const t = useTranslations('settings_voucher_series')
const { company } = useCompany() const { company } = useCompany()
@@ -48,44 +48,32 @@ export function VoucherSeriesManager({ defaultSeries }: VoucherSeriesManagerProp
const seriesEntries = Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)) const seriesEntries = Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b))
return ( return (
<section className="space-y-4"> <SettingsGroup label={t('heading')} help={t('footnote')}>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('heading')}
</h2>
{isLoading ? ( {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-32" />
<Skeleton className="h-4 w-24" /> <Skeleton className="h-4 w-24" />
</div> </div>
) : seriesEntries.length === 0 ? ( ) : 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' })} {t('empty_state', { series: defaultSeries || 'A' })}
</p> </p>
) : ( ) : (
<div className="space-y-2"> seriesEntries.map(([letter, lastNum]) => (
<Label className="text-xs text-muted-foreground">{t('active_series_label')}</Label> <div key={letter} className="flex items-center gap-3 border-b border-border px-1 py-3">
<div className="divide-y divide-border/8"> <span className="text-sm font-medium tabular-nums">
{seriesEntries.map(([letter, lastNum]) => ( {t('series_prefix')} {letter}
<div key={letter} className="flex items-center justify-between py-2"> </span>
<div className="flex items-center gap-2"> {/* The default marker is a normal state: muted text, not a chip. */}
<span className="text-sm font-medium tabular-nums">{t('series_prefix')} {letter}</span> {letter === (defaultSeries || 'A') && (
{letter === (defaultSeries || 'A') && ( <SettingsRowNote>{t('default_badge')}</SettingsRowNote>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">{t('default_badge')}</Badge> )}
)} <span className="ml-auto shrink-0 text-sm text-muted-foreground tabular-nums">
</div> {t('latest_number')}: {lastNum}
<span className="text-sm text-muted-foreground tabular-nums"> </span>
{t('latest_number')}: {lastNum}
</span>
</div>
))}
</div> </div>
</div> ))
)} )}
</SettingsGroup>
<p className="text-xs text-muted-foreground">
{t('footnote')}
</p>
</section>
) )
} }
@@ -1,17 +1,17 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { Label } from '@/components/ui/label' import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast' 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 { getErrorMessage } from '@/lib/errors/get-error-message'
import type { CompanySettings, JournalEntrySourceType } from '@/types' 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' }, { 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 // Swedish labels. Kept inline so this component is self-contained: these
// labels are bookkeeping-domain terms that intentionally stay Swedish across // labels are bookkeeping-domain terms that intentionally stay Swedish across
// locales (see CLAUDE.md i18n table). // locales (see CLAUDE.md i18n table).
@@ -65,12 +69,16 @@ interface Props {
} }
export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }: 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 { toast } = useToast()
const initialMap = settings.default_voucher_series_per_source_type || {} const initialMap = settings.default_voucher_series_per_source_type || {}
const [draft, setDraft] = useState<Partial<Record<JournalEntrySourceType, string>>>( const [draft, setDraft] = useState<Partial<Record<JournalEntrySourceType, string>>>(
initialMap as Partial<Record<JournalEntrySourceType, string>>, initialMap as Partial<Record<JournalEntrySourceType, string>>,
) )
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [showAll, setShowAll] = useState(false)
const handleChange = (sourceType: JournalEntrySourceType, value: string) => { const handleChange = (sourceType: JournalEntrySourceType, value: string) => {
setDraft((prev) => ({ ...prev, [sourceType]: value })) setDraft((prev) => ({ ...prev, [sourceType]: value }))
@@ -116,54 +124,66 @@ export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }:
} }
} }
return ( const renderRow = (
<section className="space-y-4"> { key, labelKey }: (typeof VISIBLE_SOURCE_TYPES)[number],
<div className="space-y-1"> borderless = false,
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> ) => (
Verifikationsserier per typ <SettingsRow
</h2> key={key}
<p className="text-xs text-muted-foreground"> label={SV_LABELS[labelKey] ?? key}
Tilldela en standardserie per typ av verifikat. Vanlig svensk htmlFor={`series-${key}`}
praxis: leverantörsfakturor på serie B, löner på serie C, övrigt på borderless={borderless}
serie A. Kan alltid ändras per verifikat när du bokför. >
</p> <SettingsSelect
</div> id={`series-${key}`}
value={(draft[key] as string | undefined) || 'A'}
<div className="grid gap-3 sm:grid-cols-2"> onChange={(e) => handleChange(key, e.target.value)}
{VISIBLE_SOURCE_TYPES.map(({ key, labelKey }) => ( className="font-mono"
<div key={key} className="flex items-center justify-between gap-3"> >
<Label {SERIES_OPTIONS.map((letter) => (
htmlFor={`series-${key}`} <option key={letter} value={letter}>
className="text-sm text-foreground flex-1 cursor-pointer" {letter}
> </option>
{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>
))} ))}
</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 <Button
type="button" type="button"
size="sm"
onClick={handleSave} onClick={handleSave}
disabled={!hasChanges || isSaving} disabled={!hasChanges || isSaving}
> >
@@ -171,6 +191,6 @@ export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }:
Spara serier Spara serier
</Button> </Button>
</div> </div>
</section> </SettingsGroup>
) )
} }
@@ -5,15 +5,21 @@ import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl' import { useLocale, useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Sun, Moon, Monitor, LogOut, ExternalLink } from 'lucide-react'
import { Label } from '@/components/ui/label'
import { Sun, Moon, Monitor, LogOut, Languages, ExternalLink } from 'lucide-react'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { createClient } from '@/lib/supabase/client' import { createClient } from '@/lib/supabase/client'
import { SecuritySettings } from '@/components/settings/SecuritySettings' import { SecuritySettings } from '@/components/settings/SecuritySettings'
import { InstallAppSection } from '@/components/settings/InstallAppSection' import { InstallAppSection } from '@/components/settings/InstallAppSection'
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings' import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
import { AccountDangerZone } from '@/components/settings/AccountDangerZone' 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 { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { useSettings } from '@/components/settings/useSettings' import { useSettings } from '@/components/settings/useSettings'
import { clearRecaptIdentity } from '@/lib/recapt' import { clearRecaptIdentity } from '@/lib/recapt'
@@ -31,6 +37,8 @@ export function AccountSettingsContent() {
const activeLocale = useLocale() as Locale const activeLocale = useLocale() as Locale
const tCommon = useTranslations('common') const tCommon = useTranslations('common')
const tSettings = useTranslations('settings') const tSettings = useTranslations('settings')
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const [savingLocale, setSavingLocale] = useState(false) const [savingLocale, setSavingLocale] = useState(false)
const [fullName, setFullName] = useState('') const [fullName, setFullName] = useState('')
const [initialName, setInitialName] = useState('') const [initialName, setInitialName] = useState('')
@@ -115,155 +123,139 @@ export function AccountSettingsContent() {
en: tCommon('language_english'), en: tCommon('language_english'),
} }
const nameUnchanged = !fullName.trim() || fullName.trim() === initialName
return ( return (
<div className="space-y-8"> <div>
{/* Name */} <SettingsSectionHeader title={tNav('account')} intro={tIntro('account')} />
<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>
{/* Appearance */} {/* Profile: name, appearance, language, install-as-app */}
<section className="space-y-4 border-t border-border pt-8"> <SettingsGroup label={tSettings('group_profile')}>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> <SettingsRow
{tSettings('section_appearance')} label={tSettings('name_label')}
</h2> htmlFor="full_name"
{mounted && ( help={tSettings('name_description')}
<div className="flex gap-3"> align="baseline"
{([ >
{ value: 'light', labelKey: 'theme_light', icon: Sun }, <SettingsInput
{ value: 'dark', labelKey: 'theme_dark', icon: Moon }, id="full_name"
{ value: 'system', labelKey: 'theme_system', icon: Monitor }, value={fullName}
] as const).map(({ value, labelKey, icon: Icon }) => ( onChange={(e) => setFullName(e.target.value)}
<button placeholder={tSettings('name_placeholder')}
key={value} disabled={nameLoading || savingName}
type="button" maxLength={100}
onClick={() => setTheme(value)} />
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-3 text-sm font-medium transition-colors ${ <SettingsRowEnd>
theme === value <Button
? 'border-primary bg-primary/5' size="sm"
: 'border-border hover:border-primary/40' onClick={handleSaveName}
}`} disabled={nameLoading || savingName || nameUnchanged}
>
<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'
}`}
> >
<Languages className="h-4 w-4 text-muted-foreground" /> {savingName ? tCommon('saving') : tCommon('save')}
{localeLabels[value]} </Button>
</button> </SettingsRowEnd>
))} </SettingsRow>
</div>
</section>
{/* Install as app: renders nothing when already running installed */} <SettingsRow label={tSettings('section_appearance')}>
<InstallAppSection /> {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 */} <SettingsRow
<div className="border-t border-border pt-8"> label={tSettings('section_language')}
<SecuritySettings /> help={tSettings('language_description')}
</div> >
<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 */} {/* Install as app: renders nothing when already running installed */}
{hasCalendarExtension && ( <InstallAppSection />
<div className="border-t border-border pt-8"> </SettingsGroup>
<CalendarFeedSettings />
</div>
)}
{/* Logout */} {/* Security: BankID, password, 2FA (renders its own group) */}
<section className="space-y-4 border-t border-border pt-8"> <SecuritySettings />
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{tCommon('account_settings')} {/* Calendar feed (extension-gated) */}
</h2> {hasCalendarExtension && <CalendarFeedSettings />}
<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>
{/* Privacy & agreements: surface the otherwise-unlinked DPA + privacy policy */} {/* Privacy & agreements: surface the otherwise-unlinked DPA + privacy policy */}
<section className="space-y-4 border-t border-border pt-8"> <SettingsGroup label={tSettings('legal_title')}>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> <SettingsRow label={tSettings('legal_privacy')}>
{tSettings('legal_title')} <SettingsRowEnd>
</h2> <Button variant="ghost" size="sm" asChild>
<div className="space-y-2"> <Link href="/privacy" target="_blank" rel="noopener noreferrer">
<Link <ExternalLink className="mr-2 h-3.5 w-3.5" />
href="/privacy" {tCommon('open')}
target="_blank" </Link>
rel="noopener noreferrer" </Button>
className="flex items-center justify-between rounded-lg border p-4 transition-colors hover:bg-secondary/60" </SettingsRowEnd>
> </SettingsRow>
<span className="font-medium">{tSettings('legal_privacy')}</span> <SettingsRow label={tSettings('legal_dpa')}>
<ExternalLink className="h-4 w-4 text-muted-foreground" /> <SettingsRowEnd>
</Link> <Button variant="ghost" size="sm" asChild>
<Link <Link href="/dpa" target="_blank" rel="noopener noreferrer">
href="/dpa" <ExternalLink className="mr-2 h-3.5 w-3.5" />
target="_blank" {tCommon('open')}
rel="noopener noreferrer" </Link>
className="flex items-center justify-between rounded-lg border p-4 transition-colors hover:bg-secondary/60" </Button>
> </SettingsRowEnd>
<span className="font-medium">{tSettings('legal_dpa')}</span> </SettingsRow>
<ExternalLink className="h-4 w-4 text-muted-foreground" /> </SettingsGroup>
</Link>
</div> {/* Sign out */}
</section> <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 */} {/* Delete account: only for non-sandbox */}
{!settings?.is_sandbox && <AccountDangerZone />} {!settings?.is_sandbox && <AccountDangerZone />}
@@ -1,11 +1,17 @@
'use client' 'use client'
import { useTranslations } from 'next-intl'
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel' import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
import { OAuthClientsPanel } from '@/components/settings/OAuthClientsPanel' import { OAuthClientsPanel } from '@/components/settings/OAuthClientsPanel'
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
export function ApiSettingsContent() { export function ApiSettingsContent() {
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
return ( return (
<div className="space-y-8"> <div>
<SettingsSectionHeader title={tNav('api')} intro={tIntro('api')} />
<ApiKeysPanel /> <ApiKeysPanel />
<OAuthClientsPanel /> <OAuthClientsPanel />
</div> </div>
@@ -3,18 +3,23 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useSearchParams, useRouter } from 'next/navigation' import { useSearchParams, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl' 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 { Switch } from '@/components/ui/switch'
import { AgentMemoryPanel } from '@/components/settings/AgentMemoryPanel' import { AgentMemoryPanel } from '@/components/settings/AgentMemoryPanel'
import { AgentSkillsPanel } from '@/components/settings/AgentSkillsPanel' import { AgentSkillsPanel } from '@/components/settings/AgentSkillsPanel'
import { AgentKnowledgePanel } from '@/components/agent-knowledge/AgentKnowledgePanel' 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 = // "Assistenten": the ledger profile the agent reads before booking (Kunskap =
// "Vad din agent vet", opens on the konteringskarta and is the default view), // "Vad din agent vet", opens on the konteringskarta and is the default view),
// what the assistant remembers about this company (Minne, editable), and the // what the assistant remembers about this company (Minne, editable), and the
// domain knowledge it ships with (Kompetens, read-only). Tabs keep all three // domain knowledge it ships with (Kompetens, read-only). The segmented control
// one click away instead of stacked. // keeps all three one click away instead of stacked.
type View = 'knowledge' | 'memory' | 'skills' type View = 'knowledge' | 'memory' | 'skills'
const VIEW_ROUTE: Record<View, string> = { const VIEW_ROUTE: Record<View, string> = {
@@ -23,40 +28,43 @@ const VIEW_ROUTE: Record<View, string> = {
skills: '/settings/assistant?view=skills', 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() { export function AssistantSettingsContent() {
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const searchParams = useSearchParams() const searchParams = useSearchParams()
const router = useRouter() const router = useRouter()
const raw = searchParams.get('view') const raw = searchParams.get('view')
const view: View = raw === 'skills' ? 'skills' : raw === 'memory' ? 'memory' : 'knowledge' 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). // '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 ( return (
<div className="space-y-8"> <div>
<Tabs value={view} onValueChange={setView} className="space-y-6"> <SettingsSectionHeader title={tNav('assistant')} intro={tIntro('assistant')} />
<TabsList>
<TabsTrigger value="knowledge">Kunskap</TabsTrigger>
<TabsTrigger value="memory">Minne</TabsTrigger>
<TabsTrigger value="skills">Kompetens</TabsTrigger>
</TabsList>
{/* Radix unmounts the inactive panel, so each panel's data is fetched <div className="mt-6">
lazily the first time its tab is opened. */} <SettingsSeg value={view} onChange={setView} options={VIEW_OPTIONS} aria-label="Välj vy" />
<TabsContent value="knowledge"> </div>
<AgentKnowledgePanel />
</TabsContent>
<TabsContent value="memory">
<AgentMemoryPanel />
</TabsContent>
<TabsContent value="skills">
<AgentSkillsPanel />
</TabsContent>
</Tabs>
<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> </div>
) )
} }
@@ -65,7 +73,7 @@ export function AssistantSettingsContent() {
// lives on user_preferences (server-rendered into the dashboard layout), so // lives on user_preferences (server-rendered into the dashboard layout), so
// a successful save triggers router.refresh() to make the button react // a successful save triggers router.refresh() to make the button react
// immediately instead of on next navigation. // immediately instead of on next navigation.
function FabVisibilityCard() { function FabVisibilityRow() {
const t = useTranslations('settings_assistant') const t = useTranslations('settings_assistant')
const router = useRouter() const router = useRouter()
// null = not yet loaded (switch disabled meanwhile) // null = not yet loaded (switch disabled meanwhile)
@@ -108,19 +116,17 @@ function FabVisibilityCard() {
} }
return ( return (
<Card> <SettingsGroup>
<CardContent className="p-6 flex items-center justify-between gap-4"> <SettingsRow label={t('fab_title')} help={t('fab_description')}>
<div className="space-y-1"> <SettingsRowEnd>
<p className="text-sm font-medium">{t('fab_title')}</p> <Switch
<p className="text-sm text-muted-foreground">{t('fab_description')}</p> checked={hideFab === null ? true : !hideFab}
</div> onCheckedChange={handleToggle}
<Switch disabled={hideFab === null || saving}
checked={hideFab === null ? true : !hideFab} aria-label={t('fab_title')}
onCheckedChange={handleToggle} />
disabled={hideFab === null || saving} </SettingsRowEnd>
aria-label={t('fab_title')} </SettingsRow>
/> </SettingsGroup>
</CardContent>
</Card>
) )
} }
@@ -4,7 +4,6 @@ import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useSearchParams, useRouter } from 'next/navigation' import { useSearchParams, useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { EmptyState } from '@/components/ui/empty-state' import { EmptyState } from '@/components/ui/empty-state'
import { useToast } from '@/components/ui/use-toast' 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 { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip' import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
const BankingPanel = getSettingsPanel('enable-banking') const BankingPanel = getSettingsPanel('enable-banking')
export function BankingSettingsContent() { export function BankingSettingsContent() {
const t = useTranslations('settings_banking') const t = useTranslations('settings_banking')
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const searchParams = useSearchParams() const searchParams = useSearchParams()
const router = useRouter() const router = useRouter()
const { toast } = useToast() const { toast } = useToast()
@@ -74,32 +76,36 @@ export function BankingSettingsContent() {
}, [searchParams, router, toast, t]) }, [searchParams, router, toast, t])
return ( 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 && ( {bankConnectionError && (
<div className="flex items-start gap-3 rounded-lg border border-destructive p-4"> <div role="alert" className="mt-6 flex items-start gap-2 px-1">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" /> <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-attn" />
<div className="flex-1"> <div className="min-w-0 flex-1 space-y-1 text-[12.5px] leading-relaxed">
<p className="text-sm font-medium text-destructive">{bankConnectionError}</p> <p className="text-attn">{bankConnectionError}</p>
{isAccessDenied && failedBankName && ( {isAccessDenied && failedBankName && (
<p className="mt-1 text-sm text-muted-foreground"> <p className="text-muted-foreground">
{t('access_denied_hint', { bankName: failedBankName })} {t('access_denied_hint', { bankName: failedBankName })}
</p> </p>
)} )}
{showHbPoaHint && ( {showHbPoaHint && (
<p className="mt-1 text-sm text-muted-foreground"> <p className="text-muted-foreground">
{t('hb_business_poa_hint')}{' '} {t('hb_business_poa_hint')}{' '}
<a <a
href="https://tilisy.enablebanking.com/guides/SE/Handelsbanken/" href="https://tilisy.enablebanking.com/guides/SE/Handelsbanken/"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="underline hover:text-foreground" className="underline underline-offset-2 hover:text-foreground"
> >
{t('hb_business_poa_link')} {t('hb_business_poa_link')}
</a> </a>
</p> </p>
)} )}
<p className="mt-1 text-sm text-muted-foreground"> <p className="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')} {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> </p>
</div> </div>
<button <button
@@ -109,7 +115,7 @@ export function BankingSettingsContent() {
setIsAccessDenied(false) setIsAccessDenied(false)
setShowHbPoaHint(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')} aria-label={t('dismiss_aria')}
> >
<span className="text-lg leading-none">&times;</span> <span className="text-lg leading-none">&times;</span>
@@ -119,26 +125,28 @@ export function BankingSettingsContent() {
{hasBankingExtension && BankingPanel ? ( {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 /> <BankingPanel />
</> </>
) : ( ) : (
<Card> <div className="pt-8">
<CardContent className="p-0"> <EmptyState
<EmptyState icon={CreditCard}
icon={CreditCard} title={t('not_enabled_title')}
title={t('not_enabled_title')} description={t('not_enabled_description')}
description={t('not_enabled_description')} >
> <Button variant="outline" asChild>
<Button variant="outline" asChild> <Link href="/extensions">
<Link href="/extensions"> <ExternalLink className="mr-2 h-4 w-4" />
<ExternalLink className="mr-2 h-4 w-4" /> {t('go_to_extensions')}
{t('go_to_extensions')} </Link>
</Link> </Button>
</Button> </EmptyState>
</EmptyState> </div>
</CardContent>
</Card>
)} )}
</div> </div>
) )
@@ -1,10 +1,17 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Check, Clock, Minus } from 'lucide-react' import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Check } from 'lucide-react'
import { AttnLine } from '@/components/ui/attn-line'
import { Skeleton } from '@/components/ui/skeleton' 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 { formatDateLong } from '@/lib/utils'
import { BillingActions } from '@/components/settings/BillingActions' import { BillingActions } from '@/components/settings/BillingActions'
@@ -16,15 +23,14 @@ const INCLUDED = [
'E-postutskick av fakturor, påminnelser och lönebesked', 'E-postutskick av fakturor, påminnelser och lönebesked',
] ]
// Free vs paid, shown as a comparison table: what the paid tier adds reads // What stays free forever (freeze-and-retain, nothing is taken away). Shown
// strongest next to what stays free forever (freeze-and-retain, nothing is // as the second column of the flat feature list so the paid tier reads
// taken away). Free rows mirror the old ALWAYS_FREE copy. // strongest right next to it. Mirrors the old ALWAYS_FREE copy.
const FEATURE_MATRIX: { label: string; free: boolean }[] = [ const ALWAYS_FREE = [
{ label: 'Bokföring och rapporter', free: true }, 'Bokföring och rapporter',
{ label: 'Fakturering', free: true }, 'Fakturering',
{ label: 'SIE-export', free: true }, 'SIE-export',
{ label: 'Org.nr-uppslag och momsnummerkontroll', free: true }, 'Org.nr-uppslag och momsnummerkontroll',
...INCLUDED.map((label) => ({ label, free: false })),
] ]
// Mirrors the checkout route's deferred-first-charge condition (Stripe's 48h // Mirrors the checkout route's deferred-first-charge condition (Stripe's 48h
@@ -42,12 +48,30 @@ interface BillingView {
isDemo: boolean 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 * Settings → Abonnemang. Rendered both as the full page (thin wrapper) and
* inside the settings modal (via SETTINGS_SECTIONS), so it's a client component * inside the settings modal (via SETTINGS_SECTIONS), so it's a client component
* that reads its state from GET /api/billing/status. * that reads its state from GET /api/billing/status.
*/ */
export function BillingSettingsContent() { export function BillingSettingsContent() {
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const [view, setView] = useState<BillingView | null>(null) const [view, setView] = useState<BillingView | null>(null)
useEffect(() => { useEffect(() => {
@@ -80,11 +104,16 @@ export function BillingSettingsContent() {
return () => { active = false } return () => { active = false }
}, []) }, [])
const header = <SettingsSectionHeader title={tNav('billing')} intro={tIntro('billing')} />
if (!view) { if (!view) {
return ( return (
<div className="space-y-4"> <div>
<Skeleton className="h-12 w-full" /> {header}
<Skeleton className="h-64 w-full" /> <div className="mt-6 space-y-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
</div>
</div> </div>
) )
} }
@@ -93,42 +122,46 @@ export function BillingSettingsContent() {
// them to creating a real account instead of a pay button that would 403. // them to creating a real account instead of a pay button that would 403.
if (view.isDemo) { if (view.isDemo) {
return ( return (
<Card> <div>
<CardHeader> {header}
<CardTitle className="text-base">Abonnemang</CardTitle> <SettingsGroup label="Ditt abonnemang">
</CardHeader> <SettingsRow label="Status" borderless>
<CardContent className="space-y-4"> <span>Demo</span>
<p className="text-sm text-muted-foreground"> <SettingsRowNote>
Du provkör Accounted i en demo. Skapa ett riktigt konto för att aktivera Du provkör Accounted i en demo. Skapa ett riktigt konto för att aktivera
abonnemang, AI-assistent, bankkoppling och inlämning till Skatteverket. abonnemang, AI-assistent, bankkoppling och inlämning till Skatteverket.
</p> </SettingsRowNote>
<ul className="space-y-2"> </SettingsRow>
</SettingsGroup>
<SettingsGroup label="I abonnemanget">
<ul className="space-y-2 px-1 pt-3">
{INCLUDED.map((item) => ( {INCLUDED.map((item) => (
<li key={item} className="flex items-start gap-2 text-sm"> <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> <span>{item}</span>
</li> </li>
))} ))}
</ul> </ul>
</CardContent> </SettingsGroup>
</Card> </div>
) )
} }
// Paying company → manage view. // Paying company → manage view.
if (view.isPaying) { if (view.isPaying) {
return ( return (
<Card> <div>
<CardHeader> {header}
<CardTitle className="text-base">Abonnemang</CardTitle> <SettingsGroup label="Ditt abonnemang">
</CardHeader> <SettingsRow label="Status" borderless>
<CardContent className="space-y-4"> <span>Aktivt</span>
<p className="text-sm text-muted-foreground"> <SettingsRowNote>Du kan hantera eller avsluta det när som helst.</SettingsRowNote>
Ditt abonnemang är aktivt. Du kan hantera eller avsluta det när som helst. <SettingsRowEnd>
</p> <BillingActions isPaying configured={view.configured} />
<BillingActions isPaying configured={view.configured} /> </SettingsRowEnd>
</CardContent> </SettingsRow>
</Card> </SettingsGroup>
</div>
) )
} }
@@ -136,18 +169,18 @@ export function BillingSettingsContent() {
// confirm instead of re-showing the sell pitch to someone who already paid. // confirm instead of re-showing the sell pitch to someone who already paid.
if (view.paidJustNow) { if (view.paidJustNow) {
return ( return (
<Card> <div>
<CardHeader> {header}
<CardTitle className="text-base">Abonnemang</CardTitle> <SettingsGroup label="Ditt abonnemang">
</CardHeader> <SettingsRow label="Status" borderless>
<CardContent className="space-y-2"> <span className="flex items-start gap-2">
<p 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" />
<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>
<span>Klart! Ditt abonnemang är aktiverat och alla funktioner låses upp inom någon minut.</span> </span>
</p> <SettingsRowNote>Ladda om sidan om du inte ser ändringen.</SettingsRowNote>
<p className="text-sm text-muted-foreground">Ladda om sidan om du inte ser ändringen.</p> </SettingsRow>
</CardContent> </SettingsGroup>
</Card> </div>
) )
} }
@@ -156,84 +189,60 @@ export function BillingSettingsContent() {
const deferredTo = view.chargeDeferred ? trialEndsAt : null const deferredTo = view.chargeDeferred ? trialEndsAt : null
return ( return (
<div className="space-y-6"> <div>
{header}
{/* Consequential trial countdown: one attn-tone sentence, not a banner. */}
{daysLeft !== null && ( {daysLeft !== null && (
<div className="flex items-center gap-3 rounded-lg border border-border bg-secondary px-4 py-3 text-sm"> <AttnLine className="mt-3">
<Clock className="h-4 w-4 shrink-0 text-foreground" /> {daysLeft > 0
<span> ? `Din provperiod löper ut om ${daysLeft} ${daysLeft === 1 ? 'dag' : 'dagar'}${
{daysLeft > 0 trialEndsAt ? ` (${formatDateLong(trialEndsAt)})` : ''
? `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.'
deferredTo : 'Lägg till betalning nu så fortsätter allt utan avbrott.'
? '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>
: 'Din provperiod har löpt ut. Aktivera abonnemanget för att få tillbaka AI, bankkoppling och inlämning.'}
</span>
</div>
)} )}
<Card> <SettingsGroup
<CardHeader> label="Allt du behöver för att sköta bokföringen själv"
<CardTitle>Allt du behöver för att sköta bokföringen själv</CardTitle> help="Ingen bindningstid · Avsluta när du vill · Säker betalning via Stripe"
</CardHeader> >
<CardContent className="space-y-6"> <div className="px-1 pt-3">
<BillingActions isPaying={false} configured={view.configured} firstChargeAt={deferredTo} /> <BillingActions isPaying={false} configured={view.configured} firstChargeAt={deferredTo} />
</div>
</SettingsGroup>
{deferredTo && ( {deferredTo && (
<div className="space-y-3"> <SettingsGroup label="Så funkar det">
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">Så funkar det</h3> <SettingsRow label="Idag">
<ol className="space-y-2 text-sm"> <span>Du lägger till ditt kort. Inget dras nu.</span>
<li className="flex gap-3"> </SettingsRow>
<span className="w-28 shrink-0 text-muted-foreground">Idag</span> <SettingsRow label={<span className="tabular-nums">{formatDateLong(deferredTo)}</span>}>
<span>Du lägger till ditt kort. Inget dras nu.</span> <span>Provperioden slutar och den första debiteringen sker.</span>
</li> </SettingsRow>
<li className="flex gap-3"> <SettingsRow label="När som helst" borderless>
<span className="w-28 shrink-0 text-muted-foreground tabular-nums">{formatDateLong(deferredTo)}</span> <span>
<span>Provperioden slutar och den första debiteringen sker.</span> Avsluta direkt via Stripe. Före {formatDateLong(deferredTo)} kostar det ingenting.
</li> </span>
<li className="flex gap-3"> </SettingsRow>
<span className="w-28 shrink-0 text-muted-foreground">När som helst</span> </SettingsGroup>
<span>Avsluta direkt via Stripe. Före {formatDateLong(deferredTo)} kostar det ingenting.</span> )}
</li>
</ol>
</div>
)}
<Table> <SettingsGroup
<TableHeader> label="Funktioner"
<TableRow> // The 7-year retention reassurance moved behind the "?": long legal
<TableHead className="w-full">Funktion</TableHead> // copy stays out of the page flow.
<TableHead className="text-center">Gratis</TableHead> 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."
<TableHead className="text-center">Abonnemang</TableHead> >
</TableRow> <div className="grid gap-6 px-1 pt-3 sm:grid-cols-2">
</TableHeader> <FeatureList heading="I abonnemanget" items={INCLUDED} />
<TableBody> <FeatureList heading="Alltid gratis" items={ALWAYS_FREE} />
{FEATURE_MATRIX.map((f) => ( </div>
<TableRow key={f.label}> </SettingsGroup>
<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>
</div> </div>
) )
} }
@@ -14,9 +14,14 @@ import { applyDefaultSeriesToMap } from '@/lib/bookkeeping/voucher-series-resolv
import { PeriodiseringAutoDetectToggle } from '@/components/settings/PeriodiseringAutoDetectToggle' import { PeriodiseringAutoDetectToggle } from '@/components/settings/PeriodiseringAutoDetectToggle'
import { DimensionsToggle } from '@/components/settings/DimensionsToggle' import { DimensionsToggle } from '@/components/settings/DimensionsToggle'
import { AccountingFrameworkForm } from '@/components/settings/AccountingFrameworkForm' import { AccountingFrameworkForm } from '@/components/settings/AccountingFrameworkForm'
import {
SettingsGroup,
SettingsRow,
SettingsSectionHeader,
SettingsSelect,
} from '@/components/settings/SettingsRows'
import { useSettings } from '@/components/settings/useSettings' import { useSettings } from '@/components/settings/useSettings'
import { useCompany } from '@/contexts/CompanyContext' import { useCompany } from '@/contexts/CompanyContext'
import { Label } from '@/components/ui/label'
import { ExternalLink } from 'lucide-react' import { ExternalLink } from 'lucide-react'
import type { AccountingFramework, CompanySettings } from '@/types' import type { AccountingFramework, CompanySettings } from '@/types'
@@ -24,6 +29,8 @@ const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
export function BookkeepingSettingsContent() { export function BookkeepingSettingsContent() {
const t = useTranslations('settings_bookkeeping') const t = useTranslations('settings_bookkeeping')
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const { settings, isLoading, updateSettings, refetch } = useSettings() const { settings, isLoading, updateSettings, refetch } = useSettings()
const { company } = useCompany() const { company } = useCompany()
// Local mirror of the company-level accounting_framework so the K2/K3 // 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' const isAktiebolag = company?.entity_type === 'aktiebolag'
return ( return (
<div className="space-y-8"> <div>
{isAktiebolag && ( <SettingsSectionHeader title={tNav('bookkeeping')} intro={tIntro('bookkeeping')} />
<AccountingFrameworkForm
current={framework} <SettingsFormWrapper onSave={handleSave}>
onSaved={(next) => setFramework(next)} {/* 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. */}
<SettingsFormWrapper onSave={handleSave} className="space-y-8"> <SettingsGroup label={t('group_basics')}>
{/* Accounting method */} {isAktiebolag && (
<section className="space-y-4"> <AccountingFrameworkForm
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> current={framework}
{t('method_heading')} onSaved={(next) => setFramework(next)}
</h2> />
<div className="space-y-2"> )}
<Label htmlFor="accounting_method">{t('method_label')}</Label> <SettingsRow
<select label={t('method_label')}
htmlFor="accounting_method"
help={t('method_help')}
>
<SettingsSelect
id="accounting_method" id="accounting_method"
name="accounting_method" name="accounting_method"
defaultValue={settings.accounting_method || 'accrual'} 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="accrual">{t('method_accrual')}</option>
<option value="cash">{t('method_cash')}</option> <option value="cash">{t('method_cash')}</option>
</select> </SettingsSelect>
<p className="text-xs text-muted-foreground"> </SettingsRow>
{t('method_help')}
</p>
</div>
{/* #967: register/send without booking; ekonomi books in a separate {/* #967: register/send without booking; ekonomi books in a separate
explicit step. Only meaningful under faktureringsmetoden. */} explicit step. Only meaningful under faktureringsmetoden. */}
<div className="space-y-2"> <SettingsRow
<Label htmlFor="defer_invoice_booking">{t('defer_booking_label')}</Label> label={t('defer_booking_label')}
<select htmlFor="defer_invoice_booking"
help={t('defer_booking_help')}
>
<SettingsSelect
id="defer_invoice_booking" id="defer_invoice_booking"
name="defer_invoice_booking" name="defer_invoice_booking"
defaultValue={settings.defer_invoice_booking ? 'true' : 'false'} 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="false">{t('defer_booking_off')}</option>
<option value="true">{t('defer_booking_on')}</option> <option value="true">{t('defer_booking_on')}</option>
</select> </SettingsSelect>
<p className="text-xs text-muted-foreground"> </SettingsRow>
{t('defer_booking_help')} <SettingsRow
</p> label={t('series_label')}
</div> htmlFor="default_voucher_series"
</section> 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 */} <PeriodLockingSettings settings={settings} />
<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>
</SettingsFormWrapper> </SettingsFormWrapper>
{/* Fiscal years */} <FiscalYearsManager />
<div className="border-t border-border pt-8">
<FiscalYearsManager />
</div>
{/* Voucher series: per-source-type mapping */} <VoucherSeriesPerSourceTypeForm
<div className="border-t border-border pt-8"> settings={settings}
<VoucherSeriesPerSourceTypeForm onSettingsUpdated={updateSettings}
settings={settings} />
onSettingsUpdated={updateSettings}
/>
</div>
{/* Voucher series: read-only display */} <VoucherSeriesManager defaultSeries={settings.default_voucher_series || 'A'} />
<div className="border-t border-border pt-8">
<VoucherSeriesManager defaultSeries={settings.default_voucher_series || 'A'} />
</div>
{/* Periodisering auto-detect toggle */} <SettingsGroup label={t('group_automation')}>
<div className="border-t border-border pt-8">
<PeriodiseringAutoDetectToggle /> <PeriodiseringAutoDetectToggle />
</div>
{/* Kostnadsställen & projekt (dimensions) toggle */}
<div className="border-t border-border pt-8">
<DimensionsToggle /> <DimensionsToggle />
</div> </SettingsGroup>
{/* Cross-links */} <SettingsGroup>
<div className="border-t border-border pt-8 space-y-3"> <SettingsRow label={t('related_heading')} borderless>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('related_heading')}
</h2>
<div className="flex flex-col gap-2">
<Link <Link
href="/bookkeeping?tab=accounts" 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" /> <ExternalLink className="h-3.5 w-3.5" />
{t('related_chart_of_accounts')} {t('related_chart_of_accounts')}
</Link> </Link>
</div> </SettingsRow>
</div> </SettingsGroup>
</div> </div>
) )
} }
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { CompanyDangerZone } from '@/components/settings/CompanyDangerZone' import { CompanyDangerZone } from '@/components/settings/CompanyDangerZone'
import { CompanyInfoForm } from '@/components/settings/CompanyInfoForm' import { CompanyInfoForm } from '@/components/settings/CompanyInfoForm'
import { CompanyMembersSection } from '@/components/settings/CompanyMembersSection' import { CompanyMembersSection } from '@/components/settings/CompanyMembersSection'
@@ -10,12 +11,15 @@ import { LogoUpload } from '@/components/settings/LogoUpload'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper' import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadError } from '@/components/settings/SettingsLoadError' import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton' import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
import { ShareCapitalForm } from '@/components/settings/ShareCapitalForm' import { ShareCapitalForm } from '@/components/settings/ShareCapitalForm'
import { useSettings } from '@/components/settings/useSettings' import { useSettings } from '@/components/settings/useSettings'
import type { CompanySettings } from '@/types' import type { CompanySettings } from '@/types'
export function CompanySettingsContent() { export function CompanySettingsContent() {
const router = useRouter() const router = useRouter()
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const { settings, isLoading, updateSettings, refetch } = useSettings() const { settings, isLoading, updateSettings, refetch } = useSettings()
if (isLoading) return <SettingsLoadingSkeleton /> if (isLoading) return <SettingsLoadingSkeleton />
@@ -56,8 +60,10 @@ export function CompanySettingsContent() {
} }
return ( return (
<div className="space-y-8"> <div>
<SettingsFormWrapper onSave={handleSave} className="space-y-8"> <SettingsSectionHeader title={tNav('company')} intro={tIntro('company')} />
<SettingsFormWrapper onSave={handleSave}>
<CompanyInfoForm settings={settings} /> <CompanyInfoForm settings={settings} />
{settings.entity_type === 'aktiebolag' && ( {settings.entity_type === 'aktiebolag' && (
<ShareCapitalForm <ShareCapitalForm
@@ -66,16 +72,12 @@ export function CompanySettingsContent() {
)} )}
</SettingsFormWrapper> </SettingsFormWrapper>
<div className="border-t border-border pt-8"> <LogoUpload
<LogoUpload logoUrl={settings.logo_url}
logoUrl={settings.logo_url} onUpdate={(url) => updateSettings({ logo_url: url })}
onUpdate={(url) => updateSettings({ logo_url: url })} />
/>
</div>
<div className="border-t border-border pt-8"> <CompanyMembersSection />
<CompanyMembersSection />
</div>
<FiscalPeriodEditor /> <FiscalPeriodEditor />
@@ -1,5 +1,6 @@
'use client' 'use client'
import { useTranslations } from 'next-intl'
import { InvoiceSettingsForm } from '@/components/settings/InvoiceSettingsForm' import { InvoiceSettingsForm } from '@/components/settings/InvoiceSettingsForm'
import { InvoicePaymentLinkSettings } from '@/components/settings/InvoicePaymentLinkSettings' import { InvoicePaymentLinkSettings } from '@/components/settings/InvoicePaymentLinkSettings'
import { InvoicePaymentAccountsSettings } from '@/components/settings/InvoicePaymentAccountsSettings' import { InvoicePaymentAccountsSettings } from '@/components/settings/InvoicePaymentAccountsSettings'
@@ -10,10 +11,13 @@ import { PdfPrintSettings } from '@/components/settings/PdfPrintSettings'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper' import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadError } from '@/components/settings/SettingsLoadError' import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton' import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
import { useSettings } from '@/components/settings/useSettings' import { useSettings } from '@/components/settings/useSettings'
import type { CompanySettings } from '@/types' import type { CompanySettings } from '@/types'
export function InvoicingSettingsContent() { export function InvoicingSettingsContent() {
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const { settings, isLoading, updateSettings, refetch } = useSettings() const { settings, isLoading, updateSettings, refetch } = useSettings()
if (isLoading) return <SettingsLoadingSkeleton /> if (isLoading) return <SettingsLoadingSkeleton />
@@ -43,36 +47,31 @@ export function InvoicingSettingsContent() {
} }
return ( return (
<div className="space-y-8"> <div>
<div className="flex justify-end"> <SettingsSectionHeader
<InvoicePreviewCard settings={settings} /> title={tNav('invoicing')}
</div> intro={tIntro('invoicing')}
action={<InvoicePreviewCard settings={settings} />}
/>
{/* Owner/admin only: the component gates itself on role. */}
<InvoicePaymentAccountsSettings settings={settings} onUpdate={updateSettings} /> <InvoicePaymentAccountsSettings settings={settings} onUpdate={updateSettings} />
<SettingsFormWrapper onSave={handleSave} className="space-y-8"> <SettingsFormWrapper onSave={handleSave}>
<InvoiceSettingsForm settings={settings} /> <InvoiceSettingsForm settings={settings} />
</SettingsFormWrapper> </SettingsFormWrapper>
{/* Payment link opt-in: saves individually via toggle switch */} {/* Payment link opt-in: saves individually via toggle switch */}
<div className="border-t border-border pt-8"> <InvoicePaymentLinkSettings settings={settings} onUpdate={updateSettings} />
<InvoicePaymentLinkSettings settings={settings} onUpdate={updateSettings} />
</div>
{/* PDF settings: saves individually via toggle switches */} {/* PDF settings: saves individually via toggle switches */}
<div className="border-t border-border pt-8"> <PdfPrintSettings settings={settings} onUpdate={updateSettings} />
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
</div>
{/* Fixed invoice email recipients: explicit save */} {/* Fixed invoice email recipients: explicit save (owner/admin only) */}
<div className="border-t border-border pt-8"> <InvoiceEmailRecipientsSettings settings={settings} onUpdate={updateSettings} />
<InvoiceEmailRecipientsSettings settings={settings} onUpdate={updateSettings} />
</div>
{/* Invoice email texts: autosaves on blur */} {/* Invoice email texts: autosaves on blur */}
<div className="border-t border-border pt-8"> <InvoiceEmailTextsSettings settings={settings} onUpdate={updateSettings} />
<InvoiceEmailTextsSettings settings={settings} onUpdate={updateSettings} />
</div>
</div> </div>
) )
} }
@@ -3,15 +3,19 @@
import Link from 'next/link' import Link from 'next/link'
import { useState } from 'react' import { useState } from 'react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper' import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadError } from '@/components/settings/SettingsLoadError' import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton' import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import {
SettingsGroup,
SettingsInput,
SettingsRow,
SettingsSectionHeader,
SettingsSelect,
} from '@/components/settings/SettingsRows'
import { TaxTableStatus } from '@/components/salary/TaxTableStatus' import { TaxTableStatus } from '@/components/salary/TaxTableStatus'
import { useSettings } from '@/components/settings/useSettings' import { useSettings } from '@/components/settings/useSettings'
import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver' import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
import { AlertTriangle } from 'lucide-react'
import type { CompanySettings } from '@/types' import type { CompanySettings } from '@/types'
const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
@@ -24,11 +28,11 @@ const BANK_LABEL: Record<(typeof BANK_OPTIONS)[number], string> = {
nordea: 'Nordea', 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() { export function SalarySettingsContent() {
const t = useTranslations('settings_salary') 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() const { settings, isLoading, updateSettings, refetch } = useSettings()
// Controlled so the LB sunset note reacts to the selection before save. // Controlled so the LB sunset note reacts to the selection before save.
const [format, setFormat] = useState<'bg_lb' | 'pain001' | null>(null) 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; // The booking engine resolves the series from the per-source-type map;
// salary entries pass run.voucher_series explicitly, seeded from this // 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. // source-type overrides survive.
if (series !== currentSeries) { if (series !== currentSeries) {
updates.default_voucher_series_per_source_type = { updates.default_voucher_series_per_source_type = {
@@ -72,135 +76,108 @@ export function SalarySettingsContent() {
} }
return ( return (
<div className="space-y-8"> <div>
<SettingsFormWrapper onSave={handleSave} className="space-y-8"> <SettingsSectionHeader title={tNav('salary')} intro={tIntro('salary')} />
{/* Payment */}
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('payments_heading')}
</h2>
<div className="space-y-2"> <SettingsFormWrapper onSave={handleSave}>
<Label htmlFor="salary_pay_day">{t('pay_day_label')}</Label> <SettingsGroup label={t('payments_heading')} help={t('info_payroll_scope')}>
<Input <SettingsRow
label={t('pay_day_label')}
htmlFor="salary_pay_day"
help={t('pay_day_help')}
align="baseline"
>
<SettingsInput
id="salary_pay_day" id="salary_pay_day"
name="salary_pay_day" name="salary_pay_day"
type="number" type="number"
inputMode="numeric"
min={1} min={1}
max={28} max={28}
defaultValue={settings.salary_pay_day ?? 25} 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> </SettingsRow>
</div> <SettingsRow
label={t('format_label')}
<div className="space-y-2"> htmlFor="preferred_payment_format"
<Label htmlFor="preferred_payment_format">{t('format_label')}</Label> help={t('format_help')}
<select borderless={effectiveFormat === 'bg_lb'}
>
<SettingsSelect
id="preferred_payment_format" id="preferred_payment_format"
name="preferred_payment_format" name="preferred_payment_format"
value={effectiveFormat} value={effectiveFormat}
onChange={(e) => setFormat(e.target.value as 'bg_lb' | 'pain001')} onChange={(e) => setFormat(e.target.value as 'bg_lb' | 'pain001')}
className={selectClassName}
> >
<option value="pain001">{t('format_pain001')}</option> <option value="pain001">{t('format_pain001')}</option>
<option value="bg_lb">{t('format_bg_lb')}</option> <option value="bg_lb">{t('format_bg_lb')}</option>
</select> </SettingsSelect>
<p className="text-xs text-muted-foreground">{t('format_help')}</p> </SettingsRow>
{effectiveFormat === 'bg_lb' && ( {effectiveFormat === 'bg_lb' && (
<div className="flex items-start gap-2 rounded-md border border-border p-3 text-xs max-w-xl"> <p className="border-b border-border px-1 pb-3 text-[12.5px] leading-relaxed text-attn">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" /> {t('sunset_warning')}
<span className="text-muted-foreground"> </p>
{t('sunset_warning')} )}
</span> <SettingsRow label={t('bank_label')} htmlFor="salary_default_bank" help={t('bank_help')}>
</div> <SettingsSelect
)}
</div>
<div className="space-y-2">
<Label htmlFor="salary_default_bank">{t('bank_label')}</Label>
<select
id="salary_default_bank" id="salary_default_bank"
name="salary_default_bank" name="salary_default_bank"
defaultValue={settings.salary_default_bank ?? 'none'} defaultValue={settings.salary_default_bank ?? 'none'}
className={selectClassName}
> >
<option value="none">{t('bank_none')}</option> <option value="none">{t('bank_none')}</option>
{BANK_OPTIONS.map((key) => ( {BANK_OPTIONS.map((key) => (
<option key={key} value={key}>{BANK_LABEL[key]}</option> <option key={key} value={key}>{BANK_LABEL[key]}</option>
))} ))}
<option value="other">{t('bank_other')}</option> <option value="other">{t('bank_other')}</option>
</select> </SettingsSelect>
<p className="text-xs text-muted-foreground">{t('bank_help')}</p> </SettingsRow>
</div> </SettingsGroup>
</section>
{/* Accounting */} <SettingsGroup label={t('accounting_heading')}>
<div className="border-t border-border pt-8"> <SettingsRow
<section className="space-y-4"> label={t('voucher_series_label')}
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> htmlFor="salary_voucher_series"
{t('accounting_heading')} help={t('voucher_series_help')}
</h2> >
<div className="space-y-2"> <SettingsSelect
<Label htmlFor="salary_voucher_series">{t('voucher_series_label')}</Label> id="salary_voucher_series"
<select name="salary_voucher_series"
id="salary_voucher_series" defaultValue={currentSeries}
name="salary_voucher_series" className="font-mono"
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>
{SERIES_OPTIONS.map((letter) => ( ))}
<option key={letter} value={letter}>{letter}</option> </SettingsSelect>
))} </SettingsRow>
</select> </SettingsGroup>
<p className="text-xs text-muted-foreground">{t('voucher_series_help')}</p>
</div>
</section>
</div>
</SettingsFormWrapper> </SettingsFormWrapper>
{/* Tax tables (read-only status) */} {/* Tax tables: automatic, read-only status. Lives outside the form so
<div className="border-t border-border pt-8"> the recheck action never interacts with the save flow. */}
<section className="space-y-3"> <SettingsGroup
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> label={t('tax_tables_heading')}
{t('tax_tables_heading')} help={t.rich('info_current_year', {
</h2> strong: (chunks) => <strong>{chunks}</strong>,
})}
>
<SettingsRow label={tSalary('th_status')} help={t('tax_tables_help')}>
<TaxTableStatus /> <TaxTableStatus />
<p className="text-xs text-muted-foreground">{t('tax_tables_help')}</p> </SettingsRow>
</section> </SettingsGroup>
</div>
{/* Vacation (informational — the rule is per-employee) */} {/* Vacation is configured per employee; this row only points there. */}
<div className="border-t border-border pt-8"> <SettingsGroup label={t('vacation_heading')}>
<section className="space-y-3"> <SettingsRow label={t('vacation_rule_label')} help={t('vacation_info')}>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"> <Link
{t('vacation_heading')} href="/salary/employees"
</h2> className="text-sm text-muted-foreground underline underline-offset-2 transition-colors duration-150 hover:text-foreground"
<p className="text-sm text-muted-foreground"> >
{t('vacation_info')}{' '} {t('vacation_info_link')}
<Link href="/salary/employees" className="underline underline-offset-2 hover:text-foreground"> </Link>
{t('vacation_info_link')} </SettingsRow>
</Link> </SettingsGroup>
</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>
</div> </div>
) )
} }
@@ -8,6 +8,7 @@ import { TaxAssessmentNoticesPanel } from '@/components/settings/TaxAssessmentNo
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper' import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadError } from '@/components/settings/SettingsLoadError' import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton' import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
import { SkatteverketConnectPanel } from '@/components/settings/SkatteverketConnectPanel' import { SkatteverketConnectPanel } from '@/components/settings/SkatteverketConnectPanel'
import { useSettings } from '@/components/settings/useSettings' import { useSettings } from '@/components/settings/useSettings'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
@@ -17,6 +18,8 @@ import type { CompanySettings } from '@/types'
export function TaxSettingsContent() { export function TaxSettingsContent() {
const { settings, isLoading, updateSettings, refetch } = useSettings() const { settings, isLoading, updateSettings, refetch } = useSettings()
const t = useTranslations('settings_skatteverket') const t = useTranslations('settings_skatteverket')
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const searchParams = useSearchParams() const searchParams = useSearchParams()
const router = useRouter() const router = useRouter()
const { toast } = useToast() const { toast } = useToast()
@@ -125,8 +128,8 @@ export function TaxSettingsContent() {
fiscal_year_start_month: parseInt(formData.get('fiscal_year_start_month') as string) || 1, fiscal_year_start_month: parseInt(formData.get('fiscal_year_start_month') as string) || 1,
pays_salaries: paysSalaries, pays_salaries: paysSalaries,
employer_registered: employerRegistered, employer_registered: employerRegistered,
// The seasonal checkbox is unmounted when not registered; absence // The seasonal switch stays mounted inside its reveal, but the
// means false rather than "keep stored value". // employer_registered gate still forces false when not registered.
employer_seasonal: employerRegistered && formData.get('employer_seasonal') === 'true', employer_seasonal: employerRegistered && formData.get('employer_seasonal') === 'true',
preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null, preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null,
kontrolluppgifter_enabled: formData.get('kontrolluppgifter_enabled') === 'true', kontrolluppgifter_enabled: formData.get('kontrolluppgifter_enabled') === 'true',
@@ -152,9 +155,11 @@ export function TaxSettingsContent() {
const showSkatteverket = hasSkatteverketExtension && !settings.is_sandbox const showSkatteverket = hasSkatteverketExtension && !settings.is_sandbox
return ( return (
<div className="space-y-8"> <div>
<SettingsSectionHeader title={tNav('tax')} intro={tIntro('tax')} />
{/* Connection panel first: the skattekonto and momsdeklaration pages {/* 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. */} form it sat out of view. */}
{showSkatteverket && <SkatteverketConnectPanel />} {showSkatteverket && <SkatteverketConnectPanel />}
@@ -1,11 +1,16 @@
'use client' 'use client'
import { useTranslations } from 'next-intl'
import { BookingTemplatesPanel } from '@/components/settings/BookingTemplatesPanel' import { BookingTemplatesPanel } from '@/components/settings/BookingTemplatesPanel'
import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel' import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel'
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
export function TemplatesSettingsContent() { export function TemplatesSettingsContent() {
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
return ( return (
<div className="space-y-8"> <div>
<SettingsSectionHeader title={tNav('templates')} intro={tIntro('templates')} />
<BookingTemplatesPanel /> <BookingTemplatesPanel />
<CounterpartyTemplatesPanel /> <CounterpartyTemplatesPanel />
</div> </div>
+1
View File
@@ -85,6 +85,7 @@ export function HelpPopover({ children, className }: HelpPopoverProps) {
<div <div
ref={panelRef} ref={panelRef}
role="note" 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" 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 }} style={{ top: pos.top, left: pos.left }}
> >
+1
View File
@@ -64,6 +64,7 @@ Resolution order (last wins): **defaults → env vars → extension override**.
| `RESEND_FROM_EMAIL` | Default `From` address (e.g. `noreply@your-brand.se`); also used as the address you From-spoof through Resend | | `RESEND_FROM_EMAIL` | Default `From` address (e.g. `noreply@your-brand.se`); also used as the address you From-spoof through Resend |
| `RESEND_INBOUND_DOMAIN` | Domain used to compose per-company invoice-inbox addresses: `{local-part}@{RESEND_INBOUND_DOMAIN}` | | `RESEND_INBOUND_DOMAIN` | Domain used to compose per-company invoice-inbox addresses: `{local-part}@{RESEND_INBOUND_DOMAIN}` |
| `RESEND_INBOUND_WEBHOOK_SECRET` | Verifies the `/inbound` webhook signature from Resend | | `RESEND_INBOUND_WEBHOOK_SECRET` | Verifies the `/inbound` webhook signature from Resend |
| `RESEND_DELIVERY_WEBHOOK_SECRET` | Verifies the `/delivery-status` webhook signature from Resend. Optional: without it, invoice delivery history shows "sent" but never the delivery outcome |
## Things you MUST NOT change ## Things you MUST NOT change
@@ -0,0 +1,289 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { WebhookEventPayload } from 'resend'
const verifyMock = vi.fn()
const rpcMock = vi.fn()
vi.mock('resend', () => ({
Resend: class {
webhooks = { verify: verifyMock }
},
}))
vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: () => ({ rpc: rpcMock }),
}))
import { emailExtension } from '@/extensions/general/email'
import {
ResendDeliverySignatureError,
toDeliveryReport,
verifyDeliveryWebhook,
} from '@/extensions/general/email/lib/delivery-webhook'
function baseData(overrides: Record<string, unknown> = {}) {
return {
created_at: '2026-07-24T08:00:00.000Z',
email_id: 'msg-1',
from: 'noreply@example.com',
to: ['customer@example.com'],
subject: 'Faktura F-1001',
...overrides,
}
}
const deliveryRoute = emailExtension.apiRoutes!.find(
(route) => route.path === '/delivery-status',
)!
function webhookRequest(body: unknown = { type: 'email.delivered' }): Request {
return new Request('https://example.test/api/extensions/ext/email/delivery-status', {
method: 'POST',
headers: {
'svix-id': 'msg_1',
'svix-timestamp': '1753344000',
'svix-signature': 'v1,signature',
},
body: JSON.stringify(body),
})
}
describe('toDeliveryReport', () => {
it('maps arrival outcomes to a provider status', () => {
const cases: Array<[string, string]> = [
['email.delivered', 'delivered'],
['email.delivery_delayed', 'delayed'],
['email.complained', 'complained'],
['email.bounced', 'bounced'],
['email.failed', 'failed'],
['email.suppressed', 'suppressed'],
]
for (const [type, expected] of cases) {
const event = {
type,
created_at: '2026-07-24T08:00:00.000Z',
data: baseData({
bounce: { message: 'Mailbox unavailable', subType: 'General', type: 'Permanent' },
failed: { reason: 'Rejected by upstream' },
suppressed: { message: 'On suppression list', type: 'bounce' },
}),
} as unknown as WebhookEventPayload
expect(toDeliveryReport(event)?.status).toBe(expected)
}
})
it('ignores events that say nothing about arrival', () => {
for (const type of ['email.sent', 'email.scheduled', 'email.opened', 'email.clicked']) {
const event = {
type,
created_at: '2026-07-24T08:00:00.000Z',
data: baseData(),
} as unknown as WebhookEventPayload
expect(toDeliveryReport(event)).toBeNull()
}
})
it('keeps the provider reason text for a bounce', () => {
const event = {
type: 'email.bounced',
created_at: '2026-07-24T08:00:00.000Z',
data: baseData({
bounce: {
message: '550 5.1.1 Recipient address rejected',
subType: 'General',
type: 'Permanent',
},
}),
} as unknown as WebhookEventPayload
expect(toDeliveryReport(event)).toEqual({
providerMessageId: 'msg-1',
status: 'bounced',
occurredAt: '2026-07-24T08:00:00.000Z',
detail: '550 5.1.1 Recipient address rejected Permanent/General',
})
})
it('leaves the reason empty for a plain delivery', () => {
const event = {
type: 'email.delivered',
created_at: '2026-07-24T08:00:00.000Z',
data: baseData(),
} as unknown as WebhookEventPayload
expect(toDeliveryReport(event)?.detail).toBeNull()
})
it('falls back to ingestion time when the provider clock is unusable', () => {
const event = {
type: 'email.delivered',
created_at: 'not-a-date',
data: baseData(),
} as unknown as WebhookEventPayload
const report = toDeliveryReport(event)
expect(report).not.toBeNull()
expect(Number.isNaN(new Date(report!.occurredAt).getTime())).toBe(false)
})
it('degrades to no reason when the provider ships an unexpected payload shape', () => {
const event = {
type: 'email.bounced',
created_at: '2026-07-24T08:00:00.000Z',
data: baseData(),
} as unknown as WebhookEventPayload
expect(toDeliveryReport(event)).toEqual({
providerMessageId: 'msg-1',
status: 'bounced',
occurredAt: '2026-07-24T08:00:00.000Z',
detail: null,
})
})
it('drops an event without a provider message id', () => {
const event = {
type: 'email.delivered',
created_at: '2026-07-24T08:00:00.000Z',
data: baseData({ email_id: undefined }),
} as unknown as WebhookEventPayload
expect(toDeliveryReport(event)).toBeNull()
})
})
describe('verifyDeliveryWebhook', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.RESEND_API_KEY = 'test-key'
process.env.RESEND_DELIVERY_WEBHOOK_SECRET = 'whsec_test'
})
it('passes the Svix headers through to the provider verifier', () => {
verifyMock.mockReturnValue({ type: 'email.delivered' })
const headers = new Headers({
'svix-id': 'msg_1',
'svix-timestamp': '1753344000',
'svix-signature': 'v1,signature',
})
verifyDeliveryWebhook('{"type":"email.delivered"}', headers)
expect(verifyMock).toHaveBeenCalledWith({
payload: '{"type":"email.delivered"}',
headers: { id: 'msg_1', timestamp: '1753344000', signature: 'v1,signature' },
webhookSecret: 'whsec_test',
})
})
it('raises a signature error when verification fails', () => {
verifyMock.mockImplementation(() => {
throw new Error('No matching signature found')
})
expect(() => verifyDeliveryWebhook('{}', new Headers())).toThrow(ResendDeliverySignatureError)
})
})
describe('POST /api/extensions/ext/email/delivery-status', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.RESEND_API_KEY = 'test-key'
process.env.RESEND_DELIVERY_WEBHOOK_SECRET = 'whsec_test'
})
it('is unauthenticated: the signature is the credential', () => {
expect(deliveryRoute.skipAuth).toBe(true)
expect(deliveryRoute.method).toBe('POST')
})
it('returns 503 when the webhook secret is not configured', async () => {
delete process.env.RESEND_DELIVERY_WEBHOOK_SECRET
const response = await deliveryRoute.handler(webhookRequest())
expect(response.status).toBe(503)
expect(rpcMock).not.toHaveBeenCalled()
})
it('returns 401 on an invalid signature', async () => {
verifyMock.mockImplementation(() => {
throw new Error('No matching signature found')
})
const response = await deliveryRoute.handler(webhookRequest())
expect(response.status).toBe(401)
expect(rpcMock).not.toHaveBeenCalled()
})
it('applies a verified bounce to the matching delivery', async () => {
verifyMock.mockReturnValue({
type: 'email.bounced',
created_at: '2026-07-24T08:00:00.000Z',
data: baseData({
bounce: { message: 'Mailbox unavailable', subType: 'General', type: 'Permanent' },
}),
})
rpcMock.mockResolvedValue({ data: 'delivery-1', error: null })
const response = await deliveryRoute.handler(webhookRequest())
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data).toEqual({ applied: true })
expect(rpcMock).toHaveBeenCalledWith('apply_invoice_delivery_provider_status', {
p_provider: 'resend',
p_provider_message_id: 'msg-1',
p_status: 'bounced',
p_occurred_at: '2026-07-24T08:00:00.000Z',
p_detail: 'Mailbox unavailable Permanent/General',
})
})
it('acknowledges events that are not about arrival without touching the database', async () => {
verifyMock.mockReturnValue({
type: 'email.opened',
created_at: '2026-07-24T08:00:00.000Z',
data: baseData(),
})
const response = await deliveryRoute.handler(webhookRequest())
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data).toEqual({ applied: false, reason: 'ignored_event' })
expect(rpcMock).not.toHaveBeenCalled()
})
it('acknowledges mail that is not a tracked invoice delivery', async () => {
verifyMock.mockReturnValue({
type: 'email.delivered',
created_at: '2026-07-24T08:00:00.000Z',
data: baseData({ email_id: 'payslip-mail' }),
})
rpcMock.mockResolvedValue({ data: null, error: null })
const response = await deliveryRoute.handler(webhookRequest())
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data).toEqual({ applied: false, reason: 'no_matching_delivery' })
})
it('fails loudly on a database error so the provider retries', async () => {
verifyMock.mockReturnValue({
type: 'email.delivered',
created_at: '2026-07-24T08:00:00.000Z',
data: baseData(),
})
rpcMock.mockResolvedValue({ data: null, error: { message: 'connection reset' } })
const response = await deliveryRoute.handler(webhookRequest())
expect(response.status).toBe(500)
})
})
+74
View File
@@ -1,12 +1,86 @@
import { NextResponse } from 'next/server'
import type { Extension } from '@/lib/extensions/types' import type { Extension } from '@/lib/extensions/types'
import { registerEmailService } from '@/lib/email/service' import { registerEmailService } from '@/lib/email/service'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { createLogger } from '@/lib/logger'
import { ResendEmailService } from './lib/resend-service' import { ResendEmailService } from './lib/resend-service'
import {
ResendDeliverySignatureError,
isDeliveryWebhookConfigured,
toDeliveryReport,
verifyDeliveryWebhook,
} from './lib/delivery-webhook'
// Register the Resend implementation immediately when this extension is loaded // Register the Resend implementation immediately when this extension is loaded
registerEmailService(new ResendEmailService()) registerEmailService(new ResendEmailService())
const log = createLogger('email-delivery-webhook')
export const emailExtension: Extension = { export const emailExtension: Extension = {
id: 'email', id: 'email',
name: 'E-post (Resend)', name: 'E-post (Resend)',
version: '1.0.0', version: '1.0.0',
apiRoutes: [
// ── Resend delivery webhook (Svix-signed, no user auth) ──
// Reports whether a sent invoice email actually arrived. Resend pushes
// every event for the account to this endpoint, including mail that is not
// a tracked invoice delivery: unmatched reports are acknowledged and
// dropped so they are not retried forever.
{
method: 'POST',
path: '/delivery-status',
skipAuth: true,
handler: async (request: Request) => {
if (!isDeliveryWebhookConfigured()) {
log.error('RESEND_DELIVERY_WEBHOOK_SECRET is not configured', undefined)
return NextResponse.json({ error: 'Delivery webhook not configured' }, { status: 503 })
}
const rawBody = await request.text()
let event
try {
event = verifyDeliveryWebhook(rawBody, request.headers)
} catch (err) {
if (err instanceof ResendDeliverySignatureError) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
log.error('delivery webhook verification failed', err)
return NextResponse.json({ error: 'Verification failed' }, { status: 500 })
}
const report = toDeliveryReport(event)
if (!report) {
return NextResponse.json({ data: { applied: false, reason: 'ignored_event' } })
}
const { data, error } = await createServiceClientNoCookies().rpc(
'apply_invoice_delivery_provider_status',
{
p_provider: 'resend',
p_provider_message_id: report.providerMessageId,
p_status: report.status,
p_occurred_at: report.occurredAt,
p_detail: report.detail,
},
)
// A failed apply must not be acknowledged: Svix retries non-2xx with
// backoff, which is exactly the recovery wanted for a transient
// database error.
if (error) {
log.error('failed to apply delivery status', error, { status: report.status })
return NextResponse.json({ error: 'Failed to record delivery status' }, { status: 500 })
}
if (!data) {
return NextResponse.json({ data: { applied: false, reason: 'no_matching_delivery' } })
}
log.info('delivery status applied', { deliveryId: data, status: report.status })
return NextResponse.json({ data: { applied: true } })
},
},
],
} }
@@ -0,0 +1,145 @@
/**
* Resend outbound delivery webhook.
*
* "Accepted by Resend" and "the recipient's server took it" are two different
* facts, and only the first one is known when a send returns. Resend reports
* the second one asynchronously, per message: one report covers every
* recipient on that message, and the reason text names the address that
* failed. This module verifies the signed payload and maps it onto the
* provider status stored on the invoice delivery row.
*/
import { Resend } from 'resend'
import type { WebhookEventPayload } from 'resend'
import type { InvoiceDeliveryProviderStatus } from '@/types'
export class ResendDeliverySignatureError extends Error {
constructor(message: string) {
super(message)
this.name = 'ResendDeliverySignatureError'
}
}
export interface ProviderDeliveryReport {
providerMessageId: string
status: InvoiceDeliveryProviderStatus
occurredAt: string
detail: string | null
}
/**
* Events that say something about whether the message arrived. `email.sent`
* and `email.scheduled` only repeat what the send call already told us, and
* open/click tracking is not enabled: both are ignored on purpose.
*/
const STATUS_BY_EVENT: Record<string, InvoiceDeliveryProviderStatus> = {
'email.delivered': 'delivered',
'email.delivery_delayed': 'delayed',
'email.complained': 'complained',
'email.bounced': 'bounced',
'email.failed': 'failed',
'email.suppressed': 'suppressed',
}
export function isDeliveryWebhookConfigured(): boolean {
return !!process.env.RESEND_DELIVERY_WEBHOOK_SECRET
}
/**
* Verifies the Svix-signed payload against RESEND_DELIVERY_WEBHOOK_SECRET.
* This is a separate Resend endpoint from the inbound document mailbox, so it
* carries its own signing secret.
*/
export function verifyDeliveryWebhook(
rawBody: string,
requestHeaders: Headers,
): WebhookEventPayload {
const secret = process.env.RESEND_DELIVERY_WEBHOOK_SECRET
if (!secret) throw new Error('RESEND_DELIVERY_WEBHOOK_SECRET is required')
const apiKey = process.env.RESEND_API_KEY
if (!apiKey) throw new Error('RESEND_API_KEY is required')
const svixHeaders = {
id: requestHeaders.get('svix-id') ?? '',
timestamp: requestHeaders.get('svix-timestamp') ?? '',
signature: requestHeaders.get('svix-signature') ?? '',
}
try {
return new Resend(apiKey).webhooks.verify({
payload: rawBody,
headers: svixHeaders,
webhookSecret: secret,
})
} catch (err) {
throw new ResendDeliverySignatureError(
err instanceof Error ? err.message : 'Invalid signature',
)
}
}
function text(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null
}
/**
* The reason is read defensively: the payload is external input, and a
* provider that ships a new event shape must degrade to "no reason given"
* rather than throw, which would turn every retry into another failed
* delivery report.
*/
function reasonText(event: WebhookEventPayload): string | null {
const data = event.data as {
bounce?: { message?: unknown; subType?: unknown; type?: unknown }
failed?: { reason?: unknown }
suppressed?: { message?: unknown; type?: unknown }
}
if (event.type === 'email.bounced') {
const classification = [text(data.bounce?.type), text(data.bounce?.subType)]
.filter(Boolean)
.join('/')
return [text(data.bounce?.message), classification || null].filter(Boolean).join(' ') || null
}
if (event.type === 'email.failed') {
return text(data.failed?.reason)
}
if (event.type === 'email.suppressed') {
return [text(data.suppressed?.message), text(data.suppressed?.type)]
.filter(Boolean)
.join(' ') || null
}
return null
}
/**
* Maps a verified event onto a delivery report, or null when the event says
* nothing about arrival. The provider clock wins over ingestion time: webhooks
* can be retried hours later, and the status timestamp must stay the moment
* the outcome actually happened.
*/
export function toDeliveryReport(event: WebhookEventPayload): ProviderDeliveryReport | null {
const status = STATUS_BY_EVENT[event.type]
if (!status) return null
const data = event.data as { email_id?: string }
if (!data.email_id) return null
const occurredAt = parseTimestamp(event.created_at)
return {
providerMessageId: data.email_id,
status,
occurredAt,
detail: reasonText(event),
}
}
function parseTimestamp(value: unknown): string {
if (typeof value === 'string') {
const parsed = new Date(value)
if (!Number.isNaN(parsed.getTime())) return parsed.toISOString()
}
return new Date().toISOString()
}
+1 -1
View File
@@ -5,7 +5,7 @@
"entryPoint": "@/extensions/general/email", "entryPoint": "@/extensions/general/email",
"workspace": null, "workspace": null,
"requiredEnvVars": ["RESEND_API_KEY", "RESEND_FROM_EMAIL"], "requiredEnvVars": ["RESEND_API_KEY", "RESEND_FROM_EMAIL"],
"optionalEnvVars": [], "optionalEnvVars": ["RESEND_DELIVERY_WEBHOOK_SECRET"],
"npmDependencies": ["resend"], "npmDependencies": ["resend"],
"definition": { "definition": {
"name": "E-post (Resend)", "name": "E-post (Resend)",
@@ -10,21 +10,10 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { formatDate } from '@/lib/utils' import { cn, formatDate } from '@/lib/utils'
import { getDaysUntilExpiry, isConsentExpiringSoon } from '../lib/api-client' import { getDaysUntilExpiry, isConsentExpiringSoon } from '../lib/api-client'
import Link from 'next/link' import Link from 'next/link'
import { import { ChevronDown, Loader2 } from 'lucide-react'
CreditCard,
AlertTriangle,
RefreshCw,
Settings,
Trash2,
Loader2,
CheckCircle,
ChevronDown,
XCircle,
Upload,
} from 'lucide-react'
import type { BankConnection } from '@/types' import type { BankConnection } from '@/types'
interface BankConnectionStatusProps { interface BankConnectionStatusProps {
@@ -36,6 +25,13 @@ interface BankConnectionStatusProps {
isSyncing?: boolean isSyncing?: boolean
} }
/**
* One bank connection as a flat hairline row (Fönster settings language):
* bank name + state on one line with quiet actions on the right, live
* warnings as compact warning-tone lines underneath, and the accounts as an
* indented sub-list. Normal state (Aktiv) is muted text; a Badge appears
* only when the row deviates (expired/error/pending).
*/
export function BankConnectionStatus({ export function BankConnectionStatus({
connection, connection,
onSync, onSync,
@@ -47,48 +43,19 @@ export function BankConnectionStatus({
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires) const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires)
const isExpiring = isConsentExpiringSoon(connection.consent_expires) const isExpiring = isConsentExpiringSoon(connection.consent_expires)
type StatusEntry = { type StatusEntry =
icon: typeof CheckCircle | { kind: 'text'; label: string }
color: string | { kind: 'badge'; label: string; variant: 'warning' | 'destructive' | 'secondary' }
label: string
variant: 'success' | 'warning' | 'destructive' | 'secondary'
}
const statusConfig: Record<string, StatusEntry> = { const statusConfig: Record<string, StatusEntry> = {
active: { active: { kind: 'text', label: 'Aktiv' },
icon: CheckCircle, pending: { kind: 'badge', label: 'Väntar', variant: 'warning' },
color: 'text-success', expired: { kind: 'badge', label: 'Utgånget samtycke', variant: 'warning' },
label: 'Aktiv', error: { kind: 'badge', label: 'Fel', variant: 'destructive' },
variant: 'success', revoked: { kind: 'badge', label: 'Bortkopplad', variant: 'secondary' },
},
pending: {
icon: Loader2,
color: 'text-warning',
label: 'Väntar',
variant: 'warning',
},
expired: {
icon: AlertTriangle,
color: 'text-warning',
label: 'Utgånget samtycke',
variant: 'warning',
},
error: {
icon: XCircle,
color: 'text-destructive',
label: 'Fel',
variant: 'destructive',
},
revoked: {
icon: XCircle,
color: 'text-gray-600',
label: 'Bortkopplad',
variant: 'secondary',
},
} }
const status = statusConfig[connection.status] || statusConfig.error const status = statusConfig[connection.status] || statusConfig.error
const StatusIcon = status.icon
// Parse accounts from connection // Parse accounts from connection
const accounts = (connection.accounts_data as Array<{ const accounts = (connection.accounts_data as Array<{
@@ -118,32 +85,32 @@ export function BankConnectionStatus({
const errorMessage = connection.error_message ?? '' const errorMessage = connection.error_message ?? ''
return ( return (
<div className="border rounded-lg p-4 space-y-4"> <div className="border-b border-border px-1 py-3">
{/* Header */} {/* Main line: identity + state left, quiet actions right */}
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<div className="flex items-center gap-4"> <span className="text-sm font-medium">{connection.bank_name}</span>
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center"> {status.kind === 'badge' ? (
<CreditCard className="h-5 w-5 text-primary" /> <Badge variant={status.variant}>{status.label}</Badge>
</div> ) : (
<div> <span className="text-xs text-muted-foreground">{status.label}</span>
<p className="font-medium">{connection.bank_name}</p> )}
<div className="flex items-center gap-2 text-sm text-muted-foreground"> {connection.last_synced_at && (
<StatusIcon className={`h-3 w-3 ${status.color}`} /> <span className="text-xs text-muted-foreground tabular-nums">
<span>{status.label}</span> Synkad {formatDate(connection.last_synced_at)}
{connection.last_synced_at && ( </span>
<> )}
<span>-</span> {/* Consent renewal date as quiet metadata; the expired state already
<span>Synkad {formatDate(connection.last_synced_at)}</span> carries its own warning line below. */}
</> {connection.consent_expires && !isConnectionExpired && (
)} <span className="text-xs text-muted-foreground tabular-nums">
</div> Samtycke till {formatDate(connection.consent_expires)}
</div> </span>
</div> )}
<div className="flex items-center gap-2"> <span className="ml-auto flex shrink-0 flex-wrap items-center gap-1">
{(isConnectionExpired || isConnectionError) && onReconnect && ( {(isConnectionExpired || isConnectionError) && onReconnect && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5"> <Button variant="ghost" size="sm" className="gap-1 text-muted-foreground hover:text-foreground">
Förnya anslutning Förnya anslutning
<ChevronDown className="h-3.5 w-3.5" /> <ChevronDown className="h-3.5 w-3.5" />
</Button> </Button>
@@ -170,98 +137,81 @@ export function BankConnectionStatus({
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => onSync(connection.id)} onClick={() => onSync(connection.id)}
disabled={isSyncing} disabled={isSyncing}
> >
{isSyncing ? ( {isSyncing ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
<Loader2 className="h-4 w-4 animate-spin" /> Försök igen
) : (
<>
<RefreshCw className="h-4 w-4 mr-1" />
Försök igen
</>
)}
</Button> </Button>
)} )}
{connection.status === 'active' && ( {connection.status === 'active' && (
<Button <Button
variant="outline" variant="ghost"
size="sm" size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => onSync(connection.id)} onClick={() => onSync(connection.id)}
disabled={isSyncing} disabled={isSyncing}
> >
{isSyncing ? ( {isSyncing ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
<Loader2 className="h-4 w-4 animate-spin" /> Synka
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button> </Button>
)} )}
{onManageAccounts && ( {onManageAccounts && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => onManageAccounts(connection.id)} onClick={() => onManageAccounts(connection.id)}
title="Hantera konton"
> >
<Settings className="h-4 w-4" /> Välj konton
</Button> </Button>
)} )}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-muted-foreground hover:text-destructive"
onClick={() => onDisconnect(connection.id)} onClick={() => onDisconnect(connection.id)}
> >
<Trash2 className="h-4 w-4 text-destructive" /> Koppla från
</Button> </Button>
</div> </span>
</div> </div>
{/* Error message */} {/* Error message: live warning, compact warning-tone lines */}
{isConnectionError && errorMessage && ( {isConnectionError && errorMessage && (
<> <>
<div className="flex items-center gap-2 p-3 bg-destructive/10 rounded-lg"> <p className="mt-1 text-[12.5px] leading-relaxed text-attn">{errorMessage}</p>
<XCircle className="h-4 w-4 text-destructive flex-shrink-0" /> <p className="mt-1 text-xs text-muted-foreground">
<span className="text-sm text-destructive"> Du kan också{' '}
{errorMessage} <Link href="/import?mode=bank" className="underline underline-offset-2 hover:text-foreground">
</span> importera transaktioner via bankfil
</div> </Link>
<div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg border border-border"> </p>
<Upload className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-muted-foreground">
Du kan också <Link href="/import?mode=bank" className="underline hover:text-foreground">importera transaktioner via bankfil</Link>
</span>
</div>
</> </>
)} )}
{/* Expired consent notice */} {/* Expired consent notice */}
{isConnectionExpired && ( {isConnectionExpired && (
<> <>
<div className="flex items-center gap-2 p-3 bg-warning/10 rounded-lg"> <p className="mt-1 text-[12.5px] leading-relaxed text-attn">
<AlertTriangle className="h-4 w-4 text-warning flex-shrink-0" /> PSD2-samtycket har löpt ut. Förnya anslutningen för att återuppta synkroniseringen.
<span className="text-sm"> </p>
PSD2-samtycket har löpt ut. Förnya anslutningen för att återuppta synkroniseringen. <p className="mt-1 text-xs text-muted-foreground">
</span> Medan du väntar kan du{' '}
</div> <Link href="/import?mode=bank" className="underline underline-offset-2 hover:text-foreground">
<div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg border border-border"> importera transaktioner via bankfil
<Upload className="h-4 w-4 text-muted-foreground flex-shrink-0" /> </Link>
<span className="text-sm text-muted-foreground"> </p>
Medan du väntar kan du <Link href="/import?mode=bank" className="underline hover:text-foreground">importera transaktioner via bankfil</Link>
</span>
</div>
</> </>
)} )}
{/* Consent expiry warning (for active connections) */} {/* Consent expiry warning (for active connections) */}
{!isConnectionExpired && isExpiring && daysUntilExpiry !== null && ( {!isConnectionExpired && isExpiring && daysUntilExpiry !== null && (
<div className="flex items-center gap-2 p-3 bg-warning/10 rounded-lg"> <p className="mt-1 text-[12.5px] leading-relaxed text-attn">
<AlertTriangle className="h-4 w-4 text-warning" /> Samtycket går ut om {daysUntilExpiry} {daysUntilExpiry === 1 ? 'dag' : 'dagar'}.
<span className="text-sm"> Förnya genom att ansluta igen.
Samtycket går ut om {daysUntilExpiry} {daysUntilExpiry === 1 ? 'dag' : 'dagar'}. </p>
Förnya genom att ansluta igen.
</span>
</div>
)} )}
{/* Initial backfill summary: shows what the bank actually returned vs what we asked for. */} {/* Initial backfill summary: shows what the bank actually returned vs what we asked for. */}
@@ -278,7 +228,7 @@ export function BankConnectionStatus({
truncated = (minTime - requestedTime) > 7 * 24 * 60 * 60 * 1000 truncated = (minTime - requestedTime) > 7 * 24 * 60 * 60 * 1000
} }
return ( return (
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span> <span>
Initial historik:{' '} Initial historik:{' '}
<span className="tabular-nums"> <span className="tabular-nums">
@@ -295,59 +245,58 @@ export function BankConnectionStatus({
) )
})()} })()}
{/* Accounts list */} {/* Accounts: indented flat sub-list instead of boxed rows */}
{accounts.length > 0 && ( {accounts.length > 0 && (
<div className="space-y-2"> <div className="ml-3 mt-3 border-l border-border pl-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm font-medium text-muted-foreground">Konton</p> <p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Konton
</p>
<p className="text-xs text-muted-foreground tabular-nums"> <p className="text-xs text-muted-foreground tabular-nums">
{enabledCount} av {accounts.length} synkas {enabledCount} av {accounts.length} synkas
</p> </p>
</div> </div>
<div className="space-y-2"> {accounts.map((account) => {
{accounts.map((account) => { const isDisabled = account.enabled === false
const isDisabled = account.enabled === false return (
return ( <div
<div key={account.uid}
key={account.uid} className={cn(
className={`flex items-center justify-between p-3 rounded-lg ${isDisabled ? 'bg-muted/20 opacity-60' : 'bg-muted/50'}`} 'flex flex-wrap items-center gap-x-3 gap-y-1 py-2',
> isDisabled && 'opacity-60',
<div> )}
<div className="flex items-center gap-2"> >
<p className="text-sm font-medium"> <span className="text-sm">
{account.name || account.iban || 'Okänt konto'} {account.name || account.iban || 'Okänt konto'}
</p> </span>
{isDisabled && ( {isDisabled && (
<span className="text-[10px] uppercase tracking-wide text-muted-foreground border border-border rounded px-1.5 py-0.5"> <Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground">
Synkas ej Synkas ej
</span> </Badge>
)} )}
</div> {account.iban && (
{account.iban && ( <span className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground"> {account.iban.replace(/(.{4})/g, '$1 ').trim()}
{account.iban.replace(/(.{4})/g, '$1 ').trim()} </span>
</p> )}
{account.balance !== undefined && (
<span className="ml-auto inline-flex shrink-0 items-baseline gap-2">
{account.balance_updated_at && (
<span className="text-[10px] text-muted-foreground">
{formatBalanceAge(account.balance_updated_at)}
</span>
)} )}
</div> <span className="text-sm tabular-nums">
{account.balance !== undefined && ( {new Intl.NumberFormat('sv-SE', {
<div className="text-right"> style: 'currency',
<p className="text-sm font-medium tabular-nums"> currency: account.currency,
{new Intl.NumberFormat('sv-SE', { }).format(account.balance)}
style: 'currency', </span>
currency: account.currency, </span>
}).format(account.balance)} )}
</p> </div>
{account.balance_updated_at && ( )
<p className="text-[10px] text-muted-foreground"> })}
{formatBalanceAge(account.balance_updated_at)}
</p>
)}
</div>
)}
</div>
)
})}
</div>
</div> </div>
)} )}
</div> </div>
@@ -3,17 +3,16 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { AlertTriangle, CheckCircle, Loader2, Upload } from 'lucide-react' import { CheckCircle, Loader2, Upload } from 'lucide-react'
import { cn } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client' import { createClient } from '@/lib/supabase/client'
import { notifyBankSyncUpdated } from '@/lib/transactions/bank-sync-signal' import { notifyBankSyncUpdated } from '@/lib/transactions/bank-sync-signal'
import { useCompany, useCapability } from '@/contexts/CompanyContext' import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys' import { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote' import { UpgradeNote } from '@/components/billing/UpgradeNote'
import { SettingsGroup, SettingsRow, SettingsSeg } from '@/components/settings/SettingsRows'
import { BankSelector, type Bank } from './BankSelector' import { BankSelector, type Bank } from './BankSelector'
import { BankConnectionStatus } from './BankConnectionStatus' import { BankConnectionStatus } from './BankConnectionStatus'
import { AccountPickerDialog } from './AccountPickerDialog' import { AccountPickerDialog } from './AccountPickerDialog'
@@ -431,23 +430,21 @@ export default function BankingSettingsPanel() {
// keeps the already-loaded connections visible instead of wiping them. // keeps the already-loaded connections visible instead of wiping them.
if (loadError && bankConnections.length === 0) { if (loadError && bankConnections.length === 0) {
return ( return (
<Card className="border-destructive/30"> <div className="px-1 pt-8">
<CardHeader> <p className="text-sm font-medium">Kunde inte ladda bankanslutningar</p>
<CardTitle>Kunde inte ladda bankanslutningar</CardTitle> <p className="mt-1 max-w-[56ch] text-xs leading-relaxed text-muted-foreground">
<CardDescription> Något gick fel när dina bankanslutningar skulle hämtas. Dina anslutningar
Något gick fel när dina bankanslutningar skulle hämtas. Dina anslutningar och transaktioner är oförändrade.
och transaktioner är oförändrade. </p>
</CardDescription> <div className="mt-3 flex flex-wrap items-center gap-3">
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-3">
<Button variant="outline" size="sm" onClick={() => fetchConnections()}> <Button variant="outline" size="sm" onClick={() => fetchConnections()}>
Försök igen Försök igen
</Button> </Button>
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" asChild>
<Link href="/import?mode=bank">Importera bankfil istället</Link> <Link href="/import?mode=bank">Importera bankfil istället</Link>
</Button> </Button>
</CardContent> </div>
</Card> </div>
) )
} }
@@ -463,7 +460,7 @@ export default function BankingSettingsPanel() {
: [] : []
return ( return (
<div className="space-y-6"> <div>
<DestructiveConfirmDialog {...dialogProps} /> <DestructiveConfirmDialog {...dialogProps} />
{pickerConnection && ( {pickerConnection && (
@@ -480,194 +477,146 @@ export default function BankingSettingsPanel() {
/> />
)} )}
{/* Persistent CSV fallback after connection/sync failure */} {/* Persistent CSV fallback after connection/sync failure: a live hint,
kept visible as a compact line instead of a boxed strip. */}
{showCsvFallback && ( {showCsvFallback && (
<div className="flex items-center gap-3 rounded-lg border border-border bg-muted/50 p-4"> <div className="flex items-start gap-2 px-1 pt-6">
<Upload className="h-5 w-5 shrink-0 text-muted-foreground" /> <Upload className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<p className="flex-1 text-sm text-muted-foreground"> <p className="text-[12.5px] leading-relaxed text-muted-foreground">
Har du problem med bankanslutningen? Du kan importera transaktioner manuellt via bankfil. Har du problem med bankanslutningen? Du kan{' '}
<Link href="/import?mode=bank" className="underline underline-offset-2 hover:text-foreground">
importera transaktioner manuellt via bankfil
</Link>
.
</p> </p>
<Button variant="outline" size="sm" asChild>
<Link href="/import?mode=bank">Importera bankfil</Link>
</Button>
</div> </div>
)} )}
{/* Pending account selection: new connections waiting for the user to pick accounts */} {/* Pending account selection: new connections waiting for the user to pick accounts */}
{pendingSelectionConnections.length > 0 && ( {pendingSelectionConnections.length > 0 && (
<Card className="border-warning/30"> <SettingsGroup
<CardHeader> label="Välj konton att synka"
<CardTitle>Välj konton att synka</CardTitle> help="Banken har gett åtkomst till flera konton. Välj vilka du vill synka innan några transaktioner hämtas."
<CardDescription> >
Banken har gett åtkomst till flera konton. Välj vilka du vill synka innan några transaktioner hämtas. {pendingSelectionConnections.map((connection) => {
</CardDescription> const accountsList = (connection.accounts_data as StoredAccount[] | null) || []
</CardHeader> return (
<CardContent className="space-y-3"> <div
{pendingSelectionConnections.map((connection) => { key={connection.id}
const accountsList = (connection.accounts_data as StoredAccount[] | null) || [] className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-border px-1 py-3"
return ( >
<div <span className="text-sm font-medium">{connection.bank_name}</span>
key={connection.id} <span className="text-xs text-muted-foreground">
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 p-4" {accountsList.length} konton tillgängliga: inga transaktioner synkas ännu
> </span>
<div className="flex items-center gap-3"> <span className="ml-auto flex shrink-0 items-center gap-2">
<AlertTriangle className="h-5 w-5 shrink-0 text-warning" /> <Button size="sm" onClick={() => setPickerConnectionId(connection.id)}>
<div> Välj konton
<p className="font-medium">{connection.bank_name}</p> </Button>
<p className="text-sm text-muted-foreground"> <Button
{accountsList.length} konton tillgängliga: inga transaktioner synkas ännu variant="ghost"
</p> size="sm"
</div> className="text-muted-foreground hover:text-foreground"
</div> onClick={() => handleDisconnectBank(connection.id)}
<div className="flex items-center gap-2"> >
<Button Avbryt
size="sm" </Button>
onClick={() => setPickerConnectionId(connection.id)} </span>
> </div>
Välj konton )
</Button> })}
<Button </SettingsGroup>
variant="ghost"
size="sm"
onClick={() => handleDisconnectBank(connection.id)}
>
Avbryt
</Button>
</div>
</div>
)
})}
</CardContent>
</Card>
)} )}
{/* Action required: expired/error connections */} {/* Action required: expired/error connections */}
{actionRequiredConnections.length > 0 && ( {actionRequiredConnections.length > 0 && (
<Card className="border-warning/30"> <SettingsGroup label="Åtgärd krävs" help="Dessa anslutningar behöver uppmärksamhet.">
<CardHeader> {actionRequiredConnections.map((connection) => (
<CardTitle>Åtgärd krävs</CardTitle> <BankConnectionStatus
<CardDescription> key={connection.id}
Dessa anslutningar behöver uppmärksamhet. connection={connection}
</CardDescription> onSync={handleSyncTransactions}
</CardHeader> onDisconnect={handleDisconnectBank}
<CardContent className="space-y-4"> onReconnect={handleReconnect}
{actionRequiredConnections.map((connection) => ( onManageAccounts={() => setPickerConnectionId(connection.id)}
<BankConnectionStatus isSyncing={syncingConnectionId === connection.id}
key={connection.id} />
connection={connection} ))}
onSync={handleSyncTransactions} </SettingsGroup>
onDisconnect={handleDisconnectBank}
onReconnect={handleReconnect}
onManageAccounts={() => setPickerConnectionId(connection.id)}
isSyncing={syncingConnectionId === connection.id}
/>
))}
</CardContent>
</Card>
)} )}
{/* Connected banks */} {/* Connected banks */}
{activeConnections.length > 0 && ( {activeConnections.length > 0 && (
<Card> <SettingsGroup label="Anslutna banker">
<CardHeader> {activeConnections.map((connection) => (
<CardTitle>Anslutna banker</CardTitle> <BankConnectionStatus
</CardHeader> key={connection.id}
<CardContent className="space-y-4"> connection={connection}
{activeConnections.map((connection) => ( onSync={handleSyncTransactions}
<BankConnectionStatus onDisconnect={handleDisconnectBank}
key={connection.id} onManageAccounts={() => setPickerConnectionId(connection.id)}
connection={connection} isSyncing={syncingConnectionId === connection.id}
onSync={handleSyncTransactions} />
onDisconnect={handleDisconnectBank} ))}
onManageAccounts={() => setPickerConnectionId(connection.id)} </SettingsGroup>
isSyncing={syncingConnectionId === connection.id}
/>
))}
</CardContent>
</Card>
)} )}
{/* Connect new bank. Non-payers keep seeing the card (conversion {/* Connect new bank. Non-payers keep seeing the group (conversion
surface) but the bank list is replaced by an upgrade note: the surface) but the bank list is replaced by an upgrade note: the
server gate would 403 the connect anyway. */} server gate would 403 the connect anyway. The former "Om
<Card> bankintegration (PSD2)" card lives on as group-level help. */}
<CardHeader> <SettingsGroup
<CardTitle>Anslut ny bank</CardTitle> label="Anslut ny bank"
<CardDescription> help={
Välj din bank nedan för att koppla ditt konto via PSD2. <div className="space-y-2">
</CardDescription> <p>Välj din bank nedan för att koppla ditt konto via PSD2.</p>
</CardHeader> <p className="font-medium">Om bankintegration (PSD2)</p>
<p>
Automatisk import av transaktioner via PSD2 open banking.
Samtycket gäller i 90 dagar och behöver sedan förnyas.
</p>
<p>
Vi använder säker bankintegration (PSD2). Vi kan endast läsa transaktioner,
aldrig flytta pengar. Du kan också importera transaktioner manuellt via
bankfiler på importsidan.
</p>
</div>
}
>
{!hasBankSync ? ( {!hasBankSync ? (
<CardContent> <div className="px-1 pt-3">
<UpgradeNote> <UpgradeNote>
Automatisk banksynk kräver ett abonnemang. Du kan fortfarande importera Automatisk banksynk kräver ett abonnemang. Du kan fortfarande importera
transaktioner manuellt via bankfiler på importsidan. transaktioner manuellt via bankfiler på importsidan.
</UpgradeNote> </UpgradeNote>
</CardContent>
) : (
<CardContent className="space-y-4">
{/* Account type selector */}
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">Kontotyp:</span>
<div className="inline-flex rounded-lg border border-border p-0.5">
<button
type="button"
onClick={() => setPsuType('business')}
className={cn(
'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
psuType === 'business'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
Företagskonto
</button>
<button
type="button"
onClick={() => setPsuType('personal')}
className={cn(
'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
psuType === 'personal'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
Privatkonto
</button>
</div>
</div> </div>
{psuType === 'personal' && ( ) : (
<p className="text-xs text-muted-foreground"> <>
Välj Privatkonto om du använder ditt personliga bankkonto för din verksamhet (vanligt för enskild firma). <SettingsRow
</p> label="Kontotyp"
)} help="Välj Privatkonto om du använder ditt personliga bankkonto för din verksamhet (vanligt för enskild firma)."
<BankSelector >
onConnect={(bank) => handleConnectBank(bank, psuType)} <SettingsSeg
onPsuTypeDetected={setPsuType} value={psuType}
isConnecting={isConnecting} onChange={setPsuType}
connectingBankName={connectingBankName} aria-label="Kontotyp"
/> options={[
</CardContent> { value: 'business', label: 'Företagskonto' },
{ value: 'personal', label: 'Privatkonto' },
]}
/>
</SettingsRow>
<div className="px-1 pt-4">
<BankSelector
onConnect={(bank) => handleConnectBank(bank, psuType)}
onPsuTypeDetected={setPsuType}
isConnecting={isConnecting}
connectingBankName={connectingBankName}
/>
</div>
</>
)} )}
</Card> </SettingsGroup>
{/* Info about PSD2 */}
<Card>
<CardHeader>
<CardTitle>Om bankintegration (PSD2)</CardTitle>
<CardDescription>
Automatisk import av transaktioner via PSD2 open banking.
Samtycket gäller i 90 dagar och behöver sedan förnyas.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Vi använder säker bankintegration (PSD2). Vi kan endast läsa transaktioner,
aldrig flytta pengar. Du kan också importera transaktioner manuellt via
bankfiler på importsidan.
</p>
</CardContent>
</Card>
</div> </div>
) )
} }
@@ -10,20 +10,11 @@ import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { useFormat } from '@/lib/hooks/use-format' import { useFormat } from '@/lib/hooks/use-format'
import { formatCurrency, formatDate } from '@/lib/utils'
import { CreditCard, Link2, Loader2, RefreshCw, Unlink } from 'lucide-react' import { CreditCard, Link2, Loader2, RefreshCw, Unlink } from 'lucide-react'
import type { StripeReviewEvent, StripeStatusResponse } from '../types' import type { StripeStatusResponse } from '../types'
type ConnectionInfo = NonNullable<StripeStatusResponse['connection']> type ConnectionInfo = NonNullable<StripeStatusResponse['connection']>
const KNOWN_REVIEW_REASONS = new Set([
'invoice_not_found',
'invoice_already_paid',
'amount_mismatch',
'currency_mismatch',
'non_sek_invoice',
])
const STATUS_VARIANT: Record<ConnectionInfo['status'], 'success' | 'secondary' | 'destructive' | 'warning'> = { const STATUS_VARIANT: Record<ConnectionInfo['status'], 'success' | 'secondary' | 'destructive' | 'warning'> = {
active: 'success', active: 'success',
pending: 'secondary', pending: 'secondary',
@@ -46,8 +37,6 @@ export default function StripeSettingsPanel() {
const [confirmDisconnect, setConfirmDisconnect] = useState(false) const [confirmDisconnect, setConfirmDisconnect] = useState(false)
const [syncing, setSyncing] = useState(false) const [syncing, setSyncing] = useState(false)
const [togglingTransactionSync, setTogglingTransactionSync] = useState(false) const [togglingTransactionSync, setTogglingTransactionSync] = useState(false)
const [needsReviewCount, setNeedsReviewCount] = useState(0)
const [needsReview, setNeedsReview] = useState<StripeReviewEvent[]>([])
const loadStatus = useCallback(async () => { const loadStatus = useCallback(async () => {
try { try {
@@ -56,8 +45,6 @@ export default function StripeSettingsPanel() {
const data = (await res.json()) as StripeStatusResponse const data = (await res.json()) as StripeStatusResponse
setConfigured(data.configured) setConfigured(data.configured)
setConnection(data.connection) setConnection(data.connection)
setNeedsReviewCount(data.needs_review_count ?? 0)
setNeedsReview(data.needs_review ?? [])
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -128,9 +115,7 @@ export default function StripeSettingsPanel() {
body: JSON.stringify({}), body: JSON.stringify({}),
}) })
const data = (await res.json().catch(() => ({}))) as { const data = (await res.json().catch(() => ({}))) as {
settled?: number transactions?: { fetched?: number; imported?: number; linked?: number }
needsReview?: number
transactions?: { imported?: number; linked?: number }
error?: string error?: string
} }
if (!res.ok) { if (!res.ok) {
@@ -141,18 +126,20 @@ export default function StripeSettingsPanel() {
}) })
return return
} }
const paymentsLine = t('sync_done_description', { // Honest summary: report what Stripe actually returned. An all-zero
settled: data.settled ?? 0, // run is a real answer ("the account had nothing in the window"), not
review: data.needsReview ?? 0, // a silent success.
}) const fetched = data.transactions?.fetched ?? 0
toast({ toast({
title: t('sync_done_title'), title: t('sync_done_title'),
description: data.transactions description:
? `${paymentsLine} ${t('sync_done_transactions', { fetched === 0
imported: data.transactions.imported ?? 0, ? t('sync_done_empty')
linked: data.transactions.linked ?? 0, : t('sync_done_feed', {
})}` fetched,
: paymentsLine, imported: data.transactions?.imported ?? 0,
linked: data.transactions?.linked ?? 0,
}),
}) })
await loadStatus() await loadStatus()
} finally { } finally {
@@ -362,40 +349,6 @@ export default function StripeSettingsPanel() {
</div> </div>
)} )}
{isActive && needsReviewCount > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('needs_review_title')}
</h2>
<Badge variant="warning">{needsReviewCount}</Badge>
</div>
<p className="text-sm text-muted-foreground">{t('needs_review_hint')}</p>
<ul className="divide-y divide-border rounded-lg border border-border">
{needsReview.map((event) => (
<li key={event.id} className="flex items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0">
<p className="text-sm">
{event.reason && KNOWN_REVIEW_REASONS.has(event.reason)
? t(`reason_${event.reason}`)
: event.reason || t('reason_unknown')}
</p>
{event.event_created_at && (
<p className="text-xs text-muted-foreground tabular-nums">
{formatDate(event.event_created_at)}
</p>
)}
</div>
{event.amount != null && (
<span className="shrink-0 text-sm tabular-nums">
{formatCurrency(event.amount, event.currency ?? 'SEK')}
</span>
)}
</li>
))}
</ul>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
) )
+14 -38
View File
@@ -15,7 +15,6 @@ import {
handleCreditNoteCreated, handleCreditNoteCreated,
handleInvoicePaid, handleInvoicePaid,
} from './lib/payment-links' } from './lib/payment-links'
import { syncStripeConnection } from './lib/sync'
import { syncStripeBalanceTransactions } from './lib/transaction-sync' import { syncStripeBalanceTransactions } from './lib/transaction-sync'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys' import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import type { StripeConnection, StripeStatusResponse } from './types' import type { StripeConnection, StripeStatusResponse } from './types'
@@ -38,9 +37,11 @@ const NOT_CONFIGURED_MESSAGE =
* Stripe Connect extension * Stripe Connect extension
* *
* Connects a company's Stripe account via Connect OAuth (Standard accounts). * Connects a company's Stripe account via Connect OAuth (Standard accounts).
* Auto-creates a Stripe Payment Link when an invoice is sent, marks invoices * Auto-creates a Stripe Payment Link when an invoice is sent, and imports the
* paid from Stripe checkout events, and books payouts (gross/fees/net) against * account's balance transactions into the transactions inbox as a bank feed
* the 1686 clearing account. * for the Stripe balance (1686). Feed-only by decision 2026-07-24: nothing is
* auto-booked; the event/settlement sync (lib/sync.ts, lib/payouts.ts) is
* retained in the repo but not wired to any cron or route.
* *
* Required environment variables: * Required environment variables:
* - STRIPE_SECRET_KEY (the platform account key, shared with billing) * - STRIPE_SECRET_KEY (the platform account key, shared with billing)
@@ -97,35 +98,9 @@ export const stripeExtension: Extension = {
const connection = const connection =
rows?.find((r) => r.status === 'active') ?? rows?.[0] ?? null rows?.find((r) => r.status === 'active') ?? rows?.[0] ?? null
// Events + payouts the deterministic matcher refused to auto-apply.
// Members can read both ledgers under RLS; the panel lists them for
// manual handling.
const { data: reviewRows, count: reviewCount } = await supabase
.from('stripe_payment_events')
.select('id, reason, amount, currency, invoice_id, event_created_at', {
count: 'exact',
})
.eq('company_id', ctx.companyId)
.eq('status', 'needs_review')
.order('event_created_at', { ascending: false })
.limit(5)
const { data: payoutRows, count: payoutCount } = await supabase
.from('stripe_payouts')
.select('id, reason, amount, currency, event_created_at', { count: 'exact' })
.eq('company_id', ctx.companyId)
.eq('status', 'needs_review')
.order('event_created_at', { ascending: false })
.limit(5)
const payload: StripeStatusResponse = { const payload: StripeStatusResponse = {
configured: isStripeConnectConfigured(), configured: isStripeConnectConfigured(),
connection, connection,
needs_review_count: (reviewCount ?? 0) + (payoutCount ?? 0),
needs_review: [
...(reviewRows ?? []),
...(payoutRows ?? []).map((p) => ({ ...p, invoice_id: null })),
],
} }
return NextResponse.json(payload) return NextResponse.json(payload)
}, },
@@ -175,14 +150,15 @@ export const stripeExtension: Extension = {
try { try {
const serviceClient = createServiceClientNoCookies() const serviceClient = createServiceClientNoCookies()
const typedConnection = connection as StripeConnection const typedConnection = connection as StripeConnection
const summary = await syncStripeConnection(serviceClient, typedConnection) // Feed-only (2026-07-24): Stripe sync imports balance transactions
// The manual button covers both feeds: when the balance-transaction // into the inbox, nothing more. The event/settlement sync
// feed is enabled, "Synka nu" also pulls it (same module as the // (syncStripeConnection in lib/sync.ts) stays in the repo but is
// nightly cron, no separate rate limit needed: one user action). // deliberately not called: booking is a user decision in the inbox,
const transactions = typedConnection.transaction_sync_enabled // like any bank feed. The manual button ignores
? await syncStripeBalanceTransactions(serviceClient, typedConnection) // transaction_sync_enabled (that flag gates the nightly cron):
: undefined // pressing it IS the opt-in.
return NextResponse.json({ success: true, ...summary, transactions }) const transactions = await syncStripeBalanceTransactions(serviceClient, typedConnection)
return NextResponse.json({ success: true, transactions })
} catch (error) { } catch (error) {
log.error('[stripe] Manual sync failed', { log.error('[stripe] Manual sync failed', {
message: error instanceof Error ? error.message : String(error), message: error instanceof Error ? error.message : String(error),
-12
View File
@@ -21,16 +21,6 @@ export interface StripeConnection {
updated_at: string updated_at: string
} }
/** Row shape of public.stripe_payment_events (needs_review projection). */
export interface StripeReviewEvent {
id: string
reason: string | null
amount: number | null
currency: string | null
invoice_id: string | null
event_created_at: string | null
}
/** Status payload returned by GET /api/extensions/ext/stripe/status. */ /** Status payload returned by GET /api/extensions/ext/stripe/status. */
export interface StripeStatusResponse { export interface StripeStatusResponse {
configured: boolean configured: boolean
@@ -47,6 +37,4 @@ export interface StripeStatusResponse {
| 'transaction_sync_enabled' | 'transaction_sync_enabled'
| 'last_balance_txn_synced_at' | 'last_balance_txn_synced_at'
> | null > | null
needs_review_count?: number
needs_review?: StripeReviewEvent[]
} }
+9
View File
@@ -4,6 +4,15 @@ export const DEFAULT_LOCALE: Locale = 'sv'
export const LOCALE_COOKIE = 'gnubok-locale' export const LOCALE_COOKIE = 'gnubok-locale'
/**
* Every timestamp in the app is a Swedish business event, so it is formatted
* in Swedish wall-clock time in both locales. Without this, formatting falls
* back to the runtime time zone: UTC on the server, the visitor's own zone in
* the browser, which renders a 14:05 send as 12:05 and disagrees across the
* hydration boundary.
*/
export const APP_TIME_ZONE = 'Europe/Stockholm'
export function isLocale(value: unknown): value is Locale { export function isLocale(value: unknown): value is Locale {
return typeof value === 'string' && (SUPPORTED_LOCALES as readonly string[]).includes(value) return typeof value === 'string' && (SUPPORTED_LOCALES as readonly string[]).includes(value)
} }
+2 -2
View File
@@ -1,6 +1,6 @@
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { getRequestConfig } from 'next-intl/server' import { getRequestConfig } from 'next-intl/server'
import { DEFAULT_LOCALE, LOCALE_COOKIE, isLocale, type Locale } from './config' import { APP_TIME_ZONE, DEFAULT_LOCALE, LOCALE_COOKIE, isLocale, type Locale } from './config'
export default getRequestConfig(async () => { export default getRequestConfig(async () => {
const cookieStore = await cookies() const cookieStore = await cookies()
@@ -9,5 +9,5 @@ export default getRequestConfig(async () => {
const messages = (await import(`../messages/${locale}.json`)).default const messages = (await import(`../messages/${locale}.json`)).default
return { locale, messages } return { locale, messages, timeZone: APP_TIME_ZONE }
}) })
+4 -2
View File
@@ -1839,8 +1839,10 @@ const ARTICLE: Record<string, StructuredErrorEntry> = {
}, },
ARTICLE_IN_USE: { ARTICLE_IN_USE: {
httpStatus: 409, httpStatus: 409,
message_sv: 'Artikeln har använts på en faktura och kan därför inte tas bort.', message_sv:
message_en: 'The article has been used on an invoice and cannot be deleted.', 'Artikeln har använts på en faktura och kan därför inte tas bort. Inaktivera den i stället om du inte vill kunna välja den på nya fakturor.',
message_en:
'The article has been used on an invoice and cannot be deleted. Deactivate it instead if you no longer want it selectable on new invoices.',
}, },
ARTICLE_REVENUE_ACCOUNT_INVALID: { ARTICLE_REVENUE_ACCOUNT_INVALID: {
httpStatus: 400, httpStatus: 400,
@@ -114,6 +114,30 @@ async function insertPendingEmailDelivery(params: {
return deliveryId return deliveryId
} }
/**
* A delivery that has already been handed to the provider: this is the only
* state a provider delivery report can attach to, and the provider message id
* is the key the report is matched on.
*/
async function insertSentEmailDelivery(params: {
userId: string
companyId: string
invoiceId: string
documentId: string
retentionExpiresAt?: string
}): Promise<{ deliveryId: string; providerMessageId: string }> {
const deliveryId = await insertPendingEmailDelivery(params)
const providerMessageId = `provider-${randomUUID()}`
await getPool().query(
`UPDATE public.invoice_deliveries
SET status = 'sent', provider = 'resend',
provider_message_id = $2, sent_at = now()
WHERE id = $1`,
[deliveryId, providerMessageId],
)
return { deliveryId, providerMessageId }
}
describe('invoice_deliveries.pg: immutable delivery evidence', () => { describe('invoice_deliveries.pg: immutable delivery evidence', () => {
it('allows only a pending to terminal transition and then locks the row', async () => { it('allows only a pending to terminal transition and then locks the row', async () => {
const { userId, companyId } = await seedCompany() const { userId, companyId } = await seedCompany()
@@ -727,3 +751,257 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
]) ])
}) })
}) })
describe('invoice_deliveries.pg: provider delivery outcome', () => {
it('records the provider outcome on a sent email and keeps the rest locked', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const { deliveryId, providerMessageId } = await insertSentEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
})
await withServiceRoleContext(userId, async (client) => {
const applied = await client.query<{ id: string | null }>(
`SELECT public.apply_invoice_delivery_provider_status(
'resend', $1, 'bounced', '2026-07-24T08:00:00Z'::timestamptz, $2
)::text AS id`,
[providerMessageId, '550 5.1.1 <customer@example.com>: Recipient address rejected'],
)
expect(applied.rows[0].id).toBe(deliveryId)
const row = await client.query<{
provider_status: string
provider_status_detail: string
body_text: string
subject: string
}>(
`SELECT provider_status, provider_status_detail, body_text, subject
FROM public.invoice_deliveries
WHERE id = $1`,
[deliveryId],
)
expect(row.rows[0]).toMatchObject({
provider_status: 'bounced',
provider_status_detail: '550 5.1.1 <customer@example.com>: Recipient address rejected',
body_text: 'Exact plain text',
subject: 'Faktura F-1001',
})
})
})
it('never downgrades an observed failure on a late or repeated report', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const { deliveryId, providerMessageId } = await insertSentEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
})
await withServiceRoleContext(userId, async (client) => {
const apply = (status: string, occurredAt: string) =>
client.query(
`SELECT public.apply_invoice_delivery_provider_status(
'resend', $1, $2, $3::timestamptz, NULL
)`,
[providerMessageId, status, occurredAt],
)
const currentStatus = async () => {
const row = await client.query<{ provider_status: string }>(
`SELECT provider_status FROM public.invoice_deliveries WHERE id = $1`,
[deliveryId],
)
return row.rows[0].provider_status
}
await apply('delayed', '2026-07-24T08:00:00Z')
expect(await currentStatus()).toBe('delayed')
await apply('delivered', '2026-07-24T08:01:00Z')
expect(await currentStatus()).toBe('delivered')
await apply('bounced', '2026-07-24T08:02:00Z')
expect(await currentStatus()).toBe('bounced')
// Retried and out-of-order events must not undo the bounce.
await apply('delayed', '2026-07-24T08:03:00Z')
await apply('delivered', '2026-07-24T08:04:00Z')
expect(await currentStatus()).toBe('bounced')
})
})
it('ignores reports for messages that are not tracked invoice deliveries', async () => {
const { userId } = await seedCompany()
await withServiceRoleContext(userId, async (client) => {
const applied = await client.query<{ id: string | null }>(
`SELECT public.apply_invoice_delivery_provider_status(
'resend', 'payslip-message-id', 'delivered', now(), NULL
)::text AS id`,
)
expect(applied.rows[0].id).toBeNull()
})
})
it('rejects an unsupported outcome and non-service callers', async () => {
const { userId, companyId } = await seedCompany()
const memberId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
await expect(
withServiceRoleContext(userId, (client) => client.query(
`SELECT public.apply_invoice_delivery_provider_status(
'resend', 'msg-1', 'opened', now(), NULL
)`,
)),
).rejects.toThrow(/unsupported invoice delivery provider status/i)
await expect(
withUserContext(memberId, (client) => client.query(
`SELECT public.apply_invoice_delivery_provider_status(
'resend', 'msg-1', 'delivered', now(), NULL
)`,
)),
).rejects.toThrow(/permission denied/i)
})
it('blocks a provider outcome from smuggling in other changes', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const { deliveryId } = await insertSentEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
})
await expect(
getPool().query(
`UPDATE public.invoice_deliveries
SET provider_status = 'delivered', provider_status_at = now(),
subject = 'tampered'
WHERE id = $1`,
[deliveryId],
),
).rejects.toThrow(/terminal invoice delivery.*immutable/i)
await getPool().query(
`UPDATE public.invoice_deliveries
SET provider_status = 'bounced', provider_status_at = now()
WHERE id = $1`,
[deliveryId],
)
await expect(
getPool().query(
`UPDATE public.invoice_deliveries
SET provider_status = NULL, provider_status_at = NULL,
provider_status_detail = NULL
WHERE id = $1`,
[deliveryId],
),
).rejects.toThrow(/terminal invoice delivery.*immutable/i)
})
it('refuses a provider outcome before the send is terminal', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const deliveryId = await insertPendingEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
})
await expect(
getPool().query(
`UPDATE public.invoice_deliveries
SET status = 'sent', sent_at = now(),
provider_status = 'delivered', provider_status_at = now()
WHERE id = $1`,
[deliveryId],
),
).rejects.toThrow(/invoice delivery payload is immutable/i)
})
it('redacts the provider reason text with the rest of the expired PII', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const { deliveryId } = await insertSentEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
retentionExpiresAt: '2000-01-01',
})
await getPool().query(
`UPDATE public.invoice_deliveries
SET provider_status = 'bounced', provider_status_at = now(),
provider_status_detail = '550 5.1.1 <customer@example.com> rejected'
WHERE id = $1`,
[deliveryId],
)
await getPool().query(`SELECT public.redact_expired_invoice_delivery_pii()`)
const delivery = await getPool().query<{
provider_status: string
provider_status_detail: string | null
pii_redacted_at: string | null
}>(
`SELECT provider_status, provider_status_detail, pii_redacted_at
FROM public.invoice_deliveries
WHERE id = $1`,
[deliveryId],
)
expect(delivery.rows[0].provider_status).toBe('bounced')
expect(delivery.rows[0].provider_status_detail).toBeNull()
expect(delivery.rows[0].pii_redacted_at).toBeTruthy()
})
it('masks recipient addresses quoted in the reason text of a summary', async () => {
const { userId, companyId } = await seedCompany()
const memberId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const { deliveryId } = await insertSentEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
})
await getPool().query(
`UPDATE public.invoice_deliveries
SET provider_status = 'bounced', provider_status_at = now(),
provider_status_detail = '550 5.1.1 <customer@example.com> unknown'
WHERE id = $1`,
[deliveryId],
)
const summary = await withUserContext(memberId, (client) => client.query<{
id: string
provider_status: string
provider_status_detail: string
}>(
`SELECT id::text, provider_status, provider_status_detail
FROM public.list_invoice_delivery_summaries($1, $2)`,
[companyId, invoiceId],
))
expect(summary.rows[0]).toEqual({
id: deliveryId,
provider_status: 'bounced',
provider_status_detail: '550 5.1.1 <***@example.com> unknown',
})
})
})
@@ -0,0 +1,127 @@
/**
* Where the company logo lands in the invoice header.
*
* The logo box is always drawn at the full 240x80pt reserved area (any logo
* larger than that gets clamped to it), so the image itself is positioned
* *inside* that box by objectFit/objectPosition. With the default centering,
* a near-square logo scaled down to fit 80pt of height ends up indented by
* half the leftover width, which reads as "the logo is not aligned with the
* left margin" while a wide banner logo looks fine. The template therefore
* anchors the image top-left, so every aspect ratio starts at the margin.
*
* This test renders the real PDF and reads the image placement matrix out of
* the content stream, so it fails if the anchoring regresses.
*/
import { inflateSync } from 'node:zlib'
import { describe, expect, it } from 'vitest'
import React from 'react'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { makeCompanySettings, makeCustomer, makeInvoice } from '@/tests/helpers'
import type { InvoiceItem } from '@/types'
// The page uses a 40pt left margin; a left-anchored logo starts exactly there.
const PAGE_MARGIN_PT = 40
async function makeLogoDataUrl(width: number, height: number): Promise<string> {
const { default: sharp } = await import('sharp')
const png = await sharp({
create: { width, height, channels: 3, background: { r: 20, g: 80, b: 160 } },
})
.png()
.toBuffer()
return `data:image/png;base64,${png.toString('base64')}`
}
/**
* Pull the placement of the first drawn image out of a rendered PDF.
*
* pdfkit emits `<w> 0 0 <-h> <x> <y> cm` followed by `/<label> Do` for every
* image, where x is relative to the enclosing translations. The logo box sits
* at the page margin via a plain `1 0 0 1 <tx> <ty> cm`, so the absolute left
* edge of the drawn image is that translation plus the matrix offset.
*/
function firstImagePlacement(pdf: Buffer): { x: number; width: number } {
const raw = pdf.toString('latin1')
const streams: string[] = []
const re = /stream\r?\n/g
let match: RegExpExecArray | null
while ((match = re.exec(raw)) !== null) {
const start = match.index + match[0].length
const end = raw.indexOf('endstream', start)
if (end === -1) continue
const bytes = Buffer.from(raw.slice(start, end), 'latin1')
try {
streams.push(inflateSync(bytes).toString('latin1'))
} catch {
streams.push(bytes.toString('latin1'))
}
}
const placement = /(-?[\d.]+) 0 0 (-?[\d.]+) (-?[\d.]+) (-?[\d.]+) cm\s*\/\w+ Do/
for (const stream of streams) {
const hit = placement.exec(stream)
if (!hit) continue
let translated = 0
const translate = /1 0 0 1 (-?[\d.]+) (-?[\d.]+) cm/g
let step: RegExpExecArray | null
while ((step = translate.exec(stream)) !== null && step.index < hit.index) {
translated += Number(step[1])
}
return { width: Number(hit[1]), x: translated + Number(hit[3]) }
}
throw new Error('no image draw found in the rendered PDF')
}
async function renderWithLogo(logoWidth: number, logoHeight: number): Promise<Buffer> {
const company = makeCompanySettings({
logo_url: await makeLogoDataUrl(logoWidth, logoHeight),
invoice_show_logo: true,
})
const invoice = makeInvoice({ status: 'sent', invoice_number: '2026-0001' })
const items: InvoiceItem[] = [
{
id: 'item-1',
invoice_id: invoice.id,
sort_order: 0,
line_type: 'product',
description: 'Consulting',
quantity: 1,
unit: 'st',
unit_price: 1000,
line_total: 1000,
vat_rate: 25,
vat_amount: 250,
created_at: '2026-01-15T00:00:00Z',
},
]
return renderToBuffer(
React.createElement(InvoicePDF, {
invoice,
customer: makeCustomer(),
items,
company,
}),
)
}
describe('invoice PDF logo placement', () => {
it('starts a wide banner logo at the left margin', async () => {
const placement = firstImagePlacement(await renderWithLogo(600, 160))
expect(placement.x).toBeCloseTo(PAGE_MARGIN_PT, 1)
}, 30_000)
it('starts a near-square logo at the left margin too', async () => {
// Scaled to the 80pt height cap this logo is only ~117pt wide, so it used
// to be centred in the 240pt box and printed ~60pt in from the margin.
const placement = firstImagePlacement(await renderWithLogo(1500, 1024))
expect(placement.width).toBeLessThan(200)
expect(placement.x).toBeCloseTo(PAGE_MARGIN_PT, 1)
}, 30_000)
})
+10
View File
@@ -754,6 +754,16 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
marginBottom: 6, marginBottom: 6,
alignSelf: 'flex-start', alignSelf: 'flex-start',
objectFit: 'contain', objectFit: 'contain',
// Any logo bigger than the reserved area is clamped to the
// full 240x80pt box, so the box never hugs the image and the
// image is placed *inside* it. Anchor it top-left: with the
// default centering, a near-square logo scaled down to the
// 80pt height cap is only ~117pt wide and gets pushed ~60pt
// in from the left margin, while a wide banner logo fills the
// width and looks correctly aligned. Left-anchoring makes
// every aspect ratio start at the margin instead, so a
// company doesn't have to reshape its logo to fit the layout.
objectPosition: 'left top',
}} }}
/> />
)} )}
+40 -15
View File
@@ -298,6 +298,19 @@
"terms_and": "and", "terms_and": "and",
"privacy_link": "privacy policy" "privacy_link": "privacy policy"
}, },
"settings_intro": {
"account": "Your personal details and security. Applies to you, not the company.",
"billing": "Each company has its own subscription.",
"company": "Shown on invoices, in email and in files to the authorities.",
"bookkeeping": "Framework, method and series. Most of this is set once.",
"tax": "Drives the VAT return, the employer declaration and which deadlines are tracked.",
"salary": "Defaults for payroll runs. Tax table and vacation are set per employee.",
"invoicing": "Numbers, terms and the look of your invoices.",
"templates": "Reusable postings and patterns learned from your bookkeeping.",
"banking": "Transactions are fetched automatically. Accounted can only read, never move money.",
"assistant": "What the assistant knows, remembers and can do.",
"api": "Keys for MCP clients like Claude and Cursor, and for your own integrations."
},
"settings_nav": { "settings_nav": {
"aria_label": "Settings", "aria_label": "Settings",
"company": "Company", "company": "Company",
@@ -325,7 +338,7 @@
}, },
"settings_payments": { "settings_payments": {
"title": "Stripe", "title": "Stripe",
"description": "Connect your company Stripe account and a payment link is created automatically when you send an invoice. Payments are matched to the right invoice and payouts are booked with fees.", "description": "Connect your company Stripe account and your Stripe transactions are imported into the transactions inbox, like a bank feed for your Stripe balance. A payment link is also created automatically when you send an invoice.",
"not_configured": "The Stripe integration is not configured on this installation. Contact your administrator.", "not_configured": "The Stripe integration is not configured on this installation. Contact your administrator.",
"coming_soon_title": "Stripe payments coming soon", "coming_soon_title": "Stripe payments coming soon",
"coming_soon_description": "Connect your company Stripe account and a payment link is created automatically when you send an invoice, payments are matched to the right invoice, and payouts are booked with fees and VAT. The feature will be enabled shortly.", "coming_soon_description": "Connect your company Stripe account and a payment link is created automatically when you send an invoice, payments are matched to the right invoice, and payouts are booked with fees and VAT. The feature will be enabled shortly.",
@@ -343,7 +356,7 @@
"connected_since": "Connected {date}", "connected_since": "Connected {date}",
"unnamed_account": "Stripe account", "unnamed_account": "Stripe account",
"connected_toast_title": "Stripe connected", "connected_toast_title": "Stripe connected",
"connected_toast_description": "Payment links are now created automatically when you send invoices.", "connected_toast_description": "Your Stripe transactions are now fetched nightly, and payment links are created automatically when you send invoices.",
"disconnected_toast_title": "Stripe disconnected", "disconnected_toast_title": "Stripe disconnected",
"connect_failed_title": "Connection failed", "connect_failed_title": "Connection failed",
"disconnect_failed_title": "Disconnect failed", "disconnect_failed_title": "Disconnect failed",
@@ -356,31 +369,24 @@
"sync_now": "Sync now", "sync_now": "Sync now",
"syncing": "Syncing…", "syncing": "Syncing…",
"sync_done_title": "Sync complete", "sync_done_title": "Sync complete",
"sync_done_description": "{settled} payment(s) booked, {review} need review.", "sync_done_feed": "{fetched} transaction(s) fetched: {imported} new in the inbox, {linked} linked to vouchers.",
"sync_done_empty": "Stripe returned no transactions for the period. If you expected transactions, check that the right account is connected.",
"sync_failed_title": "Sync failed", "sync_failed_title": "Sync failed",
"needs_review_title": "Needs review",
"needs_review_hint": "Payments that could not be matched automatically. Handle them manually via the invoice or in Stripe.",
"reason_invoice_not_found": "Payment without a matching invoice",
"reason_invoice_already_paid": "The invoice is already marked as paid",
"reason_amount_mismatch": "The amount does not match the invoice remaining balance",
"reason_currency_mismatch": "The currency does not match the invoice",
"reason_non_sek_invoice": "Foreign-currency invoice (book manually)",
"reason_unknown": "Unknown reason",
"transaction_sync_title": "Transactions from Stripe", "transaction_sync_title": "Transactions from Stripe",
"transaction_sync_description": "Import all Stripe transactions (payments, fees, refunds and payouts) into the transactions inbox every night, like a bank feed for your Stripe balance. Already-booked payments are linked to their vouchers; you book the rest as usual.", "transaction_sync_description": "Import all Stripe transactions (payments, fees, refunds and payouts) into the transactions inbox every night, like a bank feed for your Stripe balance. You book the rows from the inbox as usual.",
"transaction_sync_backfill_note": "The first sync fetches up to 90 days of history, but never before the bookkeeping lock date.", "transaction_sync_backfill_note": "The first sync fetches up to 90 days of history, but never before the bookkeeping lock date.",
"transaction_sync_last_synced": "Last synced {date}", "transaction_sync_last_synced": "Last synced {date}",
"transaction_sync_never_synced": "Not synced yet", "transaction_sync_never_synced": "Not synced yet",
"transaction_sync_enabled_toast": "Transaction sync enabled. History is fetched on the next sync.", "transaction_sync_enabled_toast": "Transaction sync enabled. History is fetched on the next sync.",
"transaction_sync_disabled_toast": "Transaction sync disabled.", "transaction_sync_disabled_toast": "Transaction sync disabled.",
"transaction_sync_toggle_failed": "Could not save the setting. Please try again.", "transaction_sync_toggle_failed": "Could not save the setting. Please try again."
"sync_done_transactions": "{imported} transaction(s) imported, {linked} linked to vouchers."
}, },
"settings_modal": { "settings_modal": {
"title": "Settings", "title": "Settings",
"description": "Manage your company and account" "description": "Manage your company and account"
}, },
"settings": { "settings": {
"group_profile": "Profile",
"section_name": "Name", "section_name": "Name",
"name_label": "Your name", "name_label": "Your name",
"name_description": "Used when we address you and shown as the contact person on some documents.", "name_description": "Used when we address you and shown as the contact person on some documents.",
@@ -1469,6 +1475,7 @@
"wrapper_save_failed_title": "Could not save", "wrapper_save_failed_title": "Could not save",
"wrapper_save_failed_default": "Could not save settings", "wrapper_save_failed_default": "Could not save settings",
"wrapper_try_again": "Please try again.", "wrapper_try_again": "Please try again.",
"wrapper_unsaved": "Unsaved changes",
"wrapper_saved": "Saved", "wrapper_saved": "Saved",
"wrapper_saving": "Saving...", "wrapper_saving": "Saving...",
"wrapper_save_changes": "Save changes", "wrapper_save_changes": "Save changes",
@@ -1486,6 +1493,8 @@
"iframe_title": "Invoice PDF preview" "iframe_title": "Invoice PDF preview"
}, },
"settings_bookkeeping": { "settings_bookkeeping": {
"group_basics": "Basics",
"group_automation": "Automation",
"method_heading": "Accounting method", "method_heading": "Accounting method",
"method_label": "Method", "method_label": "Method",
"method_accrual": "Faktureringsmetoden", "method_accrual": "Faktureringsmetoden",
@@ -2247,6 +2256,7 @@
"link_button": "Link BankID" "link_button": "Link BankID"
}, },
"settings_security": { "settings_security": {
"group_security": "Security",
"toast_weak_password_title": "Password is too weak", "toast_weak_password_title": "Password is too weak",
"toast_weak_password_description": "The password must be at least 8 characters and include uppercase, lowercase, digits and a special character.", "toast_weak_password_description": "The password must be at least 8 characters and include uppercase, lowercase, digits and a special character.",
"toast_mismatch_title": "Passwords do not match", "toast_mismatch_title": "Passwords do not match",
@@ -3018,6 +3028,21 @@
"delivery_status_sent": "Sent", "delivery_status_sent": "Sent",
"delivery_status_failed": "Failed", "delivery_status_failed": "Failed",
"delivery_status_marked_sent": "Manual", "delivery_status_marked_sent": "Manual",
"delivery_status_delivered": "Delivered",
"delivery_status_delayed": "Delayed",
"delivery_status_complained": "Marked as spam",
"delivery_status_bounced": "Bounced",
"delivery_status_suppressed": "Blocked",
"delivery_status_explanation_sent": "The email provider accepted the message. No word yet on whether the recipient's server took it.",
"delivery_status_explanation_delivered": "The recipient's server accepted the message. If the customer still cannot find it, ask them to check their spam folder. Quarantine on the recipient's side is never visible to the sender.",
"delivery_status_explanation_delayed": "The recipient's server has not accepted the message yet, but delivery is still being retried. If nothing changes within a few hours, contact the customer.",
"delivery_status_explanation_complained": "The recipient marked the message as spam. Further sends to this address may be blocked.",
"delivery_status_explanation_bounced": "The recipient's server rejected the message. The invoice did not arrive.",
"delivery_status_explanation_failed": "The message could not be sent. The invoice did not arrive.",
"delivery_status_explanation_suppressed": "The email provider has blocked this address after an earlier bounce or spam complaint, so the message was never sent.",
"delivery_status_whole_send_note": "This applies to the whole send, not to individual recipients.",
"delivery_provider_status_label": "Delivery status",
"delivery_provider_reason_label": "Reason from the recipient",
"delivery_manual_unknown_details": "The invoice was delivered outside Accounted, so its recipients, message, and delivered file are unknown.", "delivery_manual_unknown_details": "The invoice was delivered outside Accounted, so its recipients, message, and delivered file are unknown.",
"delivery_to_label": "To", "delivery_to_label": "To",
"delivery_cc_label": "Cc", "delivery_cc_label": "Cc",
@@ -4746,7 +4771,7 @@
"update_failed_title": "Could not update article", "update_failed_title": "Could not update article",
"retry": "Please try again.", "retry": "Please try again.",
"deactivate_confirm_title": "Deactivate {name}", "deactivate_confirm_title": "Deactivate {name}",
"deactivate_confirm_description": "The article is hidden from lists and invoice pickers but its history is kept. You can reactivate it later.", "deactivate_confirm_description": "The article can no longer be picked on new invoices. Existing invoices are unaffected, and you can activate it again whenever you want.",
"deactivate_confirm_label": "Deactivate", "deactivate_confirm_label": "Deactivate",
"deactivated_title": "Article deactivated", "deactivated_title": "Article deactivated",
"activated_title": "Article activated", "activated_title": "Article activated",
+40 -15
View File
@@ -298,6 +298,19 @@
"terms_and": "och", "terms_and": "och",
"privacy_link": "integritetspolicy" "privacy_link": "integritetspolicy"
}, },
"settings_intro": {
"account": "Dina personliga uppgifter och din säkerhet. Gäller dig, inte företaget.",
"billing": "Varje företag har sitt eget abonnemang.",
"company": "Uppgifterna visas på fakturor, i e-post och i filer till myndigheter.",
"bookkeeping": "Regelverk, metod och serier. Det mesta sätts en gång.",
"tax": "Styr momsdeklarationen, arbetsgivardeklarationen och vilka datum som bevakas.",
"salary": "Standarder för lönekörningen. Skattetabell och semester ställs in per anställd.",
"invoicing": "Nummer, villkor och utseende på dina fakturor.",
"templates": "Återanvändbara konteringar och mönster som lärts in från din bokföring.",
"banking": "Transaktioner hämtas automatiskt. Accounted kan bara läsa, aldrig flytta pengar.",
"assistant": "Vad assistenten vet, minns och kan.",
"api": "Nycklar för MCP-klienter som Claude och Cursor, och för egna integrationer."
},
"settings_nav": { "settings_nav": {
"aria_label": "Inställningar", "aria_label": "Inställningar",
"company": "Företag", "company": "Företag",
@@ -325,7 +338,7 @@
}, },
"settings_payments": { "settings_payments": {
"title": "Stripe", "title": "Stripe",
"description": "Koppla företagets Stripe-konto så skapas en betalningslänk automatiskt när du skickar en faktura. Betalningar prickas av mot rätt faktura och utbetalningar bokförs med avgifter.", "description": "Koppla företagets Stripe-konto så hämtas dina Stripe-transaktioner till transaktionsinkorgen, som ett bankflöde för ditt Stripe-saldo. Dessutom skapas en betalningslänk automatiskt när du skickar en faktura.",
"not_configured": "Stripe-integrationen är inte konfigurerad på den här installationen. Kontakta administratören.", "not_configured": "Stripe-integrationen är inte konfigurerad på den här installationen. Kontakta administratören.",
"coming_soon_title": "Stripe-betalningar kommer snart", "coming_soon_title": "Stripe-betalningar kommer snart",
"coming_soon_description": "Koppla företagets Stripe-konto så skapas en betalningslänk automatiskt när du skickar en faktura, betalningar prickas av mot rätt faktura och utbetalningar bokförs med avgifter och moms. Funktionen aktiveras inom kort.", "coming_soon_description": "Koppla företagets Stripe-konto så skapas en betalningslänk automatiskt när du skickar en faktura, betalningar prickas av mot rätt faktura och utbetalningar bokförs med avgifter och moms. Funktionen aktiveras inom kort.",
@@ -343,7 +356,7 @@
"connected_since": "Ansluten {date}", "connected_since": "Ansluten {date}",
"unnamed_account": "Stripe-konto", "unnamed_account": "Stripe-konto",
"connected_toast_title": "Stripe anslutet", "connected_toast_title": "Stripe anslutet",
"connected_toast_description": "Betalningslänkar skapas nu automatiskt när du skickar fakturor.", "connected_toast_description": "Dina Stripe-transaktioner hämtas nu varje natt, och betalningslänkar skapas automatiskt när du skickar fakturor.",
"disconnected_toast_title": "Stripe frånkopplat", "disconnected_toast_title": "Stripe frånkopplat",
"connect_failed_title": "Anslutningen misslyckades", "connect_failed_title": "Anslutningen misslyckades",
"disconnect_failed_title": "Frånkopplingen misslyckades", "disconnect_failed_title": "Frånkopplingen misslyckades",
@@ -356,31 +369,24 @@
"sync_now": "Synka nu", "sync_now": "Synka nu",
"syncing": "Synkar…", "syncing": "Synkar…",
"sync_done_title": "Synkronisering klar", "sync_done_title": "Synkronisering klar",
"sync_done_description": "{settled} betalning(ar) bokförda, {review} kräver granskning.", "sync_done_feed": "{fetched} transaktion(er) hämtade: {imported} nya i inkorgen, {linked} länkade till verifikat.",
"sync_done_empty": "Stripe returnerade inga transaktioner för perioden. Kontrollera att rätt konto är anslutet om du väntade dig transaktioner.",
"sync_failed_title": "Synkroniseringen misslyckades", "sync_failed_title": "Synkroniseringen misslyckades",
"needs_review_title": "Kräver granskning",
"needs_review_hint": "Betalningar som inte kunde prickas av automatiskt. Hantera dem manuellt via fakturan eller i Stripe.",
"reason_invoice_not_found": "Betalning utan matchande faktura",
"reason_invoice_already_paid": "Fakturan är redan markerad som betald",
"reason_amount_mismatch": "Beloppet stämmer inte med fakturans restbelopp",
"reason_currency_mismatch": "Valutan stämmer inte med fakturan",
"reason_non_sek_invoice": "Faktura i utländsk valuta (bokförs manuellt)",
"reason_unknown": "Okänd orsak",
"transaction_sync_title": "Transaktioner från Stripe", "transaction_sync_title": "Transaktioner från Stripe",
"transaction_sync_description": "Hämta alla Stripe-transaktioner (betalningar, avgifter, återbetalningar och utbetalningar) till transaktionsinkorgen varje natt, som ett bankflöde för ditt Stripe-saldo. Redan bokförda betalningar länkas till sina verifikat; övriga bokför du som vanligt.", "transaction_sync_description": "Hämta alla Stripe-transaktioner (betalningar, avgifter, återbetalningar och utbetalningar) till transaktionsinkorgen varje natt, som ett bankflöde för ditt Stripe-saldo. Du bokför raderna som vanligt från inkorgen.",
"transaction_sync_backfill_note": "Vid första synkningen hämtas upp till 90 dagars historik, dock inte före bokföringslåset.", "transaction_sync_backfill_note": "Vid första synkningen hämtas upp till 90 dagars historik, dock inte före bokföringslåset.",
"transaction_sync_last_synced": "Senast synkad {date}", "transaction_sync_last_synced": "Senast synkad {date}",
"transaction_sync_never_synced": "Inte synkad ännu", "transaction_sync_never_synced": "Inte synkad ännu",
"transaction_sync_enabled_toast": "Transaktionssynk aktiverad. Historiken hämtas vid nästa synkning.", "transaction_sync_enabled_toast": "Transaktionssynk aktiverad. Historiken hämtas vid nästa synkning.",
"transaction_sync_disabled_toast": "Transaktionssynk avaktiverad.", "transaction_sync_disabled_toast": "Transaktionssynk avaktiverad.",
"transaction_sync_toggle_failed": "Kunde inte spara inställningen. Försök igen.", "transaction_sync_toggle_failed": "Kunde inte spara inställningen. Försök igen."
"sync_done_transactions": "{imported} transaktion(er) importerade, {linked} länkade till verifikat."
}, },
"settings_modal": { "settings_modal": {
"title": "Inställningar", "title": "Inställningar",
"description": "Hantera ditt företag och konto" "description": "Hantera ditt företag och konto"
}, },
"settings": { "settings": {
"group_profile": "Profil",
"section_name": "Namn", "section_name": "Namn",
"name_label": "Ditt namn", "name_label": "Ditt namn",
"name_description": "Används när vi tilltalar dig och visas som kontaktperson på vissa underlag.", "name_description": "Används när vi tilltalar dig och visas som kontaktperson på vissa underlag.",
@@ -1469,6 +1475,7 @@
"wrapper_save_failed_title": "Kunde inte spara", "wrapper_save_failed_title": "Kunde inte spara",
"wrapper_save_failed_default": "Kunde inte spara inställningar", "wrapper_save_failed_default": "Kunde inte spara inställningar",
"wrapper_try_again": "Försök igen.", "wrapper_try_again": "Försök igen.",
"wrapper_unsaved": "Osparade ändringar",
"wrapper_saved": "Sparat", "wrapper_saved": "Sparat",
"wrapper_saving": "Sparar...", "wrapper_saving": "Sparar...",
"wrapper_save_changes": "Spara ändringar", "wrapper_save_changes": "Spara ändringar",
@@ -1486,6 +1493,8 @@
"iframe_title": "PDF-förhandsvisning av faktura" "iframe_title": "PDF-förhandsvisning av faktura"
}, },
"settings_bookkeeping": { "settings_bookkeeping": {
"group_basics": "Grunder",
"group_automation": "Automatik",
"method_heading": "Bokföringsmetod", "method_heading": "Bokföringsmetod",
"method_label": "Metod", "method_label": "Metod",
"method_accrual": "Faktureringsmetoden", "method_accrual": "Faktureringsmetoden",
@@ -2247,6 +2256,7 @@
"link_button": "Koppla BankID" "link_button": "Koppla BankID"
}, },
"settings_security": { "settings_security": {
"group_security": "Säkerhet",
"toast_weak_password_title": "Lösenordet är för svagt", "toast_weak_password_title": "Lösenordet är för svagt",
"toast_weak_password_description": "Lösenordet måste vara minst 8 tecken och innehålla versaler, gemener, siffror och specialtecken.", "toast_weak_password_description": "Lösenordet måste vara minst 8 tecken och innehålla versaler, gemener, siffror och specialtecken.",
"toast_mismatch_title": "Lösenorden matchar inte", "toast_mismatch_title": "Lösenorden matchar inte",
@@ -3018,6 +3028,21 @@
"delivery_status_sent": "Skickad", "delivery_status_sent": "Skickad",
"delivery_status_failed": "Misslyckad", "delivery_status_failed": "Misslyckad",
"delivery_status_marked_sent": "Manuell", "delivery_status_marked_sent": "Manuell",
"delivery_status_delivered": "Levererad",
"delivery_status_delayed": "Fördröjd",
"delivery_status_complained": "Spam-anmäld",
"delivery_status_bounced": "Studsade",
"delivery_status_suppressed": "Blockerad",
"delivery_status_explanation_sent": "Mailet är accepterat av e-posttjänsten. Besked om att mottagarens server tagit emot det har inte kommit in ännu.",
"delivery_status_explanation_delivered": "Mottagarens server tog emot mailet. Hittar kunden det ändå inte: be dem titta i skräpposten. Karantän hos mottagarens IT-avdelning syns aldrig för avsändaren.",
"delivery_status_explanation_delayed": "Mottagarens server har inte tagit emot mailet ännu, men nya försök pågår. Kommer inget besked inom några timmar: hör av dig till kunden.",
"delivery_status_explanation_complained": "Mottagaren markerade mailet som skräppost. Fortsatta utskick till adressen kan komma att blockeras.",
"delivery_status_explanation_bounced": "Mottagarens server avvisade mailet. Fakturan kom inte fram.",
"delivery_status_explanation_failed": "Mailet kunde inte skickas ut. Fakturan kom inte fram.",
"delivery_status_explanation_suppressed": "Adressen är spärrad hos e-posttjänsten efter tidigare studs eller spam-anmälan, så mailet skickades aldrig.",
"delivery_status_whole_send_note": "Beskedet gäller hela utskicket, inte enskilda mottagare.",
"delivery_provider_status_label": "Leveransstatus",
"delivery_provider_reason_label": "Besked från mottagaren",
"delivery_manual_unknown_details": "Utskicket gjordes utanför Accounted. Mottagare, meddelande och den levererade filen är därför inte kända.", "delivery_manual_unknown_details": "Utskicket gjordes utanför Accounted. Mottagare, meddelande och den levererade filen är därför inte kända.",
"delivery_to_label": "Till", "delivery_to_label": "Till",
"delivery_cc_label": "Kopia", "delivery_cc_label": "Kopia",
@@ -4746,7 +4771,7 @@
"update_failed_title": "Kunde inte uppdatera artikel", "update_failed_title": "Kunde inte uppdatera artikel",
"retry": "Försök igen.", "retry": "Försök igen.",
"deactivate_confirm_title": "Inaktivera {name}", "deactivate_confirm_title": "Inaktivera {name}",
"deactivate_confirm_description": "Artikeln döljs i listor och fakturaval men historiken bevaras. Du kan aktivera den igen senare.", "deactivate_confirm_description": "Artikeln går inte längre att välja på nya fakturor. Befintliga fakturor påverkas inte, och du kan aktivera artikeln igen när du vill.",
"deactivate_confirm_label": "Inaktivera", "deactivate_confirm_label": "Inaktivera",
"deactivated_title": "Artikel inaktiverad", "deactivated_title": "Artikel inaktiverad",
"activated_title": "Artikel aktiverad", "activated_title": "Artikel aktiverad",
@@ -0,0 +1,479 @@
-- Provider delivery outcome for customer invoice emails.
--
-- Until now a delivery row stopped at 'sent', which only means the email
-- provider accepted the message. Whether the receiving server took it,
-- rejected it, or deferred it stayed invisible, so a bounced invoice still
-- looked green in the delivery history.
--
-- Resend reports the outcome per message (never per recipient), so the state
-- lives on the delivery row itself: one provider status, when it was observed,
-- and the provider's own reason text for the failure cases.
--
-- The row stays WORM: exactly these three columns may change after a send,
-- and only while the row has not been redacted.
ALTER TABLE public.invoice_deliveries
ADD COLUMN provider_status text,
ADD COLUMN provider_status_at timestamptz,
ADD COLUMN provider_status_detail text;
ALTER TABLE public.invoice_deliveries
ADD CONSTRAINT invoice_deliveries_provider_status_shape CHECK (
(
provider_status IS NULL
AND provider_status_at IS NULL
AND provider_status_detail IS NULL
)
OR (
provider_status IN (
'delayed', 'delivered', 'complained', 'bounced', 'failed', 'suppressed'
)
AND provider_status_at IS NOT NULL
AND channel = 'email'
AND status = 'sent'
)
);
COMMENT ON COLUMN public.invoice_deliveries.provider_status IS
'Latest delivery outcome reported by the email provider for the whole message. NULL means no report received yet: accepted by the provider, nothing more.';
COMMENT ON COLUMN public.invoice_deliveries.provider_status_at IS
'Provider timestamp for the reported outcome, not the time the report was ingested.';
COMMENT ON COLUMN public.invoice_deliveries.provider_status_detail IS
'Provider reason text for a failed outcome. May name the failing recipient, so it is redacted with the rest of the delivery PII and masked before it leaves the server.';
-- Ranking makes out-of-order webhooks safe: a late "delayed" can never
-- overwrite an observed bounce. Equal ranks fall back to the provider clock.
CREATE OR REPLACE FUNCTION public.invoice_delivery_provider_status_rank(
p_status text
)
RETURNS integer
LANGUAGE sql
IMMUTABLE
SET search_path = pg_catalog, public
AS $$
SELECT CASE p_status
WHEN 'delayed' THEN 1
WHEN 'delivered' THEN 2
WHEN 'complained' THEN 3
WHEN 'bounced' THEN 4
WHEN 'failed' THEN 4
WHEN 'suppressed' THEN 4
ELSE 0
END
$$;
COMMENT ON FUNCTION public.invoice_delivery_provider_status_rank(text) IS
'Monotonic severity rank for provider delivery outcomes. Keeps out-of-order webhook events from downgrading an observed failure.';
-- Audit trail keeps metadata only: the outcome and its timestamp are metadata,
-- the provider reason text is not and stays out of the log.
CREATE OR REPLACE FUNCTION public.invoice_delivery_audit_state(
delivery public.invoice_deliveries
)
RETURNS jsonb
LANGUAGE sql
IMMUTABLE
SET search_path = public
AS $$
SELECT jsonb_build_object(
'id', delivery.id,
'company_id', delivery.company_id,
'user_id', delivery.user_id,
'invoice_id', delivery.invoice_id,
'channel', delivery.channel,
'status', delivery.status,
'document_attachment_id', delivery.document_attachment_id,
'provider', delivery.provider,
'provider_status', delivery.provider_status,
'provider_status_at', delivery.provider_status_at,
'error_code', delivery.error_code,
'sent_at', delivery.sent_at,
'failed_at', delivery.failed_at,
'retention_expires_at', delivery.retention_expires_at,
'pii_redacted_at', delivery.pii_redacted_at,
'created_at', delivery.created_at
)
$$;
-- Rewritten from 20260723003000. Unchanged except for the provider status
-- columns: the sending flow may never set them, an already sent row may change
-- nothing else, and redaction must clear the reason text with the rest of PII.
CREATE OR REPLACE FUNCTION public.enforce_invoice_delivery_immutability()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
IF OLD.status = 'preparing'
AND OLD.created_at <= now() - interval '15 minutes'
THEN
RETURN OLD;
END IF;
INSERT INTO public.audit_log (
user_id,
company_id,
action,
table_name,
record_id,
actor_id,
old_state,
description
) VALUES (
OLD.user_id,
OLD.company_id,
'SECURITY_EVENT',
'invoice_deliveries',
OLD.id,
auth.uid(),
public.invoice_delivery_audit_state(OLD),
'Blocked deletion of immutable invoice delivery history.'
);
RETURN NULL;
END IF;
IF OLD.status = 'preparing' THEN
IF NEW.status <> 'pending'
OR NEW.company_id IS DISTINCT FROM OLD.company_id
OR NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.invoice_id IS DISTINCT FROM OLD.invoice_id
OR NEW.channel IS DISTINCT FROM OLD.channel
OR NEW.provider IS NOT NULL
OR NEW.provider_message_id IS NOT NULL
OR NEW.provider_status IS NOT NULL
OR NEW.provider_status_at IS NOT NULL
OR NEW.provider_status_detail IS NOT NULL
OR NEW.error_code IS NOT NULL
OR NEW.sent_at IS NOT NULL
OR NEW.failed_at IS NOT NULL
OR NEW.retention_expires_at IS DISTINCT FROM OLD.retention_expires_at
OR NEW.pii_redacted_at IS NOT NULL
OR NEW.created_at IS DISTINCT FROM OLD.created_at
THEN
RAISE EXCEPTION 'preparing invoice delivery may only capture its pending payload'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END IF;
IF OLD.status = 'pending' THEN
IF NEW.status NOT IN ('sent', 'failed') THEN
RAISE EXCEPTION 'pending invoice delivery may only transition to sent or failed'
USING ERRCODE = '23514';
END IF;
IF NEW.company_id IS DISTINCT FROM OLD.company_id
OR NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.invoice_id IS DISTINCT FROM OLD.invoice_id
OR NEW.channel IS DISTINCT FROM OLD.channel
OR NEW.to_addresses IS DISTINCT FROM OLD.to_addresses
OR NEW.cc_addresses IS DISTINCT FROM OLD.cc_addresses
OR NEW.bcc_addresses IS DISTINCT FROM OLD.bcc_addresses
OR NEW.reply_to IS DISTINCT FROM OLD.reply_to
OR NEW.from_name IS DISTINCT FROM OLD.from_name
OR NEW.subject IS DISTINCT FROM OLD.subject
OR NEW.body_text IS DISTINCT FROM OLD.body_text
OR NEW.body_html IS DISTINCT FROM OLD.body_html
OR NEW.attachment_filename IS DISTINCT FROM OLD.attachment_filename
OR NEW.attachment_content_type IS DISTINCT FROM OLD.attachment_content_type
OR NEW.attachment_sha256 IS DISTINCT FROM OLD.attachment_sha256
OR NEW.retention_expires_at IS DISTINCT FROM OLD.retention_expires_at
OR NEW.pii_redacted_at IS DISTINCT FROM OLD.pii_redacted_at
OR NEW.created_at IS DISTINCT FROM OLD.created_at
OR NEW.provider_status IS NOT NULL
OR NEW.provider_status_at IS NOT NULL
OR NEW.provider_status_detail IS NOT NULL
OR (
NEW.status = 'sent'
AND NEW.document_attachment_id IS DISTINCT FROM OLD.document_attachment_id
)
OR (
NEW.status = 'failed'
AND NEW.document_attachment_id IS NOT NULL
)
THEN
RAISE EXCEPTION 'invoice delivery payload is immutable'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END IF;
-- The provider reports the outcome after the send is already terminal. Only
-- the three provider status columns may move, and only on an unredacted sent
-- row: subtracting them from the row image proves nothing else changed, so a
-- column added later is covered without revisiting this branch.
IF OLD.status = 'sent'
AND OLD.pii_redacted_at IS NULL
AND NEW.provider_status IS NOT NULL
AND (to_jsonb(NEW)
- 'provider_status' - 'provider_status_at' - 'provider_status_detail' - 'updated_at')
IS NOT DISTINCT FROM
(to_jsonb(OLD)
- 'provider_status' - 'provider_status_at' - 'provider_status_detail' - 'updated_at')
THEN
RETURN NEW;
END IF;
IF OLD.status IN ('sent', 'failed')
AND OLD.pii_redacted_at IS NULL
AND CURRENT_DATE >= OLD.retention_expires_at
AND NEW.pii_redacted_at IS NOT NULL
AND NEW.company_id IS NOT DISTINCT FROM OLD.company_id
AND NEW.user_id IS NOT DISTINCT FROM OLD.user_id
AND NEW.invoice_id IS NOT DISTINCT FROM OLD.invoice_id
AND NEW.channel IS NOT DISTINCT FROM OLD.channel
AND NEW.status IS NOT DISTINCT FROM OLD.status
AND cardinality(NEW.to_addresses) = 0
AND cardinality(NEW.cc_addresses) = 0
AND cardinality(NEW.bcc_addresses) = 0
AND NEW.reply_to IS NULL
AND NEW.from_name IS NULL
AND NEW.subject IS NULL
AND NEW.body_text IS NULL
AND NEW.body_html IS NULL
AND NEW.provider IS NOT DISTINCT FROM OLD.provider
AND NEW.provider_message_id IS NULL
AND NEW.provider_status IS NOT DISTINCT FROM OLD.provider_status
AND NEW.provider_status_at IS NOT DISTINCT FROM OLD.provider_status_at
AND NEW.provider_status_detail IS NULL
AND NEW.error_code IS NOT DISTINCT FROM OLD.error_code
AND NEW.document_attachment_id IS NOT DISTINCT FROM OLD.document_attachment_id
AND NEW.attachment_filename IS NULL
AND NEW.attachment_content_type IS NOT DISTINCT FROM OLD.attachment_content_type
AND NEW.attachment_sha256 IS NULL
AND NEW.sent_at IS NOT DISTINCT FROM OLD.sent_at
AND NEW.failed_at IS NOT DISTINCT FROM OLD.failed_at
AND NEW.retention_expires_at IS NOT DISTINCT FROM OLD.retention_expires_at
AND NEW.created_at IS NOT DISTINCT FROM OLD.created_at
THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'terminal invoice delivery (%) is immutable', OLD.status
USING ERRCODE = '23514';
END;
$$;
-- The provider reason text is recipient-related personal data and expires with
-- the rest of it. Rewritten from 20260722150000 to clear the new column too.
CREATE OR REPLACE FUNCTION public.redact_expired_invoice_delivery_pii()
RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
redacted_count integer;
BEGIN
UPDATE public.invoice_deliveries
SET to_addresses = '{}',
cc_addresses = '{}',
bcc_addresses = '{}',
reply_to = NULL,
from_name = NULL,
subject = NULL,
body_text = NULL,
body_html = NULL,
provider_message_id = NULL,
provider_status_detail = NULL,
attachment_filename = NULL,
attachment_sha256 = NULL,
pii_redacted_at = now()
WHERE channel = 'email'
AND status IN ('sent', 'failed')
AND pii_redacted_at IS NULL
AND retention_expires_at <= CURRENT_DATE;
GET DIAGNOSTICS redacted_count = ROW_COUNT;
RETURN redacted_count;
END;
$$;
REVOKE ALL ON FUNCTION public.redact_expired_invoice_delivery_pii() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.redact_expired_invoice_delivery_pii() TO service_role;
-- Applied from a signed provider webhook, which has no user session: the
-- provider is the actor. The message id is the provider's own identifier and
-- is already unique per provider, so it is the only lookup key needed.
CREATE OR REPLACE FUNCTION public.apply_invoice_delivery_provider_status(
p_provider text,
p_provider_message_id text,
p_status text,
p_occurred_at timestamptz,
p_detail text
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
DECLARE
target public.invoice_deliveries%ROWTYPE;
new_rank integer;
current_rank integer;
observed_at timestamptz;
updated_id uuid;
BEGIN
IF auth.role() IS DISTINCT FROM 'service_role' THEN
RAISE EXCEPTION 'invoice delivery provider status requires a server-controlled service role'
USING ERRCODE = '42501';
END IF;
new_rank := public.invoice_delivery_provider_status_rank(p_status);
IF new_rank = 0 THEN
RAISE EXCEPTION 'unsupported invoice delivery provider status: %', p_status
USING ERRCODE = '22023';
END IF;
IF p_provider IS NULL OR p_provider_message_id IS NULL THEN
RETURN NULL;
END IF;
SELECT * INTO target
FROM public.invoice_deliveries d
WHERE d.provider = p_provider
AND d.provider_message_id = p_provider_message_id
FOR UPDATE;
-- Events for mail that is not a tracked invoice delivery, or for a row that
-- has passed its retention date, are acknowledged and dropped.
IF target.id IS NULL
OR target.status <> 'sent'
OR target.pii_redacted_at IS NOT NULL
THEN
RETURN NULL;
END IF;
observed_at := COALESCE(p_occurred_at, now());
current_rank := public.invoice_delivery_provider_status_rank(target.provider_status);
IF new_rank < current_rank
OR (
new_rank = current_rank
AND target.provider_status_at IS NOT NULL
AND observed_at <= target.provider_status_at
)
THEN
RETURN target.id;
END IF;
UPDATE public.invoice_deliveries
SET provider_status = p_status,
provider_status_at = observed_at,
provider_status_detail = NULLIF(
left(regexp_replace(COALESCE(p_detail, ''), '\s+', ' ', 'g'), 500),
''
)
WHERE id = target.id
RETURNING id INTO updated_id;
RETURN updated_id;
END;
$$;
REVOKE ALL ON FUNCTION public.apply_invoice_delivery_provider_status(
text, text, text, timestamptz, text
) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.apply_invoice_delivery_provider_status(
text, text, text, timestamptz, text
) TO service_role;
COMMENT ON FUNCTION public.apply_invoice_delivery_provider_status(
text, text, text, timestamptz, text
) IS
'Applies a signed provider delivery report to the matching sent invoice delivery. Idempotent and monotonic: a lower ranked or older report is a no-op.';
-- Reason text can quote the failing address, so it is masked exactly like the
-- recipient list before it leaves the server.
DROP FUNCTION IF EXISTS public.list_invoice_delivery_summaries(uuid, uuid);
CREATE FUNCTION public.list_invoice_delivery_summaries(
p_company_id uuid,
p_invoice_id uuid
)
RETURNS TABLE (
id uuid,
channel text,
status text,
to_addresses text[],
cc_addresses text[],
provider text,
provider_status text,
provider_status_at timestamptz,
provider_status_detail text,
error_code text,
document_attachment_id uuid,
attachment_filename text,
sent_at timestamptz,
failed_at timestamptz,
created_at timestamptz
)
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
BEGIN
IF auth.uid() IS NULL
OR p_company_id IS DISTINCT FROM public.current_active_company_id()
OR NOT EXISTS (
SELECT 1
FROM public.company_members cm
WHERE cm.company_id = p_company_id
AND cm.user_id = auth.uid()
)
THEN
RAISE EXCEPTION 'not authorized to list invoice delivery summaries'
USING ERRCODE = '42501';
END IF;
RETURN QUERY
SELECT
d.id,
d.channel,
d.status,
ARRAY(
SELECT CASE
WHEN recipient.address ~ '^[^@]+@[^@]+$'
THEN '***@' || split_part(recipient.address, '@', 2)
ELSE '***'
END
FROM unnest(d.to_addresses) WITH ORDINALITY AS recipient(address, position)
ORDER BY recipient.position
),
ARRAY(
SELECT CASE
WHEN recipient.address ~ '^[^@]+@[^@]+$'
THEN '***@' || split_part(recipient.address, '@', 2)
ELSE '***'
END
FROM unnest(d.cc_addresses) WITH ORDINALITY AS recipient(address, position)
ORDER BY recipient.position
),
d.provider,
d.provider_status,
d.provider_status_at,
regexp_replace(d.provider_status_detail, '[A-Za-z0-9._%+-]+@', '***@', 'g'),
d.error_code,
d.document_attachment_id,
d.attachment_filename,
d.sent_at,
d.failed_at,
d.created_at
FROM public.invoice_deliveries d
WHERE d.company_id = p_company_id
AND d.invoice_id = p_invoice_id
AND d.status <> 'preparing'
ORDER BY d.created_at DESC;
END;
$$;
REVOKE ALL ON FUNCTION public.list_invoice_delivery_summaries(uuid, uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.list_invoice_delivery_summaries(uuid, uuid) TO authenticated;
COMMENT ON FUNCTION public.list_invoice_delivery_summaries(uuid, uuid) IS
'Returns active-company invoice delivery status, including the provider delivery outcome, with masked To and CC addresses and a masked provider reason text. Exact payload and BCC remain server-side.';
NOTIFY pgrst, 'reload schema';
+17
View File
@@ -1040,6 +1040,20 @@ export interface Invoice {
export type InvoiceDeliveryChannel = 'email' | 'manual' export type InvoiceDeliveryChannel = 'email' | 'manual'
export type InvoiceDeliveryStatus = 'preparing' | 'pending' | 'sent' | 'failed' | 'marked_sent' export type InvoiceDeliveryStatus = 'preparing' | 'pending' | 'sent' | 'failed' | 'marked_sent'
/**
* Delivery outcome reported by the email provider after the send itself
* succeeded. Reported per message, never per recipient: a message with several
* recipients gets one outcome, and the reason text names the address that
* failed. `null` means no report has arrived yet.
*/
export type InvoiceDeliveryProviderStatus =
| 'delayed'
| 'delivered'
| 'complained'
| 'bounced'
| 'failed'
| 'suppressed'
export interface InvoiceDelivery { export interface InvoiceDelivery {
id: string id: string
company_id: string company_id: string
@@ -1057,6 +1071,9 @@ export interface InvoiceDelivery {
body_html: string | null body_html: string | null
provider: string | null provider: string | null
provider_message_id: string | null provider_message_id: string | null
provider_status: InvoiceDeliveryProviderStatus | null
provider_status_at: string | null
provider_status_detail: string | null
error_code: string | null error_code: string | null
document_attachment_id: string | null document_attachment_id: string | null
attachment_filename: string | null attachment_filename: string | null
-4
View File
@@ -17,10 +17,6 @@
"path": "/api/extensions/enable-banking/sync/cron", "path": "/api/extensions/enable-banking/sync/cron",
"schedule": "0 5 * * *" "schedule": "0 5 * * *"
}, },
{
"path": "/api/extensions/stripe/sync/cron",
"schedule": "*/15 * * * *"
},
{ {
"path": "/api/extensions/stripe/transactions/cron", "path": "/api/extensions/stripe/transactions/cron",
"schedule": "30 3 * * *" "schedule": "30 3 * * *"