Check/salary bankid api (#892)

* fix(bankid): harden login/signup flow — polling, signup rollback, metadata merge, enrichment lookup

- middleware: read BankID enrichment from the bankid_enrichment table (the
  extension_data path has been dead since the multi-tenant refactor), so
  company-less BankID users land on /select-company instead of the manual wizard
- BankIdAuth: hard 6-min poll deadline; every failed poll counts toward the
  give-up limit; guard overlapping ticks so completion runs exactly once
  (a double /complete regenerated the magic link and invalidated the first,
  failing logins intermittently); retry clicks wait out the start cooldown
  instead of silently no-oping; Swedish messages for 429/unknown start errors
- bankid/complete: all-or-nothing signup — delete the created user when the
  identity insert, app_metadata update, or magic-link generation fails, so a
  retry starts clean instead of hitting account_exists with an unusable account
- bankid/unlink: read-merge-write app_metadata so has_password survives unlink
  (BankID-only users could otherwise strand themselves with no login method)
- login: BankID "create account" CTA now links to /register instead of
  dismissing the notice; sv.json: fix missing å/ä/ö in settings_bankid strings

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

* docs: move secondary guides into docs/, delete dead root files

Move DOCKER.md, SELF-HOSTING.md, WHITELABEL.md and extensions.md
(renamed EXTENSIONS.md) into a new docs/ folder and update all path
references (README, setup.sh, .dockerignore image rules, docker-publish
workflow comment, _example-branding, lib/branding/service.ts).

Delete two dead root files: customer.json (stray API-test payload) and
findings.md (point-in-time swarm audit export, criticals already filed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(api): security & correctness hardening + withRouteContext MFA migration across API routes

Audit of ~100 app/api routes. Highlights:

Security
- agent/conversations: list leaked colleagues' titles + message previews
  (company-scoped RLS, no user filter) -> user-scoped
- calendar/feed PUT: raw body into .update() allowed feed_token fixation on a
  public unauthenticated URL -> strict schema, content toggles only
- bokslutsdispositioner: unbounded schablonintaktRate could inflate the
  IL 30 kap 25% periodiseringsfond cap base -> bounded
- agent profile/composer/onboarding: viewers could rewrite the agent profile
  while sibling /verify blocked them -> role-gated

Correctness
- account-totals / listAssets: unbounded queries silently truncated at 1000
  rows (under-counted money; skipped assets at year-end depreciation) ->
  fetchAllRows with stable order (+3 more pagination fixes)
- voucher-gaps: swallowed detect_voucher_gaps RPC errors (BFNAR gap view could
  show "no gaps" when the check never ran) -> surfaced
- 5 phantom-success writes (OK on zero matched rows) fixed
- assets K3 component-sum validated against stale acquisition_cost -> fixed
- invite silent email-send failure -> response carries email_sent;
  deadlines/calendar cast-then-check JSON crashes -> Zod

Convention
- ~44 legacy routes converted to withRouteContext (MFA); added Zod validation,
  corrected status codes, console.* -> lib/logger

Response shapes preserved for existing callers. ~110 new tests.

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

* feat(bookkeeping): save a booking as a reusable template from Bokför direkt

Add a "Spara som mall" action to the manual booking dialog so users can
capture a kontering they just worked out as a booking template — right
where they figured out how something should be booked.

- derive amount-parameterised template lines from the concrete booking
  (settlement = the non-VAT leg nearest the total, 26xx = a VAT line with
  its rate snapped to the nearest standard rate, the rest = business
  ratios; line labels come from the loaded BAS chart)
- extract the shared TemplateForm out of BookingTemplatesPanel so the
  booking dialog reuses the same editor, live preview and convertibility
  hints instead of duplicating them
- save via the existing POST /api/settings/booking-templates endpoint

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

* fix(bokslut): render arsredovisning RR/BR at ÅRL post level — no kontonummer

Bolagsverket rejected a user's filed årsredovisning with "Balansräkning
och resultaträkning ska inte innehålla kontonummer": the PDF built every
statement row as per-account "1930 Företagskonto" lines while the iXBRL
filing path already aggregated to statutory posts, so the two artifacts
diverged.

The PDF statements now derive from the same K2 risbs mapping the iXBRL
document uses (mapTrialBalancesToK2), via a new statement-rows.ts that
emits post-level rows in uppställningsform order for both the K2 and K3
templates. Also fixed along the way:

- Jämförelseår column (ÅRL 3:5 §) — previous-year trial balances now load
  and render; the old PDF had no comparatives at all.
- mapping.warnings (unmapped accounts, RR ≠ 2099, obalans, reclass
  nudges) flow into ArsredovisningData.warnings so the wizard flags a
  non-fileable document before download.
- Flerårsöversikt current/previous year overridden with the mapper's
  strict-3000–3799 Nettoomsattning, mirroring build-input's
  duplicate-fact rule, so the FB table ties to the RR.
- FB eget kapital-table is post-level and drops obeskattade reserver
  (never eget kapital); K3 equity-changes statement uses real prior-year
  opening balances with derived utdelning/nyemission residuals that tie
  the roll-forward exactly to booked UB.
- build-input dedupes warnings now that the PDF path runs the same
  mapping.

Regression test asserts no RR/BR label ever contains a four-digit
account number again.

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

* fix(reports): diagnose untransferred prior-year results behind balance-sheet differens

Prod incident (97 kr): a multi-year SIE migration lacked one year's
omforing av arets resultat; the residual corrupted every later derived
opening balance and Balansrakningen showed a bare "Differens: 97 kr"
with no explanation. Continuity checking cannot catch this failure mode
(prior-year UB and derived IB match per-account by construction) - the
invariant that actually breaks is per-year P&L = 0 for all non-latest
years.

- lib/reports/imbalance-diagnosis.ts: shared detector
  (findUntransferredResults + buildImbalanceDiagnosis)
- Balansrakning/Balansrapport attach imbalance_diagnosis when unbalanced,
  naming the exact culprit years; rendered in web views + PDF; MCP
  gnubok_get_balance_sheet inherits the field via spread
- SIE import: parse-time warning when a completed year's vouchers leave
  a P&L residual, plus a post-import DB walk surfacing culprits as
  warnings and structured details.untransferredResults; the Arcim
  migration workspace previously dropped result.warnings entirely and
  now renders them
- opening-balance/correct: pre-flight the company lock date and return
  409 OB_COMPANY_LOCK_DATE (retryable: false, lock date interpolated in
  the client message) instead of the retryable 500 that invited blind
  retries; catch-path maps a raced trigger rejection to the same code

Diagnosis runs only on unbalanced paths (zero cost when healthy) and
never fails the report or the import. No migration, nothing persisted.

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

* fix: production error remediation — FX rates, deadlines, log levels, correction relink

Batch of fixes for recurring Vercel runtime errors:

- Riksbanken FX rates: persistent read-through cache (exchange_rates
  table), one retry honoring Retry-After on 429/5xx, bounded ingest
  concurrency, and an honest fallback — most recent cached observation
  or null, never a hardcoded rate silently booked into amount_sek.
  Unrated transactions stay repairable via refresh-exchange-rate.
- Tax deadline regeneration inserts replacement rows before deleting
  the superseded set, so a failed insert no longer wipes a company's
  deadlines (the 23502 user_id regression did exactly that). Migration
  makes deadlines.user_id nullable for system-generated rows.
- Route wrappers + errorResponse log 4xx outcomes at warn so only
  genuine 5xx reach Vercel's runtime-error clustering; client-supplied
  /api/log telemetry demoted to warn as well.
- application/json documents (raw PSD2 responses archived per BFL)
  validate as parseable JSON with object/array root instead of always
  failing the magic-byte check.
- correctEntry surfaces document-relink failures to callers, and the
  BFL document-immutability trigger now allows relinking underlag from
  a reversed entry to its correction (migration + pg test).
- Middleware clears stale session cookies on /api requests too, using
  scope 'local' so cleanup doesn't re-trigger the failed token refresh.

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

* fix(skatteverket): persist token health and stop retrying dead consents

Terminal auth errors (SESSION_EXPIRED, REFRESH_EXHAUSTED, MISSING_SCOPE,
TOKEN_CORRUPTED) mark the token row needs_reconsent with the error code
and timestamp — SKV per-flow refresh tokens live 65 minutes, so once
expired nothing recovers without a fresh BankID consent. The AGI
kvittens and skattekonto sync crons skip flagged connections instead of
failing every night, and the settings panel prompts for re-consent
proactively. A successful reconnect resets the row to active.

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

* fix(banking): allocate distinct BAS ledger slots for PSD2 mirror accounts

A bank returning N same-currency accounts used to map them all onto the
currency default (1930/1932/1933/1934), tripping the UNIQUE
(company_id, ledger_account) constraint per-account — swallowed errors
left accounts silently unmirrored. allocatePsd2LedgerAccount now hands
out the currency default first, then free 1931–1959 sub-account slots,
skipping slots held by any existing row.

- Callback persists allocations to accounts_data so the picker pre-fills
  reality; reconnect reuses previously mirrored ledgers instead of
  re-deriving (a user remap to 1935 survives).
- Selection save resolves effective ledgers up front and rejects
  duplicates or cross-connection conflicts with a 400 instead of
  silently skipping the mirror.
- Bank error codes + psu_type are forwarded to the settings page for
  every OAuth error, keying the Handelsbanken corporate fullmakt
  guidance.

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

* fix(agent): stage exact journal lines on categorization previews

Categorization previews only carried debit/credit accounts, the GROSS
amount, and separate VAT rows — read together that looks like an
unbalanced 'gross on cost account + VAT debit' entry, and it misled
both users and agents into rejecting correct proposals. The MCP
preview and the pending-operation PATCH now materialize the exact
lines the commit executor will post (net cost line, VAT line, gross
bank line, SEK) via buildTransactionEntryLines, and PATCH re-derives
them from the new mapping instead of spreading stale staged lines.
ApprovalCard and /pending render the verifikat lines, falling back to
the legacy summary only for operations staged before this fix.

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

* feat(bookkeeping): prune unused imported accounts from the chart

SIE imports routinely bring in hundreds of accounts that were never
used and clutter the kontoplan. New account_usage_counts RPC (one
grouped query instead of a count per account) backs GET
/api/bookkeeping/accounts/usage, and POST /api/bookkeeping/accounts/prune
deletes zero-usage accounts — dry-run first, then an explicit account
list capped at 2000. Accounts with journal lines are skipped, never
deleted. The chart manager shows a usage column and a prune dialog
grouping custom accounts vs unused BAS-seeded ones.

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

* feat(api): carry dimensions through v1 invoice and supplier-invoice surfaces

Credit-note creation now copies default_dimensions and per-line
dimensions from the original, so the reversing journal entry nets
against the same dimension cells instead of dropping them. List/detail
responses expose the dimension fields, and the OpenAPI spec snapshot
follows.

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

* perf: batch serial Supabase round-trips on hot dashboard paths

Every dashboard render pays the layout's query chain, so serialized
awaits are direct wall-clock: the layout, chat conversation, invoice
detail, supplier detail, select-company, and agent-onboarding pages now
run their independent lookups in parallel batches, and
getCompanyCapabilities folds its disabled-config read into the same
round-trip. JournalEntryList hydrates the saved fiscal-year scope
optimistically instead of serializing the first entries fetch behind
the fiscal-periods request. The supplier detail page filters invoices
server-side via a new supplier_id query param instead of fetching the
whole company ledger, and the invoice editor (with its framer-motion
dependency) lazy-loads so it stops shipping with the invoice list
bundle.

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

* feat(salary): one-click runs, payslip delivery, payments settings, run cockpit

Salary P1 batch, driving the 20-click flow toward 3 clicks:

- One-click 'Starta lönekörning': POST /api/salary/runs accepts an
  empty body and resolves defaults server-side — period follows the
  latest non-corrected run, payment date from the new
  salary_pay_day setting, series from the per-source-type map. The
  separate /salary/runs/new page is gone.
- Run detail page rebuilt as a step-railed cockpit (progress rail,
  KPI cards, employee ledger, journal preview) on a deliberately
  wider canvas; components extracted to components/salary/run/.
- Payslip delivery: tokenized public payslip pages (/payslip/[token],
  backed by salary_payslip_links) plus per-employee email send with
  PDF — employees need no account, and the middleware exempts the
  route from auth redirects.
- Payments settings: salary pay day, default bank, and pain.001 vs
  Bankgirot Lön format with per-bank upload instructions and an LB
  sunset warning (banks retire LB during 2026).
- AGI panel: full submission status flows (stale drafts, signing
  links, kvittens polling, error reports); tax payment panel with
  skattekonto shortcut and mark-as-paid.
- Salary calendar bulk editing, employee benefits/tax-card polish,
  municipality tax-table lookup improvements.

messages/sv+en also carry the strings for the account-prune,
skatteverket-reconsent, and banking surfaces committed just before
this.

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

* chore: adopt Next 16 proxy.ts convention + repo housekeeping

- Rename middleware.ts to proxy.ts with the proxy() export (Next 16
  renamed the middleware convention; behavior unchanged).
- Exclude dev_docs/ from tsconfig so stray snippets in planning docs
  don't break the build type-check.
- Ratchet antipatterns-baseline down (raw-route-auth 165 → 119) to
  lock in the withRouteContext migration from 5cfd2b76.
- template-library uses roundOre() instead of inline rounding.
- database.md: drop account_balances from the key-tables list.

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

* fix(bookkeeping): robust service-role detection in correction document relink

relink_documents_to_correction() keyed its service-role branch on auth.role(),
which reads the singular request.jwt.claim.role GUC that PostgREST v10+ and the
pg-real harness no longer populate. Genuine service-role callers (pending-ops
executor / MCP approve) landed in the auth gate and could not relink underlag.
Read the role from the request.jwt.claims JSON directly, mirroring the canonical
link_voucher_rpcs_tenant_guard convention. Validated on staging.

Also: harden the salary run page's error paths (res.json().catch) against
non-JSON error bodies, and roll back the pg-real service-role case in finally so
an aborted transaction cannot poison a pooled connection for the next test.

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

* fix(documents): restore journal_entry_line_id link durability (BFL 7 kap)

Migration 20260704103000 rewrote enforce_document_journal_entry_immutability to
guard journal_entry_id but left journal_entry_line_id to the metadata trigger,
which exempts draft-linked docs -- and the entry-level trigger only fired on
UPDATE OF journal_entry_id, so a line-id-only UPDATE never invoked it at all.
That let a set journal_entry_line_id be cleared to NULL, breaking the "link
durable from first set" invariant (document-immutability.pg regression).

Widen the trigger to fire on journal_entry_line_id too and guard it with the
same uuid-durability rule as journal_entry_id (setting NULL -> uuid stays
allowed; clearing/re-pointing a set value is blocked, status-independent). The
correction-relink GUC path, which legitimately clears line_id when moving
underlag to the posted correction, stays exempt. Validated on staging.

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

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-05 03:05:09 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent ec27228a8e
commit 2c2743eb79
269 changed files with 17974 additions and 6481 deletions
+65 -28
View File
@@ -538,9 +538,19 @@ function CategorizeTransactionPreview({
const amount = preview.amount as number | undefined
const currency = (preview.currency as string | undefined) ?? 'SEK'
const category = preview.category as string | undefined
// Server emits { account_number, debit_amount, credit_amount, description }
// per VAT line (extensions/general/mcp-server/server.ts:390-395). One side
// is non-zero, the other 0: render the active side with D/K prefix.
// The exact journal lines the approval will post (net cost line, VAT line,
// gross bank line, SEK), staged by the server since the preview-lines fix.
const lines = (preview.lines as
| {
account_number?: string
debit_amount?: number
credit_amount?: number
description?: string
}[]
| undefined) ?? []
// Legacy summary fields, rendered only for operations staged before the
// preview carried full lines. Pairing the gross amount with the cost
// account reads as an unbalanced entry: never show it when lines exist.
const vatLines = (preview.vat_lines as
| {
account_number?: string
@@ -560,42 +570,69 @@ function CategorizeTransactionPreview({
{prettyCategory(category)}
</span>
</div>
{debit && credit && amount != null && (
<Row
label="Bokning"
value={
<span className="tabular-nums">
<span className="text-muted-foreground">D </span>
<strong className="font-medium">{debit}</strong>
<span className="text-muted-foreground"> / K </span>
<strong className="font-medium">{credit}</strong>
<span className="ml-2">{formatCurrency(amount, currency)}</span>
</span>
}
/>
)}
{vatLines.length > 0 && (
<div className="pt-1 mt-1 border-t border-border">
{vatLines.map((v, i) => {
const debit = typeof v.debit_amount === 'number' ? v.debit_amount : 0
const credit = typeof v.credit_amount === 'number' ? v.credit_amount : 0
const side: 'D' | 'K' | null = debit > 0 ? 'D' : credit > 0 ? 'K' : null
const amount = side === 'D' ? debit : side === 'K' ? credit : 0
{lines.length > 0 ? (
<div className="pt-1 mt-1 border-t border-border space-y-0.5">
{lines.map((l, i) => {
const debitAmt = typeof l.debit_amount === 'number' ? l.debit_amount : 0
const creditAmt = typeof l.credit_amount === 'number' ? l.credit_amount : 0
const side: 'D' | 'K' = debitAmt > 0 ? 'D' : 'K'
return (
<Row
key={i}
label={i === 0 ? 'Moms' : ''}
label={i === 0 ? 'Verifikat' : ''}
value={
<span className="tabular-nums">
{side && <span className="text-muted-foreground">{side} </span>}
<span className="text-muted-foreground">{v.account_number ?? ''} </span>
{formatCurrency(amount, currency)}
<span className="text-muted-foreground">{side} </span>
<strong className="font-medium">{l.account_number ?? '?'}</strong>
<span className="ml-2">
{formatCurrency(side === 'D' ? debitAmt : creditAmt)}
</span>
</span>
}
/>
)
})}
</div>
) : (
<>
{debit && credit && amount != null && (
<Row
label="Bokning"
value={
<span className="tabular-nums">
<span className="text-muted-foreground">D </span>
<strong className="font-medium">{debit}</strong>
<span className="text-muted-foreground"> / K </span>
<strong className="font-medium">{credit}</strong>
<span className="ml-2">{formatCurrency(amount, currency)}</span>
</span>
}
/>
)}
{vatLines.length > 0 && (
<div className="pt-1 mt-1 border-t border-border">
{vatLines.map((v, i) => {
const debit = typeof v.debit_amount === 'number' ? v.debit_amount : 0
const credit = typeof v.credit_amount === 'number' ? v.credit_amount : 0
const side: 'D' | 'K' | null = debit > 0 ? 'D' : credit > 0 ? 'K' : null
const amount = side === 'D' ? debit : side === 'K' ? credit : 0
return (
<Row
key={i}
label={i === 0 ? 'Moms' : ''}
value={
<span className="tabular-nums">
{side && <span className="text-muted-foreground">{side} </span>}
<span className="text-muted-foreground">{v.account_number ?? ''} </span>
{formatCurrency(amount, currency)}
</span>
}
/>
)
})}
</div>
)}
</>
)}
</div>
)
+96 -17
View File
@@ -11,6 +11,16 @@ type BankIdStatus = 'idle' | 'scanning' | 'complete' | 'failed' | 'no_account' |
/** Max consecutive poll failures before we declare service unavailable */
const MAX_POLL_FAILURES = 3
/**
* Abandon a session that never reaches a terminal state. Safety net for TIC
* responses that carry no `status` (e.g. an expired-session 410 body) — without
* it the poll loop would spin forever on a dead QR code.
*/
const POLL_DEADLINE_MS = 6 * 60 * 1000
/** Min spacing between billable TIC session starts (mirrors the server cooldown). */
const START_COOLDOWN_MS = 5_000
interface BankIdSession {
sessionId: string
autoStartToken: string
@@ -129,6 +139,16 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
onCompleteRef.current = onComplete
const pollFailureCount = useRef(0)
/** True while a poll response is being processed — prevents overlapping ticks. */
const pollInFlightRef = useRef(false)
/** Set once a terminal poll result has been handled — the completion branch must run at most once. */
const completedRef = useRef(false)
/** When the current poll loop began, for the POLL_DEADLINE_MS cap. */
const pollStartedAtRef = useRef(0)
/** Bumped by cancel/unmount so an in-flight startSession stops touching state. */
const startGenRef = useRef(0)
/** True while startSession is running — collapses double-clicks into one billable session. */
const startingRef = useRef(false)
const cleanup = useCallback(() => {
if (pollRef.current) {
@@ -140,16 +160,43 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
abortRef.current = null
}
pollFailureCount.current = 0
pollInFlightRef.current = false
}, [])
useEffect(() => cleanup, [cleanup])
useEffect(
() => () => {
startGenRef.current++
cleanup()
},
[cleanup]
)
// Poll an in-flight BankID session until it completes, fails, or the service
// gives up. Extracted from startSession so the resume effect (mobile return)
// can re-attach to a session that was started before the tab reloaded.
const beginPolling = useCallback((session: BankIdSession) => {
abortRef.current = new AbortController()
pollStartedAtRef.current = Date.now()
completedRef.current = false
pollInFlightRef.current = false
pollRef.current = setInterval(async () => {
// Never process two ticks concurrently: a slow response overlapping the
// next tick could otherwise run the completion branch twice (double
// /complete → double generateLink, which invalidates the first magic
// link and fails the login intermittently).
if (pollInFlightRef.current || completedRef.current) return
// Hard cap — a session that never reaches a terminal state (expired
// order, TIC response without `status`) must not poll forever.
if (Date.now() - pollStartedAtRef.current > POLL_DEADLINE_MS) {
cleanup()
clearPending()
setStatus('failed')
setErrorMessage('BankID-sessionen löpte ut. Försök igen.')
return
}
pollInFlightRef.current = true
try {
const pollRes = await fetch(`${API_BASE}/poll`, {
method: 'POST',
@@ -159,16 +206,16 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
})
if (!pollRes.ok) {
const pollErr = await pollRes.json().catch(() => ({}))
if (pollErr.error === 'service_unavailable' || pollRes.status === 502 || pollRes.status === 503) {
pollFailureCount.current++
if (pollFailureCount.current >= MAX_POLL_FAILURES) {
cleanup()
clearPending()
setStatus('service_unavailable')
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
onCompleteRef.current({ error: 'service_unavailable' })
}
// Count EVERY failed poll (5xx, 429, unexpected 4xx) — errors that
// never increment the counter would otherwise leave the user
// silently polling a dead session forever.
pollFailureCount.current++
if (pollFailureCount.current >= MAX_POLL_FAILURES) {
cleanup()
clearPending()
setStatus('service_unavailable')
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
onCompleteRef.current({ error: 'service_unavailable' })
}
return
}
@@ -199,6 +246,7 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
}
if (pollData.status === 'complete') {
completedRef.current = true
cleanup()
clearPending()
setStatus('complete')
@@ -268,6 +316,7 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
})
}
} else if (pollData.status === 'failed' || pollData.status === 'cancelled') {
completedRef.current = true
cleanup()
clearPending()
setStatus('failed')
@@ -283,15 +332,17 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
onCompleteRef.current({ error: 'service_unavailable' })
}
} finally {
pollInFlightRef.current = false
}
}, 2000)
}, [cleanup, mode])
const startSession = useCallback(async () => {
// Prevent rapid restarts (each start = billable TIC session)
const now = Date.now()
if (now - lastStartRef.current < 5000) return
lastStartRef.current = now
// Collapse double-clicks — one billable TIC session per intent.
if (startingRef.current) return
startingRef.current = true
const gen = ++startGenRef.current
cleanup()
clearPending()
@@ -300,7 +351,20 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
setErrorMessage('')
try {
// Respect the billable-session cooldown by waiting out the remainder
// instead of silently dropping the click — a retry button that does
// nothing reads as "BankID is broken". Also keeps us under the
// server-side per-IP cooldown on /start.
const sinceLast = Date.now() - lastStartRef.current
if (sinceLast < START_COOLDOWN_MS) {
await new Promise((resolve) => setTimeout(resolve, START_COOLDOWN_MS - sinceLast))
}
if (gen !== startGenRef.current) return // cancelled/unmounted while waiting
lastStartRef.current = Date.now()
const res = await fetch(`${API_BASE}/start`, { method: 'POST' })
if (gen !== startGenRef.current) return
if (!res.ok) {
const err = await res.json().catch(() => ({}))
if (err.error === 'service_unavailable' || err.error === 'not_configured' || res.status === 502 || res.status === 503) {
@@ -310,7 +374,17 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
onCompleteRef.current({ error: 'service_unavailable' })
return
}
throw new Error(err.message || err.error || 'Failed to start BankID')
if (res.status === 429 || err.error === 'rate_limit') {
setStatus('failed')
setErrorMessage('För många försök. Vänta en stund och försök igen.')
return
}
// Unknown error — server messages are not user-facing copy; keep the
// detail in the console and show Swedish.
console.error('[bankid] start failed', res.status, err)
setStatus('failed')
setErrorMessage('Ett oväntat fel uppstod. Försök igen.')
return
}
const { data } = await res.json()
@@ -328,8 +402,12 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
beginPolling(newSession)
} catch (error) {
if (gen !== startGenRef.current) return
console.error('[bankid] start failed', error)
setStatus('failed')
setErrorMessage(error instanceof Error ? error.message : 'Ett oväntat fel uppstod')
setErrorMessage('Ett oväntat fel uppstod. Försök igen.')
} finally {
startingRef.current = false
}
}, [cleanup, mode, beginPolling])
@@ -348,6 +426,7 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
}, [])
const handleCancel = useCallback(async () => {
startGenRef.current++ // stop an in-flight startSession from resuming
if (session) {
fetch(`${API_BASE}/${session.sessionId}`, { method: 'DELETE' }).catch(() => {})
}
@@ -11,6 +11,7 @@ import { useToast } from '@/components/ui/use-toast'
import { AccountNumber } from '@/components/ui/account-number'
import { AddAccountDialog } from './AddAccountDialog'
import { EditAccountDialog } from './EditAccountDialog'
import { PruneAccountsDialog } from './PruneAccountsDialog'
import {
Search,
ChevronDown,
@@ -23,7 +24,7 @@ import {
BookOpen,
} from 'lucide-react'
import type { BASAccount } from '@/types'
import type { BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
import { isStandardBASAccount, type BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
// ---------------------------------------------------------------------------
// Types
@@ -66,11 +67,13 @@ export default function ChartOfAccountsManager() {
// Data state
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [referenceAccounts, setReferenceAccounts] = useState<ReferenceAccount[]>([])
const [usageCounts, setUsageCounts] = useState<Map<string, number>>(new Map())
const [loading, setLoading] = useState(true)
// Dialog state
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [editAccount, setEditAccount] = useState<BASAccount | null>(null)
const [pruneDialogOpen, setPruneDialogOpen] = useState(false)
// Action states
const [togglingAccount, setTogglingAccount] = useState<string | null>(null)
@@ -93,10 +96,30 @@ export default function ChartOfAccountsManager() {
setReferenceAccounts(data || [])
}, [])
// Per-account posting counts — drives the "Verifikat" column. Non-fatal:
// the page works without it, cells just show a dash.
const fetchUsage = useCallback(async () => {
try {
const res = await fetch('/api/bookkeeping/accounts/usage')
if (!res.ok) return
const { data } = await res.json()
setUsageCounts(
new Map(
(data || []).map((u: { account_number: string; usage_count: number }) => [
u.account_number,
Number(u.usage_count),
]),
),
)
} catch {
// Leave the map empty — usage display is informational only.
}
}, [])
useEffect(() => {
async function load() {
setLoading(true)
await Promise.all([fetchAccounts(), fetchReference()])
await Promise.all([fetchAccounts(), fetchReference(), fetchUsage()])
// Set K2 filter default based on company settings (plan_type)
if (hideK2Excluded === null) {
try {
@@ -115,11 +138,11 @@ export default function ChartOfAccountsManager() {
setLoading(false)
}
load()
}, [fetchAccounts, fetchReference, hideK2Excluded])
}, [fetchAccounts, fetchReference, fetchUsage, hideK2Excluded])
const refreshAll = useCallback(async () => {
await Promise.all([fetchAccounts(), fetchReference()])
}, [fetchAccounts, fetchReference])
await Promise.all([fetchAccounts(), fetchReference(), fetchUsage()])
}, [fetchAccounts, fetchReference, fetchUsage])
// -------------------------------------------
// Actions
@@ -294,10 +317,16 @@ export default function ChartOfAccountsManager() {
</Tabs>
{view === 'my-accounts' && (
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
{t('add_own')}
</Button>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setPruneDialogOpen(true)}>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
{t('prune_button')}
</Button>
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
{t('add_own')}
</Button>
</div>
)}
{view === 'bas-catalog' && (
@@ -363,6 +392,7 @@ export default function ChartOfAccountsManager() {
<th className="py-2">{t('col_name')}</th>
<th className="py-2 w-20 text-center">{t('col_sru')}</th>
<th className="py-2 w-24 text-center">{t('col_type')}</th>
<th className="py-2 w-20 text-right">{t('col_usage')}</th>
<th className="py-2 w-16 text-center">{t('col_active')}</th>
<th className="py-2 w-20 text-right"></th>
</tr>
@@ -386,6 +416,11 @@ export default function ChartOfAccountsManager() {
{t('system_badge')}
</span>
)}
{!isStandardBASAccount(account.account_number) && (
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
{t('own_badge')}
</span>
)}
</span>
</td>
<td className="py-2 text-center">
@@ -398,6 +433,11 @@ export default function ChartOfAccountsManager() {
{typeLabel(account.account_type)}
</span>
</td>
<td className="py-2 text-right">
<span className="text-xs text-muted-foreground tabular-nums">
{usageCounts.get(account.account_number) ?? '\u2014'}
</span>
</td>
<td className="py-2 text-center">
<Switch
checked={account.is_active}
@@ -577,6 +617,12 @@ export default function ChartOfAccountsManager() {
onCreated={refreshAll}
/>
<PruneAccountsDialog
open={pruneDialogOpen}
onOpenChange={setPruneDialogOpen}
onPruned={refreshAll}
/>
{editAccount && (
<EditAccountDialog
open={!!editAccount}
+23 -14
View File
@@ -257,6 +257,23 @@ export default function JournalEntryList() {
setPeriodHydrated(true)
return
}
const stored =
typeof window !== 'undefined'
? window.localStorage.getItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id)
: null
// Optimistic hydration: a saved scope unblocks the first entries fetch
// immediately instead of serializing it behind the fiscal-periods
// round-trip (the common returning-user case). The fetch below still
// validates a saved period id and re-scopes to the current räkenskapsår
// if it went stale (e.g. the period was deleted), the entries effect
// then refires with the corrected scope.
if (stored) {
// FISCAL_YEAR_ALL_VALUE = user explicitly chose "all years", respect it.
setPeriodId(stored === FISCAL_YEAR_ALL_VALUE ? null : stored)
setPeriodHydrated(true)
}
let cancelled = false
;(async () => {
let fetched: FiscalPeriod[] = []
@@ -271,20 +288,12 @@ export default function JournalEntryList() {
}
if (cancelled) return
const stored =
typeof window !== 'undefined'
? window.localStorage.getItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id)
: null
if (stored === FISCAL_YEAR_ALL_VALUE) {
// User explicitly chose "all years", respect it.
setPeriodId(null)
} else if (stored && fetched.some((p) => p.id === stored)) {
setPeriodId(stored)
} else {
// No (valid) saved scope → default to the current räkenskapsår.
const today = new Date().toISOString().split('T')[0]
setPeriodId(resolveCurrentPeriodId(fetched, today))
}
if (stored === FISCAL_YEAR_ALL_VALUE) return
if (stored && fetched.some((p) => p.id === stored)) return
// No (valid) saved scope → default to the current räkenskapsår.
const today = new Date().toISOString().split('T')[0]
setPeriodId(resolveCurrentPeriodId(fetched, today))
setPeriodHydrated(true)
})()
return () => {
@@ -0,0 +1,295 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, Search, Trash2 } from 'lucide-react'
interface PruneCandidate {
account_number: string
account_name: string
account_class: number
plan_type: string | null
is_active: boolean
in_bas_reference: boolean
}
interface PruneAccountsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
/** Called after a successful prune so the parent can refetch the chart. */
onPruned: () => void
}
/**
* Bulk deletion of unused accounts. Fetches the server-computed deletable set
* (dry run) and presents it in two groups — imported/custom accounts and
* untouched accounts from the seeded base plan. Nothing is preselected:
* deletion is opt-in per account or per group. Used accounts never appear
* here; the server re-verifies every guard at execute time anyway.
*/
export function PruneAccountsDialog({ open, onOpenChange, onPruned }: PruneAccountsDialogProps) {
const t = useTranslations('chart_of_accounts')
const tCommon = useTranslations('common')
const { toast } = useToast()
const [loading, setLoading] = useState(false)
const [candidates, setCandidates] = useState<PruneCandidate[]>([])
const [usedCount, setUsedCount] = useState(0)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [searchQuery, setSearchQuery] = useState('')
const [isDeleting, setIsDeleting] = useState(false)
// Grouping only: imported/manually added accounts (the typical cleanup
// target) are listed separately from unused accounts in the seeded base
// plan, so users see what came from an import at a glance.
const isImportedOrCustom = useCallback(
(c: PruneCandidate) => !c.in_bas_reference || c.plan_type === 'full_bas',
[],
)
useEffect(() => {
if (!open) return
let cancelled = false
async function load() {
setLoading(true)
setCandidates([])
setSelected(new Set())
setSearchQuery('')
try {
const res = await fetch('/api/bookkeeping/accounts/prune', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dry_run: true }),
})
if (!res.ok) throw new Error()
const { data } = await res.json()
if (cancelled) return
const deletable: PruneCandidate[] = data?.deletable ?? []
setCandidates(deletable)
setUsedCount((data?.used ?? []).length)
// Nothing starts selected — deletion is opt-in per account (or per
// group via the header checkbox), never a preloaded default.
} catch {
if (!cancelled) {
toast({ title: t('toast_prune_failed'), variant: 'destructive' })
onOpenChange(false)
}
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => {
cancelled = true
}
}, [open, onOpenChange, t, toast])
// Charts bloated by an import easily reach hundreds of candidates — filter
// by number or name, same semantics as the kontoplan page search. Selection
// is keyed by account number, so it survives filter changes; the confirm
// button always shows the true selected count.
const filteredCandidates = useMemo(() => {
if (!searchQuery) return candidates
const q = searchQuery.toLowerCase()
return candidates.filter(
(c) => c.account_number.includes(q) || c.account_name.toLowerCase().includes(q),
)
}, [candidates, searchQuery])
const customGroup = useMemo(
() => filteredCandidates.filter(isImportedOrCustom),
[filteredCandidates, isImportedOrCustom],
)
const seedGroup = useMemo(
() => filteredCandidates.filter((c) => !isImportedOrCustom(c)),
[filteredCandidates, isImportedOrCustom],
)
function toggleAccount(accountNumber: string) {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(accountNumber)) {
next.delete(accountNumber)
} else {
next.add(accountNumber)
}
return next
})
}
function toggleGroup(group: PruneCandidate[], checked: boolean) {
setSelected((prev) => {
const next = new Set(prev)
for (const c of group) {
if (checked) {
next.add(c.account_number)
} else {
next.delete(c.account_number)
}
}
return next
})
}
async function handlePrune() {
if (selected.size === 0) return
setIsDeleting(true)
try {
const res = await fetch('/api/bookkeeping/accounts/prune', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dry_run: false, account_numbers: [...selected] }),
})
const body = await res.json().catch(() => null)
if (!res.ok) throw new Error(body?.error)
const deleted: string[] = body?.data?.deleted ?? []
const skipped: string[] = [
...(body?.data?.skipped ?? []),
...(body?.data?.not_found ?? []),
]
toast({
title: t('toast_pruned_title'),
description:
skipped.length > 0
? t('toast_pruned_with_skipped', { deleted: deleted.length, skipped: skipped.length })
: t('toast_pruned_description', { deleted: deleted.length }),
})
onPruned()
onOpenChange(false)
} catch (err) {
toast({
title: err instanceof Error && err.message ? err.message : t('toast_prune_failed'),
variant: 'destructive',
})
} finally {
setIsDeleting(false)
}
}
function renderGroup(group: PruneCandidate[], titleKey: 'prune_group_custom' | 'prune_group_seed', hintKey: 'prune_group_custom_hint' | 'prune_group_seed_hint') {
if (group.length === 0) return null
const allChecked = group.every((c) => selected.has(c.account_number))
return (
<div className="space-y-2">
<div className="flex items-start gap-3">
<Checkbox
checked={allChecked}
onCheckedChange={(checked) => toggleGroup(group, checked === true)}
className="mt-1"
aria-label={t(titleKey)}
/>
<div>
<p className="text-sm font-medium">
{t(titleKey)}{' '}
<span className="text-muted-foreground tabular-nums">({group.length})</span>
</p>
<p className="text-xs text-muted-foreground">{t(hintKey)}</p>
</div>
</div>
<div className="w-full rounded-lg border border-border divide-y divide-border">
{group.map((c) => (
<label
key={c.account_number}
className="flex min-w-0 items-center gap-3 px-3 py-2 text-sm cursor-pointer hover:bg-secondary/60 transition-colors duration-150"
>
<Checkbox
checked={selected.has(c.account_number)}
onCheckedChange={() => toggleAccount(c.account_number)}
/>
<span className="font-mono tabular-nums text-muted-foreground w-14 shrink-0">
{c.account_number}
</span>
<span className="min-w-0 flex-1 truncate">{c.account_name}</span>
{!c.is_active && (
<span className="ml-auto text-[10px] uppercase tracking-wider text-muted-foreground shrink-0">
{t('prune_inactive_badge')}
</span>
)}
</label>
))}
</div>
</div>
)
}
return (
<Dialog open={open} onOpenChange={(v) => !isDeleting && onOpenChange(v)}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t('prune_title')}</DialogTitle>
<DialogDescription>{t('prune_description')}</DialogDescription>
</DialogHeader>
{loading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />
{t('prune_loading')}
</div>
) : candidates.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t('prune_empty')}</p>
) : (
// min-w-0: DialogContent is a grid, and grid items default to
// min-width auto — without this, the nowrap (truncate) account
// names propagate their full width up and blow the dialog open
// horizontally instead of truncating.
<div className="min-w-0 space-y-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('search_placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="space-y-4 max-h-[60vh] overflow-y-auto overflow-x-hidden pr-1">
{filteredCandidates.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">{t('no_matches')}</p>
) : (
<>
{renderGroup(customGroup, 'prune_group_custom', 'prune_group_custom_hint')}
{renderGroup(seedGroup, 'prune_group_seed', 'prune_group_seed_hint')}
</>
)}
{usedCount > 0 && (
<p className="text-xs text-muted-foreground">
{t('prune_used_note', { count: usedCount })}
</p>
)}
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isDeleting}>
{tCommon('cancel')}
</Button>
<Button
variant="destructive"
onClick={handlePrune}
disabled={loading || isDeleting || selected.size === 0}
>
{isDeleting ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
)}
{t('prune_confirm', { count: selected.size })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+17 -4
View File
@@ -28,12 +28,25 @@ export function MainContainer({
// pane on wide viewports.
const isFullBleed = pathname.startsWith('/e/') || pathname.startsWith('/chat')
return isFullBleed ? (
<div key={companyId ?? ''} className="h-full">{children}</div>
) : (
// The salary run detail page drives a wide, horizontal-flow layout (progress
// band + 5-up KPIs + full-width employee ledger) that the standard max-w-5xl
// column squeezes. It opts into a wider canvas — a deliberate, scoped
// exception to the locked container token. Match only /salary/runs/{id}, not
// its nested employee sub-pages.
const isWide = /^\/salary\/runs\/[^/]+$/.test(pathname)
if (isFullBleed) {
return <div key={companyId ?? ''} className="h-full">{children}</div>
}
return (
<div
key={companyId ?? ''}
className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10"
className={
isWide
? 'max-w-7xl mx-auto px-5 py-8 md:px-8 md:py-10'
: 'max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10'
}
>
{children}
</div>
@@ -1174,6 +1174,9 @@ function getFYStatus(r: ImportResult): { icon: 'success' | 'warning' | 'error';
if (r.errors.length > 0 || (r.details?.skippedVouchers && r.details.skippedVouchers.total > 0)) {
return { icon: 'warning', label: 'Delvis importerad' }
}
if (r.details?.untransferredResults && r.details.untransferredResults.length > 0) {
return { icon: 'warning', label: 'Importerad med varning' }
}
return { icon: 'success', label: 'Importerad' }
}
@@ -1354,6 +1357,61 @@ function FiscalYearResult({ result, index }: { result: ImportResult; index: numb
</div>
)}
{/* Untransferred prior-year results — omföring av årets resultat saknas */}
{d?.untransferredResults && d.untransferredResults.length > 0 && (
<div className="rounded-md border border-amber-200 bg-amber-50/50 p-3 dark:border-amber-900/30 dark:bg-amber-950/20">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<div>
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">
Årets resultat är inte omfört till eget kapital
</p>
<p className="mt-1 text-sm text-muted-foreground">
Följande räkenskapsår saknar omföring av årets resultat. Senare års
balansräkning visar en differens på beloppet tills omföringen bokförs
(konto 8999 mot eget kapital, t.ex. 2099) i respektive år.
</p>
<div className="mt-2 space-y-1 text-sm text-muted-foreground tabular-nums">
{d.untransferredResults.map((u) => (
<div key={u.fiscal_period_id} className="flex justify-between gap-6">
<span>{u.period_name}</span>
<span className="font-medium">
{u.pl_net.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK
</span>
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* Remaining warnings — previously dropped entirely in this flow.
Strings covered by structured cards above are filtered out. */}
{(() => {
const remainingWarnings = result.warnings.filter(
(w) =>
!(d?.skippedVouchers && d.skippedVouchers.total > 0 && w.includes('hoppades över')) &&
!(d?.untransferredResults && d.untransferredResults.length > 0 && w.includes('förts om till eget kapital'))
)
if (remainingWarnings.length === 0) return null
return (
<div className="rounded-md border border-amber-200 bg-amber-50/50 p-3 dark:border-amber-900/30 dark:bg-amber-950/20">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<div className="space-y-1.5">
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">
{remainingWarnings.length === 1 ? '1 varning' : `${remainingWarnings.length} varningar`}
</p>
{remainingWarnings.map((w, i) => (
<p key={i} className="text-sm text-muted-foreground">{w}</p>
))}
</div>
</div>
</div>
)
})()}
{/* Retry info (only shown if retries happened) */}
{d && d.retriedBatches > 0 && (
<p className="text-xs text-muted-foreground">
@@ -15,11 +15,13 @@ import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, Plus, Trash2, AlertTriangle, Search, Check } from 'lucide-react'
import { Loader2, Plus, Trash2, AlertTriangle, Search, Check, BookmarkPlus } from 'lucide-react'
import { cn, formatCurrency } from '@/lib/utils'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane'
import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker'
import { TemplateForm } from '@/components/settings/TemplateForm'
import { deriveTemplateLinesFromBooking } from '@/lib/bookkeeping/template-library'
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
import { useCompany } from '@/contexts/CompanyContext'
import {
@@ -30,7 +32,7 @@ import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { resolveAccount } from '@/lib/cash-accounts/resolve-account'
import type { BASAccount, CashAccount, FiscalPeriod, InvoiceExtractionResult } from '@/types'
import type { BASAccount, BookingTemplateLibrary, CashAccount, FiscalPeriod, InvoiceExtractionResult } from '@/types'
interface InboxItem {
id: string
@@ -68,6 +70,15 @@ interface FormLine {
const BLANK_LINE: FormLine = { account_number: '', debit_amount: '', credit_amount: '' }
// Swedish entity labels for the "Spara som mall" editor. Hard-coded to match
// this dialog's Swedish-only surface (the shared TemplateForm handles the rest
// of its own strings bilingually).
const TEMPLATE_ENTITY_LABELS: Record<string, string> = {
all: 'Alla',
enskild_firma: 'Enskild firma',
aktiebolag: 'Aktiebolag',
}
interface Props {
open: boolean
onOpenChange: (v: boolean) => void
@@ -196,6 +207,12 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
const [isSubmitting, setIsSubmitting] = useState(false)
// "Spara som mall" — derive amount-parameterised template lines from the
// current konteringsrader so the user can save the pattern they just worked
// out. Labels come from the loaded BAS chart; the user reviews/edits in the
// shared TemplateForm before saving.
const [showSaveTemplate, setShowSaveTemplate] = useState(false)
// Reset state when a different item opens the dialog. We pass bankAccount
// here but it may still be null (fetch in flight): in that case '1930' is
// used as a placeholder and the prefill-update effect below will overwrite
@@ -426,6 +443,19 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
}
}, [lines])
// Account number → BAS name, so derived template lines get meaningful labels.
const accountNameMap = useMemo(
() => Object.fromEntries(accounts.map((a) => [a.account_number, a.account_name])),
[accounts],
)
// Template lines derived from the current booking. Empty (<2 usable lines)
// disables the "Spara som mall" button.
const derivedTemplateLines = useMemo(
() => deriveTemplateLinesFromBooking(lines, accountNameMap),
[lines, accountNameMap],
)
const updateLine = useCallback((idx: number, patch: Partial<FormLine>) => {
setLines((prev) => prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)))
}, [])
@@ -841,6 +871,21 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
: targetSek ?? undefined
}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowSaveTemplate(true)}
disabled={isSubmitting || derivedTemplateLines.length < 2}
title={
derivedTemplateLines.length < 2
? 'Fyll i minst två konteringsrader med konto och belopp'
: undefined
}
>
<BookmarkPlus className="h-3.5 w-3.5 mr-1.5" />
Spara som mall
</Button>
</div>
{totals.balanced ? (
<Badge variant="success" className="text-[11px]">
@@ -914,6 +959,45 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
onConfirm={confirmActivation}
onCancel={cancelActivation}
/>
{/* Save the current kontering as a reusable template. Amounts are stored
as ratios of the total, so the user picks a fresh amount when applying
the mall later. The shared TemplateForm re-seeds from the derived lines
each time the dialog opens (Radix unmounts its content when closed). */}
<Dialog open={showSaveTemplate} onOpenChange={setShowSaveTemplate}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Spara som bokföringsmall</DialogTitle>
<DialogDescription>
Spara den här konteringen som en återanvändbar mall. Beloppen sparas
som andelar av totalsumman — du anger ett nytt belopp när du använder
mallen. Kontrollera raderna nedan innan du sparar.
</DialogDescription>
</DialogHeader>
{showSaveTemplate && (
<TemplateForm
mode="create"
entityLabels={TEMPLATE_ENTITY_LABELS}
initialTemplate={{
id: '',
company_id: null,
team_id: null,
created_by: null,
name: description.trim(),
description: '',
category: 'other',
entity_type: company?.entity_type ?? 'all',
lines: derivedTemplateLines,
is_system: false,
is_active: true,
created_at: '',
updated_at: '',
} satisfies BookingTemplateLibrary}
onSaved={() => setShowSaveTemplate(false)}
/>
)}
</DialogContent>
</Dialog>
</Dialog>
)
}
+36 -2
View File
@@ -18,6 +18,7 @@ import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { formatCurrency } from '@/lib/utils'
import type { ImportResult } from '@/lib/import/types'
interface ImportResultStepProps {
@@ -41,11 +42,15 @@ export default function ImportResultStep({ result, onNewImport, onUndo }: Import
}
const hasErrors = result.errors.length > 0
const skipped = result.details?.skippedVouchers
const untransferred = result.details?.untransferredResults
// Filter out raw "hoppades över" warnings when we have structured data
const otherWarnings = skipped && skipped.total > 0
// Filter out raw warnings when we have structured data for them
let otherWarnings = skipped && skipped.total > 0
? result.warnings.filter((w) => !w.includes('hoppades över'))
: result.warnings
if (untransferred && untransferred.length > 0) {
otherWarnings = otherWarnings.filter((w) => !w.includes('förts om till eget kapital'))
}
return (
<div className="space-y-6">
@@ -259,6 +264,35 @@ export default function ImportResultStep({ result, onNewImport, onUndo }: Import
</Card>
)}
{/* Untransferred prior-year results — the year-end omföring is missing */}
{untransferred && untransferred.length > 0 && (
<Card className="border-warning/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-warning">
<AlertCircle className="h-5 w-5" />
Årets resultat är inte omfört
</CardTitle>
<CardDescription>
Följande räkenskapsår saknar omföring av årets resultat till eget kapital.
Senare års balansräkning visar en differens på beloppet tills omföringen
bokförs (konto 8999 mot eget kapital, t.ex. 2099) i respektive år.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{untransferred.map((u) => (
<div key={u.fiscal_period_id} className="text-sm flex justify-between gap-4">
<span className="font-medium">{u.period_name}</span>
<span className="tabular-nums">
{formatCurrency(u.pl_net, 'SEK', { minimumFractionDigits: 2 })}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Other warnings (filtered) */}
{otherWarnings.length > 0 && (
<Card className="border-warning/50">
+16 -1
View File
@@ -1,8 +1,23 @@
'use client'
import dynamic from 'next/dynamic'
import { useTranslations } from 'next-intl'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import InvoiceEditor from '@/components/invoices/InvoiceEditor'
import { Skeleton } from '@/components/ui/skeleton'
// Deferred: the editor (and its framer-motion dependency) is a large chunk
// that would otherwise ship with the invoice LIST bundle — it's only needed
// once this dialog actually opens.
const InvoiceEditor = dynamic(() => import('@/components/invoices/InvoiceEditor'), {
ssr: false,
loading: () => (
<div className="space-y-4 p-6">
<Skeleton className="h-8 w-1/3" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
),
})
interface Props {
open: boolean
+20 -3
View File
@@ -664,6 +664,11 @@ export function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: {
</div>
)}
</div>
{!isBalanced && data.imbalance_diagnosis && (
<p className="text-sm text-muted-foreground mt-3 pt-3 border-t">
{data.imbalance_diagnosis.message}
</p>
)}
</CardContent>
</Card>
</div>
@@ -948,11 +953,23 @@ export function BalansrapportView({ periodId, dateRange, onNavigateToAccount }:
Balanserar
</Badge>
) : (
<Badge variant="destructive" className="text-base px-3 py-1">
Balanserar ej
</Badge>
<div className="text-right">
<Badge variant="destructive" className="text-base px-3 py-1">
Balanserar ej
</Badge>
{data.imbalance_diagnosis && (
<p className="text-sm text-destructive mt-1">
Differens: {formatAmount(Math.abs(data.imbalance_diagnosis.differens))} kr
</p>
)}
</div>
)}
</div>
{!data.is_balanced && data.imbalance_diagnosis && (
<p className="text-sm text-muted-foreground pt-2 border-t">
{data.imbalance_diagnosis.message}
</p>
)}
</CardContent>
</Card>
</div>
+88 -91
View File
@@ -15,6 +15,7 @@ import {
ShieldAlert,
Unlock,
} from 'lucide-react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useCapability } from '@/contexts/CompanyContext'
@@ -114,6 +115,7 @@ export function AGIPanel(props: AGIPanelProps) {
onChange,
} = props
const t = useTranslations('salary_agi')
const hasSkatteverket = useCapability(CAPABILITY.skatteverket)
const [extensionDisabled, setExtensionDisabled] = useState(false)
@@ -188,19 +190,19 @@ export function AGIPanel(props: AGIPanelProps) {
if (event.origin !== window.location.origin) return
if (event.data?.type === 'skatteverket-oauth-success') {
setError(null)
setSuccess('Anslutningen mot Skatteverket lyckades.')
setSuccess(t('oauth_success'))
fetchStatus()
} else if (event.data?.type === 'skatteverket-oauth-error') {
const reason =
typeof event.data.reason === 'string' && event.data.reason
? event.data.reason
: 'OAuth-anslutningen misslyckades. Försök igen.'
: t('oauth_error_fallback')
setError(reason)
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [fetchStatus])
}, [fetchStatus, t])
// Drop a stale "AGI-XML saknas" error once the run's AGI is (re)generated.
// That error is set when "Skicka in underlag" runs before the XML exists; if
@@ -252,14 +254,14 @@ export function AGIPanel(props: AGIPanelProps) {
// Replace any lingering "Granskningsunderlag klart…" / stale error
// with an unambiguous confirmation. Mirrors handleCheckSubmitted.
setError(null)
setSuccess('AGI har signerats och lämnats in.')
setSuccess(t('signed_success'))
onChange?.()
}
return signed
} catch {
return false
}
}, [arbetsgivare, period, fetchSubmission, onChange])
}, [arbetsgivare, period, fetchSubmission, onChange, t])
/**
* Background-poll /agi/kvittenser at 30s, 2 min, and 5 min after the user
@@ -327,18 +329,18 @@ export function AGIPanel(props: AGIPanelProps) {
})
if (!res.ok) {
const json = await res.json().catch(() => ({}))
setError(json.error || `Kunde inte koppla bort (${res.status})`)
setError(json.error || t('disconnect_failed_status', { status: res.status }))
return
}
setSuccess('Anslutningen mot Skatteverket har kopplats bort.')
setSuccess(t('disconnect_success'))
await fetchStatus()
await fetchSubmission()
} catch (e) {
setError(e instanceof Error ? e.message : 'Kunde inte koppla bort')
setError(e instanceof Error ? e.message : t('disconnect_failed'))
} finally {
setActionLoading(null)
}
}, [fetchStatus, fetchSubmission])
}, [fetchStatus, fetchSubmission, t])
const handleConnect = () => {
// Open the BankID OAuth flow in a centered popup. The callback page
@@ -425,7 +427,7 @@ export function AGIPanel(props: AGIPanelProps) {
const res = await fetch(`/api/salary/runs/${salaryRunId}/agi/xml`)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || 'Kunde inte generera AGI-filen')
throw new Error(data.error || t('xml_generate_failed'))
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
@@ -438,7 +440,7 @@ export function AGIPanel(props: AGIPanelProps) {
URL.revokeObjectURL(url)
onChange?.()
} catch (e) {
setError(e instanceof Error ? e.message : 'Kunde inte ladda ner AGI-filen')
setError(e instanceof Error ? e.message : t('xml_download_failed'))
} finally {
setActionLoading(null)
}
@@ -457,12 +459,12 @@ export function AGIPanel(props: AGIPanelProps) {
})
const submitJson = await submitRes.json()
if (!submitRes.ok || submitJson.error) {
setError(submitJson.error || `Inlämning misslyckades (${submitRes.status})`)
setError(submitJson.error || t('submit_failed_status', { status: submitRes.status }))
return
}
const inlamningId = submitJson.data?.inlamningId as number | undefined
if (!inlamningId) {
setError('Inlämningssvar saknar inlamningId')
setError(t('submit_missing_id'))
return
}
@@ -474,7 +476,7 @@ export function AGIPanel(props: AGIPanelProps) {
)
const krJson = await krRes.json()
if (!krRes.ok || krJson.error) {
setError(krJson.error || `Kontrollresultat misslyckades (${krRes.status})`)
setError(krJson.error || t('kontrollresultat_failed_status', { status: krRes.status }))
return
}
kr = krJson.data as Kontrollresultat
@@ -482,7 +484,7 @@ export function AGIPanel(props: AGIPanelProps) {
await new Promise(r => setTimeout(r, 1000))
}
if (!kr || kr.status === 'PROCESSING') {
setError('Skatteverket bearbetar fortfarande underlaget. Försök igen om en stund.')
setError(t('still_processing'))
return
}
@@ -490,20 +492,17 @@ export function AGIPanel(props: AGIPanelProps) {
setKontroller(findings)
if (kr.status === 'DONE_SUCCESS') {
setSuccess(
'Underlag accepterat hos Skatteverket. Klicka "Skapa signeringslänk" ' +
'för att gå vidare till BankID-signering i Mina Sidor.',
)
setSuccess(t('underlag_accepted'))
} else if (kr.status === 'DONE_REJECTED') {
setError(`Underlaget innehåller ${findings.filter(f => f.status === 'STOPP').length} stoppande fel. Åtgärda och skicka igen.`)
setError(t('underlag_rejected_error', { count: findings.filter(f => f.status === 'STOPP').length }))
} else {
setError('Skatteverket avvisade underlaget (DONE_FAILED).')
setError(t('underlag_failed'))
}
await fetchSubmission()
onChange?.()
} catch (e) {
setError(e instanceof Error ? e.message : 'Kunde inte skicka AGI')
setError(e instanceof Error ? e.message : t('submit_failed'))
} finally {
setActionLoading(null)
}
@@ -525,13 +524,13 @@ export function AGIPanel(props: AGIPanelProps) {
)
const json = await res.json()
if (!res.ok || json.error) {
setError(json.error || `Kunde inte skapa granskningsunderlag (${res.status})`)
setError(json.error || t('signing_link_failed_status', { status: res.status }))
return
}
if (json.data?.tillstand === 'INCORRECT_DATA') {
setError(`${json.data.meddelande || 'Felaktiga underlag finns'}: öppna länken för felrapport.`)
setError(t('incorrect_data_error', { message: json.data.meddelande || t('incorrect_data_fallback') }))
} else {
setSuccess('Granskningsunderlag klart. Öppna signeringslänken för att signera med BankID.')
setSuccess(t('signing_link_ready'))
// The user typically opens the link, signs in Mina Sidor, then
// returns later (or never). Auto-poll so we capture the kvittens
// (and stamp agi_submitted_at) without forcing the user to come
@@ -540,7 +539,7 @@ export function AGIPanel(props: AGIPanelProps) {
}
await fetchSubmission()
} catch (e) {
setError(e instanceof Error ? e.message : 'Kunde inte skapa granskningsunderlag')
setError(e instanceof Error ? e.message : t('signing_link_failed'))
} finally {
setActionLoading(null)
}
@@ -557,13 +556,13 @@ export function AGIPanel(props: AGIPanelProps) {
)
const json = await res.json()
if (!res.ok || json.error) {
setError(json.error || `Kunde inte låsa upp (${res.status})`)
setError(json.error || t('unlock_failed_status', { status: res.status }))
return
}
setSuccess('AGI har låsts upp')
setSuccess(t('unlock_success'))
await fetchSubmission()
} catch (e) {
setError(e instanceof Error ? e.message : 'Kunde inte låsa upp')
setError(e instanceof Error ? e.message : t('unlock_failed'))
} finally {
setActionLoading(null)
}
@@ -585,19 +584,19 @@ export function AGIPanel(props: AGIPanelProps) {
)
const json = await res.json()
if (!res.ok || json.error) {
setError(json.error || 'Kunde inte hämta kvittenser')
setError(json.error || t('kvittens_fetch_failed'))
return
}
const kvittens = json.data?.kvittenser?.[0]
if (kvittens?.uuidKvittens) {
setSuccess('AGI har signerats och lämnats in.')
setSuccess(t('signed_success'))
} else {
setSuccess('Ingen signerad kvittens hittades än för perioden.')
setSuccess(t('no_kvittens_yet'))
}
await fetchSubmission()
onChange?.()
} catch (e) {
setError(e instanceof Error ? e.message : 'Kunde inte kontrollera status')
setError(e instanceof Error ? e.message : t('check_status_failed'))
} finally {
setActionLoading(null)
}
@@ -609,15 +608,15 @@ export function AGIPanel(props: AGIPanelProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Arbetsgivardeklaration (AGI)</CardTitle>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<div className="flex items-start gap-2">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
<p>
Skatteverket-integrationen är inaktiverad i denna miljö. Aktivera
{t('disabled_before')}
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">SKATTEVERKET_ENABLED</code>
för att skicka AGI direkt till Skatteverket.
{t('disabled_after')}
</p>
</div>
</CardContent>
@@ -629,10 +628,10 @@ export function AGIPanel(props: AGIPanelProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Arbetsgivardeklaration (AGI)</CardTitle>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
<Loader2 className="mr-2 inline h-4 w-4 animate-spin" /> Hämtar Skatteverket-status...
<Loader2 className="mr-2 inline h-4 w-4 animate-spin" /> {t('loading_status')}
</CardContent>
</Card>
)
@@ -642,16 +641,16 @@ export function AGIPanel(props: AGIPanelProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Arbetsgivardeklaration (AGI)</CardTitle>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
Anslut till Skatteverket med BankID för att skicka AGI direkt från accounted.
{t('connect_description')}
</p>
{!readOnly && (
<Button onClick={handleConnect}>
<Link2 className="mr-2 h-4 w-4" />
Anslut med BankID
{t('connect_button')}
</Button>
)}
</CardContent>
@@ -687,11 +686,11 @@ export function AGIPanel(props: AGIPanelProps) {
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between text-base">
<span>Arbetsgivardeklaration (AGI)</span>
<span>{t('title')}</span>
<span className="flex items-center gap-2 text-xs font-normal text-muted-foreground">
<span className="flex items-center gap-1">
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
Ansluten
{t('connected')}
</span>
{!readOnly && (
<button
@@ -699,14 +698,14 @@ export function AGIPanel(props: AGIPanelProps) {
onClick={handleDisconnect}
disabled={actionLoading === 'disconnect'}
className="inline-flex items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground transition-colors hover:border-destructive/50 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-50"
title="Koppla bort anslutningen mot Skatteverket"
title={t('disconnect_title')}
>
{actionLoading === 'disconnect' ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<PlugZap className="h-3 w-3" />
)}
Koppla bort
{t('disconnect_button')}
</button>
)}
</span>
@@ -719,13 +718,13 @@ export function AGIPanel(props: AGIPanelProps) {
only fix is a fresh BankID round-trip. */}
{(status?.expired === true || status?.canRefresh === false) && !readOnly && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20">
<p className="text-sm font-medium">Anslutningen mot Skatteverket har gått ut</p>
<p className="text-sm font-medium">{t('expired_banner_title')}</p>
<p className="mt-1 text-xs text-muted-foreground">
Logga in med BankID igen för att kunna skicka AGI.
{t('expired_banner_description')}
</p>
<Button size="sm" variant="outline" className="mt-2" onClick={handleConnect}>
<Link2 className="mr-1.5 h-3.5 w-3.5" />
Återanslut med BankID
{t('reconnect_button')}
</Button>
</div>
)}
@@ -737,18 +736,16 @@ export function AGIPanel(props: AGIPanelProps) {
{missingAgdScope && !readOnly && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20">
<p className="text-sm font-medium">
Anslutningen mot Skatteverket saknar behörighet för Arbetsgivardeklaration
{t('missing_scope_title')}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Din anslutning utfärdades innan AGI-stödet aktiverades. Koppla
bort och anslut igen via Inställningar → Skatt för att
kunna skicka AGI direkt.
{t('missing_scope_description')}
</p>
<a
href="/settings/tax"
className="mt-2 inline-flex items-center gap-1 text-sm font-medium hover:underline"
>
Öppna inställningar <ExternalLink className="h-3.5 w-3.5" />
{t('open_settings')} <ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
)}
@@ -757,26 +754,26 @@ export function AGIPanel(props: AGIPanelProps) {
<div className="space-y-1.5 text-sm">
<StatusRow
ok={!!agiGeneratedAt}
okText={agiGeneratedAt ? `AGI-fil genererad ${new Date(agiGeneratedAt).toLocaleString('sv-SE')}` : ''}
pendingText="AGI-fil har inte genererats ännu."
okText={agiGeneratedAt ? t('file_generated', { date: new Date(agiGeneratedAt).toLocaleString('sv-SE') }) : ''}
pendingText={t('file_not_generated')}
/>
<StatusRow
ok={isSigned}
okText={
submission?.kvittensnummer
? `Skickad till Skatteverket, kvittens ${submission.kvittensnummer}`
? t('submitted_with_kvittens', { kvittens: submission.kvittensnummer })
: agiSubmittedAt
? `Skickad till Skatteverket ${new Date(agiSubmittedAt).toLocaleString('sv-SE')}`
: 'Skickad'
? t('submitted_at', { date: new Date(agiSubmittedAt).toLocaleString('sv-SE') })
: t('submitted')
}
pendingText={
awaitingSigning
? draftIsStale
? 'Ett signeringsutkast finns hos Skatteverket men är inaktuellt: lås upp och skicka in underlaget på nytt.'
: 'Granskningsunderlag klart: väntar på BankID-signatur i Mina Sidor.'
? t('pending_stale_draft')
: t('pending_awaiting_signature')
: underlagSubmitted
? 'Underlag inläst hos Skatteverket. Skapa granskningsunderlag för att gå vidare till signering.'
: 'Inte skickad till Skatteverket ännu. Deadline: 12:e i månaden efter utbetalning (17:e i januari/augusti för arbetsgivare vars sammanlagda lönesumma understiger 40 MSEK per år).'
? t('pending_underlag_submitted')
: t('pending_not_submitted')
}
/>
</div>
@@ -788,9 +785,9 @@ export function AGIPanel(props: AGIPanelProps) {
BankID signing is even possible. */}
{submission?.signeringslank && awaitingSigning && !draftIsStale && (
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20">
<p className="text-sm font-medium">Utkastet är låst och redo att signeras</p>
<p className="text-sm font-medium">{t('draft_locked_title')}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Öppna länken nedan och signera med BankID på Skatteverkets sida.
{t('draft_locked_description')}
</p>
<a
href={submission.signeringslank}
@@ -798,7 +795,7 @@ export function AGIPanel(props: AGIPanelProps) {
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-sm font-medium text-amber-900 hover:underline dark:text-amber-200"
>
Öppna signeringslänk <ExternalLink className="h-3.5 w-3.5" />
{t('open_signing_link')} <ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
)}
@@ -810,18 +807,19 @@ export function AGIPanel(props: AGIPanelProps) {
the SKV lock; the user then re-submits the freshly generated XML. */}
{awaitingSigning && draftIsStale && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20">
<p className="text-sm font-medium">Signeringsutkastet är inaktuellt</p>
<p className="text-sm font-medium">{t('stale_draft_title')}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
AGI:n genererades om{' '}
{agiGeneratedAt ? new Date(agiGeneratedAt).toLocaleString('sv-SE') : ''}{' '}
efter att det här signeringsutkastet skapades
{submission?.updatedAt
? ` (${new Date(submission.updatedAt).toLocaleString('sv-SE')})`
: ''}
. Utkastet hos Skatteverket innehåller äldre siffror. Klicka{' '}
<span className="font-medium">Lås upp</span> och därefter{' '}
<span className="font-medium">Skicka in underlag</span> för att
signera rätt belopp.
{t('stale_draft_description', {
generatedAt: agiGeneratedAt ? new Date(agiGeneratedAt).toLocaleString('sv-SE') : '',
draftCreatedAt: submission?.updatedAt
? ` (${new Date(submission.updatedAt).toLocaleString('sv-SE')})`
: '',
})}{' '}
{t('stale_draft_click')}{' '}
<span className="font-medium">{t('unlock_button')}</span>{' '}
{t('stale_draft_then')}{' '}
<span className="font-medium">{t('submit_button')}</span>{' '}
{t('stale_draft_to_sign')}
</p>
</div>
)}
@@ -834,10 +832,10 @@ export function AGIPanel(props: AGIPanelProps) {
{submission?.signeringslank && underlagRejected && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3">
<p className="text-sm font-medium text-destructive">
Felaktiga underlag: granskningsunderlag kunde inte signeras
{t('incorrect_data_title')}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{submission.meddelande || 'Skatteverket avvisade underlaget. Öppna felrapporten för detaljer.'}
{submission.meddelande || t('incorrect_data_description')}
</p>
<a
href={submission.signeringslank}
@@ -845,7 +843,7 @@ export function AGIPanel(props: AGIPanelProps) {
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-sm font-medium text-destructive hover:underline"
>
Öppna felrapport hos Skatteverket <ExternalLink className="h-3.5 w-3.5" />
{t('open_error_report')} <ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
)}
@@ -887,7 +885,7 @@ export function AGIPanel(props: AGIPanelProps) {
<div className="mt-2">
<Button size="sm" variant="outline" onClick={handleConnect}>
<Link2 className="mr-1.5 h-3.5 w-3.5" />
Återanslut med BankID
{t('reconnect_button')}
</Button>
</div>
)}
@@ -908,14 +906,14 @@ export function AGIPanel(props: AGIPanelProps) {
variant="outline"
onClick={handleDownloadXml}
disabled={actionLoading === 'download'}
title="Ladda ner AGI-filen (XML) för manuell inlämning hos Skatteverket"
title={t('download_xml_title')}
>
{actionLoading === 'download' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-1.5 h-3.5 w-3.5" />
)}
Ladda ner AGI-fil
{t('download_xml_button')}
</Button>
<Button
size="sm"
@@ -924,7 +922,7 @@ export function AGIPanel(props: AGIPanelProps) {
disabled={actionLoading === 'submit' || !hasSkatteverket}
title={
!hasSkatteverket
? 'Inlämning av AGI till Skatteverket ingår i en uppgraderad plan.'
? t('submit_upgrade_title')
: undefined
}
>
@@ -933,7 +931,7 @@ export function AGIPanel(props: AGIPanelProps) {
) : (
<Send className="mr-1.5 h-3.5 w-3.5" />
)}
Skicka in underlag
{t('submit_button')}
</Button>
<Button
size="sm"
@@ -945,7 +943,7 @@ export function AGIPanel(props: AGIPanelProps) {
) : (
<Lock className="mr-1.5 h-3.5 w-3.5" />
)}
Skapa signeringslänk
{t('signing_link_button')}
</Button>
<Button
size="sm"
@@ -958,7 +956,7 @@ export function AGIPanel(props: AGIPanelProps) {
) : (
<Download className="mr-1.5 h-3.5 w-3.5" />
)}
Hämta kvittens
{t('check_kvittens_button')}
</Button>
{awaitingSigning && (
<Button
@@ -972,7 +970,7 @@ export function AGIPanel(props: AGIPanelProps) {
) : (
<Unlock className="mr-1.5 h-3.5 w-3.5" />
)}
Lås upp
{t('unlock_button')}
</Button>
)}
</div>
@@ -980,12 +978,11 @@ export function AGIPanel(props: AGIPanelProps) {
{!readOnly && !isSigned && !hasSkatteverket && (
<p className="text-xs text-muted-foreground">
Ladda ner AGI-filen ovan och lämna in den manuellt i Skatteverkets
e-tjänst, eller{' '}
{t('upgrade_hint_before')}{' '}
<a href="/settings/billing" className="font-medium underline hover:no-underline">
uppgradera
{t('upgrade_hint_link')}
</a>{' '}
för att skicka in direkt härifrån.
{t('upgrade_hint_after')}
</p>
)}
</CardContent>
+32 -28
View File
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -25,6 +26,8 @@ interface EmployeeBenefit {
is_active: boolean
}
// Swedish defaults written to the DB when the description is left empty:
// stored data stays Swedish regardless of the viewer's UI locale.
const BENEFIT_LABELS: Record<BenefitType, string> = {
bike: 'Cykelförmån',
car: 'Bilförmån',
@@ -35,6 +38,7 @@ const BENEFIT_LABELS: Record<BenefitType, string> = {
}
export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: string; canWrite: boolean }) {
const t = useTranslations('salary_employee')
const { toast } = useToast()
const [benefits, setBenefits] = useState<EmployeeBenefit[]>([])
const [loading, setLoading] = useState(true)
@@ -94,13 +98,13 @@ export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: st
})
if (res.ok) {
toast({ title: 'Förmån tillagd' })
toast({ title: t('benefits_added') })
reset()
await load()
} else {
const result = await res.json()
toast({
title: 'Kunde inte spara förmån',
title: t('benefits_save_failed'),
description: getErrorMessage(result, { statusCode: res.status }),
variant: 'destructive',
})
@@ -111,10 +115,10 @@ export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: st
async function handleDelete(id: string) {
const res = await fetch(`/api/salary/employees/${employeeId}/benefits/${id}`, { method: 'DELETE' })
if (res.ok) {
toast({ title: 'Förmån borttagen' })
toast({ title: t('benefits_removed') })
await load()
} else {
toast({ title: 'Kunde inte ta bort', variant: 'destructive' })
toast({ title: t('benefits_remove_failed'), variant: 'destructive' })
}
}
@@ -126,46 +130,46 @@ export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: st
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Förmåner</CardTitle>
<CardTitle className="text-base">{t('benefits_title')}</CardTitle>
{canWrite && !adding && (
<Button size="sm" variant="outline" onClick={() => setAdding(true)}>
<Plus className="mr-2 h-4 w-4" />
Lägg till förmån
{t('benefits_add')}
</Button>
)}
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<p className="text-sm text-muted-foreground">Laddar…</p>
<p className="text-sm text-muted-foreground">{t('benefits_loading')}</p>
) : benefits.length === 0 && !adding ? (
<p className="text-sm text-muted-foreground">
Inga förmåner registrerade. Aktiva förmåner läggs till automatiskt som rader vid lönekörning.
{t('benefits_empty')}
</p>
) : (
benefits.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Typ</TableHead>
<TableHead>Beskrivning</TableHead>
<TableHead className="text-right">Värde/mån</TableHead>
<TableHead>Period</TableHead>
<TableHead>{t('benefits_type')}</TableHead>
<TableHead>{t('benefits_description')}</TableHead>
<TableHead className="text-right">{t('benefits_value_per_month')}</TableHead>
<TableHead>{t('benefits_period')}</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{benefits.map(b => (
<TableRow key={b.id}>
<TableCell>{BENEFIT_LABELS[b.benefit_type]}</TableCell>
<TableCell>{t(`benefits_type_${b.benefit_type}`)}</TableCell>
<TableCell className="text-muted-foreground">{b.description}</TableCell>
<TableCell className="text-right tabular-nums">{formatCurrency(b.monthly_value)}</TableCell>
<TableCell className="text-muted-foreground tabular-nums text-xs">
{formatDate(b.valid_from)}
{b.valid_to ? ` till ${formatDate(b.valid_to)}` : ' till löpande'}
{b.valid_to ? ` ${formatDate(b.valid_to)}` : ` ${t('benefits_ongoing')}`}
</TableCell>
<TableCell className="text-right">
{canWrite && (
<Button size="icon" variant="ghost" onClick={() => handleDelete(b.id)} aria-label="Ta bort">
<Button size="icon" variant="ghost" onClick={() => handleDelete(b.id)} aria-label={t('benefits_remove')}>
<Trash2 className="h-4 w-4" />
</Button>
)}
@@ -181,32 +185,32 @@ export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: st
<div className="space-y-4 rounded-md border bg-muted/30 p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="benefit_type">Typ</Label>
<Label htmlFor="benefit_type">{t('benefits_type')}</Label>
<Select value={type} onValueChange={(v) => setType(v as BenefitType)}>
<SelectTrigger id="benefit_type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(BENEFIT_LABELS) as BenefitType[]).map(k => (
<SelectItem key={k} value={k}>{BENEFIT_LABELS[k]}</SelectItem>
<SelectItem key={k} value={k}>{t(`benefits_type_${k}`)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="benefit_description">Beskrivning</Label>
<Label htmlFor="benefit_description">{t('benefits_description')}</Label>
<Input
id="benefit_description"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder={BENEFIT_LABELS[type]}
placeholder={t(`benefits_type_${type}`)}
/>
</div>
</div>
{type === 'bike' ? (
<div className="space-y-2">
<Label htmlFor="annual_market_value">Årligt marknadsvärde (SEK)</Label>
<Label htmlFor="annual_market_value">{t('benefits_annual_market_value')}</Label>
<Input
id="annual_market_value"
type="number"
@@ -214,21 +218,21 @@ export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: st
min="0"
value={annualMarketValue}
onChange={e => setAnnualMarketValue(e.target.value)}
placeholder="t.ex. 8 400 (700 kr/mån)"
placeholder={t('benefits_annual_market_value_placeholder')}
className="max-w-xs"
/>
<p className="text-xs text-muted-foreground">
Skatteverkets schablon: första 3 000 kr/år är skattefri. Resterande beskattas månadsvis.
{t('benefits_bike_hint')}
{parseFloat(annualMarketValue) > 0 && (
<span className="ml-1">
Månatligt förmånsvärde: <strong className="tabular-nums">{formatCurrency(previewMonthlyBike)}</strong>
{t('benefits_monthly_value_preview')} <strong className="tabular-nums">{formatCurrency(previewMonthlyBike)}</strong>
</span>
)}
</p>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="monthly_value">Månatligt förmånsvärde (SEK)</Label>
<Label htmlFor="monthly_value">{t('benefits_monthly_value')}</Label>
<Input
id="monthly_value"
type="number"
@@ -243,20 +247,20 @@ export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: st
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="valid_from">Gäller från</Label>
<Label htmlFor="valid_from">{t('benefits_valid_from')}</Label>
<Input id="valid_from" type="date" value={validFrom} onChange={e => setValidFrom(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="valid_to">Gäller till (valfritt)</Label>
<Label htmlFor="valid_to">{t('benefits_valid_to')}</Label>
<Input id="valid_to" type="date" value={validTo} onChange={e => setValidTo(e.target.value)} />
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={reset} disabled={submitting}>Avbryt</Button>
<Button variant="outline" size="sm" onClick={reset} disabled={submitting}>{t('form_cancel')}</Button>
<Button size="sm" onClick={handleAdd} disabled={submitting}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Spara
{t('form_save')}
</Button>
</div>
</div>
+20 -18
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useMemo, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -44,6 +45,7 @@ export default function EmployeeTaxCard({
disabled,
onChange,
}: EmployeeTaxCardProps) {
const t = useTranslations('salary_employee')
const incomeYear = year ?? new Date().getFullYear()
const [fSkatt, setFSkatt] = useState(initial?.f_skatt_status ?? 'a_skatt')
@@ -89,14 +91,14 @@ export default function EmployeeTaxCard({
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Skatt</CardTitle>
<CardTitle className="text-base">{t('tax_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="f_skatt_status">
<InfoTooltip content="A-skatt: du drar preliminärskatt enligt skattetabell. F-skatt/FA-skatt: personen sköter sin egen skatt: inget skatteavdrag görs.">
Skatteform
<InfoTooltip content={t('tax_form_tooltip')}>
{t('tax_form_label')}
</InfoTooltip>
</Label>
<Select value={fSkatt} onValueChange={setFSkatt} disabled={disabled}>
@@ -107,7 +109,7 @@ export default function EmployeeTaxCard({
<SelectItem value="a_skatt">A-skatt</SelectItem>
<SelectItem value="f_skatt">F-skatt</SelectItem>
<SelectItem value="fa_skatt">FA-skatt</SelectItem>
<SelectItem value="not_verified">Ej verifierad</SelectItem>
<SelectItem value="not_verified">{t('tax_status_not_verified')}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -120,8 +122,8 @@ export default function EmployeeTaxCard({
disabled={disabled}
className="rounded border-border"
/>
<InfoTooltip content="Kryssa i om detta INTE är personens huvudarbetsgivare. Då dras en fast skatt på 30 % istället för enligt tabell.">
Sidoinkomst (30 % skatteavdrag)
<InfoTooltip content={t('tax_sidoinkomst_tooltip')}>
{t('tax_sidoinkomst_label')}
</InfoTooltip>
</label>
</div>
@@ -131,8 +133,8 @@ export default function EmployeeTaxCard({
<>
<div className="space-y-2">
<Label htmlFor="tax_municipality">
<InfoTooltip content="Kommunen där personen är folkbokförd (per 1 november föregående år). Den avgör skattetabellen: välj kommun så fylls tabellen i automatiskt.">
Folkbokföringskommun
<InfoTooltip content={t('tax_municipality_tooltip')}>
{t('tax_municipality_label')}
</InfoTooltip>
<RequiredMark />
</Label>
@@ -163,8 +165,8 @@ export default function EmployeeTaxCard({
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tax_table_number">
<InfoTooltip content="Skatteverkets tabell 29-42, baserad på kommunens totala skattesats. Härleds automatiskt från folkbokföringskommunen.">
Skattetabell
<InfoTooltip content={t('tax_table_tooltip')}>
{t('tax_table_label')}
</InfoTooltip>
<RequiredMark />
</Label>
@@ -191,7 +193,7 @@ export default function EmployeeTaxCard({
</div>
) : (
<p className="rounded-md border border-dashed border-input px-3 py-2 text-sm text-muted-foreground">
Välj kommun ovan
{t('tax_table_pick_municipality')}
</p>
)}
@@ -201,15 +203,15 @@ export default function EmployeeTaxCard({
onClick={() => setTableManual((v) => !v)}
className="text-xs text-primary hover:underline underline-offset-4"
>
{tableManual ? 'Använd kommunens tabell' : 'Ange tabell manuellt'}
{tableManual ? t('tax_table_use_municipality') : t('tax_table_enter_manually')}
</button>
)}
</div>
<div className="space-y-2">
<Label htmlFor="tax_column">
<InfoTooltip content="Kolumnen i skattetabellen avgör hur avdraget beräknas, främst utifrån ålder och inkomsttyp. Föreslås automatiskt från personnumret.">
Kolumn
<InfoTooltip content={t('tax_column_tooltip')}>
{t('tax_column_label')}
</InfoTooltip>
</Label>
<Select
@@ -233,11 +235,11 @@ export default function EmployeeTaxCard({
</Select>
{!columnTouched && derivedColumn != null ? (
<p className="text-xs text-muted-foreground">
Föreslås automatiskt: anställd under 66 år.
{t('tax_column_suggested_under_66')}
</p>
) : isSenior && !columnTouched ? (
<p className="text-xs text-warning-foreground">
Personen har fyllt 66 år: välj kolumn manuellt (lön = kolumn 3, pension = kolumn 2).
{t('tax_column_senior_warning')}
</p>
) : null}
</div>
@@ -246,8 +248,8 @@ export default function EmployeeTaxCard({
) : (
<p className="rounded-md border border-dashed border-input px-3 py-3 text-sm text-muted-foreground">
{sido
? 'Sidoinkomst: ett fast skatteavdrag på 30 % görs: ingen skattetabell behövs.'
: 'Med F-skatt eller FA-skatt sköter personen sin egen skatt: inget skatteavdrag görs och ingen skattetabell behövs.'}
? t('tax_no_table_sidoinkomst')
: t('tax_no_table_f_skatt')}
</p>
)}
</CardContent>
+5 -3
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input'
export interface KommunRate {
@@ -38,6 +39,7 @@ export default function MunicipalityCombobox({
id,
className,
}: MunicipalityComboboxProps) {
const t = useTranslations('salary_employee')
const [search, setSearch] = useState(value)
const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(0)
@@ -154,7 +156,7 @@ export default function MunicipalityCombobox({
onChange={handleInputChange}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder="Sök kommun…"
placeholder={t('kommun_search_placeholder')}
autoComplete="off"
disabled={disabled}
className={className}
@@ -183,7 +185,7 @@ export default function MunicipalityCombobox({
>
<span className="min-w-0 break-words">{k.kommun}</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
Tabell {k.tableNumber}
{t('kommun_table_number', { number: k.tableNumber })}
</span>
</button>
)
@@ -193,7 +195,7 @@ export default function MunicipalityCombobox({
{loadFailed && (
<p className="mt-1 text-xs text-muted-foreground">
Kunde inte hämta kommunlistan: skriv kommunnamnet och ange skattetabellen manuellt.
{t('kommun_load_failed')}
</p>
)}
</div>
+69 -42
View File
@@ -1,10 +1,12 @@
'use client'
import Link from 'next/link'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Download, Loader2, CheckCircle2, ChevronDown, Info } from 'lucide-react'
import { AlertTriangle, Download, Loader2, CheckCircle2, ChevronDown, Info } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -16,32 +18,26 @@ interface PaymentFilePanelProps {
paymentFileFormat: string | null
paymentFileGeneratedAt: string | null
defaultFormat: PaymentFormat
/** company_settings.salary_default_bank: sorts and auto-expands the matching bank's instructions. */
defaultBank?: string | null
readOnly?: boolean
onDownloaded?: () => void
}
const FORMAT_LABEL: Record<PaymentFormat, string> = {
bg_lb: 'Bankgirot LB-fil',
pain001: 'SEPA pain.001 (XML)',
type BankKey = 'swedbank' | 'seb' | 'handelsbanken' | 'nordea'
const BANK_NAME: Record<BankKey, string> = {
swedbank: 'Swedbank',
seb: 'SEB',
handelsbanken: 'Handelsbanken',
nordea: 'Nordea',
}
const FORMAT_DESCRIPTION: Record<PaymentFormat, string> = {
bg_lb: 'Standard för Swedbank, SEB, Handelsbanken, Nordea m.fl. Kräver bankgironummer hos Bankgirot.',
pain001: 'ISO 20022. För banker som inte är anslutna till Bankgirot, eller internationell SEPA.',
}
const BANK_INSTRUCTIONS: Record<PaymentFormat, Array<{ bank: string; steps: string }>> = {
bg_lb: [
{ bank: 'Swedbank Företag', steps: 'Företagsbetalningar → Importera fil → välj LB-format → ladda upp och signera med BankID.' },
{ bank: 'SEB Företag', steps: 'Betalningar → Filöverföring → välj Bankgiro LB → ladda upp och attestera.' },
{ bank: 'Handelsbanken', steps: 'Betala → Filimport → välj LB → kontrollera summor → signera.' },
{ bank: 'Nordea Företag', steps: 'Filimport → Bankgiro LB → ladda upp → attestera betalningen.' },
],
pain001: [
{ bank: 'SEB', steps: 'Betalningar → Importera SEPA / ISO 20022 (pain.001) → ladda upp och signera.' },
{ bank: 'Handelsbanken', steps: 'Betala → Filimport → ISO 20022 → välj pain.001.' },
{ bank: 'Nordea', steps: 'Filimport → Format pain.001.001.03 → ladda upp → attestera.' },
],
// Instruction copy lives in messages/{sv,en}.json under
// salary_payments.steps_<format>_<bank>; this is the ordered key list.
const BANKS_BY_FORMAT: Record<PaymentFormat, BankKey[]> = {
bg_lb: ['swedbank', 'seb', 'handelsbanken', 'nordea'],
pain001: ['swedbank', 'seb', 'handelsbanken', 'nordea'],
}
export function PaymentFilePanel({
@@ -50,13 +46,26 @@ export function PaymentFilePanel({
paymentFileFormat,
paymentFileGeneratedAt,
defaultFormat,
defaultBank,
readOnly,
onDownloaded,
}: PaymentFilePanelProps) {
const t = useTranslations('salary_payments')
const { toast } = useToast()
const [format, setFormat] = useState<PaymentFormat>(defaultFormat)
const [downloading, setDownloading] = useState(false)
const [showInstructions, setShowInstructions] = useState(false)
const banks = BANKS_BY_FORMAT[format]
const matchedBank = banks.find((b) => b === defaultBank) ?? null
const sortedBanks = matchedBank
? [matchedBank, ...banks.filter((b) => b !== matchedBank)]
: banks
const [showInstructions, setShowInstructions] = useState(Boolean(matchedBank))
const FORMAT_LABEL: Record<PaymentFormat, string> = {
bg_lb: t('format_bg_lb'),
pain001: t('format_pain001'),
}
const endpoint =
format === 'bg_lb'
@@ -68,9 +77,9 @@ export function PaymentFilePanel({
try {
const res = await fetch(endpoint)
if (!res.ok) {
const result = await res.json().catch(() => ({ error: 'Kunde inte generera betalfil' }))
const result = await res.json().catch(() => ({ error: t('download_failed_fallback') }))
toast({
title: 'Betalfil kunde inte genereras',
title: t('download_failed_title'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
@@ -86,7 +95,7 @@ export function PaymentFilePanel({
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast({ title: 'Betalfil nedladdad' })
toast({ title: t('downloaded') })
onDownloaded?.()
} finally {
setDownloading(false)
@@ -96,14 +105,14 @@ export function PaymentFilePanel({
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Betalfil till bank</CardTitle>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{paymentFileFormat && paymentFileGeneratedAt && (
<div className="flex items-start gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-success" />
<div>
Senast genererad:{' '}
{t('last_generated')}{' '}
<span className="text-foreground">
{FORMAT_LABEL[paymentFileFormat as PaymentFormat] ?? paymentFileFormat}
</span>{' '}
@@ -115,19 +124,36 @@ export function PaymentFilePanel({
{!readOnly && (
<>
<div className="space-y-1">
<label className="text-sm font-medium">Format</label>
<label className="text-sm font-medium">{t('format_label')}</label>
<Select value={format} onValueChange={(v) => setFormat(v as PaymentFormat)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bg_lb">{FORMAT_LABEL.bg_lb}</SelectItem>
<SelectItem value="pain001">{FORMAT_LABEL.pain001}</SelectItem>
<SelectItem value="bg_lb">{FORMAT_LABEL.bg_lb}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{FORMAT_DESCRIPTION[format]}</p>
<p className="text-xs text-muted-foreground">
{format === 'bg_lb' ? t('format_description_bg_lb') : t('format_description_pain001')}
</p>
</div>
{format === 'bg_lb' && (
<div className="flex items-start gap-2 rounded-md border border-border p-3 text-xs">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span className="text-muted-foreground">
{t('sunset_warning')}{' '}
<Link
href="/settings/salary"
className="underline underline-offset-2 hover:text-foreground"
>
{t('sunset_link')}
</Link>
</span>
</div>
)}
<div className="flex justify-end">
<Button onClick={handleDownload} disabled={downloading}>
{downloading ? (
@@ -135,7 +161,7 @@ export function PaymentFilePanel({
) : (
<Download className="mr-2 h-4 w-4" />
)}
Ladda ner betalfil
{t('download')}
</Button>
</div>
@@ -146,30 +172,31 @@ export function PaymentFilePanel({
aria-expanded={showInstructions}
>
<ChevronDown className={`h-3 w-3 transition-transform ${showInstructions ? 'rotate-180' : ''}`} />
Så importerar du filen i din bank
{t('instructions_toggle')}
</button>
{showInstructions && (
<div className="space-y-2 rounded-md border bg-muted/30 p-3 text-xs">
{BANK_INSTRUCTIONS[format].map(b => (
<div key={b.bank}>
<strong className="text-foreground">{b.bank}.</strong>{' '}
<span className="text-muted-foreground">{b.steps}</span>
{sortedBanks.map((bank) => (
<div key={bank}>
<strong className="text-foreground">
{BANK_NAME[bank]}
{bank === matchedBank ? ` (${t('your_bank')})` : ''}.
</strong>{' '}
<span className="text-muted-foreground">
{t(`steps_${format}_${bank}`)}
</span>
</div>
))}
<p className="pt-1 text-muted-foreground border-t mt-2">
Beloppen är förifyllda: kvar är att signera med BankID i banken.
{t('instructions_footer')}
</p>
</div>
)}
<div className="flex items-start gap-2 rounded-md border border-dashed bg-muted/20 p-3 text-xs text-muted-foreground">
<Info className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>
<strong className="text-foreground">Open Payments / direktbetalning</strong> via PSD2 (utan filimport)
är planerat för framtiden via Enable Banking. Initiering kräver separat PIS-avtal: vi följer upp när
tillräckligt många kunder använder lönebetalfiler regelbundet.
</span>
<span>{t('open_payments_note')}</span>
</div>
</>
)}
+92 -85
View File
@@ -11,7 +11,7 @@ import {
startOfMonth,
startOfWeek,
} from 'date-fns'
import { sv } from 'date-fns/locale'
import { enUS, sv } from 'date-fns/locale'
import {
Activity,
Baby,
@@ -27,6 +27,7 @@ import {
X,
type LucideIcon,
} from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import {
Dialog,
@@ -68,21 +69,22 @@ interface WorkedDay {
}
interface AbsenceTypeMeta {
label: string
shortLabel: string
/** Translation keys in the `salary_calendar` namespace. */
labelKey: string
shortLabelKey: string
icon: LucideIcon
pillClass: string
}
const TYPE_META: Record<AbsenceType, AbsenceTypeMeta> = {
sick: { label: 'Sjukfrånvaro', shortLabel: 'Sjuk', icon: HeartPulse, pillClass: 'bg-red-100 text-red-800' },
vab: { label: 'VAB', shortLabel: 'VAB', icon: Baby, pillClass: 'bg-amber-100 text-amber-800' },
parental: { label: 'Föräldraledighet', shortLabel: 'Förä.', icon: Heart, pillClass: 'bg-emerald-100 text-emerald-800' },
pregnancy: { label: 'Graviditetspenning', shortLabel: 'Grav.', icon: Heart, pillClass: 'bg-pink-100 text-pink-800' },
care_relative: { label: 'Närståendepenning', shortLabel: 'Närst.', icon: Heart, pillClass: 'bg-blue-100 text-blue-800' },
study: { label: 'Studieledig', shortLabel: 'Studie', icon: Activity, pillClass: 'bg-indigo-100 text-indigo-800' },
unpaid_leave: { label: 'Tjänstledig utan lön', shortLabel: 'Tjänstl.', icon: MinusCircle, pillClass: 'bg-slate-100 text-slate-800' },
other_leave: { label: 'Övrig ledighet', shortLabel: 'Övrigt', icon: Activity, pillClass: 'bg-zinc-100 text-zinc-800' },
sick: { labelKey: 'type_sick', shortLabelKey: 'type_sick_short', icon: HeartPulse, pillClass: 'bg-red-100 text-red-800' },
vab: { labelKey: 'type_vab', shortLabelKey: 'type_vab_short', icon: Baby, pillClass: 'bg-amber-100 text-amber-800' },
parental: { labelKey: 'type_parental', shortLabelKey: 'type_parental_short', icon: Heart, pillClass: 'bg-emerald-100 text-emerald-800' },
pregnancy: { labelKey: 'type_pregnancy', shortLabelKey: 'type_pregnancy_short', icon: Heart, pillClass: 'bg-pink-100 text-pink-800' },
care_relative: { labelKey: 'type_care_relative', shortLabelKey: 'type_care_relative_short', icon: Heart, pillClass: 'bg-blue-100 text-blue-800' },
study: { labelKey: 'type_study', shortLabelKey: 'type_study_short', icon: Activity, pillClass: 'bg-indigo-100 text-indigo-800' },
unpaid_leave: { labelKey: 'type_unpaid_leave', shortLabelKey: 'type_unpaid_leave_short', icon: MinusCircle, pillClass: 'bg-slate-100 text-slate-800' },
other_leave: { labelKey: 'type_other_leave', shortLabelKey: 'type_other_leave_short', icon: Activity, pillClass: 'bg-zinc-100 text-zinc-800' },
}
const TYPE_ORDER: AbsenceType[] = ['sick', 'vab', 'parental', 'pregnancy', 'care_relative', 'study', 'unpaid_leave', 'other_leave']
@@ -119,6 +121,9 @@ export function SalaryCalendar({
onChange,
onAbsenceCountsChange,
}: SalaryCalendarProps) {
const t = useTranslations('salary_calendar')
const locale = useLocale()
const dateLocale = locale === 'en' ? enUS : sv
const isHourly = salaryType === 'hourly'
const periodStartDate = useMemo(() => parseISO(periodStart), [periodStart])
const periodEndDate = useMemo(() => parseISO(periodEnd), [periodEnd])
@@ -151,15 +156,15 @@ export function SalaryCalendar({
}
const responses = await Promise.all(requests)
const absJson = await responses[0]!.json()
if (!responses[0]!.ok) throw new Error(absJson.error || 'Kunde inte ladda frånvaro')
if (!responses[0]!.ok) throw new Error(absJson.error || t('error_load_absence'))
setAbsences(absJson.data ?? [])
if (isHourly && responses[1]) {
const wJson = await responses[1].json()
if (!responses[1].ok) throw new Error(wJson.error || 'Kunde inte ladda arbetade timmar')
if (!responses[1].ok) throw new Error(wJson.error || t('error_load_worked'))
setWorked(wJson.data ?? [])
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel')
setError(e instanceof Error ? e.message : t('unknown_error'))
} finally {
setLoading(false)
}
@@ -272,9 +277,7 @@ export function SalaryCalendar({
const handleBulkDelete = async () => {
if (selected.size === 0 || readOnly) return
if (!confirm(
`Ta bort allt (arbetad tid och frånvaro) på ${selected.size} ${selected.size === 1 ? 'dag' : 'dagar'}?`,
)) return
if (!confirm(t('confirm_bulk_delete', { count: selected.size }))) return
setDeleting(true)
setError(null)
try {
@@ -288,7 +291,7 @@ export function SalaryCalendar({
)
if (!wRes.ok) {
const j = await wRes.json().catch(() => ({}))
throw new Error(j.error || `Kunde inte ta bort arbetad tid på ${date}`)
throw new Error(j.error || t('error_delete_worked_date', { date }))
}
}
const aRes = await fetch(
@@ -297,14 +300,14 @@ export function SalaryCalendar({
)
if (!aRes.ok) {
const j = await aRes.json().catch(() => ({}))
throw new Error(j.error || `Kunde inte ta bort frånvaro på ${date}`)
throw new Error(j.error || t('error_delete_absence_date', { date }))
}
}
clearSelection()
await load()
onChange?.()
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel')
setError(e instanceof Error ? e.message : t('unknown_error'))
} finally {
setDeleting(false)
}
@@ -319,18 +322,18 @@ export function SalaryCalendar({
variant="ghost"
size="sm"
onClick={() => setVisibleMonth(prev => addDays(startOfMonth(prev), -1))}
aria-label="Föregående månad"
aria-label={t('prev_month')}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-medium tabular-nums">
{format(visibleMonth, 'MMMM yyyy', { locale: sv })}
{format(visibleMonth, 'MMMM yyyy', { locale: dateLocale })}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setVisibleMonth(prev => addDays(endOfMonth(prev), 1))}
aria-label="Nästa månad"
aria-label={t('next_month')}
>
<ChevronRight className="h-4 w-4" />
</Button>
@@ -342,18 +345,18 @@ export function SalaryCalendar({
size="sm"
onClick={handleFillWeekdays}
className="text-xs"
title="Markera alla vardagar i perioden som inte redan har arbetad tid"
title={t('fill_weekdays_title')}
>
<CalendarPlus className="mr-1 h-3.5 w-3.5" />
Fyll vardagar
{t('fill_weekdays')}
</Button>
)}
</div>
{/* Weekday header */}
<div className="grid grid-cols-7 border-b bg-muted/40 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{['Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör', 'Sön'].map(d => (
<div key={d} className="px-2 py-1.5 text-center">{d}</div>
{(['wd_mon', 'wd_tue', 'wd_wed', 'wd_thu', 'wd_fri', 'wd_sat', 'wd_sun'] as const).map(d => (
<div key={d} className="px-2 py-1.5 text-center">{t(d)}</div>
))}
</div>
@@ -387,7 +390,7 @@ export function SalaryCalendar({
today && 'ring-1 ring-inset ring-primary/40',
isSelected && 'ring-2 ring-inset ring-primary bg-primary/5',
)}
title={hasContent ? 'Dubbelklicka för detaljer' : undefined}
title={hasContent ? t('cell_details_title') : undefined}
>
<span className={cn('tabular-nums', today && 'font-semibold')}>
{format(date, 'd')}
@@ -411,10 +414,10 @@ export function SalaryCalendar({
'inline-flex items-center gap-0.5 rounded-full px-1 py-px text-[10px] font-medium',
meta.pillClass,
)}
title={`${meta.label} (${a.hours}h)`}
title={t('pill_title', { label: t(meta.labelKey), hours: String(a.hours) })}
>
<Icon className="h-2.5 w-2.5" aria-hidden />
<span>{meta.shortLabel}</span>
<span>{t(meta.shortLabelKey)}</span>
</span>
)
})}
@@ -431,12 +434,12 @@ export function SalaryCalendar({
<span>
{isHourly && (
<>
Arbetade timmar i perioden:{' '}
{t('worked_hours_in_period')}{' '}
<span className="tabular-nums font-medium text-foreground">{periodTotalHours} h</span>
{' · '}
</>
)}
Klicka för att markera, shift-klicka för intervall, dubbelklicka för detaljer.
{t('hint_click')}
</span>
</div>
@@ -447,18 +450,18 @@ export function SalaryCalendar({
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-emerald-100">
<Clock className="h-2 w-2 text-emerald-800" aria-hidden />
</span>
<span>Arbetad tid</span>
<span>{t('worked_time')}</span>
</span>
)}
{TYPE_ORDER.map(t => {
const meta = TYPE_META[t]
{TYPE_ORDER.map(type => {
const meta = TYPE_META[type]
const Icon = meta.icon
return (
<span key={t} className="inline-flex items-center gap-1">
<span key={type} className="inline-flex items-center gap-1">
<span className={cn('inline-flex h-3 w-3 items-center justify-center rounded-full', meta.pillClass)}>
<Icon className="h-2 w-2" aria-hidden />
</span>
<span>{meta.label}</span>
<span>{t(meta.labelKey)}</span>
</span>
)
})}
@@ -475,12 +478,12 @@ export function SalaryCalendar({
<div className="flex flex-wrap items-center justify-between gap-2 border-t bg-muted/40 px-3 py-2">
<div className="text-xs">
<span className="font-medium tabular-nums">{selected.size}</span>{' '}
{selected.size === 1 ? 'dag' : 'dagar'} valda
{t('days_selected', { count: selected.size })}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="ghost" size="sm" onClick={clearSelection} disabled={deleting}>
<X className="mr-1 h-3.5 w-3.5" />
Rensa val
{t('clear_selection')}
</Button>
<Button
variant="outline"
@@ -493,14 +496,14 @@ export function SalaryCalendar({
) : (
<Trash2 className="mr-1 h-3.5 w-3.5" />
)}
Ta bort
{t('delete')}
</Button>
<Button variant="outline" size="sm" onClick={() => setBulkMode('absence')} disabled={deleting}>
Frånvaro…
{t('absence_action')}
</Button>
{isHourly && (
<Button size="sm" onClick={() => setBulkMode('worked')} disabled={deleting}>
Arbetade timmar…
{t('worked_hours_action')}
</Button>
)}
</div>
@@ -577,6 +580,7 @@ function BulkWorkedDialog({
onClose,
onSaved,
}: BulkWorkedDialogProps) {
const t = useTranslations('salary_calendar')
const [hours, setHours] = useState<string>('8')
const [notes, setNotes] = useState<string>('')
const [submitting, setSubmitting] = useState(false)
@@ -592,7 +596,7 @@ function BulkWorkedDialog({
setConflicts([])
try {
if (!isFinite(hoursNum) || hoursNum < 0 || hoursNum > 24) {
throw new Error('Timmar måste vara mellan 0 och 24')
throw new Error(t('error_hours_range'))
}
// 0 hours = "no worked time on these days" → delete any existing rows.
// Avoids tripping the DB CHECK (hours > 0) and matches user intent.
@@ -604,7 +608,7 @@ function BulkWorkedDialog({
)
if (!res.ok) {
const j = await res.json().catch(() => ({}))
throw new Error(j.error || `Kunde inte ta bort ${date}`)
throw new Error(j.error || t('error_delete_date', { date }))
}
}
onSaved([])
@@ -625,10 +629,10 @@ function BulkWorkedDialog({
setConflicts(json.data?.conflicts ?? [])
return
}
if (!res.ok) throw new Error(json.error || 'Kunde inte spara timmar')
if (!res.ok) throw new Error(json.error || t('error_save_hours'))
onSaved([])
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel')
setError(e instanceof Error ? e.message : t('unknown_error'))
} finally {
setSubmitting(false)
}
@@ -638,17 +642,17 @@ function BulkWorkedDialog({
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Arbetade timmar</DialogTitle>
<DialogTitle>{t('bulk_worked_title')}</DialogTitle>
<DialogDescription>
{isClear
? `Arbetad tid tas bort för ${dates.length} ${dates.length === 1 ? 'dag' : 'dagar'}.`
: `${dates.length} ${dates.length === 1 ? 'dag' : 'dagar'} markeras som arbetade. Befintliga timmar för dessa datum skrivs över.`}
? t('bulk_worked_clear_desc', { count: dates.length })
: t('bulk_worked_desc', { count: dates.length })}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-xs font-medium" htmlFor="bulk-w-hours">Timmar per dag</label>
<label className="text-xs font-medium" htmlFor="bulk-w-hours">{t('hours_per_day')}</label>
<input
id="bulk-w-hours"
type="number"
@@ -661,12 +665,12 @@ function BulkWorkedDialog({
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm tabular-nums shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<p className="text-[11px] text-muted-foreground">
Sätt till 0 för att ta bort arbetad tid på de valda dagarna.
{t('hours_zero_hint')}
</p>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium" htmlFor="bulk-w-notes">Anteckning (valfri)</label>
<label className="text-xs font-medium" htmlFor="bulk-w-notes">{t('notes_label')}</label>
<textarea
id="bulk-w-notes"
value={notes}
@@ -680,7 +684,7 @@ function BulkWorkedDialog({
{conflicts.length > 0 && (
<div className="space-y-1 rounded-md border border-amber-200 bg-amber-50 p-2 text-xs">
<div className="font-medium text-amber-900">
{conflicts.length} {conflicts.length === 1 ? 'dag utelämnades' : 'dagar utelämnades'}: kombinationen med befintlig frånvaro hade överstigit 24 timmar.
{t('conflicts_worked', { count: conflicts.length })}
</div>
<ul className="list-disc space-y-0.5 pl-4 text-amber-800 tabular-nums">
{conflicts.map(c => <li key={c.date}>{c.date}</li>)}
@@ -694,13 +698,13 @@ function BulkWorkedDialog({
</div>
<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>Stäng</Button>
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>{t('close')}</Button>
{conflicts.length > 0 ? (
<Button size="sm" onClick={() => onSaved(conflicts)}>OK</Button>
<Button size="sm" onClick={() => onSaved(conflicts)}>{t('ok')}</Button>
) : (
<Button size="sm" onClick={handleSave} disabled={submitting}>
{submitting && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
{isClear ? 'Ta bort' : 'Spara'}
{isClear ? t('delete') : t('save')}
</Button>
)}
</DialogFooter>
@@ -726,6 +730,7 @@ function BulkAbsenceDialog({
onClose,
onSaved,
}: BulkAbsenceDialogProps) {
const t = useTranslations('salary_calendar')
const [absenceType, setAbsenceType] = useState<AbsenceType>('sick')
const [hours, setHours] = useState<string>('8')
const [notes, setNotes] = useState<string>('')
@@ -740,7 +745,7 @@ function BulkAbsenceDialog({
try {
const hoursNum = parseFloat(hours)
if (!isFinite(hoursNum) || hoursNum <= 0 || hoursNum > 24) {
throw new Error('Timmar måste vara mellan 0 och 24')
throw new Error(t('error_hours_range'))
}
// No batch endpoint for absence: call POST per date so we can isolate
// 24h-cap conflicts. Pay-period sized loops are fine.
@@ -764,7 +769,7 @@ function BulkAbsenceDialog({
}
if (!res.ok) {
const j = await res.json().catch(() => ({}))
throw new Error(j.error || `Kunde inte spara frånvaro på ${date}`)
throw new Error(j.error || t('error_save_absence_date', { date }))
}
}
if (localConflicts.length > 0) {
@@ -773,7 +778,7 @@ function BulkAbsenceDialog({
}
onSaved([])
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel')
setError(e instanceof Error ? e.message : t('unknown_error'))
} finally {
setSubmitting(false)
}
@@ -783,28 +788,27 @@ function BulkAbsenceDialog({
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Frånvaro</DialogTitle>
<DialogTitle>{t('bulk_absence_title')}</DialogTitle>
<DialogDescription>
{dates.length} {dates.length === 1 ? 'dag' : 'dagar'} markeras som frånvaro.
Sjuklöneberäkning, karensavdrag och AGI-rapportering härleds automatiskt.
{t('bulk_absence_desc', { count: dates.length })}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-xs font-medium">Typ</label>
<label className="text-xs font-medium">{t('type_label')}</label>
<Select value={absenceType} onValueChange={v => setAbsenceType(v as AbsenceType)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{TYPE_ORDER.map(t => (
<SelectItem key={t} value={t}>{TYPE_META[t].label}</SelectItem>
{TYPE_ORDER.map(type => (
<SelectItem key={type} value={type}>{t(TYPE_META[type].labelKey)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium" htmlFor="bulk-a-hours">Timmar per dag</label>
<label className="text-xs font-medium" htmlFor="bulk-a-hours">{t('hours_per_day')}</label>
<input
id="bulk-a-hours"
type="number"
@@ -818,7 +822,7 @@ function BulkAbsenceDialog({
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium" htmlFor="bulk-a-notes">Anteckning (valfri)</label>
<label className="text-xs font-medium" htmlFor="bulk-a-notes">{t('notes_label')}</label>
<textarea
id="bulk-a-notes"
value={notes}
@@ -832,7 +836,7 @@ function BulkAbsenceDialog({
{conflicts.length > 0 && (
<div className="space-y-1 rounded-md border border-amber-200 bg-amber-50 p-2 text-xs">
<div className="font-medium text-amber-900">
{conflicts.length} {conflicts.length === 1 ? 'dag utelämnades' : 'dagar utelämnades'}: kombinationen med befintlig arbetad tid hade överstigit 24 timmar.
{t('conflicts_absence', { count: conflicts.length })}
</div>
<ul className="list-disc space-y-0.5 pl-4 text-amber-800 tabular-nums">
{conflicts.map(c => <li key={c.date}>{c.date}</li>)}
@@ -846,13 +850,13 @@ function BulkAbsenceDialog({
</div>
<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>Stäng</Button>
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>{t('close')}</Button>
{conflicts.length > 0 ? (
<Button size="sm" onClick={() => onSaved(conflicts)}>OK</Button>
<Button size="sm" onClick={() => onSaved(conflicts)}>{t('ok')}</Button>
) : (
<Button size="sm" onClick={handleSave} disabled={submitting}>
{submitting && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
Spara
{t('save')}
</Button>
)}
</DialogFooter>
@@ -882,6 +886,9 @@ function DayInspectorDialog({
onClose,
onChanged,
}: DayInspectorDialogProps) {
const t = useTranslations('salary_calendar')
const locale = useLocale()
const dateLocale = locale === 'en' ? enUS : sv
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState<string | null>(null)
@@ -893,12 +900,12 @@ function DayInspectorDialog({
const res = await fetch(`/api/salary/employees/${employeeId}/worked-hours?date=${date}`, { method: 'DELETE' })
if (!res.ok) {
const j = await res.json().catch(() => ({}))
throw new Error(j.error || 'Kunde inte ta bort arbetad tid')
throw new Error(j.error || t('error_delete_worked'))
}
onChanged()
onClose()
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel')
setError(e instanceof Error ? e.message : t('unknown_error'))
} finally {
setBusy(null)
}
@@ -914,13 +921,13 @@ function DayInspectorDialog({
)
if (!res.ok) {
const j = await res.json().catch(() => ({}))
throw new Error(j.error || 'Kunde inte ta bort frånvaro')
throw new Error(j.error || t('error_delete_absence'))
}
onChanged()
// Stay open if there's other content; close if this was the last entry.
if (absences.length === 1 && !worked) onClose()
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel')
setError(e instanceof Error ? e.message : t('unknown_error'))
} finally {
setBusy(null)
}
@@ -931,10 +938,10 @@ function DayInspectorDialog({
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{format(parseISO(date), 'd MMMM yyyy', { locale: sv })}
{format(parseISO(date), 'd MMMM yyyy', { locale: dateLocale })}
</DialogTitle>
<DialogDescription>
Översikt av arbetad tid och frånvaro på den här dagen.
{t('inspector_desc')}
</DialogDescription>
</DialogHeader>
@@ -944,8 +951,8 @@ function DayInspectorDialog({
<div className="flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-emerald-700" />
<div>
<div className="font-medium">Arbetad tid</div>
<div className="text-xs text-muted-foreground tabular-nums">{worked.hours} timmar</div>
<div className="font-medium">{t('worked_time')}</div>
<div className="text-xs text-muted-foreground tabular-nums">{t('hours_count', { hours: String(worked.hours) })}</div>
{worked.notes && <div className="text-xs text-muted-foreground italic">{worked.notes}</div>}
</div>
</div>
@@ -954,7 +961,7 @@ function DayInspectorDialog({
size="sm"
onClick={handleDeleteWorked}
disabled={busy !== null}
aria-label="Ta bort arbetad tid"
aria-label={t('delete_worked_aria')}
>
{busy === 'worked' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
</Button>
@@ -969,8 +976,8 @@ function DayInspectorDialog({
<div className="flex items-center gap-2 text-sm">
<Icon className="h-4 w-4" />
<div>
<div className="font-medium">{meta.label}</div>
<div className="text-xs opacity-80 tabular-nums">{a.hours} timmar</div>
<div className="font-medium">{t(meta.labelKey)}</div>
<div className="text-xs opacity-80 tabular-nums">{t('hours_count', { hours: String(a.hours) })}</div>
{a.notes && <div className="text-xs opacity-70 italic">{a.notes}</div>}
</div>
</div>
@@ -979,7 +986,7 @@ function DayInspectorDialog({
size="sm"
onClick={() => handleDeleteAbsence(a)}
disabled={busy !== null}
aria-label="Ta bort frånvaro"
aria-label={t('delete_absence_aria')}
>
{busy === a.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
</Button>
@@ -989,7 +996,7 @@ function DayInspectorDialog({
{!worked && absences.length === 0 && (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
Inga registreringar på den här dagen.
{t('no_entries')}
</p>
)}
@@ -999,7 +1006,7 @@ function DayInspectorDialog({
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose}>Stäng</Button>
<Button variant="outline" size="sm" onClick={onClose}>{t('close')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+24 -24
View File
@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -33,6 +34,7 @@ function num(v: string): number | null {
}
export function SalaryOverridePanel(props: SalaryOverridePanelProps) {
const t = useTranslations('salary_override')
const { toast } = useToast()
const [expanded, setExpanded] = useState(
props.taxOverride !== null ||
@@ -71,18 +73,18 @@ export function SalaryOverridePanel(props: SalaryOverridePanelProps) {
const data = await res.json()
if (!res.ok) {
toast({
title: 'Kunde inte spara justering',
description: typeof data?.error === 'string' ? data.error : 'Okänt fel',
title: t('save_failed'),
description: typeof data?.error === 'string' ? data.error : t('unknown_error'),
variant: 'destructive',
})
return
}
toast({ title: 'Justering sparad' })
toast({ title: t('saved') })
props.onSaved()
} catch (err) {
toast({
title: 'Kunde inte spara justering',
description: err instanceof Error ? err.message : 'Okänt fel',
title: t('save_failed'),
description: err instanceof Error ? err.message : t('unknown_error'),
variant: 'destructive',
})
} finally {
@@ -106,8 +108,8 @@ export function SalaryOverridePanel(props: SalaryOverridePanelProps) {
if (!res.ok) {
const data = await res.json()
toast({
title: 'Kunde inte rensa justering',
description: typeof data?.error === 'string' ? data.error : 'Okänt fel',
title: t('clear_failed'),
description: typeof data?.error === 'string' ? data.error : t('unknown_error'),
variant: 'destructive',
})
return
@@ -116,7 +118,7 @@ export function SalaryOverridePanel(props: SalaryOverridePanelProps) {
setAvgStr('')
setBasisStr('')
setReason('')
toast({ title: 'Justering rensad' })
toast({ title: t('cleared') })
props.onSaved()
} finally {
setSaving(false)
@@ -127,8 +129,8 @@ export function SalaryOverridePanel(props: SalaryOverridePanelProps) {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2">
<CardTitle className="text-base">Avancerat läge</CardTitle>
{hasOverride && <Badge variant="warning">Justerat</Badge>}
<CardTitle className="text-base">{t('title')}</CardTitle>
{hasOverride && <Badge variant="warning">{t('adjusted_badge')}</Badge>}
</div>
<Button
variant="outline"
@@ -137,21 +139,19 @@ export function SalaryOverridePanel(props: SalaryOverridePanelProps) {
disabled={props.disabled}
>
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
{expanded ? 'Dölj' : 'Visa'}
{expanded ? t('hide') : t('show')}
</Button>
</CardHeader>
{expanded && (
<CardContent className="space-y-4">
<p className="text-xs text-muted-foreground">
Justera skatteavdrag eller arbetsgivaravgift för den här anställde, t.ex. för FoU-avdrag eller
jämkning. Justerade värden används vid bokföring och AGI-rapportering. Endast tillåtet i
granskningsläge.
{t('description')}
</p>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor="tax_override" className="text-xs">
Skatteavdrag (kr)
{t('tax_label')}
</Label>
<Input
id="tax_override"
@@ -163,13 +163,13 @@ export function SalaryOverridePanel(props: SalaryOverridePanelProps) {
className="tabular-nums"
/>
<p className="text-[11px] text-muted-foreground">
Beräknat: <span className="tabular-nums">{formatCurrency(props.taxWithheld)}</span>
{t('calculated')} <span className="tabular-nums">{formatCurrency(props.taxWithheld)}</span>
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="avgifter_override" className="text-xs">
Arbetsgivaravgifter (kr)
{t('avgifter_label')}
</Label>
<Input
id="avgifter_override"
@@ -181,13 +181,13 @@ export function SalaryOverridePanel(props: SalaryOverridePanelProps) {
className="tabular-nums"
/>
<p className="text-[11px] text-muted-foreground">
Beräknat: <span className="tabular-nums">{formatCurrency(props.avgifterAmount)}</span>
{t('calculated')} <span className="tabular-nums">{formatCurrency(props.avgifterAmount)}</span>
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="avgifter_basis_override" className="text-xs">
Avgiftsunderlag (kr)
{t('basis_label')}
</Label>
<Input
id="avgifter_basis_override"
@@ -199,19 +199,19 @@ export function SalaryOverridePanel(props: SalaryOverridePanelProps) {
className="tabular-nums"
/>
<p className="text-[11px] text-muted-foreground">
Beräknat: <span className="tabular-nums">{formatCurrency(props.avgifterBasis)}</span>
{t('calculated')} <span className="tabular-nums">{formatCurrency(props.avgifterBasis)}</span>
</p>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="override_reason" className="text-xs">
Anledning (krävs vid justering)
{t('reason_label')}
</Label>
<Textarea
id="override_reason"
rows={2}
placeholder="T.ex. FoU-avdrag 10 % på arbetsgivaravgifter, jämkning enligt beslut från Skatteverket"
placeholder={t('reason_placeholder')}
value={reason}
onChange={(e) => setReason(e.target.value)}
disabled={props.disabled || saving}
@@ -221,7 +221,7 @@ export function SalaryOverridePanel(props: SalaryOverridePanelProps) {
<div className="flex flex-wrap gap-2">
<Button onClick={handleSave} disabled={props.disabled || saving}>
{saving && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
Spara justering
{t('save')}
</Button>
{hasOverride && (
<Button
@@ -229,7 +229,7 @@ export function SalaryOverridePanel(props: SalaryOverridePanelProps) {
onClick={handleClear}
disabled={props.disabled || saving}
>
Rensa justering
{t('clear')}
</Button>
)}
</div>
+22 -21
View File
@@ -1,6 +1,7 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Download, Loader2, CheckCircle2, ExternalLink } from 'lucide-react'
@@ -33,6 +34,7 @@ export function TaxPaymentPanel({
readOnly,
onChange,
}: TaxPaymentPanelProps) {
const t = useTranslations('salary_payments')
const { toast } = useToast()
const [downloading, setDownloading] = useState(false)
const [marking, setMarking] = useState(false)
@@ -55,9 +57,9 @@ export function TaxPaymentPanel({
try {
const res = await fetch(`/api/skatteverket/tax-payments/${period}/payment-file`)
if (!res.ok) {
const result = await res.json().catch(() => ({ error: 'Kunde inte generera betalfil' }))
const result = await res.json().catch(() => ({ error: t('tax_download_failed_fallback') }))
toast({
title: 'Betalfil kunde inte genereras',
title: t('tax_download_failed_title'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
@@ -72,12 +74,12 @@ export function TaxPaymentPanel({
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast({ title: 'Betalfil för skatt nedladdad' })
toast({ title: t('tax_downloaded') })
onChange?.()
} finally {
setDownloading(false)
}
}, [period, toast, onChange])
}, [period, toast, onChange, t])
const handleMarkPaid = useCallback(async () => {
setMarking(true)
@@ -86,54 +88,53 @@ export function TaxPaymentPanel({
method: 'POST',
})
if (!res.ok) {
const result = await res.json().catch(() => ({ error: 'Kunde inte markera som betald' }))
const result = await res.json().catch(() => ({ error: t('tax_mark_paid_failed_fallback') }))
toast({
title: 'Kunde inte markera som betald',
title: t('tax_mark_paid_failed_title'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
return
}
toast({ title: 'Markerad som betald' })
toast({ title: t('tax_marked_paid') })
onChange?.()
} finally {
setMarking(false)
}
}, [period, toast, onChange])
}, [period, toast, onChange, t])
if (totalAmount <= 0) return null
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Inbetalning till Skattekontot</CardTitle>
<CardTitle className="text-base">{t('tax_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 text-sm">
<div>
<p className="text-xs text-muted-foreground">Skatt</p>
<p className="text-xs text-muted-foreground">{t('tax_label_tax')}</p>
<p className="font-semibold tabular-nums">{formatCurrency(totalTax)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Arbetsgivaravgifter</p>
<p className="text-xs text-muted-foreground">{t('tax_label_avgifter')}</p>
<p className="font-semibold tabular-nums">{formatCurrency(totalAvgifter)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Totalt att betala</p>
<p className="text-xs text-muted-foreground">{t('tax_label_total')}</p>
<p className="font-semibold tabular-nums">{formatCurrency(totalAmount)}</p>
</div>
</div>
<div className="text-sm text-muted-foreground space-y-1">
<p>
Mottagare: <span className="text-foreground">Skatteverket Bankgiro 5050-1055</span>
{t('tax_recipient')} <span className="text-foreground">{t('tax_recipient_value')}</span>
</p>
<p>
Förfallodag: <span className="text-foreground tabular-nums">{paymentDeadline}</span>
{t('tax_due_date')} <span className="text-foreground tabular-nums">{paymentDeadline}</span>
</p>
<p className="text-xs">
Betalfil använder ditt Skattekonto-OCR (org-nummer + Luhn-kontrollsiffra). Skatteverket
applicerar betalningen på det belopp du deklarerat i AGI för perioden.
{t('tax_ocr_note')}
</p>
</div>
@@ -141,7 +142,7 @@ export function TaxPaymentPanel({
<div className="flex items-start gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-success" />
<div>
Betalfil senast genererad{' '}
{t('tax_file_generated')}{' '}
<span className="text-foreground">
{new Date(paymentFileGeneratedAt).toLocaleString('sv-SE')}
</span>
@@ -153,7 +154,7 @@ export function TaxPaymentPanel({
<div className="flex items-start gap-2 text-sm text-success">
<CheckCircle2 className="h-4 w-4 mt-0.5" />
<div>
Markerad som betald{' '}
{t('tax_marked_paid')}{' '}
<span className="font-medium">{new Date(taxPaidAt).toLocaleString('sv-SE')}</span>
</div>
</div>
@@ -168,7 +169,7 @@ export function TaxPaymentPanel({
rel="noopener noreferrer"
>
<ExternalLink className="mr-2 h-4 w-4" />
Skattekontot
{t('tax_skattekonto_button')}
</a>
</Button>
<Button onClick={handleDownload} disabled={downloading || marking}>
@@ -177,7 +178,7 @@ export function TaxPaymentPanel({
) : (
<Download className="mr-2 h-4 w-4" />
)}
Ladda ner betalfil
{t('tax_download_button')}
</Button>
{!taxPaidAt && (
<Button
@@ -186,7 +187,7 @@ export function TaxPaymentPanel({
disabled={downloading || marking}
>
{marking ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
Markera som betald
{t('tax_mark_paid_button')}
</Button>
)}
</div>
+7 -5
View File
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Globe, CheckCircle2, AlertTriangle, RefreshCcw, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
@@ -19,6 +20,7 @@ interface Props {
}
export function TaxTableStatus({ year, compact = false }: Props) {
const t = useTranslations('salary_tax_tables')
const [status, setStatus] = useState<Status | null>(null)
const [loading, setLoading] = useState(true)
@@ -43,7 +45,7 @@ export function TaxTableStatus({ year, compact = false }: Props) {
return (
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />
Kontrollerar skattetabeller…
{t('checking')}
</div>
)
}
@@ -58,10 +60,10 @@ export function TaxTableStatus({ year, compact = false }: Props) {
const label =
status.source === 'api'
? `Skattetabeller för ${status.year} hämtas live från Skatteverket`
? t('source_api', { year: status.year })
: status.source === 'fallback'
? `Skatteverkets API är inte nåbart: använder lokal reservdata för ${status.year}`
: `Skattetabeller för ${status.year} kunde inte hämtas`
? t('source_fallback', { year: status.year })
: t('source_unavailable', { year: status.year })
if (compact) {
return (
@@ -78,7 +80,7 @@ export function TaxTableStatus({ year, compact = false }: Props) {
<Icon className={`h-4 w-4 ${iconColor}`} />
<span>{label}</span>
</div>
<Button variant="ghost" size="sm" onClick={check} disabled={loading} aria-label="Kontrollera igen">
<Button variant="ghost" size="sm" onClick={check} disabled={loading} aria-label={t('recheck')}>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCcw className="h-3.5 w-3.5" />}
</Button>
</div>
@@ -0,0 +1,60 @@
'use client'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { TaxTableStatus } from '@/components/salary/TaxTableStatus'
import { formatCurrency } from '@/lib/utils'
import type { SalaryRunEmployee } from '@/types'
type SreWithEmployee = SalaryRunEmployee & {
employee?: { first_name: string; last_name: string }
}
interface RunCalculationDetailsProps {
periodYear: number
employees: SalaryRunEmployee[]
}
export function RunCalculationDetails({ periodYear, employees }: RunCalculationDetailsProps) {
const t = useTranslations('salary_run')
const withBreakdown = employees.filter(e => e.calculation_breakdown)
if (withBreakdown.length === 0) return null
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('calculation_details_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<TaxTableStatus year={periodYear} compact />
{withBreakdown.map(sre => {
const breakdown = sre.calculation_breakdown as {
steps?: Array<{ label: string; formula: string; output: number | null }>
}
const employee = (sre as SreWithEmployee).employee
return (
<div key={sre.id} className="space-y-2">
<h4 className="text-sm font-medium">
{employee
? `${employee.first_name} ${employee.last_name}`
: sre.employee_id.slice(0, 8)}
</h4>
<div className="text-xs space-y-1 bg-muted/50 rounded-lg p-3">
{(breakdown?.steps || []).map((step, i) => (
<div key={i} className="flex justify-between gap-4">
<span className="text-muted-foreground">
{step.label}: <span className="font-mono">{step.formula}</span>
</span>
{step.output !== null && (
<span className="font-medium tabular-nums">{formatCurrency(step.output)}</span>
)}
</div>
))}
</div>
</div>
)
})}
</CardContent>
</Card>
)
}
+236
View File
@@ -0,0 +1,236 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ChevronRight, FileDown, Loader2, Trash2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import type { Employee, SalaryRunEmployee } from '@/types'
import type { RunDetail } from './types'
type SreWithEmployee = SalaryRunEmployee & {
employee?: {
first_name: string
last_name: string
personnummer: string
default_dimensions?: Record<string, string>
}
}
interface RunEmployeesTableProps {
run: RunDetail
runId: string
employees: SalaryRunEmployee[]
availableEmployees: Employee[]
canWrite: boolean
actionLoading: string | null
dimensionsEnabled: boolean
isCalculated: boolean
onAddEmployee: (employeeId: string) => void
onRemoveEmployee: (employeeId: string, name: string) => void
onSalaryEdit: (employeeId: string, raw: string, previous: number) => void
}
export function RunEmployeesTable({
run,
runId,
employees,
availableEmployees,
canWrite,
actionLoading,
dimensionsEnabled,
isCalculated,
onAddEmployee,
onRemoveEmployee,
onSalaryEdit,
}: RunEmployeesTableProps) {
const t = useTranslations('salary_run')
const router = useRouter()
const [addEmployeeKey, setAddEmployeeKey] = useState(0)
const addedEmployeeIds = new Set(employees.map(e => e.employee_id))
const notAdded = availableEmployees.filter(e => !addedEmployeeIds.has(e.id))
const canRemoveEmployee = run.status === 'draft' && canWrite
const isDraft = run.status === 'draft'
// Δ vs the latest booked run — hidden on the first-ever run and before
// calculation (gross is 0 until then, the diff would be noise).
const previous = run.previous_run ?? null
const showDiff = previous != null && isCalculated
function diffNode(sre: SalaryRunEmployee) {
if (!previous) return null
const prev = previous.by_employee[sre.employee_id]
if (!prev) {
return (
<Badge variant="secondary" className="font-normal">
{t('diff_new_employee')}
</Badge>
)
}
const delta = roundOre(sre.gross_salary - prev.gross)
if (delta === 0) return null
const sign = delta > 0 ? '+' : '−'
return (
<span className="text-xs text-muted-foreground tabular-nums">
{sign}
{formatCurrency(Math.abs(delta))}
</span>
)
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('employees_title', { count: employees.length })}
</h2>
{isDraft && canWrite && notAdded.length > 0 && (
<Select
key={addEmployeeKey}
onValueChange={value => {
onAddEmployee(value)
setAddEmployeeKey(k => k + 1)
}}
>
<SelectTrigger className="w-[200px] h-8 text-sm">
<SelectValue placeholder={t('add_employee_placeholder')} />
</SelectTrigger>
<SelectContent>
{notAdded.map(emp => (
<SelectItem key={emp.id} value={emp.id}>
{emp.first_name} {emp.last_name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{employees.length === 0 ? (
<div className="rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
{t('no_employees_yet')}
</div>
) : (
<div className="rounded-lg border border-border divide-y divide-border">
{employees.map(sre => {
const employee = (sre as SreWithEmployee).employee
const name = employee
? `${employee.first_name} ${employee.last_name}`
: `${t('employee_fallback')} ${sre.employee_id.slice(0, 8)}...`
const dims = employee?.default_dimensions ?? {}
const dimLabel = Object.keys(dims)
.sort((a, b) => Number(a) - Number(b))
.map(k => dims[k])
.join(' · ')
const taxValue = sre.tax_withheld_override ?? sre.tax_withheld
const avgifterValue = sre.avgifter_amount_override ?? sre.avgifter_amount
const netValue = sre.net_salary + (sre.tax_withheld - taxValue)
const editableSalary = isDraft && canWrite && sre.salary_type === 'monthly'
const primaryNumber = isDraft ? sre.monthly_salary : sre.gross_salary
return (
<div
key={sre.id}
className="group flex items-center gap-3 px-4 py-3 hover:bg-secondary/60 transition-colors cursor-pointer"
onClick={() => router.push(`/salary/runs/${runId}/employees/${sre.employee_id}`)}
>
{/* Name + inline meta + (once calculated) a breakdown sub-line */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<Link
href={`/salary/runs/${runId}/employees/${sre.employee_id}`}
className="font-medium truncate hover:underline"
onClick={e => e.stopPropagation()}
>
{name}
</Link>
{dimensionsEnabled && dimLabel && (
<Badge variant="secondary">{dimLabel}</Badge>
)}
{showDiff && diffNode(sre)}
</div>
{isCalculated && (
<p className="mt-1 text-xs text-muted-foreground tabular-nums">
{t('th_tax')} {formatCurrency(taxValue)} · {t('th_net')}{' '}
{formatCurrency(netValue)} · {t('th_avgifter')}{' '}
{formatCurrency(avgifterValue)} · {t('th_vacation')}{' '}
{formatCurrency(sre.vacation_accrual)}
</p>
)}
</div>
{/* Right side: editable monthly salary (draft) or the number */}
{editableSalary ? (
<Input
type="number"
step="0.01"
min="0"
defaultValue={sre.monthly_salary}
onClick={e => e.stopPropagation()}
onBlur={e => onSalaryEdit(sre.employee_id, e.target.value, sre.monthly_salary)}
disabled={actionLoading === `salary-${sre.employee_id}`}
aria-label={t('salary_input_aria', { name })}
className="h-8 w-28 text-right tabular-nums shrink-0"
/>
) : (
<span className="text-right tabular-nums font-medium shrink-0">
{formatCurrency(primaryNumber)}
</span>
)}
{/* Payslip PDF */}
<a
href={`/api/salary/runs/${runId}/payslips/${sre.employee_id}/pdf`}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
title={t('view_payslip_title')}
aria-label={t('view_payslip_title')}
>
<FileDown className="h-4 w-4" />
</a>
{canRemoveEmployee && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
onClick={e => {
e.stopPropagation()
onRemoveEmployee(sre.employee_id, name)
}}
disabled={actionLoading === `remove-${sre.employee_id}`}
aria-label={t('remove_employee_aria', { name })}
title={t('remove_employee_title')}
>
{actionLoading === `remove-${sre.employee_id}` ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
)}
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</div>
)
})}
</div>
)}
</div>
)
}
+168
View File
@@ -0,0 +1,168 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { AlertTriangle, ArrowLeft, Loader2, MoreVertical, Trash2, Undo2 } from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { periodLabelOf, type RunDetail } from './types'
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'> = {
draft: 'secondary',
review: 'warning',
approved: 'default',
paid: 'success',
booked: 'success',
corrected: 'outline',
}
interface RunHeaderProps {
run: RunDetail
canWrite: boolean
actionLoading: string | null
employeeCount: number
onDelete: () => void
onCorrect: () => void
}
export function RunHeader({ run, canWrite, actionLoading, employeeCount, onDelete, onCorrect }: RunHeaderProps) {
const t = useTranslations('salary_run')
const tSalary = useTranslations('salary')
const [correctOpen, setCorrectOpen] = useState(false)
const periodLabel = periodLabelOf(run)
const statusKey = `status_${run.status}`
const showMenu = canWrite && (run.status === 'draft' || run.status === 'booked')
return (
<div className="flex items-start gap-2">
<Button variant="ghost" size="icon" asChild className="h-8 w-8 -ml-2 shrink-0">
<Link href="/salary" aria-label={t('back_to_salary')}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="font-display text-2xl tracking-tight truncate">
{t('title', { period: periodLabel })}
</h1>
<Badge variant={STATUS_VARIANTS[run.status] || 'secondary'} className="shrink-0">
{tSalary(statusKey)}
</Badge>
</div>
{/* Inline property row — Linear-style dot-separated metadata. */}
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>
{t('payment_date_label')} {formatDate(run.payment_date)}
</span>
<span aria-hidden>·</span>
<span>{t('header_employees', { count: employeeCount })}</span>
{run.is_correction && run.corrects_run_id && (
<>
<span aria-hidden>·</span>
<Badge variant="secondary">{t('correction_badge')}</Badge>
<Link
href={`/salary/runs/${run.corrects_run_id}`}
className="underline underline-offset-2 hover:text-foreground"
>
{t('corrects_link', { period: periodLabel })}
</Link>
</>
)}
{run.status === 'corrected' && run.corrected_by_run_id && (
<>
<span aria-hidden>·</span>
<Link
href={`/salary/runs/${run.corrected_by_run_id}`}
className="underline underline-offset-2 hover:text-foreground"
>
{t('corrected_by_link')}
</Link>
</>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{showMenu && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('more_actions')}>
{actionLoading === 'delete' || actionLoading === 'correct' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreVertical className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{run.status === 'draft' && (
<DropdownMenuItem
onClick={onDelete}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{t('action_delete_draft')}
</DropdownMenuItem>
)}
{run.status === 'booked' && (
<DropdownMenuItem onClick={() => setCorrectOpen(true)}>
<Undo2 className="mr-2 h-4 w-4" />
{t('action_correct')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* Correction confirm — storno per BFL 5 kap. 5 §, nothing is deleted. */}
<Dialog open={correctOpen} onOpenChange={setCorrectOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('correct_dialog_title')}</DialogTitle>
<DialogDescription className="space-y-3 pt-2 text-left">
<span className="block">{t('correct_dialog_body', { period: periodLabel })}</span>
{run.agi_generated_at && (
<span className="flex items-start gap-2 rounded-md border border-border p-3 text-xs">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>{t('correct_dialog_agi_warning')}</span>
</span>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setCorrectOpen(false)}>
{t('correct_dialog_cancel')}
</Button>
<Button
onClick={() => {
setCorrectOpen(false)
onCorrect()
}}
>
{t('correct_dialog_confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,98 @@
'use client'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Calculator, Loader2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
export interface EntryPreviewLine {
account_number: string
line_description: string
debit_amount: number | null
credit_amount: number | null
}
export interface EntryPreview {
description: string
lines: EntryPreviewLine[]
}
export interface PreviewData {
salaryEntry: EntryPreview | null
avgifterEntry: EntryPreview | null
vacationEntry: EntryPreview | null
pensionEntry?: EntryPreview | null
}
interface RunJournalPreviewProps {
preview: PreviewData
// When provided (draft + write access), a "Beräkna om" button renders in the
// header — recalculation sits on the output it refreshes.
onRecalculate?: () => void
recalculating?: boolean
}
export function RunJournalPreview({ preview, onRecalculate, recalculating }: RunJournalPreviewProps) {
const t = useTranslations('salary_run')
const entries = [
preview.salaryEntry,
preview.avgifterEntry,
preview.vacationEntry,
preview.pensionEntry,
].filter(Boolean) as EntryPreview[]
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0">
<CardTitle className="text-base">{t('journal_preview_title')}</CardTitle>
{onRecalculate && (
<Button variant="outline" size="sm" onClick={onRecalculate} disabled={recalculating}>
{recalculating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Calculator className="mr-2 h-4 w-4" />
)}
{t('action_recalculate')}
</Button>
)}
</CardHeader>
<CardContent className="space-y-6">
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('journal_preview_nollkorning')}</p>
) : (
entries.map((entry, idx) => (
<div key={idx} className="space-y-2">
<h4 className="text-sm font-medium">{entry.description}</h4>
<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">{t('journal_th_account')}</th>
<th className="text-left py-1">{t('journal_th_description')}</th>
<th className="text-right py-1">{t('journal_th_debit')}</th>
<th className="text-right py-1">{t('journal_th_credit')}</th>
</tr>
</thead>
<tbody>
{entry.lines.map((line, li) => (
<tr key={li} className="border-t border-border/30">
<td className="py-1.5 tabular-nums font-mono">{line.account_number}</td>
<td className="py-1.5 text-muted-foreground">{line.line_description}</td>
<td className="py-1.5 text-right tabular-nums">
{line.debit_amount ? formatCurrency(line.debit_amount) : ''}
</td>
<td className="py-1.5 text-right tabular-nums">
{line.credit_amount ? formatCurrency(line.credit_amount) : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
))
)}
</CardContent>
</Card>
)
}
+64
View File
@@ -0,0 +1,64 @@
'use client'
import { useTranslations } from 'next-intl'
import { formatCurrency } from '@/lib/utils'
import type { SalaryRunEmployee } from '@/types'
import type { RunDetail } from './types'
interface RunKpiCardsProps {
run: RunDetail
employees: SalaryRunEmployee[]
}
/**
* Recomputed from per-employee rows so manual overrides (avancerat läge) are
* reflected immediately, without relying on run.total_* columns which are
* frozen at calculate-time.
*/
export function RunKpiCards({ run, employees }: RunKpiCardsProps) {
const t = useTranslations('salary_run')
const effTax = employees.reduce((s, e) => s + (e.tax_withheld_override ?? e.tax_withheld), 0)
const effAvgifter = employees.reduce(
(s, e) => s + (e.avgifter_amount_override ?? e.avgifter_amount),
0,
)
const effNet = employees.reduce(
(s, e) => s + (e.net_salary + (e.tax_withheld - (e.tax_withheld_override ?? e.tax_withheld))),
0,
)
const effEmployerCost = employees.reduce(
(s, e) =>
s +
e.gross_salary +
(e.avgifter_amount_override ?? e.avgifter_amount) +
e.vacation_accrual +
e.vacation_accrual_avgifter,
0,
)
const cards = [
{ label: t('kpi_gross'), value: run.total_gross },
{ label: t('kpi_tax'), value: effTax },
{ label: t('kpi_net'), value: effNet, accent: true },
{ label: t('kpi_avgifter'), value: effAvgifter },
{ label: t('kpi_employer_cost'), value: effEmployerCost },
]
// Inline stat row framed by hairlines — Linear-style, no boxes. Metrics
// breathe on whitespace and wrap on narrow viewports.
return (
<div className="flex flex-wrap gap-x-10 gap-y-3 border-y border-border py-4">
{cards.map(({ label, value, accent }) => (
<div key={label} className="min-w-0">
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</p>
<p
className={`mt-1 font-sans font-medium text-lg tabular-nums leading-none ${accent ? 'text-success' : ''}`}
>
{formatCurrency(value)}
</p>
</div>
))}
</div>
)
}
+270
View File
@@ -0,0 +1,270 @@
'use client'
import { useTranslations, useLocale } from 'next-intl'
import { Button } from '@/components/ui/button'
import { ArrowLeftCircle, Eye, FileDown, Loader2, Send } from 'lucide-react'
import { formatDateLong } from '@/lib/utils'
import type { RunDetail } from './types'
type StepState = 'done' | 'active' | 'upcoming'
interface RunProgressBarProps {
run: RunDetail
isCalculated: boolean
// True when the run pays out nothing (nollkörning / fully net-deducted): the
// pay step carries no "download the file" hint because there is no file.
noPayout?: boolean
canWrite: boolean
actionLoading: string | null
// The single "forward" step for the current status (Beräkna → Skicka till
// granskning → Godkänn → Markera utbetald → Bokför). Rendered as the large
// primary target in the control zone, right next to the secondary actions.
primaryAction?: { key: string; label: string; onClick: () => void } | null
// Secondary actions for the stage: preview, revert, and the parallel payslip
// obligation. (Recalculate lives with the employee rows.)
onPreview: () => void
onRevert: () => void
onSendPayslips: () => void
onDownloadPayslips: () => void
}
const STATUS_RANK: Record<string, number> = {
draft: 0,
review: 1,
approved: 2,
paid: 3,
booked: 4,
corrected: 4,
}
export function RunProgressBar(props: RunProgressBarProps) {
const t = useTranslations('salary_run')
const locale = useLocale()
const { run, isCalculated, noPayout, canWrite, actionLoading, primaryAction } = props
const rank = STATUS_RANK[run.status] ?? 0
const busy = !!actionLoading
const deliveries = run.payslip_deliveries_summary
function spinnerOr(icon: React.ReactNode, key: string) {
return actionLoading === key ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : icon
}
// Payslips are a parallel obligation from `approved` onwards — never gate
// progression, so their "done" is simply having reached every employee.
const payslipsAvailable = rank >= 2
const payslipsDone =
payslipsAvailable && !!deliveries && deliveries.sent > 0 && deliveries.failed === 0
const payslipDetail = !payslipsAvailable
? undefined
: deliveries && deliveries.sent + deliveries.failed + deliveries.skipped > 0
? t('rail_payslips_summary', {
sent: deliveries.sent,
failed: deliveries.failed,
skipped: deliveries.skipped,
})
: t('rail_payslips_none')
interface Step {
key: string
label: string
state: StepState
detail?: string
}
const steps: Step[] = [
{
key: 'calculate',
label: t('rail_calculate'),
state: rank > 0 || isCalculated ? 'done' : 'active',
detail: rank === 0 && !isCalculated ? t('rail_calculate_hint') : undefined,
},
{
key: 'approve',
label: t('rail_approve'),
state: rank > 1 ? 'done' : run.status === 'review' ? 'active' : 'upcoming',
detail: run.approved_at ? formatDateLong(run.approved_at, locale) : undefined,
},
{
key: 'pay',
label: t('rail_pay'),
state: rank > 2 ? 'done' : run.status === 'approved' ? 'active' : 'upcoming',
detail:
run.paid_at != null
? formatDateLong(run.paid_at, locale)
: run.status === 'approved'
? noPayout
? t('rail_pay_nopayout_hint')
: t('rail_pay_hint')
: undefined,
},
{
key: 'payslips',
label: t('rail_payslips'),
state: payslipsDone ? 'done' : payslipsAvailable ? 'active' : 'upcoming',
detail: payslipDetail,
},
{
key: 'book',
label: t('rail_book'),
state: rank > 3 ? 'done' : run.status === 'paid' ? 'active' : 'upcoming',
detail: run.booked_at ? formatDateLong(run.booked_at, locale) : undefined,
},
{
key: 'agi',
label: t('rail_agi'),
state: run.agi_submitted_at ? 'done' : run.status === 'booked' ? 'active' : 'upcoming',
detail: run.agi_submitted_at
? t('rail_agi_submitted')
: run.agi_generated_at
? t('rail_agi_generated')
: run.status === 'booked'
? t('rail_agi_hint')
: undefined,
},
]
const doneCount = steps.filter(s => s.state === 'done').length
const activeStep = steps.find(s => s.state === 'active')
// Payslip send/download — shared by the mobile summary and the desktop bar.
const payslipActions = payslipsAvailable && canWrite && (
<>
<Button
size="sm"
variant={deliveries && deliveries.sent > 0 ? 'outline' : 'default'}
onClick={props.onSendPayslips}
disabled={busy}
>
{spinnerOr(<Send className="mr-2 h-4 w-4" />, 'payslips-send')}
{deliveries && deliveries.sent > 0
? t('action_send_payslips_again')
: t('action_send_payslips')}
</Button>
<Button variant="ghost" size="sm" onClick={props.onDownloadPayslips} disabled={busy}>
{spinnerOr(<FileDown className="mr-2 h-4 w-4" />, 'bulk_payslip')}
{t('action_download_payslips')}
</Button>
</>
)
// Secondary actions for the current status. The forward/primary action is
// the header CTA, so this is deliberately the "everything else" set.
let secondaryActions: React.ReactNode = null
if (canWrite) {
if (run.status === 'draft') {
// Recalculate ("Beräkna om") lives with the employee rows it recalculates,
// not here. This band keeps only the preview toggle.
secondaryActions = (
<Button
variant="outline"
size="sm"
onClick={props.onPreview}
disabled={busy || !isCalculated}
>
{spinnerOr(<Eye className="mr-2 h-4 w-4" />, 'preview')}
{t('action_preview')}
</Button>
)
} else if (run.status === 'review') {
secondaryActions = (
<>
<Button variant="outline" size="sm" onClick={props.onPreview} disabled={busy}>
{spinnerOr(<Eye className="mr-2 h-4 w-4" />, 'preview')}
{t('action_preview')}
</Button>
<Button variant="ghost" size="sm" onClick={props.onRevert} disabled={busy}>
<ArrowLeftCircle className="mr-2 h-4 w-4" />
{t('action_revert')}
</Button>
</>
)
} else if (payslipsAvailable) {
secondaryActions = payslipActions
}
}
function segClass(state: StepState) {
return state === 'done'
? 'bg-primary'
: state === 'active'
? 'bg-primary/50'
: 'bg-border'
}
// The stage line + its detail — a single sentence that sits under the track.
const currentLine = activeStep?.detail ?? (activeStep ? activeStep.label : t('rail_all_done'))
const primaryButton = primaryAction && (
<Button size="sm" onClick={primaryAction.onClick} disabled={busy}>
{actionLoading === primaryAction.key && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{primaryAction.label}
</Button>
)
return (
<div className="rounded-lg border border-border p-4">
{/* Mobile: segmented track + current step + a full-width primary button. */}
<div className="md:hidden space-y-3">
<div className="flex gap-1">
{steps.map(step => (
<span key={step.key} className={`h-1 flex-1 rounded-full ${segClass(step.state)}`} />
))}
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium">
{activeStep ? activeStep.label : t('rail_all_done')}
</p>
<p className="text-[11px] text-muted-foreground tabular-nums">
{t('rail_step_counter', { done: doneCount, total: steps.length })}
</p>
</div>
{activeStep?.detail && (
<p className="text-[11px] text-muted-foreground">{activeStep.detail}</p>
)}
{primaryAction && (
<Button className="w-full" onClick={primaryAction.onClick} disabled={busy}>
{actionLoading === primaryAction.key && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{primaryAction.label}
</Button>
)}
{secondaryActions && <div className="flex flex-wrap gap-2">{secondaryActions}</div>}
</div>
{/* Desktop: segmented track with per-segment labels, then a single action
row — the stage sentence on the left, secondary + primary on the right. */}
<div className="hidden md:block space-y-4">
<ol className="flex gap-2">
{steps.map(step => (
<li key={step.key} className="flex-1 min-w-0 space-y-2">
<span className={`block h-1 rounded-full ${segClass(step.state)}`} aria-hidden />
<p
className={`text-[11px] truncate ${
step.state === 'active'
? 'font-medium text-foreground'
: step.state === 'done'
? 'text-foreground'
: 'text-muted-foreground'
}`}
title={step.label}
>
{step.label}
</p>
</li>
))}
</ol>
{(secondaryActions || primaryButton) && (
<div className="flex items-center justify-between gap-4">
<p className="text-xs text-muted-foreground">{currentLine}</p>
<div className="flex flex-wrap items-center justify-end gap-2 shrink-0">
{secondaryActions}
{primaryButton}
</div>
</div>
)}
</div>
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
import type { SalaryRun } from '@/types'
/** Latest-attempt payslip delivery counts, from GET /api/salary/runs/[id]. */
export interface PayslipDeliveriesSummary {
sent: number
failed: number
skipped: number
last_sent_at: string | null
}
/** Effective per-employee totals of the latest booked run before this period. */
export interface PreviousRunDiff {
id: string
period_year: number
period_month: number
by_employee: Record<string, { gross: number; tax: number; net: number }>
}
/** The run payload as the detail GET returns it (additive fields). */
export type RunDetail = SalaryRun & {
arbetsgivare?: string | null
previous_run?: PreviousRunDiff | null
corrected_by_run_id?: string | null
payslip_deliveries_summary?: PayslipDeliveriesSummary
}
export function periodLabelOf(run: Pick<SalaryRun, 'period_year' | 'period_month'>): string {
return `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
}
+14 -3
View File
@@ -52,6 +52,17 @@ const KIND_FILTER: { value: 'all' | Kind; label: string }[] = [
{ value: 'correction', label: 'Korrigeringar' },
]
// The API returns errors either as a plain string (legacy/validation) or as
// 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) {
const m = (error as { message?: unknown }).message
return typeof m === 'string' ? m : undefined
}
return undefined
}
export function AgentMemoryPanel() {
const { toast } = useToast()
const { canWrite } = useCanWrite()
@@ -74,7 +85,7 @@ export function AgentMemoryPanel() {
const res = await fetch(`/api/agent/memory?${params.toString()}`)
const json = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte hämta minne', description: json.error, variant: 'destructive' })
toast({ title: 'Kunde inte hämta minne', description: apiErrorText(json.error), variant: 'destructive' })
setRows([])
return
}
@@ -100,7 +111,7 @@ export function AgentMemoryPanel() {
})
const json = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte uppdatera', description: json.error, variant: 'destructive' })
toast({ title: 'Kunde inte uppdatera', description: apiErrorText(json.error), variant: 'destructive' })
return
}
setRows((prev) => prev?.map((r) => (r.id === id ? (json.data as AgentMemoryRow) : r)) ?? null)
@@ -120,7 +131,7 @@ export function AgentMemoryPanel() {
})
const json = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte spara minne', description: json.error, variant: 'destructive' })
toast({ title: 'Kunde inte spara minne', description: apiErrorText(json.error), variant: 'destructive' })
return
}
setRows((prev) => [json.data as AgentMemoryRow, ...(prev ?? [])])
+13 -2
View File
@@ -52,6 +52,17 @@ const PROSE =
'[&_th]:text-muted-foreground [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-[10px] ' +
'[&_td]:border-b [&_td]:border-border/60 [&_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.
function apiErrorText(error: unknown): string | undefined {
if (typeof error === 'string') return error
if (error && typeof error === 'object' && 'message' in error) {
const m = (error as { message?: unknown }).message
return typeof m === 'string' ? m : undefined
}
return undefined
}
export function AgentSkillsPanel() {
const { toast } = useToast()
@@ -64,7 +75,7 @@ export function AgentSkillsPanel() {
const res = await fetch('/api/agent/skills')
const json = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte hämta kunskap', description: json.error, variant: 'destructive' })
toast({ title: 'Kunde inte hämta kunskap', description: apiErrorText(json.error), variant: 'destructive' })
setAtoms([])
return
}
@@ -97,7 +108,7 @@ export function AgentSkillsPanel() {
const res = await fetch(`/api/agent/skills?slug=${encodeURIComponent(atom.id)}`)
const json = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte läsa kunskapen', description: json.error, variant: 'destructive' })
toast({ title: 'Kunde inte läsa kunskapen', description: apiErrorText(json.error), variant: 'destructive' })
return
}
setBodies((prev) => ({ ...prev, [atom.id]: (json.data?.body as string) ?? '' }))
+6 -2
View File
@@ -38,8 +38,12 @@ export function BillingActions({ isPaying, configured }: { isPaying: boolean; co
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload ?? {}),
})
const data = (await res.json().catch(() => ({}))) as { url?: string; error?: string }
if (!res.ok || !data.url) throw new Error(data.error || 'Något gick fel')
const data = (await res.json().catch(() => ({}))) as {
url?: string
error?: string | { message?: string }
}
const errorMessage = typeof data.error === 'string' ? data.error : data.error?.message
if (!res.ok || !data.url) throw new Error(errorMessage || 'Något gick fel')
window.location.href = data.url
} catch (e) {
toast({
+4 -368
View File
@@ -1,14 +1,10 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import {
Dialog,
@@ -18,11 +14,10 @@ import {
DialogTrigger,
} from '@/components/ui/dialog'
import { Loader2, Trash2, Plus, ChevronDown, Download, Upload, Building2, Users, Globe, Pencil, Copy } from 'lucide-react'
import { TEMPLATE_CATEGORY_LABELS, convertLibraryToBookingTemplate, applyTemplate } from '@/lib/bookkeeping/template-library'
import { TEMPLATE_CATEGORY_LABELS, convertLibraryToBookingTemplate } from '@/lib/bookkeeping/template-library'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { formatCurrency } from '@/lib/utils'
import type { BookingTemplateLibrary, BookingTemplateCategory, BookingTemplateLibraryLine } from '@/types'
import { TemplateForm } from '@/components/settings/TemplateForm'
import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types'
export function BookingTemplatesPanel() {
const t = useTranslations('settings_booking_templates')
@@ -427,362 +422,3 @@ function TemplateSection({
</div>
)
}
type TemplateFormMode = 'create' | 'edit' | 'duplicate'
function TemplateForm({
mode,
initialTemplate,
entityLabels,
duplicateNamePool = [],
onSaved,
}: {
mode: TemplateFormMode
initialTemplate?: BookingTemplateLibrary
entityLabels: Record<string, string>
duplicateNamePool?: string[]
onSaved: () => void
}) {
const t = useTranslations('settings_booking_templates')
const { toast } = useToast()
const [isSubmitting, setIsSubmitting] = useState(false)
// When customizing a system template (mode 'duplicate') we suggest a distinct
// "(anpassad)" name so the company copy doesn't read as the standard one.
const [name, setName] = useState(() =>
initialTemplate
? mode === 'duplicate'
? t('copy_name_suffix', { name: initialTemplate.name })
: initialTemplate.name
: '',
)
const [description, setDescription] = useState(initialTemplate?.description ?? '')
const [category, setCategory] = useState<BookingTemplateCategory>(initialTemplate?.category ?? 'other')
const [entityType, setEntityType] = useState<'all' | 'enskild_firma' | 'aktiebolag'>(
initialTemplate?.entity_type ?? 'all',
)
const [lines, setLines] = useState<BookingTemplateLibraryLine[]>(() =>
initialTemplate
? initialTemplate.lines.map((l) => ({ ...l }))
: [
{ account: '', label: '', side: 'debit', type: 'business', ratio: 1 },
{ account: '', label: '', side: 'credit', type: 'settlement', ratio: 1 },
],
)
function updateLine(index: number, field: keyof BookingTemplateLibraryLine, value: string | number) {
setLines((prev) => {
const updated = [...prev]
updated[index] = { ...updated[index], [field]: value }
return updated
})
}
function updateLineType(index: number, newType: BookingTemplateLibraryLine['type']) {
setLines((prev) => {
const updated = [...prev]
const current = updated[index]
const next: BookingTemplateLibraryLine = { ...current, type: newType }
// Auto-pick a sensible default for the type-specific field so the
// converter (and applyTemplate) sees a complete line shape.
if (newType === 'vat' && next.vat_rate === undefined) {
next.vat_rate = 0.25
}
updated[index] = next
return updated
})
}
// Default new lines to a VAT line: the 2-line template starts with one
// business + one settlement, and the natural extension is a VAT leg.
// Defaulting to 'business' instead would silently break the converter
// (which requires exactly one business line) and the template would
// disappear from the transaction picker.
function addLine() {
setLines((prev) => [...prev, { account: '', label: '', side: 'debit', type: 'vat', vat_rate: 0.25 }])
}
function removeLine(index: number) {
if (lines.length <= 2) return
setLines((prev) => prev.filter((_, i) => i !== index))
}
// Ratio is only load-bearing when a template splits the amount across more
// than one cost/revenue line. Hide it for the simple case to keep the form
// approachable for non-accountants; it stays 1.0 under the hood.
const businessLineCount = lines.filter((l) => l.type === 'business').length
const showRatio = businessLineCount > 1
// The ratio only validates against cost/revenue lines (businessRatioSum), so
// only those get an editable input. The settlement leg is the full counter-
// amount (ratio 1.0) and is shown in the live preview, not as a control:
// an editable settlement ratio that doesn't feed the sum check would mislead.
const firstRatioIndex = showRatio ? lines.findIndex((l) => l.type === 'business') : -1
const businessRatioSum = lines
.filter((l) => l.type === 'business')
.reduce((sum, l) => sum + (l.ratio ?? 1), 0)
const ratioSumOff = showRatio && Math.abs(businessRatioSum - 1) > 0.001
// Live split preview for a 1 000 kr amount. Computed only once every line has
// an account so the table doesn't flicker while the form is half-filled.
const preview = useMemo(() => {
if (lines.some((l) => !l.account)) return null
try {
return applyTemplate(lines, 1000)
} catch {
return null
}
}, [lines])
// Soft, non-blocking hint when the chosen name collides with an existing
// company template (no DB unique constraint: duplicates are allowed).
const nameCollision =
mode !== 'edit' &&
name.trim().length > 0 &&
duplicateNamePool.some((n) => n.trim().toLowerCase() === name.trim().toLowerCase())
// Real-time check: can this draft be picked from the transaction sheet?
// If not, we show a hint: save remains allowed (templates may still be
// useful from the journal-entry form).
const isConvertible = (() => {
const draft: BookingTemplateLibrary = {
id: initialTemplate?.id ?? '',
company_id: null,
team_id: null,
created_by: null,
name,
description,
category,
entity_type: entityType,
lines,
is_system: false,
is_active: true,
created_at: '',
updated_at: '',
}
return convertLibraryToBookingTemplate(draft) !== null
})()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!name || lines.some((l) => !l.account || !l.label)) {
toast({ title: t('toast_fill_all_fields'), variant: 'destructive' })
return
}
setIsSubmitting(true)
try {
// Edit updates the existing template in place (PUT); create and duplicate
// both write a new company-scoped template (POST).
const isEdit = mode === 'edit'
const url = isEdit
? `/api/settings/booking-templates/${initialTemplate!.id}`
: '/api/settings/booking-templates'
const res = await fetch(url, {
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, category, entity_type: entityType, lines }),
})
if (!res.ok) {
const json = await res.json().catch(() => ({}))
toast({ title: json.error || t('toast_create_failed'), variant: 'destructive' })
return
}
toast({ title: isEdit ? t('toast_updated') : t('toast_created') })
onSaved()
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label>{t('name_label')}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('name_placeholder')}
autoFocus={mode === 'duplicate'}
onFocus={mode === 'duplicate' ? (e) => e.target.select() : undefined}
/>
</div>
<div>
<Label>{t('description_label')} <span className="text-muted-foreground font-normal">{t('optional_suffix')}</span></Label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder={t('description_placeholder')} rows={2} className="resize-none" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>{t('category_label')}</Label>
<Select value={category} onValueChange={(v) => setCategory(v as BookingTemplateCategory)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(TEMPLATE_CATEGORY_LABELS).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>{t('entity_type_label')}</Label>
<Select value={entityType} onValueChange={(v) => setEntityType(v as typeof entityType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(entityLabels).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label>{t('lines_label')}</Label>
<div className="space-y-2 mt-1">
{lines.map((line, i) => {
const showRatioInput = showRatio && line.type === 'business'
return (
<div key={i} className="rounded-md border border-border p-2 space-y-1.5">
<div className="flex items-center gap-2">
<Input
value={line.account}
onChange={(e) => updateLine(i, 'account', e.target.value.replace(/\D/g, '').slice(0, 4))}
placeholder={t('account_placeholder')}
className="w-20 font-mono"
maxLength={4}
/>
<Input
value={line.label}
onChange={(e) => updateLine(i, 'label', e.target.value)}
placeholder={t('description_short_placeholder')}
className="flex-1 min-w-0"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeLine(i)}
disabled={lines.length <= 2}
className="h-8 w-8 p-0 shrink-0"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-center gap-2">
<Select value={line.side} onValueChange={(v) => updateLine(i, 'side', v)}>
<SelectTrigger className="w-24"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="debit">{t('debit_label')}</SelectItem>
<SelectItem value="credit">{t('credit_label')}</SelectItem>
</SelectContent>
</Select>
<Select value={line.type} onValueChange={(v) => updateLineType(i, v as BookingTemplateLibraryLine['type'])}>
<SelectTrigger className="flex-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="business">{t('type_cost')}</SelectItem>
<SelectItem value="vat">{t('type_vat')}</SelectItem>
<SelectItem value="settlement">{t('type_settlement')}</SelectItem>
</SelectContent>
</Select>
{line.type === 'vat' && (
<Select
value={String(line.vat_rate ?? 0.25)}
onValueChange={(v) => updateLine(i, 'vat_rate', Number(v))}
>
<SelectTrigger className="w-24" aria-label={t('vat_rate_label')}><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="0.25">{t('vat_rate_25')}</SelectItem>
<SelectItem value="0.12">{t('vat_rate_12')}</SelectItem>
<SelectItem value="0.06">{t('vat_rate_6')}</SelectItem>
<SelectItem value="0">{t('vat_rate_0')}</SelectItem>
</SelectContent>
</Select>
)}
{showRatioInput && (
<div className="flex items-center gap-1 shrink-0">
<Input
type="number"
inputMode="decimal"
step="0.1"
min={0}
max={10}
value={String(line.ratio ?? 1)}
onChange={(e) => {
const n = Number(e.target.value)
if (!Number.isNaN(n)) updateLine(i, 'ratio', n)
}}
aria-label={t('ratio_label')}
className="w-16 font-mono tabular-nums text-right"
/>
{i === firstRatioIndex && <InfoTooltip content={t('ratio_help')} />}
</div>
)}
</div>
</div>
)})}
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="h-3 w-3 mr-1" />
{t('add_line')}
</Button>
</div>
</div>
{ratioSumOff && (
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2">
<p className="text-xs text-warning-foreground leading-snug">
{t('ratio_sum_warning')}
</p>
</div>
)}
{preview && (
<div>
<Label>{t('preview_label')}</Label>
<table className="w-full text-xs mt-1">
<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-right py-1 w-20">{t('th_debit')}</th>
<th className="text-right py-1 w-20">{t('th_credit')}</th>
</tr>
</thead>
<tbody>
{preview.map((pl, i) => (
<tr key={i} className="border-b last:border-0">
<td className="py-1 font-mono">{pl.account_number}</td>
<td className="py-1">{pl.line_description}</td>
<td className="py-1 text-right tabular-nums">
{pl.debit_amount ? formatCurrency(Number(pl.debit_amount)) : ''}
</td>
<td className="py-1 text-right tabular-nums">
{pl.credit_amount ? formatCurrency(Number(pl.credit_amount)) : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{nameCollision && (
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2">
<p className="text-xs text-warning-foreground leading-snug">
{t('duplicate_name_warning')}
</p>
</div>
)}
{!isConvertible && (
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2">
<p className="text-xs text-warning-foreground leading-snug">
{t('unconvertible_hint')}
</p>
</div>
)}
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{mode === 'create' ? t('create_button') : t('save_button')}
</Button>
</form>
)
}
@@ -18,6 +18,8 @@ type Status =
connected: true
expired: boolean
canRefresh: boolean
needsReconsent?: boolean
lastErrorCode?: string | null
scope: string
expiresAt: string
environment?: Environment
@@ -167,6 +169,12 @@ export function SkatteverketConnectPanel() {
</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" />
<p>{t('needs_reconsent_message')}</p>
</div>
)}
<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>
@@ -218,7 +226,7 @@ export function SkatteverketConnectPanel() {
)}
<div className="flex gap-2 pt-2">
{(status.expired || !status.canRefresh || !scopes.includes('skattekonto') || !scopes.includes('agd')) && (
{(status.expired || status.needsReconsent || !status.canRefresh || !scopes.includes('skattekonto') || !scopes.includes('agd')) && (
<Button
onClick={startConnect}
disabled={status.disabled || !hasSkatteverket}
+382
View File
@@ -0,0 +1,382 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useMemo } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, Trash2, Plus } from 'lucide-react'
import { TEMPLATE_CATEGORY_LABELS, convertLibraryToBookingTemplate, applyTemplate } from '@/lib/bookkeeping/template-library'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { formatCurrency } from '@/lib/utils'
import type { BookingTemplateLibrary, BookingTemplateCategory, BookingTemplateLibraryLine } from '@/types'
export type TemplateFormMode = 'create' | 'edit' | 'duplicate'
/**
* Shared editor for booking templates. Seeds its state once from
* `initialTemplate` (used by the settings panel to edit/customize a template,
* and by "Spara som mall" in the booking dialog to pre-fill a derived draft).
*
* Submit behaviour is driven by `mode`: 'edit' PUTs the existing template,
* 'create' and 'duplicate' both POST a new company-scoped template.
*/
export function TemplateForm({
mode,
initialTemplate,
entityLabels,
duplicateNamePool = [],
onSaved,
}: {
mode: TemplateFormMode
initialTemplate?: BookingTemplateLibrary
entityLabels: Record<string, string>
duplicateNamePool?: string[]
onSaved: () => void
}) {
const t = useTranslations('settings_booking_templates')
const { toast } = useToast()
const [isSubmitting, setIsSubmitting] = useState(false)
// When customizing a system template (mode 'duplicate') we suggest a distinct
// "(anpassad)" name so the company copy doesn't read as the standard one.
const [name, setName] = useState(() =>
initialTemplate
? mode === 'duplicate'
? t('copy_name_suffix', { name: initialTemplate.name })
: initialTemplate.name
: '',
)
const [description, setDescription] = useState(initialTemplate?.description ?? '')
const [category, setCategory] = useState<BookingTemplateCategory>(initialTemplate?.category ?? 'other')
const [entityType, setEntityType] = useState<'all' | 'enskild_firma' | 'aktiebolag'>(
initialTemplate?.entity_type ?? 'all',
)
const [lines, setLines] = useState<BookingTemplateLibraryLine[]>(() =>
initialTemplate
? initialTemplate.lines.map((l) => ({ ...l }))
: [
{ account: '', label: '', side: 'debit', type: 'business', ratio: 1 },
{ account: '', label: '', side: 'credit', type: 'settlement', ratio: 1 },
],
)
function updateLine(index: number, field: keyof BookingTemplateLibraryLine, value: string | number) {
setLines((prev) => {
const updated = [...prev]
updated[index] = { ...updated[index], [field]: value }
return updated
})
}
function updateLineType(index: number, newType: BookingTemplateLibraryLine['type']) {
setLines((prev) => {
const updated = [...prev]
const current = updated[index]
const next: BookingTemplateLibraryLine = { ...current, type: newType }
// Auto-pick a sensible default for the type-specific field so the
// converter (and applyTemplate) sees a complete line shape.
if (newType === 'vat' && next.vat_rate === undefined) {
next.vat_rate = 0.25
}
updated[index] = next
return updated
})
}
// Default new lines to a VAT line — the 2-line template starts with one
// business + one settlement, and the natural extension is a VAT leg.
// Defaulting to 'business' instead would silently break the converter
// (which requires exactly one business line) and the template would
// disappear from the transaction picker.
function addLine() {
setLines((prev) => [...prev, { account: '', label: '', side: 'debit', type: 'vat', vat_rate: 0.25 }])
}
function removeLine(index: number) {
if (lines.length <= 2) return
setLines((prev) => prev.filter((_, i) => i !== index))
}
// Ratio is only load-bearing when a template splits the amount across more
// than one cost/revenue line. Hide it for the simple case to keep the form
// approachable for non-accountants; it stays 1.0 under the hood.
const businessLineCount = lines.filter((l) => l.type === 'business').length
const showRatio = businessLineCount > 1
// The ratio only validates against cost/revenue lines (businessRatioSum), so
// only those get an editable input. The settlement leg is the full counter-
// amount (ratio 1.0) and is shown in the live preview, not as a control —
// an editable settlement ratio that doesn't feed the sum check would mislead.
const firstRatioIndex = showRatio ? lines.findIndex((l) => l.type === 'business') : -1
const businessRatioSum = lines
.filter((l) => l.type === 'business')
.reduce((sum, l) => sum + (l.ratio ?? 1), 0)
const ratioSumOff = showRatio && Math.abs(businessRatioSum - 1) > 0.001
// Live split preview for a 1 000 kr amount. Computed only once every line has
// an account so the table doesn't flicker while the form is half-filled.
const preview = useMemo(() => {
if (lines.some((l) => !l.account)) return null
try {
return applyTemplate(lines, 1000)
} catch {
return null
}
}, [lines])
// Soft, non-blocking hint when the chosen name collides with an existing
// company template (no DB unique constraint — duplicates are allowed).
const nameCollision =
mode !== 'edit' &&
name.trim().length > 0 &&
duplicateNamePool.some((n) => n.trim().toLowerCase() === name.trim().toLowerCase())
// Real-time check: can this draft be picked from the transaction sheet?
// If not, we show a hint — save remains allowed (templates may still be
// useful from the journal-entry form).
const isConvertible = (() => {
const draft: BookingTemplateLibrary = {
id: initialTemplate?.id ?? '',
company_id: null,
team_id: null,
created_by: null,
name,
description,
category,
entity_type: entityType,
lines,
is_system: false,
is_active: true,
created_at: '',
updated_at: '',
}
return convertLibraryToBookingTemplate(draft) !== null
})()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!name || lines.some((l) => !l.account || !l.label)) {
toast({ title: t('toast_fill_all_fields'), variant: 'destructive' })
return
}
setIsSubmitting(true)
try {
// Edit updates the existing template in place (PUT); create and duplicate
// both write a new company-scoped template (POST).
const isEdit = mode === 'edit'
const url = isEdit
? `/api/settings/booking-templates/${initialTemplate!.id}`
: '/api/settings/booking-templates'
const res = await fetch(url, {
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, category, entity_type: entityType, lines }),
})
if (!res.ok) {
const json = await res.json().catch(() => ({}))
toast({ title: json.error || t('toast_create_failed'), variant: 'destructive' })
return
}
toast({ title: isEdit ? t('toast_updated') : t('toast_created') })
onSaved()
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label>{t('name_label')}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('name_placeholder')}
autoFocus={mode === 'duplicate'}
onFocus={mode === 'duplicate' ? (e) => e.target.select() : undefined}
/>
</div>
<div>
<Label>{t('description_label')} <span className="text-muted-foreground font-normal">{t('optional_suffix')}</span></Label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder={t('description_placeholder')} rows={2} className="resize-none" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>{t('category_label')}</Label>
<Select value={category} onValueChange={(v) => setCategory(v as BookingTemplateCategory)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(TEMPLATE_CATEGORY_LABELS).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>{t('entity_type_label')}</Label>
<Select value={entityType} onValueChange={(v) => setEntityType(v as typeof entityType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(entityLabels).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label>{t('lines_label')}</Label>
<div className="space-y-2 mt-1">
{lines.map((line, i) => {
const showRatioInput = showRatio && line.type === 'business'
return (
<div key={i} className="rounded-md border border-border p-2 space-y-1.5">
<div className="flex items-center gap-2">
<Input
value={line.account}
onChange={(e) => updateLine(i, 'account', e.target.value.replace(/\D/g, '').slice(0, 4))}
placeholder={t('account_placeholder')}
className="w-20 font-mono"
maxLength={4}
/>
<Input
value={line.label}
onChange={(e) => updateLine(i, 'label', e.target.value)}
placeholder={t('description_short_placeholder')}
className="flex-1 min-w-0"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeLine(i)}
disabled={lines.length <= 2}
className="h-8 w-8 p-0 shrink-0"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-center gap-2">
<Select value={line.side} onValueChange={(v) => updateLine(i, 'side', v)}>
<SelectTrigger className="w-24"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="debit">{t('debit_label')}</SelectItem>
<SelectItem value="credit">{t('credit_label')}</SelectItem>
</SelectContent>
</Select>
<Select value={line.type} onValueChange={(v) => updateLineType(i, v as BookingTemplateLibraryLine['type'])}>
<SelectTrigger className="flex-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="business">{t('type_cost')}</SelectItem>
<SelectItem value="vat">{t('type_vat')}</SelectItem>
<SelectItem value="settlement">{t('type_settlement')}</SelectItem>
</SelectContent>
</Select>
{line.type === 'vat' && (
<Select
value={String(line.vat_rate ?? 0.25)}
onValueChange={(v) => updateLine(i, 'vat_rate', Number(v))}
>
<SelectTrigger className="w-24" aria-label={t('vat_rate_label')}><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="0.25">{t('vat_rate_25')}</SelectItem>
<SelectItem value="0.12">{t('vat_rate_12')}</SelectItem>
<SelectItem value="0.06">{t('vat_rate_6')}</SelectItem>
<SelectItem value="0">{t('vat_rate_0')}</SelectItem>
</SelectContent>
</Select>
)}
{showRatioInput && (
<div className="flex items-center gap-1 shrink-0">
<Input
type="number"
inputMode="decimal"
step="0.1"
min={0}
max={10}
value={String(line.ratio ?? 1)}
onChange={(e) => {
const n = Number(e.target.value)
if (!Number.isNaN(n)) updateLine(i, 'ratio', n)
}}
aria-label={t('ratio_label')}
className="w-16 font-mono tabular-nums text-right"
/>
{i === firstRatioIndex && <InfoTooltip content={t('ratio_help')} />}
</div>
)}
</div>
</div>
)})}
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="h-3 w-3 mr-1" />
{t('add_line')}
</Button>
</div>
</div>
{ratioSumOff && (
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2">
<p className="text-xs text-warning-foreground leading-snug">
{t('ratio_sum_warning')}
</p>
</div>
)}
{preview && (
<div>
<Label>{t('preview_label')}</Label>
<table className="w-full text-xs mt-1">
<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-right py-1 w-20">{t('th_debit')}</th>
<th className="text-right py-1 w-20">{t('th_credit')}</th>
</tr>
</thead>
<tbody>
{preview.map((pl, i) => (
<tr key={i} className="border-b last:border-0">
<td className="py-1 font-mono">{pl.account_number}</td>
<td className="py-1">{pl.line_description}</td>
<td className="py-1 text-right tabular-nums">
{pl.debit_amount ? formatCurrency(Number(pl.debit_amount)) : ''}
</td>
<td className="py-1 text-right tabular-nums">
{pl.credit_amount ? formatCurrency(Number(pl.credit_amount)) : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{nameCollision && (
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2">
<p className="text-xs text-warning-foreground leading-snug">
{t('duplicate_name_warning')}
</p>
</div>
)}
{!isConvertible && (
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2">
<p className="text-xs text-warning-foreground leading-snug">
{t('unconvertible_hint')}
</p>
</div>
)}
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{mode === 'create' ? t('create_button') : t('save_button')}
</Button>
</form>
)
}
@@ -22,6 +22,7 @@ export function BankingSettingsContent() {
const [bankConnectionError, setBankConnectionError] = useState<string | null>(null)
const [failedBankName, setFailedBankName] = useState<string | null>(null)
const [isAccessDenied, setIsAccessDenied] = useState(false)
const [showHbPoaHint, setShowHbPoaHint] = useState(false)
const syncInitiatedRef = useRef(false)
const abortControllerRef = useRef<AbortController | null>(null)
const unmountedRef = useRef(false)
@@ -102,6 +103,12 @@ export function BankingSettingsContent() {
try { errorMsg = decodeURIComponent(bankError) } catch { errorMsg = bankError }
const bankName = searchParams.get('bank_name')
const errorCode = searchParams.get('bank_error_code')
const psuType = searchParams.get('psu_type')
// The bank often returns a bare "server_error" with no description —
// show a human message instead of the raw OAuth error code.
if (errorCode === 'server_error' && errorMsg === 'server_error') {
errorMsg = t('bank_server_error')
}
toast({
title: t('connect_failed_title'),
description: errorMsg,
@@ -110,6 +117,12 @@ export function BankingSettingsContent() {
setBankConnectionError(errorMsg)
if (bankName) setFailedBankName(bankName)
if (errorCode === 'access_denied') setIsAccessDenied(true)
// Handelsbanken rejects business connects with server_error when the
// company hasn't registered the open banking fullmakt ("Internet
// Företag – tilläggstjänst API Företag") — surface the fix steps.
if (bankName === 'Handelsbanken' && psuType === 'business' && errorCode === 'server_error') {
setShowHbPoaHint(true)
}
router.replace('/settings/banking')
}
}, [searchParams, router, toast, t])
@@ -126,6 +139,19 @@ export function BankingSettingsContent() {
{t('access_denied_hint', { bankName: failedBankName })}
</p>
)}
{showHbPoaHint && (
<p className="mt-1 text-sm 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"
>
{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>
@@ -135,6 +161,7 @@ export function BankingSettingsContent() {
setBankConnectionError(null)
setFailedBankName(null)
setIsAccessDenied(false)
setShowHbPoaHint(false)
}}
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground"
aria-label={t('dismiss_aria')}
@@ -1,77 +1,199 @@
'use client'
import Link from 'next/link'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { PageHeader } from '@/components/ui/page-header'
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 { 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('')
const BANK_OPTIONS = ['swedbank', 'seb', 'handelsbanken', 'nordea'] as const
const BANK_LABEL: Record<(typeof BANK_OPTIONS)[number], string> = {
swedbank: 'Swedbank',
seb: 'SEB',
handelsbanken: 'Handelsbanken',
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 { 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)
if (isLoading) return <SettingsLoadingSkeleton />
if (!settings) return <SettingsLoadError onRetry={refetch} />
const effectiveFormat = format ?? settings.preferred_payment_format ?? 'pain001'
const currentSeries = resolveDefaultSeriesForSource(settings, 'salary_payment')
function handleSave(formData: FormData) {
const payDayRaw = parseInt((formData.get('salary_pay_day') as string) || '25', 10)
const payDay = Number.isFinite(payDayRaw) ? Math.min(28, Math.max(1, payDayRaw)) : 25
const paymentFormat = (formData.get('preferred_payment_format') as string) || 'pain001'
const bank = (formData.get('salary_default_bank') as string) || 'none'
const series = (formData.get('salary_voucher_series') as string) || 'A'
const updates: Record<string, unknown> = {
salary_pay_day: payDay,
preferred_payment_format: paymentFormat,
salary_default_bank: bank === 'none' ? null : bank,
}
// 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
// source-type overrides survive.
if (series !== currentSeries) {
updates.default_voucher_series_per_source_type = {
...(settings?.default_voucher_series_per_source_type || {}),
salary_payment: series,
}
}
return {
updates,
onSuccess: (data: Record<string, unknown>) => {
updateSettings(data as Partial<CompanySettings>)
},
}
}
return (
<div className="space-y-8">
<PageHeader title={t('title')} />
<Card>
<CardHeader>
<CardTitle className="text-base">{t('accounting_heading')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('voucher_series_label')}</label>
<select className="flex h-9 w-full max-w-xs rounded-md border border-input bg-transparent px-3 py-1 text-sm" defaultValue="A">
<option value="A">{t('voucher_series_a')}</option>
<option value="L">{t('voucher_series_l')}</option>
</select>
<p className="text-xs text-muted-foreground">
{t('voucher_series_help')}
</p>
</div>
</CardContent>
</Card>
<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>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('tax_tables_heading')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<Label htmlFor="salary_pay_day">{t('pay_day_label')}</Label>
<Input
id="salary_pay_day"
name="salary_pay_day"
type="number"
min={1}
max={28}
defaultValue={settings.salary_pay_day ?? 25}
className="w-24 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
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
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>
{/* 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>
</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>
<TaxTableStatus />
<p className="text-xs text-muted-foreground">
{t('tax_tables_help')}
<p className="text-xs text-muted-foreground">{t('tax_tables_help')}</p>
</section>
</div>
{/* 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>
</CardContent>
</Card>
</section>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('vacation_heading')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('vacation_rule_label')}</label>
<select className="flex h-9 w-full max-w-xs rounded-md border border-input bg-transparent px-3 py-1 text-sm" defaultValue="procentregeln">
<option value="procentregeln">{t('vacation_rule_percentage')}</option>
<option value="sammaloneregeln">{t('vacation_rule_same_pay')}</option>
<option value="none">{t('vacation_rule_none')}</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('vacation_supplement_label')}</label>
<select className="flex h-9 w-full max-w-xs rounded-md border border-input bg-transparent px-3 py-1 text-sm" defaultValue="0.0043">
<option value="0.0043">{t('vacation_supplement_min')}</option>
<option value="0.008">{t('vacation_supplement_cba')}</option>
</select>
<p className="text-xs text-muted-foreground">
{t('vacation_supplement_help')}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('info_heading')}</CardTitle>
</CardHeader>
<CardContent>
{/* 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>
@@ -80,8 +202,8 @@ export function SalarySettingsContent() {
})}
</p>
</div>
</CardContent>
</Card>
</section>
</div>
</div>
)
}