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:
co-authored by
Claude Opus 4.8
parent
ec27228a8e
commit
2c2743eb79
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')}`
|
||||
}
|
||||
@@ -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 ?? [])])
|
||||
|
||||
@@ -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) ?? '' }))
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user