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:
@@ -3,6 +3,7 @@
|
||||
Classification: Confidential
|
||||
|
||||
Date: 2026-07-22
|
||||
Updated: 2026-07-24 (provider delivery outcome)
|
||||
Owner: Accounted controller
|
||||
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 sent accounting document. It is not necessary in the routine browser list.
|
||||
The list therefore exposes only status, timestamps, masked recipient domains,
|
||||
provider name, error code, and an active-company-scoped link to the archived
|
||||
PDF. Subjects, bodies, full addresses, reply-to addresses, provider message IDs,
|
||||
BCC recipients, filenames, and checksums are excluded.
|
||||
provider name, the provider delivery outcome with its masked reason text, error
|
||||
code, and an active-company-scoped link to the archived PDF. Subjects, bodies,
|
||||
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
|
||||
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
|
||||
exception is limited to the downloadable statutory archive purpose described
|
||||
above and is not reused by the routine history endpoint.
|
||||
The summary function is defined in migration `20260723003000` and the route
|
||||
applies domain masking again before returning its allow-listed fields.
|
||||
The summary function is defined in migration `20260724160000` and the route
|
||||
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
|
||||
removed. Server-only functions bind reservations and state transitions to a
|
||||
verified writable company member. Payload-free crashed reservations may be
|
||||
|
||||
@@ -52,6 +52,8 @@ processing_activities:
|
||||
- exact_sent_pdf_worm_protection
|
||||
- metadata_only_immutable_audit_log
|
||||
- daily_post_retention_pii_redaction
|
||||
- signed_provider_delivery_webhook_only
|
||||
- masked_recipient_addresses_in_provider_reason_text
|
||||
|
||||
- id: customer.private_identity
|
||||
name: Personnummer för privatkund
|
||||
|
||||
@@ -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 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] 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] 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.
|
||||
@@ -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] 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] 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.
|
||||
|
||||
@@ -57,8 +57,8 @@ export default function ArticleDetailPage({
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isEditOpen, setIsEditOpen] = useState(false)
|
||||
const [isUpdating, setIsUpdating] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isTogglingActive, setIsTogglingActive] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -131,17 +131,21 @@ export default function ArticleDetailPage({
|
||||
}
|
||||
}
|
||||
|
||||
// Soft retire/restore: the only path for articles already used on invoices,
|
||||
// where hard delete is refused (ARTICLE_IN_USE) to keep invoice history.
|
||||
// Soft deactivation is the answer for an article that has already been used
|
||||
// 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() {
|
||||
if (!article) return
|
||||
const deactivating = article.active
|
||||
if (deactivating) {
|
||||
const nextActive = !article.active
|
||||
|
||||
if (!nextActive) {
|
||||
const ok = await confirmAction({
|
||||
title: t('deactivate_confirm_title', { name: article.name }),
|
||||
description: t('deactivate_confirm_description'),
|
||||
confirmLabel: t('deactivate_confirm_label'),
|
||||
variant: 'destructive',
|
||||
variant: 'warning',
|
||||
})
|
||||
if (!ok) return
|
||||
}
|
||||
@@ -151,18 +155,19 @@ export default function ArticleDetailPage({
|
||||
const response = await fetch(`/api/articles/${id}`, {
|
||||
method: 'PATCH',
|
||||
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({
|
||||
title: deactivating ? t('deactivated_title') : t('activated_title'),
|
||||
title: nextActive ? t('activated_title') : t('deactivated_title'),
|
||||
description: article.name,
|
||||
})
|
||||
fetchArticle()
|
||||
} catch (err) {
|
||||
const body = (err as { body?: unknown }).body
|
||||
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 }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -264,6 +269,7 @@ export default function ArticleDetailPage({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleToggleActive}
|
||||
className="min-h-10"
|
||||
disabled={isTogglingActive || !canWrite}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
>
|
||||
|
||||
@@ -63,6 +63,27 @@ describe('GET/PATCH/DELETE /api/articles/[id]', () => {
|
||||
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 () => {
|
||||
// chart_of_accounts lookup: no row, but 3999 is a known BAS class-3
|
||||
// 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(),
|
||||
error_message: null,
|
||||
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)
|
||||
.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.',
|
||||
provider: 'resend',
|
||||
provider_message_id: 'provider-1',
|
||||
provider_status: 'delivered',
|
||||
provider_status_at: '2026-07-22T10:30:04.000Z',
|
||||
provider_status_detail: null,
|
||||
error_code: null,
|
||||
document_attachment_id: 'document-1',
|
||||
attachment_filename: 'faktura-f-1001.pdf',
|
||||
@@ -105,6 +108,9 @@ describe('GET /api/invoices/[id]/deliveries', () => {
|
||||
to_addresses: ['***@example.com'],
|
||||
cc_addresses: ['***@example.com'],
|
||||
provider: 'resend',
|
||||
provider_status: 'delivered',
|
||||
provider_status_at: '2026-07-22T10:30:04.000Z',
|
||||
provider_status_detail: null,
|
||||
error_code: null,
|
||||
document_attachment_id: 'document-1',
|
||||
attachment_filename: 'faktura-f-1001.pdf',
|
||||
@@ -127,4 +133,85 @@ describe('GET /api/invoices/[id]/deliveries', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,11 @@ import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import type { InvoiceDeliveryChannel, InvoiceDeliveryStatus } from '@/types'
|
||||
import type {
|
||||
InvoiceDeliveryChannel,
|
||||
InvoiceDeliveryProviderStatus,
|
||||
InvoiceDeliveryStatus,
|
||||
} from '@/types'
|
||||
|
||||
interface InvoiceDeliverySummaryRow {
|
||||
id: string
|
||||
@@ -11,6 +15,9 @@ interface InvoiceDeliverySummaryRow {
|
||||
to_addresses: string[]
|
||||
cc_addresses: string[]
|
||||
provider: string | null
|
||||
provider_status: InvoiceDeliveryProviderStatus | null
|
||||
provider_status_at: string | null
|
||||
provider_status_detail: string | null
|
||||
error_code: string | null
|
||||
document_attachment_id: string | null
|
||||
attachment_filename: string | null
|
||||
@@ -35,8 +42,12 @@ interface MaskedInvoiceDeliverySummaryRow
|
||||
* addresses stay server-side. The attachment filename passes through: it is
|
||||
* derived from data the invoice already exposes to every company member. The
|
||||
* 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.
|
||||
*
|
||||
* 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 }> }>(
|
||||
'invoice.deliveries.list',
|
||||
@@ -79,6 +90,9 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
to_addresses: delivery.to_addresses.map(maskRecipientDomain),
|
||||
cc_addresses: delivery.cc_addresses.map(maskRecipientDomain),
|
||||
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,
|
||||
document_attachment_id: delivery.document_attachment_id,
|
||||
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 {
|
||||
const separator = address.lastIndexOf('@')
|
||||
if (separator <= 0 || separator === address.length - 1) {
|
||||
|
||||
@@ -34,6 +34,7 @@ const SAMPLE_ARTICLE = {
|
||||
type: 'tjanst',
|
||||
unit: 'tim',
|
||||
price_excl_vat: 850,
|
||||
currency: 'SEK',
|
||||
vat_rate: 25,
|
||||
revenue_account: null,
|
||||
cost_price: null,
|
||||
@@ -111,6 +112,26 @@ describe('GET /api/v1/companies/:companyId/articles', () => {
|
||||
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 () => {
|
||||
const client = makeSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
|
||||
@@ -23,6 +23,7 @@ const ArticleShape = z.object({
|
||||
type: z.enum(['vara', 'tjanst']),
|
||||
unit: z.string(),
|
||||
price_excl_vat: z.number(),
|
||||
currency: z.string(),
|
||||
vat_rate: z.number(),
|
||||
revenue_account: z.string().nullable(),
|
||||
cost_price: z.number().nullable(),
|
||||
@@ -36,7 +37,7 @@ const ArticleShape = z.object({
|
||||
|
||||
// Explicit projection: excludes user_id, company_id (internal scoping).
|
||||
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({
|
||||
operation: 'articles.list',
|
||||
@@ -52,6 +53,7 @@ registerEndpoint({
|
||||
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).',
|
||||
'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.',
|
||||
'Inactive articles (active=false) are hidden by default but remain linkable for historical reads.',
|
||||
],
|
||||
@@ -67,6 +69,7 @@ registerEndpoint({
|
||||
type: 'tjanst',
|
||||
unit: 'tim',
|
||||
price_excl_vat: 850,
|
||||
currency: 'SEK',
|
||||
vat_rate: 25,
|
||||
revenue_account: null,
|
||||
cost_price: null,
|
||||
|
||||
+4
-1
@@ -14,6 +14,7 @@ import { RecaptHideWidget } from "@/components/RecaptHideWidget";
|
||||
import { ScrollbarReveal } from "@/components/ScrollbarReveal";
|
||||
import { ensureInitialized } from "@/lib/init";
|
||||
import { getBranding } from "@/lib/branding/service";
|
||||
import { APP_TIME_ZONE } from "@/i18n/config";
|
||||
import "./globals.css";
|
||||
|
||||
// Load extensions before metadata/viewport functions read the branding service.
|
||||
@@ -81,7 +82,9 @@ export default async function RootLayout({
|
||||
<body
|
||||
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
|
||||
attribute="class"
|
||||
defaultTheme="light"
|
||||
|
||||
@@ -12,7 +12,7 @@ import AttGoraSection from '@/components/dashboard/AttGoraSection'
|
||||
import ResumePane from '@/components/dashboard/ResumePane'
|
||||
import BackupHealthBanner from '@/components/dashboard/BackupHealthBanner'
|
||||
import { SkatteverketPromoCard } from '@/components/dashboard/SkatteverketPromoCard'
|
||||
import { ArrowRight, MessageCircle } from 'lucide-react'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import type { InitialSetupState, OnboardingProgress } from '@/types'
|
||||
import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types'
|
||||
import type { ResumeItem } from '@/lib/worklist/resume'
|
||||
@@ -111,9 +111,6 @@ export default function DashboardContent({
|
||||
<Link href={hasAi ? '/onboarding/agent' : '/settings/billing'} className="block group">
|
||||
<Card className="transition-colors hover:border-primary/50">
|
||||
<CardContent className="p-6 flex items-center gap-4">
|
||||
<div className="flex-shrink-0 h-10 w-10 rounded-lg flex items-center justify-center bg-foreground text-background">
|
||||
<MessageCircle className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-display text-xl leading-tight">Bygg din bokföringsassistent</p>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import type { InvoiceDelivery } from '@/types'
|
||||
import type { InvoiceDelivery, InvoiceDeliveryProviderStatus } from '@/types'
|
||||
|
||||
export type InvoiceDeliveryView = Pick<
|
||||
InvoiceDelivery,
|
||||
@@ -15,6 +15,9 @@ export type InvoiceDeliveryView = Pick<
|
||||
| 'to_addresses'
|
||||
| 'cc_addresses'
|
||||
| 'provider'
|
||||
| 'provider_status'
|
||||
| 'provider_status_at'
|
||||
| 'provider_status_detail'
|
||||
| 'error_code'
|
||||
| 'document_attachment_id'
|
||||
| 'attachment_filename'
|
||||
@@ -30,12 +33,37 @@ interface InvoiceDeliveryHistoryProps {
|
||||
showLegacyEmptyState: boolean
|
||||
}
|
||||
|
||||
const statusVariant = {
|
||||
/**
|
||||
* What the row actually says happened. The send status alone stops at
|
||||
* "handed to the provider", which is why an accepted-but-bounced invoice used
|
||||
* to read as a plain success. When the provider has reported back, its
|
||||
* outcome is what the row shows.
|
||||
*/
|
||||
type DeliveryOutcome =
|
||||
| 'pending'
|
||||
| 'sent'
|
||||
| 'failed'
|
||||
| 'marked_sent'
|
||||
| InvoiceDeliveryProviderStatus
|
||||
|
||||
function outcomeOf(delivery: InvoiceDeliveryView): DeliveryOutcome {
|
||||
if (delivery.status === 'sent' && delivery.provider_status) return delivery.provider_status
|
||||
return delivery.status
|
||||
}
|
||||
|
||||
// Green is reserved for a confirmed arrival. "Skickad" without a delivery
|
||||
// report is a neutral, honest in-between state.
|
||||
const outcomeVariant: Record<DeliveryOutcome, 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'> = {
|
||||
pending: 'secondary',
|
||||
sent: 'success',
|
||||
sent: 'secondary',
|
||||
delivered: 'success',
|
||||
delayed: 'warning',
|
||||
complained: 'warning',
|
||||
bounced: 'destructive',
|
||||
failed: 'destructive',
|
||||
suppressed: 'destructive',
|
||||
marked_sent: 'outline',
|
||||
} as const
|
||||
}
|
||||
|
||||
export function InvoiceDeliveryHistory({
|
||||
deliveries,
|
||||
@@ -76,6 +104,10 @@ export function InvoiceDeliveryHistory({
|
||||
{deliveries.map((delivery) => {
|
||||
const occurredAt = delivery.sent_at || delivery.failed_at || delivery.created_at
|
||||
const isManual = delivery.channel === 'manual'
|
||||
const outcome = outcomeOf(delivery)
|
||||
const isEmailSend = !isManual && delivery.status === 'sent'
|
||||
const recipientCount =
|
||||
delivery.to_addresses.length + delivery.cc_addresses.length
|
||||
|
||||
return (
|
||||
<details key={delivery.id} className="group rounded-lg border bg-card">
|
||||
@@ -91,8 +123,8 @@ export function InvoiceDeliveryHistory({
|
||||
{formatTimestamp(occurredAt)}
|
||||
</span>
|
||||
</span>
|
||||
<Badge variant={statusVariant[delivery.status]}>
|
||||
{t(`delivery_status_${delivery.status}`)}
|
||||
<Badge variant={outcomeVariant[outcome]}>
|
||||
{t(`delivery_status_${outcome}`)}
|
||||
</Badge>
|
||||
</summary>
|
||||
|
||||
@@ -115,6 +147,35 @@ export function InvoiceDeliveryHistory({
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{isEmailSend && (
|
||||
<div className="rounded-lg border bg-muted/30 p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{t('delivery_provider_status_label')}
|
||||
</span>
|
||||
<span>{t(`delivery_status_${outcome}`)}</span>
|
||||
{delivery.provider_status_at && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{formatTimestamp(delivery.provider_status_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t(`delivery_status_explanation_${outcome}`)}
|
||||
</p>
|
||||
{delivery.provider_status_detail && (
|
||||
<p className="mt-2 break-words text-xs text-muted-foreground">
|
||||
{t('delivery_provider_reason_label')}: {delivery.provider_status_detail}
|
||||
</p>
|
||||
)}
|
||||
{recipientCount > 1 && delivery.provider_status && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t('delivery_status_whole_send_note')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{delivery.document_attachment_id && (
|
||||
<Button asChild variant="outline" size="sm" className="max-w-full">
|
||||
<a
|
||||
|
||||
@@ -74,13 +74,21 @@ export function TaxTableStatus({ year, compact = false }: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
// Flat row presentation (Fönster settings language): muted status line
|
||||
// with a quiet right-aligned recheck, no box.
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border bg-muted/30 px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Icon className={`h-4 w-4 ${iconColor}`} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={check} disabled={loading} aria-label={t('recheck')}>
|
||||
<div className="flex w-full min-w-0 items-center gap-2">
|
||||
<Icon className={`h-3.5 w-3.5 shrink-0 ${iconColor}`} />
|
||||
<span className="min-w-0 text-xs text-muted-foreground">{label}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={check}
|
||||
disabled={loading}
|
||||
aria-label={t('recheck')}
|
||||
className="ml-auto shrink-0"
|
||||
>
|
||||
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCcw className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,12 @@ import { RetentionNotice } from '@/components/ui/retention-notice'
|
||||
import { ExternalLink, Loader2 } from 'lucide-react'
|
||||
import { SupportLink } from '@/components/ui/support-link'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import {
|
||||
SettingsDangerZone,
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsRowNote,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
|
||||
interface Blocker {
|
||||
id: string
|
||||
@@ -28,6 +34,7 @@ interface Blocker {
|
||||
|
||||
export function AccountDangerZone() {
|
||||
const t = useTranslations('settings_account_danger')
|
||||
const tRetention = useTranslations('retention_notice')
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState<string | null>(null)
|
||||
const [blockers, setBlockers] = useState<Blocker[]>([])
|
||||
@@ -103,61 +110,62 @@ export function AccountDangerZone() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-4 border-t border-border pt-8">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive">
|
||||
{t('heading')}
|
||||
</h2>
|
||||
<SettingsDangerZone label={t('heading')}>
|
||||
{/* Owned companies block deletion: functional state, kept visible as
|
||||
rows (only the first row carries the label and the why-help). */}
|
||||
{hasBlockers &&
|
||||
blockers.map((b, i) => (
|
||||
<SettingsRow
|
||||
key={b.id}
|
||||
label={i === 0 ? t('blockers_title') : ''}
|
||||
help={i === 0 ? t('blockers_description') : undefined}
|
||||
>
|
||||
<span className="text-sm">{b.name}</span>
|
||||
<SettingsRowEnd>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/settings/company">{t('blockers_manage')}</Link>
|
||||
</Button>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
))}
|
||||
|
||||
{hasBlockers && (
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-3">
|
||||
<p className="text-sm font-medium">{t('blockers_title')}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('blockers_description')}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{blockers.map((b) => (
|
||||
<li
|
||||
key={b.id}
|
||||
className="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2"
|
||||
>
|
||||
<span className="text-sm font-medium">{b.name}</span>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/settings/company">{t('blockers_manage')}</Link>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RetentionNotice variant="account" />
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('support_question')}{' '}
|
||||
<SupportLink variant="inline" subject={t('support_subject')} />
|
||||
</p>
|
||||
<SettingsRow
|
||||
label={t('delete_button')}
|
||||
borderless
|
||||
// The full anonymization/BFL retention copy (incl. the backup link)
|
||||
// lives behind the "?": the visible row stays one quiet line.
|
||||
help={<RetentionNotice variant="account" className="border-0 bg-transparent p-0" />}
|
||||
>
|
||||
<SettingsRowNote>{tRetention('account_title')}</SettingsRowNote>
|
||||
<SettingsRowEnd>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/reports?type=sie">
|
||||
<ExternalLink className="mr-2 h-3.5 w-3.5" />
|
||||
{t('export_sie')}
|
||||
</Link>
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDialog(true)}
|
||||
disabled={!canDelete}
|
||||
className="text-sm font-medium text-destructive underline underline-offset-2 transition-colors duration-150 hover:text-destructive/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('delete_button')}
|
||||
</button>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
|
||||
{error && !showDialog && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<p className="px-1 text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button variant="outline" className="w-full sm:w-auto" asChild>
|
||||
<Link href="/reports?type=sie">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
{t('export_sie')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setShowDialog(true)}
|
||||
disabled={!canDelete}
|
||||
>
|
||||
{t('delete_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<p className="px-1 pt-3">
|
||||
<SettingsRowNote>
|
||||
{t('support_question')}{' '}
|
||||
<SupportLink variant="inline" subject={t('support_subject')} />
|
||||
</SettingsRowNote>
|
||||
</p>
|
||||
</SettingsDangerZone>
|
||||
|
||||
<Dialog
|
||||
open={showDialog}
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -19,6 +11,11 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
SettingsRow,
|
||||
SettingsRowNote,
|
||||
SettingsSelect,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import type { AccountingFramework } from '@/types'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
@@ -30,8 +27,9 @@ interface AccountingFrameworkFormProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* K2/K3 selector for AB. Lives on the bookkeeping settings page. Renders nothing
|
||||
* for non-AB entities: the parent gates this component by entity_type.
|
||||
* K2/K3 selector row for AB. Lives in the Grunder group on the bookkeeping
|
||||
* settings page. Renders nothing for non-AB entities: the parent gates this
|
||||
* component by entity_type.
|
||||
*
|
||||
* UX rules (regulatory area: kept in Swedish):
|
||||
* - Default is K2 (matches the column default and BFNAR 2016:10 baseline).
|
||||
@@ -100,33 +98,36 @@ export function AccountingFrameworkForm({ current, onSaved }: AccountingFramewor
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Redovisningsregelverk
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accounting_framework">Regelverk</Label>
|
||||
<Select
|
||||
<>
|
||||
<SettingsRow
|
||||
label="Regelverk"
|
||||
htmlFor="accounting_framework"
|
||||
help={
|
||||
<>
|
||||
K2 är standard för mindre bolag och innebär förenklade regler. K3 krävs när
|
||||
bolaget når två av tre tröskelvärden (nettoomsättning > 80 MSEK, tillgångar
|
||||
> 40 MSEK, eller fler än 50 anställda). K3 ställer högre krav: kassaflödesanalys,
|
||||
komponentavskrivning på materiella anläggningstillgångar och redovisning av
|
||||
uppskjuten skatt på obeskattade reserver (79,4 % eget kapital / 20,6 % skuld).
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SettingsSelect
|
||||
id="accounting_framework"
|
||||
value={selected}
|
||||
onValueChange={handleChange}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
// Saves via its own PATCH; the row sits inside the page's
|
||||
// SettingsFormWrapper form, so keep the wrapper's dirty tracking
|
||||
// (onInput on the form) from reacting to this select. No `name`
|
||||
// either, so the wrapper's FormData never picks it up.
|
||||
onInput={(e) => e.stopPropagation()}
|
||||
disabled={saving}
|
||||
>
|
||||
<SelectTrigger id="accounting_framework" className="w-full max-w-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="k2">K2 (BFNAR 2016:10): mindre företag</SelectItem>
|
||||
<SelectItem value="k3">K3 (BFNAR 2012:1): större företag</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
K2 är standard för mindre bolag och innebär förenklade regler. K3 krävs när
|
||||
bolaget når två av tre tröskelvärden (nettoomsättning > 80 MSEK, tillgångar
|
||||
> 40 MSEK, eller fler än 50 anställda). K3 ställer högre krav: kassaflödesanalys,
|
||||
komponentavskrivning på materiella anläggningstillgångar och redovisning av
|
||||
uppskjuten skatt på obeskattade reserver (79,4 % eget kapital / 20,6 % skuld).
|
||||
</p>
|
||||
</div>
|
||||
<option value="k2">K2 (BFNAR 2016:10): mindre företag</option>
|
||||
<option value="k3">K3 (BFNAR 2012:1): större företag</option>
|
||||
</SettingsSelect>
|
||||
{saving && <SettingsRowNote>Sparar…</SettingsRowNote>}
|
||||
</SettingsRow>
|
||||
|
||||
<Dialog
|
||||
open={pending !== null}
|
||||
@@ -174,6 +175,6 @@ export function AccountingFrameworkForm({ current, onSaved }: AccountingFramewor
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,16 +2,24 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Brain, Loader2, Pin, PinOff, Pencil, Plus, RotateCcw, Trash2, X } from 'lucide-react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsReveal,
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsRowNote,
|
||||
SettingsSeg,
|
||||
SettingsSelect,
|
||||
SettingsTextarea,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
import { cn, formatDateLong } from '@/lib/utils'
|
||||
|
||||
type Kind = 'fact' | 'preference' | 'pattern' | 'correction'
|
||||
type Source = 'composer' | 'user_taught' | 'agent_learned' | 'derived'
|
||||
@@ -53,7 +61,7 @@ const KIND_FILTER: { value: 'all' | Kind; label: string }[] = [
|
||||
]
|
||||
|
||||
// The API returns errors either as a plain string (legacy/validation) or as
|
||||
// the canonical { code, message } envelope — extract something renderable.
|
||||
// the canonical { code, message } envelope; extract something renderable.
|
||||
function apiErrorText(error: unknown): string | undefined {
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
@@ -159,19 +167,31 @@ export function AgentMemoryPanel() {
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
// The view wrapper in AssistantSettingsContent already provides the gap
|
||||
// under the segmented control, so the group starts flush (pt-0).
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Vad min assistent kommer ihåg</CardTitle>
|
||||
<CardDescription>
|
||||
Bokföringsassistenten använder dessa anteckningar för att ge dig rätt råd. Fäst det som
|
||||
alltid ska vara med, redigera fel, eller dölj det som inte längre stämmer.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<SettingsGroup
|
||||
label="Vad min assistent kommer ihåg"
|
||||
help={
|
||||
<>
|
||||
Bokföringsassistenten använder dessa anteckningar för att ge dig rätt råd. Fäst det som
|
||||
alltid ska vara med, redigera fel, eller dölj det som inte längre stämmer. Upp till 30
|
||||
minnen ingår i samtal per tur.
|
||||
</>
|
||||
}
|
||||
className="pt-0 first:pt-0"
|
||||
>
|
||||
{/* Toolbar row: kind filter + the add-memory entry point. */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-1 py-3">
|
||||
<SettingsSeg
|
||||
value={kindFilter}
|
||||
onChange={setKindFilter}
|
||||
options={KIND_FILTER}
|
||||
aria-label="Filtrera minnen"
|
||||
/>
|
||||
{canWrite && (
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdd((v) => !v)}
|
||||
disabled={adding}
|
||||
@@ -180,31 +200,32 @@ export function AgentMemoryPanel() {
|
||||
Lägg till minne
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
</div>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{showAdd && canWrite && (
|
||||
<div className="rounded-lg border border-border p-4 space-y-3">
|
||||
<Textarea
|
||||
{canWrite && (
|
||||
<SettingsReveal open={showAdd}>
|
||||
<div className="space-y-3 py-3">
|
||||
<SettingsTextarea
|
||||
value={newContent}
|
||||
onChange={(e) => setNewContent(e.target.value)}
|
||||
placeholder="T.ex. Vi använder Stripe för B2C-betalningar; utbetalningar landar på 1930 var måndag."
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
aria-label="Nytt minne"
|
||||
className="w-full border-border"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Typ</span>
|
||||
<Select value={newKind} onValueChange={(v) => setNewKind(v as Kind)}>
|
||||
<SelectTrigger className="h-8 w-[160px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(KIND_LABEL) as Kind[]).map((k) => (
|
||||
<SelectItem key={k} value={k}>{KIND_LABEL[k]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SettingsSelect
|
||||
value={newKind}
|
||||
onChange={(e) => setNewKind(e.target.value as Kind)}
|
||||
aria-label="Typ"
|
||||
>
|
||||
{(Object.keys(KIND_LABEL) as Kind[]).map((k) => (
|
||||
<option key={k} value={k}>{KIND_LABEL[k]}</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => { setShowAdd(false); setNewContent('') }}>
|
||||
@@ -217,194 +238,181 @@ export function AgentMemoryPanel() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsReveal>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{KIND_FILTER.map((f) => {
|
||||
const active = kindFilter === f.value
|
||||
return (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => setKindFilter(f.value)}
|
||||
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
|
||||
active
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<label className="inline-flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeDismissed}
|
||||
onChange={(e) => setIncludeDismissed(e.target.checked)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
Visa dolda
|
||||
</label>
|
||||
</div>
|
||||
<SettingsRow label="Visa dolda">
|
||||
<SettingsRowEnd>
|
||||
<Switch
|
||||
checked={includeDismissed}
|
||||
onCheckedChange={setIncludeDismissed}
|
||||
aria-label="Visa dolda"
|
||||
/>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
|
||||
{rows && (
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
{/* Dynamic status stays visible; the static "how it's used" copy lives
|
||||
in the group help above. */}
|
||||
{rows && (
|
||||
<p className="px-1 pt-3">
|
||||
<SettingsRowNote className="tabular-nums">
|
||||
{counts.active} aktiva · {counts.pinned} fästa
|
||||
{includeDismissed && counts.dismissed > 0 ? ` · ${counts.dismissed} dolda` : ''}
|
||||
<span className="ml-1">(upp till 30 ingår i samtal per tur)</span>
|
||||
</div>
|
||||
)}
|
||||
</SettingsRowNote>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rows === null && (
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} className="h-20 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{rows === null && (
|
||||
<div className="space-y-3 pt-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows && rows.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Brain}
|
||||
title="Inga minnen ännu"
|
||||
description="När du lär assistenten saker (eller när den noterar saker själv med ditt godkännande) dyker de upp här."
|
||||
/>
|
||||
)}
|
||||
{rows && rows.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Brain}
|
||||
title="Inga minnen ännu"
|
||||
description="När du lär assistenten saker (eller när den noterar saker själv med ditt godkännande) dyker de upp här."
|
||||
/>
|
||||
)}
|
||||
|
||||
{rows && rows.length > 0 && (
|
||||
<ul className="space-y-3">
|
||||
{rows.map((row) => {
|
||||
const isEditing = editingId === row.id
|
||||
const isBusy = busyId === row.id
|
||||
const dimmed = !row.is_active
|
||||
return (
|
||||
<li
|
||||
key={row.id}
|
||||
className={`rounded-lg border border-border p-4 transition-colors ${
|
||||
dimmed ? 'bg-muted/30 opacity-70' : 'bg-card'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{canWrite && row.is_active ? (
|
||||
<button
|
||||
onClick={() => patch(row.id, { is_pinned: !row.is_pinned })}
|
||||
disabled={isBusy}
|
||||
className={`mt-0.5 shrink-0 rounded-md p-1.5 transition-colors ${
|
||||
row.is_pinned
|
||||
? 'text-foreground bg-secondary'
|
||||
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground'
|
||||
}`}
|
||||
aria-label={row.is_pinned ? 'Lossa' : 'Fäst'}
|
||||
title={row.is_pinned ? 'Lossa' : 'Fäst: säkerställer att minnet alltid skickas med'}
|
||||
>
|
||||
{row.is_pinned ? <Pin className="h-4 w-4 fill-current" /> : <PinOff className="h-4 w-4" />}
|
||||
</button>
|
||||
{rows && rows.length > 0 && (
|
||||
<ul>
|
||||
{rows.map((row) => {
|
||||
const isEditing = editingId === row.id
|
||||
const isBusy = busyId === row.id
|
||||
const dimmed = !row.is_active
|
||||
return (
|
||||
<li
|
||||
key={row.id}
|
||||
className={cn(
|
||||
'border-b border-border px-1 py-3 transition-colors',
|
||||
dimmed && 'opacity-70',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{canWrite && row.is_active ? (
|
||||
<button
|
||||
onClick={() => patch(row.id, { is_pinned: !row.is_pinned })}
|
||||
disabled={isBusy}
|
||||
className={cn(
|
||||
'mt-0.5 shrink-0 rounded-md p-1.5 transition-colors duration-150',
|
||||
row.is_pinned
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
|
||||
)}
|
||||
aria-label={row.is_pinned ? 'Lossa' : 'Fäst'}
|
||||
title={row.is_pinned ? 'Lossa' : 'Fäst: säkerställer att minnet alltid skickas med'}
|
||||
>
|
||||
{row.is_pinned ? <Pin className="h-4 w-4 fill-current" /> : <PinOff className="h-4 w-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="mt-0.5 shrink-0 p-1.5">
|
||||
{row.is_pinned && <Pin className="h-4 w-4 fill-current text-foreground" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{KIND_LABEL[row.kind]} · {SOURCE_LABEL[row.source]}
|
||||
</span>
|
||||
{dimmed && <Badge variant="secondary">Dold</Badge>}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<SettingsTextarea
|
||||
value={editDraft}
|
||||
onChange={(e) => setEditDraft(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
autoFocus
|
||||
aria-label="Redigera minne"
|
||||
className="w-full border-border"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-0.5 shrink-0 p-1.5">
|
||||
{row.is_pinned && <Pin className="h-4 w-4 fill-current text-foreground" />}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap break-words text-sm text-foreground">{row.content}</p>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{KIND_LABEL[row.kind]} · {SOURCE_LABEL[row.source]}
|
||||
</span>
|
||||
{dimmed && <Badge variant="secondary">Dold</Badge>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pt-1">
|
||||
<p className="text-[11px] text-muted-foreground tabular-nums">
|
||||
Skapad {formatDateLong(row.created_at)}
|
||||
{row.updated_at !== row.created_at && ` · uppdaterad ${formatDateLong(row.updated_at)}`}
|
||||
</p>
|
||||
|
||||
{isEditing ? (
|
||||
<Textarea
|
||||
value={editDraft}
|
||||
onChange={(e) => setEditDraft(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap break-words">{row.content}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pt-1">
|
||||
<p className="text-[11px] text-muted-foreground tabular-nums">
|
||||
Skapad {formatDateLong(row.created_at)}
|
||||
{row.updated_at !== row.created_at && ` · uppdaterad ${formatDateLong(row.updated_at)}`}
|
||||
</p>
|
||||
|
||||
{canWrite && (
|
||||
<div className="flex items-center gap-1">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingId(null)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Avbryt</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveEdit(row)}
|
||||
disabled={isBusy || editDraft.trim().length < 2}
|
||||
>
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Spara'}
|
||||
</Button>
|
||||
</>
|
||||
) : row.is_active ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => startEdit(row)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil className="mr-1 h-3.5 w-3.5" />
|
||||
Redigera
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => patch(row.id, { is_active: false })}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{isBusy ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
Dölj
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
{canWrite && (
|
||||
<div className="flex items-center gap-1">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => patch(row.id, { is_active: true })}
|
||||
onClick={() => setEditingId(null)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Avbryt</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveEdit(row)}
|
||||
disabled={isBusy || editDraft.trim().length < 2}
|
||||
>
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Spara'}
|
||||
</Button>
|
||||
</>
|
||||
) : row.is_active ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => startEdit(row)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil className="mr-1 h-3.5 w-3.5" />
|
||||
Redigera
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => patch(row.id, { is_active: false })}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{isBusy ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="mr-1 h-3.5 w-3.5" />
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
Återställ
|
||||
Dölj
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => patch(row.id, { is_active: true })}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{isBusy ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
Återställ
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ChevronDown, GraduationCap, Loader2 } from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { SettingsGroup, SettingsRowNote } from '@/components/settings/SettingsRows'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Tier = 'horizontal' | 'vertical' | 'modifier'
|
||||
|
||||
@@ -53,7 +55,7 @@ const PROSE =
|
||||
'[&_td]:border-b [&_td]:border-border [&_td]:py-1.5 [&_td]:px-2 [&_td]:align-top'
|
||||
|
||||
// The API returns errors either as a plain string (legacy/validation) or as
|
||||
// the canonical { code, message } envelope — extract something renderable.
|
||||
// the canonical { code, message } envelope; extract something renderable.
|
||||
function apiErrorText(error: unknown): string | undefined {
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
@@ -118,112 +120,107 @@ export function AgentSkillsPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Vad min assistent kan</CardTitle>
|
||||
<CardDescription>
|
||||
Utöver vad den minns om ditt företag bygger assistenten på en uppsättning kunskapsområden
|
||||
om svensk bokföring och skatt. Kärnkompetensen gäller alla; bransch- och bolagsanpassningen
|
||||
väljs utifrån ditt företag. Klicka för att läsa hela kunskapen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-8">
|
||||
{atoms && (
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
<div>
|
||||
{/* Dynamic status stays visible; the static "what this is" copy sits
|
||||
behind the "?" next to it. */}
|
||||
{atoms && (
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<SettingsRowNote className="tabular-nums">
|
||||
{counts.total} kunskapsområden · {counts.active} aktiva för ditt företag
|
||||
</div>
|
||||
)}
|
||||
</SettingsRowNote>
|
||||
<HelpPopover className="shrink-0">
|
||||
Utöver vad den minns om ditt företag bygger assistenten på en uppsättning
|
||||
kunskapsområden om svensk bokföring och skatt. Kärnkompetensen gäller alla;
|
||||
bransch- och bolagsanpassningen väljs utifrån ditt företag. Klicka på ett
|
||||
område för att läsa hela kunskapen.
|
||||
</HelpPopover>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{atoms === null && (
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-16 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{atoms === null && (
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{atoms && atoms.length === 0 && (
|
||||
<EmptyState
|
||||
icon={GraduationCap}
|
||||
title="Inga kunskapsområden ännu"
|
||||
description="När din assistent har komponerats dyker dess kunskapsområden upp här."
|
||||
/>
|
||||
)}
|
||||
{atoms && atoms.length === 0 && (
|
||||
<EmptyState
|
||||
icon={GraduationCap}
|
||||
title="Inga kunskapsområden ännu"
|
||||
description="När din assistent har komponerats dyker dess kunskapsområden upp här."
|
||||
/>
|
||||
)}
|
||||
|
||||
{atoms && atoms.length > 0 &&
|
||||
TIER_ORDER.filter((tier) => grouped[tier].length > 0).map((tier) => (
|
||||
<section key={tier} className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{TIER_SECTION[tier].title}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{TIER_SECTION[tier].blurb}</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{grouped[tier].map((atom) => {
|
||||
const isOpen = expandedId === atom.id
|
||||
const isLoading = loadingBody === atom.id
|
||||
const body = bodies[atom.id]
|
||||
const dormant = !atom.active
|
||||
return (
|
||||
<li
|
||||
key={atom.id}
|
||||
className={`rounded-lg border border-border transition-colors ${
|
||||
dormant ? 'bg-muted/30' : 'bg-card'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => toggle(atom)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-start gap-3 p-4 text-left"
|
||||
{atoms && atoms.length > 0 &&
|
||||
TIER_ORDER.filter((tier) => grouped[tier].length > 0).map((tier) => (
|
||||
<SettingsGroup key={tier} label={TIER_SECTION[tier].title} help={TIER_SECTION[tier].blurb}>
|
||||
{grouped[tier].map((atom) => {
|
||||
const isOpen = expandedId === atom.id
|
||||
const isLoading = loadingBody === atom.id
|
||||
const body = bodies[atom.id]
|
||||
const dormant = !atom.active
|
||||
return (
|
||||
<div key={atom.id} className="border-b border-border">
|
||||
<button
|
||||
onClick={() => toggle(atom)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-start gap-3 px-1 py-3 text-left"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform',
|
||||
!isOpen && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
'block text-sm font-medium',
|
||||
dormant ? 'text-muted-foreground' : 'text-foreground',
|
||||
)}
|
||||
>
|
||||
<ChevronDown
|
||||
className={`mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform ${
|
||||
isOpen ? '' : '-rotate-90'
|
||||
}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{atom.title}</span>
|
||||
{tier !== 'horizontal' && (
|
||||
<Badge variant={atom.active ? 'success' : 'secondary'}>
|
||||
{atom.active ? 'Aktiv' : 'Vilande'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{atom.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
{atom.title}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">{atom.description}</span>
|
||||
</span>
|
||||
{/* Chips mark exceptions: active is the normal state and
|
||||
renders as muted text, only dormant gets a Badge. */}
|
||||
{tier !== 'horizontal' && (
|
||||
atom.active ? (
|
||||
<span className="mt-0.5 shrink-0 text-xs text-muted-foreground">Aktiv</span>
|
||||
) : (
|
||||
<Badge variant="outline" className="mt-0.5 shrink-0">Vilande</Badge>
|
||||
)
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="border-t border-border px-4 py-4 pl-11">
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Läser in…
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && body !== undefined && body.length > 0 && (
|
||||
<div className={PROSE}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && body !== undefined && body.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Innehållet kunde inte läsas in.
|
||||
</p>
|
||||
)}
|
||||
{isOpen && (
|
||||
<div className="px-1 pb-4 pl-8">
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Läser in…
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{!isLoading && body !== undefined && body.length > 0 && (
|
||||
<div className={PROSE}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && body !== undefined && body.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Innehållet kunde inte läsas in.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</SettingsGroup>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -18,9 +17,16 @@ import {
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsReveal,
|
||||
SettingsRow,
|
||||
SettingsRowNote,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { Loader2, Plus, Copy, Check, Trash2, Key, ChevronDown, AlertTriangle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { cn, formatDateLong } from '@/lib/utils'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { STAGING_SCOPES } from '@/lib/auth/api-keys'
|
||||
import type { ApiKeyScope } from '@/lib/auth/api-keys'
|
||||
@@ -366,15 +372,6 @@ export function ApiKeysPanel() {
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
if (!iso) return '-'
|
||||
return new Date(iso).toLocaleDateString('sv-SE', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const mcpBase = typeof window !== 'undefined'
|
||||
? `${window.location.origin}/api/extensions/ext/mcp-server/mcp`
|
||||
: '/api/extensions/ext/mcp-server/mcp'
|
||||
@@ -383,140 +380,137 @@ export function ApiKeysPanel() {
|
||||
const mcpUrl = (client: string) => `${mcpBase}?client=${client}`
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
disabled={keys.length >= 10}
|
||||
>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t('create_key')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Key}
|
||||
title={t('empty_title')}
|
||||
description={t('empty_help')}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{keys.map((key) => {
|
||||
const scopeCount = key.scopes?.length ?? 0
|
||||
return (
|
||||
<div
|
||||
key={key.id}
|
||||
className="flex items-center justify-between rounded-md border px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium truncate">{key.name}</p>
|
||||
{key.mode === 'test' && (
|
||||
<Badge variant="secondary" className="shrink-0 text-[10px] font-normal px-1.5 py-0">
|
||||
{t('badge_test')}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{scopeCount === ALL_SCOPES.length
|
||||
? t('all_permissions')
|
||||
: scopeCount === 0
|
||||
? t('no_permissions')
|
||||
: t('permissions_count', { count: scopeCount })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<code className="text-xs text-muted-foreground font-mono">
|
||||
{key.key_prefix}...
|
||||
</code>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('created')} {formatDate(key.created_at)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{key.last_used_at
|
||||
? t('used_on', { date: formatDate(key.last_used_at) })
|
||||
: t('never_used')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRevoke(key.id, key.name)}
|
||||
aria-label={t('revoke_aria', { name: key.name })}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<>
|
||||
<SettingsGroup>
|
||||
{/* Group eyebrow with the group's primary action on the right. Styling
|
||||
mirrors SettingsGroup's label line; the "?" holds the old panel
|
||||
description. */}
|
||||
<div className="flex items-center justify-between gap-4 px-1">
|
||||
<p className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<span>{t('title')}</span>
|
||||
<HelpPopover className="shrink-0">{t('description')}</HelpPopover>
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
disabled={keys.length >= 10}
|
||||
>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t('create_key')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('connect_mcp_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<p className="text-sm font-medium">Claude.ai</p>
|
||||
<span className="text-xs text-muted-foreground">{t('recommended_badge')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{t.rich('claude_ai_instructions', {
|
||||
connectorName,
|
||||
path: (chunks) => <strong>{chunks}</strong>,
|
||||
})}
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Key}
|
||||
title={t('empty_title')}
|
||||
description={t('empty_help')}
|
||||
/>
|
||||
) : (
|
||||
keys.map((key) => {
|
||||
const scopeCount = key.scopes?.length ?? 0
|
||||
const permissionSummary =
|
||||
scopeCount === ALL_SCOPES.length
|
||||
? t('all_permissions')
|
||||
: scopeCount === 0
|
||||
? t('no_permissions')
|
||||
: t('permissions_count', { count: scopeCount })
|
||||
return (
|
||||
<div
|
||||
key={key.id}
|
||||
className="flex items-center gap-3 border-b border-border px-1 py-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm">{key.name}</span>
|
||||
{key.mode === 'test' && (
|
||||
<Badge variant="secondary" className="shrink-0 px-1.5 py-0 text-[10px] font-normal">
|
||||
{t('badge_test')}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-xs text-muted-foreground">
|
||||
{permissionSummary}
|
||||
{' · '}
|
||||
<span className="font-mono">{key.key_prefix}...</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{t('created')} {formatDateLong(key.created_at)}
|
||||
{' · '}
|
||||
{key.last_used_at
|
||||
? t('used_on', { date: formatDateLong(key.last_used_at) })
|
||||
: t('never_used')}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRevoke(key.id, key.name)}
|
||||
aria-label={t('revoke_aria', { name: key.name })}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup label={t('connect_mcp_title')}>
|
||||
<SettingsRow
|
||||
label="Claude.ai"
|
||||
align="baseline"
|
||||
help={t.rich('claude_ai_instructions', {
|
||||
connectorName,
|
||||
path: (chunks) => <strong>{chunks}</strong>,
|
||||
})}
|
||||
>
|
||||
<SettingsRowNote>{t('recommended_badge')}</SettingsRowNote>
|
||||
<div className="w-full min-w-0">
|
||||
<CopyBlock text={mcpUrl('claude-connector')} copyAriaLabel={t('copy_aria')} />
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">{t('claude_code_cursor')}</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{t('terminal_runs_browser_login')}
|
||||
</p>
|
||||
{/* URL is quoted: unquoted `?` in the query string trips zsh globbing. */}
|
||||
<SettingsRow
|
||||
label={t('claude_code_cursor')}
|
||||
align="baseline"
|
||||
help={t('terminal_runs_browser_login')}
|
||||
>
|
||||
{/* URL is quoted: unquoted `?` in the query string trips zsh globbing. */}
|
||||
<div className="w-full min-w-0">
|
||||
<CopyBlock text={`claude mcp add ${connectorName} --transport http "${mcpUrl('claude-code')}"`} copyAriaLabel={t('copy_aria')} />
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setShowApiKeyMethods(!showApiKeyMethods)}
|
||||
>
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${showApiKeyMethods ? '' : '-rotate-90'}`} />
|
||||
{t('connect_with_api_key')}
|
||||
</button>
|
||||
{showApiKeyMethods && (
|
||||
<div className="space-y-6 pt-4 animate-in slide-in-from-top-1 duration-150">
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">Claude Desktop</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{t.rich('claude_desktop_instructions', {
|
||||
code: (chunks) => <code className="text-xs">{chunks}</code>,
|
||||
})}
|
||||
</p>
|
||||
<CopyBlock text={`{
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showApiKeyMethods}
|
||||
onClick={() => setShowApiKeyMethods(!showApiKeyMethods)}
|
||||
className="flex items-center gap-2 px-1 py-3 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 transition-transform duration-150',
|
||||
!showApiKeyMethods && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
{t('connect_with_api_key')}
|
||||
</button>
|
||||
<SettingsReveal open={showApiKeyMethods}>
|
||||
<div className="space-y-6 pb-3 pt-1">
|
||||
<div>
|
||||
<p className="mb-1 text-sm">Claude Desktop</p>
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
{t.rich('claude_desktop_instructions', {
|
||||
code: (chunks) => <code className="text-xs">{chunks}</code>,
|
||||
})}
|
||||
</p>
|
||||
<CopyBlock text={`{
|
||||
"mcpServers": {
|
||||
"${connectorName}": {
|
||||
"command": "npx",
|
||||
@@ -528,22 +522,20 @@ export function ApiKeysPanel() {
|
||||
}
|
||||
}
|
||||
}`} copyAriaLabel={t('copy_aria')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">{t('claude_code_cursor')}</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{t('terminal_with_api_key')}
|
||||
</p>
|
||||
<CopyBlock text={`claude mcp add ${connectorName} --transport http \\
|
||||
<div>
|
||||
<p className="mb-1 text-sm">{t('claude_code_cursor')}</p>
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
{t('terminal_with_api_key')}
|
||||
</p>
|
||||
<CopyBlock text={`claude mcp add ${connectorName} --transport http \\
|
||||
--url "${mcpUrl('claude-code')}" \\
|
||||
--header "Authorization: Bearer gnubok_sk_..."`} copyAriaLabel={t('copy_aria')} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsReveal>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Create key dialog */}
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
@@ -697,6 +689,7 @@ export function ApiKeysPanel() {
|
||||
size="sm"
|
||||
className="absolute right-2 top-2"
|
||||
onClick={handleCopy}
|
||||
aria-label={t('copy_aria')}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-success" />
|
||||
@@ -716,6 +709,6 @@ export function ApiKeysPanel() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,10 +6,14 @@ import { createClient } from '@/lib/supabase/client'
|
||||
import { BankIdAuth } from '@/components/auth/BankIdAuth'
|
||||
import type { BankIdResult } from '@/components/auth/BankIdAuth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Shield, ShieldCheck, Loader2 } from 'lucide-react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
import {
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsRowNote,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
|
||||
interface BankIdIdentity {
|
||||
given_name: string | null
|
||||
@@ -78,73 +82,59 @@ export function BankIdSettings() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLinking) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('link_bankid_title')}</CardTitle>
|
||||
<CardDescription>{t('link_bankid_description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center">
|
||||
<BankIdAuth mode="link" onComplete={handleLinkComplete} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SettingsRow label={t('title')}>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</SettingsRow>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{identity ? (
|
||||
<ShieldCheck className="h-4 w-4 text-success" />
|
||||
) : (
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
{t('title')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{identity ? t('linked_description') : t('not_linked_description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<>
|
||||
<SettingsRow
|
||||
label={t('title')}
|
||||
help={identity ? t('linked_description') : t('not_linked_description')}
|
||||
borderless={isLinking}
|
||||
>
|
||||
{identity ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{identity.given_name} {identity.surname}
|
||||
</span>
|
||||
<span className="ml-2">
|
||||
{t('linked_on', { date: formatDateLong(identity.linked_at) })}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleUnlink}
|
||||
disabled={isUnlinking}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
{isUnlinking ? t('unlinking') : t('unlink_button')}
|
||||
</Button>
|
||||
</div>
|
||||
<>
|
||||
<span className="text-sm font-medium">
|
||||
{identity.given_name} {identity.surname}
|
||||
</span>
|
||||
<SettingsRowNote>
|
||||
{t('linked_on', { date: formatDateLong(identity.linked_at) })}
|
||||
</SettingsRowNote>
|
||||
<SettingsRowEnd>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleUnlink}
|
||||
disabled={isUnlinking}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
{isUnlinking ? t('unlinking') : t('unlink_button')}
|
||||
</Button>
|
||||
</SettingsRowEnd>
|
||||
</>
|
||||
) : isLinking ? (
|
||||
// Active flow: the scan instruction is the actionable content and
|
||||
// stays visible while the QR block below is open.
|
||||
<SettingsRowNote>{t('link_bankid_description')}</SettingsRowNote>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsLinking(true)}
|
||||
>
|
||||
{t('link_button')}
|
||||
</Button>
|
||||
<SettingsRowEnd>
|
||||
<Button variant="ghost" size="sm" onClick={() => setIsLinking(true)}>
|
||||
{t('link_button')}
|
||||
</Button>
|
||||
</SettingsRowEnd>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsRow>
|
||||
|
||||
{/* QR flow: an expanding block below the row. Mounted only while
|
||||
linking so the BankID session starts exactly when requested. */}
|
||||
{isLinking && (
|
||||
<div className="flex flex-col items-center border-b border-border px-1 py-4">
|
||||
<BankIdAuth mode="link" onComplete={handleLinkComplete} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { SettingsSeg } from '@/components/settings/SettingsRows'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
import type { BillingPlan } from '@/lib/stripe/client'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
@@ -25,8 +26,9 @@ const PRICE: Record<BillingPlan, { amount: string; suffix: string; sub: string;
|
||||
|
||||
/**
|
||||
* Interactive billing CTA. Paying companies get the Stripe Customer Portal
|
||||
* (manage/cancel); everyone else gets a reactive plan picker + Checkout. Both
|
||||
* POST to a route that returns a hosted Stripe URL we redirect to.
|
||||
* (manage/cancel) as a quiet row action; everyone else gets a plan picker
|
||||
* (SettingsSeg) + Checkout. Both POST to a route that returns a hosted Stripe
|
||||
* URL we redirect to.
|
||||
*
|
||||
* `firstChargeAt`: when the checkout route will defer the first charge to the
|
||||
* trial's end (see billing/checkout), the date it lands. Shifts the CTA from
|
||||
@@ -72,7 +74,7 @@ export function BillingActions({
|
||||
|
||||
if (isPaying) {
|
||||
return (
|
||||
<Button size="lg" onClick={() => go('/api/billing/portal')} disabled={loading} className="w-full sm:w-auto">
|
||||
<Button variant="outline" size="sm" onClick={() => go('/api/billing/portal')} disabled={loading}>
|
||||
Hantera abonnemang
|
||||
</Button>
|
||||
)
|
||||
@@ -80,58 +82,55 @@ export function BillingActions({
|
||||
|
||||
if (!configured) {
|
||||
return (
|
||||
<Button size="lg" disabled className="w-full">
|
||||
<Button size="lg" disabled className="w-full sm:w-auto">
|
||||
Uppgradering öppnar snart
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const segment = (p: BillingPlan, label: ReactNode) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlan(p)}
|
||||
className={`flex items-center rounded-md px-3 py-2 transition-colors ${
|
||||
plan === p ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-display text-3xl tracking-tight tabular-nums">{PRICE[plan].amount}</span>
|
||||
<span className="text-muted-foreground">{PRICE[plan].suffix}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{PRICE[plan].sub}</p>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<SettingsSeg
|
||||
value={plan}
|
||||
onChange={setPlan}
|
||||
aria-label="Betalningsintervall"
|
||||
options={[
|
||||
{ value: 'monthly', label: 'Månadsvis' },
|
||||
{
|
||||
value: 'yearly',
|
||||
label: (
|
||||
<>
|
||||
Årsvis
|
||||
<span className="ml-2 text-muted-foreground">Spara 2 mån</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="tabular-nums text-foreground">{PRICE[plan].amount}</span> {PRICE[plan].suffix}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground tabular-nums">{PRICE[plan].sub}</p>
|
||||
|
||||
<div className="inline-flex rounded-lg border border-border p-1 text-sm">
|
||||
{segment('monthly', 'Månadsvis')}
|
||||
{segment(
|
||||
'yearly',
|
||||
<>
|
||||
Årsvis
|
||||
<span className="ml-2 text-xs text-muted-foreground">Spara 2 mån</span>
|
||||
</>,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button size="lg" onClick={() => go('/api/billing/checkout', { plan })} disabled={loading} className="w-full">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => go('/api/billing/checkout', { plan })}
|
||||
disabled={loading}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{loading ? 'Öppnar…' : firstChargeAt ? 'Starta abonnemanget: 0 kr idag' : PRICE[plan].cta}
|
||||
{!loading && <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
{firstChargeAt && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
// The one deferred-charge line that stays visible (the rest of the
|
||||
// legal/marketing copy lives behind the group-level "?").
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Första debiteringen sker {formatDateLong(firstChargeAt)}, när provperioden slutar. Avslutar du innan dess
|
||||
kostar det ingenting.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Ingen bindningstid · Avsluta när du vill · Säker betalning via Stripe
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Loader2, Trash2, Plus, ChevronDown, Download, Upload, Building2, Users, Globe, Pencil, Copy } from 'lucide-react'
|
||||
import { SettingsGroup } from '@/components/settings/SettingsRows'
|
||||
import { Loader2, Trash2, Plus, ChevronDown, Download, Upload, Pencil, Copy } from 'lucide-react'
|
||||
import { TEMPLATE_CATEGORY_LABELS, convertLibraryToBookingTemplate } from '@/lib/bookkeeping/template-library'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { TemplateForm } from '@/components/settings/TemplateForm'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types'
|
||||
|
||||
export function BookingTemplatesPanel() {
|
||||
@@ -125,124 +127,131 @@ export function BookingTemplatesPanel() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('export')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => importRef.current?.click()}>
|
||||
<Upload className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('import')}
|
||||
</Button>
|
||||
<input
|
||||
ref={importRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={handleImport}
|
||||
/>
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('new_template')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('create_dialog_title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<TemplateForm
|
||||
mode="create"
|
||||
entityLabels={ENTITY_LABELS}
|
||||
duplicateNamePool={companyTemplateNames}
|
||||
onSaved={() => {
|
||||
setShowCreate(false)
|
||||
fetchTemplates()
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-12">
|
||||
{t('empty_state')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* System templates */}
|
||||
{systemTemplates.length > 0 && (
|
||||
<TemplateSection
|
||||
title={t('section_system')}
|
||||
icon={Globe}
|
||||
templates={systemTemplates}
|
||||
expandedId={expandedId}
|
||||
onToggle={setExpandedId}
|
||||
deletingId={deletingId}
|
||||
onDelete={handleDelete}
|
||||
canDelete={false}
|
||||
canEdit={false}
|
||||
canCustomize={canWrite}
|
||||
onCustomize={setActiveTemplate}
|
||||
entityLabels={ENTITY_LABELS}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Team templates */}
|
||||
{teamTemplates.length > 0 && (
|
||||
<TemplateSection
|
||||
title={t('section_team')}
|
||||
icon={Users}
|
||||
templates={teamTemplates}
|
||||
expandedId={expandedId}
|
||||
onToggle={setExpandedId}
|
||||
deletingId={deletingId}
|
||||
onDelete={handleDelete}
|
||||
canDelete={canWrite}
|
||||
canEdit={canWrite}
|
||||
onEdit={setActiveTemplate}
|
||||
entityLabels={ENTITY_LABELS}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Company templates */}
|
||||
{companyTemplates.length > 0 && (
|
||||
<TemplateSection
|
||||
title={t('section_company')}
|
||||
icon={Building2}
|
||||
templates={companyTemplates}
|
||||
expandedId={expandedId}
|
||||
onToggle={setExpandedId}
|
||||
deletingId={deletingId}
|
||||
onDelete={handleDelete}
|
||||
canDelete={canWrite}
|
||||
canEdit={canWrite}
|
||||
onEdit={setActiveTemplate}
|
||||
entityLabels={ENTITY_LABELS}
|
||||
/>
|
||||
)}
|
||||
<SettingsGroup>
|
||||
{/* Group eyebrow with the panel's actions on the right: export/import as
|
||||
quiet buttons, "Ny mall" as the one pill. The old card description
|
||||
lives behind the "?". */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-1">
|
||||
<p className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<span>{t('title')}</span>
|
||||
<HelpPopover className="shrink-0">{t('description')}</HelpPopover>
|
||||
</p>
|
||||
{canWrite && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t('export')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => importRef.current?.click()}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t('import')}
|
||||
</Button>
|
||||
<input
|
||||
ref={importRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={handleImport}
|
||||
/>
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t('new_template')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('create_dialog_title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<TemplateForm
|
||||
mode="create"
|
||||
entityLabels={ENTITY_LABELS}
|
||||
duplicateNamePool={companyTemplateNames}
|
||||
onSaved={() => {
|
||||
setShowCreate(false)
|
||||
fetchTemplates()
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||
{t('empty_state')}
|
||||
</p>
|
||||
) : null}
|
||||
</SettingsGroup>
|
||||
|
||||
{!isLoading && templates.length > 0 && (
|
||||
<>
|
||||
{/* System templates */}
|
||||
{systemTemplates.length > 0 && (
|
||||
<TemplateSection
|
||||
title={t('section_system')}
|
||||
templates={systemTemplates}
|
||||
expandedId={expandedId}
|
||||
onToggle={setExpandedId}
|
||||
deletingId={deletingId}
|
||||
onDelete={handleDelete}
|
||||
canDelete={false}
|
||||
canEdit={false}
|
||||
canCustomize={canWrite}
|
||||
onCustomize={setActiveTemplate}
|
||||
entityLabels={ENTITY_LABELS}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Team templates */}
|
||||
{teamTemplates.length > 0 && (
|
||||
<TemplateSection
|
||||
title={t('section_team')}
|
||||
templates={teamTemplates}
|
||||
expandedId={expandedId}
|
||||
onToggle={setExpandedId}
|
||||
deletingId={deletingId}
|
||||
onDelete={handleDelete}
|
||||
canDelete={canWrite}
|
||||
canEdit={canWrite}
|
||||
onEdit={setActiveTemplate}
|
||||
entityLabels={ENTITY_LABELS}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Company templates */}
|
||||
{companyTemplates.length > 0 && (
|
||||
<TemplateSection
|
||||
title={t('section_company')}
|
||||
templates={companyTemplates}
|
||||
expandedId={expandedId}
|
||||
onToggle={setExpandedId}
|
||||
deletingId={deletingId}
|
||||
onDelete={handleDelete}
|
||||
canDelete={canWrite}
|
||||
canEdit={canWrite}
|
||||
onEdit={setActiveTemplate}
|
||||
entityLabels={ENTITY_LABELS}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Shared edit / customize dialog. Editing a company or team template uses
|
||||
PUT; customizing a read-only system template creates a company-scoped
|
||||
@@ -276,7 +285,6 @@ export function BookingTemplatesPanel() {
|
||||
|
||||
function TemplateSection({
|
||||
title,
|
||||
icon: Icon,
|
||||
templates,
|
||||
expandedId,
|
||||
onToggle,
|
||||
@@ -290,7 +298,6 @@ function TemplateSection({
|
||||
entityLabels,
|
||||
}: {
|
||||
title: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
templates: BookingTemplateLibrary[]
|
||||
expandedId: string | null
|
||||
onToggle: (id: string | null) => void
|
||||
@@ -304,52 +311,54 @@ function TemplateSection({
|
||||
entityLabels: Record<string, string>
|
||||
}) {
|
||||
const t = useTranslations('settings_booking_templates')
|
||||
const tCommon = useTranslations('common')
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{templates.length}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<SettingsGroup>
|
||||
{/* Origin eyebrow with count; mirrors SettingsGroup's label line. */}
|
||||
<p className="flex items-center gap-2 px-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<span>{title}</span>
|
||||
<span className="tabular-nums">{templates.length}</span>
|
||||
</p>
|
||||
<div>
|
||||
{templates.map((tt) => {
|
||||
const isExpanded = expandedId === tt.id
|
||||
const isConvertible = convertLibraryToBookingTemplate(tt) !== null
|
||||
return (
|
||||
<div
|
||||
key={tt.id}
|
||||
className="rounded-lg border"
|
||||
>
|
||||
<div className="flex items-center gap-3 p-3 hover:bg-muted/50 transition-colors">
|
||||
<div key={tt.id} className="border-b border-border">
|
||||
<div className="flex items-center gap-3 px-1 py-3 transition-colors duration-150 hover:bg-secondary/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(isExpanded ? null : tt.id)}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-left"
|
||||
aria-expanded={isExpanded}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
>
|
||||
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-0' : '-rotate-90'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">{tt.name}</span>
|
||||
<div className="flex items-center gap-1.5 mt-0.5 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{TEMPLATE_CATEGORY_LABELS[tt.category]}
|
||||
{tt.entity_type !== 'all' && ` · ${entityLabels[tt.entity_type]}`}
|
||||
</span>
|
||||
{!isConvertible && (
|
||||
<Badge variant="warning" className="text-[10px] px-1.5 py-0">
|
||||
{t('unconvertible_badge')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-muted-foreground transition-transform',
|
||||
!isExpanded && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="truncate text-sm">{tt.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{TEMPLATE_CATEGORY_LABELS[tt.category]}
|
||||
{tt.entity_type !== 'all' && ` · ${entityLabels[tt.entity_type]}`}
|
||||
</span>
|
||||
{!isConvertible && (
|
||||
<Badge variant="warning" className="px-1.5 py-0 text-[10px]">
|
||||
{t('unconvertible_badge')}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{canCustomize && onCustomize && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
onClick={() => onCustomize(tt)}
|
||||
aria-label={t('customize')}
|
||||
title={t('customize')}
|
||||
className="h-8 w-8 p-0 shrink-0"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -357,11 +366,11 @@ function TemplateSection({
|
||||
{canEdit && onEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
onClick={() => onEdit(tt)}
|
||||
aria-label={t('edit')}
|
||||
title={t('edit')}
|
||||
className="h-8 w-8 p-0 shrink-0"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -369,10 +378,12 @@ function TemplateSection({
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
onClick={() => onDelete(tt.id)}
|
||||
disabled={deletingId === tt.id}
|
||||
className="h-8 w-8 p-0 shrink-0"
|
||||
aria-label={tCommon('delete')}
|
||||
title={tCommon('delete')}
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
{deletingId === tt.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
@@ -383,23 +394,23 @@ function TemplateSection({
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 pt-0">
|
||||
<div className="px-1 pb-3">
|
||||
{tt.description && (
|
||||
<p className="text-xs text-muted-foreground mb-2">{tt.description}</p>
|
||||
<p className="mb-2 text-xs text-muted-foreground">{tt.description}</p>
|
||||
)}
|
||||
<table className="w-full text-xs">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-1 w-14">{t('th_account')}</th>
|
||||
<th className="text-left py-1">{t('th_description')}</th>
|
||||
<th className="text-center py-1 w-16">{t('th_type')}</th>
|
||||
<th className="text-right py-1 w-12">{t('th_debit')}</th>
|
||||
<th className="text-right py-1 w-12">{t('th_credit')}</th>
|
||||
<tr className="border-b border-border">
|
||||
<th className="w-14 py-1 text-left">{t('th_account')}</th>
|
||||
<th className="py-1 text-left">{t('th_description')}</th>
|
||||
<th className="w-16 py-1 text-center">{t('th_type')}</th>
|
||||
<th className="w-12 py-1 text-right">{t('th_debit')}</th>
|
||||
<th className="w-12 py-1 text-right">{t('th_credit')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tt.lines.map((line: BookingTemplateLibraryLine, i: number) => (
|
||||
<tr key={i} className="border-b last:border-0">
|
||||
<tr key={i} className="border-b border-border last:border-0">
|
||||
<td className="py-1 font-mono">{line.account}</td>
|
||||
<td className="py-1">{line.label}</td>
|
||||
<td className="py-1 text-center">
|
||||
@@ -419,6 +430,6 @@ function TemplateSection({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Calendar, Copy, RefreshCw, Loader2, ExternalLink, Check } from 'lucide-react'
|
||||
import { Calendar, Copy, RefreshCw, Loader2, Check } from 'lucide-react'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsInput,
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsRowNote,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import type { CalendarFeed } from '@/types'
|
||||
|
||||
interface CalendarFeedWithUrls extends CalendarFeed {
|
||||
@@ -30,7 +34,6 @@ export function CalendarFeedSettings() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeed()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const fetchFeed = async () => {
|
||||
@@ -162,7 +165,7 @@ export function CalendarFeedSettings() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
@@ -170,177 +173,133 @@ export function CalendarFeedSettings() {
|
||||
|
||||
if (!feed) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
{t('title')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center space-y-4 py-4">
|
||||
<p className="text-muted-foreground">
|
||||
{t('empty_intro')}
|
||||
</p>
|
||||
<Button onClick={createFeed} disabled={isSaving}>
|
||||
<SettingsGroup label={t('title')} help={t('description')}>
|
||||
<SettingsRow label={t('activate_sync')} help={t('empty_intro')}>
|
||||
<SettingsRowEnd>
|
||||
<Button variant="ghost" size="sm" onClick={createFeed} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
{t('creating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
<Calendar className="mr-2 h-3.5 w-3.5" />
|
||||
{t('activate_sync')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<>
|
||||
{/* Feed URL */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
{t('title')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('subscribe_description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Quick add for Apple Calendar */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={openWebcal} className="flex-1">
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
<SettingsGroup label={t('title')} help={t('subscribe_description')}>
|
||||
<SettingsRow
|
||||
label={t('calendar_link_label')}
|
||||
htmlFor="calendar-feed-url"
|
||||
help={t('calendar_link_help')}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="calendar-feed-url"
|
||||
value={feed.httpsUrl}
|
||||
readOnly
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(feed.httpsUrl)}
|
||||
aria-label={t('calendar_link_help')}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
<SettingsRowEnd>
|
||||
<Button variant="ghost" size="sm" onClick={openWebcal}>
|
||||
<Calendar className="mr-2 h-3.5 w-3.5" />
|
||||
{t('add_to_apple_calendar')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => copyToClipboard(feed.httpsUrl)}>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
|
||||
{/* URL display */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('calendar_link_label')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={feed.httpsUrl}
|
||||
readOnly
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('calendar_link_help')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<SettingsRow label={t('create_new_link')} help={t('regen_help')}>
|
||||
{/* Live feed stats stay visible: they are state, not instructions */}
|
||||
{feed.last_accessed_at && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{t('last_fetched')}</span>
|
||||
<span>
|
||||
{new Date(feed.last_accessed_at).toLocaleDateString('sv-SE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<span className="tabular-nums">
|
||||
{t('times_count', { count: feed.access_count })}
|
||||
</span>
|
||||
</div>
|
||||
<SettingsRowNote className="tabular-nums">
|
||||
{t('last_fetched')}{' '}
|
||||
{new Date(feed.last_accessed_at).toLocaleDateString('sv-SE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
{' · '}
|
||||
{t('times_count', { count: feed.access_count })}
|
||||
</SettingsRowNote>
|
||||
)}
|
||||
|
||||
{/* Regenerate link */}
|
||||
<div className="pt-2 border-t">
|
||||
<SettingsRowEnd>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={regenerateToken}
|
||||
disabled={isRegenerating}
|
||||
>
|
||||
{isRegenerating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
{t('creating_new_link')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
<RefreshCw className="mr-2 h-3.5 w-3.5" />
|
||||
{t('create_new_link')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('regen_help')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Content settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('content_title')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('content_description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="include-tax">{t('tax_deadlines_label')}</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('tax_deadlines_help')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="include-tax"
|
||||
checked={feed.include_tax_deadlines}
|
||||
onCheckedChange={(checked) =>
|
||||
updateFeed('include_tax_deadlines', checked)
|
||||
}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
<SettingsGroup label={t('content_title')} help={t('content_description')}>
|
||||
<SettingsRow
|
||||
label={t('tax_deadlines_label')}
|
||||
htmlFor="include-tax"
|
||||
help={t('tax_deadlines_help')}
|
||||
>
|
||||
<Switch
|
||||
id="include-tax"
|
||||
checked={feed.include_tax_deadlines}
|
||||
onCheckedChange={(checked) =>
|
||||
updateFeed('include_tax_deadlines', checked)
|
||||
}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="include-invoices">{t('invoices_label')}</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('invoices_help')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="include-invoices"
|
||||
checked={feed.include_invoices}
|
||||
onCheckedChange={(checked) =>
|
||||
updateFeed('include_invoices', checked)
|
||||
}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SettingsRow
|
||||
label={t('invoices_label')}
|
||||
htmlFor="include-invoices"
|
||||
help={t('invoices_help')}
|
||||
>
|
||||
<Switch
|
||||
id="include-invoices"
|
||||
checked={feed.include_invoices}
|
||||
onCheckedChange={(checked) =>
|
||||
updateFeed('include_invoices', checked)
|
||||
}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
<DestructiveConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { RetentionNotice } from '@/components/ui/retention-notice'
|
||||
import {
|
||||
SettingsDangerZone,
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsRowNote,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
@@ -36,6 +42,7 @@ const branding = getBranding()
|
||||
*/
|
||||
export function CompanyDangerZone() {
|
||||
const t = useTranslations('settings_company')
|
||||
const tRetention = useTranslations('retention_notice')
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const { company, role } = useCompany()
|
||||
@@ -82,23 +89,26 @@ export function CompanyDangerZone() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-4 border-t border-border pt-8">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive">
|
||||
{t('danger_heading')}
|
||||
</h2>
|
||||
|
||||
<RetentionNotice variant="company" />
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setShowDialog(true)}
|
||||
>
|
||||
{t('danger_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<SettingsDangerZone label={t('danger_heading')}>
|
||||
<SettingsRow
|
||||
label={t('danger_button')}
|
||||
borderless
|
||||
// The full BFL retention copy (incl. the backup link) lives behind
|
||||
// the "?": the visible row stays one quiet line.
|
||||
help={<RetentionNotice variant="company" className="border-0 bg-transparent p-0" />}
|
||||
>
|
||||
<SettingsRowNote>{tRetention('company_title')}</SettingsRowNote>
|
||||
<SettingsRowEnd>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDialog(true)}
|
||||
className="text-sm font-medium text-destructive underline underline-offset-2 transition-colors duration-150 hover:text-destructive/80"
|
||||
>
|
||||
{t('danger_button')}
|
||||
</button>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
</SettingsDangerZone>
|
||||
|
||||
<Dialog
|
||||
open={showDialog}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsInput,
|
||||
SettingsRow,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface CompanyInfoFormProps {
|
||||
@@ -11,96 +14,76 @@ interface CompanyInfoFormProps {
|
||||
|
||||
export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
|
||||
const t = useTranslations('settings_company')
|
||||
const orgLocked = settings.onboarding_complete === true
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('company_info_heading')}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company_name">{t('company_name_label')}</Label>
|
||||
<Input
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
defaultValue={settings.company_name || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('company_name_help')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="org_number">{t('org_number_label')}</Label>
|
||||
<Input
|
||||
id="org_number"
|
||||
name="org_number"
|
||||
defaultValue={settings.org_number || ''}
|
||||
disabled={settings.onboarding_complete === true}
|
||||
/>
|
||||
{settings.onboarding_complete && (
|
||||
<p className="text-xs text-muted-foreground">{t('org_number_locked')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address_line1">{t('address_label')}</Label>
|
||||
<Input
|
||||
<SettingsGroup label={t('company_info_heading')}>
|
||||
<SettingsRow
|
||||
label={t('company_name_label')}
|
||||
htmlFor="company_name"
|
||||
help={t('company_name_help')}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
defaultValue={settings.company_name || ''}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('org_number_label')}
|
||||
htmlFor="org_number"
|
||||
help={orgLocked ? t('org_number_locked') : undefined}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="org_number"
|
||||
name="org_number"
|
||||
defaultValue={settings.org_number || ''}
|
||||
disabled={orgLocked}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={t('address_label')} htmlFor="address_line1" align="baseline">
|
||||
<SettingsInput
|
||||
id="address_line1"
|
||||
name="address_line1"
|
||||
defaultValue={settings.address_line1 || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postal_code">{t('postal_code_label')}</Label>
|
||||
<Input
|
||||
id="postal_code"
|
||||
name="postal_code"
|
||||
defaultValue={settings.postal_code || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">{t('city_label')}</Label>
|
||||
<Input
|
||||
id="city"
|
||||
name="city"
|
||||
defaultValue={settings.city || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">{t('phone_label')}</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
defaultValue={settings.phone || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t('email_label')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
defaultValue={settings.email || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website">{t('website_label')}</Label>
|
||||
<Input
|
||||
</SettingsRow>
|
||||
<SettingsRow label={t('postal_code_label')} htmlFor="postal_code" align="baseline">
|
||||
<SettingsInput
|
||||
id="postal_code"
|
||||
name="postal_code"
|
||||
defaultValue={settings.postal_code || ''}
|
||||
className="max-w-24 flex-none"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={t('city_label')} htmlFor="city" align="baseline">
|
||||
<SettingsInput id="city" name="city" defaultValue={settings.city || ''} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label={t('phone_label')} htmlFor="phone" align="baseline">
|
||||
<SettingsInput
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
defaultValue={settings.phone || ''}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={t('email_label')} htmlFor="email" align="baseline">
|
||||
<SettingsInput
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
defaultValue={settings.email || ''}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={t('website_label')} htmlFor="website" align="baseline">
|
||||
<SettingsInput
|
||||
id="website"
|
||||
name="website"
|
||||
defaultValue={settings.website || ''}
|
||||
placeholder="https://"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsInput,
|
||||
SettingsRowNote,
|
||||
SettingsSelect,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
import { Loader2, Plus, Trash2, Mail, Clock, Users } from 'lucide-react'
|
||||
import { Loader2, Plus, Trash2, Mail } from 'lucide-react'
|
||||
|
||||
interface CompanyMemberItem {
|
||||
id: string
|
||||
@@ -159,164 +161,127 @@ export function CompanyMembersSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Invite form */}
|
||||
{canInvite && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('members_invite_title', { companyName: company?.name ?? '' })}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('members_invite_description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleInvite} className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="company-invite-email" className="sr-only">{t('members_invite_email_label')}</Label>
|
||||
<Input
|
||||
id="company-invite-email"
|
||||
type="email"
|
||||
placeholder={t('members_invite_email_placeholder')}
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
disabled={isSending}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Select value={inviteRole} onValueChange={setInviteRole}>
|
||||
<SelectTrigger className="w-full sm:w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="viewer">{t('members_role_viewer')}</SelectItem>
|
||||
<SelectItem value="member">{t('members_role_member')}</SelectItem>
|
||||
<SelectItem value="admin">{t('members_role_admin')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="submit" disabled={isSending || !inviteEmail.trim()}>
|
||||
{isSending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
{t('members_invite_button')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Members list */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
{t('members_title')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('members_count', { count: members.length, companyName: company?.name ?? '' })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="divide-y divide-border/40">
|
||||
{members.map((member) => (
|
||||
<div key={member.id} className="flex items-center justify-between py-3 first:pt-0 last:pb-0">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="h-8 w-8 rounded-full bg-muted/60 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{member.email.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{member.email}
|
||||
{member.is_current_user && (
|
||||
<span className="text-muted-foreground font-normal ml-1">{t('members_you')}</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{roleLabels[member.role] || member.role}
|
||||
</span>
|
||||
{member.source === 'team' && (
|
||||
<span className="text-xs text-muted-foreground">· {t('members_team_badge')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{canInvite && !member.is_current_user && member.role !== 'owner' && member.source !== 'team' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemoveMember(member.id)}
|
||||
disabled={removingId === member.id}
|
||||
>
|
||||
{removingId === member.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<SettingsGroup label={t('members_title')} help={t('members_invite_description')}>
|
||||
{/* Member rows: flat hairline list, no cards. */}
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center gap-3 border-b border-border px-1 py-3"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted/60">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{member.email.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="min-w-0 flex-1 truncate text-sm">
|
||||
{member.email}
|
||||
{member.is_current_user && (
|
||||
<span className="ml-1 text-muted-foreground">{t('members_you')}</span>
|
||||
)}
|
||||
</p>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{roleLabels[member.role] || member.role}
|
||||
{member.source === 'team' && <> · {t('members_team_badge')}</>}
|
||||
</span>
|
||||
{canInvite && !member.is_current_user && member.role !== 'owner' && member.source !== 'team' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
aria-label={t('members_removed')}
|
||||
onClick={() => handleRemoveMember(member.id)}
|
||||
disabled={removingId === member.id}
|
||||
>
|
||||
{removingId === member.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Pending invitations */}
|
||||
{invitations.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('invitations_pending_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="divide-y divide-border/40">
|
||||
{invitations.map((inv) => (
|
||||
<div key={inv.id} className="flex items-center justify-between py-3 first:pt-0 last:pb-0">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="h-8 w-8 rounded-full bg-muted/60 flex items-center justify-center flex-shrink-0">
|
||||
<Mail className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{inv.email}</p>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>
|
||||
{t('invitations_expires', { date: formatDateLong(inv.expires_at) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{roleLabels[inv.role] || inv.role}
|
||||
</span>
|
||||
{canInvite && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRevokeInvite(inv.id)}
|
||||
disabled={revokingId === inv.id}
|
||||
>
|
||||
{revokingId === inv.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Pending invitations continue the same list, visually quieter. */}
|
||||
{invitations.map((inv) => (
|
||||
<div
|
||||
key={inv.id}
|
||||
className="flex items-center gap-3 border-b border-border px-1 py-3"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-dashed border-border">
|
||||
<Mail className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
|
||||
{inv.email}
|
||||
<span className="ml-1 text-xs">
|
||||
· {t('invitations_expires', { date: formatDateLong(inv.expires_at) })}
|
||||
</span>
|
||||
</p>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{roleLabels[inv.role] || inv.role}
|
||||
</span>
|
||||
{canInvite && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
aria-label={t('members_invite_revoked')}
|
||||
onClick={() => handleRevokeInvite(inv.id)}
|
||||
disabled={revokingId === inv.id}
|
||||
>
|
||||
{revokingId === inv.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Inline invite: the list's own last row instead of a separate card. */}
|
||||
{canInvite && (
|
||||
<form onSubmit={handleInvite} className="flex flex-col gap-3 px-1 pt-3 sm:flex-row sm:items-center">
|
||||
<label htmlFor="company-invite-email" className="sr-only">
|
||||
{t('members_invite_email_label')}
|
||||
</label>
|
||||
<SettingsInput
|
||||
id="company-invite-email"
|
||||
type="email"
|
||||
placeholder={t('members_invite_email_placeholder')}
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
disabled={isSending}
|
||||
required
|
||||
className="border-border sm:flex-1"
|
||||
/>
|
||||
<SettingsSelect
|
||||
value={inviteRole}
|
||||
onChange={(e) => setInviteRole(e.target.value)}
|
||||
aria-label={t('members_invite_email_label')}
|
||||
>
|
||||
<option value="viewer">{t('members_role_viewer')}</option>
|
||||
<option value="member">{t('members_role_member')}</option>
|
||||
<option value="admin">{t('members_role_admin')}</option>
|
||||
</SettingsSelect>
|
||||
<Button type="submit" size="sm" disabled={isSending || !inviteEmail.trim()}>
|
||||
{isSending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('members_invite_button')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="px-1 pt-3">
|
||||
<SettingsRowNote>
|
||||
{t('members_count', { count: members.length, companyName: company?.name ?? '' })}
|
||||
</SettingsRowNote>
|
||||
</p>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,10 +7,14 @@ import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { CompanyProfileView } from '@/components/settings/CompanyProfileView'
|
||||
import { refreshCompanyProfileAction } from '@/lib/company/tic-refresh'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsInput,
|
||||
SettingsRow,
|
||||
SettingsRowNote,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
|
||||
type Snapshot = Parameters<typeof CompanyProfileView>[0]['snapshot']
|
||||
|
||||
@@ -24,9 +28,9 @@ const ERROR_MESSAGES: Record<string, string> = {
|
||||
// Företagsprofil: the cached TIC company snapshot (Bolagsuppgifter), rendered
|
||||
// as a read-only section on the Företag tab. Fetched client-side (low-traffic
|
||||
// settings) so it sits alongside the client-rendered company form. RLS scopes
|
||||
// the read to the user's own company. The "Hämta" form lets the user (re)fetch
|
||||
// live when the snapshot is missing or wrong: the recovery path for an enskild
|
||||
// firma whose personnummer previously resolved to the wrong entity.
|
||||
// the read to the user's own company. The trailing "Hämta" row lets the user
|
||||
// (re)fetch live when the snapshot is missing or wrong: the recovery path for
|
||||
// an enskild firma whose personnummer previously resolved to the wrong entity.
|
||||
export function CompanyProfileSection() {
|
||||
const { company } = useCompany()
|
||||
const [snapshot, setSnapshot] = useState<Snapshot>(null)
|
||||
@@ -76,49 +80,44 @@ export function CompanyProfileSection() {
|
||||
if (loading) return <Skeleton className="h-48 w-full rounded-lg" />
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CompanyProfileView snapshot={snapshot} fetchedAt={fetchedAt} />
|
||||
<SettingsGroup label="Bolagsuppgifter">
|
||||
<CompanyProfileView snapshot={snapshot} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{snapshot ? 'Uppdatera bolagsuppgifter' : 'Hämta bolagsuppgifter'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleFetch} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tic_org_number">Organisationsnummer eller personnummer</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="tic_org_number"
|
||||
value={orgInput}
|
||||
onChange={(e) => setOrgInput(e.target.value)}
|
||||
placeholder="XXXXXX-XXXX"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
className="max-w-xs tabular-nums"
|
||||
/>
|
||||
<Button type="submit" disabled={submitting || !orgInput.trim()}>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
Hämtar…
|
||||
</>
|
||||
) : (
|
||||
'Hämta'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Uppgifterna hämtas från Bolagsverket. För enskild firma anges
|
||||
personnumret.
|
||||
</p>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<SettingsRow
|
||||
label={snapshot ? 'Uppdatera bolagsuppgifter' : 'Hämta bolagsuppgifter'}
|
||||
help="Uppgifterna hämtas från Bolagsverket. För enskild firma anges personnumret."
|
||||
borderless
|
||||
>
|
||||
<form
|
||||
onSubmit={handleFetch}
|
||||
className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1"
|
||||
>
|
||||
<SettingsInput
|
||||
id="tic_org_number"
|
||||
aria-label="Organisationsnummer eller personnummer"
|
||||
value={orgInput}
|
||||
onChange={(e) => setOrgInput(e.target.value)}
|
||||
placeholder="XXXXXX-XXXX"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
className="max-w-xs tabular-nums"
|
||||
/>
|
||||
<Button type="submit" size="sm" disabled={submitting || !orgInput.trim()}>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
Hämtar…
|
||||
</>
|
||||
) : (
|
||||
'Hämta'
|
||||
)}
|
||||
</Button>
|
||||
{fetchedAt && (
|
||||
<SettingsRowNote>Uppdaterad {formatDateLong(fetchedAt)}</SettingsRowNote>
|
||||
)}
|
||||
{error && <span className="basis-full text-xs text-destructive">{error}</span>}
|
||||
</form>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { formatDate, formatDateLong } from '@/lib/utils'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { SettingsRow, SettingsRowNote } from '@/components/settings/SettingsRows'
|
||||
|
||||
// Read-only "Bolagsuppgifter" view of the cached TIC company profile
|
||||
// (companies.tic_snapshot). Lives in core: reads the snapshot as plain
|
||||
// (companies.tic_snapshot), rendered as flat settings rows inside the
|
||||
// section's SettingsGroup. Lives in core: reads the snapshot as plain
|
||||
// JSON rather than importing the TIC extension's types, so the
|
||||
// core-build CI boundary (no core → @/extensions/) stays intact.
|
||||
//
|
||||
@@ -65,37 +66,14 @@ function cleanSignatory(raw: string): string[] {
|
||||
.filter((s) => s.length > 0)
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-2">
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function CompanyProfileView({
|
||||
snapshot,
|
||||
fetchedAt,
|
||||
}: {
|
||||
snapshot: SnapshotShape | null
|
||||
fetchedAt: string | null
|
||||
}) {
|
||||
export function CompanyProfileView({ snapshot }: { snapshot: SnapshotShape | null }) {
|
||||
if (!snapshot) {
|
||||
// Dynamic status (nothing fetched yet): stays visible as a quiet line.
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Bolagsuppgifter</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Inga företagsuppgifter hämtade ännu. Uppgifterna hämtas automatiskt
|
||||
från Bolagsverket via organisationsnumret.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="border-b border-border px-1 py-3 text-sm text-muted-foreground">
|
||||
Inga företagsuppgifter hämtade ännu. Uppgifterna hämtas automatiskt
|
||||
från Bolagsverket via organisationsnumret.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -118,164 +96,156 @@ export function CompanyProfileView({
|
||||
? `${snapshot.fiscalYear.startMonthDay} till ${snapshot.fiscalYear.endMonthDay}`
|
||||
: null
|
||||
|
||||
// Only show dated status entries: Bolagsverket emits informational
|
||||
// flags like "Har aldrig varit verksam" with no date that read as
|
||||
// noise next to the real ones. Plain text, no colour: per the
|
||||
// design system, semantic colour is data-only and never chrome.
|
||||
const datedStatuses = (snapshot.statuses ?? []).filter((s) => s.statusDate)
|
||||
|
||||
// Flatten every signatory row, clean ">" markers, split run-on
|
||||
// clauses, and dedupe: the source repeats "Firman tecknas av
|
||||
// styrelsen" across rows.
|
||||
const signatoryRules = Array.from(
|
||||
new Set(
|
||||
(snapshot.signatory ?? []).flatMap((s) => cleanSignatory(s.description)),
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Bolagsuppgifter</CardTitle>
|
||||
{fetchedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Uppdaterad {formatDateLong(fetchedAt)}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8">
|
||||
{/* Identity */}
|
||||
<div>
|
||||
<p className="font-display text-xl tracking-tight">
|
||||
{snapshot.companyName ?? 'Okänt företag'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground tabular-nums">
|
||||
<>
|
||||
<SettingsRow label="Företag">
|
||||
<span className="text-foreground">{snapshot.companyName ?? 'Okänt företag'}</span>
|
||||
{(snapshot.orgNumber || entityLabel) && (
|
||||
<SettingsRowNote className="tabular-nums">
|
||||
{[snapshot.orgNumber, entityLabel].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
{snapshot.address && (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{[
|
||||
snapshot.address.street,
|
||||
[snapshot.address.postalCode, snapshot.address.city].filter(Boolean).join(' '),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{regBadges.length > 0 && (
|
||||
<Section title="Registrerat för">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{regBadges.map((b) => (
|
||||
<Badge key={b} variant="secondary" className="font-normal">{b}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</SettingsRowNote>
|
||||
)}
|
||||
</SettingsRow>
|
||||
|
||||
{Array.isArray(snapshot.sniCodes) && snapshot.sniCodes.length > 0 && (
|
||||
<Section title="SNI-koder">
|
||||
<ul className="space-y-1">
|
||||
{snapshot.sniCodes.map((s) => (
|
||||
<li key={s.code} className="text-sm tabular-nums">
|
||||
<span className="text-foreground">{s.code}</span>{' '}
|
||||
<span className="text-muted-foreground">{s.name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
{snapshot.address && (
|
||||
<SettingsRow label="Adress">
|
||||
<span className="text-muted-foreground">
|
||||
{[
|
||||
snapshot.address.street,
|
||||
[snapshot.address.postalCode, snapshot.address.city].filter(Boolean).join(' '),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
</span>
|
||||
</SettingsRow>
|
||||
)}
|
||||
|
||||
{Array.isArray(snapshot.bankAccounts) && snapshot.bankAccounts.length > 0 && (
|
||||
<Section title="Bankuppgifter">
|
||||
<ul className="space-y-1">
|
||||
{snapshot.bankAccounts.map((b, i) => (
|
||||
<li key={`${b.type}-${b.accountNumber}-${i}`} className="text-sm tabular-nums">
|
||||
<span className="text-muted-foreground">{b.type}:</span>{' '}
|
||||
<span className="text-foreground">{b.accountNumber}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
{regBadges.length > 0 && (
|
||||
<SettingsRow label="Registrerat för">
|
||||
{regBadges.map((b) => (
|
||||
<Badge key={b} variant="secondary" className="font-normal">{b}</Badge>
|
||||
))}
|
||||
</SettingsRow>
|
||||
)}
|
||||
|
||||
{snapshot.purpose && (
|
||||
<Section title="Verksamhet">
|
||||
<p className="text-sm leading-6 text-muted-foreground">{snapshot.purpose}</p>
|
||||
</Section>
|
||||
)}
|
||||
{Array.isArray(snapshot.sniCodes) && snapshot.sniCodes.length > 0 && (
|
||||
<SettingsRow label="SNI-koder" align="baseline">
|
||||
<ul className="w-full space-y-1">
|
||||
{snapshot.sniCodes.map((s) => (
|
||||
<li key={s.code} className="text-sm tabular-nums">
|
||||
<span className="text-foreground">{s.code}</span>{' '}
|
||||
<span className="text-muted-foreground">{s.name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SettingsRow>
|
||||
)}
|
||||
|
||||
<Section title="Anställda">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{snapshot.employeeRange ?? 'Inga anställda'}
|
||||
</p>
|
||||
</Section>
|
||||
{Array.isArray(snapshot.bankAccounts) && snapshot.bankAccounts.length > 0 && (
|
||||
<SettingsRow label="Bankuppgifter" align="baseline">
|
||||
<ul className="w-full space-y-1">
|
||||
{snapshot.bankAccounts.map((b, i) => (
|
||||
<li key={`${b.type}-${b.accountNumber}-${i}`} className="text-sm tabular-nums">
|
||||
<span className="text-muted-foreground">{b.type}:</span>{' '}
|
||||
<span className="text-foreground">{b.accountNumber}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SettingsRow>
|
||||
)}
|
||||
|
||||
<Section title="Senaste bokslut">
|
||||
{snapshot.financials ? (
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
|
||||
<dt className="text-muted-foreground">Nettoomsättning</dt>
|
||||
<dd className="text-right tabular-nums">
|
||||
{snapshot.purpose && (
|
||||
<SettingsRow label="Verksamhet" align="baseline">
|
||||
<p className="text-sm leading-6 text-muted-foreground">{snapshot.purpose}</p>
|
||||
</SettingsRow>
|
||||
)}
|
||||
|
||||
<SettingsRow label="Anställda">
|
||||
<span className="text-muted-foreground">
|
||||
{snapshot.employeeRange ?? 'Inga anställda'}
|
||||
</span>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow label="Senaste bokslut">
|
||||
{snapshot.financials ? (
|
||||
<>
|
||||
<span>
|
||||
<span className="text-muted-foreground">Nettoomsättning </span>
|
||||
<span className="tabular-nums">
|
||||
{snapshot.financials.netSalesK != null
|
||||
? `${snapshot.financials.netSalesK.toLocaleString('sv-SE')} tkr`
|
||||
: '-'}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">Rörelseresultat</dt>
|
||||
<dd className="text-right tabular-nums">
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-muted-foreground">Rörelseresultat </span>
|
||||
<span className="tabular-nums">
|
||||
{snapshot.financials.operatingProfitK != null
|
||||
? `${snapshot.financials.operatingProfitK.toLocaleString('sv-SE')} tkr`
|
||||
: '-'}
|
||||
</dd>
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Inga finansiella uppgifter tillgängliga.</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{(() => {
|
||||
// Only show dated status entries: Bolagsverket emits informational
|
||||
// flags like "Har aldrig varit verksam" with no date that read as
|
||||
// noise next to the real ones. Plain text, no colour: per the
|
||||
// design system, semantic colour is data-only and never chrome.
|
||||
const datedStatuses = (snapshot.statuses ?? []).filter((s) => s.statusDate)
|
||||
if (datedStatuses.length === 0) return null
|
||||
return (
|
||||
<Section title="Status">
|
||||
<dl className="space-y-1">
|
||||
{datedStatuses.map((s, i) => (
|
||||
<div key={`${s.code}-${i}`} className="flex items-center justify-between gap-3 text-sm">
|
||||
<dt className={s.isCeased ? 'text-destructive' : 'text-foreground'}>
|
||||
{s.description ?? s.code ?? '-'}
|
||||
</dt>
|
||||
<dd className="text-xs text-muted-foreground tabular-nums">
|
||||
{formatDate(s.statusDate!)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Section>
|
||||
)
|
||||
})()}
|
||||
|
||||
{fyLabel && (
|
||||
<Section title="Räkenskapsår">
|
||||
<p className="text-sm tabular-nums text-muted-foreground">Nuvarande: {fyLabel}</p>
|
||||
</Section>
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Inga finansiella uppgifter tillgängliga.</span>
|
||||
)}
|
||||
</SettingsRow>
|
||||
|
||||
{(() => {
|
||||
// Flatten every signatory row, clean ">" markers, split run-on
|
||||
// clauses, and dedupe: the source repeats "Firman tecknas av
|
||||
// styrelsen" across rows.
|
||||
const rules = Array.from(
|
||||
new Set(
|
||||
(snapshot.signatory ?? []).flatMap((s) => cleanSignatory(s.description)),
|
||||
),
|
||||
)
|
||||
if (rules.length === 0) return null
|
||||
return (
|
||||
<Section title="Firmateckning">
|
||||
<ul className="space-y-1.5">
|
||||
{rules.map((rule, i) => (
|
||||
<li key={i} className="text-sm leading-6 text-muted-foreground">
|
||||
{rule}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)
|
||||
})()}
|
||||
{datedStatuses.length > 0 && (
|
||||
<SettingsRow label="Status" align="baseline">
|
||||
<dl className="w-full space-y-1">
|
||||
{datedStatuses.map((s, i) => (
|
||||
<div key={`${s.code}-${i}`} className="flex items-center justify-between gap-3 text-sm">
|
||||
<dt className={s.isCeased ? 'text-destructive' : 'text-foreground'}>
|
||||
{s.description ?? s.code ?? '-'}
|
||||
</dt>
|
||||
<dd className="text-xs text-muted-foreground tabular-nums">
|
||||
{formatDate(s.statusDate!)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</SettingsRow>
|
||||
)}
|
||||
|
||||
{Array.isArray(snapshot.representatives) && snapshot.representatives.length > 0 && (
|
||||
<Section title="Företrädare">
|
||||
{fyLabel && (
|
||||
<SettingsRow label="Räkenskapsår">
|
||||
<span className="tabular-nums text-muted-foreground">Nuvarande: {fyLabel}</span>
|
||||
</SettingsRow>
|
||||
)}
|
||||
|
||||
{signatoryRules.length > 0 && (
|
||||
<SettingsRow label="Firmateckning" align="baseline">
|
||||
<ul className="w-full space-y-1">
|
||||
{signatoryRules.map((rule, i) => (
|
||||
<li key={i} className="text-sm leading-6 text-muted-foreground">
|
||||
{rule}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SettingsRow>
|
||||
)}
|
||||
|
||||
{Array.isArray(snapshot.representatives) && snapshot.representatives.length > 0 && (
|
||||
<SettingsRow label="Företrädare" align="baseline">
|
||||
<div className="w-full">
|
||||
{snapshot.board && (
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
{[
|
||||
snapshot.board.numberOfBoardMembers != null
|
||||
? `${snapshot.board.numberOfBoardMembers} styrelseledamot/-ledamöter`
|
||||
@@ -292,7 +262,7 @@ export function CompanyProfileView({
|
||||
{snapshot.representatives.map((r, i) => (
|
||||
<li key={`${r.name}-${i}`} className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-foreground">{r.name ?? '-'}</span>
|
||||
<span className="text-xs text-muted-foreground text-right">
|
||||
<span className="text-right text-xs text-muted-foreground">
|
||||
{[r.positionType, r.positionStart ? formatDate(r.positionStart) : null]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
@@ -300,9 +270,9 @@ export function CompanyProfileView({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { SettingsGroup } from '@/components/settings/SettingsRows'
|
||||
import { Loader2, Trash2, Users, ChevronDown } from 'lucide-react'
|
||||
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
import { formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
|
||||
@@ -92,166 +92,150 @@ export function CounterpartyTemplatesPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={t('empty_title')}
|
||||
description={t('empty_help')}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{templates.map((tt) => {
|
||||
const isExpanded = expandedId === tt.id
|
||||
const isMultiLine = tt.line_pattern && tt.line_pattern.length > 0
|
||||
<SettingsGroup label={t('title')} help={t('description')}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={t('empty_title')}
|
||||
description={t('empty_help')}
|
||||
/>
|
||||
) : (
|
||||
templates.map((tt) => {
|
||||
const isExpanded = expandedId === tt.id
|
||||
const isMultiLine = tt.line_pattern && tt.line_pattern.length > 0
|
||||
|
||||
return (
|
||||
<div key={tt.id} className="rounded-md border overflow-hidden">
|
||||
{/* Clickable summary row */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedId(isExpanded ? null : tt.id)}
|
||||
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium truncate">{formatCounterpartyName(tt.counterparty_name)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5 text-xs text-muted-foreground">
|
||||
{isMultiLine ? (
|
||||
<span className="font-mono">
|
||||
{tt.line_pattern!.filter(lp => lp.type === 'business').map(lp => lp.account).join(', ')}
|
||||
return (
|
||||
<div key={tt.id} className="border-b border-border">
|
||||
{/* Clickable summary row: flat hairline, one line. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedId(isExpanded ? null : tt.id)}
|
||||
aria-expanded={isExpanded}
|
||||
className="flex w-full items-center gap-3 px-1 py-3 text-left transition-colors duration-150 hover:bg-secondary/60"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="truncate text-sm">{formatCounterpartyName(tt.counterparty_name)}</span>
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
{isMultiLine ? (
|
||||
<span className="font-mono">
|
||||
{tt.line_pattern!.filter(lp => lp.type === 'business').map(lp => lp.account).join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono">
|
||||
{tt.debit_account}
|
||||
<span className="text-muted-foreground/50"> → </span>
|
||||
{tt.credit_account}
|
||||
</span>
|
||||
)}
|
||||
{tt.vat_treatment && (
|
||||
<span>· {VAT_LABELS[tt.vat_treatment] || tt.vat_treatment}</span>
|
||||
)}
|
||||
<span>· {t('times_count', { count: tt.occurrence_count })}</span>
|
||||
<span className={`tabular-nums ${confidenceColor(Number(tt.confidence))}`}>
|
||||
{Math.round(Number(tt.confidence) * 100)}%
|
||||
</span>
|
||||
<span>· {SOURCE_LABELS[tt.source] || tt.source}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{isExpanded && (
|
||||
<div className="space-y-3 px-1 pb-3">
|
||||
{/* Account lines */}
|
||||
<div>
|
||||
<p className="mb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{t('booking_label')}</p>
|
||||
{isMultiLine ? (
|
||||
<div className="space-y-1">
|
||||
{tt.line_pattern!.map((lp, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-14 shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
{lp.side === 'debit' ? t('debit_label') : t('credit_label')}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-mono">{tt.debit_account}</span>
|
||||
<span className="text-muted-foreground/50">→</span>
|
||||
<span className="font-mono">{tt.credit_account}</span>
|
||||
</>
|
||||
)}
|
||||
{tt.vat_treatment && (
|
||||
<>
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<span>{VAT_LABELS[tt.vat_treatment] || tt.vat_treatment}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<span>{t('times_count', { count: tt.occurrence_count })}</span>
|
||||
<span className={`tabular-nums ${confidenceColor(Number(tt.confidence))}`}>
|
||||
{Math.round(Number(tt.confidence) * 100)}%
|
||||
</span>
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<span>{SOURCE_LABELS[tt.source] || tt.source}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className={`h-4 w-4 text-muted-foreground shrink-0 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{isExpanded && (
|
||||
<div className="border-t bg-muted/30 px-4 py-3 space-y-3">
|
||||
{/* Account lines */}
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">{t('booking_label')}</p>
|
||||
{isMultiLine ? (
|
||||
<div className="space-y-1">
|
||||
{tt.line_pattern!.map((lp, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-14 shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
{lp.side === 'debit' ? t('debit_label') : t('credit_label')}
|
||||
</span>
|
||||
<span className="font-mono">{formatAccountWithName(lp.account)}</span>
|
||||
{lp.type === 'vat' && lp.vat_rate && (
|
||||
<span className="text-muted-foreground">{t('vat_paren', { rate: Math.round(lp.vat_rate * 100) })}</span>
|
||||
)}
|
||||
{lp.ratio !== undefined && (
|
||||
<span className="text-muted-foreground">({Math.round(lp.ratio * 100)}%)</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-14 shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">{t('debit_label')}</span>
|
||||
<span className="font-mono">{formatAccountWithName(tt.debit_account)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-14 shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">{t('credit_label')}</span>
|
||||
<span className="font-mono">{formatAccountWithName(tt.credit_account)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs">
|
||||
{tt.vat_treatment && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('vat_label')}</span>
|
||||
<span>{VAT_LABELS[tt.vat_treatment] || tt.vat_treatment}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('occurrence_count_label')}</span>
|
||||
<span className="tabular-nums">{tt.occurrence_count}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('confidence_label')}</span>
|
||||
<span className={`tabular-nums ${confidenceColor(Number(tt.confidence))}`}>
|
||||
{Math.round(Number(tt.confidence) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('last_seen_label')}</span>
|
||||
<span>{formatDate(tt.last_seen_date)}</span>
|
||||
</div>
|
||||
{tt.counterparty_aliases && tt.counterparty_aliases.length > 1 && (
|
||||
<div className="col-span-2 flex justify-between">
|
||||
<span className="text-muted-foreground">{t('aliases_label')}</span>
|
||||
<span className="text-right truncate ml-4">{tt.counterparty_aliases.slice(0, 3).join(', ')}{tt.counterparty_aliases.length > 3 ? ` +${tt.counterparty_aliases.length - 3}` : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(tt.id)}
|
||||
disabled={deletingId === tt.id}
|
||||
className="text-destructive hover:text-destructive text-xs h-7"
|
||||
>
|
||||
{deletingId === tt.id ? (
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-1.5 h-3 w-3" />
|
||||
<span className="font-mono">{formatAccountWithName(lp.account)}</span>
|
||||
{lp.type === 'vat' && lp.vat_rate && (
|
||||
<span className="text-muted-foreground">{t('vat_paren', { rate: Math.round(lp.vat_rate * 100) })}</span>
|
||||
)}
|
||||
{t('delete_button')}
|
||||
</Button>
|
||||
{lp.ratio !== undefined && (
|
||||
<span className="text-muted-foreground">({Math.round(lp.ratio * 100)}%)</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-14 shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">{t('debit_label')}</span>
|
||||
<span className="font-mono">{formatAccountWithName(tt.debit_account)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-14 shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">{t('credit_label')}</span>
|
||||
<span className="font-mono">{formatAccountWithName(tt.credit_account)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs">
|
||||
{tt.vat_treatment && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('vat_label')}</span>
|
||||
<span>{VAT_LABELS[tt.vat_treatment] || tt.vat_treatment}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('occurrence_count_label')}</span>
|
||||
<span className="tabular-nums">{tt.occurrence_count}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('confidence_label')}</span>
|
||||
<span className={`tabular-nums ${confidenceColor(Number(tt.confidence))}`}>
|
||||
{Math.round(Number(tt.confidence) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('last_seen_label')}</span>
|
||||
<span>{formatDate(tt.last_seen_date)}</span>
|
||||
</div>
|
||||
{tt.counterparty_aliases && tt.counterparty_aliases.length > 1 && (
|
||||
<div className="col-span-2 flex justify-between">
|
||||
<span className="text-muted-foreground">{t('aliases_label')}</span>
|
||||
<span className="ml-4 truncate text-right">{tt.counterparty_aliases.slice(0, 3).join(', ')}{tt.counterparty_aliases.length > 3 ? ` +${tt.counterparty_aliases.length - 3}` : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(tt.id)}
|
||||
disabled={deletingId === tt.id}
|
||||
className="h-7 text-xs text-destructive hover:text-destructive"
|
||||
>
|
||||
{deletingId === tt.id ? (
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-1.5 h-3 w-3" />
|
||||
)}
|
||||
{t('delete_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,14 @@ import Link from 'next/link'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
|
||||
/**
|
||||
@@ -88,36 +92,33 @@ export function DimensionsToggle() {
|
||||
}
|
||||
}
|
||||
|
||||
const locked = isSaving || !canWrite
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('settings_heading')}
|
||||
</h2>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="dimensions-enabled" className="text-sm">
|
||||
{t('settings_toggle_label')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground max-w-md">
|
||||
{t('settings_toggle_help')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="dimensions-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(next) => void handleChange(next)}
|
||||
disabled={isSaving || !canWrite}
|
||||
/>
|
||||
</div>
|
||||
<SettingsRow label={t('settings_heading')} help={t('settings_toggle_help')}>
|
||||
<Switch
|
||||
id="dimensions-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(next) => void handleChange(next)}
|
||||
disabled={locked}
|
||||
/>
|
||||
<label
|
||||
htmlFor="dimensions-enabled"
|
||||
className={cn('text-sm', locked ? 'text-muted-foreground' : 'cursor-pointer')}
|
||||
>
|
||||
{t('settings_toggle_label')}
|
||||
</label>
|
||||
{enabled && (
|
||||
<Link
|
||||
href="/dimensions"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t('settings_open_register')}
|
||||
</Link>
|
||||
<SettingsRowEnd>
|
||||
<Link
|
||||
href="/dimensions"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t('settings_open_register')}
|
||||
</Link>
|
||||
</SettingsRowEnd>
|
||||
)}
|
||||
</section>
|
||||
</SettingsRow>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,19 +4,20 @@ import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
DestructiveConfirmDialog,
|
||||
useDestructiveConfirm,
|
||||
} from '@/components/ui/destructive-confirm-dialog'
|
||||
import { Loader2, Info, Lock } from 'lucide-react'
|
||||
import { Loader2, Lock } from 'lucide-react'
|
||||
import { parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
|
||||
import { validateFirstPeriod } from '@/components/bookkeeping/FiscalPeriodDateFields'
|
||||
import {
|
||||
FiscalPeriodDateFields,
|
||||
validateFirstPeriod,
|
||||
} from '@/components/bookkeeping/FiscalPeriodDateFields'
|
||||
SettingsGroup,
|
||||
SettingsInput,
|
||||
SettingsRow,
|
||||
SettingsRowNote,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
@@ -175,76 +176,83 @@ export function FiscalPeriodEditor() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-4 border-t border-border pt-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('fp_heading')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('fp_intro')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsGroup label={t('fp_heading')} help={t('fp_intro')}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2 px-1 py-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('fp_loading')}
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<p className="text-sm text-destructive">{loadError}</p>
|
||||
<p className="px-1 py-3 text-sm text-destructive">{loadError}</p>
|
||||
) : !period ? (
|
||||
<p className="text-sm text-muted-foreground">{t('fp_none')}</p>
|
||||
<p className="px-1 py-3 text-sm text-muted-foreground">{t('fp_none')}</p>
|
||||
) : isBlocked ? (
|
||||
<BlockedState
|
||||
period={period}
|
||||
postedCount={postedCount ?? 0}
|
||||
/>
|
||||
<BlockedRow period={period} postedCount={postedCount ?? 0} />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-warning/20 bg-warning/5 p-3 text-sm flex gap-2">
|
||||
<Info className="h-4 w-4 text-warning flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{t('fp_warning_title')}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t('fp_warning_body')}
|
||||
{isEF && t('fp_warning_ef_suffix')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
{/* Consequential warning: stays visible as one quiet ochre sentence. */}
|
||||
<p className="px-1 py-3 text-[12.5px] text-attn">
|
||||
{t('fp_warning_title')} {t('fp_warning_body')}
|
||||
{isEF ? t('fp_warning_ef_suffix') : null}
|
||||
</p>
|
||||
|
||||
<FiscalPeriodDateFields
|
||||
startDate={startDate}
|
||||
onStartDateChange={setStartDate}
|
||||
endDate={endDate}
|
||||
entityType={company?.entity_type}
|
||||
summaryTitle={t('fp_summary_title')}
|
||||
endDateSlot={
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fp_end">{t('fp_end_date_label')}</Label>
|
||||
<Input
|
||||
id="fp_end"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('fp_end_date_help')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
label="Startdatum"
|
||||
htmlFor="fiscal-period-start"
|
||||
help="Första räkenskapsåret kan börja valfri dag."
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="fiscal-period-start"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="max-w-44 flex-none tabular-nums"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<SettingsRow
|
||||
label={t('fp_end_date_label')}
|
||||
htmlFor="fp_end"
|
||||
help={t('fp_end_date_help')}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="fp_end"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="max-w-44 flex-none tabular-nums"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
{validation.canSummarise && (
|
||||
<p className="px-1 pt-3 text-xs text-muted-foreground">
|
||||
{t('fp_summary_title')}:{' '}
|
||||
<span className="tabular-nums">
|
||||
{formatSwedishDate(startDate)} till {formatSwedishDate(endDate)}
|
||||
</span>
|
||||
{validation.months !== null && <> · {validation.months} månader</>}
|
||||
</p>
|
||||
)}
|
||||
{validation.error && (
|
||||
<p className="px-1 pt-1 text-xs text-destructive">{validation.error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 px-1 pt-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={!isDirty || isSaving}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t('fp_reset')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
!isDirty ||
|
||||
@@ -264,15 +272,15 @@ export function FiscalPeriodEditor() {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</SettingsGroup>
|
||||
<DestructiveConfirmDialog {...dialogProps} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BlockedState({
|
||||
function BlockedRow({
|
||||
period,
|
||||
postedCount,
|
||||
}: {
|
||||
@@ -287,26 +295,27 @@ function BlockedState({
|
||||
: t('fp_blocked_reason_posted', { count: postedCount })
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Lock className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{t('fp_blocked_title')}</p>
|
||||
<p className="text-sm text-muted-foreground">{reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<p>
|
||||
{t('fp_blocked_first_year')}{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{formatSwedishDate(period.period_start)} – {formatSwedishDate(period.period_end)}
|
||||
</span>
|
||||
{isCalendarYear(period) ? t('fp_blocked_calendar_year') : t('fp_blocked_broken_year')}
|
||||
</p>
|
||||
<p>
|
||||
{t('fp_blocked_explainer')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsRow
|
||||
// The key carries a trailing colon from its old inline usage; strip it
|
||||
// for the micro-label position.
|
||||
label={t('fp_blocked_first_year').replace(/:$/, '')}
|
||||
help={
|
||||
<>
|
||||
<p>
|
||||
{t('fp_blocked_title')}. {reason}
|
||||
</p>
|
||||
<p className="mt-2">{t('fp_blocked_explainer')}</p>
|
||||
</>
|
||||
}
|
||||
borderless
|
||||
>
|
||||
<Lock aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="tabular-nums">
|
||||
{formatSwedishDate(period.period_start)} till {formatSwedishDate(period.period_end)}
|
||||
</span>
|
||||
<SettingsRowNote>
|
||||
{(isCalendarYear(period) ? t('fp_blocked_calendar_year') : t('fp_blocked_broken_year')).trim()}
|
||||
</SettingsRowNote>
|
||||
</SettingsRow>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DestructiveConfirmDialog,
|
||||
useDestructiveConfirm,
|
||||
} from '@/components/ui/destructive-confirm-dialog'
|
||||
import { SettingsGroup } from '@/components/settings/SettingsRows'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Plus, Lock, Unlock, Loader2 } from 'lucide-react'
|
||||
@@ -25,10 +26,11 @@ function periodStatus(p: FiscalPeriod): 'closed' | 'locked' | 'open' {
|
||||
return 'open'
|
||||
}
|
||||
|
||||
const STATUS_VARIANT: Record<'closed' | 'locked' | 'open', 'secondary' | 'warning' | 'success'> = {
|
||||
// Open is the normal state and renders as muted text; only the deviations
|
||||
// (locked/closed) get a chip (UI-migration convention 5).
|
||||
const STATUS_VARIANT: Record<'closed' | 'locked', 'secondary' | 'warning'> = {
|
||||
closed: 'secondary',
|
||||
locked: 'warning',
|
||||
open: 'success',
|
||||
}
|
||||
|
||||
export function FiscalYearsManager() {
|
||||
@@ -113,14 +115,83 @@ export function FiscalYearsManager() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('fy_heading')}
|
||||
</h2>
|
||||
<SettingsGroup label={t('fy_heading')} help={t('fy_help')}>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2 px-1 py-3">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
) : hasError ? (
|
||||
<p className="px-1 py-3 text-sm text-muted-foreground">{t('fy_load_error')}</p>
|
||||
) : sorted.length === 0 ? (
|
||||
<p className="px-1 py-3 text-sm text-muted-foreground">{t('fy_empty')}</p>
|
||||
) : (
|
||||
// Period rows: flat hairline list, no cards.
|
||||
sorted.map((p) => {
|
||||
const status = periodStatus(p)
|
||||
const isMutating = mutatingId === p.id
|
||||
return (
|
||||
<div key={p.id} className="flex items-center gap-3 border-b border-border px-1 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium">{p.name}</span>
|
||||
<span className="ml-2 text-sm text-muted-foreground tabular-nums">
|
||||
{formatDate(p.period_start)} - {formatDate(p.period_end)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{status === 'open' ? (
|
||||
<span className="text-xs text-muted-foreground">{t('fy_status_open')}</span>
|
||||
) : (
|
||||
<Badge variant={STATUS_VARIANT[status]}>{t(`fy_status_${status}`)}</Badge>
|
||||
)}
|
||||
{canManage && status === 'open' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
disabled={isMutating}
|
||||
onClick={() => handleLock(p)}
|
||||
>
|
||||
{isMutating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Lock className="mr-1.5 h-4 w-4" />
|
||||
{t('fy_action_lock')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{canManage && status === 'locked' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
disabled={isMutating}
|
||||
onClick={() => handleUnlock(p)}
|
||||
>
|
||||
{isMutating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Unlock className="mr-1.5 h-4 w-4" />
|
||||
{t('fy_action_unlock')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Trailing quiet action: create the next fiscal year. */}
|
||||
<div className="px-1 pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
@@ -129,73 +200,6 @@ export function FiscalYearsManager() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">{t('fy_help')}</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
) : hasError ? (
|
||||
<p className="text-sm text-muted-foreground">{t('fy_load_error')}</p>
|
||||
) : sorted.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('fy_empty')}</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{sorted.map((p) => {
|
||||
const status = periodStatus(p)
|
||||
const isMutating = mutatingId === p.id
|
||||
return (
|
||||
<div key={p.id} className="flex items-center justify-between gap-4 py-2">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium">{p.name}</span>
|
||||
<span className="ml-2 text-sm text-muted-foreground tabular-nums">
|
||||
{formatDate(p.period_start)} - {formatDate(p.period_end)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<Badge variant={STATUS_VARIANT[status]}>{t(`fy_status_${status}`)}</Badge>
|
||||
{canManage && status === 'open' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isMutating}
|
||||
onClick={() => handleLock(p)}
|
||||
>
|
||||
{isMutating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Lock className="mr-1.5 h-4 w-4" />
|
||||
{t('fy_action_lock')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{canManage && status === 'locked' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isMutating}
|
||||
onClick={() => handleUnlock(p)}
|
||||
>
|
||||
{isMutating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Unlock className="mr-1.5 h-4 w-4" />
|
||||
{t('fy_action_unlock')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreatePeriodDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
@@ -205,6 +209,6 @@ export function FiscalYearsManager() {
|
||||
/>
|
||||
|
||||
<DestructiveConfirmDialog {...dialogProps} />
|
||||
</section>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import { useEffect, useState, useSyncExternalStore } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { MonitorDown } from 'lucide-react'
|
||||
import {
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsRowNote,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
|
||||
type BeforeInstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>
|
||||
@@ -25,14 +30,15 @@ function getIsStandalone() {
|
||||
}
|
||||
|
||||
/**
|
||||
* "Install as app" section for account settings. Chromium fires
|
||||
* "Install as app" row for account settings. Chromium fires
|
||||
* beforeinstallprompt when the PWA is installable; we capture it and offer a
|
||||
* real install button. Other browsers get per-platform instructions. Hidden
|
||||
* entirely when already running standalone (installed) or after installing.
|
||||
* real install button. Other browsers get per-platform instructions as the
|
||||
* row's visible note (that text IS the action). Hidden entirely when already
|
||||
* running standalone (installed) or after installing.
|
||||
*/
|
||||
export function InstallAppSection() {
|
||||
const t = useTranslations('settings')
|
||||
// Server snapshot says standalone so the section is absent from server HTML
|
||||
// Server snapshot says standalone so the row is absent from server HTML
|
||||
// and only appears client-side when actually running in a browser tab.
|
||||
const isStandalone = useSyncExternalStore(subscribeDisplayMode, getIsStandalone, () => true)
|
||||
const [installed, setInstalled] = useState(false)
|
||||
@@ -74,21 +80,19 @@ export function InstallAppSection() {
|
||||
: t('install_app_hint_generic')
|
||||
|
||||
return (
|
||||
<section className="space-y-4 border-t border-border pt-8">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('install_app_title')}
|
||||
</h2>
|
||||
<div className="flex items-center justify-between gap-4 p-4 border rounded-lg">
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
{installPrompt ? t('install_app_description') : hint}
|
||||
</p>
|
||||
{installPrompt && (
|
||||
<Button variant="outline" onClick={handleInstall}>
|
||||
<MonitorDown className="mr-2 h-4 w-4" />
|
||||
<SettingsRow label={t('install_app_title')} help={t('install_app_description')}>
|
||||
{installPrompt ? (
|
||||
<SettingsRowEnd>
|
||||
<Button variant="ghost" size="sm" onClick={handleInstall}>
|
||||
<MonitorDown className="mr-2 h-3.5 w-3.5" />
|
||||
{t('install_app_button')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</SettingsRowEnd>
|
||||
) : (
|
||||
// No captured prompt: the platform-specific instruction is the only
|
||||
// way to act, so it stays visible instead of hiding behind the "?".
|
||||
<SettingsRowNote>{hint}</SettingsRowNote>
|
||||
)}
|
||||
</SettingsRow>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsTextarea,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import {
|
||||
EMAIL_PATTERN,
|
||||
MAX_INVOICE_EMAIL_COPY_RECIPIENTS,
|
||||
@@ -107,44 +110,41 @@ export function InvoiceEmailRecipientsSettings({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('heading')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('description')}</p>
|
||||
</div>
|
||||
<SettingsGroup label={t('heading')} help={t('description')}>
|
||||
<SettingsRow
|
||||
label={t('cc_label')}
|
||||
htmlFor="invoice-email-cc"
|
||||
help={t('cc_hint')}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsTextarea
|
||||
id="invoice-email-cc"
|
||||
value={ccText}
|
||||
onChange={(event) => setCcText(event.target.value)}
|
||||
placeholder={t('cc_placeholder')}
|
||||
rows={3}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('bcc_label')}
|
||||
htmlFor="invoice-email-bcc"
|
||||
help={t('bcc_hint')}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsTextarea
|
||||
id="invoice-email-bcc"
|
||||
value={bccText}
|
||||
onChange={(event) => setBccText(event.target.value)}
|
||||
placeholder={t('bcc_placeholder')}
|
||||
rows={3}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice-email-cc">{t('cc_label')}</Label>
|
||||
<Textarea
|
||||
id="invoice-email-cc"
|
||||
value={ccText}
|
||||
onChange={(event) => setCcText(event.target.value)}
|
||||
placeholder={t('cc_placeholder')}
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('cc_hint')}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice-email-bcc">{t('bcc_label')}</Label>
|
||||
<Textarea
|
||||
id="invoice-email-bcc"
|
||||
value={bccText}
|
||||
onChange={(event) => setBccText(event.target.value)}
|
||||
placeholder={t('bcc_placeholder')}
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('bcc_hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={save} disabled={isSaving}>
|
||||
<div className="flex justify-end px-1 pt-4">
|
||||
<Button type="button" size="sm" onClick={save} disabled={isSaving}>
|
||||
{isSaving ? t('saving') : t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsInput,
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsRowNote,
|
||||
SettingsSeg,
|
||||
SettingsTextarea,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import {
|
||||
INVOICE_EMAIL_DEFAULT_TEXTS,
|
||||
INVOICE_EMAIL_PLACEHOLDER_KEYS,
|
||||
@@ -86,6 +91,7 @@ export function InvoiceEmailTextsSettings({ settings, onUpdate }: InvoiceEmailTe
|
||||
const t = useTranslations('settings_email_texts')
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useCanWrite()
|
||||
const [lang, setLang] = useState<Lang>('sv')
|
||||
const [texts, setTexts] = useState<DisplayTexts>(() => buildDisplay(settings.invoice_email_texts))
|
||||
// Serialized last-persisted overrides: skips no-op PUTs on blur without
|
||||
// edits. toOverrides() builds keys in a fixed order, so comparison is stable.
|
||||
@@ -98,7 +104,8 @@ export function InvoiceEmailTextsSettings({ settings, onUpdate }: InvoiceEmailTe
|
||||
}
|
||||
|
||||
// Whole-object save: a JSONB column update replaces the stored value, and
|
||||
// the inactive language tab is unmounted, so per-field PATCHes can't work.
|
||||
// the inactive language's fields are unmounted (conditional render below),
|
||||
// so per-field PATCHes can't work.
|
||||
const persist = useCallback(async (display: DisplayTexts) => {
|
||||
const overrides = toOverrides(display)
|
||||
const serialized = JSON.stringify(overrides)
|
||||
@@ -133,78 +140,74 @@ export function InvoiceEmailTextsSettings({ settings, onUpdate }: InvoiceEmailTe
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('heading')}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('description')}</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="sv">
|
||||
<TabsList>
|
||||
<TabsTrigger value="sv">{t('tab_sv')}</TabsTrigger>
|
||||
<TabsTrigger value="en">{t('tab_en')}</TabsTrigger>
|
||||
</TabsList>
|
||||
{LANGS.map((lang) => (
|
||||
<TabsContent key={lang} value={lang} className="mt-4 space-y-4">
|
||||
{lang === 'en' && (
|
||||
<p className="text-xs text-muted-foreground">{t('en_tab_hint')}</p>
|
||||
)}
|
||||
{FIELD_CONFIG.map(({ field, labelKey, multiline }) => {
|
||||
const id = `invoice-email-${field}-${lang}`
|
||||
const modified =
|
||||
texts[lang][field].trim() !== INVOICE_EMAIL_DEFAULT_TEXTS[lang][field]
|
||||
const common = {
|
||||
id,
|
||||
value: texts[lang][field],
|
||||
onBlur: handleBlur,
|
||||
disabled: !canWrite,
|
||||
}
|
||||
return (
|
||||
<div key={field} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={id}>{t(labelKey)}</Label>
|
||||
{modified && canWrite && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetField(lang, field)}
|
||||
className="text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
||||
>
|
||||
{t('reset_label')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{multiline ? (
|
||||
<Textarea
|
||||
{...common}
|
||||
rows={4}
|
||||
onChange={(e) => setField(lang, field, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
{...common}
|
||||
onChange={(e) => setField(lang, field, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
<div className="space-y-1">
|
||||
{/* Legend is rendered from code, not messages/*.json: ICU message
|
||||
syntax treats literal braces as interpolation. */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{t('placeholders_help')}</span>
|
||||
{INVOICE_EMAIL_PLACEHOLDER_KEYS.map((key) => (
|
||||
<code key={key} className="rounded bg-muted px-1 text-xs">{`{${key}}`}</code>
|
||||
))}
|
||||
<SettingsGroup
|
||||
label={t('heading')}
|
||||
help={
|
||||
<div className="space-y-2">
|
||||
<p>{t('description')}</p>
|
||||
{/* Legend is rendered from code, not messages/*.json: ICU message
|
||||
syntax treats literal braces as interpolation. */}
|
||||
<p>
|
||||
{t('placeholders_help')}{' '}
|
||||
{INVOICE_EMAIL_PLACEHOLDER_KEYS.map((key) => (
|
||||
<code key={key} className="mr-1 rounded bg-muted px-1 text-xs">{`{${key}}`}</code>
|
||||
))}
|
||||
</p>
|
||||
<p>{t('firstname_note')}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('firstname_note')}</p>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-border px-1 py-3">
|
||||
<SettingsSeg
|
||||
value={lang}
|
||||
onChange={setLang}
|
||||
options={[
|
||||
{ value: 'sv', label: t('tab_sv') },
|
||||
{ value: 'en', label: t('tab_en') },
|
||||
]}
|
||||
aria-label={t('heading')}
|
||||
/>
|
||||
{lang === 'en' && <SettingsRowNote>{t('en_tab_hint')}</SettingsRowNote>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{FIELD_CONFIG.map(({ field, labelKey, multiline }) => {
|
||||
const id = `invoice-email-${field}-${lang}`
|
||||
const modified =
|
||||
texts[lang][field].trim() !== INVOICE_EMAIL_DEFAULT_TEXTS[lang][field]
|
||||
const common = {
|
||||
id,
|
||||
value: texts[lang][field],
|
||||
onBlur: handleBlur,
|
||||
disabled: !canWrite,
|
||||
}
|
||||
return (
|
||||
<SettingsRow key={`${lang}-${field}`} label={t(labelKey)} htmlFor={id} align="baseline">
|
||||
{multiline ? (
|
||||
<SettingsTextarea
|
||||
{...common}
|
||||
rows={4}
|
||||
onChange={(e) => setField(lang, field, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<SettingsInput
|
||||
{...common}
|
||||
onChange={(e) => setField(lang, field, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{modified && canWrite && (
|
||||
<SettingsRowEnd>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetField(lang, field)}
|
||||
className="text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
||||
>
|
||||
{t('reset_label')}
|
||||
</button>
|
||||
</SettingsRowEnd>
|
||||
)}
|
||||
</SettingsRow>
|
||||
)
|
||||
})}
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
SettingsGroup,
|
||||
SettingsInput,
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsRowNote,
|
||||
SettingsSeg,
|
||||
SettingsSelect,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { validateBankgiroNumber, validatePlusgiroNumber } from '@/lib/bankgiro/luhn'
|
||||
@@ -282,18 +282,11 @@ export function InvoicePaymentAccountsSettings({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('heading')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('description')}</p>
|
||||
</div>
|
||||
|
||||
<SettingsGroup label={t('heading')} help={t('description')}>
|
||||
{hasExternalUpdate && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col gap-3 rounded-lg border border-border bg-muted/40 p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
className="mt-3 flex flex-col gap-3 rounded-lg border border-border bg-muted/40 p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{t('conflict_title')}</p>
|
||||
@@ -305,146 +298,165 @@ export function InvoicePaymentAccountsSettings({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2" aria-label={t('currency_tabs_label')}>
|
||||
{configuredCurrencies.map((currency) => (
|
||||
<Button
|
||||
key={currency}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={activeCurrency === currency ? 'default' : 'outline'}
|
||||
onClick={() => setActiveCurrency(currency)}
|
||||
>
|
||||
{currency}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<SettingsRow label={t('currency_tabs_label')}>
|
||||
<SettingsSeg
|
||||
value={activeCurrency}
|
||||
onChange={(currency) => setActiveCurrency(currency)}
|
||||
options={configuredCurrencies.map((currency) => ({ value: currency, label: currency }))}
|
||||
aria-label={t('currency_tabs_label')}
|
||||
/>
|
||||
{activeCurrency !== 'SEK' && (
|
||||
<SettingsRowNote>
|
||||
{t('foreign_account_hint', { currency: activeCurrency })}
|
||||
</SettingsRowNote>
|
||||
)}
|
||||
{activeCurrency !== 'SEK' && (
|
||||
<SettingsRowEnd>
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeActiveCurrency}
|
||||
className="text-xs text-muted-foreground transition-colors duration-150 hover:text-destructive"
|
||||
>
|
||||
{t('remove_currency')}
|
||||
</button>
|
||||
</SettingsRowEnd>
|
||||
)}
|
||||
</SettingsRow>
|
||||
|
||||
{availableCurrencies.length > 0 && (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||
<div className="w-full space-y-2 sm:max-w-52">
|
||||
<Label>{t('add_currency_label')}</Label>
|
||||
<Select
|
||||
value={currencyToAdd}
|
||||
onValueChange={(next) => setCurrencyToAdd(next as Currency)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('add_currency_placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableCurrencies.map((currency) => (
|
||||
<SelectItem key={currency} value={currency}>{currency}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={addCurrency} disabled={!currencyToAdd}>
|
||||
<SettingsRow label={t('add_currency_label')}>
|
||||
<SettingsSelect
|
||||
value={currencyToAdd}
|
||||
onChange={(event) => setCurrencyToAdd(event.target.value as Currency | '')}
|
||||
aria-label={t('add_currency_label')}
|
||||
>
|
||||
<option value="">{t('add_currency_placeholder')}</option>
|
||||
{availableCurrencies.map((currency) => (
|
||||
<option key={currency} value={currency}>{currency}</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addCurrency}
|
||||
disabled={!currencyToAdd}
|
||||
>
|
||||
{t('add_currency')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-border p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-medium">{t('account_heading', { currency: activeCurrency })}</h3>
|
||||
{activeCurrency !== 'SEK' && (
|
||||
<p className="text-xs text-muted-foreground">{t('foreign_account_hint')}</p>
|
||||
)}
|
||||
</div>
|
||||
{activeCurrency !== 'SEK' && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={removeActiveCurrency}>
|
||||
{t('remove_currency')}
|
||||
</Button>
|
||||
)}
|
||||
<SettingsRow label={t('bank_label')}>
|
||||
{/* Typeahead combobox stays boxed on purpose: it is a picker, not a field. */}
|
||||
<div className="min-w-0 flex-1 sm:max-w-64">
|
||||
<BankNameCombobox
|
||||
value={value(activeAccount, 'bank_name')}
|
||||
onChange={(next) => updateField('bank_name', next)}
|
||||
enableBankingEnabled={hasBankingExtension}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('clearing_label')}
|
||||
htmlFor={`payment-clearing-${activeCurrency}`}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id={`payment-clearing-${activeCurrency}`}
|
||||
inputMode="numeric"
|
||||
maxLength={5}
|
||||
value={value(activeAccount, 'clearing_number')}
|
||||
onChange={(event) => updateField('clearing_number', event.target.value.replace(/\D/g, ''))}
|
||||
className="max-w-24 flex-none tabular-nums"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('account_number_label')}
|
||||
htmlFor={`payment-account-${activeCurrency}`}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id={`payment-account-${activeCurrency}`}
|
||||
inputMode="numeric"
|
||||
maxLength={12}
|
||||
value={value(activeAccount, 'account_number')}
|
||||
onChange={(event) => updateField('account_number', event.target.value.replace(/\D/g, ''))}
|
||||
className="max-w-40 flex-none tabular-nums"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('bankgiro_label')}
|
||||
htmlFor={`payment-bankgiro-${activeCurrency}`}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id={`payment-bankgiro-${activeCurrency}`}
|
||||
value={value(activeAccount, 'bankgiro')}
|
||||
onChange={(event) => updateField('bankgiro', event.target.value)}
|
||||
className="max-w-40 flex-none tabular-nums"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('plusgiro_label')}
|
||||
htmlFor={`payment-plusgiro-${activeCurrency}`}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id={`payment-plusgiro-${activeCurrency}`}
|
||||
value={value(activeAccount, 'plusgiro')}
|
||||
onChange={(event) => updateField('plusgiro', event.target.value)}
|
||||
className="max-w-40 flex-none tabular-nums"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('swish_label')}
|
||||
htmlFor={`payment-swish-${activeCurrency}`}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id={`payment-swish-${activeCurrency}`}
|
||||
value={value(activeAccount, 'swish')}
|
||||
onChange={(event) => updateField('swish', event.target.value)}
|
||||
className="max-w-40 flex-none tabular-nums"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={
|
||||
activeCurrency !== 'SEK'
|
||||
? `${t('iban_label')} ${t('required_suffix')}`
|
||||
: t('iban_label')
|
||||
}
|
||||
htmlFor={`payment-iban-${activeCurrency}`}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id={`payment-iban-${activeCurrency}`}
|
||||
value={value(activeAccount, 'iban')}
|
||||
onChange={(event) => updateField('iban', event.target.value.toUpperCase())}
|
||||
placeholder="SE00 0000 0000 0000 0000 0000"
|
||||
className="tabular-nums"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('bic_label')}
|
||||
htmlFor={`payment-bic-${activeCurrency}`}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id={`payment-bic-${activeCurrency}`}
|
||||
maxLength={11}
|
||||
value={value(activeAccount, 'bic')}
|
||||
onChange={(event) => updateField('bic', event.target.value.toUpperCase())}
|
||||
className="max-w-32 flex-none tabular-nums"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('bank_label')}</Label>
|
||||
<BankNameCombobox
|
||||
value={value(activeAccount, 'bank_name')}
|
||||
onChange={(next) => updateField('bank_name', next)}
|
||||
enableBankingEnabled={hasBankingExtension}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`payment-clearing-${activeCurrency}`}>{t('clearing_label')}</Label>
|
||||
<Input
|
||||
id={`payment-clearing-${activeCurrency}`}
|
||||
inputMode="numeric"
|
||||
maxLength={5}
|
||||
value={value(activeAccount, 'clearing_number')}
|
||||
onChange={(event) => updateField('clearing_number', event.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`payment-account-${activeCurrency}`}>{t('account_number_label')}</Label>
|
||||
<Input
|
||||
id={`payment-account-${activeCurrency}`}
|
||||
inputMode="numeric"
|
||||
maxLength={12}
|
||||
value={value(activeAccount, 'account_number')}
|
||||
onChange={(event) => updateField('account_number', event.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`payment-bankgiro-${activeCurrency}`}>{t('bankgiro_label')}</Label>
|
||||
<Input
|
||||
id={`payment-bankgiro-${activeCurrency}`}
|
||||
value={value(activeAccount, 'bankgiro')}
|
||||
onChange={(event) => updateField('bankgiro', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`payment-plusgiro-${activeCurrency}`}>{t('plusgiro_label')}</Label>
|
||||
<Input
|
||||
id={`payment-plusgiro-${activeCurrency}`}
|
||||
value={value(activeAccount, 'plusgiro')}
|
||||
onChange={(event) => updateField('plusgiro', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`payment-swish-${activeCurrency}`}>{t('swish_label')}</Label>
|
||||
<Input
|
||||
id={`payment-swish-${activeCurrency}`}
|
||||
value={value(activeAccount, 'swish')}
|
||||
onChange={(event) => updateField('swish', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor={`payment-iban-${activeCurrency}`}>
|
||||
{t('iban_label')}{activeCurrency !== 'SEK' ? ` ${t('required_suffix')}` : ''}
|
||||
</Label>
|
||||
<Input
|
||||
id={`payment-iban-${activeCurrency}`}
|
||||
value={value(activeAccount, 'iban')}
|
||||
onChange={(event) => updateField('iban', event.target.value.toUpperCase())}
|
||||
placeholder="SE00 0000 0000 0000 0000 0000"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`payment-bic-${activeCurrency}`}>{t('bic_label')}</Label>
|
||||
<Input
|
||||
id={`payment-bic-${activeCurrency}`}
|
||||
maxLength={11}
|
||||
value={value(activeAccount, 'bic')}
|
||||
onChange={(event) => updateField('bic', event.target.value.toUpperCase())}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={save} disabled={isSaving || hasExternalUpdate}>
|
||||
<div className="flex justify-end px-1 pt-4">
|
||||
<Button type="button" size="sm" onClick={save} disabled={isSaving || hasExternalUpdate}>
|
||||
{isSaving ? t('saving') : t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface InvoicePaymentLinkSettingsProps {
|
||||
@@ -40,23 +44,17 @@ export function InvoicePaymentLinkSettings({ settings, onUpdate }: InvoicePaymen
|
||||
}, [onUpdate, toast, t])
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('heading')}
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>{t('enable_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('enable_help')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_payment_links_enabled ?? false}
|
||||
onCheckedChange={saveToggle}
|
||||
disabled={isSaving}
|
||||
aria-label={t('enable_label')}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<SettingsGroup label={t('heading')}>
|
||||
<SettingsRow label={t('enable_label')} help={t('enable_help')}>
|
||||
<SettingsRowEnd>
|
||||
<Switch
|
||||
checked={settings.invoice_payment_links_enabled ?? false}
|
||||
onCheckedChange={saveToggle}
|
||||
disabled={isSaving}
|
||||
aria-label={t('enable_label')}
|
||||
/>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Eye } from 'lucide-react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -128,11 +127,16 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
{/* Quiet header-action trigger (Fönster): the preview lives in the
|
||||
section header's action slot, not as a card in the page flow. */}
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Eye className="h-4 w-4" />
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
{t('preview_button')}
|
||||
</Button>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsInput,
|
||||
SettingsRow,
|
||||
SettingsTextarea,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface InvoiceSettingsFormProps {
|
||||
@@ -13,125 +16,136 @@ interface InvoiceSettingsFormProps {
|
||||
export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
|
||||
const t = useTranslations('settings_invoice_form')
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('heading')}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_prefix">{t('prefix_label')}</Label>
|
||||
<Input
|
||||
<>
|
||||
<SettingsGroup label={t('heading')}>
|
||||
<SettingsRow label={t('prefix_label')} htmlFor="invoice_prefix" align="baseline">
|
||||
<SettingsInput
|
||||
id="invoice_prefix"
|
||||
name="invoice_prefix"
|
||||
placeholder={t('prefix_placeholder')}
|
||||
defaultValue={settings.invoice_prefix || ''}
|
||||
className="max-w-32 flex-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="next_invoice_number">{t('next_number_label')}</Label>
|
||||
<Input
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('next_number_label')}
|
||||
htmlFor="next_invoice_number"
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="next_invoice_number"
|
||||
name="next_invoice_number"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue={settings.next_invoice_number || 1}
|
||||
className="max-w-32 flex-none tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_default_days">{t('default_days_label')}</Label>
|
||||
<Input
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('default_days_label')}
|
||||
htmlFor="invoice_default_days"
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="invoice_default_days"
|
||||
name="invoice_default_days"
|
||||
type="number"
|
||||
min="0"
|
||||
defaultValue={settings.invoice_default_days || 30}
|
||||
className="max-w-24 flex-none tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="next_arrival_number">{t('arrival_start_label')}</Label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Input
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('arrival_start_label')}
|
||||
htmlFor="next_arrival_number"
|
||||
help={t('arrival_start_help')}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="next_arrival_number"
|
||||
name="next_arrival_number"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue={settings.next_arrival_number || 1}
|
||||
className="max-w-32 flex-none tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('arrival_start_help')}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('default_notes_label')}
|
||||
htmlFor="invoice_default_notes"
|
||||
help={t('default_notes_help')}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsTextarea
|
||||
id="invoice_default_notes"
|
||||
name="invoice_default_notes"
|
||||
rows={3}
|
||||
placeholder={t('default_notes_placeholder')}
|
||||
defaultValue={settings.invoice_default_notes || ''}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('default_our_reference_label')}
|
||||
htmlFor="default_our_reference"
|
||||
help={t('default_our_reference_help')}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="default_our_reference"
|
||||
name="default_our_reference"
|
||||
placeholder={t('default_our_reference_placeholder')}
|
||||
defaultValue={settings.default_our_reference || ''}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_default_notes">{t('default_notes_label')}</Label>
|
||||
<Textarea
|
||||
id="invoice_default_notes"
|
||||
name="invoice_default_notes"
|
||||
rows={3}
|
||||
placeholder={t('default_notes_placeholder')}
|
||||
defaultValue={settings.invoice_default_notes || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('default_notes_help')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_our_reference">{t('default_our_reference_label')}</Label>
|
||||
<Input
|
||||
id="default_our_reference"
|
||||
name="default_our_reference"
|
||||
placeholder={t('default_our_reference_placeholder')}
|
||||
defaultValue={settings.default_our_reference || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('default_our_reference_help')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<fieldset className="space-y-4 border-t border-border pt-4">
|
||||
<legend className="text-sm font-medium">{t('reminder_days_heading')}</legend>
|
||||
<p className="text-xs text-muted-foreground">{t('reminder_days_help')}</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder_days_level_1">{t('reminder_days_level_1')}</Label>
|
||||
<Input
|
||||
id="reminder_days_level_1"
|
||||
name="reminder_days_level_1"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
defaultValue={settings.reminder_days_level_1 ?? 15}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder_days_level_2">{t('reminder_days_level_2')}</Label>
|
||||
<Input
|
||||
id="reminder_days_level_2"
|
||||
name="reminder_days_level_2"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
defaultValue={settings.reminder_days_level_2 ?? 30}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder_days_level_3">{t('reminder_days_level_3')}</Label>
|
||||
<Input
|
||||
id="reminder_days_level_3"
|
||||
name="reminder_days_level_3"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
defaultValue={settings.reminder_days_level_3 ?? 45}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</section>
|
||||
<SettingsGroup label={t('reminder_days_heading')} help={t('reminder_days_help')}>
|
||||
<SettingsRow
|
||||
label={t('reminder_days_level_1')}
|
||||
htmlFor="reminder_days_level_1"
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="reminder_days_level_1"
|
||||
name="reminder_days_level_1"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
defaultValue={settings.reminder_days_level_1 ?? 15}
|
||||
className="max-w-24 flex-none tabular-nums"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('reminder_days_level_2')}
|
||||
htmlFor="reminder_days_level_2"
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="reminder_days_level_2"
|
||||
name="reminder_days_level_2"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
defaultValue={settings.reminder_days_level_2 ?? 30}
|
||||
className="max-w-24 flex-none tabular-nums"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('reminder_days_level_3')}
|
||||
htmlFor="reminder_days_level_3"
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="reminder_days_level_3"
|
||||
name="reminder_days_level_3"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
defaultValue={settings.reminder_days_level_3 ?? 45}
|
||||
className="max-w-24 flex-none tabular-nums"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, Upload, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { LOGO_UPLOAD_MAX_BYTES } from '@/lib/invoices/branding-constants'
|
||||
|
||||
@@ -109,32 +112,26 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('logo_heading')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
{t('logo_help')}
|
||||
</p>
|
||||
|
||||
{preview ? (
|
||||
<div className="space-y-3">
|
||||
<div className="inline-block rounded-lg border border-border bg-muted/30 p-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={preview}
|
||||
alt={t('logo_alt')}
|
||||
className="max-h-16 max-w-[200px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<SettingsGroup>
|
||||
<SettingsRow label={t('logo_heading')} help={t('logo_help')}>
|
||||
{preview ? (
|
||||
<>
|
||||
<span className="inline-flex rounded-lg border border-border bg-muted/30 p-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={preview}
|
||||
alt={t('logo_alt')}
|
||||
className="max-h-10 max-w-32 object-contain"
|
||||
/>
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{isUploading ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Upload className="mr-2 h-3.5 w-3.5" />}
|
||||
{isUploading ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
{t('logo_change')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -147,31 +144,29 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
|
||||
{isDeleting ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Trash2 className="mr-2 h-3.5 w-3.5" />}
|
||||
{t('logo_remove')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
disabled={isUploading}
|
||||
className={`flex flex-col items-center justify-center w-full max-w-xs rounded-lg border-2 border-dashed py-8 px-4 text-center transition-colors disabled:opacity-50 ${
|
||||
isDragging ? 'border-foreground bg-muted/40' : 'border-border hover:border-border hover:bg-muted/20'
|
||||
}`}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-6 w-6 text-muted-foreground animate-spin mb-2" />
|
||||
) : (
|
||||
<Upload className="h-6 w-6 text-muted-foreground/50 mb-2" />
|
||||
)}
|
||||
<Label className="text-sm text-muted-foreground cursor-pointer">
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
disabled={isUploading}
|
||||
className={`inline-flex min-h-10 items-center gap-2 rounded-lg border border-dashed px-4 py-2 text-sm text-muted-foreground transition-colors duration-150 disabled:opacity-50 ${
|
||||
isDragging ? 'border-foreground bg-muted/40' : 'border-border hover:bg-muted/20'
|
||||
}`}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4 text-muted-foreground/60" />
|
||||
)}
|
||||
{isUploading ? t('logo_uploading') : t('logo_pick_or_drop')}
|
||||
</Label>
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</SettingsRow>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -180,6 +175,6 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</section>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -16,7 +15,10 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { SettingsGroup } from '@/components/settings/SettingsRows'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
import { Loader2, Plus, Trash2, Globe } from 'lucide-react'
|
||||
|
||||
interface OAuthClient {
|
||||
@@ -111,75 +113,61 @@ export function OAuthClientsPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('sv-SE', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t('register_uri')}
|
||||
</Button>
|
||||
<>
|
||||
<SettingsGroup>
|
||||
{/* Group eyebrow with the group's primary action on the right. Styling
|
||||
mirrors SettingsGroup's label line; the "?" holds the old panel
|
||||
description. */}
|
||||
<div className="flex items-center justify-between gap-4 px-1">
|
||||
<p className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<span>{t('title')}</span>
|
||||
<HelpPopover className="shrink-0">{t('description')}</HelpPopover>
|
||||
</p>
|
||||
<Button size="sm" onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t('register_uri')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
) : clients.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Globe}
|
||||
title={t('empty_title')}
|
||||
description={t('empty_help')}
|
||||
/>
|
||||
) : (
|
||||
clients.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="flex items-center gap-3 border-b border-border px-1 py-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="truncate text-sm">{c.client_name}</span>
|
||||
<code className="min-w-0 truncate font-mono text-xs text-muted-foreground">
|
||||
{c.redirect_uri}
|
||||
</code>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{t('registered_on')} {formatDateLong(c.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRevoke(c.id, c.client_name)}
|
||||
aria-label={t('revoke_aria', { name: c.client_name })}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : clients.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Globe}
|
||||
title={t('empty_title')}
|
||||
description={t('empty_help')}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{clients.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="flex items-center justify-between rounded-md border px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{c.client_name}</p>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<code className="text-xs text-muted-foreground font-mono truncate">
|
||||
{c.redirect_uri}
|
||||
</code>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{t('registered_on')} {formatDate(c.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRevoke(c.id, c.client_name)}
|
||||
aria-label={t('revoke_aria', { name: c.client_name })}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</SettingsGroup>
|
||||
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogContent>
|
||||
@@ -227,6 +215,6 @@ export function OAuthClientsPanel() {
|
||||
</Dialog>
|
||||
|
||||
<DestructiveConfirmDialog {...revokeDialogProps} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,19 @@
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useCallback, useRef, type ChangeEvent } from 'react'
|
||||
import { Loader2, Trash2, Upload } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsReveal,
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsRowNote,
|
||||
SettingsSeg,
|
||||
SettingsSelect,
|
||||
SettingsTextarea,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { INVOICE_FONT_FAMILIES } from '@/lib/invoices/branding-constants'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import type { CompanySettings, InvoiceFontFamily } from '@/types'
|
||||
@@ -18,6 +25,42 @@ interface PdfPrintSettingsProps {
|
||||
onUpdate: (updates: Partial<CompanySettings>) => void
|
||||
}
|
||||
|
||||
type PdfToggleField =
|
||||
| 'ore_rounding'
|
||||
| 'invoice_show_ocr'
|
||||
| 'invoice_show_bankgiro'
|
||||
| 'invoice_show_plusgiro'
|
||||
| 'invoice_show_swish'
|
||||
| 'invoice_show_logo'
|
||||
| 'invoice_show_company_name'
|
||||
|
||||
/** Compact switch row for the show/hide grid: small label, "?", Switch. */
|
||||
function PdfToggleRow({
|
||||
id,
|
||||
label,
|
||||
help,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
id: string
|
||||
label: string
|
||||
help?: React.ReactNode
|
||||
checked: boolean
|
||||
onCheckedChange: (value: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border px-1 py-3">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<label htmlFor={id} className="truncate text-sm">
|
||||
{label}
|
||||
</label>
|
||||
{help ? <HelpPopover className="shrink-0">{help}</HelpPopover> : null}
|
||||
</span>
|
||||
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps) {
|
||||
const t = useTranslations('settings_pdf_print')
|
||||
const { toast } = useToast()
|
||||
@@ -148,75 +191,79 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
|
||||
Custom: t('font_custom'),
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('heading')}
|
||||
</h2>
|
||||
const toggleRows: Array<{ field: PdfToggleField; label: string; help: string; defaultOn: boolean }> = [
|
||||
{ field: 'ore_rounding', label: t('ore_rounding_label'), help: t('ore_rounding_help'), defaultOn: true },
|
||||
{ field: 'invoice_show_ocr', label: t('show_ocr_label'), help: t('show_ocr_help'), defaultOn: true },
|
||||
{ field: 'invoice_show_bankgiro', label: t('show_bankgiro_label'), help: t('show_bankgiro_help'), defaultOn: true },
|
||||
{ field: 'invoice_show_plusgiro', label: t('show_plusgiro_label'), help: t('show_plusgiro_help'), defaultOn: true },
|
||||
{ field: 'invoice_show_swish', label: t('show_swish_label'), help: t('show_swish_help'), defaultOn: false },
|
||||
{ field: 'invoice_show_logo', label: t('show_logo_label'), help: t('show_logo_help'), defaultOn: true },
|
||||
{ field: 'invoice_show_company_name', label: t('show_company_name_label'), help: t('show_company_name_help'), defaultOn: true },
|
||||
]
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="invoice_font_family">{t('font_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('font_help')}</p>
|
||||
</div>
|
||||
<Select
|
||||
return (
|
||||
<SettingsGroup label={t('heading')}>
|
||||
<SettingsRow
|
||||
label={t('font_label')}
|
||||
htmlFor="invoice_font_family"
|
||||
help={
|
||||
<div className="space-y-2">
|
||||
<p>{t('font_help')}</p>
|
||||
<p>{t('font_file_help')}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SettingsSelect
|
||||
id="invoice_font_family"
|
||||
value={settings.invoice_font_family ?? 'Helvetica'}
|
||||
onValueChange={(value) => {
|
||||
if (value) void saveFont(value as InvoiceFontFamily)
|
||||
onChange={(event) => {
|
||||
if (event.target.value) void saveFont(event.target.value as InvoiceFontFamily)
|
||||
}}
|
||||
disabled={isSavingFont || isUploadingFont || isDeletingFont}
|
||||
>
|
||||
<SelectTrigger id="invoice_font_family" className="max-w-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{INVOICE_FONT_FAMILIES
|
||||
.filter((family) => family !== 'Custom' || settings.invoice_custom_font_path)
|
||||
.map((family) => (
|
||||
<SelectItem key={family} value={family}>
|
||||
{fontLabels[family]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
{INVOICE_FONT_FAMILIES
|
||||
.filter((family) => family !== 'Custom' || settings.invoice_custom_font_path)
|
||||
.map((family) => (
|
||||
<option key={family} value={family}>
|
||||
{fontLabels[family]}
|
||||
</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
{settings.invoice_custom_font_name && (
|
||||
<SettingsRowNote>
|
||||
{t('font_uploaded_name', { name: settings.invoice_custom_font_name })}
|
||||
</SettingsRowNote>
|
||||
)}
|
||||
<SettingsRowEnd>
|
||||
<button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => fontInputRef.current?.click()}
|
||||
disabled={isUploadingFont || isDeletingFont}
|
||||
className="inline-flex items-center gap-2 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isUploadingFont ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{settings.invoice_custom_font_path ? t('font_replace') : t('font_upload')}
|
||||
</Button>
|
||||
</button>
|
||||
{settings.invoice_custom_font_path && (
|
||||
<Button
|
||||
<button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => void deleteFont()}
|
||||
disabled={isUploadingFont || isDeletingFont}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
className="inline-flex items-center gap-2 text-xs text-muted-foreground transition-colors duration-150 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isDeletingFont ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('font_remove')}
|
||||
</Button>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{settings.invoice_custom_font_name && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('font_uploaded_name', { name: settings.invoice_custom_font_name })}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{t('font_file_help')}</p>
|
||||
</SettingsRowEnd>
|
||||
<input
|
||||
ref={fontInputRef}
|
||||
type="file"
|
||||
@@ -224,146 +271,61 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
|
||||
className="hidden"
|
||||
onChange={handleFontChange}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
{/* Show/hide switches: compact two-column grid of small switch rows. */}
|
||||
<div className="grid gap-x-8 md:grid-cols-2">
|
||||
{toggleRows.map(({ field, label, help, defaultOn }) => (
|
||||
<PdfToggleRow
|
||||
key={field}
|
||||
id={`pdf-toggle-${field}`}
|
||||
label={label}
|
||||
help={help}
|
||||
checked={settings[field] ?? defaultOn}
|
||||
onCheckedChange={(v) => void saveToggle(field, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>{t('ore_rounding_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('ore_rounding_help')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.ore_rounding ?? true}
|
||||
onCheckedChange={(v) => saveToggle('ore_rounding', v)}
|
||||
{/* Placement only applies while the company name is shown. */}
|
||||
<SettingsReveal open={settings.invoice_show_company_name ?? true}>
|
||||
<SettingsRow label={t('placement_label')} borderless>
|
||||
<SettingsSeg
|
||||
value={settings.invoice_company_name_position ?? 'header'}
|
||||
onChange={(pos) => void savePosition(pos)}
|
||||
options={[
|
||||
{ value: 'header', label: t('placement_header') },
|
||||
{ value: 'footer', label: t('placement_footer') },
|
||||
]}
|
||||
aria-label={t('placement_aria_label')}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsReveal>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>{t('show_ocr_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('show_ocr_help')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_ocr ?? true}
|
||||
onCheckedChange={(v) => saveToggle('invoice_show_ocr', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>{t('show_bankgiro_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('show_bankgiro_help')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_bankgiro ?? true}
|
||||
onCheckedChange={(v) => saveToggle('invoice_show_bankgiro', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>{t('show_plusgiro_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('show_plusgiro_help')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_plusgiro ?? true}
|
||||
onCheckedChange={(v) => saveToggle('invoice_show_plusgiro', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>{t('show_swish_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('show_swish_help')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_swish ?? false}
|
||||
onCheckedChange={(v) => saveToggle('invoice_show_swish', v)}
|
||||
aria-label={t('show_swish_label')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>{t('show_logo_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('show_logo_help')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_logo ?? true}
|
||||
onCheckedChange={(v) => saveToggle('invoice_show_logo', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>{t('show_company_name_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('show_company_name_help')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_company_name ?? true}
|
||||
onCheckedChange={(v) => saveToggle('invoice_show_company_name', v)}
|
||||
/>
|
||||
</div>
|
||||
{(settings.invoice_show_company_name ?? true) && (
|
||||
<div className="flex items-center justify-between pl-0">
|
||||
<p className="text-xs text-muted-foreground">{t('placement_label')}</p>
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('placement_aria_label')}
|
||||
className="inline-flex rounded-md border border-border p-1"
|
||||
>
|
||||
{(['header', 'footer'] as const).map((pos) => {
|
||||
const active = (settings.invoice_company_name_position ?? 'header') === pos
|
||||
return (
|
||||
<button
|
||||
key={pos}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => savePosition(pos)}
|
||||
className={
|
||||
'h-10 px-4 text-sm rounded-sm transition-colors ' +
|
||||
(active
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground')
|
||||
}
|
||||
>
|
||||
{pos === 'header' ? t('placement_header') : t('placement_footer')}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_late_fee_text">{t('late_fee_label')}</Label>
|
||||
<Textarea
|
||||
id="invoice_late_fee_text"
|
||||
rows={2}
|
||||
placeholder={t('late_fee_placeholder')}
|
||||
value={lateFeeText}
|
||||
onChange={(e) => setLateFeeText(e.target.value)}
|
||||
onBlur={() => saveText('invoice_late_fee_text', lateFeeText)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_credit_terms_text">{t('credit_terms_label')}</Label>
|
||||
<Textarea
|
||||
id="invoice_credit_terms_text"
|
||||
rows={2}
|
||||
placeholder={t('credit_terms_placeholder')}
|
||||
value={creditTermsText}
|
||||
onChange={(e) => setCreditTermsText(e.target.value)}
|
||||
onBlur={() => saveText('invoice_credit_terms_text', creditTermsText)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<SettingsRow label={t('late_fee_label')} htmlFor="invoice_late_fee_text" align="baseline">
|
||||
<SettingsTextarea
|
||||
id="invoice_late_fee_text"
|
||||
rows={2}
|
||||
placeholder={t('late_fee_placeholder')}
|
||||
value={lateFeeText}
|
||||
onChange={(e) => setLateFeeText(e.target.value)}
|
||||
onBlur={() => saveText('invoice_late_fee_text', lateFeeText)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('credit_terms_label')}
|
||||
htmlFor="invoice_credit_terms_text"
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsTextarea
|
||||
id="invoice_credit_terms_text"
|
||||
rows={2}
|
||||
placeholder={t('credit_terms_placeholder')}
|
||||
value={creditTermsText}
|
||||
onChange={(e) => setCreditTermsText(e.target.value)}
|
||||
onBlur={() => saveText('invoice_credit_terms_text', creditTermsText)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,58 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsInput,
|
||||
SettingsRow,
|
||||
SettingsSelect,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface PeriodLockingSettingsProps {
|
||||
settings: CompanySettings
|
||||
}
|
||||
|
||||
/**
|
||||
* Period-lock rows. Uncontrolled on purpose: the values are read via FormData
|
||||
* by the surrounding SettingsFormWrapper on the bookkeeping settings page.
|
||||
*/
|
||||
export function PeriodLockingSettings({ settings }: PeriodLockingSettingsProps) {
|
||||
const t = useTranslations('settings_period_locking')
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('heading')}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bookkeeping_locked_through">{t('locked_through_label')}</Label>
|
||||
<Input
|
||||
id="bookkeeping_locked_through"
|
||||
name="bookkeeping_locked_through"
|
||||
type="date"
|
||||
defaultValue={settings.bookkeeping_locked_through || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('locked_through_help')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auto_lock_period_days">{t('auto_lock_label')}</Label>
|
||||
<Select
|
||||
name="auto_lock_period_days"
|
||||
defaultValue={settings.auto_lock_period_days?.toString() || 'none'}
|
||||
>
|
||||
<SelectTrigger id="auto_lock_period_days">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('auto_lock_none')}</SelectItem>
|
||||
<SelectItem value="30">{t('auto_lock_30')}</SelectItem>
|
||||
<SelectItem value="60">{t('auto_lock_60')}</SelectItem>
|
||||
<SelectItem value="90">{t('auto_lock_90')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('auto_lock_help')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<SettingsGroup label={t('heading')}>
|
||||
<SettingsRow
|
||||
label={t('locked_through_label')}
|
||||
htmlFor="bookkeeping_locked_through"
|
||||
help={t('locked_through_help')}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="bookkeeping_locked_through"
|
||||
name="bookkeeping_locked_through"
|
||||
type="date"
|
||||
defaultValue={settings.bookkeeping_locked_through || ''}
|
||||
className="max-w-44 flex-none tabular-nums"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('auto_lock_label')}
|
||||
htmlFor="auto_lock_period_days"
|
||||
help={t('auto_lock_help')}
|
||||
>
|
||||
<SettingsSelect
|
||||
id="auto_lock_period_days"
|
||||
name="auto_lock_period_days"
|
||||
defaultValue={settings.auto_lock_period_days?.toString() || 'none'}
|
||||
>
|
||||
<option value="none">{t('auto_lock_none')}</option>
|
||||
<option value="30">{t('auto_lock_30')}</option>
|
||||
<option value="60">{t('auto_lock_60')}</option>
|
||||
<option value="90">{t('auto_lock_90')}</option>
|
||||
</SettingsSelect>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { useCallback, useSyncExternalStore } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
|
||||
/**
|
||||
* Per-user toggle for the periodisering wizard's auto-detection step.
|
||||
@@ -76,33 +79,27 @@ export function PeriodiseringAutoDetectToggle() {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Periodisering
|
||||
</h2>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="periodisering-autodetect" className="text-sm">
|
||||
Aktivera automatisk periodiseringsdetektering
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground max-w-md">
|
||||
Skannar fakturor i bokslutet efter datumintervall som sträcker sig
|
||||
in i nästa räkenskapsår och föreslår periodiseringar i bokslut-wizarden.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="periodisering-autodetect"
|
||||
checked={enabled}
|
||||
onCheckedChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href="/bookkeeping/year-end/periodisering"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Öppna periodiserings-wizarden
|
||||
</Link>
|
||||
</section>
|
||||
<SettingsRow
|
||||
label="Periodisering"
|
||||
help="Skannar fakturor i bokslutet efter datumintervall som sträcker sig in i nästa räkenskapsår och föreslår periodiseringar i bokslut-wizarden."
|
||||
>
|
||||
<Switch
|
||||
id="periodisering-autodetect"
|
||||
checked={enabled}
|
||||
onCheckedChange={handleChange}
|
||||
/>
|
||||
<label htmlFor="periodisering-autodetect" className="cursor-pointer text-sm">
|
||||
Aktivera automatisk periodiseringsdetektering
|
||||
</label>
|
||||
<SettingsRowEnd>
|
||||
<Link
|
||||
href="/bookkeeping/year-end/periodisering"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Öppna periodiserings-wizarden
|
||||
</Link>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,17 +4,22 @@ import { useTranslations } from 'next-intl'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, ShieldCheck, ShieldOff, KeyRound } from 'lucide-react'
|
||||
import { Loader2, ShieldCheck, ShieldOff } from 'lucide-react'
|
||||
import { isMfaRequired } from '@/lib/auth/mfa'
|
||||
import { isBankIdEnabled } from '@/lib/auth/bankid'
|
||||
import { BankIdSettings } from '@/components/settings/BankIdSettings'
|
||||
import { userHasPassword } from '@/lib/auth/has-password'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsInput,
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsRowNote,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
|
||||
const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true'
|
||||
const mfaRequired = isMfaRequired()
|
||||
@@ -166,181 +171,162 @@ export function SecuritySettings() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsGroup label={t('group_security')}>
|
||||
{bankIdEnabled && <BankIdSettings />}
|
||||
|
||||
{/* BankID-only users with no password: banner above everything else */}
|
||||
{/* BankID-only users with no password: set-password row before the rest */}
|
||||
{hasPassword === false && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Sätt ett lösenord
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Du loggade in med BankID och har inget lösenord ännu. Sätt ett
|
||||
lösenord för att kunna aktivera 2FA eller logga in när BankID
|
||||
inte är tillgängligt.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SettingsRow
|
||||
label="Sätt ett lösenord"
|
||||
help="Du loggade in med BankID och har inget lösenord ännu. Sätt ett lösenord för att kunna aktivera 2FA eller logga in när BankID inte är tillgängligt."
|
||||
>
|
||||
<SettingsRowEnd>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
router.push('/account/set-password?returnTo=/settings/account')
|
||||
}
|
||||
>
|
||||
Sätt lösenord
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
)}
|
||||
|
||||
{/* Change password: hidden when the user has no password (the banner
|
||||
{/* Change password: hidden when the user has no password (the row
|
||||
above handles the set-initial-password flow). */}
|
||||
{hasPassword !== false && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyRound className="h-5 w-5" />
|
||||
{t('change_password_title')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('change_password_description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4 max-w-md">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new_password">{t('new_password_label')}</Label>
|
||||
<Input
|
||||
id="new_password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t('new_password_placeholder')}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isChangingPassword}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm_new_password">{t('confirm_password_label')}</Label>
|
||||
<Input
|
||||
id="confirm_new_password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t('confirm_password_placeholder')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isChangingPassword}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={isChangingPassword}>
|
||||
{isChangingPassword ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('saving')}
|
||||
</>
|
||||
) : (
|
||||
t('update_password_button')
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<form onSubmit={handleChangePassword}>
|
||||
<SettingsRow
|
||||
label={t('new_password_label')}
|
||||
htmlFor="new_password"
|
||||
help={t('change_password_description')}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="new_password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t('new_password_placeholder')}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isChangingPassword}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('confirm_password_label')}
|
||||
htmlFor="confirm_new_password"
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="confirm_new_password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t('confirm_password_placeholder')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isChangingPassword}
|
||||
/>
|
||||
<SettingsRowEnd>
|
||||
<Button type="submit" size="sm" disabled={isChangingPassword}>
|
||||
{isChangingPassword ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
{t('saving')}
|
||||
</>
|
||||
) : (
|
||||
t('update_password_button')
|
||||
)}
|
||||
</Button>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* MFA: hidden for self-hosted */}
|
||||
{!isSelfHosted && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
{t('mfa_title')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('mfa_description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingMfa ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('loading')}
|
||||
</div>
|
||||
) : hasMfa ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg border border-border bg-secondary">
|
||||
<ShieldCheck className="h-5 w-5 text-success" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">{t('mfa_active_title')}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('mfa_active_description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!mfaRequired && (
|
||||
<SettingsRow
|
||||
label={t('mfa_title')}
|
||||
help={
|
||||
<>
|
||||
<p>{t('mfa_description')}</p>
|
||||
{!isLoadingMfa && !hasMfa && (
|
||||
<p className="mt-2">{t('mfa_inactive_description')}</p>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{isLoadingMfa ? (
|
||||
<span className="inline-flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t('loading')}
|
||||
</span>
|
||||
) : hasMfa ? (
|
||||
<>
|
||||
<Badge variant="success">{t('mfa_active_title')}</Badge>
|
||||
<SettingsRowNote>{t('mfa_active_description')}</SettingsRowNote>
|
||||
<SettingsRowEnd>
|
||||
{mfaRequired ? (
|
||||
// Required by the hosted config: no disable action exists,
|
||||
// so the reason stays visible as the row's status.
|
||||
<SettingsRowNote>{t('mfa_required_note')}</SettingsRowNote>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleUnenrollMfa}
|
||||
disabled={isUnenrolling}
|
||||
>
|
||||
{isUnenrolling ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
{t('disabling')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldOff className="mr-2 h-4 w-4" />
|
||||
<ShieldOff className="mr-2 h-3.5 w-3.5" />
|
||||
{t('disable_mfa')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{mfaRequired && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('mfa_required_note')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg border">
|
||||
<ShieldOff className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">{t('mfa_inactive_title')}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('mfa_inactive_description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsRowEnd>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SettingsRowNote>{t('mfa_inactive_title')}</SettingsRowNote>
|
||||
<SettingsRowEnd>
|
||||
{hasPassword === false ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
'/account/set-password?returnTo=/mfa/enroll',
|
||||
)
|
||||
router.push('/account/set-password?returnTo=/mfa/enroll')
|
||||
}
|
||||
>
|
||||
{t('set_password_first')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => router.push(`/mfa/enroll?returnTo=${encodeURIComponent('/settings/account')}`)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
router.push(`/mfa/enroll?returnTo=${encodeURIComponent('/settings/account')}`)
|
||||
}
|
||||
>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
<ShieldCheck className="mr-2 h-3.5 w-3.5" />
|
||||
{t('enable_mfa')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsRowEnd>
|
||||
</>
|
||||
)}
|
||||
</SettingsRow>
|
||||
)}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Loader2, Check, Lock } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type SaveResult =
|
||||
| Record<string, unknown>
|
||||
@@ -18,12 +19,18 @@ interface SettingsFormWrapperProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Form shell for settings sections saving to PUT /api/settings. The save
|
||||
* affordance is a sticky bar that fades in only when the form is dirty
|
||||
* (Fönster concept): a quiet page until you actually change something.
|
||||
*/
|
||||
export function SettingsFormWrapper({ children, onSave, className }: SettingsFormWrapperProps) {
|
||||
const t = useTranslations('settings_company')
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useCanWrite()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,6 +85,7 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
|
||||
}
|
||||
|
||||
onSuccess?.(result.data ?? updates)
|
||||
setDirty(false)
|
||||
setSaved(true)
|
||||
timerRef.current = setTimeout(() => setSaved(false), 2000)
|
||||
} catch (error) {
|
||||
@@ -91,21 +99,32 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
|
||||
setIsSaving(false)
|
||||
}, [onSave, toast, t])
|
||||
|
||||
const barVisible = dirty || isSaving || saved
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={className}>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
onInput={() => setDirty(true)}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 mt-8">
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1.5 text-sm text-muted-foreground animate-in fade-in duration-200">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t('wrapper_saved')}
|
||||
</span>
|
||||
{/* Sticky save bar: invisible until the form is dirty. The gradient
|
||||
lets rows scroll away underneath without a hard edge. */}
|
||||
<div
|
||||
className={cn(
|
||||
'sticky bottom-0 flex items-center gap-4 bg-gradient-to-t from-background via-background/95 to-transparent px-1 transition-opacity duration-200',
|
||||
barVisible
|
||||
? 'mt-2 pb-3 pt-6 opacity-100'
|
||||
: 'pointer-events-none h-0 overflow-hidden py-0 opacity-0',
|
||||
)}
|
||||
aria-hidden={!barVisible}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSaving || !canWrite}
|
||||
size="sm"
|
||||
tabIndex={barVisible ? 0 : -1}
|
||||
title={!canWrite ? t('wrapper_readonly_tooltip') : undefined}
|
||||
>
|
||||
{isSaving ? (
|
||||
@@ -122,6 +141,14 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
|
||||
t('wrapper_save_changes')
|
||||
)}
|
||||
</Button>
|
||||
{saved ? (
|
||||
<span className="flex items-center gap-2 text-sm text-muted-foreground animate-in fade-in duration-200">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t('wrapper_saved')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{t('wrapper_unsaved')}</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { SETTINGS_SECTIONS } from './sections'
|
||||
import { SettingsShell } from './SettingsShell'
|
||||
import { ActiveCompanyBadge } from './ActiveCompanyBadge'
|
||||
|
||||
/**
|
||||
* The settings popup. Rendered only by the intercepting route
|
||||
@@ -20,6 +19,9 @@ import { ActiveCompanyBadge } from './ActiveCompanyBadge'
|
||||
* returning the user to the page they came from (which stayed mounted in the
|
||||
* `children` slot behind the scrim). On hard load / refresh / deep-link the
|
||||
* interceptor doesn't fire and the real full-page settings render instead.
|
||||
*
|
||||
* Chrome follows the Fönster concept: company kicker over a serif title,
|
||||
* fixed-height window so the rail never jumps between sections.
|
||||
*/
|
||||
export function SettingsModal({ sectionId }: { sectionId?: string }) {
|
||||
const router = useRouter()
|
||||
@@ -54,16 +56,25 @@ export function SettingsModal({ sectionId }: { sectionId?: string }) {
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onOpenChange}>
|
||||
{/* Escape with an open "?" help popover closes the popover (its own
|
||||
listener), not the whole settings window. */}
|
||||
<DialogContent
|
||||
className="flex h-[100dvh] max-h-[100dvh] max-w-none flex-col gap-0 overflow-hidden rounded-none p-0 md:h-auto md:max-h-[85dvh] md:max-w-4xl md:rounded-lg"
|
||||
className="flex h-[100dvh] max-h-[100dvh] max-w-none flex-col gap-0 overflow-hidden rounded-none p-0 md:h-[min(680px,88dvh)] md:max-h-[88dvh] md:max-w-[920px] md:rounded-xl"
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (document.querySelector('[data-help-popover]')) e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-3 border-b border-border px-6 py-4">
|
||||
<DialogTitle className="font-display text-lg tracking-tight">
|
||||
{t('title')}
|
||||
</DialogTitle>
|
||||
{/* The modal covers the sidebar's CompanySwitcher, so the active
|
||||
company must stay visible here (mr-6 clears the close button). */}
|
||||
<ActiveCompanyBadge className="ml-auto mr-6" />
|
||||
<div className="flex shrink-0 items-center gap-3 border-b border-border px-6 py-3">
|
||||
<div className="min-w-0">
|
||||
{/* The modal covers the sidebar's CompanySwitcher, so the active
|
||||
company must stay visible here as the kicker over the title. */}
|
||||
{company ? (
|
||||
<p className="truncate text-xs text-muted-foreground">{company.name}</p>
|
||||
) : null}
|
||||
<DialogTitle className="font-display text-lg tracking-tight">
|
||||
{t('title')}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</div>
|
||||
<DialogDescription className="sr-only">{t('description')}</DialogDescription>
|
||||
<SettingsShell variant="modal" activeSection={resolved} />
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
|
||||
/**
|
||||
* The Fönster settings language (founder-approved concept 2026-07-25):
|
||||
* flat hairline rows with an uppercase micro-label on the left and the
|
||||
* control on the right, section titles in the display serif, groups under
|
||||
* eyebrow labels, and every explanation collapsed behind a "?" popover
|
||||
* (UI-migration convention 7 applied at row level).
|
||||
*
|
||||
* All settings sections compose these primitives; do not hand-roll row
|
||||
* layouts or inline help paragraphs in section components.
|
||||
*/
|
||||
|
||||
interface SettingsSectionHeaderProps {
|
||||
title: string
|
||||
/** One quiet line under the title. Longer guidance belongs in row help. */
|
||||
intro?: React.ReactNode
|
||||
/** Right-aligned header action (e.g. "Förhandsvisa faktura", "Skapa nyckel"). */
|
||||
action?: React.ReactNode
|
||||
}
|
||||
|
||||
export function SettingsSectionHeader({ title, intro, action }: SettingsSectionHeaderProps) {
|
||||
return (
|
||||
<header>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<h2 className="font-display text-xl tracking-tight">{title}</h2>
|
||||
{action ? <div className="flex shrink-0 items-center gap-3">{action}</div> : null}
|
||||
</div>
|
||||
{intro ? (
|
||||
<p className="mt-1 max-w-[56ch] text-xs leading-relaxed text-muted-foreground">{intro}</p>
|
||||
) : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
interface SettingsGroupProps {
|
||||
label?: string
|
||||
/** Group-level help ("?" right after the eyebrow) for guidance that spans the rows. */
|
||||
help?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SettingsGroup({ label, help, children, className }: SettingsGroupProps) {
|
||||
return (
|
||||
<section className={cn('pt-8 first:pt-6', className)}>
|
||||
{label ? (
|
||||
<p className="flex items-center gap-2 px-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
{help ? <HelpPopover className="shrink-0">{help}</HelpPopover> : null}
|
||||
</p>
|
||||
) : null}
|
||||
<div>{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface SettingsRowProps {
|
||||
label: React.ReactNode
|
||||
/** Ties the label to a control; renders a <label> instead of a <span>. */
|
||||
htmlFor?: string
|
||||
/** Row help content: goes behind a "?" next to the label, never inline. */
|
||||
help?: React.ReactNode
|
||||
/** 'center' for toggles/selects/chips, 'baseline' for text inputs. */
|
||||
align?: 'center' | 'baseline'
|
||||
/** Drop the hairline (last row before a fold/reveal). */
|
||||
borderless?: boolean
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SettingsRow({
|
||||
label,
|
||||
htmlFor,
|
||||
help,
|
||||
align = 'center',
|
||||
borderless = false,
|
||||
children,
|
||||
className,
|
||||
}: SettingsRowProps) {
|
||||
const labelClass = 'text-[11px] font-medium uppercase tracking-wider text-muted-foreground'
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-1 px-1 py-3 md:flex-row md:gap-4',
|
||||
align === 'center' ? 'md:items-center' : 'md:items-baseline',
|
||||
!borderless && 'border-b border-border',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full shrink-0 items-center gap-2 md:w-44">
|
||||
{htmlFor ? (
|
||||
<label htmlFor={htmlFor} className={labelClass}>
|
||||
{label}
|
||||
</label>
|
||||
) : (
|
||||
<span className={labelClass}>{label}</span>
|
||||
)}
|
||||
{help ? <HelpPopover className="shrink-0">{help}</HelpPopover> : null}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Right-aligned slot inside a row (quiet actions, secondary values). */
|
||||
export function SettingsRowEnd({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return <div className={cn('ml-auto flex shrink-0 items-center gap-3', className)}>{children}</div>
|
||||
}
|
||||
|
||||
/** Muted secondary text inside a row. */
|
||||
export function SettingsRowNote({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return <span className={cn('text-xs text-muted-foreground', className)}>{children}</span>
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat text input: borderless with a dashed underline on hover and a solid
|
||||
* one on focus. Sized by the row, not by a box.
|
||||
*/
|
||||
export function SettingsInput({ className, ...rest }: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
{...rest}
|
||||
className={cn(
|
||||
'min-w-0 flex-1 rounded-none border-0 border-b border-dashed border-transparent bg-transparent px-0 py-1 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground/60 hover:border-border',
|
||||
'focus:outline-none focus:border-solid focus:border-foreground/50',
|
||||
'disabled:cursor-not-allowed disabled:border-transparent disabled:opacity-60',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Flat textarea sibling of SettingsInput. */
|
||||
export function SettingsTextarea({ className, ...rest }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return (
|
||||
<textarea
|
||||
{...rest}
|
||||
className={cn(
|
||||
'min-w-0 flex-1 resize-y rounded-none border-0 border-b border-dashed border-transparent bg-transparent px-0 py-1 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground/60 hover:border-border',
|
||||
'focus:outline-none focus:border-solid focus:border-foreground/50',
|
||||
'disabled:cursor-not-allowed disabled:border-transparent disabled:opacity-60',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Flat native select with a quiet chevron. */
|
||||
export function SettingsSelect({
|
||||
className,
|
||||
wrapperClassName,
|
||||
children,
|
||||
...rest
|
||||
}: React.SelectHTMLAttributes<HTMLSelectElement> & { wrapperClassName?: string }) {
|
||||
return (
|
||||
<span className={cn('relative inline-flex max-w-full items-center', wrapperClassName)}>
|
||||
<select
|
||||
{...rest}
|
||||
className={cn(
|
||||
'max-w-full cursor-pointer appearance-none truncate rounded-none border-0 border-b border-dashed border-transparent bg-transparent py-1 pl-0 pr-6 text-sm text-foreground',
|
||||
'hover:border-border focus:outline-none focus-visible:border-solid focus-visible:border-foreground/50',
|
||||
'disabled:cursor-not-allowed disabled:opacity-60',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-1 h-3.5 w-3.5 text-muted-foreground"
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface SettingsRevealProps {
|
||||
open: boolean
|
||||
/** Indent revealed rows behind a left hairline (gated sub-settings). */
|
||||
indent?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** Animated reveal for settings gated behind a toggle (momsreg → momsblock). */
|
||||
export function SettingsReveal({ open, indent = true, children }: SettingsRevealProps) {
|
||||
return (
|
||||
<div
|
||||
inert={!open}
|
||||
className={cn(
|
||||
'grid transition-[grid-template-rows] duration-300 motion-reduce:transition-none',
|
||||
open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
||||
)}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div className={cn(indent && 'ml-3 border-l border-border pl-4')}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SettingsSegProps<T extends string> {
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
options: Array<{ value: T; label: React.ReactNode }>
|
||||
'aria-label': string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** Quiet segmented control (theme, language, plan interval, sv/en texts). */
|
||||
export function SettingsSeg<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
'aria-label': ariaLabel,
|
||||
disabled,
|
||||
}: SettingsSegProps<T>) {
|
||||
return (
|
||||
<div role="group" aria-label={ariaLabel} className="inline-flex items-center gap-1 rounded-lg bg-muted/70 p-1">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={o.value === value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1 text-xs transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-60',
|
||||
o.value === value
|
||||
? 'border border-border bg-card font-medium text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Terracotta-tinted trailing block for destructive actions. */
|
||||
export function SettingsDangerZone({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="mt-10 border-t border-destructive/30 pt-3">
|
||||
<p className="px-1 text-[11px] font-medium uppercase tracking-wider text-destructive/80">
|
||||
{label}
|
||||
</p>
|
||||
<div>{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsInput,
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsRowNote,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
@@ -40,49 +45,52 @@ export function ShareCapitalForm({ settings }: ShareCapitalFormProps) {
|
||||
: null
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('share_capital_heading')}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="aktiekapital">{t('aktiekapital_label')}</Label>
|
||||
<Input
|
||||
id="aktiekapital"
|
||||
name="aktiekapital"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min="1"
|
||||
step="1"
|
||||
value={aktiekapital}
|
||||
onChange={(e) => setAktiekapital(e.target.value)}
|
||||
required={antalAktier.trim() !== ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('aktiekapital_help')}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="antal_aktier">{t('antal_aktier_label')}</Label>
|
||||
<Input
|
||||
id="antal_aktier"
|
||||
name="antal_aktier"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min="1"
|
||||
step="1"
|
||||
value={antalAktier}
|
||||
onChange={(e) => setAntalAktier(e.target.value)}
|
||||
required={aktiekapital.trim() !== ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('antal_aktier_help')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{kvotvarde !== null && (
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{t('kvotvarde_display', { value: formatCurrency(kvotvarde) })}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
<SettingsGroup label={t('share_capital_heading')}>
|
||||
<SettingsRow
|
||||
label={t('aktiekapital_label')}
|
||||
htmlFor="aktiekapital"
|
||||
help={t('aktiekapital_help')}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="aktiekapital"
|
||||
name="aktiekapital"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min="1"
|
||||
step="1"
|
||||
value={aktiekapital}
|
||||
onChange={(e) => setAktiekapital(e.target.value)}
|
||||
required={antalAktier.trim() !== ''}
|
||||
className="max-w-32 flex-none tabular-nums"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('antal_aktier_label')}
|
||||
htmlFor="antal_aktier"
|
||||
help={t('antal_aktier_help')}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="antal_aktier"
|
||||
name="antal_aktier"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min="1"
|
||||
step="1"
|
||||
value={antalAktier}
|
||||
onChange={(e) => setAntalAktier(e.target.value)}
|
||||
required={aktiekapital.trim() !== ''}
|
||||
className="max-w-32 flex-none tabular-nums"
|
||||
/>
|
||||
{kvotvarde !== null && (
|
||||
<SettingsRowEnd>
|
||||
<SettingsRowNote className="tabular-nums">
|
||||
{t('kvotvarde_display', { value: formatCurrency(kvotvarde) })}
|
||||
</SettingsRowNote>
|
||||
</SettingsRowEnd>
|
||||
)}
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@@ -10,6 +9,12 @@ import { useCapability } from '@/contexts/CompanyContext'
|
||||
import { isAllowedSkvPopupOrigin } from '@/lib/skatteverket/popup-origin'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { UpgradeNote } from '@/components/billing/UpgradeNote'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsRowNote,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { CheckCircle2, ExternalLink, Loader2, ShieldOff, FlaskConical, ShieldAlert } from 'lucide-react'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
@@ -29,12 +34,21 @@ type Status =
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** Live warning inside a settings group: one warning-tone line, no banner. */
|
||||
function WarningLine({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="border-b border-border px-1 py-3 text-[12.5px] leading-relaxed text-attn">
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkatteverketConnectPanel() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<>
|
||||
<SkatteverketPersonalConnectionCard />
|
||||
<SkatteverketSystemConnectionCard />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,9 +97,10 @@ function SkatteverketPersonalConnectionCard() {
|
||||
agd: t('scope_agd'),
|
||||
}
|
||||
|
||||
// Only the first load blanks the card to the loading state: later refetches
|
||||
// (postMessage, closed-tab watcher, delayed sync refetch, visibility) update
|
||||
// in the background so the panel doesn't flash on every signal.
|
||||
// Only the first load blanks the section to the loading state: later
|
||||
// refetches (postMessage, closed-tab watcher, delayed sync refetch,
|
||||
// visibility) update in the background so the panel doesn't flash on every
|
||||
// signal.
|
||||
const hasLoadedRef = useRef(false)
|
||||
const loadStatus = useCallback(async () => {
|
||||
if (!hasLoadedRef.current) setLoading(true)
|
||||
@@ -236,64 +251,61 @@ function SkatteverketPersonalConnectionCard() {
|
||||
}
|
||||
}
|
||||
|
||||
// Static connect guidance lives behind the "?": what the connection is
|
||||
// used for, plus the consent-page instructions ("godkänn alla
|
||||
// behörigheter", the ska/skahmst explainer). The consent notes only matter
|
||||
// when the user can actually reach the consent page: hidden while the
|
||||
// feature is entitlement-gated.
|
||||
const connectHelp = (
|
||||
<div className="space-y-2">
|
||||
<p>{t('connect_intro')}</p>
|
||||
{hasSkatteverket && (
|
||||
<>
|
||||
<p>{t('connect_approve_all')}</p>
|
||||
<p>
|
||||
{t.rich('skahmst_note', {
|
||||
code: (chunks) => <span className="font-mono">{chunks}</span>,
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-sm text-muted-foreground">
|
||||
{t('loading_status')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SettingsGroup>
|
||||
<SettingsRow label={t('title')} help={connectHelp} borderless>
|
||||
<SettingsRowNote>{t('loading_status')}</SettingsRowNote>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
if (!status?.connected) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
<EnvironmentBadge environment={status?.environment} disabled={status?.disabled} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{status?.disabled && (
|
||||
<div className="flex gap-2 rounded-md border border-border bg-secondary/40 p-3 text-sm text-foreground">
|
||||
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p>{t('disabled_message')}</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('connect_intro')}
|
||||
</p>
|
||||
{/* The consent-page notes only matter when the user can actually
|
||||
reach that page: hidden while the feature is gated. */}
|
||||
{hasSkatteverket && (
|
||||
<div className="space-y-2 rounded-md border border-border bg-secondary/40 p-3 text-xs text-muted-foreground">
|
||||
{/* Pre-empt the most common broken connect: a behörighet left
|
||||
unticked on SKV's consent page. Before this note the
|
||||
"godkänn alla" guidance only appeared AFTER a failed
|
||||
attempt (missing_scope_message). */}
|
||||
<p className="text-foreground">{t('connect_approve_all')}</p>
|
||||
<p>
|
||||
{t.rich('skahmst_note', {
|
||||
code: (chunks) => <span className="font-mono">{chunks}</span>,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!hasSkatteverket && (
|
||||
<SettingsGroup>
|
||||
{status?.disabled && <WarningLine>{t('disabled_message')}</WarningLine>}
|
||||
<SettingsRow label={t('title')} help={connectHelp} borderless={!hasSkatteverket}>
|
||||
<EnvironmentBadge environment={status?.environment} disabled={status?.disabled} />
|
||||
<SettingsRowEnd>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={startConnect}
|
||||
disabled={status?.disabled || !hasSkatteverket || connecting}
|
||||
title={!hasSkatteverket ? 'Anslutning till Skatteverket kräver ett abonnemang' : undefined}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
{connecting ? t('connect_waiting') : t('connect_with_bankid')}
|
||||
</Button>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
{!hasSkatteverket && (
|
||||
<div className="px-1 py-3">
|
||||
<UpgradeNote>Anslutning till Skatteverket kräver ett abonnemang.</UpgradeNote>
|
||||
)}
|
||||
<Button
|
||||
onClick={startConnect}
|
||||
disabled={status?.disabled || !hasSkatteverket || connecting}
|
||||
title={!hasSkatteverket ? 'Anslutning till Skatteverket kräver ett abonnemang' : undefined}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
{connecting ? t('connect_waiting') : t('connect_with_bankid')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -304,95 +316,35 @@ function SkatteverketPersonalConnectionCard() {
|
||||
)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{t('title')}
|
||||
{status.expired ? (
|
||||
<Badge variant="destructive">{t('expired')}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
{t('connected')}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<EnvironmentBadge environment={status.environment} disabled={status.disabled} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{status.needsReconsent && (
|
||||
<div className="flex gap-2 rounded-md border border-border bg-secondary/40 p-3 text-sm text-foreground">
|
||||
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
{/* MISSING_SCOPE right after a connect means the user skipped a
|
||||
behörighet on SKV's consent page; tell them exactly that
|
||||
instead of the generic "session expired" prompt. */}
|
||||
<p>
|
||||
{status.lastErrorCode === 'MISSING_SCOPE'
|
||||
? t('missing_scope_message')
|
||||
: t('needs_reconsent_message')}
|
||||
</p>
|
||||
</div>
|
||||
<SettingsGroup>
|
||||
{status.needsReconsent && (
|
||||
<WarningLine>
|
||||
{/* MISSING_SCOPE right after a connect means the user skipped a
|
||||
behörighet on SKV's consent page; tell them exactly that
|
||||
instead of the generic "session expired" prompt. */}
|
||||
{status.lastErrorCode === 'MISSING_SCOPE'
|
||||
? t('missing_scope_message')
|
||||
: t('needs_reconsent_message')}
|
||||
</WarningLine>
|
||||
)}
|
||||
|
||||
<SettingsRow label={t('title')} help={connectHelp}>
|
||||
{status.expired ? (
|
||||
<Badge variant="warning">{t('expired')}</Badge>
|
||||
) : (
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
{t('connected')}
|
||||
</Badge>
|
||||
)}
|
||||
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('token_expires_label')}</dt>
|
||||
<dd className="font-medium tabular-nums">
|
||||
{expiresAtDate.toLocaleString('sv-SE')}
|
||||
{!status.expired && expiresInMinutes > 0 && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{t('expires_in_minutes', { minutes: expiresInMinutes })}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('refresh_label')}</dt>
|
||||
<dd className="font-medium">
|
||||
{status.canRefresh ? t('refresh_auto') : t('refresh_exhausted')}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{t('permissions_label')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{scopes.map(s => (
|
||||
<Badge key={s} variant="outline">
|
||||
{SCOPE_LABELS[s] ?? s}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{/* `ska` is the scope the interactive skattekonto API enforces;
|
||||
skahmst (bulk E-transport service) does not substitute for it. */}
|
||||
{!scopes.includes('ska') && (
|
||||
<p className="mt-3 text-sm text-foreground">
|
||||
{t('missing_skattekonto')}
|
||||
</p>
|
||||
)}
|
||||
{!scopes.includes('agd') && (
|
||||
<p className="mt-3 text-sm text-foreground">
|
||||
{t('missing_agd')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status.disabled && (
|
||||
<div className="flex gap-2 rounded-md border border-border bg-secondary/40 p-3 text-sm text-foreground">
|
||||
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p>{t('disabled_filings_message')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<EnvironmentBadge environment={status.environment} disabled={status.disabled} />
|
||||
<SettingsRowEnd>
|
||||
{/* `ska` gates the interactive skattekonto API (saldo +
|
||||
transaktioner); a grant without it cannot sync, so offer the
|
||||
reconnect even while the token is otherwise healthy. */}
|
||||
{(status.expired || status.needsReconsent || !status.canRefresh || !scopes.includes('ska') || !scopes.includes('agd')) && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={startConnect}
|
||||
disabled={status.disabled || !hasSkatteverket || connecting}
|
||||
title={!hasSkatteverket ? 'Anslutning till Skatteverket kräver ett abonnemang' : undefined}
|
||||
@@ -403,15 +355,48 @@ function SkatteverketPersonalConnectionCard() {
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={disconnect}
|
||||
disabled={disconnecting || connecting}
|
||||
>
|
||||
<ShieldOff className="mr-2 h-4 w-4" />
|
||||
{disconnecting ? t('disconnecting') : t('disconnect')}
|
||||
</Button>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow label={t('token_expires_label')}>
|
||||
<SettingsRowNote className="tabular-nums">
|
||||
{expiresAtDate.toLocaleString('sv-SE')}
|
||||
{!status.expired && expiresInMinutes > 0 && (
|
||||
<> {t('expires_in_minutes', { minutes: expiresInMinutes })}</>
|
||||
)}
|
||||
</SettingsRowNote>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow label={t('refresh_label')}>
|
||||
<SettingsRowNote>
|
||||
{status.canRefresh ? t('refresh_auto') : t('refresh_exhausted')}
|
||||
</SettingsRowNote>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow label={t('permissions_label')}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{scopes.map(s => (
|
||||
<Badge key={s} variant="outline">
|
||||
{SCOPE_LABELS[s] ?? s}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsRow>
|
||||
|
||||
{/* `ska` is the scope the interactive skattekonto API enforces;
|
||||
skahmst (bulk E-transport service) does not substitute for it.
|
||||
Missing scopes are actionable state: keep them visible. */}
|
||||
{!scopes.includes('ska') && <WarningLine>{t('missing_skattekonto')}</WarningLine>}
|
||||
{!scopes.includes('agd') && <WarningLine>{t('missing_agd')}</WarningLine>}
|
||||
{status.disabled && <WarningLine>{t('disabled_filings_message')}</WarningLine>}
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -510,54 +495,44 @@ function SkatteverketSystemConnectionCard() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('system_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t('system_intro')}</p>
|
||||
<SettingsGroup label={t('system_title')} help={t('system_intro')}>
|
||||
{state.ombud_org_number && (
|
||||
<SettingsRow label={t('system_org_label')}>
|
||||
<span className="font-mono text-sm tabular-nums">{state.ombud_org_number}</span>
|
||||
</SettingsRow>
|
||||
)}
|
||||
|
||||
{state.ombud_org_number && (
|
||||
<div className="rounded-md border border-border bg-secondary/40 p-3 text-sm">
|
||||
<p className="text-muted-foreground">{t('system_org_label')}</p>
|
||||
<p className="font-mono font-medium tabular-nums">{state.ombud_org_number}</p>
|
||||
</div>
|
||||
<SettingsRow label={t('system_behorighet_lasombud')}>
|
||||
{grantBadge(state.connection?.lasombud_status)}
|
||||
</SettingsRow>
|
||||
<SettingsRow label={t('system_behorighet_moms')}>
|
||||
{grantBadge(state.connection?.moms_ombud_status)}
|
||||
</SettingsRow>
|
||||
|
||||
{state.cert?.expiresSoon && (
|
||||
<WarningLine>
|
||||
{t('system_cert_expires_soon', { days: state.cert.daysUntilExpiry })}
|
||||
</WarningLine>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 px-1 py-3">
|
||||
{state.grant_url && (
|
||||
<a
|
||||
href={state.grant_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t('system_open_ombud')}
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-border p-3">
|
||||
<span>{t('system_behorighet_lasombud')}</span>
|
||||
{grantBadge(state.connection?.lasombud_status)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-border p-3">
|
||||
<span>{t('system_behorighet_moms')}</span>
|
||||
{grantBadge(state.connection?.moms_ombud_status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.cert?.expiresSoon && (
|
||||
<div className="flex gap-2 rounded-md border border-border bg-secondary/40 p-3 text-sm text-foreground">
|
||||
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p>{t('system_cert_expires_soon', { days: state.cert.daysUntilExpiry })}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{state.grant_url && (
|
||||
<Button variant="outline" asChild>
|
||||
<a href={state.grant_url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
{t('system_open_ombud')}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={verify} disabled={verifying}>
|
||||
{verifying && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{verifying ? t('system_verifying') : t('system_verify')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Button size="sm" onClick={verify} disabled={verifying}>
|
||||
{verifying && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{verifying ? t('system_verifying') : t('system_verify')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,18 +2,16 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { CalendarClock, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
SettingsGroup,
|
||||
SettingsInput,
|
||||
SettingsRow,
|
||||
SettingsRowNote,
|
||||
SettingsSelect,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useErrorToast } from '@/lib/hooks/use-error-toast'
|
||||
import type {
|
||||
@@ -142,102 +140,98 @@ export function TaxAssessmentNoticesPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="border-t border-border pt-8" aria-labelledby="tax-assessment-notices-title">
|
||||
<div className="max-w-3xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<CalendarClock className="mt-0.5 h-5 w-5" />
|
||||
<div>
|
||||
<h2 id="tax-assessment-notices-title" className="font-display text-xl tracking-tight">
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{t('description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={saveNotice} className="mt-6 grid gap-4 rounded-lg border border-border p-4 sm:grid-cols-2 sm:p-6">
|
||||
// What this panel is for ("enter the exact payment date from the
|
||||
// slutskattebesked") is static guidance: it lives behind the group "?".
|
||||
<SettingsGroup label={t('title')} help={t('description')}>
|
||||
<form onSubmit={saveNotice}>
|
||||
<SettingsRow label={t('fiscal_period')}>
|
||||
<FiscalYearSelector
|
||||
value={selectedPeriodId}
|
||||
onChange={(periodId) => setSelectedPeriodId(periodId)}
|
||||
includeAllOption={false}
|
||||
label={t('fiscal_period')}
|
||||
className="sm:col-span-2"
|
||||
label={null}
|
||||
className="w-full max-w-72"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tax-assessment-decision-type">{t('decision_type')}</Label>
|
||||
<Select value={decisionType} onValueChange={(value) => setDecisionType(value as TaxAssessmentDecisionType)}>
|
||||
<SelectTrigger id="tax-assessment-decision-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="final">{t('decision_final')}</SelectItem>
|
||||
<SelectItem value="reassessment">{t('decision_reassessment')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tax-assessment-decision-date">{t('decision_date')}</Label>
|
||||
<Input
|
||||
id="tax-assessment-decision-date"
|
||||
type="date"
|
||||
required
|
||||
value={decisionDate}
|
||||
onChange={(event) => setDecisionDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="tax-assessment-payment-due-date">{t('payment_due_date')}</Label>
|
||||
<Input
|
||||
id="tax-assessment-payment-due-date"
|
||||
type="date"
|
||||
required
|
||||
min={decisionDate}
|
||||
value={paymentDueDate}
|
||||
onChange={(event) => setPaymentDueDate(event.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('payment_due_date_help')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 sm:col-span-2">
|
||||
<Button type="submit" disabled={saving || !selectedPeriodId || !paymentDueDate}>
|
||||
{saving ? t('saving') : t(editingId ? 'update_action' : 'save_action')}
|
||||
</SettingsRow>
|
||||
<SettingsRow label={t('decision_type')} htmlFor="tax-assessment-decision-type">
|
||||
<SettingsSelect
|
||||
id="tax-assessment-decision-type"
|
||||
value={decisionType}
|
||||
onChange={(event) => setDecisionType(event.target.value as TaxAssessmentDecisionType)}
|
||||
>
|
||||
<option value="final">{t('decision_final')}</option>
|
||||
<option value="reassessment">{t('decision_reassessment')}</option>
|
||||
</SettingsSelect>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('decision_date')}
|
||||
htmlFor="tax-assessment-decision-date"
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="tax-assessment-decision-date"
|
||||
type="date"
|
||||
required
|
||||
value={decisionDate}
|
||||
onChange={(event) => setDecisionDate(event.target.value)}
|
||||
className="max-w-40 flex-none tabular-nums"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('payment_due_date')}
|
||||
htmlFor="tax-assessment-payment-due-date"
|
||||
help={t('payment_due_date_help')}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="tax-assessment-payment-due-date"
|
||||
type="date"
|
||||
required
|
||||
min={decisionDate}
|
||||
value={paymentDueDate}
|
||||
onChange={(event) => setPaymentDueDate(event.target.value)}
|
||||
className="max-w-40 flex-none tabular-nums"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<div className="flex flex-wrap gap-2 px-1 py-3">
|
||||
<Button type="submit" size="sm" disabled={saving || !selectedPeriodId || !paymentDueDate}>
|
||||
{saving ? t('saving') : t(editingId ? 'update_action' : 'save_action')}
|
||||
</Button>
|
||||
{editingId && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={resetForm} disabled={saving}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
{editingId && (
|
||||
<Button type="button" variant="ghost" onClick={resetForm} disabled={saving}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{!loading && notices.length > 0 && (
|
||||
<div className="mt-6 divide-y divide-border border-y border-border">
|
||||
{notices.map((notice) => (
|
||||
<div key={notice.id} className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{notice.decision_type === 'final' ? t('decision_final') : t('decision_reassessment')}
|
||||
{notice.fiscal_period?.name ? `: ${notice.fiscal_period.name}` : ''}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('due_summary', { date: notice.payment_due_date })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => editNotice(notice)} disabled={saving}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{t('edit')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => void archiveNotice(notice)} disabled={saving}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t('archive')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Registered notices continue as flat hairline rows under the form. */}
|
||||
{!loading && notices.length > 0 && notices.map((notice) => (
|
||||
<div
|
||||
key={notice.id}
|
||||
className="flex flex-col gap-3 border-t border-border px-1 py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm">
|
||||
{notice.decision_type === 'final' ? t('decision_final') : t('decision_reassessment')}
|
||||
{notice.fiscal_period?.name ? `: ${notice.fiscal_period.name}` : ''}
|
||||
</p>
|
||||
<SettingsRowNote className="tabular-nums">
|
||||
{t('due_summary', { date: notice.payment_due_date })}
|
||||
</SettingsRowNote>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => editNotice(notice)} disabled={saving}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{t('edit')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => void archiveNotice(notice)} disabled={saving}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t('archive')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsInput,
|
||||
SettingsReveal,
|
||||
SettingsRow,
|
||||
SettingsSelect,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface TaxSettingsFormProps {
|
||||
@@ -18,6 +23,20 @@ interface TaxSettingsFormProps {
|
||||
rotRutSignalDetected?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Ledger/invoice-derived suggestion (EU sales, KU, ROT/RUT) as one visible
|
||||
* warning-tone sentence. The full body, including the legal deadlines and
|
||||
* late-fee amounts, lives behind the "?" so the signal stays a single line.
|
||||
*/
|
||||
function SignalLine({ text, help }: { text: string; help: React.ReactNode }) {
|
||||
return (
|
||||
<p className="flex items-center gap-2 border-b border-border px-1 py-3 text-[12.5px] leading-relaxed text-attn">
|
||||
<span>{text}</span>
|
||||
<HelpPopover className="shrink-0">{help}</HelpPopover>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export function TaxSettingsForm({
|
||||
settings,
|
||||
euSalesDetected = false,
|
||||
@@ -59,320 +78,267 @@ export function TaxSettingsForm({
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Entity type: read-only */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('entity_form_heading')}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium">
|
||||
<div>
|
||||
<SettingsGroup label={t('tax_vat_heading')}>
|
||||
{/* Entity type: read-only. Changing it is a support operation. */}
|
||||
<SettingsRow label={t('entity_form_heading')} help={t('entity_form_help')}>
|
||||
<span className="text-sm">
|
||||
{settings.entity_type === 'aktiebolag' ? t('entity_aktiebolag') : t('entity_enskild_firma')}
|
||||
</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('entity_form_help')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsRow>
|
||||
|
||||
{/* F-skatt */}
|
||||
<section className="border-t border-border pt-8 space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('tax_vat_heading')}
|
||||
</h2>
|
||||
<SettingsRow label={t('f_skatt_label')} htmlFor="f_skatt" help={t('f_skatt_help')}>
|
||||
<Switch
|
||||
id="f_skatt"
|
||||
checked={fSkatt}
|
||||
onCheckedChange={(v) => setFSkatt(v === true)}
|
||||
/>
|
||||
<input type="hidden" name="f_skatt" value={fSkatt ? 'true' : 'false'} />
|
||||
</SettingsRow>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="f_skatt"
|
||||
checked={fSkatt}
|
||||
onCheckedChange={(v) => setFSkatt(v === true)}
|
||||
<SettingsRow
|
||||
label={t('vat_registered_label')}
|
||||
htmlFor="vat_registered"
|
||||
help={t('vat_registered_help')}
|
||||
borderless={vatRegistered}
|
||||
>
|
||||
<Switch
|
||||
id="vat_registered"
|
||||
checked={vatRegistered}
|
||||
onCheckedChange={(value) => {
|
||||
const checked = value === true
|
||||
setVatRegistered(checked)
|
||||
if (checked && vatTaxableBaseOver40m) setMomsPeriod('monthly')
|
||||
}}
|
||||
/>
|
||||
<input type="hidden" name="vat_registered" value={vatRegistered ? 'true' : 'false'} />
|
||||
</SettingsRow>
|
||||
|
||||
{euSalesDetected && vatRegistered && (!hasEuTrade || !psEnabled) && (
|
||||
<SignalLine
|
||||
text={t('eu_trade_suggestion_title')}
|
||||
help={t('eu_trade_suggestion_help')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* The VAT sub-block. The reveal keeps the fields mounted while
|
||||
hidden (inert): the handleSave gates on vat_registered in
|
||||
TaxSettingsContent make the saved result identical to the old
|
||||
unmount behavior. */}
|
||||
<SettingsReveal open={vatRegistered} indent>
|
||||
<SettingsRow
|
||||
label={t('vat_number_label')}
|
||||
htmlFor="vat_number"
|
||||
help={t('vat_number_help')}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="vat_number"
|
||||
name="vat_number"
|
||||
placeholder="SE123456789001"
|
||||
defaultValue={settings.vat_number || ''}
|
||||
/>
|
||||
<input type="hidden" name="f_skatt" value={fSkatt ? 'true' : 'false'} />
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="f_skatt" className="cursor-pointer">{t('f_skatt_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('f_skatt_help')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="vat_registered"
|
||||
checked={vatRegistered}
|
||||
<SettingsRow
|
||||
label={t('moms_period_label')}
|
||||
htmlFor="moms_period"
|
||||
help={t('moms_period_help')}
|
||||
>
|
||||
<SettingsSelect
|
||||
id="moms_period"
|
||||
name="moms_period"
|
||||
value={momsPeriod}
|
||||
onChange={(e) => setMomsPeriod(e.target.value)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t('select_period_placeholder')}
|
||||
</option>
|
||||
<option value="monthly">{t('period_monthly')}</option>
|
||||
<option value="quarterly" disabled={vatTaxableBaseOver40m}>
|
||||
{t('period_quarterly')}
|
||||
</option>
|
||||
<option value="yearly" disabled={vatTaxableBaseOver40m}>
|
||||
{t('period_yearly')}
|
||||
</option>
|
||||
</SettingsSelect>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
label={t('vat_taxable_base_over_40m_label')}
|
||||
htmlFor="vat_taxable_base_over_40m"
|
||||
help={t('vat_taxable_base_over_40m_help')}
|
||||
>
|
||||
<Switch
|
||||
id="vat_taxable_base_over_40m"
|
||||
checked={vatTaxableBaseOver40m}
|
||||
onCheckedChange={(value) => {
|
||||
const checked = value === true
|
||||
setVatRegistered(checked)
|
||||
if (checked && vatTaxableBaseOver40m) setMomsPeriod('monthly')
|
||||
setVatTaxableBaseOver40m(checked)
|
||||
// Over 40 MSEK forces monthly VAT reporting.
|
||||
if (checked) setMomsPeriod('monthly')
|
||||
}}
|
||||
/>
|
||||
<input type="hidden" name="vat_registered" value={vatRegistered ? 'true' : 'false'} />
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="vat_registered" className="cursor-pointer">{t('vat_registered_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('vat_registered_help')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="hidden"
|
||||
name="vat_taxable_base_over_40m"
|
||||
value={vatTaxableBaseOver40m ? 'true' : 'false'}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
{euSalesDetected && vatRegistered && (!hasEuTrade || !psEnabled) && (
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<p className="text-sm">{t('eu_trade_suggestion_title')}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('eu_trade_suggestion_help')}
|
||||
</p>
|
||||
</div>
|
||||
<SettingsRow
|
||||
label={t('vat_has_eu_trade_label')}
|
||||
htmlFor="vat_has_eu_trade"
|
||||
help={t('vat_has_eu_trade_help')}
|
||||
borderless={hasEuTrade}
|
||||
>
|
||||
<Switch
|
||||
id="vat_has_eu_trade"
|
||||
checked={hasEuTrade}
|
||||
onCheckedChange={(value) => {
|
||||
const checked = value === true
|
||||
setHasEuTrade(checked)
|
||||
// No EU trade means no periodisk sammanställning.
|
||||
if (!checked) setPsEnabled(false)
|
||||
}}
|
||||
/>
|
||||
<input type="hidden" name="vat_has_eu_trade" value={hasEuTrade ? 'true' : 'false'} />
|
||||
</SettingsRow>
|
||||
|
||||
{momsPeriod === 'yearly' && !hasEuTrade && !isEnskildFirma && (
|
||||
<SettingsRow
|
||||
label={t('vat_filing_method_label')}
|
||||
htmlFor="vat_filing_method"
|
||||
help={t('vat_filing_method_help')}
|
||||
borderless
|
||||
>
|
||||
<SettingsSelect
|
||||
id="vat_filing_method"
|
||||
name="vat_filing_method"
|
||||
defaultValue={settings.vat_filing_method || 'electronic'}
|
||||
>
|
||||
<option value="electronic">{t('filing_method_electronic')}</option>
|
||||
<option value="paper">{t('filing_method_paper')}</option>
|
||||
</SettingsSelect>
|
||||
</SettingsRow>
|
||||
)}
|
||||
|
||||
{vatRegistered && (
|
||||
<div className="space-y-4 pl-7">
|
||||
<div className="max-w-xs space-y-2">
|
||||
<Label htmlFor="vat_number">{t('vat_number_label')}</Label>
|
||||
<Input
|
||||
id="vat_number"
|
||||
name="vat_number"
|
||||
placeholder="SE123456789001"
|
||||
defaultValue={settings.vat_number || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('vat_number_help')}
|
||||
</p>
|
||||
</div>
|
||||
<SettingsReveal open={hasEuTrade}>
|
||||
<SettingsRow
|
||||
label={t('periodisk_enabled_label')}
|
||||
htmlFor="periodisk_sammanstallning_enabled"
|
||||
help={t('periodisk_enabled_help')}
|
||||
borderless={psEnabled}
|
||||
>
|
||||
<Switch
|
||||
id="periodisk_sammanstallning_enabled"
|
||||
checked={psEnabled}
|
||||
onCheckedChange={(value) => setPsEnabled(value === true)}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="periodisk_sammanstallning_enabled"
|
||||
value={psEnabled ? 'true' : 'false'}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<div className="max-w-xs space-y-2">
|
||||
<Label>{t('moms_period_label')}</Label>
|
||||
<Select
|
||||
name="moms_period"
|
||||
value={momsPeriod || undefined}
|
||||
onValueChange={setMomsPeriod}
|
||||
<SettingsReveal open={psEnabled}>
|
||||
<SettingsRow
|
||||
label={t('periodisk_label')}
|
||||
htmlFor="periodisk_sammanstallning_period"
|
||||
help={t('periodisk_help')}
|
||||
>
|
||||
<SettingsSelect
|
||||
id="periodisk_sammanstallning_period"
|
||||
name="periodisk_sammanstallning_period"
|
||||
defaultValue={settings.periodisk_sammanstallning_period || 'monthly'}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('select_period_placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="monthly">{t('period_monthly')}</SelectItem>
|
||||
<SelectItem value="quarterly" disabled={vatTaxableBaseOver40m}>
|
||||
{t('period_quarterly')}
|
||||
</SelectItem>
|
||||
<SelectItem value="yearly" disabled={vatTaxableBaseOver40m}>
|
||||
{t('period_yearly')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('moms_period_help')}
|
||||
</p>
|
||||
</div>
|
||||
<option value="monthly">{t('period_monthly')}</option>
|
||||
<option value="quarterly">{t('period_quarterly')}</option>
|
||||
</SettingsSelect>
|
||||
</SettingsRow>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="vat_taxable_base_over_40m"
|
||||
checked={vatTaxableBaseOver40m}
|
||||
onCheckedChange={(value) => {
|
||||
const checked = value === true
|
||||
setVatTaxableBaseOver40m(checked)
|
||||
if (checked) setMomsPeriod('monthly')
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="vat_taxable_base_over_40m"
|
||||
value={vatTaxableBaseOver40m ? 'true' : 'false'}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="vat_taxable_base_over_40m" className="cursor-pointer">
|
||||
{t('vat_taxable_base_over_40m_label')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('vat_taxable_base_over_40m_help')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsRow
|
||||
label={t('periodisk_filing_method_label')}
|
||||
htmlFor="periodisk_sammanstallning_filing_method"
|
||||
help={t('periodisk_filing_method_help')}
|
||||
borderless
|
||||
>
|
||||
<SettingsSelect
|
||||
id="periodisk_sammanstallning_filing_method"
|
||||
name="periodisk_sammanstallning_filing_method"
|
||||
defaultValue={settings.periodisk_sammanstallning_filing_method || 'electronic'}
|
||||
>
|
||||
<option value="electronic">{t('filing_method_electronic')}</option>
|
||||
<option value="paper">{t('filing_method_paper')}</option>
|
||||
</SettingsSelect>
|
||||
</SettingsRow>
|
||||
</SettingsReveal>
|
||||
</SettingsReveal>
|
||||
</SettingsReveal>
|
||||
</SettingsGroup>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="vat_has_eu_trade"
|
||||
checked={hasEuTrade}
|
||||
onCheckedChange={(value) => {
|
||||
const checked = value === true
|
||||
setHasEuTrade(checked)
|
||||
if (!checked) setPsEnabled(false)
|
||||
}}
|
||||
/>
|
||||
<input type="hidden" name="vat_has_eu_trade" value={hasEuTrade ? 'true' : 'false'} />
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="vat_has_eu_trade" className="cursor-pointer">
|
||||
{t('vat_has_eu_trade_label')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('vat_has_eu_trade_help')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{momsPeriod === 'yearly' && !hasEuTrade && !isEnskildFirma && (
|
||||
<div className="max-w-xs space-y-2">
|
||||
<Label>{t('vat_filing_method_label')}</Label>
|
||||
<Select
|
||||
name="vat_filing_method"
|
||||
defaultValue={settings.vat_filing_method || 'electronic'}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="electronic">{t('filing_method_electronic')}</SelectItem>
|
||||
<SelectItem value="paper">{t('filing_method_paper')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{t('vat_filing_method_help')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasEuTrade && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="periodisk_sammanstallning_enabled"
|
||||
checked={psEnabled}
|
||||
onCheckedChange={(value) => setPsEnabled(value === true)}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="periodisk_sammanstallning_enabled"
|
||||
value={psEnabled ? 'true' : 'false'}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="periodisk_sammanstallning_enabled" className="cursor-pointer">
|
||||
{t('periodisk_enabled_label')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('periodisk_enabled_help')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{psEnabled && (
|
||||
<div className="grid max-w-2xl grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('periodisk_label')}</Label>
|
||||
<Select
|
||||
name="periodisk_sammanstallning_period"
|
||||
defaultValue={settings.periodisk_sammanstallning_period || 'monthly'}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="monthly">{t('period_monthly')}</SelectItem>
|
||||
<SelectItem value="quarterly">{t('period_quarterly')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{t('periodisk_help')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('periodisk_filing_method_label')}</Label>
|
||||
<Select
|
||||
name="periodisk_sammanstallning_filing_method"
|
||||
defaultValue={settings.periodisk_sammanstallning_filing_method || 'electronic'}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="electronic">{t('filing_method_electronic')}</SelectItem>
|
||||
<SelectItem value="paper">{t('filing_method_paper')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('periodisk_filing_method_help')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tax contact: required for SKV-filings */}
|
||||
<section className="border-t border-border pt-8 space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('tax_contact_heading')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
{t('tax_contact_help')}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-2xl">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tax_contact_name">{t('tax_contact_name_label')}</Label>
|
||||
<Input
|
||||
id="tax_contact_name"
|
||||
name="tax_contact_name"
|
||||
defaultValue={settings.tax_contact_name || ''}
|
||||
placeholder={t('tax_contact_name_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tax_contact_phone">{t('tax_contact_phone_label')}</Label>
|
||||
<Input
|
||||
id="tax_contact_phone"
|
||||
name="tax_contact_phone"
|
||||
defaultValue={settings.tax_contact_phone || ''}
|
||||
placeholder="08-123 45 67"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="tax_contact_email">{t('tax_contact_email_label')}</Label>
|
||||
<Input
|
||||
id="tax_contact_email"
|
||||
name="tax_contact_email"
|
||||
type="email"
|
||||
defaultValue={settings.tax_contact_email || ''}
|
||||
placeholder="anna@foretaget.se"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/* Tax contact: required for SKV filings. */}
|
||||
<SettingsGroup label={t('tax_contact_heading')} help={t('tax_contact_help')}>
|
||||
<SettingsRow label={t('tax_contact_name_label')} htmlFor="tax_contact_name" align="baseline">
|
||||
<SettingsInput
|
||||
id="tax_contact_name"
|
||||
name="tax_contact_name"
|
||||
defaultValue={settings.tax_contact_name || ''}
|
||||
placeholder={t('tax_contact_name_placeholder')}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={t('tax_contact_phone_label')} htmlFor="tax_contact_phone" align="baseline">
|
||||
<SettingsInput
|
||||
id="tax_contact_phone"
|
||||
name="tax_contact_phone"
|
||||
defaultValue={settings.tax_contact_phone || ''}
|
||||
placeholder="08-123 45 67"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={t('tax_contact_email_label')} htmlFor="tax_contact_email" align="baseline">
|
||||
<SettingsInput
|
||||
id="tax_contact_email"
|
||||
name="tax_contact_email"
|
||||
type="email"
|
||||
defaultValue={settings.tax_contact_email || ''}
|
||||
placeholder="anna@foretaget.se"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Fiscal year & salaries */}
|
||||
<section className="border-t border-border pt-8 space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('fiscal_year_salaries_heading')}
|
||||
</h2>
|
||||
|
||||
<div className="max-w-xs space-y-2">
|
||||
<Label>{t('fiscal_year_start_label')}</Label>
|
||||
<SettingsGroup label={t('fiscal_year_salaries_heading')}>
|
||||
<SettingsRow
|
||||
label={t('fiscal_year_start_label')}
|
||||
htmlFor="fiscal_year_start_month"
|
||||
help={isEnskildFirma ? t('fiscal_year_ef_help') : t('fiscal_year_change_help')}
|
||||
>
|
||||
{isEnskildFirma ? (
|
||||
<>
|
||||
<Input value={t('month_jan')} disabled />
|
||||
<SettingsInput
|
||||
id="fiscal_year_start_month"
|
||||
value={t('month_jan')}
|
||||
disabled
|
||||
className="max-w-32 flex-none"
|
||||
/>
|
||||
<input type="hidden" name="fiscal_year_start_month" value="1" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('fiscal_year_ef_help')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
name="fiscal_year_start_month"
|
||||
defaultValue={String(settings.fiscal_year_start_month || 1)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{months.map((month, i) => (
|
||||
<SelectItem key={i + 1} value={String(i + 1)}>{month}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('fiscal_year_change_help')}
|
||||
</p>
|
||||
</>
|
||||
<SettingsSelect
|
||||
id="fiscal_year_start_month"
|
||||
name="fiscal_year_start_month"
|
||||
defaultValue={String(settings.fiscal_year_start_month || 1)}
|
||||
>
|
||||
{months.map((month, i) => (
|
||||
<option key={i + 1} value={String(i + 1)}>{month}</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
)}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
<SettingsRow label={t('pays_salaries_label')} htmlFor="pays_salaries" help={t('pays_salaries_help')}>
|
||||
<Switch
|
||||
id="pays_salaries"
|
||||
checked={paysSalaries}
|
||||
onCheckedChange={(v) => {
|
||||
@@ -383,16 +349,15 @@ export function TaxSettingsForm({
|
||||
}}
|
||||
/>
|
||||
<input type="hidden" name="pays_salaries" value={paysSalaries ? 'true' : 'false'} />
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="pays_salaries" className="cursor-pointer">{t('pays_salaries_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('pays_salaries_help')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
<SettingsRow
|
||||
label={t('employer_registered_label')}
|
||||
htmlFor="employer_registered"
|
||||
help={t('employer_registered_help')}
|
||||
borderless={employerRegistered}
|
||||
>
|
||||
<Switch
|
||||
id="employer_registered"
|
||||
checked={employerRegistered}
|
||||
onCheckedChange={(v) => {
|
||||
@@ -406,19 +371,16 @@ export function TaxSettingsForm({
|
||||
name="employer_registered"
|
||||
value={employerRegistered ? 'true' : 'false'}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="employer_registered" className="cursor-pointer">
|
||||
{t('employer_registered_label')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('employer_registered_help')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
{employerRegistered && (
|
||||
<div className="flex items-start space-x-3 pl-7">
|
||||
<Checkbox
|
||||
<SettingsReveal open={employerRegistered}>
|
||||
<SettingsRow
|
||||
label={t('employer_seasonal_label')}
|
||||
htmlFor="employer_seasonal"
|
||||
help={t('employer_seasonal_help')}
|
||||
borderless
|
||||
>
|
||||
<Switch
|
||||
id="employer_seasonal"
|
||||
checked={employerSeasonal}
|
||||
onCheckedChange={(v) => setEmployerSeasonal(v === true)}
|
||||
@@ -428,35 +390,22 @@ export function TaxSettingsForm({
|
||||
name="employer_seasonal"
|
||||
value={employerSeasonal ? 'true' : 'false'}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="employer_seasonal" className="cursor-pointer">
|
||||
{t('employer_seasonal_label')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('employer_seasonal_help')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</SettingsRow>
|
||||
</SettingsReveal>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Kontrolluppgifter (KU) */}
|
||||
<section className="border-t border-border pt-8 space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('kontrolluppgifter_heading')}
|
||||
</h2>
|
||||
|
||||
<SettingsGroup label={t('kontrolluppgifter_heading')}>
|
||||
{kuSignalDetected && !kuEnabled && (
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<p className="text-sm">{t('ku_suggestion_title')}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('ku_suggestion_help')}
|
||||
</p>
|
||||
</div>
|
||||
<SignalLine text={t('ku_suggestion_title')} help={t('ku_suggestion_help')} />
|
||||
)}
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
<SettingsRow
|
||||
label={t('kontrolluppgifter_label')}
|
||||
htmlFor="kontrolluppgifter_enabled"
|
||||
help={t('kontrolluppgifter_help')}
|
||||
>
|
||||
<Switch
|
||||
id="kontrolluppgifter_enabled"
|
||||
checked={kuEnabled}
|
||||
onCheckedChange={(v) => setKuEnabled(v === true)}
|
||||
@@ -466,34 +415,21 @@ export function TaxSettingsForm({
|
||||
name="kontrolluppgifter_enabled"
|
||||
value={kuEnabled ? 'true' : 'false'}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="kontrolluppgifter_enabled" className="cursor-pointer">
|
||||
{t('kontrolluppgifter_label')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('kontrolluppgifter_help')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* ROT/RUT */}
|
||||
<section className="border-t border-border pt-8 space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('rot_rut_heading')}
|
||||
</h2>
|
||||
|
||||
<SettingsGroup label={t('rot_rut_heading')}>
|
||||
{rotRutSignalDetected && !rotRutEnabled && (
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<p className="text-sm">{t('rot_rut_suggestion_title')}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('rot_rut_suggestion_help')}
|
||||
</p>
|
||||
</div>
|
||||
<SignalLine text={t('rot_rut_suggestion_title')} help={t('rot_rut_suggestion_help')} />
|
||||
)}
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
<SettingsRow
|
||||
label={t('rot_rut_label')}
|
||||
htmlFor="rot_rut_enabled"
|
||||
help={t('rot_rut_help')}
|
||||
>
|
||||
<Switch
|
||||
id="rot_rut_enabled"
|
||||
checked={rotRutEnabled}
|
||||
onCheckedChange={(v) => setRotRutEnabled(v === true)}
|
||||
@@ -503,78 +439,51 @@ export function TaxSettingsForm({
|
||||
name="rot_rut_enabled"
|
||||
value={rotRutEnabled ? 'true' : 'false'}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rot_rut_enabled" className="cursor-pointer">
|
||||
{t('rot_rut_label')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('rot_rut_help')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Preliminary tax */}
|
||||
<section className="border-t border-border pt-8 space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('preliminary_tax_heading')}
|
||||
</h2>
|
||||
|
||||
<div className="max-w-xs space-y-2">
|
||||
<Label htmlFor="preliminary_tax_monthly">
|
||||
{t('preliminary_tax_monthly_label')}
|
||||
</Label>
|
||||
<Input
|
||||
<SettingsGroup label={t('preliminary_tax_heading')}>
|
||||
<SettingsRow
|
||||
label={t('preliminary_tax_monthly_label')}
|
||||
htmlFor="preliminary_tax_monthly"
|
||||
help={t('preliminary_tax_monthly_help')}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="preliminary_tax_monthly"
|
||||
name="preliminary_tax_monthly"
|
||||
type="number"
|
||||
defaultValue={settings.preliminary_tax_monthly || ''}
|
||||
className="max-w-32 flex-none tabular-nums"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('preliminary_tax_monthly_help')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Long-tail deadlines: explicit opt-in only */}
|
||||
<section className="border-t border-border pt-8 space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('more_deadlines_heading')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
{t('more_deadlines_help')}
|
||||
</p>
|
||||
|
||||
<SettingsGroup label={t('more_deadlines_heading')} help={t('more_deadlines_help')}>
|
||||
{vatRegistered && (
|
||||
<>
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
<SettingsRow label={t('oss_label')} htmlFor="oss_enabled" help={t('oss_help')}>
|
||||
<Switch
|
||||
id="oss_enabled"
|
||||
checked={ossEnabled}
|
||||
onCheckedChange={(v) => setOssEnabled(v === true)}
|
||||
/>
|
||||
<input type="hidden" name="oss_enabled" value={ossEnabled ? 'true' : 'false'} />
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="oss_enabled" className="cursor-pointer">{t('oss_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('oss_help')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
<SettingsRow label={t('ioss_label')} htmlFor="ioss_enabled" help={t('ioss_help')}>
|
||||
<Switch
|
||||
id="ioss_enabled"
|
||||
checked={iossEnabled}
|
||||
onCheckedChange={(v) => setIossEnabled(v === true)}
|
||||
/>
|
||||
<input type="hidden" name="ioss_enabled" value={iossEnabled ? 'true' : 'false'} />
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="ioss_enabled" className="cursor-pointer">{t('ioss_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('ioss_help')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
<SettingsRow label={t('intrastat_label')} htmlFor="intrastat_enabled" help={t('intrastat_help')}>
|
||||
<Switch
|
||||
id="intrastat_enabled"
|
||||
checked={intrastatEnabled}
|
||||
onCheckedChange={(v) => setIntrastatEnabled(v === true)}
|
||||
@@ -584,18 +493,12 @@ export function TaxSettingsForm({
|
||||
name="intrastat_enabled"
|
||||
value={intrastatEnabled ? 'true' : 'false'}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="intrastat_enabled" className="cursor-pointer">
|
||||
{t('intrastat_label')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('intrastat_help')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
<SettingsRow label={t('punktskatt_label')} htmlFor="punktskatt_enabled" help={t('punktskatt_help')}>
|
||||
<Switch
|
||||
id="punktskatt_enabled"
|
||||
checked={punktskattEnabled}
|
||||
onCheckedChange={(v) => setPunktskattEnabled(v === true)}
|
||||
@@ -605,16 +508,14 @@ export function TaxSettingsForm({
|
||||
name="punktskatt_enabled"
|
||||
value={punktskattEnabled ? 'true' : 'false'}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="punktskatt_enabled" className="cursor-pointer">
|
||||
{t('punktskatt_label')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('punktskatt_help')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
<SettingsRow
|
||||
label={t('fyllnadsinbetalning_label')}
|
||||
htmlFor="fyllnadsinbetalning_enabled"
|
||||
help={t('fyllnadsinbetalning_help')}
|
||||
>
|
||||
<Switch
|
||||
id="fyllnadsinbetalning_enabled"
|
||||
checked={fyllnadEnabled}
|
||||
onCheckedChange={(v) => setFyllnadEnabled(v === true)}
|
||||
@@ -624,14 +525,8 @@ export function TaxSettingsForm({
|
||||
name="fyllnadsinbetalning_enabled"
|
||||
value={fyllnadEnabled ? 'true' : 'false'}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="fyllnadsinbetalning_enabled" className="cursor-pointer">
|
||||
{t('fyllnadsinbetalning_label')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('fyllnadsinbetalning_help')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Loader2, Users } from 'lucide-react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { SettingsGroup } from '@/components/settings/SettingsRows'
|
||||
|
||||
interface TeamMember {
|
||||
id: string
|
||||
@@ -57,41 +57,29 @@ export function TeamPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
{teamName || t('team_fallback')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="divide-y divide-border/40">
|
||||
{members.map((member) => (
|
||||
<div key={member.id} className="flex items-center justify-between py-3 first:pt-0 last:pb-0">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="h-8 w-8 rounded-full bg-muted/60 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{member.email.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{member.email}
|
||||
{member.is_current_user && (
|
||||
<span className="text-muted-foreground font-normal ml-1">{t('you_suffix')}</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{roleLabel(member.role)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<SettingsGroup label={teamName || t('team_fallback')}>
|
||||
{/* Read-only member roster: flat hairline rows, no cards. */}
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center gap-3 border-b border-border px-1 py-3"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted/60">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{member.email.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<p className="min-w-0 flex-1 truncate text-sm">
|
||||
{member.email}
|
||||
{member.is_current_user && (
|
||||
<span className="ml-1 text-muted-foreground">{t('you_suffix')}</span>
|
||||
)}
|
||||
</p>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{roleLabel(member.role)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,8 @@ import { useTranslations } from 'next-intl'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { SettingsGroup, SettingsRowNote } from '@/components/settings/SettingsRows'
|
||||
|
||||
interface VoucherSeries {
|
||||
voucher_series: string
|
||||
@@ -18,6 +17,7 @@ interface VoucherSeriesManagerProps {
|
||||
defaultSeries?: string
|
||||
}
|
||||
|
||||
/** Read-only list of the series that have actually been used. */
|
||||
export function VoucherSeriesManager({ defaultSeries }: VoucherSeriesManagerProps) {
|
||||
const t = useTranslations('settings_voucher_series')
|
||||
const { company } = useCompany()
|
||||
@@ -48,44 +48,32 @@ export function VoucherSeriesManager({ defaultSeries }: VoucherSeriesManagerProp
|
||||
const seriesEntries = Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('heading')}
|
||||
</h2>
|
||||
|
||||
<SettingsGroup label={t('heading')} help={t('footnote')}>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 px-1 py-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
) : seriesEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="px-1 py-3 text-sm text-muted-foreground">
|
||||
{t('empty_state', { series: defaultSeries || 'A' })}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">{t('active_series_label')}</Label>
|
||||
<div className="divide-y divide-border/8">
|
||||
{seriesEntries.map(([letter, lastNum]) => (
|
||||
<div key={letter} className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium tabular-nums">{t('series_prefix')} {letter}</span>
|
||||
{letter === (defaultSeries || 'A') && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">{t('default_badge')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{t('latest_number')}: {lastNum}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
seriesEntries.map(([letter, lastNum]) => (
|
||||
<div key={letter} className="flex items-center gap-3 border-b border-border px-1 py-3">
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
{t('series_prefix')} {letter}
|
||||
</span>
|
||||
{/* The default marker is a normal state: muted text, not a chip. */}
|
||||
{letter === (defaultSeries || 'A') && (
|
||||
<SettingsRowNote>{t('default_badge')}</SettingsRowNote>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 text-sm text-muted-foreground tabular-nums">
|
||||
{t('latest_number')}: {lastNum}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('footnote')}
|
||||
</p>
|
||||
</section>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { ChevronDown, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsReveal,
|
||||
SettingsRow,
|
||||
SettingsSelect,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import type { CompanySettings, JournalEntrySourceType } from '@/types'
|
||||
|
||||
@@ -39,6 +39,10 @@ const VISIBLE_SOURCE_TYPES: Array<{ key: JournalEntrySourceType; labelKey: strin
|
||||
{ key: 'year_end', labelKey: 'year_end' },
|
||||
]
|
||||
|
||||
// The everyday types stay visible; the long tail folds behind "Visa alla".
|
||||
// Keeps the map's iteration order intact: we only split it, never reorder.
|
||||
const ALWAYS_VISIBLE_COUNT = 3
|
||||
|
||||
// Swedish labels. Kept inline so this component is self-contained: these
|
||||
// labels are bookkeeping-domain terms that intentionally stay Swedish across
|
||||
// locales (see CLAUDE.md i18n table).
|
||||
@@ -65,12 +69,16 @@ interface Props {
|
||||
}
|
||||
|
||||
export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }: Props) {
|
||||
// Generic fold labels ("Visa alla (n)" / "Visa färre") shared with the
|
||||
// dashboard widgets; the domain labels themselves stay hardcoded Swedish.
|
||||
const tCommon = useTranslations('dashboard')
|
||||
const { toast } = useToast()
|
||||
const initialMap = settings.default_voucher_series_per_source_type || {}
|
||||
const [draft, setDraft] = useState<Partial<Record<JournalEntrySourceType, string>>>(
|
||||
initialMap as Partial<Record<JournalEntrySourceType, string>>,
|
||||
)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
|
||||
const handleChange = (sourceType: JournalEntrySourceType, value: string) => {
|
||||
setDraft((prev) => ({ ...prev, [sourceType]: value }))
|
||||
@@ -116,54 +124,66 @@ export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }:
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Verifikationsserier per typ
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tilldela en standardserie per typ av verifikat. Vanlig svensk
|
||||
praxis: leverantörsfakturor på serie B, löner på serie C, övrigt på
|
||||
serie A. Kan alltid ändras per verifikat när du bokför.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{VISIBLE_SOURCE_TYPES.map(({ key, labelKey }) => (
|
||||
<div key={key} className="flex items-center justify-between gap-3">
|
||||
<Label
|
||||
htmlFor={`series-${key}`}
|
||||
className="text-sm text-foreground flex-1 cursor-pointer"
|
||||
>
|
||||
{SV_LABELS[labelKey] ?? key}
|
||||
</Label>
|
||||
<Select
|
||||
value={(draft[key] as string | undefined) || 'A'}
|
||||
onValueChange={(v) => handleChange(key, v)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`series-${key}`}
|
||||
className="w-16 font-mono"
|
||||
aria-label={SV_LABELS[labelKey] ?? key}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SERIES_OPTIONS.map((letter) => (
|
||||
<SelectItem key={letter} value={letter} className="font-mono">
|
||||
{letter}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
const renderRow = (
|
||||
{ key, labelKey }: (typeof VISIBLE_SOURCE_TYPES)[number],
|
||||
borderless = false,
|
||||
) => (
|
||||
<SettingsRow
|
||||
key={key}
|
||||
label={SV_LABELS[labelKey] ?? key}
|
||||
htmlFor={`series-${key}`}
|
||||
borderless={borderless}
|
||||
>
|
||||
<SettingsSelect
|
||||
id={`series-${key}`}
|
||||
value={(draft[key] as string | undefined) || 'A'}
|
||||
onChange={(e) => handleChange(key, e.target.value)}
|
||||
className="font-mono"
|
||||
>
|
||||
{SERIES_OPTIONS.map((letter) => (
|
||||
<option key={letter} value={letter}>
|
||||
{letter}
|
||||
</option>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSelect>
|
||||
</SettingsRow>
|
||||
)
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
const alwaysVisible = VISIBLE_SOURCE_TYPES.slice(0, ALWAYS_VISIBLE_COUNT)
|
||||
const folded = VISIBLE_SOURCE_TYPES.slice(ALWAYS_VISIBLE_COUNT)
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
label="Verifikationsserier per typ"
|
||||
help="Tilldela en standardserie per typ av verifikat. Vanlig svensk praxis: leverantörsfakturor på serie B, löner på serie C, övrigt på serie A. Kan alltid ändras per verifikat när du bokför."
|
||||
>
|
||||
{alwaysVisible.map((entry, i) =>
|
||||
// The last always-visible row sits right above the fold toggle:
|
||||
// drop its hairline so the fold reads as part of the same list.
|
||||
renderRow(entry, i === alwaysVisible.length - 1),
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAll((v) => !v)}
|
||||
aria-expanded={showAll}
|
||||
className="flex items-center gap-1.5 px-1 py-2 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
||||
>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn('h-3.5 w-3.5 transition-transform duration-150', showAll && 'rotate-180')}
|
||||
/>
|
||||
{showAll ? tCommon('show_less') : tCommon('show_all', { count: folded.length })}
|
||||
</button>
|
||||
|
||||
<SettingsReveal open={showAll} indent={false}>
|
||||
{folded.map((entry, i) => renderRow(entry, i === folded.length - 1))}
|
||||
</SettingsReveal>
|
||||
|
||||
<div className="flex justify-end px-1 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isSaving}
|
||||
>
|
||||
@@ -171,6 +191,6 @@ export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }:
|
||||
Spara serier
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,15 +5,21 @@ import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Sun, Moon, Monitor, LogOut, Languages, ExternalLink } from 'lucide-react'
|
||||
import { Sun, Moon, Monitor, LogOut, ExternalLink } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { SecuritySettings } from '@/components/settings/SecuritySettings'
|
||||
import { InstallAppSection } from '@/components/settings/InstallAppSection'
|
||||
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
|
||||
import { AccountDangerZone } from '@/components/settings/AccountDangerZone'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsInput,
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsSectionHeader,
|
||||
SettingsSeg,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import { clearRecaptIdentity } from '@/lib/recapt'
|
||||
@@ -31,6 +37,8 @@ export function AccountSettingsContent() {
|
||||
const activeLocale = useLocale() as Locale
|
||||
const tCommon = useTranslations('common')
|
||||
const tSettings = useTranslations('settings')
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const [savingLocale, setSavingLocale] = useState(false)
|
||||
const [fullName, setFullName] = useState('')
|
||||
const [initialName, setInitialName] = useState('')
|
||||
@@ -115,155 +123,139 @@ export function AccountSettingsContent() {
|
||||
en: tCommon('language_english'),
|
||||
}
|
||||
|
||||
const nameUnchanged = !fullName.trim() || fullName.trim() === initialName
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Name */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{tSettings('section_name')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
{tSettings('name_description')}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex-1 space-y-2 sm:max-w-sm">
|
||||
<Label htmlFor="full_name">{tSettings('name_label')}</Label>
|
||||
<Input
|
||||
id="full_name"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
placeholder={tSettings('name_placeholder')}
|
||||
disabled={nameLoading || savingName}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSaveName}
|
||||
disabled={
|
||||
nameLoading || savingName || !fullName.trim() || fullName.trim() === initialName
|
||||
}
|
||||
>
|
||||
{savingName ? tCommon('saving') : tCommon('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<div>
|
||||
<SettingsSectionHeader title={tNav('account')} intro={tIntro('account')} />
|
||||
|
||||
{/* Appearance */}
|
||||
<section className="space-y-4 border-t border-border pt-8">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{tSettings('section_appearance')}
|
||||
</h2>
|
||||
{mounted && (
|
||||
<div className="flex gap-3">
|
||||
{([
|
||||
{ value: 'light', labelKey: 'theme_light', icon: Sun },
|
||||
{ value: 'dark', labelKey: 'theme_dark', icon: Moon },
|
||||
{ value: 'system', labelKey: 'theme_system', icon: Monitor },
|
||||
] as const).map(({ value, labelKey, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTheme(value)}
|
||||
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-3 text-sm font-medium transition-colors ${
|
||||
theme === value
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/40'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
{tCommon(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Language */}
|
||||
<section className="space-y-4 border-t border-border pt-8">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{tSettings('section_language')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
{tSettings('language_description')}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
{SUPPORTED_LOCALES.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => handleLocaleChange(value)}
|
||||
disabled={savingLocale}
|
||||
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-3 text-sm font-medium transition-colors disabled:opacity-50 ${
|
||||
activeLocale === value
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/40'
|
||||
}`}
|
||||
{/* Profile: name, appearance, language, install-as-app */}
|
||||
<SettingsGroup label={tSettings('group_profile')}>
|
||||
<SettingsRow
|
||||
label={tSettings('name_label')}
|
||||
htmlFor="full_name"
|
||||
help={tSettings('name_description')}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="full_name"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
placeholder={tSettings('name_placeholder')}
|
||||
disabled={nameLoading || savingName}
|
||||
maxLength={100}
|
||||
/>
|
||||
<SettingsRowEnd>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveName}
|
||||
disabled={nameLoading || savingName || nameUnchanged}
|
||||
>
|
||||
<Languages className="h-4 w-4 text-muted-foreground" />
|
||||
{localeLabels[value]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{savingName ? tCommon('saving') : tCommon('save')}
|
||||
</Button>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
|
||||
{/* Install as app: renders nothing when already running installed */}
|
||||
<InstallAppSection />
|
||||
<SettingsRow label={tSettings('section_appearance')}>
|
||||
{mounted && (
|
||||
<SettingsSeg
|
||||
value={theme ?? 'system'}
|
||||
onChange={setTheme}
|
||||
aria-label={tSettings('section_appearance')}
|
||||
options={[
|
||||
{
|
||||
value: 'light',
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Sun className="h-3.5 w-3.5" />
|
||||
{tCommon('theme_light')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Moon className="h-3.5 w-3.5" />
|
||||
{tCommon('theme_dark')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'system',
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Monitor className="h-3.5 w-3.5" />
|
||||
{tCommon('theme_system')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</SettingsRow>
|
||||
|
||||
{/* Security */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<SecuritySettings />
|
||||
</div>
|
||||
<SettingsRow
|
||||
label={tSettings('section_language')}
|
||||
help={tSettings('language_description')}
|
||||
>
|
||||
<SettingsSeg
|
||||
value={activeLocale}
|
||||
onChange={(next) => void handleLocaleChange(next)}
|
||||
disabled={savingLocale}
|
||||
aria-label={tSettings('section_language')}
|
||||
options={SUPPORTED_LOCALES.map((value) => ({
|
||||
value,
|
||||
label: localeLabels[value],
|
||||
}))}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
{/* Calendar feed */}
|
||||
{hasCalendarExtension && (
|
||||
<div className="border-t border-border pt-8">
|
||||
<CalendarFeedSettings />
|
||||
</div>
|
||||
)}
|
||||
{/* Install as app: renders nothing when already running installed */}
|
||||
<InstallAppSection />
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Logout */}
|
||||
<section className="space-y-4 border-t border-border pt-8">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{tCommon('account_settings')}
|
||||
</h2>
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">{tCommon('logout')}</p>
|
||||
<p className="text-sm text-muted-foreground">{tCommon('logout_description')}</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{tCommon('logout')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
{/* Security: BankID, password, 2FA (renders its own group) */}
|
||||
<SecuritySettings />
|
||||
|
||||
{/* Calendar feed (extension-gated) */}
|
||||
{hasCalendarExtension && <CalendarFeedSettings />}
|
||||
|
||||
{/* Privacy & agreements: surface the otherwise-unlinked DPA + privacy policy */}
|
||||
<section className="space-y-4 border-t border-border pt-8">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{tSettings('legal_title')}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
href="/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between rounded-lg border p-4 transition-colors hover:bg-secondary/60"
|
||||
>
|
||||
<span className="font-medium">{tSettings('legal_privacy')}</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/dpa"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between rounded-lg border p-4 transition-colors hover:bg-secondary/60"
|
||||
>
|
||||
<span className="font-medium">{tSettings('legal_dpa')}</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
<SettingsGroup label={tSettings('legal_title')}>
|
||||
<SettingsRow label={tSettings('legal_privacy')}>
|
||||
<SettingsRowEnd>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/privacy" target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="mr-2 h-3.5 w-3.5" />
|
||||
{tCommon('open')}
|
||||
</Link>
|
||||
</Button>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={tSettings('legal_dpa')}>
|
||||
<SettingsRowEnd>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/dpa" target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="mr-2 h-3.5 w-3.5" />
|
||||
{tCommon('open')}
|
||||
</Link>
|
||||
</Button>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Sign out */}
|
||||
<SettingsGroup>
|
||||
<SettingsRow label={tCommon('logout')} help={tCommon('logout_description')}>
|
||||
<SettingsRowEnd>
|
||||
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-3.5 w-3.5" />
|
||||
{tCommon('logout')}
|
||||
</Button>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Delete account: only for non-sandbox */}
|
||||
{!settings?.is_sandbox && <AccountDangerZone />}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
|
||||
import { OAuthClientsPanel } from '@/components/settings/OAuthClientsPanel'
|
||||
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
|
||||
|
||||
export function ApiSettingsContent() {
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<SettingsSectionHeader title={tNav('api')} intro={tIntro('api')} />
|
||||
<ApiKeysPanel />
|
||||
<OAuthClientsPanel />
|
||||
</div>
|
||||
|
||||
@@ -3,18 +3,23 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { AgentMemoryPanel } from '@/components/settings/AgentMemoryPanel'
|
||||
import { AgentSkillsPanel } from '@/components/settings/AgentSkillsPanel'
|
||||
import { AgentKnowledgePanel } from '@/components/agent-knowledge/AgentKnowledgePanel'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsSectionHeader,
|
||||
SettingsSeg,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
|
||||
// "Assistenten": the ledger profile the agent reads before booking (Kunskap =
|
||||
// "Vad din agent vet", opens on the konteringskarta and is the default view),
|
||||
// what the assistant remembers about this company (Minne, editable), and the
|
||||
// domain knowledge it ships with (Kompetens, read-only). Tabs keep all three
|
||||
// one click away instead of stacked.
|
||||
// domain knowledge it ships with (Kompetens, read-only). The segmented control
|
||||
// keeps all three one click away instead of stacked.
|
||||
type View = 'knowledge' | 'memory' | 'skills'
|
||||
|
||||
const VIEW_ROUTE: Record<View, string> = {
|
||||
@@ -23,40 +28,43 @@ const VIEW_ROUTE: Record<View, string> = {
|
||||
skills: '/settings/assistant?view=skills',
|
||||
}
|
||||
|
||||
const VIEW_OPTIONS: Array<{ value: View; label: string }> = [
|
||||
{ value: 'knowledge', label: 'Kunskap' },
|
||||
{ value: 'memory', label: 'Minne' },
|
||||
{ value: 'skills', label: 'Kompetens' },
|
||||
]
|
||||
|
||||
export function AssistantSettingsContent() {
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const raw = searchParams.get('view')
|
||||
const view: View = raw === 'skills' ? 'skills' : raw === 'memory' ? 'memory' : 'knowledge'
|
||||
|
||||
function setView(next: string) {
|
||||
function setView(next: View) {
|
||||
// 'knowledge' is the default: keep its URL clean (no query string).
|
||||
router.replace(VIEW_ROUTE[next as View] ?? VIEW_ROUTE.knowledge, { scroll: false })
|
||||
router.replace(VIEW_ROUTE[next] ?? VIEW_ROUTE.knowledge, { scroll: false })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Tabs value={view} onValueChange={setView} className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="knowledge">Kunskap</TabsTrigger>
|
||||
<TabsTrigger value="memory">Minne</TabsTrigger>
|
||||
<TabsTrigger value="skills">Kompetens</TabsTrigger>
|
||||
</TabsList>
|
||||
<div>
|
||||
<SettingsSectionHeader title={tNav('assistant')} intro={tIntro('assistant')} />
|
||||
|
||||
{/* Radix unmounts the inactive panel, so each panel's data is fetched
|
||||
lazily the first time its tab is opened. */}
|
||||
<TabsContent value="knowledge">
|
||||
<AgentKnowledgePanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="memory">
|
||||
<AgentMemoryPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="skills">
|
||||
<AgentSkillsPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="mt-6">
|
||||
<SettingsSeg value={view} onChange={setView} options={VIEW_OPTIONS} aria-label="Välj vy" />
|
||||
</div>
|
||||
|
||||
<FabVisibilityCard />
|
||||
{/* Only the active view mounts, so each panel's data is fetched lazily
|
||||
the first time its view is opened (same behavior as when Radix Tabs
|
||||
unmounted the inactive panels). */}
|
||||
<div className="mt-6">
|
||||
{view === 'knowledge' && <AgentKnowledgePanel />}
|
||||
{view === 'memory' && <AgentMemoryPanel />}
|
||||
{view === 'skills' && <AgentSkillsPanel />}
|
||||
</div>
|
||||
|
||||
<FabVisibilityRow />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -65,7 +73,7 @@ export function AssistantSettingsContent() {
|
||||
// lives on user_preferences (server-rendered into the dashboard layout), so
|
||||
// a successful save triggers router.refresh() to make the button react
|
||||
// immediately instead of on next navigation.
|
||||
function FabVisibilityCard() {
|
||||
function FabVisibilityRow() {
|
||||
const t = useTranslations('settings_assistant')
|
||||
const router = useRouter()
|
||||
// null = not yet loaded (switch disabled meanwhile)
|
||||
@@ -108,19 +116,17 @@ function FabVisibilityCard() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6 flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{t('fab_title')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('fab_description')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={hideFab === null ? true : !hideFab}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={hideFab === null || saving}
|
||||
aria-label={t('fab_title')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SettingsGroup>
|
||||
<SettingsRow label={t('fab_title')} help={t('fab_description')}>
|
||||
<SettingsRowEnd>
|
||||
<Switch
|
||||
checked={hideFab === null ? true : !hideFab}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={hideFab === null || saving}
|
||||
aria-label={t('fab_title')}
|
||||
/>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@@ -12,11 +11,14 @@ import { AlertTriangle, CreditCard, ExternalLink } from 'lucide-react'
|
||||
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
|
||||
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
|
||||
|
||||
const BankingPanel = getSettingsPanel('enable-banking')
|
||||
|
||||
export function BankingSettingsContent() {
|
||||
const t = useTranslations('settings_banking')
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
@@ -74,32 +76,36 @@ export function BankingSettingsContent() {
|
||||
}, [searchParams, router, toast, t])
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<SettingsSectionHeader title={tNav('banking')} intro={tIntro('banking')} />
|
||||
|
||||
{/* OAuth bounce-back failure: a live warning, so it stays visible in the
|
||||
page flow, as compact warning-tone lines instead of a bordered box. */}
|
||||
{bankConnectionError && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-destructive p-4">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-destructive">{bankConnectionError}</p>
|
||||
<div role="alert" className="mt-6 flex items-start gap-2 px-1">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-attn" />
|
||||
<div className="min-w-0 flex-1 space-y-1 text-[12.5px] leading-relaxed">
|
||||
<p className="text-attn">{bankConnectionError}</p>
|
||||
{isAccessDenied && failedBankName && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground">
|
||||
{t('access_denied_hint', { bankName: failedBankName })}
|
||||
</p>
|
||||
)}
|
||||
{showHbPoaHint && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground">
|
||||
{t('hb_business_poa_hint')}{' '}
|
||||
<a
|
||||
href="https://tilisy.enablebanking.com/guides/SE/Handelsbanken/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
{t('hb_business_poa_link')}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('import_fallback_text')}<Link href="/import?mode=bank" className="underline hover:text-foreground">{t('import_fallback_link')}</Link>{t('import_fallback_suffix')}
|
||||
<p className="text-muted-foreground">
|
||||
{t('import_fallback_text')}<Link href="/import?mode=bank" className="underline underline-offset-2 hover:text-foreground">{t('import_fallback_link')}</Link>{t('import_fallback_suffix')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -109,7 +115,7 @@ export function BankingSettingsContent() {
|
||||
setIsAccessDenied(false)
|
||||
setShowHbPoaHint(false)
|
||||
}}
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
||||
aria-label={t('dismiss_aria')}
|
||||
>
|
||||
<span className="text-lg leading-none">×</span>
|
||||
@@ -119,26 +125,28 @@ export function BankingSettingsContent() {
|
||||
|
||||
{hasBankingExtension && BankingPanel ? (
|
||||
<>
|
||||
<BankSyncStatusChip />
|
||||
{/* The chip renders null when there are no connections; empty:hidden
|
||||
keeps its margin from leaving a stray gap in that case. */}
|
||||
<div className="mt-6 empty:hidden">
|
||||
<BankSyncStatusChip />
|
||||
</div>
|
||||
<BankingPanel />
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<EmptyState
|
||||
icon={CreditCard}
|
||||
title={t('not_enabled_title')}
|
||||
description={t('not_enabled_description')}
|
||||
>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/extensions">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
{t('go_to_extensions')}
|
||||
</Link>
|
||||
</Button>
|
||||
</EmptyState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="pt-8">
|
||||
<EmptyState
|
||||
icon={CreditCard}
|
||||
title={t('not_enabled_title')}
|
||||
description={t('not_enabled_description')}
|
||||
>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/extensions">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
{t('go_to_extensions')}
|
||||
</Link>
|
||||
</Button>
|
||||
</EmptyState>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, Clock, Minus } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Check } from 'lucide-react'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsRowNote,
|
||||
SettingsSectionHeader,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
import { BillingActions } from '@/components/settings/BillingActions'
|
||||
|
||||
@@ -16,15 +23,14 @@ const INCLUDED = [
|
||||
'E-postutskick av fakturor, påminnelser och lönebesked',
|
||||
]
|
||||
|
||||
// Free vs paid, shown as a comparison table: what the paid tier adds reads
|
||||
// strongest next to what stays free forever (freeze-and-retain, nothing is
|
||||
// taken away). Free rows mirror the old ALWAYS_FREE copy.
|
||||
const FEATURE_MATRIX: { label: string; free: boolean }[] = [
|
||||
{ label: 'Bokföring och rapporter', free: true },
|
||||
{ label: 'Fakturering', free: true },
|
||||
{ label: 'SIE-export', free: true },
|
||||
{ label: 'Org.nr-uppslag och momsnummerkontroll', free: true },
|
||||
...INCLUDED.map((label) => ({ label, free: false })),
|
||||
// What stays free forever (freeze-and-retain, nothing is taken away). Shown
|
||||
// as the second column of the flat feature list so the paid tier reads
|
||||
// strongest right next to it. Mirrors the old ALWAYS_FREE copy.
|
||||
const ALWAYS_FREE = [
|
||||
'Bokföring och rapporter',
|
||||
'Fakturering',
|
||||
'SIE-export',
|
||||
'Org.nr-uppslag och momsnummerkontroll',
|
||||
]
|
||||
|
||||
// Mirrors the checkout route's deferred-first-charge condition (Stripe's 48h
|
||||
@@ -42,12 +48,30 @@ interface BillingView {
|
||||
isDemo: boolean
|
||||
}
|
||||
|
||||
function FeatureList({ heading, items }: { heading: string; items: string[] }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">{heading}</p>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{items.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2 text-sm">
|
||||
<Check aria-hidden="true" className="mt-1 h-3.5 w-3.5 shrink-0 text-foreground" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → Abonnemang. Rendered both as the full page (thin wrapper) and
|
||||
* inside the settings modal (via SETTINGS_SECTIONS), so it's a client component
|
||||
* that reads its state from GET /api/billing/status.
|
||||
*/
|
||||
export function BillingSettingsContent() {
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const [view, setView] = useState<BillingView | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -80,11 +104,16 @@ export function BillingSettingsContent() {
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
|
||||
const header = <SettingsSectionHeader title={tNav('billing')} intro={tIntro('billing')} />
|
||||
|
||||
if (!view) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<div>
|
||||
{header}
|
||||
<div className="mt-6 space-y-4">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -93,42 +122,46 @@ export function BillingSettingsContent() {
|
||||
// them to creating a real account instead of a pay button that would 403.
|
||||
if (view.isDemo) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Abonnemang</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Du provkör Accounted i en demo. Skapa ett riktigt konto för att aktivera
|
||||
abonnemang, AI-assistent, bankkoppling och inlämning till Skatteverket.
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
<div>
|
||||
{header}
|
||||
<SettingsGroup label="Ditt abonnemang">
|
||||
<SettingsRow label="Status" borderless>
|
||||
<span>Demo</span>
|
||||
<SettingsRowNote>
|
||||
Du provkör Accounted i en demo. Skapa ett riktigt konto för att aktivera
|
||||
abonnemang, AI-assistent, bankkoppling och inlämning till Skatteverket.
|
||||
</SettingsRowNote>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
<SettingsGroup label="I abonnemanget">
|
||||
<ul className="space-y-2 px-1 pt-3">
|
||||
{INCLUDED.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2 text-sm">
|
||||
<Check className="h-4 w-4 mt-1 shrink-0 text-foreground" />
|
||||
<Check aria-hidden="true" className="mt-1 h-3.5 w-3.5 shrink-0 text-foreground" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Paying company → manage view.
|
||||
if (view.isPaying) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Abonnemang</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ditt abonnemang är aktivt. Du kan hantera eller avsluta det när som helst.
|
||||
</p>
|
||||
<BillingActions isPaying configured={view.configured} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div>
|
||||
{header}
|
||||
<SettingsGroup label="Ditt abonnemang">
|
||||
<SettingsRow label="Status" borderless>
|
||||
<span>Aktivt</span>
|
||||
<SettingsRowNote>Du kan hantera eller avsluta det när som helst.</SettingsRowNote>
|
||||
<SettingsRowEnd>
|
||||
<BillingActions isPaying configured={view.configured} />
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -136,18 +169,18 @@ export function BillingSettingsContent() {
|
||||
// confirm instead of re-showing the sell pitch to someone who already paid.
|
||||
if (view.paidJustNow) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Abonnemang</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="flex items-start gap-2 text-sm">
|
||||
<Check className="h-4 w-4 mt-0.5 shrink-0 text-foreground" />
|
||||
<span>Klart! Ditt abonnemang är aktiverat och alla funktioner låses upp inom någon minut.</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Ladda om sidan om du inte ser ändringen.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div>
|
||||
{header}
|
||||
<SettingsGroup label="Ditt abonnemang">
|
||||
<SettingsRow label="Status" borderless>
|
||||
<span className="flex items-start gap-2">
|
||||
<Check aria-hidden="true" className="mt-1 h-3.5 w-3.5 shrink-0 text-foreground" />
|
||||
<span>Klart! Ditt abonnemang är aktiverat och alla funktioner låses upp inom någon minut.</span>
|
||||
</span>
|
||||
<SettingsRowNote>Ladda om sidan om du inte ser ändringen.</SettingsRowNote>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -156,84 +189,60 @@ export function BillingSettingsContent() {
|
||||
const deferredTo = view.chargeDeferred ? trialEndsAt : null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
{header}
|
||||
|
||||
{/* Consequential trial countdown: one attn-tone sentence, not a banner. */}
|
||||
{daysLeft !== null && (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-secondary px-4 py-3 text-sm">
|
||||
<Clock className="h-4 w-4 shrink-0 text-foreground" />
|
||||
<span>
|
||||
{daysLeft > 0
|
||||
? `Din provperiod löper ut om ${daysLeft} ${daysLeft === 1 ? 'dag' : 'dagar'}${
|
||||
trialEndsAt ? ` (${formatDateLong(trialEndsAt)})` : ''
|
||||
}. ${
|
||||
deferredTo
|
||||
? 'Lägg till ditt kort nu: inget dras förrän provperioden är slut.'
|
||||
: 'Lägg till betalning nu så fortsätter allt utan avbrott.'
|
||||
}`
|
||||
: 'Din provperiod har löpt ut. Aktivera abonnemanget för att få tillbaka AI, bankkoppling och inlämning.'}
|
||||
</span>
|
||||
</div>
|
||||
<AttnLine className="mt-3">
|
||||
{daysLeft > 0
|
||||
? `Din provperiod löper ut om ${daysLeft} ${daysLeft === 1 ? 'dag' : 'dagar'}${
|
||||
trialEndsAt ? ` (${formatDateLong(trialEndsAt)})` : ''
|
||||
}. ${
|
||||
deferredTo
|
||||
? 'Lägg till ditt kort nu: inget dras förrän provperioden är slut.'
|
||||
: 'Lägg till betalning nu så fortsätter allt utan avbrott.'
|
||||
}`
|
||||
: 'Din provperiod har löpt ut. Aktivera abonnemanget för att få tillbaka AI, bankkoppling och inlämning.'}
|
||||
</AttnLine>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Allt du behöver för att sköta bokföringen själv</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<SettingsGroup
|
||||
label="Allt du behöver för att sköta bokföringen själv"
|
||||
help="Ingen bindningstid · Avsluta när du vill · Säker betalning via Stripe"
|
||||
>
|
||||
<div className="px-1 pt-3">
|
||||
<BillingActions isPaying={false} configured={view.configured} firstChargeAt={deferredTo} />
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
{deferredTo && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">Så funkar det</h3>
|
||||
<ol className="space-y-2 text-sm">
|
||||
<li className="flex gap-3">
|
||||
<span className="w-28 shrink-0 text-muted-foreground">Idag</span>
|
||||
<span>Du lägger till ditt kort. Inget dras nu.</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="w-28 shrink-0 text-muted-foreground tabular-nums">{formatDateLong(deferredTo)}</span>
|
||||
<span>Provperioden slutar och den första debiteringen sker.</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="w-28 shrink-0 text-muted-foreground">När som helst</span>
|
||||
<span>Avsluta direkt via Stripe. Före {formatDateLong(deferredTo)} kostar det ingenting.</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
{deferredTo && (
|
||||
<SettingsGroup label="Så funkar det">
|
||||
<SettingsRow label="Idag">
|
||||
<span>Du lägger till ditt kort. Inget dras nu.</span>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={<span className="tabular-nums">{formatDateLong(deferredTo)}</span>}>
|
||||
<span>Provperioden slutar och den första debiteringen sker.</span>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="När som helst" borderless>
|
||||
<span>
|
||||
Avsluta direkt via Stripe. Före {formatDateLong(deferredTo)} kostar det ingenting.
|
||||
</span>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
)}
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-full">Funktion</TableHead>
|
||||
<TableHead className="text-center">Gratis</TableHead>
|
||||
<TableHead className="text-center">Abonnemang</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{FEATURE_MATRIX.map((f) => (
|
||||
<TableRow key={f.label}>
|
||||
<TableCell className="text-sm">{f.label}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{f.free ? (
|
||||
<Check role="img" aria-label="Ingår" className="h-4 w-4 mx-auto text-foreground" />
|
||||
) : (
|
||||
<Minus role="img" aria-label="Ingår inte" className="h-4 w-4 mx-auto text-muted-foreground/50" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Check role="img" aria-label="Ingår" className="h-4 w-4 mx-auto text-foreground" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Utan abonnemang behåller du bokföringen, fakturorna, rapporterna och all din data utan kostnad. Ingenting
|
||||
raderas: räkenskapsinformation bevaras i sju år enligt bokföringslagen, oavsett abonnemang.
|
||||
</p>
|
||||
<SettingsGroup
|
||||
label="Funktioner"
|
||||
// The 7-year retention reassurance moved behind the "?": long legal
|
||||
// copy stays out of the page flow.
|
||||
help="Utan abonnemang behåller du bokföringen, fakturorna, rapporterna och all din data utan kostnad. Ingenting raderas: räkenskapsinformation bevaras i sju år enligt bokföringslagen, oavsett abonnemang."
|
||||
>
|
||||
<div className="grid gap-6 px-1 pt-3 sm:grid-cols-2">
|
||||
<FeatureList heading="I abonnemanget" items={INCLUDED} />
|
||||
<FeatureList heading="Alltid gratis" items={ALWAYS_FREE} />
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,9 +14,14 @@ import { applyDefaultSeriesToMap } from '@/lib/bookkeeping/voucher-series-resolv
|
||||
import { PeriodiseringAutoDetectToggle } from '@/components/settings/PeriodiseringAutoDetectToggle'
|
||||
import { DimensionsToggle } from '@/components/settings/DimensionsToggle'
|
||||
import { AccountingFrameworkForm } from '@/components/settings/AccountingFrameworkForm'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsSectionHeader,
|
||||
SettingsSelect,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import type { AccountingFramework, CompanySettings } from '@/types'
|
||||
|
||||
@@ -24,6 +29,8 @@ const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
||||
|
||||
export function BookkeepingSettingsContent() {
|
||||
const t = useTranslations('settings_bookkeeping')
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const { settings, isLoading, updateSettings, refetch } = useSettings()
|
||||
const { company } = useCompany()
|
||||
// Local mirror of the company-level accounting_framework so the K2/K3
|
||||
@@ -88,127 +95,98 @@ export function BookkeepingSettingsContent() {
|
||||
const isAktiebolag = company?.entity_type === 'aktiebolag'
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{isAktiebolag && (
|
||||
<AccountingFrameworkForm
|
||||
current={framework}
|
||||
onSaved={(next) => setFramework(next)}
|
||||
/>
|
||||
)}
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
|
||||
{/* Accounting method */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('method_heading')}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accounting_method">{t('method_label')}</Label>
|
||||
<select
|
||||
<div>
|
||||
<SettingsSectionHeader title={tNav('bookkeeping')} intro={tIntro('bookkeeping')} />
|
||||
|
||||
<SettingsFormWrapper onSave={handleSave}>
|
||||
{/* Grunder: framework (AB only), method, deferred booking, default
|
||||
series. The framework row saves through its own PATCH and opts out
|
||||
of this wrapper's dirty tracking; the rest read via FormData. */}
|
||||
<SettingsGroup label={t('group_basics')}>
|
||||
{isAktiebolag && (
|
||||
<AccountingFrameworkForm
|
||||
current={framework}
|
||||
onSaved={(next) => setFramework(next)}
|
||||
/>
|
||||
)}
|
||||
<SettingsRow
|
||||
label={t('method_label')}
|
||||
htmlFor="accounting_method"
|
||||
help={t('method_help')}
|
||||
>
|
||||
<SettingsSelect
|
||||
id="accounting_method"
|
||||
name="accounting_method"
|
||||
defaultValue={settings.accounting_method || 'accrual'}
|
||||
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="accrual">{t('method_accrual')}</option>
|
||||
<option value="cash">{t('method_cash')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('method_help')}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSelect>
|
||||
</SettingsRow>
|
||||
{/* #967: register/send without booking; ekonomi books in a separate
|
||||
explicit step. Only meaningful under faktureringsmetoden. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="defer_invoice_booking">{t('defer_booking_label')}</Label>
|
||||
<select
|
||||
<SettingsRow
|
||||
label={t('defer_booking_label')}
|
||||
htmlFor="defer_invoice_booking"
|
||||
help={t('defer_booking_help')}
|
||||
>
|
||||
<SettingsSelect
|
||||
id="defer_invoice_booking"
|
||||
name="defer_invoice_booking"
|
||||
defaultValue={settings.defer_invoice_booking ? 'true' : 'false'}
|
||||
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="false">{t('defer_booking_off')}</option>
|
||||
<option value="true">{t('defer_booking_on')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('defer_booking_help')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsSelect>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('series_label')}
|
||||
htmlFor="default_voucher_series"
|
||||
help={t('series_help')}
|
||||
>
|
||||
<SettingsSelect
|
||||
id="default_voucher_series"
|
||||
name="default_voucher_series"
|
||||
defaultValue={settings.default_voucher_series || 'A'}
|
||||
className="font-mono"
|
||||
>
|
||||
{SERIES_OPTIONS.map((letter) => (
|
||||
<option key={letter} value={letter}>
|
||||
{letter}
|
||||
</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Default voucher series */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('series_heading')}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_voucher_series">{t('series_label')}</Label>
|
||||
<select
|
||||
id="default_voucher_series"
|
||||
name="default_voucher_series"
|
||||
defaultValue={settings.default_voucher_series || 'A'}
|
||||
className="flex h-10 w-16 rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{SERIES_OPTIONS.map((letter) => (
|
||||
<option key={letter} value={letter}>{letter}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('series_help')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Period locking */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<PeriodLockingSettings settings={settings} />
|
||||
</div>
|
||||
<PeriodLockingSettings settings={settings} />
|
||||
</SettingsFormWrapper>
|
||||
|
||||
{/* Fiscal years */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<FiscalYearsManager />
|
||||
</div>
|
||||
<FiscalYearsManager />
|
||||
|
||||
{/* Voucher series: per-source-type mapping */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<VoucherSeriesPerSourceTypeForm
|
||||
settings={settings}
|
||||
onSettingsUpdated={updateSettings}
|
||||
/>
|
||||
</div>
|
||||
<VoucherSeriesPerSourceTypeForm
|
||||
settings={settings}
|
||||
onSettingsUpdated={updateSettings}
|
||||
/>
|
||||
|
||||
{/* Voucher series: read-only display */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<VoucherSeriesManager defaultSeries={settings.default_voucher_series || 'A'} />
|
||||
</div>
|
||||
<VoucherSeriesManager defaultSeries={settings.default_voucher_series || 'A'} />
|
||||
|
||||
{/* Periodisering auto-detect toggle */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<SettingsGroup label={t('group_automation')}>
|
||||
<PeriodiseringAutoDetectToggle />
|
||||
</div>
|
||||
|
||||
{/* Kostnadsställen & projekt (dimensions) toggle */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<DimensionsToggle />
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Cross-links */}
|
||||
<div className="border-t border-border pt-8 space-y-3">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('related_heading')}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
<SettingsGroup>
|
||||
<SettingsRow label={t('related_heading')} borderless>
|
||||
<Link
|
||||
href="/bookkeeping?tab=accounts"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t('related_chart_of_accounts')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { CompanyDangerZone } from '@/components/settings/CompanyDangerZone'
|
||||
import { CompanyInfoForm } from '@/components/settings/CompanyInfoForm'
|
||||
import { CompanyMembersSection } from '@/components/settings/CompanyMembersSection'
|
||||
@@ -10,12 +11,15 @@ import { LogoUpload } from '@/components/settings/LogoUpload'
|
||||
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
|
||||
import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
|
||||
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
|
||||
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
|
||||
import { ShareCapitalForm } from '@/components/settings/ShareCapitalForm'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
export function CompanySettingsContent() {
|
||||
const router = useRouter()
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const { settings, isLoading, updateSettings, refetch } = useSettings()
|
||||
|
||||
if (isLoading) return <SettingsLoadingSkeleton />
|
||||
@@ -56,8 +60,10 @@ export function CompanySettingsContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
|
||||
<div>
|
||||
<SettingsSectionHeader title={tNav('company')} intro={tIntro('company')} />
|
||||
|
||||
<SettingsFormWrapper onSave={handleSave}>
|
||||
<CompanyInfoForm settings={settings} />
|
||||
{settings.entity_type === 'aktiebolag' && (
|
||||
<ShareCapitalForm
|
||||
@@ -66,16 +72,12 @@ export function CompanySettingsContent() {
|
||||
)}
|
||||
</SettingsFormWrapper>
|
||||
|
||||
<div className="border-t border-border pt-8">
|
||||
<LogoUpload
|
||||
logoUrl={settings.logo_url}
|
||||
onUpdate={(url) => updateSettings({ logo_url: url })}
|
||||
/>
|
||||
</div>
|
||||
<LogoUpload
|
||||
logoUrl={settings.logo_url}
|
||||
onUpdate={(url) => updateSettings({ logo_url: url })}
|
||||
/>
|
||||
|
||||
<div className="border-t border-border pt-8">
|
||||
<CompanyMembersSection />
|
||||
</div>
|
||||
<CompanyMembersSection />
|
||||
|
||||
<FiscalPeriodEditor />
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { InvoiceSettingsForm } from '@/components/settings/InvoiceSettingsForm'
|
||||
import { InvoicePaymentLinkSettings } from '@/components/settings/InvoicePaymentLinkSettings'
|
||||
import { InvoicePaymentAccountsSettings } from '@/components/settings/InvoicePaymentAccountsSettings'
|
||||
@@ -10,10 +11,13 @@ import { PdfPrintSettings } from '@/components/settings/PdfPrintSettings'
|
||||
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
|
||||
import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
|
||||
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
|
||||
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
export function InvoicingSettingsContent() {
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const { settings, isLoading, updateSettings, refetch } = useSettings()
|
||||
|
||||
if (isLoading) return <SettingsLoadingSkeleton />
|
||||
@@ -43,36 +47,31 @@ export function InvoicingSettingsContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-end">
|
||||
<InvoicePreviewCard settings={settings} />
|
||||
</div>
|
||||
<div>
|
||||
<SettingsSectionHeader
|
||||
title={tNav('invoicing')}
|
||||
intro={tIntro('invoicing')}
|
||||
action={<InvoicePreviewCard settings={settings} />}
|
||||
/>
|
||||
|
||||
{/* Owner/admin only: the component gates itself on role. */}
|
||||
<InvoicePaymentAccountsSettings settings={settings} onUpdate={updateSettings} />
|
||||
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
|
||||
<SettingsFormWrapper onSave={handleSave}>
|
||||
<InvoiceSettingsForm settings={settings} />
|
||||
</SettingsFormWrapper>
|
||||
|
||||
{/* Payment link opt-in: saves individually via toggle switch */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<InvoicePaymentLinkSettings settings={settings} onUpdate={updateSettings} />
|
||||
</div>
|
||||
<InvoicePaymentLinkSettings settings={settings} onUpdate={updateSettings} />
|
||||
|
||||
{/* PDF settings: saves individually via toggle switches */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
|
||||
</div>
|
||||
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
|
||||
|
||||
{/* Fixed invoice email recipients: explicit save */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<InvoiceEmailRecipientsSettings settings={settings} onUpdate={updateSettings} />
|
||||
</div>
|
||||
{/* Fixed invoice email recipients: explicit save (owner/admin only) */}
|
||||
<InvoiceEmailRecipientsSettings settings={settings} onUpdate={updateSettings} />
|
||||
|
||||
{/* Invoice email texts: autosaves on blur */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<InvoiceEmailTextsSettings settings={settings} onUpdate={updateSettings} />
|
||||
</div>
|
||||
<InvoiceEmailTextsSettings settings={settings} onUpdate={updateSettings} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,15 +3,19 @@
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
|
||||
import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
|
||||
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsInput,
|
||||
SettingsRow,
|
||||
SettingsSectionHeader,
|
||||
SettingsSelect,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { TaxTableStatus } from '@/components/salary/TaxTableStatus'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
||||
@@ -24,11 +28,11 @@ const BANK_LABEL: Record<(typeof BANK_OPTIONS)[number], string> = {
|
||||
nordea: 'Nordea',
|
||||
}
|
||||
|
||||
const selectClassName =
|
||||
'flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
|
||||
|
||||
export function SalarySettingsContent() {
|
||||
const t = useTranslations('settings_salary')
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const tSalary = useTranslations('salary')
|
||||
const { settings, isLoading, updateSettings, refetch } = useSettings()
|
||||
// Controlled so the LB sunset note reacts to the selection before save.
|
||||
const [format, setFormat] = useState<'bg_lb' | 'pain001' | null>(null)
|
||||
@@ -54,7 +58,7 @@ export function SalarySettingsContent() {
|
||||
|
||||
// The booking engine resolves the series from the per-source-type map;
|
||||
// salary entries pass run.voucher_series explicitly, seeded from this
|
||||
// entry at run creation. Merge — never replace — the map so other
|
||||
// entry at run creation. Merge, never replace, the map so other
|
||||
// source-type overrides survive.
|
||||
if (series !== currentSeries) {
|
||||
updates.default_voucher_series_per_source_type = {
|
||||
@@ -72,135 +76,108 @@ export function SalarySettingsContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
|
||||
{/* Payment */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('payments_heading')}
|
||||
</h2>
|
||||
<div>
|
||||
<SettingsSectionHeader title={tNav('salary')} intro={tIntro('salary')} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="salary_pay_day">{t('pay_day_label')}</Label>
|
||||
<Input
|
||||
<SettingsFormWrapper onSave={handleSave}>
|
||||
<SettingsGroup label={t('payments_heading')} help={t('info_payroll_scope')}>
|
||||
<SettingsRow
|
||||
label={t('pay_day_label')}
|
||||
htmlFor="salary_pay_day"
|
||||
help={t('pay_day_help')}
|
||||
align="baseline"
|
||||
>
|
||||
<SettingsInput
|
||||
id="salary_pay_day"
|
||||
name="salary_pay_day"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={28}
|
||||
defaultValue={settings.salary_pay_day ?? 25}
|
||||
className="w-24 tabular-nums"
|
||||
className="max-w-24 flex-none tabular-nums"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('pay_day_help')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preferred_payment_format">{t('format_label')}</Label>
|
||||
<select
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('format_label')}
|
||||
htmlFor="preferred_payment_format"
|
||||
help={t('format_help')}
|
||||
borderless={effectiveFormat === 'bg_lb'}
|
||||
>
|
||||
<SettingsSelect
|
||||
id="preferred_payment_format"
|
||||
name="preferred_payment_format"
|
||||
value={effectiveFormat}
|
||||
onChange={(e) => setFormat(e.target.value as 'bg_lb' | 'pain001')}
|
||||
className={selectClassName}
|
||||
>
|
||||
<option value="pain001">{t('format_pain001')}</option>
|
||||
<option value="bg_lb">{t('format_bg_lb')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">{t('format_help')}</p>
|
||||
{effectiveFormat === 'bg_lb' && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-border p-3 text-xs max-w-xl">
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span className="text-muted-foreground">
|
||||
{t('sunset_warning')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="salary_default_bank">{t('bank_label')}</Label>
|
||||
<select
|
||||
</SettingsSelect>
|
||||
</SettingsRow>
|
||||
{effectiveFormat === 'bg_lb' && (
|
||||
<p className="border-b border-border px-1 pb-3 text-[12.5px] leading-relaxed text-attn">
|
||||
{t('sunset_warning')}
|
||||
</p>
|
||||
)}
|
||||
<SettingsRow label={t('bank_label')} htmlFor="salary_default_bank" help={t('bank_help')}>
|
||||
<SettingsSelect
|
||||
id="salary_default_bank"
|
||||
name="salary_default_bank"
|
||||
defaultValue={settings.salary_default_bank ?? 'none'}
|
||||
className={selectClassName}
|
||||
>
|
||||
<option value="none">{t('bank_none')}</option>
|
||||
{BANK_OPTIONS.map((key) => (
|
||||
<option key={key} value={key}>{BANK_LABEL[key]}</option>
|
||||
))}
|
||||
<option value="other">{t('bank_other')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">{t('bank_help')}</p>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsSelect>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Accounting */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('accounting_heading')}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="salary_voucher_series">{t('voucher_series_label')}</Label>
|
||||
<select
|
||||
id="salary_voucher_series"
|
||||
name="salary_voucher_series"
|
||||
defaultValue={currentSeries}
|
||||
className="flex h-10 w-16 rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{SERIES_OPTIONS.map((letter) => (
|
||||
<option key={letter} value={letter}>{letter}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">{t('voucher_series_help')}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<SettingsGroup label={t('accounting_heading')}>
|
||||
<SettingsRow
|
||||
label={t('voucher_series_label')}
|
||||
htmlFor="salary_voucher_series"
|
||||
help={t('voucher_series_help')}
|
||||
>
|
||||
<SettingsSelect
|
||||
id="salary_voucher_series"
|
||||
name="salary_voucher_series"
|
||||
defaultValue={currentSeries}
|
||||
className="font-mono"
|
||||
>
|
||||
{SERIES_OPTIONS.map((letter) => (
|
||||
<option key={letter} value={letter}>{letter}</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</SettingsFormWrapper>
|
||||
|
||||
{/* Tax tables (read-only status) */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('tax_tables_heading')}
|
||||
</h2>
|
||||
{/* Tax tables: automatic, read-only status. Lives outside the form so
|
||||
the recheck action never interacts with the save flow. */}
|
||||
<SettingsGroup
|
||||
label={t('tax_tables_heading')}
|
||||
help={t.rich('info_current_year', {
|
||||
strong: (chunks) => <strong>{chunks}</strong>,
|
||||
})}
|
||||
>
|
||||
<SettingsRow label={tSalary('th_status')} help={t('tax_tables_help')}>
|
||||
<TaxTableStatus />
|
||||
<p className="text-xs text-muted-foreground">{t('tax_tables_help')}</p>
|
||||
</section>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Vacation (informational — the rule is per-employee) */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('vacation_heading')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('vacation_info')}{' '}
|
||||
<Link href="/salary/employees" className="underline underline-offset-2 hover:text-foreground">
|
||||
{t('vacation_info_link')}
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('info_heading')}
|
||||
</h2>
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
<p>{t('info_payroll_scope')}</p>
|
||||
<p>
|
||||
{t.rich('info_current_year', {
|
||||
strong: (chunks) => <strong>{chunks}</strong>,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{/* Vacation is configured per employee; this row only points there. */}
|
||||
<SettingsGroup label={t('vacation_heading')}>
|
||||
<SettingsRow label={t('vacation_rule_label')} help={t('vacation_info')}>
|
||||
<Link
|
||||
href="/salary/employees"
|
||||
className="text-sm text-muted-foreground underline underline-offset-2 transition-colors duration-150 hover:text-foreground"
|
||||
>
|
||||
{t('vacation_info_link')}
|
||||
</Link>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TaxAssessmentNoticesPanel } from '@/components/settings/TaxAssessmentNo
|
||||
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
|
||||
import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
|
||||
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
|
||||
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
|
||||
import { SkatteverketConnectPanel } from '@/components/settings/SkatteverketConnectPanel'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@@ -17,6 +18,8 @@ import type { CompanySettings } from '@/types'
|
||||
export function TaxSettingsContent() {
|
||||
const { settings, isLoading, updateSettings, refetch } = useSettings()
|
||||
const t = useTranslations('settings_skatteverket')
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
@@ -125,8 +128,8 @@ export function TaxSettingsContent() {
|
||||
fiscal_year_start_month: parseInt(formData.get('fiscal_year_start_month') as string) || 1,
|
||||
pays_salaries: paysSalaries,
|
||||
employer_registered: employerRegistered,
|
||||
// The seasonal checkbox is unmounted when not registered; absence
|
||||
// means false rather than "keep stored value".
|
||||
// The seasonal switch stays mounted inside its reveal, but the
|
||||
// employer_registered gate still forces false when not registered.
|
||||
employer_seasonal: employerRegistered && formData.get('employer_seasonal') === 'true',
|
||||
preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null,
|
||||
kontrolluppgifter_enabled: formData.get('kontrolluppgifter_enabled') === 'true',
|
||||
@@ -152,9 +155,11 @@ export function TaxSettingsContent() {
|
||||
const showSkatteverket = hasSkatteverketExtension && !settings.is_sandbox
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<SettingsSectionHeader title={tNav('tax')} intro={tIntro('tax')} />
|
||||
|
||||
{/* Connection panel first: the skattekonto and momsdeklaration pages
|
||||
send users here specifically to (re)connect — below the long tax
|
||||
send users here specifically to (re)connect; below the long tax
|
||||
form it sat out of view. */}
|
||||
{showSkatteverket && <SkatteverketConnectPanel />}
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { BookingTemplatesPanel } from '@/components/settings/BookingTemplatesPanel'
|
||||
import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel'
|
||||
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
|
||||
|
||||
export function TemplatesSettingsContent() {
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<SettingsSectionHeader title={tNav('templates')} intro={tIntro('templates')} />
|
||||
<BookingTemplatesPanel />
|
||||
<CounterpartyTemplatesPanel />
|
||||
</div>
|
||||
|
||||
@@ -85,6 +85,7 @@ export function HelpPopover({ children, className }: HelpPopoverProps) {
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="note"
|
||||
data-help-popover=""
|
||||
className="fixed z-[60] w-[300px] rounded-lg border border-border bg-popover p-4 text-[13px] leading-relaxed text-foreground shadow-lg animate-in fade-in slide-in-from-top-1 duration-150"
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
>
|
||||
|
||||
@@ -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_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_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
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,86 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
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 {
|
||||
ResendDeliverySignatureError,
|
||||
isDeliveryWebhookConfigured,
|
||||
toDeliveryReport,
|
||||
verifyDeliveryWebhook,
|
||||
} from './lib/delivery-webhook'
|
||||
|
||||
// Register the Resend implementation immediately when this extension is loaded
|
||||
registerEmailService(new ResendEmailService())
|
||||
|
||||
const log = createLogger('email-delivery-webhook')
|
||||
|
||||
export const emailExtension: Extension = {
|
||||
id: 'email',
|
||||
name: 'E-post (Resend)',
|
||||
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()
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"entryPoint": "@/extensions/general/email",
|
||||
"workspace": null,
|
||||
"requiredEnvVars": ["RESEND_API_KEY", "RESEND_FROM_EMAIL"],
|
||||
"optionalEnvVars": [],
|
||||
"optionalEnvVars": ["RESEND_DELIVERY_WEBHOOK_SECRET"],
|
||||
"npmDependencies": ["resend"],
|
||||
"definition": {
|
||||
"name": "E-post (Resend)",
|
||||
|
||||
@@ -10,21 +10,10 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { cn, formatDate } from '@/lib/utils'
|
||||
import { getDaysUntilExpiry, isConsentExpiringSoon } from '../lib/api-client'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
CreditCard,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Trash2,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
XCircle,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
import { ChevronDown, Loader2 } from 'lucide-react'
|
||||
import type { BankConnection } from '@/types'
|
||||
|
||||
interface BankConnectionStatusProps {
|
||||
@@ -36,6 +25,13 @@ interface BankConnectionStatusProps {
|
||||
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({
|
||||
connection,
|
||||
onSync,
|
||||
@@ -47,48 +43,19 @@ export function BankConnectionStatus({
|
||||
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires)
|
||||
const isExpiring = isConsentExpiringSoon(connection.consent_expires)
|
||||
|
||||
type StatusEntry = {
|
||||
icon: typeof CheckCircle
|
||||
color: string
|
||||
label: string
|
||||
variant: 'success' | 'warning' | 'destructive' | 'secondary'
|
||||
}
|
||||
type StatusEntry =
|
||||
| { kind: 'text'; label: string }
|
||||
| { kind: 'badge'; label: string; variant: 'warning' | 'destructive' | 'secondary' }
|
||||
|
||||
const statusConfig: Record<string, StatusEntry> = {
|
||||
active: {
|
||||
icon: CheckCircle,
|
||||
color: 'text-success',
|
||||
label: 'Aktiv',
|
||||
variant: 'success',
|
||||
},
|
||||
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',
|
||||
},
|
||||
active: { kind: 'text', label: 'Aktiv' },
|
||||
pending: { kind: 'badge', label: 'Väntar', variant: 'warning' },
|
||||
expired: { kind: 'badge', label: 'Utgånget samtycke', variant: 'warning' },
|
||||
error: { kind: 'badge', label: 'Fel', variant: 'destructive' },
|
||||
revoked: { kind: 'badge', label: 'Bortkopplad', variant: 'secondary' },
|
||||
}
|
||||
|
||||
const status = statusConfig[connection.status] || statusConfig.error
|
||||
const StatusIcon = status.icon
|
||||
|
||||
// Parse accounts from connection
|
||||
const accounts = (connection.accounts_data as Array<{
|
||||
@@ -118,32 +85,32 @@ export function BankConnectionStatus({
|
||||
const errorMessage = connection.error_message ?? ''
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<CreditCard className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{connection.bank_name}</p>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<StatusIcon className={`h-3 w-3 ${status.color}`} />
|
||||
<span>{status.label}</span>
|
||||
{connection.last_synced_at && (
|
||||
<>
|
||||
<span>-</span>
|
||||
<span>Synkad {formatDate(connection.last_synced_at)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="border-b border-border px-1 py-3">
|
||||
{/* Main line: identity + state left, quiet actions right */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="text-sm font-medium">{connection.bank_name}</span>
|
||||
{status.kind === 'badge' ? (
|
||||
<Badge variant={status.variant}>{status.label}</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{status.label}</span>
|
||||
)}
|
||||
{connection.last_synced_at && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
Synkad {formatDate(connection.last_synced_at)}
|
||||
</span>
|
||||
)}
|
||||
{/* Consent renewal date as quiet metadata; the expired state already
|
||||
carries its own warning line below. */}
|
||||
{connection.consent_expires && !isConnectionExpired && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
Samtycke till {formatDate(connection.consent_expires)}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto flex shrink-0 flex-wrap items-center gap-1">
|
||||
{(isConnectionExpired || isConnectionError) && onReconnect && (
|
||||
<DropdownMenu>
|
||||
<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
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -170,98 +137,81 @@ export function BankConnectionStatus({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onSync(connection.id)}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
{isSyncing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
Försök igen
|
||||
</>
|
||||
)}
|
||||
{isSyncing ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Försök igen
|
||||
</Button>
|
||||
)}
|
||||
{connection.status === 'active' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onSync(connection.id)}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
{isSyncing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
{isSyncing ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Synka
|
||||
</Button>
|
||||
)}
|
||||
{onManageAccounts && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onManageAccounts(connection.id)}
|
||||
title="Hantera konton"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Välj konton
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onDisconnect(connection.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
Koppla från
|
||||
</Button>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{/* Error message: live warning, compact warning-tone lines */}
|
||||
{isConnectionError && errorMessage && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 p-3 bg-destructive/10 rounded-lg">
|
||||
<XCircle className="h-4 w-4 text-destructive flex-shrink-0" />
|
||||
<span className="text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg border border-border">
|
||||
<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>
|
||||
<p className="mt-1 text-[12.5px] leading-relaxed text-attn">{errorMessage}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Du kan också{' '}
|
||||
<Link href="/import?mode=bank" className="underline underline-offset-2 hover:text-foreground">
|
||||
importera transaktioner via bankfil
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Expired consent notice */}
|
||||
{isConnectionExpired && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 p-3 bg-warning/10 rounded-lg">
|
||||
<AlertTriangle className="h-4 w-4 text-warning flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
PSD2-samtycket har löpt ut. Förnya anslutningen för att återuppta synkroniseringen.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg border border-border">
|
||||
<Upload className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Medan du väntar kan du <Link href="/import?mode=bank" className="underline hover:text-foreground">importera transaktioner via bankfil</Link>
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[12.5px] leading-relaxed text-attn">
|
||||
PSD2-samtycket har löpt ut. Förnya anslutningen för att återuppta synkroniseringen.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Medan du väntar kan du{' '}
|
||||
<Link href="/import?mode=bank" className="underline underline-offset-2 hover:text-foreground">
|
||||
importera transaktioner via bankfil
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Consent expiry warning (for active connections) */}
|
||||
{!isConnectionExpired && isExpiring && daysUntilExpiry !== null && (
|
||||
<div className="flex items-center gap-2 p-3 bg-warning/10 rounded-lg">
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
<span className="text-sm">
|
||||
Samtycket går ut om {daysUntilExpiry} {daysUntilExpiry === 1 ? 'dag' : 'dagar'}.
|
||||
Förnya genom att ansluta igen.
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[12.5px] leading-relaxed text-attn">
|
||||
Samtycket går ut om {daysUntilExpiry} {daysUntilExpiry === 1 ? 'dag' : 'dagar'}.
|
||||
Förnya genom att ansluta igen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
}
|
||||
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>
|
||||
Initial historik:{' '}
|
||||
<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 && (
|
||||
<div className="space-y-2">
|
||||
<div className="ml-3 mt-3 border-l border-border pl-4">
|
||||
<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">
|
||||
{enabledCount} av {accounts.length} synkas
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{accounts.map((account) => {
|
||||
const isDisabled = account.enabled === false
|
||||
return (
|
||||
<div
|
||||
key={account.uid}
|
||||
className={`flex items-center justify-between p-3 rounded-lg ${isDisabled ? 'bg-muted/20 opacity-60' : 'bg-muted/50'}`}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">
|
||||
{account.name || account.iban || 'Okänt konto'}
|
||||
</p>
|
||||
{isDisabled && (
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground border border-border rounded px-1.5 py-0.5">
|
||||
Synkas ej
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{account.iban && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{account.iban.replace(/(.{4})/g, '$1 ').trim()}
|
||||
</p>
|
||||
{accounts.map((account) => {
|
||||
const isDisabled = account.enabled === false
|
||||
return (
|
||||
<div
|
||||
key={account.uid}
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-x-3 gap-y-1 py-2',
|
||||
isDisabled && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">
|
||||
{account.name || account.iban || 'Okänt konto'}
|
||||
</span>
|
||||
{isDisabled && (
|
||||
<Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Synkas ej
|
||||
</Badge>
|
||||
)}
|
||||
{account.iban && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{account.iban.replace(/(.{4})/g, '$1 ').trim()}
|
||||
</span>
|
||||
)}
|
||||
{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>
|
||||
{account.balance !== undefined && (
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium tabular-nums">
|
||||
{new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: account.currency,
|
||||
}).format(account.balance)}
|
||||
</p>
|
||||
{account.balance_updated_at && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{formatBalanceAge(account.balance_updated_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<span className="text-sm tabular-nums">
|
||||
{new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: account.currency,
|
||||
}).format(account.balance)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,17 +3,16 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
import { AlertTriangle, CheckCircle, Loader2, Upload } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CheckCircle, Loader2, Upload } from 'lucide-react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { notifyBankSyncUpdated } from '@/lib/transactions/bank-sync-signal'
|
||||
import { useCompany, useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { UpgradeNote } from '@/components/billing/UpgradeNote'
|
||||
import { SettingsGroup, SettingsRow, SettingsSeg } from '@/components/settings/SettingsRows'
|
||||
import { BankSelector, type Bank } from './BankSelector'
|
||||
import { BankConnectionStatus } from './BankConnectionStatus'
|
||||
import { AccountPickerDialog } from './AccountPickerDialog'
|
||||
@@ -431,23 +430,21 @@ export default function BankingSettingsPanel() {
|
||||
// keeps the already-loaded connections visible instead of wiping them.
|
||||
if (loadError && bankConnections.length === 0) {
|
||||
return (
|
||||
<Card className="border-destructive/30">
|
||||
<CardHeader>
|
||||
<CardTitle>Kunde inte ladda bankanslutningar</CardTitle>
|
||||
<CardDescription>
|
||||
Något gick fel när dina bankanslutningar skulle hämtas. Dina anslutningar
|
||||
och transaktioner är oförändrade.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-center gap-3">
|
||||
<div className="px-1 pt-8">
|
||||
<p className="text-sm font-medium">Kunde inte ladda bankanslutningar</p>
|
||||
<p className="mt-1 max-w-[56ch] text-xs leading-relaxed text-muted-foreground">
|
||||
Något gick fel när dina bankanslutningar skulle hämtas. Dina anslutningar
|
||||
och transaktioner är oförändrade.
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" size="sm" onClick={() => fetchConnections()}>
|
||||
Försök igen
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/import?mode=bank">Importera bankfil istället</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -463,7 +460,7 @@ export default function BankingSettingsPanel() {
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<DestructiveConfirmDialog {...dialogProps} />
|
||||
|
||||
{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 && (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-muted/50 p-4">
|
||||
<Upload className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<p className="flex-1 text-sm text-muted-foreground">
|
||||
Har du problem med bankanslutningen? Du kan importera transaktioner manuellt via bankfil.
|
||||
<div className="flex items-start gap-2 px-1 pt-6">
|
||||
<Upload className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<p className="text-[12.5px] leading-relaxed text-muted-foreground">
|
||||
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>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/import?mode=bank">Importera bankfil</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending account selection: new connections waiting for the user to pick accounts */}
|
||||
{pendingSelectionConnections.length > 0 && (
|
||||
<Card className="border-warning/30">
|
||||
<CardHeader>
|
||||
<CardTitle>Välj konton att synka</CardTitle>
|
||||
<CardDescription>
|
||||
Banken har gett åtkomst till flera konton. Välj vilka du vill synka innan några transaktioner hämtas.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{pendingSelectionConnections.map((connection) => {
|
||||
const accountsList = (connection.accounts_data as StoredAccount[] | null) || []
|
||||
return (
|
||||
<div
|
||||
key={connection.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="h-5 w-5 shrink-0 text-warning" />
|
||||
<div>
|
||||
<p className="font-medium">{connection.bank_name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{accountsList.length} konton tillgängliga: inga transaktioner synkas ännu
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPickerConnectionId(connection.id)}
|
||||
>
|
||||
Välj konton
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDisconnectBank(connection.id)}
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SettingsGroup
|
||||
label="Välj konton att synka"
|
||||
help="Banken har gett åtkomst till flera konton. Välj vilka du vill synka innan några transaktioner hämtas."
|
||||
>
|
||||
{pendingSelectionConnections.map((connection) => {
|
||||
const accountsList = (connection.accounts_data as StoredAccount[] | null) || []
|
||||
return (
|
||||
<div
|
||||
key={connection.id}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-border px-1 py-3"
|
||||
>
|
||||
<span className="text-sm font-medium">{connection.bank_name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{accountsList.length} konton tillgängliga: inga transaktioner synkas ännu
|
||||
</span>
|
||||
<span className="ml-auto flex shrink-0 items-center gap-2">
|
||||
<Button size="sm" onClick={() => setPickerConnectionId(connection.id)}>
|
||||
Välj konton
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleDisconnectBank(connection.id)}
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</SettingsGroup>
|
||||
)}
|
||||
|
||||
{/* Action required: expired/error connections */}
|
||||
{actionRequiredConnections.length > 0 && (
|
||||
<Card className="border-warning/30">
|
||||
<CardHeader>
|
||||
<CardTitle>Åtgärd krävs</CardTitle>
|
||||
<CardDescription>
|
||||
Dessa anslutningar behöver uppmärksamhet.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{actionRequiredConnections.map((connection) => (
|
||||
<BankConnectionStatus
|
||||
key={connection.id}
|
||||
connection={connection}
|
||||
onSync={handleSyncTransactions}
|
||||
onDisconnect={handleDisconnectBank}
|
||||
onReconnect={handleReconnect}
|
||||
onManageAccounts={() => setPickerConnectionId(connection.id)}
|
||||
isSyncing={syncingConnectionId === connection.id}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SettingsGroup label="Åtgärd krävs" help="Dessa anslutningar behöver uppmärksamhet.">
|
||||
{actionRequiredConnections.map((connection) => (
|
||||
<BankConnectionStatus
|
||||
key={connection.id}
|
||||
connection={connection}
|
||||
onSync={handleSyncTransactions}
|
||||
onDisconnect={handleDisconnectBank}
|
||||
onReconnect={handleReconnect}
|
||||
onManageAccounts={() => setPickerConnectionId(connection.id)}
|
||||
isSyncing={syncingConnectionId === connection.id}
|
||||
/>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
)}
|
||||
|
||||
{/* Connected banks */}
|
||||
{activeConnections.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anslutna banker</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{activeConnections.map((connection) => (
|
||||
<BankConnectionStatus
|
||||
key={connection.id}
|
||||
connection={connection}
|
||||
onSync={handleSyncTransactions}
|
||||
onDisconnect={handleDisconnectBank}
|
||||
onManageAccounts={() => setPickerConnectionId(connection.id)}
|
||||
isSyncing={syncingConnectionId === connection.id}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SettingsGroup label="Anslutna banker">
|
||||
{activeConnections.map((connection) => (
|
||||
<BankConnectionStatus
|
||||
key={connection.id}
|
||||
connection={connection}
|
||||
onSync={handleSyncTransactions}
|
||||
onDisconnect={handleDisconnectBank}
|
||||
onManageAccounts={() => setPickerConnectionId(connection.id)}
|
||||
isSyncing={syncingConnectionId === connection.id}
|
||||
/>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
server gate would 403 the connect anyway. */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anslut ny bank</CardTitle>
|
||||
<CardDescription>
|
||||
Välj din bank nedan för att koppla ditt konto via PSD2.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
server gate would 403 the connect anyway. The former "Om
|
||||
bankintegration (PSD2)" card lives on as group-level help. */}
|
||||
<SettingsGroup
|
||||
label="Anslut ny bank"
|
||||
help={
|
||||
<div className="space-y-2">
|
||||
<p>Välj din bank nedan för att koppla ditt konto via PSD2.</p>
|
||||
<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 ? (
|
||||
<CardContent>
|
||||
<div className="px-1 pt-3">
|
||||
<UpgradeNote>
|
||||
Automatisk banksynk kräver ett abonnemang. Du kan fortfarande importera
|
||||
transaktioner manuellt via bankfiler på importsidan.
|
||||
</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>
|
||||
{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).
|
||||
</p>
|
||||
)}
|
||||
<BankSelector
|
||||
onConnect={(bank) => handleConnectBank(bank, psuType)}
|
||||
onPsuTypeDetected={setPsuType}
|
||||
isConnecting={isConnecting}
|
||||
connectingBankName={connectingBankName}
|
||||
/>
|
||||
</CardContent>
|
||||
) : (
|
||||
<>
|
||||
<SettingsRow
|
||||
label="Kontotyp"
|
||||
help="Välj Privatkonto om du använder ditt personliga bankkonto för din verksamhet (vanligt för enskild firma)."
|
||||
>
|
||||
<SettingsSeg
|
||||
value={psuType}
|
||||
onChange={setPsuType}
|
||||
aria-label="Kontotyp"
|
||||
options={[
|
||||
{ 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>
|
||||
|
||||
{/* 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>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,20 +10,11 @@ import { Switch } from '@/components/ui/switch'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useFormat } from '@/lib/hooks/use-format'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
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']>
|
||||
|
||||
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'> = {
|
||||
active: 'success',
|
||||
pending: 'secondary',
|
||||
@@ -46,8 +37,6 @@ export default function StripeSettingsPanel() {
|
||||
const [confirmDisconnect, setConfirmDisconnect] = useState(false)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [togglingTransactionSync, setTogglingTransactionSync] = useState(false)
|
||||
const [needsReviewCount, setNeedsReviewCount] = useState(0)
|
||||
const [needsReview, setNeedsReview] = useState<StripeReviewEvent[]>([])
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
try {
|
||||
@@ -56,8 +45,6 @@ export default function StripeSettingsPanel() {
|
||||
const data = (await res.json()) as StripeStatusResponse
|
||||
setConfigured(data.configured)
|
||||
setConnection(data.connection)
|
||||
setNeedsReviewCount(data.needs_review_count ?? 0)
|
||||
setNeedsReview(data.needs_review ?? [])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -128,9 +115,7 @@ export default function StripeSettingsPanel() {
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
settled?: number
|
||||
needsReview?: number
|
||||
transactions?: { imported?: number; linked?: number }
|
||||
transactions?: { fetched?: number; imported?: number; linked?: number }
|
||||
error?: string
|
||||
}
|
||||
if (!res.ok) {
|
||||
@@ -141,18 +126,20 @@ export default function StripeSettingsPanel() {
|
||||
})
|
||||
return
|
||||
}
|
||||
const paymentsLine = t('sync_done_description', {
|
||||
settled: data.settled ?? 0,
|
||||
review: data.needsReview ?? 0,
|
||||
})
|
||||
// Honest summary: report what Stripe actually returned. An all-zero
|
||||
// run is a real answer ("the account had nothing in the window"), not
|
||||
// a silent success.
|
||||
const fetched = data.transactions?.fetched ?? 0
|
||||
toast({
|
||||
title: t('sync_done_title'),
|
||||
description: data.transactions
|
||||
? `${paymentsLine} ${t('sync_done_transactions', {
|
||||
imported: data.transactions.imported ?? 0,
|
||||
linked: data.transactions.linked ?? 0,
|
||||
})}`
|
||||
: paymentsLine,
|
||||
description:
|
||||
fetched === 0
|
||||
? t('sync_done_empty')
|
||||
: t('sync_done_feed', {
|
||||
fetched,
|
||||
imported: data.transactions?.imported ?? 0,
|
||||
linked: data.transactions?.linked ?? 0,
|
||||
}),
|
||||
})
|
||||
await loadStatus()
|
||||
} finally {
|
||||
@@ -362,40 +349,6 @@ export default function StripeSettingsPanel() {
|
||||
</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>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
handleCreditNoteCreated,
|
||||
handleInvoicePaid,
|
||||
} from './lib/payment-links'
|
||||
import { syncStripeConnection } from './lib/sync'
|
||||
import { syncStripeBalanceTransactions } from './lib/transaction-sync'
|
||||
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import type { StripeConnection, StripeStatusResponse } from './types'
|
||||
@@ -38,9 +37,11 @@ const NOT_CONFIGURED_MESSAGE =
|
||||
* Stripe Connect extension
|
||||
*
|
||||
* Connects a company's Stripe account via Connect OAuth (Standard accounts).
|
||||
* Auto-creates a Stripe Payment Link when an invoice is sent, marks invoices
|
||||
* paid from Stripe checkout events, and books payouts (gross/fees/net) against
|
||||
* the 1686 clearing account.
|
||||
* Auto-creates a Stripe Payment Link when an invoice is sent, and imports the
|
||||
* account's balance transactions into the transactions inbox as a bank feed
|
||||
* 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:
|
||||
* - STRIPE_SECRET_KEY (the platform account key, shared with billing)
|
||||
@@ -97,35 +98,9 @@ export const stripeExtension: Extension = {
|
||||
const connection =
|
||||
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 = {
|
||||
configured: isStripeConnectConfigured(),
|
||||
connection,
|
||||
needs_review_count: (reviewCount ?? 0) + (payoutCount ?? 0),
|
||||
needs_review: [
|
||||
...(reviewRows ?? []),
|
||||
...(payoutRows ?? []).map((p) => ({ ...p, invoice_id: null })),
|
||||
],
|
||||
}
|
||||
return NextResponse.json(payload)
|
||||
},
|
||||
@@ -175,14 +150,15 @@ export const stripeExtension: Extension = {
|
||||
try {
|
||||
const serviceClient = createServiceClientNoCookies()
|
||||
const typedConnection = connection as StripeConnection
|
||||
const summary = await syncStripeConnection(serviceClient, typedConnection)
|
||||
// The manual button covers both feeds: when the balance-transaction
|
||||
// feed is enabled, "Synka nu" also pulls it (same module as the
|
||||
// nightly cron, no separate rate limit needed: one user action).
|
||||
const transactions = typedConnection.transaction_sync_enabled
|
||||
? await syncStripeBalanceTransactions(serviceClient, typedConnection)
|
||||
: undefined
|
||||
return NextResponse.json({ success: true, ...summary, transactions })
|
||||
// Feed-only (2026-07-24): Stripe sync imports balance transactions
|
||||
// into the inbox, nothing more. The event/settlement sync
|
||||
// (syncStripeConnection in lib/sync.ts) stays in the repo but is
|
||||
// deliberately not called: booking is a user decision in the inbox,
|
||||
// like any bank feed. The manual button ignores
|
||||
// transaction_sync_enabled (that flag gates the nightly cron):
|
||||
// pressing it IS the opt-in.
|
||||
const transactions = await syncStripeBalanceTransactions(serviceClient, typedConnection)
|
||||
return NextResponse.json({ success: true, transactions })
|
||||
} catch (error) {
|
||||
log.error('[stripe] Manual sync failed', {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
|
||||
@@ -21,16 +21,6 @@ export interface StripeConnection {
|
||||
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. */
|
||||
export interface StripeStatusResponse {
|
||||
configured: boolean
|
||||
@@ -47,6 +37,4 @@ export interface StripeStatusResponse {
|
||||
| 'transaction_sync_enabled'
|
||||
| 'last_balance_txn_synced_at'
|
||||
> | null
|
||||
needs_review_count?: number
|
||||
needs_review?: StripeReviewEvent[]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,15 @@ export const DEFAULT_LOCALE: Locale = 'sv'
|
||||
|
||||
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 {
|
||||
return typeof value === 'string' && (SUPPORTED_LOCALES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { cookies } from 'next/headers'
|
||||
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 () => {
|
||||
const cookieStore = await cookies()
|
||||
@@ -9,5 +9,5 @@ export default getRequestConfig(async () => {
|
||||
|
||||
const messages = (await import(`../messages/${locale}.json`)).default
|
||||
|
||||
return { locale, messages }
|
||||
return { locale, messages, timeZone: APP_TIME_ZONE }
|
||||
})
|
||||
|
||||
@@ -1839,8 +1839,10 @@ const ARTICLE: Record<string, StructuredErrorEntry> = {
|
||||
},
|
||||
ARTICLE_IN_USE: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Artikeln har använts på en faktura och kan därför inte tas bort.',
|
||||
message_en: 'The article has been used on an invoice and cannot be deleted.',
|
||||
message_sv:
|
||||
'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: {
|
||||
httpStatus: 400,
|
||||
|
||||
@@ -114,6 +114,30 @@ async function insertPendingEmailDelivery(params: {
|
||||
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', () => {
|
||||
it('allows only a pending to terminal transition and then locks the row', async () => {
|
||||
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)
|
||||
})
|
||||
@@ -754,6 +754,16 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
marginBottom: 6,
|
||||
alignSelf: 'flex-start',
|
||||
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
@@ -298,6 +298,19 @@
|
||||
"terms_and": "and",
|
||||
"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": {
|
||||
"aria_label": "Settings",
|
||||
"company": "Company",
|
||||
@@ -325,7 +338,7 @@
|
||||
},
|
||||
"settings_payments": {
|
||||
"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.",
|
||||
"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.",
|
||||
@@ -343,7 +356,7 @@
|
||||
"connected_since": "Connected {date}",
|
||||
"unnamed_account": "Stripe account",
|
||||
"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",
|
||||
"connect_failed_title": "Connection failed",
|
||||
"disconnect_failed_title": "Disconnect failed",
|
||||
@@ -356,31 +369,24 @@
|
||||
"sync_now": "Sync now",
|
||||
"syncing": "Syncing…",
|
||||
"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",
|
||||
"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_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_last_synced": "Last synced {date}",
|
||||
"transaction_sync_never_synced": "Not synced yet",
|
||||
"transaction_sync_enabled_toast": "Transaction sync enabled. History is fetched on the next sync.",
|
||||
"transaction_sync_disabled_toast": "Transaction sync disabled.",
|
||||
"transaction_sync_toggle_failed": "Could not save the setting. Please try again.",
|
||||
"sync_done_transactions": "{imported} transaction(s) imported, {linked} linked to vouchers."
|
||||
"transaction_sync_toggle_failed": "Could not save the setting. Please try again."
|
||||
},
|
||||
"settings_modal": {
|
||||
"title": "Settings",
|
||||
"description": "Manage your company and account"
|
||||
},
|
||||
"settings": {
|
||||
"group_profile": "Profile",
|
||||
"section_name": "Name",
|
||||
"name_label": "Your name",
|
||||
"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_default": "Could not save settings",
|
||||
"wrapper_try_again": "Please try again.",
|
||||
"wrapper_unsaved": "Unsaved changes",
|
||||
"wrapper_saved": "Saved",
|
||||
"wrapper_saving": "Saving...",
|
||||
"wrapper_save_changes": "Save changes",
|
||||
@@ -1486,6 +1493,8 @@
|
||||
"iframe_title": "Invoice PDF preview"
|
||||
},
|
||||
"settings_bookkeeping": {
|
||||
"group_basics": "Basics",
|
||||
"group_automation": "Automation",
|
||||
"method_heading": "Accounting method",
|
||||
"method_label": "Method",
|
||||
"method_accrual": "Faktureringsmetoden",
|
||||
@@ -2247,6 +2256,7 @@
|
||||
"link_button": "Link BankID"
|
||||
},
|
||||
"settings_security": {
|
||||
"group_security": "Security",
|
||||
"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_mismatch_title": "Passwords do not match",
|
||||
@@ -3018,6 +3028,21 @@
|
||||
"delivery_status_sent": "Sent",
|
||||
"delivery_status_failed": "Failed",
|
||||
"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_to_label": "To",
|
||||
"delivery_cc_label": "Cc",
|
||||
@@ -4746,7 +4771,7 @@
|
||||
"update_failed_title": "Could not update article",
|
||||
"retry": "Please try again.",
|
||||
"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",
|
||||
"deactivated_title": "Article deactivated",
|
||||
"activated_title": "Article activated",
|
||||
|
||||
+40
-15
@@ -298,6 +298,19 @@
|
||||
"terms_and": "och",
|
||||
"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": {
|
||||
"aria_label": "Inställningar",
|
||||
"company": "Företag",
|
||||
@@ -325,7 +338,7 @@
|
||||
},
|
||||
"settings_payments": {
|
||||
"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.",
|
||||
"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.",
|
||||
@@ -343,7 +356,7 @@
|
||||
"connected_since": "Ansluten {date}",
|
||||
"unnamed_account": "Stripe-konto",
|
||||
"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",
|
||||
"connect_failed_title": "Anslutningen misslyckades",
|
||||
"disconnect_failed_title": "Frånkopplingen misslyckades",
|
||||
@@ -356,31 +369,24 @@
|
||||
"sync_now": "Synka nu",
|
||||
"syncing": "Synkar…",
|
||||
"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",
|
||||
"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_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_last_synced": "Senast synkad {date}",
|
||||
"transaction_sync_never_synced": "Inte synkad ännu",
|
||||
"transaction_sync_enabled_toast": "Transaktionssynk aktiverad. Historiken hämtas vid nästa synkning.",
|
||||
"transaction_sync_disabled_toast": "Transaktionssynk avaktiverad.",
|
||||
"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."
|
||||
"transaction_sync_toggle_failed": "Kunde inte spara inställningen. Försök igen."
|
||||
},
|
||||
"settings_modal": {
|
||||
"title": "Inställningar",
|
||||
"description": "Hantera ditt företag och konto"
|
||||
},
|
||||
"settings": {
|
||||
"group_profile": "Profil",
|
||||
"section_name": "Namn",
|
||||
"name_label": "Ditt namn",
|
||||
"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_default": "Kunde inte spara inställningar",
|
||||
"wrapper_try_again": "Försök igen.",
|
||||
"wrapper_unsaved": "Osparade ändringar",
|
||||
"wrapper_saved": "Sparat",
|
||||
"wrapper_saving": "Sparar...",
|
||||
"wrapper_save_changes": "Spara ändringar",
|
||||
@@ -1486,6 +1493,8 @@
|
||||
"iframe_title": "PDF-förhandsvisning av faktura"
|
||||
},
|
||||
"settings_bookkeeping": {
|
||||
"group_basics": "Grunder",
|
||||
"group_automation": "Automatik",
|
||||
"method_heading": "Bokföringsmetod",
|
||||
"method_label": "Metod",
|
||||
"method_accrual": "Faktureringsmetoden",
|
||||
@@ -2247,6 +2256,7 @@
|
||||
"link_button": "Koppla BankID"
|
||||
},
|
||||
"settings_security": {
|
||||
"group_security": "Säkerhet",
|
||||
"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_mismatch_title": "Lösenorden matchar inte",
|
||||
@@ -3018,6 +3028,21 @@
|
||||
"delivery_status_sent": "Skickad",
|
||||
"delivery_status_failed": "Misslyckad",
|
||||
"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_to_label": "Till",
|
||||
"delivery_cc_label": "Kopia",
|
||||
@@ -4746,7 +4771,7 @@
|
||||
"update_failed_title": "Kunde inte uppdatera artikel",
|
||||
"retry": "Försök igen.",
|
||||
"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",
|
||||
"deactivated_title": "Artikel inaktiverad",
|
||||
"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';
|
||||
@@ -1040,6 +1040,20 @@ export interface Invoice {
|
||||
export type InvoiceDeliveryChannel = 'email' | 'manual'
|
||||
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 {
|
||||
id: string
|
||||
company_id: string
|
||||
@@ -1057,6 +1071,9 @@ export interface InvoiceDelivery {
|
||||
body_html: string | null
|
||||
provider: 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
|
||||
document_attachment_id: string | null
|
||||
attachment_filename: string | null
|
||||
|
||||
@@ -17,10 +17,6 @@
|
||||
"path": "/api/extensions/enable-banking/sync/cron",
|
||||
"schedule": "0 5 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/extensions/stripe/sync/cron",
|
||||
"schedule": "*/15 * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/extensions/stripe/transactions/cron",
|
||||
"schedule": "30 3 * * *"
|
||||
|
||||
Reference in New Issue
Block a user