style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)

Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-04 15:58:06 +02:00
committed by GitHub
co-authored by Claude Sonnet 5
parent a5154ee884
commit ec27228a8e
1588 changed files with 28388 additions and 10890 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
* Loads the Recapt SDK globally.
*
* Renders a plain <script> tag (not next/script) so it lands in <head> and
* runs as early as possible — that's what the SDK needs to capture full
* runs as early as possible: that's what the SDK needs to capture full
* session replays. The public key is sourced from NEXT_PUBLIC_RECAPT_PUBLIC_KEY
* so hosted and self-hosted deployments can each supply their own key (or
* disable Recapt entirely by leaving it unset).
+1 -1
View File
@@ -11,7 +11,7 @@ interface Props {
alt?: string
}
// Renders the agent's avatar — either the chosen dicebear SVG from the
// Renders the agent's avatar: either the chosen dicebear SVG from the
// AVATAR_OPTIONS registry, or a fallback MessageCircle glyph on a dark circle
// when no avatar is set yet (free tier / older profiles).
//
+29 -29
View File
@@ -17,7 +17,7 @@ import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import ApprovalCard from './ApprovalCard'
// Reusable chat surface — used both inside the right-hand AgentSheet and on
// Reusable chat surface: used both inside the right-hand AgentSheet and on
// the full-page /chat route. Owns:
// * Message state (rendered list)
// * NDJSON stream consumer for /api/agent/invoke
@@ -25,8 +25,8 @@ import ApprovalCard from './ApprovalCard'
// * Input form
//
// What it does NOT own:
// * Sheet chrome (title bar, close button) — wrapper's job
// * Page layout / sidebar — wrapper's job
// * Sheet chrome (title bar, close button): wrapper's job
// * Page layout / sidebar: wrapper's job
//
// Two modes:
// * Fresh start (initialMessages empty, initialConversationId null):
@@ -40,7 +40,7 @@ export interface ChatMessage {
role: 'user' | 'assistant'
text: string
// Extended-thinking reasoning, streamed token-by-token via reasoning_delta.
// Shown in a collapsible "Tänkte…" block. Stream-time only — not hydrated.
// Shown in a collapsible "Tänkte…" block. Stream-time only: not hydrated.
reasoning?: string
// Tool-use chips. `completed` flips true when the matching `tool_result`
// event arrives so the UI can swap the pulsing dot for a static check
@@ -53,7 +53,7 @@ export interface ChatMessage {
// Emitted by run-turn.ts after a successful remember_fact / forget_fact call
// so the chat surface can render a quiet "Sparat som minne: …" chip below the
// assistant message. Stream-time only — not hydrated on /chat resume.
// assistant message. Stream-time only: not hydrated on /chat resume.
interface MemoryEvent {
tool_use_id: string
action: 'remembered' | 'forgotten'
@@ -74,7 +74,7 @@ interface StagedOperation {
// by tool; ApprovalCard's renderers do the type-narrowing.
preview?: unknown
// Period state at the operation's effective date. Surfaced as a small
// badge — open|locked|closed.
// badge: open|locked|closed.
period_status?: {
period_id?: string | null
status: 'open' | 'locked' | 'closed'
@@ -89,13 +89,13 @@ export interface AgentChatProps {
initialMessages?: ChatMessage[]
initialConversationId?: string | null
onConversationIdChange?: (id: string) => void
// Fires after the first turn_complete in a fresh-start session — used by
// Fires after the first turn_complete in a fresh-start session: used by
// bootstrap starters (ChatNewStarter, ChatIntakeStarter) to defer the URL
// swap until streaming is done. Swapping on the early `conversation`
// event unmounts the component mid-stream and the assistant reply is
// never persisted before /chat/[id] hydrates.
onFirstTurnComplete?: (id: string) => void
// Optional vertical padding override — defaults to py-6 inside the
// Optional vertical padding override: defaults to py-6 inside the
// scroller. The full-page chat uses py-8 for breathing room.
scrollerClassName?: string
// Pre-baked first user message. When set, the mount effect fires the first
@@ -119,7 +119,7 @@ export default function AgentChat({
const [conversationId, setConversationId] = useState<string | null>(initialConversationId ?? null)
// Track whether the first-turn callback has fired so the bootstrap
// starters get exactly one notification even if a turn fires before
// the conversation_id event (defensive — order shouldn't matter).
// the conversation_id event (defensive: order shouldn't matter).
const firstTurnFiredRef = useRef(false)
const conversationIdRef = useRef<string | null>(initialConversationId ?? null)
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages ?? [])
@@ -134,23 +134,23 @@ export default function AgentChat({
// Set when a tool call runs; consumed by the NEXT text_delta to insert a
// single paragraph break so post-tool narration starts on its own line.
// A ref (not state) because it must be read/cleared synchronously inside
// the streaming loop without triggering re-renders — and because the
// the streaming loop without triggering re-renders, and because the
// break must fire exactly once per resume, not on every delta.
const breakBeforeNextTextRef = useRef(false)
// Fresh-start vs. resume — only kick off the first turn when we have neither
// Fresh-start vs. resume: only kick off the first turn when we have neither
// a hydrated conversation nor pre-existing messages. React 19 Strict Mode
// runs effects twice in dev; the first call's cleanup aborts its fetch, the
// second completes. The invoke endpoint is idempotent on first-turn when
// no conversation_id is supplied (it creates a fresh row each time, so a
// transient duplicate just orphans the first conversation — harmless).
// transient duplicate just orphans the first conversation: harmless).
useEffect(() => {
// Only bootstrap a first turn on a genuine fresh start — i.e. NO
// Only bootstrap a first turn on a genuine fresh start: i.e. NO
// conversation id. A present id means the conversation already exists
// (or is mid-creation elsewhere), so we must not fire an invoke.
//
// Why id-alone, not id+messages: the intake flow fires an invoke with
// no conversation_id, then swaps the URL to /chat/[id] the moment the
// `conversation` event lands — which can beat the greeting being
// `conversation` event lands: which can beat the greeting being
// persisted. /chat/[id] then hydrates with 0 messages. If we keyed the
// guard on messages.length we'd auto-fire a SECOND invoke against the
// same conversation and render two greetings. Keying on id presence
@@ -180,7 +180,7 @@ export default function AgentChat({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Autoscroll on new content — but only if the user was already pinned to the
// Autoscroll on new content, but only if the user was already pinned to the
// bottom. Scrolling up to re-read a long answer should NOT yank the user
// back on every streaming token. Threshold accounts for sub-pixel rounding.
const wasAtBottomRef = useRef(true)
@@ -210,7 +210,7 @@ export default function AgentChat({
// fed back into the chat). The caller also skips adding a visible bubble.
hidden?: boolean
}): Promise<void> {
// Abort any in-flight turn before starting a new one — guards against
// Abort any in-flight turn before starting a new one: guards against
// racing two turns when handleSend is triggered twice fast.
activeControllerRef.current?.abort()
const controller = new AbortController()
@@ -258,7 +258,7 @@ export default function AgentChat({
msg = errBody.error
}
} catch {
// non-JSON / empty body — keep the generic message
// non-JSON / empty body: keep the generic message
}
setErrorMessage(msg)
setStreaming(false)
@@ -266,7 +266,7 @@ export default function AgentChat({
return
}
// Assistant bubble is appended LAZILY — only when the first event that
// Assistant bubble is appended LAZILY: only when the first event that
// produces user-visible content arrives. Eagerly appending here would
// leave an empty bubble dangling if the stream errors or yields zero
// events (e.g. proxy hiccup) before any content.
@@ -290,7 +290,7 @@ export default function AgentChat({
const line = buffer.slice(0, nl).trim()
buffer = buffer.slice(nl + 1)
if (!line) continue
// Guard JSON.parse per line — a malformed line (proxy split,
// Guard JSON.parse per line: a malformed line (proxy split,
// partial buffer flush) must NOT abort the entire stream. Skip and
// continue; the next well-formed line will be handled normally.
let parsed: unknown
@@ -324,7 +324,7 @@ export default function AgentChat({
// already released
}
// Guard against an aborted prior turn clobbering the new turn's
// streaming flag — only the active controller may reset the state.
// streaming flag: only the active controller may reset the state.
if (activeControllerRef.current === controller) {
setStreaming(false)
activeControllerRef.current = null
@@ -358,7 +358,7 @@ export default function AgentChat({
// Fired after the user rejects a proposal with a reason. The rejection is
// already recorded server-side; here we feed the correction back as a HIDDEN
// user turn so the agent re-proposes inline — no synthetic user bubble (we
// user turn so the agent re-proposes inline: no synthetic user bubble (we
// don't add a user row, and the turn is persisted hidden).
function handleCorrection(correctionMessage: string) {
void startTurn({ conversationId, userMessage: correctionMessage, hidden: true })
@@ -392,7 +392,7 @@ export default function AgentChat({
// gluing onto the previous sentence ("kategoriseras.Inget historik").
// breakBeforeNextTextRef is set by tool_use/tool_result and consumed
// here on the first delta. Critically, the break is applied to the
// delta exactly once — NOT re-evaluated per delta, which previously
// delta exactly once: NOT re-evaluated per delta, which previously
// split mid-word ("minnes\n\nno\n\nterna") because streaming deltas
// arrive in sub-word chunks.
setMessages((prev) =>
@@ -426,7 +426,7 @@ export default function AgentChat({
break
case 'tool_result':
// Mark the matching chip as completed instead of removing it. Tools
// run in 100–500 ms so yanking the chip the moment it finishes makes
// run in 100-500 ms so yanking the chip the moment it finishes makes
// the indicator feel like a flicker rather than a record of what
// happened. Leaving the chip in place (with a static check dot,
// no pulse) gives the user a stable trace of which calls ran.
@@ -451,7 +451,7 @@ export default function AgentChat({
updateLastAssistant(prev, (m) => ({
...m,
memoryEvents: [...(m.memoryEvents ?? []), evt],
// Drop the matching tool_use chip — the richer memory chip
// Drop the matching tool_use chip: the richer memory chip
// replaces it and they convey the same event.
toolCalls: m.toolCalls?.filter((tc) => tc.tool_use_id !== evt.tool_use_id),
})),
@@ -521,7 +521,7 @@ export default function AgentChat({
el.style.height = `${Math.min(el.scrollHeight, max)}px`
}, [input])
// Index of the last assistant bubble — used to gate the Regenerate
// Index of the last assistant bubble: used to gate the Regenerate
// affordance so it only appears on the latest response.
let lastAssistantIdx = -1
for (let i = messages.length - 1; i >= 0; i--) {
@@ -592,7 +592,7 @@ export default function AgentChat({
}}
/>
{streaming ? (
// Stop button while the agent is producing tokens — biggest
// Stop button while the agent is producing tokens: biggest
// pain killer. Aborts the in-flight fetch + reader.
<Button
type="button"
@@ -747,7 +747,7 @@ function MessageBubble({
)
}
// Pre-token "typing" indicator. Three staggered pulsing dots — reads as
// Pre-token "typing" indicator. Three staggered pulsing dots: reads as
// "Anna is typing" much faster than the single blinking caret it replaced.
// Stays only until the first text_delta lands, then the message body takes
// over.
@@ -762,7 +762,7 @@ function Cursor() {
}
// Collapsible extended-thinking trace. While the model is still reasoning
// (active), it auto-expands and streams — doubling as the "working" indicator
// (active), it auto-expands and streams: doubling as the "working" indicator
// in place of the typing cursor. Once the answer starts it collapses to a
// quiet toggle so the reply stays the focus and the surface stays calm.
function ReasoningBlock({ reasoning, active }: { reasoning: string; active: boolean }) {
@@ -927,7 +927,7 @@ export function normalizeStoredMessages(
for (const block of content as { type: string; text?: string; id?: string; name?: string }[]) {
if (block.type === 'text' && block.text) text += block.text
else if (block.type === 'tool_use' && block.id && block.name) {
// Hydrated rows are historical — the tool already finished by
// Hydrated rows are historical: the tool already finished by
// definition (otherwise the assistant content wouldn't have been
// persisted). Mark every chip as completed so the rendered state
// matches the live tool_result-handled state.
+1 -1
View File
@@ -16,7 +16,7 @@ interface Props {
// Highlight the row for the conversation currently open in the sheet.
activeConversationId?: string | null
// Fired when the user picks a conversation to resume. The sheet fetches its
// messages and swaps back to the chat view — the list itself stays dumb.
// messages and swaps back to the chat view: the list itself stays dumb.
onSelect: (id: string) => void
}
+11 -11
View File
@@ -10,7 +10,7 @@ import { useAgentSheet } from './AgentSheetProvider'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import { cn } from '@/lib/utils'
// Undimmed non-modal side sheet — sits above the page on a hairline border +
// Undimmed non-modal side sheet: sits above the page on a hairline border +
// shadow, but the page underneath stays fully interactive. Plan §3b.
//
// The sheet is a thin wrapper around AgentChat: it owns the title bar, close
@@ -58,7 +58,7 @@ export default function AgentSheet({
const [loaded, setLoaded] = useState<LoadedConversation | null>(null)
const [loadingConversation, setLoadingConversation] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
// Enlarge the panel IN PLACE (no navigation) — the user stays on the current
// Enlarge the panel IN PLACE (no navigation): the user stays on the current
// page (e.g. /bookkeeping) with a wider reading/verifying surface.
const [expanded, setExpanded] = useState(false)
const { identity } = useAgentSheet()
@@ -139,18 +139,18 @@ export default function AgentSheet({
aria-label={displayTitle}
// z-[60] sits above the mobile bottom nav (z-50) so on phones the sheet
// covers the full screen including where the nav would otherwise show.
// `hidden` (display:none) when collapsed keeps the component mounted — the
// conversation state in AgentChat survives — while removing it from view
// `hidden` (display:none) when collapsed keeps the component mounted (the
// conversation state in AgentChat survives) while removing it from view
// and layout entirely (no stray horizontal scroll from an off-screen box).
className={cn(
'fixed inset-y-0 right-0 z-[60] flex w-full flex-col border-l border-border bg-background shadow-lg transition-[max-width] duration-200 ease-out',
collapsed && 'hidden',
// Expanded grows the panel leftward over the page (still non-modal — the
// Expanded grows the panel leftward over the page (still non-modal: the
// page stays interactive); normal is the compact side sheet.
expanded ? 'max-w-[min(100vw,1100px)]' : 'max-w-[480px]',
)}
style={{
// iOS notch / Android cutout — the sheet top edge needs to clear the
// iOS notch / Android cutout: the sheet top edge needs to clear the
// status bar. Bottom is handled inside the form below.
paddingTop: 'env(safe-area-inset-top, 0px)',
}}
@@ -190,7 +190,7 @@ export default function AgentSheet({
<AgentAvatar avatarId={identity.avatarId} size="sm" alt={agentName ?? 'Assistent'} />
<h2 className="font-display text-lg tracking-tight truncate">{displayTitle}</h2>
<div className="ml-auto flex items-center gap-1">
{/* Grow/shrink the panel in place — NEVER navigates away, so the
{/* Grow/shrink the panel in place: NEVER navigates away, so the
user stays on the current page. Hidden on mobile where the sheet
is already full-width (the toggle would be a no-op). */}
{!isSandbox && (
@@ -203,15 +203,15 @@ export default function AgentSheet({
{expanded ? <Shrink className="h-4 w-4" /> : <Expand className="h-4 w-4" />}
</button>
)}
{/* Labeled (not icon-only) so it isn't mistaken for close/minimize —
{/* Labeled (not icon-only) so it isn't mistaken for close/minimize,
and gated on an existing conversation so there's nothing to
mis-click on a fresh, empty chat. */}
{activeConversationId && !isSandbox && (
<button
onClick={onRestart}
className="h-9 inline-flex items-center gap-2 rounded-md px-2 text-xs font-medium text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
aria-label="Rensa — börja en ny konversation"
title="Rensa — börja en ny konversation"
aria-label="Rensa: börja en ny konversation"
title="Rensa: börja en ny konversation"
>
<Eraser className="h-4 w-4" />
Rensa
@@ -221,7 +221,7 @@ export default function AgentSheet({
onClick={handleCollapse}
className="h-9 w-9 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
aria-label="Minimera"
title="Minimera — behåll sessionen"
title="Minimera: behåll sessionen"
>
<PanelRightClose className="h-4 w-4" />
</button>
+3 -3
View File
@@ -23,7 +23,7 @@ export interface AgentIdentity {
export interface OpenAgentSheetArgs {
intentId: string
// Intent-specific args passed to the server's intent.capture() — e.g.
// Intent-specific args passed to the server's intent.capture(), e.g.
// { transaction_id: '...' } for transaction.categorization.
intentArgs?: Record<string, unknown>
// Optional ref persisted on agent_conversations.context_ref so the UI can
@@ -39,7 +39,7 @@ interface AgentSheetContextValue {
openAgentSheet: (args: OpenAgentSheetArgs) => void
closeAgentSheet: () => void
// Collapse hides the sheet WITHOUT unmounting it, so the in-memory
// conversation (messages, streaming, pending approval cards) survives — the
// conversation (messages, streaming, pending approval cards) survives: the
// floating trigger re-expands the same session. Distinct from close, which
// ends the session entirely.
collapseAgentSheet: () => void
@@ -52,7 +52,7 @@ interface AgentSheetContextValue {
isOpen: boolean
// True while a session exists but is minimized off-screen.
collapsed: boolean
// Agent name + avatar — set once from the server-loaded agent_profile
// Agent name + avatar: set once from the server-loaded agent_profile
// and exposed through context so the trigger / chat headers can render
// them without their own fetches. Null when the user hasn't verified a
// profile yet (free tier or pre-onboarding).
+2 -2
View File
@@ -23,7 +23,7 @@ interface Props {
// page-header button across the dashboard (invoice form, supplier invoice,
// bookkeeping, year-end, VAT report, KPI, …) routes through this component
// so they share the same icon size, label format, spacing, and resolved
// agent name. The transaction-row icon button stays separate — it's a
// agent name. The transaction-row icon button stays separate: it's a
// different UX (icon-only, ghost) embedded inside a row's action group.
export default function AgentSparkleButton({
intentId,
@@ -35,7 +35,7 @@ export default function AgentSparkleButton({
className,
}: Props) {
const { openAgentSheet, identity } = useAgentSheet()
// Same gate as AgentTrigger — hide all "Fråga …" affordances until the
// Same gate as AgentTrigger: hide all "Fråga …" affordances until the
// user has finished /onboarding/agent.
if (!identity.isVerified) return null
const name = identity.displayName?.trim() || 'min assistent'
+6 -6
View File
@@ -23,8 +23,8 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
// Page-specific triggers (e.g. "Granska med assistent" on a supplier invoice)
// still call useAgentSheet() directly from their own buttons because they
// know exactly which entity to pass. (Per-transaction help has its own
// row-level "Fråga [namn]" button in TransactionInboxCard — and the matching
// "Fråga assistenten" in Dokumentinkorgen — both passing a transaction_id the
// row-level "Fråga [namn]" button in TransactionInboxCard, and the matching
// "Fråga assistenten" in Dokumentinkorgen: both passing a transaction_id the
// pathname-only FAB can't know.)
export default function AgentTrigger() {
const { openAgentSheet, expandAgentSheet, isOpen, collapsed, identity } = useAgentSheet()
@@ -33,19 +33,19 @@ export default function AgentTrigger() {
const hasAi = useCapability(CAPABILITY.ai)
// Sheet open AND visible → hide the FAB so the icon doesn't double up. When
// the session is merely collapsed we KEEP the FAB — it's the handle that
// the session is merely collapsed we KEEP the FAB: it's the handle that
// brings the minimized conversation back.
if (isOpen && !collapsed) return null
// The page-suppression rules below apply only to a FRESH open. A collapsed
// session always gets its reopen handle, regardless of page — otherwise a
// session always gets its reopen handle, regardless of page: otherwise a
// conversation minimized on /chat or /bookkeeping/[id] could never be
// brought back.
if (!collapsed) {
// The /chat surface IS the chat — a floating "Fråga …" pill on top of it
// The /chat surface IS the chat: a floating "Fråga …" pill on top of it
// is redundant and overlaps the input. Suppress while the user is here.
if (pathname?.startsWith('/chat')) return null
// The verifikation editor is a dense regulatory surface (debits/credits,
// BAS codes, period locks) — a floating "Fråga … om denna verifikation"
// BAS codes, period locks): a floating "Fråga … om denna verifikation"
// pill on top of it adds noise without earning its place. Suppress on
// /bookkeeping/[id] specifically; /bookkeeping (list), /bookkeeping/new,
// and /bookkeeping/year-end still get the FAB.
+36 -36
View File
@@ -15,16 +15,16 @@ import { formatCurrency } from '@/lib/utils'
// Inline approval card for an agent-staged pending_operation.
//
// Risk tiers (plan §9, §12):
// low — single-click "Godkänn". Trust UI for auto-approve lives
// low: single-click "Godkänn". Trust UI for auto-approve lives
// post-V0 (data model supports it via agent_profiles.trust_per_tool).
// medium — single-click "Godkänn".
// high — requires the user to type "godkänn" verbatim. Never auto-
// medium: single-click "Godkänn".
// high: requires the user to type "godkänn" verbatim. Never auto-
// approvable, by design (legal compliance).
//
// Reject is always one-click.
//
// The card posts to the existing /api/pending-operations/<id>/{commit,reject}
// endpoints — same surface the Accounted "Förslag" page uses, so there is
// endpoints: same surface the Accounted "Förslag" page uses, so there is
// exactly one approval source of record.
//
// Structured preview: when the staged envelope carries a preview object, we
@@ -45,7 +45,7 @@ interface Props {
toolName?: string
preview?: unknown
periodStatus?: PeriodStatus
// Fired after a reject that carries a reason — the chat feeds this synthetic
// Fired after a reject that carries a reason: the chat feeds this synthetic
// correction back as a hidden user turn so the agent re-proposes inline.
onRequestCorrection?: (correctionMessage: string) => void
}
@@ -65,14 +65,14 @@ const REJECTION_CATEGORY_LABELS: Record<PendingOperationRejectionCategory, strin
// Subset of fields the commit response may return that the success state
// uses to deep-link to the freshly-created artifact. Different
// operation_types return different shapes — only the ones we actually
// operation_types return different shapes: only the ones we actually
// surface as links are declared.
interface CommitResultData {
journal_entry_id?: string | null
invoice_id?: string | null
customer_id?: string | null
supplier_invoice_id?: string | null
// bulk_book_inbox_items creates N verifikationer, not one artifact — the
// bulk_book_inbox_items creates N verifikationer, not one artifact: the
// executor returns per-item counts instead of a single id. Surfaced as a
// "N bokförda" summary + a link to the ledger (or the sole verifikat).
booked_count?: number
@@ -92,7 +92,7 @@ export default function ApprovalCard({
// Gating the AI re-propose path only: approving/rejecting the staged
// operation is manual ledger work and stays enabled without the AI add-on.
// What's paid is feeding a rejection back so the agent generates a *new*
// proposal (an LLM call) — that's suppressed when the company lacks `ai`.
// proposal (an LLM call): that's suppressed when the company lacks `ai`.
const hasAi = useCapability(CAPABILITY.ai)
const [state, setState] = useState<State>('pending')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
@@ -130,7 +130,7 @@ export default function ApprovalCard({
}
if (!res.ok) {
// Recoverable: the booking posts to BAS accounts not active in the
// chart. Offer to activate them and retry — the op stays pending.
// chart. Offer to activate them and retry: the op stays pending.
const structured = typeof body.error === 'object' && body.error !== null ? body.error : null
if (structured?.code === 'ACCOUNTS_NOT_IN_CHART' && structured.account_numbers?.length) {
setAccountsToActivate(structured.account_numbers)
@@ -178,7 +178,7 @@ export default function ApprovalCard({
setErrorMessage(null)
const categoryLabel = rejectCategory ? REJECTION_CATEGORY_LABELS[rejectCategory] : null
const reason = rejectReason.trim()
// Both fields optional — a bare "Avvisa" still rejects (parity with the
// Both fields optional: a bare "Avvisa" still rejects (parity with the
// granskning dialog and older bodyless clients).
const body =
rejectCategory || reason
@@ -200,12 +200,12 @@ export default function ApprovalCard({
}
setShowRejectForm(false)
setState('rejected')
// Feed the correction back so the agent re-proposes — only when the user
// Feed the correction back so the agent re-proposes: only when the user
// actually said what was wrong. A bare reject just stops here.
const parts = [categoryLabel, reason].filter(Boolean) as string[]
if (hasAi && parts.length > 0) {
onRequestCorrection?.(
`Jag avvisade förslaget. Det som var fel: ${parts.join(' — ')}. Föreslå en korrigerad bokning.`,
`Jag avvisade förslaget. Det som var fel: ${parts.join(', ')}. Föreslå en korrigerad bokning.`,
)
}
} catch (err) {
@@ -242,8 +242,8 @@ export default function ApprovalCard({
}
// Bulk operations (bulk_book_inbox_items) book N underlag at once and return
// counts instead of a single id. Show the outcome ("N bokförda · M
// överhoppade") — a bulk commit silently skips non-bookable items, so
// without this the user can't tell whether anything was booked — and link to
// överhoppade") (a bulk commit silently skips non-bookable items, so
// without this the user can't tell whether anything was booked) and link to
// the ledger list, or straight to the sole verifikat when exactly one landed.
const bulkSummary =
typeof commitResult?.booked_count === 'number'
@@ -259,7 +259,7 @@ export default function ApprovalCard({
// The server's `message` field (e.g. "Operation staged for review …
// Open the Accounted web app to approve or reject it.") was written for
// MCP clients without an inline approval surface. Inside the in-app
// chat it's redundant noise — the agent already narrated the why
// chat it's redundant noise: the agent already narrated the why
// above the card. We keep it accessible via aria-description for
// screen readers but don't render it.
return (
@@ -335,7 +335,7 @@ export default function ApprovalCard({
<div className="space-y-1">
<p className="flex items-center gap-2 text-xs text-destructive">
<AlertTriangle className="h-3.5 w-3.5" />
Hög risk — skriv <strong className="font-semibold">godkänn</strong> för att bekräfta.
Hög risk: skriv <strong className="font-semibold">godkänn</strong> för att bekräfta.
</p>
<input
type="text"
@@ -469,7 +469,7 @@ export default function ApprovalCard({
{/* Keep in sync with EXPIRY_DAYS in
app/api/pending-operations/expire/cron/route.ts. */}
<p className="text-[11px] text-muted-foreground">
Om du inte gör något utgår förslaget automatiskt efter 30 dagar — inget bokförs.
Om du inte gör något utgår förslaget automatiskt efter 30 dagar, inget bokförs.
</p>
</>
)}
@@ -507,24 +507,24 @@ function PreviewBlock({ toolName, preview }: PreviewBlockProps) {
// 20 categories from types/index.ts TransactionCategory. Kept inline so the
// component has no cross-module enum import; sync if the type changes.
const CATEGORY_OPTIONS: { value: string; label: string }[] = [
{ value: 'income_services', label: 'Intäkt — tjänster' },
{ value: 'income_products', label: 'Intäkt — produkter' },
{ value: 'income_other', label: 'Intäkt — övrigt' },
{ value: 'expense_software', label: 'Kostnad — mjukvara' },
{ value: 'expense_equipment', label: 'Kostnad — utrustning' },
{ value: 'expense_office', label: 'Kostnad — kontor' },
{ value: 'expense_travel', label: 'Kostnad — resor' },
{ value: 'expense_marketing', label: 'Kostnad — marknadsföring' },
{ value: 'expense_professional_services', label: 'Kostnad — konsult/tjänster' },
{ value: 'expense_education', label: 'Kostnad — utbildning' },
{ value: 'expense_representation', label: 'Kostnad — representation' },
{ value: 'expense_consumables', label: 'Kostnad — förbrukning' },
{ value: 'expense_vehicle', label: 'Kostnad — fordon' },
{ value: 'expense_telecom', label: 'Kostnad — telefon/internet' },
{ value: 'expense_bank_fees', label: 'Kostnad — bankavgifter' },
{ value: 'expense_card_fees', label: 'Kostnad — kortavgifter' },
{ value: 'expense_currency_exchange', label: 'Kostnad — valutaväxling' },
{ value: 'expense_other', label: 'Kostnad — övrigt' },
{ value: 'income_services', label: 'Intäkt: tjänster' },
{ value: 'income_products', label: 'Intäkt: produkter' },
{ value: 'income_other', label: 'Intäkt: övrigt' },
{ value: 'expense_software', label: 'Kostnad: mjukvara' },
{ value: 'expense_equipment', label: 'Kostnad: utrustning' },
{ value: 'expense_office', label: 'Kostnad: kontor' },
{ value: 'expense_travel', label: 'Kostnad: resor' },
{ value: 'expense_marketing', label: 'Kostnad: marknadsföring' },
{ value: 'expense_professional_services', label: 'Kostnad: konsult/tjänster' },
{ value: 'expense_education', label: 'Kostnad: utbildning' },
{ value: 'expense_representation', label: 'Kostnad: representation' },
{ value: 'expense_consumables', label: 'Kostnad: förbrukning' },
{ value: 'expense_vehicle', label: 'Kostnad: fordon' },
{ value: 'expense_telecom', label: 'Kostnad: telefon/internet' },
{ value: 'expense_bank_fees', label: 'Kostnad: bankavgifter' },
{ value: 'expense_card_fees', label: 'Kostnad: kortavgifter' },
{ value: 'expense_currency_exchange', label: 'Kostnad: valutaväxling' },
{ value: 'expense_other', label: 'Kostnad: övrigt' },
{ value: 'private', label: 'Privat uttag' },
]
@@ -540,7 +540,7 @@ function CategorizeTransactionPreview({
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.
// is non-zero, the other 0: render the active side with D/K prefix.
const vatLines = (preview.vat_lines as
| {
account_number?: string
+3 -3
View File
@@ -16,7 +16,7 @@ import { createClient } from '@/lib/supabase/client'
//
// The three suggestion chips below the headline give users a one-click way
// in. They navigate to /chat/new?intent=…&prompt=… which mounts AgentChat
// inline and swaps to /chat/[id] once the conversation is created — so the
// inline and swaps to /chat/[id] once the conversation is created: so the
// flow stays full-screen instead of opening a slide-in sheet.
const SUGGESTIONS: { label: string; prompt: string }[] = [
{
@@ -44,7 +44,7 @@ export default function ChatEmptyState() {
if (isSandbox) {
const handleCreateAccount = async () => {
const supabase = createClient()
// Sign-out is best-effort — navigate even if Supabase is unreachable
// Sign-out is best-effort: navigate even if Supabase is unreachable
// so the button never looks dead.
try {
await supabase.auth.signOut()
@@ -99,7 +99,7 @@ export default function ChatEmptyState() {
)
}
// Hidden on mobile — the sidebar IS the page when no conversation is open.
// Hidden on mobile: the sidebar IS the page when no conversation is open.
// On desktop, fills the right pane with a centered prompt.
return (
<div className="hidden md:flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">
+6 -6
View File
@@ -9,8 +9,8 @@ import { useAgentSheet } from './AgentSheetProvider'
import { useCompanyOptional } from '@/contexts/CompanyContext'
// Phase C entry surface. Lands here from ReviewCard's "kör" after Phase B
// verify succeeds. Renders AgentChat in fresh-start mode — no
// initialConversationId, no initialMessages — so the auto-fire effect
// verify succeeds. Renders AgentChat in fresh-start mode: no
// initialConversationId, no initialMessages: so the auto-fire effect
// kicks the first invoke. As soon as /api/agent/invoke emits the
// conversation event, the URL swaps to /chat/[id] so reload / share /
// browser-back all work like any other conversation.
@@ -22,7 +22,7 @@ export default function ChatIntakeStarter() {
const companyCtx = useCompanyOptional()
const isSandbox = companyCtx?.isSandbox ?? false
const agentName = identity.displayName?.trim() || 'Din assistent'
// Lock the swap to the first id we see — defensive guard against the
// Lock the swap to the first id we see: defensive guard against the
// AgentChat callback firing twice during React 19 Strict Mode reruns.
const [swapped, setSwapped] = useState(false)
@@ -34,8 +34,8 @@ export default function ChatIntakeStarter() {
<h1 className="font-display text-lg tracking-tight truncate">{agentName} är redo</h1>
<p className="text-xs text-muted-foreground">
{isSandbox
? 'Förhandsvisning — den verkliga konversationen kräver ett konto.'
: 'Några frågor för att lära känna din verksamhet — svara i din egen takt, du kan avsluta när du vill.'}
? 'Förhandsvisning: den verkliga konversationen kräver ett konto.'
: 'Några frågor för att lära känna din verksamhet: svara i din egen takt, du kan avsluta när du vill.'}
</p>
</div>
</header>
@@ -52,7 +52,7 @@ export default function ChatIntakeStarter() {
// Wait for the greeting to finish streaming AND persist before
// swapping the URL. Swapping on the early `conversation` event
// unmounts AgentChat mid-stream, so the greeting is never saved
// and /chat/[id] hydrates empty — the bug where the chat lands
// and /chat/[id] hydrates empty: the bug where the chat lands
// blank and only shows the intro on a later visit.
if (swapped) return
setSwapped(true)
+2 -2
View File
@@ -33,7 +33,7 @@ export default function ChatNewStarter({
<div className="min-w-0">
<h1 className="font-display text-lg tracking-tight truncate">{agentName}</h1>
<p className="text-xs text-muted-foreground truncate">
{isSandbox ? 'Förhandsvisning — avstängd i sandlådan' : 'Ny konversation'}
{isSandbox ? 'Förhandsvisning: avstängd i sandlådan' : 'Ny konversation'}
</p>
</div>
</header>
@@ -48,7 +48,7 @@ export default function ChatNewStarter({
initialMessages={[]}
initialConversationId={null}
onFirstTurnComplete={(id) => {
// Wait for the first turn to finish before swapping the URL —
// Wait for the first turn to finish before swapping the URL:
// otherwise the unmount aborts the in-flight stream and
// /chat/[id] hydrates with only the user message.
if (swapped) return
+5 -5
View File
@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button'
* /api/agent/invoke which is server-gated by guardSandbox(), so the input
* would just produce a 403. Instead of showing that as a raw error, we
* render a brief description of what the assistant does in prod and a
* single "Skapa konto" CTA. Same chrome (header) as the real chat — only
* single "Skapa konto" CTA. Same chrome (header) as the real chat: only
* the body swaps out.
*
* Mirrors the look of the empty-state but with an explanation block so the
@@ -28,13 +28,13 @@ export default function SandboxAgentPreview({
async function handleCreateAccount() {
const supabase = createClient()
// Sign-out is best-effort — a transient Supabase failure shouldn't
// Sign-out is best-effort: a transient Supabase failure shouldn't
// strand the user on a dead button; navigate to /register either way
// and let the registration flow re-init auth state.
try {
await supabase.auth.signOut()
} catch {
// Intentionally swallowed — see comment above.
// Intentionally swallowed: see comment above.
}
router.push('/register')
}
@@ -51,7 +51,7 @@ export default function SandboxAgentPreview({
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
{name} är en specialiserad bokföringsassistent som kan
kategorisera transaktioner, granska leverantörsfakturor och
svara på frågor om din bokföring — kalibrerad mot dina
svara på frågor om din bokföring: kalibrerad mot dina
kontoplaner, verksamhet och svensk skattelagstiftning.
</p>
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">
@@ -66,7 +66,7 @@ export default function SandboxAgentPreview({
<span className="text-foreground mt-0.5">·</span>
<span>
<span className="text-foreground">Föreslår bokföring</span>{' '}
för oklassificerade transaktioner — du godkänner i ett klick.
för oklassificerade transaktioner: du godkänner i ett klick.
</span>
</li>
<li className="flex items-start gap-2">
+3 -3
View File
@@ -1,13 +1,13 @@
// Avatar registry for the specialized accountant agent.
//
// We use the dicebear "notionists" style — clean line illustrations that
// We use the dicebear "notionists" style: clean line illustrations that
// match the editorial monochrome brand without the cartoony feel of most
// avatar libraries. 8 hand-picked seeds give distinct faces without being
// overwhelming. The user picks one during Phase B review; the choice is
// persisted as agent_profiles.avatar_id.
//
// URLs are served by dicebear's free CDN. They're public SVGs derived from
// the seed only — no user data leaves gnubok. If we ever need fully offline
// the seed only: no user data leaves gnubok. If we ever need fully offline
// generation, swap to @dicebear/core npm package and render server-side.
export interface AvatarOption {
@@ -22,7 +22,7 @@ function dicebearNotionists(seed: string): string {
return `https://api.dicebear.com/9.x/notionists/svg?seed=${encodeURIComponent(seed)}&radius=50&backgroundColor=f5f3ed`
}
// Eight neutral seeds — names chosen to produce visibly different faces.
// Eight neutral seeds: names chosen to produce visibly different faces.
// Labels are just for the picker tooltip; the user names the agent
// themselves in the adjacent text field.
export const AVATAR_OPTIONS: readonly AvatarOption[] = [
+1 -1
View File
@@ -1,4 +1,4 @@
// Shared display helpers for the agent conversation list — used by both the
// Shared display helpers for the agent conversation list: used by both the
// full-page /chat sidebar (ChatSidebar) and the in-sheet "resume conversation"
// list (AgentSessionList). Pure functions; no React. Keeping them in one place
// means the Idag / Igår / Denna vecka / Äldre grouping and the relative-time
+4 -4
View File
@@ -42,15 +42,15 @@ export default function ArticleForm({
const supabase = createClient()
const t = useTranslations('form_article')
// Active class-3 (revenue) accounts for the combobox. The combobox accepts
// unknown 4-digit numbers optimistically — the API answers with
// unknown 4-digit numbers optimistically: the API answers with
// ACCOUNTS_NOT_IN_CHART for activatable BAS accounts, and the host page's
// ActivateAccountsDialog flow takes over (same UX as the journal entry form).
const [revenueAccounts, setRevenueAccounts] = useState<BASAccount[]>([])
// Inline account creation: what the user typed in the combobox when they hit
// "Skapa konto" — non-null opens AddAccountDialog prefilled with it.
// "Skapa konto": non-null opens AddAccountDialog prefilled with it.
const [createAccountPrefill, setCreateAccountPrefill] = useState<string | null>(null)
// Momsregistrerad? A non-VAT-registered company never charges moms, so the
// VAT field is hidden and the rate forced to 0 — mirrors the invoice editor.
// VAT field is hidden and the rate forced to 0: mirrors the invoice editor.
const [vatRegistered, setVatRegistered] = useState(true)
async function fetchRevenueAccounts() {
@@ -393,7 +393,7 @@ export default function ArticleForm({
{/* Inline custom-account creation (renders in a portal, outside the form).
After create: refresh the chart and select the new number as the
article's revenue account — mirrors the journal entry form. */}
article's revenue account: mirrors the journal entry form. */}
<AddAccountDialog
open={createAccountPrefill != null}
onOpenChange={(next) => {
+3 -3
View File
@@ -43,7 +43,7 @@ function isMobile(): boolean {
/**
* sessionStorage key holding an in-flight BankID session across the mobile
* return-redirect. On iOS the BankID app returns to the SAME Safari tab by
* reloading it (see launchBankIdApp), which wipes React state — so we stash the
* reloading it (see launchBankIdApp), which wipes React state, so we stash the
* session here and resume polling on the next mount.
*/
const PENDING_KEY = 'bankid:pending'
@@ -63,7 +63,7 @@ function persistPending(session: BankIdSession, mode: string): void {
JSON.stringify({ session, mode, ts: Date.now() } satisfies PendingBankId)
)
} catch {
// sessionStorage unavailable (private mode / quota) — auto-resume just won't
// sessionStorage unavailable (private mode / quota): auto-resume just won't
// fire; the user can still switch back to the tab manually as before.
}
}
@@ -93,7 +93,7 @@ function clearPending(): void {
/**
* Launch the BankID app on the same (mobile) device.
*
* Uses the universal link https://app.bankid.com/ — NOT the bankid:/// custom
* Uses the universal link https://app.bankid.com/: NOT the bankid:/// custom
* scheme. A custom-scheme launch has no association with the originating Safari
* tab, so on iOS the post-auth redirect opens in a NEW tab (git history: commit
* 3bc652cc reverted a redirect for exactly that reason). The universal link is
+1 -1
View File
@@ -56,7 +56,7 @@ export function BankIdQrCode({ qrStartToken, qrStartSecret }: BankIdQrCodeProps)
})
setSvgData(svg)
} catch {
// Silently fail — next tick will retry
// Silently fail: next tick will retry
}
elapsedRef.current++
+14 -14
View File
@@ -4,14 +4,14 @@
* Digital inlämning av årsredovisning (iXBRL → Bolagsverket).
*
* Three steps below the year-end ÅR editors:
* 1. Granska — the generated iXBRL rendered in an iframe (the XHTML *is*
* 1. Granska: the generated iXBRL rendered in an iframe (the XHTML *is*
* the filed presentation) + pre-flight validation results + download
* for manual filing (the self-hosted/no-extension path).
* 2. Skicka in — only when the bolagsverket extension responds: avtalstext
* 2. Skicka in: only when the bolagsverket extension responds: avtalstext
* acceptance → kontrollera-utfall → upload till eget utrymme → kvittens
* with "signera hos Bolagsverket"-link. The fastställelseintyg is signed
* with e-legitimation at Bolagsverket, never here.
* 3. Status — submission history driven by webhooks + polling fallback.
* 3. Status: submission history driven by webhooks + polling fallback.
*
* Year-end surface: copy stays Swedish in both locales (see i18n rules).
*/
@@ -36,7 +36,7 @@ import {
/** Inlämningen till Bolagsverket väntar på avtal + organisationscertifikat
* (M0). Tills dess visas hela digital inlämning-sektionen blurrad med en
* "Kommer snart"-skylt — endast PDF-nedladdningen på ÅR-sidan är användbar.
* "Kommer snart"-skylt: endast PDF-nedladdningen på ÅR-sidan är användbar.
* Flippa till false när integrationen är godkänd. Importeras också av
* ÅR-sidan som blurrar sina Bolagsverket-delar med samma flagga. */
export const INLAMNING_COMING_SOON = true
@@ -108,9 +108,9 @@ function normalizePnr(raw: string): string | null {
const STATUS_BADGES: Record<string, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline' }> = {
draft: { label: 'Utkast', variant: 'outline' },
kontrollerad: { label: 'Kontrollerad', variant: 'secondary' },
uploaded: { label: 'Uppladdad — väntar på signering', variant: 'warning' },
uploaded: { label: 'Uppladdad: väntar på signering', variant: 'warning' },
inkommen: { label: 'Inkommen till Bolagsverket', variant: 'secondary' },
forelagd: { label: 'Föreläggande — åtgärd krävs', variant: 'destructive' },
forelagd: { label: 'Föreläggande: åtgärd krävs', variant: 'destructive' },
komplettering: { label: 'Komplettering inlämnad', variant: 'secondary' },
registrerad: { label: 'Registrerad', variant: 'success' },
avslutad: { label: 'Avslutad utan registrering', variant: 'destructive' },
@@ -167,13 +167,13 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
setSubmissions((body.data ?? []) as SubmissionRow[])
setSubmissionsError(null)
} else {
setSubmissionsError('Kunde inte hämta inlämningshistoriken — försök igen.')
setSubmissionsError('Kunde inte hämta inlämningshistoriken: försök igen.')
}
} catch {
// Non-blocking: the call sites fire-and-forget (`void loadSubmissions()`),
// so a network failure must surface here instead of as an unhandled
// rejection.
setSubmissionsError('Kunde inte hämta inlämningshistoriken — försök igen.')
setSubmissionsError('Kunde inte hämta inlämningshistoriken: försök igen.')
} finally {
setLoadingSubmissions(false)
}
@@ -333,10 +333,10 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
{/* Steg: Granska & validera */}
<Card>
<CardHeader>
<CardTitle className="text-base">Digital inlämning — granska &amp; validera (iXBRL)</CardTitle>
<CardTitle className="text-base">Digital inlämning: granska &amp; validera (iXBRL)</CardTitle>
<p className="text-sm text-muted-foreground">
Bolagsverket tar emot årsredovisningen som iXBRL (XHTML). Dokumentet nedan är
exakt det som lämnas in — granska det som den slutliga presentationen.
exakt det som lämnas in: granska det som den slutliga presentationen.
</p>
</CardHeader>
<CardContent className="space-y-4 text-sm">
@@ -350,7 +350,7 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
onChange={(event) => setUtdelning(event.target.value)}
/>
<p className="text-xs text-muted-foreground">
Ingår i resultatdispositionen i dokumentet — 0 betyder att allt
Ingår i resultatdispositionen i dokumentet: 0 betyder att allt
balanseras i ny räkning. Beloppet följer med förhandsgranskning,
nedladdning och inlämning.
</p>
@@ -421,7 +421,7 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
<p className="text-sm text-muted-foreground">
Årsredovisningen laddas upp till företagets eget utrymme hos Bolagsverket.
Undertecknaren får ett e-postmeddelande och signerar fastställelseintyget med
e-legitimation hos Bolagsverket — först då är årsredovisningen inlämnad.
e-legitimation hos Bolagsverket: först då är årsredovisningen inlämnad.
</p>
</CardHeader>
<CardContent className="space-y-4 text-sm">
@@ -515,7 +515,7 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
</div>
<p className="text-xs text-muted-foreground">
Personnumren skickas till Bolagsverket för att skapa eget utrymme och bjuda
in undertecknaren. De sparas inte i Accounted — endast en teknisk
in undertecknaren. De sparas inte i Accounted: endast en teknisk
referens (hash) lagras.
</p>
@@ -665,7 +665,7 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
</p>
{submission.status === 'forelagd' && (
<p className="text-xs text-destructive">
Bolagsverket har skickat ett föreläggande — läs brevet, åtgärda
Bolagsverket har skickat ett föreläggande: läs brevet, åtgärda
bristerna och lämna in en komplettering (ny inlämning ovan).
</p>
)}
+9 -9
View File
@@ -33,12 +33,12 @@ interface AccountComboboxProps {
// Label shown next to catalogue-only (not-yet-activated) accounts. Defaults
// to Swedish; bilingual hosts pass a localized string.
notActivatedLabel?: string
// Extra classes merged into the trigger Input — callers pass `h-8` for dense
// Extra classes merged into the trigger Input: callers pass `h-8` for dense
// table rows, omit it to use the default Input height.
className?: string
// Optional callback ref to the underlying <input>, invoked alongside the
// internal one. Lets a parent imperatively focus the field (e.g. auto-advance
// to the next konteringsrad's account on Enter — see JournalEntryForm.focusAccount).
// to the next konteringsrad's account on Enter: see JournalEntryForm.focusAccount).
inputRef?: React.RefCallback<HTMLInputElement>
}
@@ -50,7 +50,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
const internalInputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
// Whether the user has typed or arrow-navigated since the field was focused.
// Enter only selects the highlighted item after an actual interaction — a
// Enter only selects the highlighted item after an actual interaction: a
// bare Enter on a freshly-focused field must not grab the first account in
// the list (it either re-commits the current value or bubbles to the form).
const hasInteractedRef = useRef(false)
@@ -149,7 +149,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
setIsOpen(true)
e.preventDefault()
} else if (e.key === 'Enter' && /^\d{4}$/.test(search)) {
// Dropdown closed but a full account number sits in the field — treat
// Dropdown closed but a full account number sits in the field: treat
// Enter as a re-commit so focus advances to the amount field.
e.preventDefault()
onCommit?.(search)
@@ -173,12 +173,12 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
e.preventDefault()
selectAccount(flatList[highlightedIndex].account_number)
} else if (/^\d{4}$/.test(search)) {
// Committed number, no new interaction — advance without re-selecting.
// Committed number, no new interaction: advance without re-selecting.
e.preventDefault()
setIsOpen(false)
onCommit?.(search)
} else {
// Nothing actively chosen — close the list and let the event bubble
// Nothing actively chosen: close the list and let the event bubble
// so the form-level Enter (open review when balanced) can take over.
setIsOpen(false)
}
@@ -195,14 +195,14 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
hasInteractedRef.current = true
setSearch(newValue)
// Emit any 4-digit numeric value to the parent. Unknown BAS numbers are
// accepted optimistically — the submit-time ActivateAccountsDialog lets
// accepted optimistically: the submit-time ActivateAccountsDialog lets
// the user activate missing accounts without leaving the form. A complete
// 4-digit number is treated as a commit so focus can advance to the amount.
if (/^\d{4}$/.test(newValue)) {
onChange(newValue)
// Only treat as a commit when the value newly becomes this account, so
// editing an already-committed number doesn't keep stealing focus. On
// commit, close the dropdown too — focus advances to the amount field, so
// commit, close the dropdown too: focus advances to the amount field, so
// a lingering open list would just cover the rows below.
if (newValue !== value) {
onCommit?.(newValue)
@@ -225,7 +225,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
// the rows below when focus advances via keyboard (Enter/Tab).
setIsOpen(false)
// Small delay to allow dropdown click to fire first. Keep any 4-digit
// numeric value even if it's not in the currently-active chart — the
// numeric value even if it's not in the currently-active chart: the
// submit handler will prompt to activate it.
setTimeout(() => {
const isFourDigit = /^\d{4}$/.test(search)
@@ -25,7 +25,7 @@ export interface AccrualFormValue {
balanceAccount: string
}
// The statutory BAS interim accounts per direction — a fixed list reads
// The statutory BAS interim accounts per direction: a fixed list reads
// better than a full account combobox and mirrors the DB CHECK (17xx/29xx).
const BALANCE_ACCOUNT_OPTIONS: Record<AccrualDirection, Array<{ value: string; label: string }>> = {
expense: [
@@ -47,7 +47,7 @@ const BALANCE_ACCOUNT_OPTIONS: Record<AccrualDirection, Array<{ value: string; l
* Per-line periodisering panel for the invoice editors: service period +
* interim balance account + a live "N månader × X kr" preview. The parent
* owns the toggle; this renders only while periodisering is active on the
* line. VAT is never affected — only the net amount is deferred.
* line. VAT is never affected: only the net amount is deferred.
*/
export default function AccrualPeriodControl({
direction,
@@ -58,7 +58,7 @@ export default function AccrualPeriodControl({
idPrefix,
}: {
direction: AccrualDirection
/** Net line amount (ex VAT) — drives the preview and the K2 hint. */
/** Net line amount (ex VAT): drives the preview and the K2 hint. */
amount: number
value: AccrualFormValue
onChange: (next: AccrualFormValue) => void
@@ -15,12 +15,12 @@ interface AttachDocumentPreviewProps {
* the pairing without cross-referencing IDs.
*/
export function AttachDocumentPreview({ data, params }: AttachDocumentPreviewProps) {
const txDescription = (data.transaction_description as string) || '—'
const txDescription = (data.transaction_description as string) || '-'
const txAmount = data.transaction_amount as number | undefined
const txCurrency = (data.transaction_currency as string) || 'SEK'
const txDate = data.transaction_date as string | undefined
const docFileName = (data.document_file_name as string) || '—'
const docFileName = (data.document_file_name as string) || '-'
const docVendor = data.document_vendor_name as string | undefined
const docAmount = data.document_amount as number | undefined
const docCurrency = (data.document_currency as string) || txCurrency
@@ -41,11 +41,11 @@ export function AttachDocumentPreview({ data, params }: AttachDocumentPreviewPro
<div className="space-y-3 text-sm">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<PreviewCard label="Transaktion">
<Row label="Datum" value={txDate ? formatDate(txDate) : '—'} tabular />
<Row label="Datum" value={txDate ? formatDate(txDate) : '-'} tabular />
<Row label="Beskrivning" value={txDescription} />
<Row
label="Belopp"
value={typeof txAmount === 'number' ? formatCurrency(txAmount, txCurrency) : '—'}
value={typeof txAmount === 'number' ? formatCurrency(txAmount, txCurrency) : '-'}
tabular
/>
</PreviewCard>
@@ -78,7 +78,7 @@ export function AttachDocumentPreview({ data, params }: AttachDocumentPreviewPro
<p className="mt-1 text-xs">
Befintligt dokument{existingDocName ? ` (${existingDocName})` : ''} är markerat som
räkenskapsinformation enligt BFL 7 kap. Att ersätta det här gör det tidigare
verifikationsunderlaget otillgängligt — bekräfta att du har originalet sparat innan
verifikationsunderlaget otillgängligt: bekräfta att du har originalet sparat innan
du godkänner.
</p>
</div>
@@ -44,7 +44,7 @@ interface AttachmentPreviewSheetProps {
// Tri-state integrity result so a transport/parse failure does not get
// collapsed into "valid". Document bytes are immutable (WORM), so once we
// have a definitive valid/invalid we can also memoise across re-opens of
// the sheet — the integrity probe is the most expensive call on this
// the sheet: the integrity probe is the most expensive call on this
// surface and there's no need to re-run it for the same document twice in
// a session.
type IntegrityState = 'valid' | 'invalid' | 'error'
@@ -53,7 +53,7 @@ const integrityCache = new Map<string, IntegrityState>()
function isImageType(type: string | null, fileName?: string): boolean {
if (type?.startsWith('image/')) return true
// Legacy uploads and browsers that fail to sniff sometimes leave mime_type
// null or set it to application/octet-stream — fall back to filename.
// null or set it to application/octet-stream: fall back to filename.
if (type === null || type === 'application/octet-stream') {
return /\.(jpe?g|png|gif|webp|svg)$/i.test(fileName ?? '')
}
@@ -100,7 +100,7 @@ export default function AttachmentPreviewSheet({
const { data } = await res.json()
const list: DocumentRecord[] = data || []
// Pull a signed download_url for each — used by the
// Pull a signed download_url for each: used by the
// "open in new tab" link. The iframe/img sources use the
// same-origin inline proxy and do not need download_url.
const enriched = await Promise.all(
@@ -150,7 +150,7 @@ export default function AttachmentPreviewSheet({
try {
const r = await fetch(`/api/documents/${doc.id}/integrity`)
if (!r.ok) {
// Server reachable but returned a non-2xx — treat as unknown.
// Server reachable but returned a non-2xx: treat as unknown.
// Caching the error would stick across reloads of the sheet,
// which is not what we want for transient 5xx.
return [doc.id, 'error' as const, false] as const
@@ -358,8 +358,8 @@ export default function AttachmentPreviewSheet({
// preview still renders; the user can fall back to
// Chrome's own viewer error UI if the bytes are
// also corrupt. Surfacing this rather than falling
// through silently to "valid" satisfies SOC 2 CC8.1
// — failed checks must not be masked.
// through silently to "valid" satisfies SOC 2 CC8.1:
// failed checks must not be masked.
<p className="text-xs text-muted-foreground">
{t('integrity_unknown')}
</p>
@@ -374,7 +374,7 @@ export default function AttachmentPreviewSheet({
{t('not_previewable')}
{doc.download_url && (
<>
{' — '}
{': '}
<a
href={doc.download_url}
target="_blank"
@@ -41,7 +41,7 @@ export default function BookingTemplatePicker({ onApply, entityType, defaultAmou
const [selectedId, setSelectedId] = useState<string | null>(null)
// Prefill the amount from the caller's known total each time the picker
// opens. Only when provided — callers without a known amount (e.g. the
// opens. Only when provided: callers without a known amount (e.g. the
// journal-entry form) keep the blank-then-type behaviour.
useEffect(() => {
if (open && defaultAmount != null && defaultAmount > 0) {
+1 -1
View File
@@ -174,7 +174,7 @@ export default function ChartOfAccounts() {
setSruValue(account.sru_code || '')
}}
>
{account.sru_code || '—'}
{account.sru_code || '-'}
</button>
)}
</td>
@@ -32,7 +32,7 @@ interface Props {
let seedIdCounter = 0
// Map the booked IB's lines into editable rows. account_name isn't stored on
// the line, so resolve it from BAS for display (cosmetic — only account_number
// the line, so resolve it from BAS for display (cosmetic: only account_number
// + amounts are sent on save).
function seedRowsFromEntry(entry: JournalEntry): EditableRow[] {
const lines = ((entry.lines || []) as JournalEntryLine[])
@@ -134,12 +134,12 @@ export default function CorrectOpeningBalanceDialog({
</DialogDescription>
</DialogHeader>
{/* Storno explanation — a booked verifikat can't be edited in place */}
{/* Storno explanation: a booked verifikat can't be edited in place */}
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">
En bokförd verifikation kan inte ändras direkt (Bokföringslagen). När du sparar stornas
den gamla IB-verifikationen och en ny bokförs — båda sparas som en spårbar rättelse.
den gamla IB-verifikationen och en ny bokförs: båda sparas som en spårbar rättelse.
</p>
</div>
@@ -11,7 +11,7 @@
* - /transactions row menu when transaction.journal_entry_id is set
*
* Surfacing the storno+rättelse flow at the point where users notice the
* mistake matters — the dialog itself was already correct (it pre-fills
* mistake matters: the dialog itself was already correct (it pre-fills
* lines and emits the storno+correction pair per BFL), but it was hidden
* behind a deep-link the customer never reached.
*/
+2 -2
View File
@@ -37,7 +37,7 @@ export default function CorrectionChain({ currentEntryId, chain }: Props) {
const getRole = useGetRole()
if (chain.length === 0) return null
// Combine current entry isn't in chain — chain is "other" entries
// Combine current entry isn't in chain: chain is "other" entries
// Sort chronologically
const sorted = [...chain].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
@@ -63,7 +63,7 @@ export default function CorrectionChain({ currentEntryId, chain }: Props) {
// A cancelled entry is residue from an aborted correction attempt:
// it was voided before taking effect and its lines were removed, so
// it always sums to 0,00. Without the status badge it renders
// exactly like a live storno — dim it and say what it is.
// exactly like a live storno: dim it and say what it is.
const isCancelled = entry.status === 'cancelled'
return (
@@ -66,7 +66,7 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
const { data } = await res.json()
setAccounts(data || [])
} catch {
// Accounts will be empty — user can still type account numbers manually
// Accounts will be empty: user can still type account numbers manually
}
}
@@ -158,11 +158,11 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
<li>En ny verifikation med dina rättade uppgifter</li>
</ol>
<p className="mt-2">
Rättelsen bokförs i samma räkenskapsperiod som originalet — du hittar den under originalets räkenskapsår.
Rättelsen bokförs i samma räkenskapsperiod som originalet: du hittar den under originalets räkenskapsår.
</p>
</div>
{/* Original entry metadata — lines live inside CorrectionPreview below */}
{/* Original entry metadata: lines live inside CorrectionPreview below */}
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
<span className="text-muted-foreground">Original</span>
@@ -180,7 +180,7 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
<div className="space-y-1">
<p className="text-sm font-medium">Rättade rader</p>
<p className="text-xs text-muted-foreground">
Det här är hela den nya verifikationen — alla konton som ska finnas kvar måste stå
Det här är hela den nya verifikationen: alla konton som ska finnas kvar måste stå
kvar. Tar du bort ett konto nollställs det (stornon återför det). Vill du bara återföra
hela verifikatet utan att ersätta det, använd Återför (storno) istället.
</p>
+7 -7
View File
@@ -31,12 +31,12 @@ export default function CorrectionPreview({ originalLines, correctedLines }: Pro
// An account that was on the original but the user dropped from the rättelse:
// the storno still drains it to zero (delta = −original). Flag it so the cell
// reads "tas bort" instead of a bare "–", which would imply "unchanged".
// reads "tas bort" instead of a bare "-", which would imply "unchanged".
const isRemoved = (row: AccountRow) =>
hasAnyCorrection && !row.correctionPresent && Math.abs(row.original) >= 0.005
// The per-account deltas sum to the corrected lines' debit − credit. A non-zero
// sum means the proposed rättelse is not yet balanced — surface that here so the
// sum means the proposed rättelse is not yet balanced: surface that here so the
// förändring column is read as a work-in-progress, not a miscalculation.
const netDelta = rows.reduce((sum, r) => sum + r.delta, 0)
const unbalanced = hasAnyCorrection && Math.abs(netDelta) >= 0.005
@@ -76,12 +76,12 @@ export default function CorrectionPreview({ originalLines, correctedLines }: Pro
{formatSignedAmount(row.storno)}
</td>
<td className={`px-3 py-1.5 text-right tabular-nums ${isRemoved(row) ? 'text-muted-foreground' : signClass(row.correction)}`}>
{!hasAnyCorrection ? '–' : isRemoved(row) ? 'tas bort' : formatSignedAmount(row.correction)}
{!hasAnyCorrection ? '-' : isRemoved(row) ? 'tas bort' : formatSignedAmount(row.correction)}
</td>
<td
className={`px-3 py-1.5 text-right tabular-nums border-l font-medium ${signClass(row.delta)}`}
>
{hasAnyCorrection ? formatSignedAmount(row.delta) : '–'}
{hasAnyCorrection ? formatSignedAmount(row.delta) : '-'}
</td>
</tr>
))}
@@ -95,7 +95,7 @@ export default function CorrectionPreview({ originalLines, correctedLines }: Pro
<div className="flex items-center justify-between gap-2">
<AccountNumber number={row.account_number} showName size="sm" />
<span className={`text-sm font-medium tabular-nums ${signClass(row.delta)}`}>
{hasAnyCorrection ? formatSignedAmount(row.delta) : '–'}
{hasAnyCorrection ? formatSignedAmount(row.delta) : '-'}
</span>
</div>
<dl className="grid grid-cols-3 gap-2 text-xs">
@@ -110,7 +110,7 @@ export default function CorrectionPreview({ originalLines, correctedLines }: Pro
<div>
<dt className="text-muted-foreground">Rättelse</dt>
<dd className={`tabular-nums ${isRemoved(row) ? 'text-muted-foreground' : signClass(row.correction)}`}>
{!hasAnyCorrection ? '–' : isRemoved(row) ? 'tas bort' : formatSignedAmount(row.correction)}
{!hasAnyCorrection ? '-' : isRemoved(row) ? 'tas bort' : formatSignedAmount(row.correction)}
</dd>
</div>
</dl>
@@ -124,7 +124,7 @@ export default function CorrectionPreview({ originalLines, correctedLines }: Pro
</p>
{unbalanced && (
<p className="text-xs text-destructive">
Förslaget balanserar inte ännu – debet och kredit i rättelsen måste vara lika.
Förslaget balanserar inte ännu: debet och kredit i rättelsen måste vara lika.
</p>
)}
</div>
@@ -79,7 +79,7 @@ export default function CreatePeriodDialog({ open, onOpenChange, entryDate, peri
if (!res.ok) {
const err = result?.error
// Blocked by an open prior year — surface an inline "lås och försök
// Blocked by an open prior year: surface an inline "lås och försök
// igen" path instead of a dead-end toast.
if (
err &&
@@ -123,7 +123,7 @@ export default function CreatePeriodDialog({ open, onOpenChange, entryDate, peri
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
// An already-locked period is fine — keep going.
// An already-locked period is fine: keep going.
if (body?.error?.code === 'PERIOD_LOCK_ALREADY_LOCKED') continue
toast({
title: `Kunde inte låsa ${p.name}`,
@@ -183,14 +183,14 @@ export default function CreatePeriodDialog({ open, onOpenChange, entryDate, peri
<p className="font-medium">Föregående räkenskapsår är öppet</p>
<p className="text-muted-foreground">
Du måste låsa föregående räkenskapsår innan du kan skapa ett nytt.
Låsningen är vändbar — du kan låsa upp året igen för att bokföra
Låsningen är vändbar: du kan låsa upp året igen för att bokföra
bokslutsposter.
</p>
</div>
<ul className="space-y-0.5 text-muted-foreground">
{blockingPeriods.map((p) => (
<li key={p.id} className="tabular-nums">
{p.name} ({p.period_start} – {p.period_end})
{p.name} ({p.period_start} till {p.period_end})
</li>
))}
</ul>
@@ -112,7 +112,7 @@ export default function DocumentUploadZone({
error: err,
fileName: file.fileName,
})
return { ...file, status: 'error', error: 'Uppladdning misslyckades — nätverksfel' }
return { ...file, status: 'error', error: 'Uppladdning misslyckades: nätverksfel' }
}
}, [journalEntryId])
@@ -12,17 +12,17 @@ import { cn } from '@/lib/utils'
*
* Renders by document id through the same-origin inline proxy
* (/api/documents/:id/inline). PDFs use <object type="application/pdf"> rather
* than <iframe> — Chrome intermittently blocks PDFs in a frame even with a
* than <iframe>: Chrome intermittently blocks PDFs in a frame even with a
* permissive CSP (see the note in AttachmentPreviewSheet), and <object>
* invokes the PDF plugin directly. Images use <img>.
*
* Unlike AttachmentPreviewSheet this is keyed off a document id, not a
* journal_entry_id — during manual booking the entry does not exist yet.
* journal_entry_id: during manual booking the entry does not exist yet.
*/
function isImageType(type: string | null, fileName?: string | null): boolean {
if (type?.startsWith('image/')) return true
// Legacy uploads sometimes leave mime_type null or application/octet-stream —
// Legacy uploads sometimes leave mime_type null or application/octet-stream:
// fall back to the filename extension.
if (type === null || type === 'application/octet-stream') {
return /\.(jpe?g|png|gif|webp|svg)$/i.test(fileName ?? '')
@@ -45,7 +45,7 @@ interface DocumentViewerPaneProps {
mime?: string | null
/** Pre-known signed URL for the "open in new tab" link. Optional. */
downloadUrl?: string | null
/** Optional filename — used for mime sniffing on legacy/octet-stream files. */
/** Optional filename: used for mime sniffing on legacy/octet-stream files. */
fileName?: string | null
className?: string
}
@@ -67,8 +67,8 @@ export default function DocumentViewerPane({
// Resolve mime / download_url from the documents API only when the caller did
// not supply a mime (e.g. a pre-linked transaction document). When a mime is
// provided (fresh upload, inbox preview) we skip the round-trip entirely.
// setState happens only inside the async callbacks — never synchronously in
// the effect body — to avoid cascading renders (react-hooks/set-state-in-effect).
// setState happens only inside the async callbacks: never synchronously in
// the effect body: to avoid cascading renders (react-hooks/set-state-in-effect).
useEffect(() => {
if (!documentId || mimeProp) return
let cancelled = false
@@ -83,7 +83,7 @@ export default function DocumentViewerPane({
})
})
.catch(() => {
// preview is best-effort — record an empty result so we stop "loading"
// preview is best-effort: record an empty result so we stop "loading"
if (!cancelled) setFetched({ id: documentId, mime: null, url: null })
})
return () => {
@@ -151,7 +151,7 @@ export default function DocumentViewerPane({
>
<div className="flex h-full w-full items-center justify-center p-4 text-center text-sm text-muted-foreground">
{t('not_previewable')}
{' — '}
{': '}
<a
href={newTabHref}
target="_blank"
+1 -1
View File
@@ -264,7 +264,7 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
onSaved()
onOpenChange(false)
} catch {
// Error handled silently — toast is in parent
// Error handled silently: toast is in parent
} finally {
setIsSaving(false)
}
@@ -22,7 +22,7 @@ interface Props {
* Edit a DRAFT verifikat. Wraps JournalEntryForm in edit mode, pre-filled from
* the draft's header + lines; the form PATCHes the entry in place and it stays
* a draft (the user posts it separately). Only ever opened for status==='draft'
* entries — the engine + DB triggers reject edits on committed entries anyway.
* entries: the engine + DB triggers reject edits on committed entries anyway.
*/
export default function EditDraftEntryDialog({ entry, open, onOpenChange, onUpdated }: Props) {
const t = useTranslations('bookkeeping')
@@ -35,7 +35,7 @@ export default function EditDraftEntryDialog({ entry, open, onOpenChange, onUpda
debit_amount: Number(l.debit_amount) > 0 ? String(l.debit_amount) : '',
credit_amount: Number(l.credit_amount) > 0 ? String(l.credit_amount) : '',
line_description: l.line_description || '',
// Carry the line's dimensions into the form — the PATCH replaces all
// Carry the line's dimensions into the form: the PATCH replaces all
// lines, so omitting this would silently strip existing tags.
dimensions:
l.dimensions && Object.keys(l.dimensions).length > 0 ? { ...l.dimensions } : undefined,
@@ -48,7 +48,7 @@ export interface FiscalPeriodValidation {
}
/**
* Shared validation for the first fiscal period — used by both onboarding Step 3
* Shared validation for the first fiscal period: used by both onboarding Step 3
* and the settings FiscalPeriodEditor. Returns Swedish error copy.
*/
export function validateFirstPeriod(
@@ -112,7 +112,7 @@ interface FiscalPeriodDateFieldsProps {
/**
* Shared first-fiscal-period date entry: day-level start (native date input),
* caller-provided end-date control, and a Swedish summary card with inline
* validation errors (6–18 months, EF calendar-year rule, last-day-of-month).
* validation errors (6-18 months, EF calendar-year rule, last-day-of-month).
*
* Used by onboarding Step 3 and the settings FiscalPeriodEditor so the two
* screens stay in lockstep.
@@ -155,7 +155,7 @@ export function FiscalPeriodDateFields({
</div>
{startDate && endDate && (
<p className="text-sm text-muted-foreground">
{formatSwedishDate(startDate)} – {formatSwedishDate(endDate)}
{formatSwedishDate(startDate)}: {formatSwedishDate(endDate)}
</p>
)}
{validation.months !== null && (
@@ -163,7 +163,7 @@ export function FiscalPeriodDateFields({
className={`text-xs ${validation.error ? 'text-destructive' : 'text-muted-foreground'}`}
>
{validation.months} månader
{validation.error ? ` — ${validation.error}` : ''}
{validation.error ? `: ${validation.error}` : ''}
</p>
)}
{validation.error && validation.months === null && (
@@ -26,7 +26,7 @@ import { FileText, ImageIcon, Loader2, Search, Inbox, Eye } from 'lucide-react'
// Two modes:
// - Link mode (JournalEntryAttachments, "Välj från inkorgen"): `journalEntryId`
// is set and picking a document immediately links it to the journal entry AND
// stamps the inbox item so it drops out of the active inbox — see
// stamps the inbox item so it drops out of the active inbox: see
// app/api/documents/[id]/link/route.ts.
// - Select mode (TransactionBookingDialog, "Välj befintligt underlag"): the
// journal entry does not exist yet, so `onSelect` is provided instead and the
@@ -121,7 +121,7 @@ export default function InboxDocumentPicker({ open, onClose, journalEntryId, onL
}, [items, search])
async function handlePick(item: AvailableInboxDoc) {
// Select mode: the journal entry doesn't exist yet — hand the pick to the
// Select mode: the journal entry doesn't exist yet: hand the pick to the
// parent and let it link after creation. Clear the preview first: the
// preview dialog's open state is `previewItem !== null`, so leaving it set
// would strand a floating preview after the picker closes (the component
@@ -77,7 +77,7 @@ export default function JournalEntryAttachments({
const [uploadFiles, setUploadFiles] = useState<UploadedFile[]>([])
// Docs listed here are filtered by journal_entry_id, so every row is bound
// to a verifikation — BFL 7 kap 2§ blocks deletion. "Ta bort" therefore
// to a verifikation: BFL 7 kap 2§ blocks deletion. "Ta bort" therefore
// surfaces the educational modal; "Ersätt" goes through createNewVersion()
// so the original stays in the version chain.
const [blockedDoc, setBlockedDoc] = useState<DocumentRecord | null>(null)
@@ -97,7 +97,7 @@ export default function JournalEntryAttachments({
setDocuments(data || [])
onCountChangeRef.current?.(data?.length || 0)
} catch {
// Non-critical — silently ignore
// Non-critical: silently ignore
} finally {
setLoading(false)
}
@@ -126,7 +126,7 @@ export default function JournalEntryAttachments({
window.open(data.download_url, '_blank')
}
} catch {
// Non-critical — silently ignore
// Non-critical: silently ignore
}
}
+35 -35
View File
@@ -111,13 +111,13 @@ export default function JournalEntryForm({
const [notes, setNotes] = useState(initialNotes ?? '')
const [showNotes, setShowNotes] = useState(false)
// Dimension tagging (kostnadsställe/projekt). The affordances render only
// when company_settings.dimensions_enabled — a UI-visibility gate; lines
// when company_settings.dimensions_enabled: a UI-visibility gate; lines
// that already carry dimensions (e.g. a draft being edited) still round-trip
// untouched when the toggle is off.
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
const [showDims, setShowDims] = useState(false)
// Header-level default dims ("gäller alla rader"). The per-row maps on
// `lines` are the ONE source of truth — this state only drives the header
// `lines` are the ONE source of truth: this state only drives the header
// comboboxes; setHeaderDimension writes the default through to the rows.
const [headerDims, setHeaderDims] = useState<Record<string, string>>({})
// Which row's dimension popover is open (desktop table), and its container
@@ -133,7 +133,7 @@ export default function JournalEntryForm({
// Booking-time duplicate guard (TRANSACTION_BOOK_POSSIBLE_DUPLICATE): the
// /book endpoint flags an already-booked sibling sharing date+amount+account.
// Surface it and let the user book anyway. The override is bound to the
// reviewed candidate via a ref the next submit reads — force is sent ONLY on
// reviewed candidate via a ref the next submit reads: force is sent ONLY on
// that retry, never on a normal submit or to the manual journal-entry endpoint.
const [duplicateCandidate, setDuplicateCandidate] = useState<BookedDuplicateCandidate | null>(null)
const forceDuplicateRef = useRef<{ force: true; expected_duplicate_journal_entry_id: string } | null>(null)
@@ -156,7 +156,7 @@ export default function JournalEntryForm({
const [showClearConfirm, setShowClearConfirm] = useState(false)
// Month (YYYY-MM) of the most recently posted voucher this session. Used to
// flag, at the review step, when the user is about to book into a different
// month — guards against accidentally posting to the wrong month.
// month: guards against accidentally posting to the wrong month.
const [lastPostedMonth, setLastPostedMonth] = useState<string | null>(null)
// Per-account saldo as of entryDate, keyed by account_number.
// undefined = not fetched, null = fetch in flight.
@@ -218,10 +218,10 @@ export default function JournalEntryForm({
loadBasCatalog().then(setCatalog).catch(() => {/* search degrades to the active chart */})
// Company settings power two things here: dimensions_enabled gates the
// tagging affordances (all modes, incl. the TransactionBookingDialog
// embed), and the default voucher series seeds the standalone form —
// embed), and the default voucher series seeds the standalone form:
// prefer the per-source-type mapping when present; fall back to the legacy
// default_voucher_series, then to 'A'. In edit mode the draft's own series
// is pre-filled — never override it from the company defaults.
// is pre-filled: never override it from the company defaults.
fetch('/api/settings').then(r => r.json()).then(({ data }) => {
if (!data) return
setDimensionsEnabled(data.dimensions_enabled === true)
@@ -289,7 +289,7 @@ export default function JournalEntryForm({
}
}
} catch {
// Non-critical — user can enter rate manually
// Non-critical: user can enter rate manually
} finally {
setIsFetchingRate(false)
}
@@ -314,7 +314,7 @@ export default function JournalEntryForm({
)
// Fetch per-account saldo as of entryDate for the accounts currently on the
// form. Balances are reference-only ("saldo before this entry") — they ignore
// form. Balances are reference-only ("saldo before this entry"): they ignore
// the draft lines the user is typing, by design.
useEffect(() => {
if (!accountsKey) {
@@ -339,7 +339,7 @@ export default function JournalEntryForm({
if (!res.ok) {
// 4xx (e.g. future entryDate rejected by Zod) or 5xx: collapse the
// loading skeleton so the column doesn't get stuck. Saldo is a
// reference value, not authoritative — showing 0 here is preferable
// reference value, not authoritative: showing 0 here is preferable
// to an indefinite spinner.
if (cancelled) return
setAccountBalances((prev) => {
@@ -361,7 +361,7 @@ export default function JournalEntryForm({
return next
})
} catch {
// Reference value — failure is non-fatal, just leave previous state.
// Reference value: failure is non-fatal, just leave previous state.
}
}, 150)
@@ -372,7 +372,7 @@ export default function JournalEntryForm({
}, [accountsKey, entryDate])
// New rows inherit the current header default (a row without a per-row
// override follows the header — see setHeaderDimension).
// override follows the header (see setHeaderDimension).
const makeBlankLine = useCallback(
(): FormLine =>
Object.keys(headerDims).length > 0
@@ -399,7 +399,7 @@ export default function JournalEntryForm({
* (including clearing); rows whose value differs are per-row overrides and
* are left untouched. A row explicitly set to the same code as the header is
* indistinguishable from an inherited one and follows later header changes
* by design — the per-row maps stay the single source of truth.
* by design: the per-row maps stay the single source of truth.
*/
const setHeaderDimension = (dimNo: string, code: string | null) => {
const prev = headerDims[dimNo]
@@ -412,7 +412,7 @@ export default function JournalEntryForm({
})
setLines((ls) =>
ls.map((l) => {
if (l.dimensions?.[dimNo] !== prev) return l // per-row override — keep
if (l.dimensions?.[dimNo] !== prev) return l // per-row override: keep
const dims = { ...(l.dimensions ?? {}) }
if (next) dims[dimNo] = next
else delete dims[dimNo]
@@ -472,7 +472,7 @@ export default function JournalEntryForm({
}
// Auto-fill line description from account name when selecting an account.
// NOTE: we intentionally do NOT auto-fill a balancing amount here — that was
// NOTE: we intentionally do NOT auto-fill a balancing amount here: that was
// surprising when splitting across several lines. The balancing amount is
// now opt-in via double-clicking a debit/credit field (handleFillBalance).
if (field === 'account_number' && value) {
@@ -484,7 +484,7 @@ export default function JournalEntryForm({
if (account) {
updated[index].line_description = account.account_name
// Fortnox-style: seed the verifikationstext from the first row's account
// when the user hasn't typed one yet. Non-destructive — never overwrites.
// when the user hasn't typed one yet. Non-destructive: never overwrites.
if (index === 0 && !description.trim()) {
setDescription(account.account_name)
}
@@ -553,7 +553,7 @@ export default function JournalEntryForm({
// till rad": once the last row is started (account or amount), append a fresh
// blank below it. Applies uniformly to typed, templated and copied lines.
// The guard lives inside the functional updater so chained updates see each
// other's result — making it idempotent and safe under StrictMode's dev-only
// other's result, making it idempotent and safe under StrictMode's dev-only
// double-invoke (no runaway append, no double blank row).
useEffect(() => {
setLines((prev) => {
@@ -566,7 +566,7 @@ export default function JournalEntryForm({
}, [lines, makeBlankLine])
// Inline (bare) review: move focus to the confirm button when it opens so
// Enter posts — parity with the ConfirmationDialog's autoFocusConfirm.
// Enter posts: parity with the ConfirmationDialog's autoFocusConfirm.
useEffect(() => {
if (bare && showReview) {
requestAnimationFrame(() => bareConfirmRef.current?.focus())
@@ -611,7 +611,7 @@ export default function JournalEntryForm({
: 0
// Month/period safety signals surfaced at the review step (not as a blocking
// dialog on every date change — that would add friction to routine entry).
// dialog on every date change (that would add friction to routine entry).
const monthLabel = useCallback(
(ym: string) => {
const [y, m] = ym.split('-').map(Number)
@@ -655,7 +655,7 @@ export default function JournalEntryForm({
// After a new account is created, refresh the chart, auto-select it on the
// line that initiated the create, and close the dialog. All other form
// state is preserved — we never navigate away from the form.
// state is preserved: we never navigate away from the form.
const handleAccountCreated = async (account: BASAccount) => {
await fetchAccounts()
if (creatingAccountForLine != null) {
@@ -675,7 +675,7 @@ export default function JournalEntryForm({
setShowReview(true)
}
// Whether an Enter should open the review — mirrors the review button's
// Whether an Enter should open the review: mirrors the review button's
// enable gate exactly, so Enter never submits something the button wouldn't.
const canSubmitReview = () =>
isBalanced &&
@@ -690,7 +690,7 @@ export default function JournalEntryForm({
// Enter anywhere in the form = "Granska & skapa": opens the review exactly as
// the button does, from any field. Navigation is Tab's job. Two Enter
// exceptions stay intact: the account combobox (it calls preventDefault to
// select the highlighted account — we skip when defaultPrevented) and the
// select the highlighted account (we skip when defaultPrevented) and the
// internal-note textarea (newlines). The inline review owns its own Enter.
const handleFormKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key !== 'Enter') return
@@ -702,7 +702,7 @@ export default function JournalEntryForm({
// Enter-to-advance inside the konteringsrader: konto → debet → kredit →
// nästa rads konto. Navigation only fires while the entry is NOT
// submittable — once the voucher balances, Enter falls through to the
// submittable: once the voucher balances, Enter falls through to the
// form-level handler above and opens the review instead, so a single Enter
// never both moves focus and submits.
const handleAmountKeyDown =
@@ -855,7 +855,7 @@ export default function JournalEntryForm({
}
} catch (err) {
if (err instanceof Error && err.message === 'cancelled') {
// User dismissed the activation dialog — no toast needed
// User dismissed the activation dialog: no toast needed
} else {
const anyErr = err as {
body?: { error?: { code?: string; details?: { candidate?: BookedDuplicateCandidate } } }
@@ -863,7 +863,7 @@ export default function JournalEntryForm({
}
const candidate = anyErr.body?.error?.details?.candidate
if (anyErr.body?.error?.code === 'TRANSACTION_BOOK_POSSIBLE_DUPLICATE' && candidate) {
// Soft duplicate guard fired — don't dead-end on a toast that merely
// Soft duplicate guard fired: don't dead-end on a toast that merely
// says "book anyway". Open the dialog so the user can review the
// existing verifikat or confirm. handleBookAnyway re-submits with
// force bound to this candidate.
@@ -890,7 +890,7 @@ export default function JournalEntryForm({
if (!candidate) return
forceDuplicateRef.current = {
force: true,
// Bind on the voucher id — present on both a sibling-transaction candidate
// Bind on the voucher id: present on both a sibling-transaction candidate
// and a ledger-only voucher candidate (which has no transaction_id).
expected_duplicate_journal_entry_id: candidate.journal_entry_id,
}
@@ -943,7 +943,7 @@ export default function JournalEntryForm({
}
} catch (err) {
if (err instanceof Error && err.message === 'cancelled') {
// Activation dialog dismissed — silent
// Activation dialog dismissed: silent
} else {
const anyErr = err as { body?: unknown; status?: number }
toast({
@@ -959,7 +959,7 @@ export default function JournalEntryForm({
}
// Edit an existing draft: PATCH in place (postJournalEntry routes to the
// editEntryId URL) and keep it a draft. No field reset — the host dialog
// editEntryId URL) and keep it a draft. No field reset: the host dialog
// closes on success via onUpdated.
const handleSaveEdit = async () => {
if (!selectedPeriod || !description || !isBalanced || periodMismatch) return
@@ -973,7 +973,7 @@ export default function JournalEntryForm({
onUpdated?.()
} catch (err) {
if (err instanceof Error && err.message === 'cancelled') {
// Activation dialog dismissed — silent
// Activation dialog dismissed: silent
} else {
const anyErr = err as { body?: unknown; status?: number }
toast({
@@ -994,7 +994,7 @@ export default function JournalEntryForm({
<div
className="space-y-4"
// The host dialog swallows Escape (accidental-close guard), so Escape
// here is free to mean "back to the form" — keyboard mirror of ←.
// here is free to mean "back to the form": keyboard mirror of ←.
onKeyDown={(e) => {
if (e.key === 'Escape' && !isSubmitting) {
e.stopPropagation()
@@ -1073,7 +1073,7 @@ export default function JournalEntryForm({
<div className="space-y-4" onKeyDown={handleFormKeyDown}>
{bare && showReview ? reviewPanel : (
<>
{/* Verifikat metadata — compact bar on top (Fortnox-style). Date, series
{/* Verifikat metadata: compact bar on top (Fortnox-style). Date, series
and period are pre-filled; the period derives from the date. The
konteringsrader below are the focus. */}
<div className="rounded-lg border bg-muted/20 p-3 space-y-3">
@@ -1249,7 +1249,7 @@ export default function JournalEntryForm({
</div>
)}
{/* Header default dims — writes through to all rows without a per-row
{/* Header default dims: writes through to all rows without a per-row
override (see setHeaderDimension for the inheritance rule). */}
{dimensionsEnabled && showDims && (
<div className="max-w-md space-y-1">
@@ -1282,7 +1282,7 @@ export default function JournalEntryForm({
)}
</div>
{/* Entry lines — mobile cards */}
{/* Entry lines: mobile cards */}
<div className="sm:hidden space-y-3">
{lines.map((line, index) => (
<div key={index} className="rounded-lg border bg-card p-3 space-y-2">
@@ -1401,7 +1401,7 @@ export default function JournalEntryForm({
</div>
</div>
{/* Entry lines — desktop table */}
{/* Entry lines: desktop table */}
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
@@ -1515,7 +1515,7 @@ export default function JournalEntryForm({
className="absolute right-0 top-full z-50 mt-1 w-64 rounded-md border bg-card p-3 shadow-md"
onKeyDown={(e) => {
// The comboboxes preventDefault their own Escape
// (closing their dropdown) — only an unhandled
// (closing their dropdown): only an unhandled
// Escape closes the popover.
if (e.key === 'Escape' && !e.defaultPrevented) {
e.preventDefault()
@@ -1592,7 +1592,7 @@ export default function JournalEntryForm({
</p>
</div>
{/* Document attachments — hidden when editing a draft; underlag is
{/* Document attachments: hidden when editing a draft; underlag is
managed from the verifikat detail page (JournalEntryAttachments). */}
{!embedded && !editEntryId && (
<div>
+24 -24
View File
@@ -195,7 +195,7 @@ export default function JournalEntryList() {
)
setAttachmentCounts(Object.assign({}, ...results))
} catch {
// Non-critical — silently ignore
// Non-critical: silently ignore
}
}, [])
@@ -210,7 +210,7 @@ export default function JournalEntryList() {
}
setNoDocRequired(map)
} catch {
// Non-critical — silently ignore
// Non-critical: silently ignore
}
}, [])
@@ -221,7 +221,7 @@ export default function JournalEntryList() {
// Restore the persisted sort order (per company). Read in an effect rather
// than the useState initializer to avoid an SSR/client hydration mismatch.
// sortHydrated gates the first fetch so the list is fetched once, already in
// the saved order — no flash of the default sort.
// the saved order, no flash of the default sort.
useEffect(() => {
if (typeof window !== 'undefined') {
const stored = window.localStorage.getItem(SORT_STORAGE_KEY_PREFIX + (company?.id ?? 'default'))
@@ -231,7 +231,7 @@ export default function JournalEntryList() {
}, [company?.id])
// Restore the persisted page-size choice (per company). Same hydration pattern
// as the sort order — read in an effect to avoid an SSR mismatch, and gate the
// as the sort order, read in an effect to avoid an SSR mismatch, and gate the
// first fetch so the list is fetched once at the saved size.
useEffect(() => {
if (typeof window !== 'undefined') {
@@ -245,11 +245,11 @@ export default function JournalEntryList() {
// The list is period-oriented (BFL): verifikationsnummer run as an unbroken
// series *per räkenskapsår*, so the same number (e.g. A42) recurs once per
// year. Showing every year at once makes those look like duplicates and makes
// a bare "A42" reference ambiguous — so we default to the räkenskapsår the
// a bare "A42" reference ambiguous, so we default to the räkenskapsår the
// user is currently in rather than "all years". An explicit "Alla
// räkenskapsår" choice (persisted as ALL_YEARS_VALUE) is still honoured.
// Resolving the scope here — not in the dialog's FiscalYearSelector, which
// only mounts when opened — keeps the first fetch correct. periodHydrated
// Resolving the scope here, not in the dialog's FiscalYearSelector, which
// only mounts when opened, keeps the first fetch correct. periodHydrated
// gates that first fetch so the list loads already scoped to the resolved year.
useEffect(() => {
if (!company?.id) {
@@ -267,7 +267,7 @@ export default function JournalEntryList() {
fetched = (data || []) as FiscalPeriod[]
}
} catch {
// Non-critical — fall through with an empty list (scope stays "all years").
// Non-critical: fall through with an empty list (scope stays "all years").
}
if (cancelled) return
@@ -276,7 +276,7 @@ export default function JournalEntryList() {
? window.localStorage.getItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id)
: null
if (stored === FISCAL_YEAR_ALL_VALUE) {
// User explicitly chose "all years" — respect it.
// User explicitly chose "all years", respect it.
setPeriodId(null)
} else if (stored && fetched.some((p) => p.id === stored)) {
setPeriodId(stored)
@@ -294,7 +294,7 @@ export default function JournalEntryList() {
// Debounce the free-text search before it reaches the API. Require ≥2 chars:
// a single character matches almost every verifikationstext and isn't a useful
// filter, so 0–1 chars are treated as "no search" instead of firing a query on
// filter, so 0-1 chars are treated as "no search" instead of firing a query on
// every keystroke (ASVS V2.4).
useEffect(() => {
const handle = setTimeout(() => {
@@ -307,14 +307,14 @@ export default function JournalEntryList() {
async function fetchEntries() {
setLoading(true)
setSelectedIds(new Set()) // selection is page-scoped — reset on reload
setSelectedIds(new Set()) // selection is page-scoped, reset on reload
const params = new URLSearchParams({
limit: String(pageSize),
offset: String(page * pageSize),
sort_by: sortBy,
})
if (listMode === 'drafts') {
// Drafts get their own view spanning all years — they're work-in-progress
// Drafts get their own view spanning all years: they're work-in-progress
// and shouldn't be hidden by the selected fiscal-year scope.
params.set('status', 'draft')
} else {
@@ -353,7 +353,7 @@ export default function JournalEntryList() {
fetchAttachmentCounts(ids)
}
// Cheap count-only query for the "Utkast" badge — all years, so the badge
// Cheap count-only query for the "Utkast" badge, all years, so the badge
// surfaces drafts regardless of the selected fiscal-year scope.
async function fetchDraftCount() {
try {
@@ -409,7 +409,7 @@ export default function JournalEntryList() {
}
}
// Pure reversal (storno) of a posted verifikat — books a stornoverifikation
// Pure reversal (storno) of a posted verifikat: books a stornoverifikation
// with no replacement, per BFL 5 kap 5§. Routes through the engine's
// reverseEntry (storno + reverses_id link; original → 'reversed', never
// deleted). "Rätta" stays the path for booking a replacement entry instead.
@@ -439,7 +439,7 @@ export default function JournalEntryList() {
}
// A posted, document-requiring entry with no attachment yet and not already
// exempt — i.e. the rows that show the warning triangle. Only these can be
// exempt, i.e. the rows that show the warning triangle. Only these can be
// batch-marked "Inget underlag krävs".
const isEligibleForExempt = useCallback(
(entry: JournalEntry) =>
@@ -495,7 +495,7 @@ export default function JournalEntryList() {
}
// Filter-scoped bulk mark: mark EVERY missing-doc verifikat matching the active
// filters (period/series/date/search), across all pages — the scalable remedy
// filters (period/series/date/search), across all pages: the scalable remedy
// for a post-import flood. A dry_run first surfaces the exact count to confirm.
const filterPayload = () => ({
period_id: periodId,
@@ -572,7 +572,7 @@ export default function JournalEntryList() {
(seriesFilter !== 'all' ? 1 : 0) +
(dateFrom || dateTo ? 1 : 0) +
(showMissingOnly ? 1 : 0)
// Year scope included — drives empty-state messaging + keeping the bar mounted.
// Year scope included: drives empty-state messaging + keeping the bar mounted.
const activeFilterCount = (periodId ? 1 : 0) + dialogFilterCount
// When any filter or search is active we keep the filter bar mounted even
@@ -633,7 +633,7 @@ export default function JournalEntryList() {
// Pristine, untouched ledger: nothing posted, no drafts, no filters, and we're
// on the committed view. ONLY this genuinely-empty case may short-circuit the
// whole component — every other empty state (a draft exists, or we're in the
// whole component: every other empty state (a draft exists, or we're in the
// drafts view) must fall through to the main render below so the
// Verifikat/Utkast toggle stays reachable.
if (!loading && entries.length === 0 && !hasActiveFilters && listMode === 'committed' && draftCount === 0) {
@@ -792,7 +792,7 @@ export default function JournalEntryList() {
}}
className="h-9 flex-1 text-sm"
/>
<span className="text-sm text-muted-foreground">–</span>
<span className="text-sm text-muted-foreground">-</span>
<Input
type="text"
placeholder={t('date_to_placeholder')}
@@ -868,7 +868,7 @@ export default function JournalEntryList() {
</DialogContent>
</Dialog>
{/* Active fiscal-year scope as a direct one-click picker, pushed to the
right of the control bar — keeps the räkenskapsår visible per BFL and
right of the control bar, keeps the räkenskapsår visible per BFL and
changeable in one click. Distinct from Filtrera (which now holds only
sort / series / date / underlag); previously both just opened the same
dialog. The selector persists the choice to localStorage; the first-load
@@ -892,7 +892,7 @@ export default function JournalEntryList() {
) : filteredEntries.length === 0 ? (
// Empty placeholder, scoped to the situation: an empty drafts view, a
// filtered committed view with no matches, or a committed view with no
// posted entries yet (but drafts exist — hence we got here, not the
// posted entries yet (but drafts exist, hence we got here, not the
// pristine early return above).
<DataList>
<DataListEmpty
@@ -974,7 +974,7 @@ export default function JournalEntryList() {
</div>
) : (
// Filter-scoped: mark every missing-doc verifikat matching the active
// filters across all pages — scales to a post-import flood.
// filters across all pages: scales to a post-import flood.
<Button size="sm" variant="outline" onClick={openBulk}>
<CircleSlash className="mr-2 h-4 w-4" />
{t('batch_mark_all_missing')}
@@ -1485,7 +1485,7 @@ export default function JournalEntryList() {
{/* Pagination + page-size selector. Shown when the result set spans more
than one page at the default size, OR when a non-default page size
('all' included) is active — so a user who narrowed the list below the
('all' included) is active, so a user who narrowed the list below the
default can always switch the size back. Hidden for an empty result. */}
{count > 0 && (count > PAGE_SIZE_OPTIONS[0] || pageSizeChoice !== '20') && (
<div className="flex flex-col gap-3 pt-2 sm:flex-row sm:items-center sm:justify-between">
@@ -1518,7 +1518,7 @@ export default function JournalEntryList() {
</span>
</div>
{/* Page navigation — hidden when showing all or when everything fits on one page */}
{/* Page navigation: hidden when showing all or when everything fits on one page */}
{!showingAll && count > pageSize && (
<div className="flex items-center gap-1">
<Button
@@ -99,7 +99,7 @@ export function JournalEntryReviewContent({
</div>
)}
{/* Debit/Credit — table on desktop, cards on mobile */}
{/* Debit/Credit: table on desktop, cards on mobile */}
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
@@ -12,12 +12,12 @@ interface MatchTransactionInvoicePreviewProps {
* AttachDocumentPreview's layout so reviewers learn one matching idiom.
*/
export function MatchTransactionInvoicePreview({ data }: MatchTransactionInvoicePreviewProps) {
const txDescription = (data.transaction_description as string) || '—'
const txDescription = (data.transaction_description as string) || '-'
const txAmount = data.transaction_amount as number | undefined
const txCurrency = (data.transaction_currency as string) || 'SEK'
const txDate = data.transaction_date as string | undefined
const invoiceNumber = (data.invoice_number as string) || '—'
const invoiceNumber = (data.invoice_number as string) || '-'
const invoiceTotal = data.invoice_total as number | undefined
const invoiceCurrency = (data.invoice_currency as string) || txCurrency
const invoiceDate = data.invoice_date as string | undefined
@@ -26,7 +26,7 @@ export function MatchTransactionInvoicePreview({ data }: MatchTransactionInvoice
// BFL 5 kap 4§ requires bookings to be made "so soon as possible" relative
// to the affärshändelse, so a transaction and invoice that diverge by more
// than a calendar month deserve a second look before the reviewer approves
// the match. The threshold is editorial, not legislated — it just nudges
// the match. The threshold is editorial, not legislated: it just nudges
// the reviewer; it doesn't block.
const showDateDriftHint =
txDate &&
@@ -41,7 +41,7 @@ export function MatchTransactionInvoicePreview({ data }: MatchTransactionInvoice
<Row label="Beskrivning" value={txDescription} />
<Row
label="Belopp"
value={typeof txAmount === 'number' ? formatCurrency(txAmount, txCurrency) : '—'}
value={typeof txAmount === 'number' ? formatCurrency(txAmount, txCurrency) : '-'}
tabular
/>
</PreviewCard>
@@ -63,7 +63,7 @@ export function MatchTransactionInvoicePreview({ data }: MatchTransactionInvoice
{showDateDriftHint && (
<p className="text-xs text-muted-foreground">
Transaktionsdatum och fakturadatum skiljer sig med mer än en månad — kontrollera att
Transaktionsdatum och fakturadatum skiljer sig med mer än en månad: kontrollera att
matchningen avser rätt affärshändelse.
</p>
)}
@@ -31,7 +31,7 @@ interface Props {
}
/**
* "Ny verifikat" as a modal — the dialog you type a manual voucher into,
* "Ny verifikat" as a modal: the dialog you type a manual voucher into,
* instead of an inline tab. Wraps the standalone JournalEntryForm; the form's
* own review/confirm dialogs stack on top of this one.
*/
@@ -51,7 +51,7 @@ export default function NewJournalEntryDialog({
className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
// A half-typed verifikat must survive an accidental backdrop click or a
// stray Escape (easy to hit across multiple windows/screens, or when you
// only meant to dismiss a combobox dropdown). Closing is explicit — the
// only meant to dismiss a combobox dropdown). Closing is explicit: the
// header X. This also stops nested popovers (AccountCombobox, date
// pickers) and the form's own confirm dialogs from collapsing the parent
// when they portal outside it.
@@ -66,7 +66,7 @@ export default function NewJournalEntryDialog({
{identity.isVerified && !copyPrefill && (
// Hand off to the assistant: it reads the underlag (the figures the
// user often can't see), suggests accounts, and stages a balanced
// verifikat to approve — no copy-paste. Close the modal first so its
// verifikat to approve: no copy-paste. Close the modal first so its
// focus trap doesn't fight the (non-modal) agent sheet.
<button
type="button"
@@ -63,7 +63,7 @@ export default function NoDocRequiredToggle({
description: body.error,
variant: 'destructive',
})
// Roll back UI state on failure — both the toggle AND the reason so
// Roll back UI state on failure: both the toggle AND the reason so
// the rendered state matches the DB row we failed to mutate.
setExempt(!nextExempt)
setReason(previousReason)
@@ -93,7 +93,7 @@ export default function RecordateEntryDialog({ entry, open, onOpenChange, onMove
const dateChanged = ISO_DATE.test(newDate) && newDate !== entry.entry_date
const targetOpen = preview?.status === 'open' && !!preview?.period_id
const noCoveringPeriod = preview?.status === 'open' && !preview?.period_id
// Soft, non-blocking advisory when moving into a past date — the moms for
// Soft, non-blocking advisory when moving into a past date: the moms for
// that period may already have been filed.
const today = new Date().toISOString().slice(0, 10)
const movingIntoPast = dateChanged && newDate < today
@@ -151,7 +151,7 @@ export default function RecordateEntryDialog({ entry, open, onOpenChange, onMove
<div className="rounded-lg bg-muted/50 border p-3 text-sm text-muted-foreground">
<p className="font-medium text-foreground mb-1">Flytta verifikationen till rätt datum</p>
<p>
En bokförd verifikation kan inte ändras direkt. Raderna behålls oförändrade — istället
En bokförd verifikation kan inte ändras direkt. Raderna behålls oförändrade: istället
skapas automatiskt:
</p>
<ol className="list-decimal list-inside mt-1 space-y-0.5">
@@ -10,7 +10,7 @@ describe('buildCorrectionRows', () => {
expect(buildCorrectionRows([], [])).toEqual([])
})
it('amount change on same accounts — storno cancels original, correction adds the new value', () => {
it('amount change on same accounts: storno cancels original, correction adds the new value', () => {
const original = [
makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1000 }),
@@ -27,7 +27,7 @@ describe('buildCorrectionRows', () => {
])
})
it('account swap — old account zeros out, new account picks up the value', () => {
it('account swap: old account zeros out, new account picks up the value', () => {
const original = [
makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1000 }),
@@ -39,7 +39,7 @@ describe('buildCorrectionRows', () => {
const rows = buildCorrectionRows(original, corrected)
// 5410 ends at delta=-1000 (drained back), 5420 ends at delta=+1000 (new),
// 1930 nets to zero — correction matches storno exactly.
// 1930 nets to zero: correction matches storno exactly.
expect(rows.find((r) => r.account_number === '5410')).toEqual({
account_number: '5410',
original: 1000,
@@ -119,7 +119,7 @@ describe('buildCorrectionRows', () => {
// The reported scenario: a sale-shaped verifikat where the user drops the
// revenue line (3001) from the rättelse. The storno still reverses 3001
// (delta = −original), and correctionPresent=false lets the UI label it
// "tas bort" rather than rendering a bare "–".
// "tas bort" rather than rendering a bare "-".
const original = [
makeJournalEntryLine({ account_number: '1510', debit_amount: 0, credit_amount: 1875 }),
makeJournalEntryLine({ account_number: '2611', debit_amount: 375, credit_amount: 0 }),
@@ -167,8 +167,8 @@ describe('formatSignedAmount', () => {
expect(formatSignedAmount(-1000)).toBe('−1\u00a0000,00')
})
it('renders zero as en-dash', () => {
expect(formatSignedAmount(0)).toBe('–')
it('renders zero as a hyphen', () => {
expect(formatSignedAmount(0)).toBe('-')
})
it('always renders two decimals', () => {
@@ -30,7 +30,7 @@ interface CreateAssetDialogProps {
onCreated: () => void
}
/** Editor row state — strings so the user can clear inputs without zeroing
/** Editor row state: strings so the user can clear inputs without zeroing
* out the component immediately. Converted to numbers at submit time. */
interface ComponentRow {
id: string
@@ -53,7 +53,7 @@ function newComponentRow(): ComponentRow {
}
// Defaults are K2-redovisning (BFNAR 2016:10) schablon, NOT skattemässig
// avskrivning. Building / markanläggning values are conservative — IL 19/20
// avskrivning. Building / markanläggning values are conservative: IL 19/20
// kap may allow longer (50 yr) or shorter (10 yr) depending on byggnadstyp.
const CATEGORY_OPTIONS: { value: AssetCategory; label: string; defaultYears: number }[] = [
{ value: 'computer', label: 'Dator / IT-utrustning', defaultYears: 3 },
@@ -75,17 +75,17 @@ const DEPRECIATION_METHOD_OPTIONS: { value: DepreciationMethod; label: string; h
{
value: 'declining_balance_30',
label: 'Räkenskapsenlig 30 %',
hint: 'Huvudregeln (IL 18 kap 13§) — 30 % degressivt på avskrivningsunderlaget.',
hint: 'Huvudregeln (IL 18 kap 13§): 30 % degressivt på avskrivningsunderlaget.',
},
{
value: 'declining_balance_20',
label: 'Räkenskapsenlig 20 %',
hint: 'Kompletteringsregeln (IL 18 kap 17§) — 20 % degressivt. Vanlig för byggnader.',
hint: 'Kompletteringsregeln (IL 18 kap 17§): 20 % degressivt. Vanlig för byggnader.',
},
{
value: 'restvardesavskrivning_25',
label: 'Restvärdeavskrivning 25 %',
hint: 'IL 18 kap 13§ st.3 — 25 % degressivt ner till angivet restvärde.',
hint: 'IL 18 kap 13§ st.3: 25 % degressivt ner till angivet restvärde.',
},
]
@@ -173,7 +173,7 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
}
restvardeTargetNumber = parsed
}
// K3 components — only when both the framework permits (gate at API)
// K3 components: only when both the framework permits (gate at API)
// and the user opted into the section. Empty array is invalid (the
// validator rejects it) so the dialog also flips back to "off" when
// every row is removed.
@@ -336,7 +336,7 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
/>
<p className="text-xs text-muted-foreground">
K2-schablon för redovisning: datorer 3 år, inventarier 5 år, byggnader 25 år.
För skattemässig avskrivning kan annan livslängd gälla (IL 18–20 kap).
För skattemässig avskrivning kan annan livslängd gälla (IL 18-20 kap).
</p>
</div>
<div className="space-y-1.5">
@@ -382,10 +382,10 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Avancerat — komponentuppdelning
Avancerat: komponentuppdelning
</Label>
<p className="text-xs text-muted-foreground">
K3 (BFNAR 2012:1 17.4) — när väsentliga komponenter har olika nyttjandeperiod
K3 (BFNAR 2012:1 17.4): när väsentliga komponenter har olika nyttjandeperiod
skrivs varje komponent av för sig. Typisk för fastigheter (tak, fasad, stomme,
installationer).
</p>
@@ -531,7 +531,7 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
)}
<div className="rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
<strong className="text-foreground">Tips:</strong> Anskaffningen måste redan vara
bokförd (debet på 1xxx-kontot mot t.ex. 1930/2440) — registret bokför inte
bokförd (debet på 1xxx-kontot mot t.ex. 1930/2440): registret bokför inte
själva köpet. Det här registret styr enbart de planenliga avskrivningarna under
bokslutet.
</div>
@@ -36,7 +36,7 @@ interface EditAssetDialogProps {
}
// Same category labels and depreciation hints as CreateAssetDialog (kept in
// sync deliberately — this register surface is Swedish-only, like creation).
// sync deliberately: this register surface is Swedish-only, like creation).
const CATEGORY_OPTIONS: { value: AssetCategory; label: string }[] = [
{ value: 'computer', label: 'Dator / IT-utrustning' },
{ value: 'equipment', label: 'Inventarier' },
@@ -57,17 +57,17 @@ const DEPRECIATION_METHOD_OPTIONS: { value: DepreciationMethod; label: string; h
{
value: 'declining_balance_30',
label: 'Räkenskapsenlig 30 %',
hint: 'Huvudregeln (IL 18 kap 13§) — 30 % degressivt på avskrivningsunderlaget.',
hint: 'Huvudregeln (IL 18 kap 13§): 30 % degressivt på avskrivningsunderlaget.',
},
{
value: 'declining_balance_20',
label: 'Räkenskapsenlig 20 %',
hint: 'Kompletteringsregeln (IL 18 kap 17§) — 20 % degressivt. Vanlig för byggnader.',
hint: 'Kompletteringsregeln (IL 18 kap 17§): 20 % degressivt. Vanlig för byggnader.',
},
{
value: 'restvardesavskrivning_25',
label: 'Restvärdeavskrivning 25 %',
hint: 'IL 18 kap 13§ st.3 — 25 % degressivt ner till angivet restvärde.',
hint: 'IL 18 kap 13§ st.3: 25 % degressivt ner till angivet restvärde.',
},
]
@@ -75,7 +75,7 @@ export function EditAssetDialog({ asset, open, onOpenChange, onSaved }: EditAsse
const { toast } = useToast()
const { canWrite } = useCanWrite()
// Once depreciation has been booked, acquisition date/cost/category are
// locked — a real change has to go through storno. The server enforces the
// locked: a real change has to go through storno. The server enforces the
// same rule (ASSET_CORRECTION_BLOCKED); this just makes it visible up front.
const basisLocked = asset.has_posted_depreciation === true
@@ -299,7 +299,7 @@ export function EditAssetDialog({ asset, open, onOpenChange, onSaved }: EditAsse
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Byte av avskrivningsmetod efter att avskrivning påbörjats. Enligt K2
(BFNAR 2016:10 p. 10.26) ska vald metod tillämpas konsekvent — ändra
(BFNAR 2016:10 p. 10.26) ska vald metod tillämpas konsekvent: ändra
bara vid särskilda skäl och lämna i så fall upplysning i bokslutet.
Ändringen gäller framåt; redan bokförda avskrivningar påverkas inte.
</span>
@@ -14,10 +14,10 @@ export interface AccountRow {
delta: number
/**
* True when this account appears on at least one (4-digit) corrected line.
* Lets the UI tell apart an account the user removed from the rättelse — which
* the storno then zeroes (delta = −original) — from one that was never part of
* Lets the UI tell apart an account the user removed from the rättelse, which
* the storno then zeroes (delta = −original), from one that was never part of
* the correction at all. Without this distinction a removed account renders as
* a bare "–", reading as "unchanged" when it is in fact being drained.
* a bare "-", reading as "unchanged" when it is in fact being drained.
*/
correctionPresent: boolean
}
@@ -40,7 +40,7 @@ function round2(n: number): number {
* a row, so the user sees account swaps clearly (old account drains to zero,
* new account picks up the value).
*
* Corrected lines with account_number.length !== 4 are skipped — those are
* Corrected lines with account_number.length !== 4 are skipped: those are
* incomplete user input mid-edit, not real proposals.
*/
export function buildCorrectionRows(
@@ -94,7 +94,7 @@ export function buildCorrectionRows(
}
export function formatSignedAmount(n: number): string {
if (n === 0) return '–'
if (n === 0) return '-'
const abs = Math.abs(n).toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
@@ -188,7 +188,7 @@ export function AccrualsStep({ periodId, onBack, onContinue }: AccrualsStepProps
<CardTitle className="text-base">Periodiseringar</CardTitle>
<p className="text-sm text-muted-foreground">
Förutbetalda kostnader (17xx) och upplupna kostnader (29xx). Posteringarna
ska vändas på första dagen av nästa räkenskapsår — datumet visas per
ska vändas på första dagen av nästa räkenskapsår: datumet visas per
verifikation. Automatisk omvändning är planerad till en kommande version.
</p>
</CardHeader>
@@ -334,8 +334,8 @@ function ManualEntryEditor({
onChange({ liabilityAccount: e.target.value as '2991' | '2992' })
}
>
<option value="2992">2992 — Revision</option>
<option value="2991">2991 — Bokslut</option>
<option value="2992">2992: Revision</option>
<option value="2991">2991: Bokslut</option>
</select>
</div>
)}
@@ -36,7 +36,7 @@ interface Proposal {
interface DepreciationPanelProps {
periodId: string
/** Called after a successful post — parent refetches dispositions because
/** Called after a successful post: parent refetches dispositions because
* posted avskrivningar change the result which affects bolagsskatt etc. */
onPosted: () => void
}
@@ -34,7 +34,7 @@ interface UiState {
* Phase 2 bokslutsdispositioner step. Fetches proposals from the dispositions
* API, lets the user adjust amounts (or skip) per proposal, then POSTs the
* accepted ones. Mandatory p-fond reversals (cohort ≥ 6 years old) cannot be
* skipped — checkbox stays disabled-on.
* skipped: checkbox stays disabled-on.
*
* EF companies get an empty `proposals` array from the server, so this step
* renders a short pass-through note and lets the user continue.
@@ -66,7 +66,7 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
const selections: UiState['selections'] = {}
data.proposals.forEach((p, index) => {
// Key must match the render loop and buildPostItems, which both pass
// the array index — omitting it here defaulted every non-ateforing
// the array index: omitting it here defaulted every non-ateforing
// key to ":0", so only the first proposal card ever rendered.
const key = proposalKey(p, index)
selections[key] = {
@@ -95,7 +95,7 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
try {
const items = buildPostItems(proposal, ui)
if (items.length === 0) {
// Nothing selected — just move on
// Nothing selected: just move on
onContinue()
return
}
@@ -337,7 +337,7 @@ function ProposalCard({
}
function isOverridable(kind: DispositionKind): boolean {
// Bolagsskatt and SLP are derived from posted entries — overriding the amount
// Bolagsskatt and SLP are derived from posted entries: overriding the amount
// would silently break the journal posting (the calculator would still
// recompute server-side). p-fond avsättning and överavskrivningar take a
// desired amount as input, so editing is meaningful. p-fond återföring is
@@ -347,7 +347,7 @@ function isOverridable(kind: DispositionKind): boolean {
}
function proposalKey(p: ProposedDisposition, index = 0): string {
// For återföring there can be multiple cards (one per cohort) — disambiguate
// For återföring there can be multiple cards (one per cohort): disambiguate
// by including the line account in the key. For other kinds, the kind is unique.
if (p.kind === 'periodiseringsfond_ateforing') {
return `${p.kind}:${p.lines[0]?.account_number ?? index}`
@@ -362,7 +362,7 @@ interface PostItem {
function buildPostItems(proposal: DispositionsProposal, ui: UiState): PostItem[] {
const items: PostItem[] = []
// Group återföring entries — server expects a single item with a `returns`
// Group återföring entries: server expects a single item with a `returns`
// map keyed by cohort account.
const ateforingReturns: Record<string, number> = {}
for (const p of proposal.proposals) {
@@ -395,7 +395,7 @@ function buildPostItems(proposal: DispositionsProposal, ui: UiState): PostItem[]
})
break
case 'uppskjuten_skatt':
// K3 only — server recomputes the amount; client just signals intent.
// K3 only: server recomputes the amount; client just signals intent.
items.push({ kind: 'uppskjuten_skatt' })
break
}
@@ -14,7 +14,7 @@ import type { EfDeclarationItem } from '@/lib/bokslut/enskild-firma/types'
interface EfDeclarationSectionProps {
fiscalPeriodId: string
/** Bokfört resultat (income statement net_result) — shown as the surplus
/** Bokfört resultat (income statement net_result): shown as the surplus
* base in the wizard header. Server recomputes from the trial balance. */
bookedSurplus: number
/** Closing year of the fiscal period (for periodiseringsfond cohort). */
@@ -55,12 +55,12 @@ const DEFAULT_OVERRIDES: EfOverrideInputs = {
}
/**
* EF declaration step — fetches the four calculator outputs (egenavgifter,
* EF declaration step: fetches the four calculator outputs (egenavgifter,
* räntefördelning, periodiseringsfond-EF, expansionsfond) from the server
* so the same source-of-truth (computeEfDeclarationPreview) is used by
* wizard, MCP tool and NE-bilaga.
*
* EF tax mechanisms are declaration-only — they NEVER produce journal
* EF tax mechanisms are declaration-only: they NEVER produce journal
* entries. The banner makes that BFL distinction visible.
*
* Override inputs persist to localStorage scoped by fiscal period id, so
@@ -88,7 +88,7 @@ export function EfDeclarationSection({
setOverrides({ ...DEFAULT_OVERRIDES, ...parsed })
}
} catch {
// Ignore — start with defaults.
// Ignore: start with defaults.
}
}, [storageKey])
@@ -98,7 +98,7 @@ export function EfDeclarationSection({
try {
window.localStorage.setItem(storageKey, JSON.stringify(overrides))
} catch {
// Quota exceeded or disabled — non-fatal.
// Quota exceeded or disabled, non-fatal.
}
}, [storageKey, overrides])
@@ -164,13 +164,13 @@ export function EfDeclarationSection({
return (
<div className="space-y-6">
{/* BFL distinction banner — EF values are declaration-only, never booked. */}
{/* BFL distinction banner: EF values are declaration-only, never booked. */}
<Card className="border-border bg-secondary/40">
<CardContent className="p-4 flex items-start gap-3">
<Info className="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">
Skattemässiga justeringar — NE-bilaga
Skattemässiga justeringar: NE-bilaga
</span>
<br />
För enskild firma bokförs varken skatt, egenavgifter, fonder eller räntefördelning.
@@ -179,7 +179,7 @@ export function EfDeclarationSection({
</CardContent>
</Card>
{/* "No journal entries posted" banner — surfaced when the period has
{/* "No journal entries posted" banner: surfaced when the period has
zero posted vouchers, so the user knows the surplus is 0 because
nothing's booked yet, not because the calculators failed. */}
{noPostedEntries && (
@@ -210,7 +210,7 @@ export function EfDeclarationSection({
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label className="text-xs">Egenavgifter — kategori</Label>
<Label className="text-xs">Egenavgifter: kategori</Label>
<select
className="border border-border rounded-md h-9 text-sm px-2 w-full bg-background"
value={overrides.category}
@@ -266,7 +266,7 @@ export function EfDeclarationSection({
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Tidigare expansionsfond — saldo</Label>
<Label className="text-xs">Tidigare expansionsfond: saldo</Label>
<Input
type="number"
step="1"
@@ -348,7 +348,7 @@ export function EfDeclarationSection({
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Info className="h-4 w-4 text-muted-foreground" />
NE-bilaga räkenskapsschema (R1–R11)
NE-bilaga räkenskapsschema (R1-R11)
</CardTitle>
<p className="text-sm text-muted-foreground">
Räkenskapsschema-delen genereras automatiskt från bokföringen för räkenskapsåret {fiscalYear}.
@@ -16,7 +16,7 @@ interface ExecuteStepProps {
/**
* Final confirmation before executing year-end. Uses the destructive confirm
* dialog because year-end is irreversible per BFL — once the period is closed,
* dialog because year-end is irreversible per BFL: once the period is closed,
* no further entries can be posted to it and the closing transaction is
* immutable.
*/
@@ -39,7 +39,7 @@ export function ExecuteStep({ periodName, isRunning, error, onBack, onExecute }:
</p>
<ul className="text-sm space-y-2 list-disc pl-5 text-muted-foreground">
<li>Kursrevaluering bokas för öppna poster i utländsk valuta (om relevant)</li>
<li>Bokslutsverifikationen skapas och nollställer klass 3–8</li>
<li>Bokslutsverifikationen skapas och nollställer klass 3-8</li>
<li>Perioden låses och stängs (oåterkalleligt enligt BFL)</li>
<li>En ny räkenskapsperiod skapas och får ingående balanser från balansräkningen</li>
<li>IB/UB-kontinuitet verifieras</li>
@@ -51,7 +51,7 @@ export function PreflightStep({ report, isLoading, error, onContinue }: Prefligh
<div>
<CardTitle className="text-base">{report.period.name}</CardTitle>
<p className="text-sm text-muted-foreground tabular-nums">
{report.period.period_start} – {report.period.period_end}
{report.period.period_start}: {report.period.period_end}
</p>
</div>
{report.ready ? (
@@ -109,9 +109,9 @@ export function PreviewStep({ preview, isLoading, error, onBack, onContinue }: P
<Card>
<CardHeader>
<CardTitle className="text-base">Bokslutsverifikation — förhandsgranskning</CardTitle>
<CardTitle className="text-base">Bokslutsverifikation: förhandsgranskning</CardTitle>
<p className="text-sm text-muted-foreground">
{preview.closingLines.length} kontorader. Nollställer klass 3–8 mot {preview.closingAccount}.
{preview.closingLines.length} kontorader. Nollställer klass 3-8 mot {preview.closingAccount}.
</p>
</CardHeader>
<CardContent className="p-0">
@@ -130,10 +130,10 @@ export function PreviewStep({ preview, isLoading, error, onBack, onContinue }: P
<TableCell className="tabular-nums">{line.account_number}</TableCell>
<TableCell className="text-sm">{line.line_description}</TableCell>
<TableCell className="text-right tabular-nums">
{line.debit_amount > 0 ? formatCurrency(line.debit_amount) : '—'}
{line.debit_amount > 0 ? formatCurrency(line.debit_amount) : '-'}
</TableCell>
<TableCell className="text-right tabular-nums">
{line.credit_amount > 0 ? formatCurrency(line.credit_amount) : '—'}
{line.credit_amount > 0 ? formatCurrency(line.credit_amount) : '-'}
</TableCell>
</TableRow>
))}
@@ -32,7 +32,7 @@ export function ResultStep({ result }: ResultStepProps) {
const discrepancies = continuity?.discrepancies ?? []
// If the wizard reached ResultStep, executeYearEndClosing already enforced
// that no per-account diff exceeded ORE_TOLERANCE — but surface a panel
// that no per-account diff exceeded ORE_TOLERANCE: but surface a panel
// grouped by BAS class so the user can confirm visually before leaving.
return (
<div className="space-y-6">
@@ -92,7 +92,7 @@ export function ResultStep({ result }: ResultStepProps) {
Bokslutet och de ingående balanserna är klara, men konto 2099 “Årets
resultat” bär fortfarande föregående års resultat in i den nya perioden.
Det måste flyttas till 2098 innan balansräkningen stämmer. Kör om bokslutet
eller kontakta support — felet är loggat.
eller kontakta support: felet är loggat.
</p>
</CardContent>
</Card>
@@ -231,7 +231,7 @@ function ContinuityPanel({ discrepancies, checkedAccounts }: ContinuityPanelProp
return (
<div key={klass}>
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-2">
Klass {klass} – {klass === 1 ? 'Tillgångar' : 'Skulder & eget kapital'}
Klass {klass}: {klass === 1 ? 'Tillgångar' : 'Skulder & eget kapital'}
</h3>
<Table>
<TableHeader>
+1 -1
View File
@@ -19,7 +19,7 @@ interface BrandWordmarkProps {
/**
* Text-only wordmark used in place of the legacy logo image on auth /
* onboarding / sandbox / invite surfaces. Renders the active brand's
* `appName` in Hedvig Letters Serif at weight 700 — the display font is
* `appName` in Hedvig Letters Serif at weight 700: the display font is
* single-weight on Google Fonts so 700 ends up synthetically bolded, but
* that matches the requested aesthetic.
*/
+2 -2
View File
@@ -16,7 +16,7 @@ const STORAGE_KEY_PREFIX = 'Accounted:cash-account:'
interface Props {
/**
* Current selection — a BAS ledger account number ('1930', '1932', …).
* Current selection: a BAS ledger account number ('1930', '1932', …).
* `null` would only be meaningful if "all accounts" were an option, which
* isn't currently supported (reconciliation is always single-account).
*/
@@ -104,7 +104,7 @@ export function CashAccountSelector({
return () => {
cancelled = true
}
// onReady excluded — lifecycle callback, shouldn't retrigger on parent renders.
// onReady excluded: lifecycle callback, shouldn't retrigger on parent renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [company?.id])
+1 -1
View File
@@ -12,7 +12,7 @@ interface DataStateProps {
error: string | null
/** When true (and not loading/error), render `empty` instead of `children`. */
isEmpty?: boolean
/** Retry handler — typically `useFetch().refetch`. Shows a retry button. */
/** Retry handler: typically `useFetch().refetch`. Shows a retry button. */
onRetry?: () => void
/** Loading placeholder. Defaults to three skeleton rows. */
skeleton?: React.ReactNode
+3 -3
View File
@@ -22,7 +22,7 @@ export const ALL_YEARS_VALUE = '__all__'
interface Props {
/**
* Current selection. `null` means "all years" — no filter applied.
* Current selection. `null` means "all years": no filter applied.
*/
value: string | null
/**
@@ -103,7 +103,7 @@ export function FiscalYearSelector({
const today = new Date().toISOString().split('T')[0]
fetched = fetched.filter((p) => p.period_start <= today)
}
// Newest first — most migrations list recent years at the top
// Newest first: most migrations list recent years at the top
fetched.sort((a, b) => b.period_start.localeCompare(a.period_start))
setPeriods(fetched)
setLoaded(true)
@@ -176,7 +176,7 @@ export function FiscalYearSelector({
)}
{periods.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name} ({p.period_start} — {p.period_end})
{p.name} ({p.period_start} till {p.period_end})
{p.locked_at ? t('suffix_locked') : p.is_closed ? t('suffix_closed') : ''}
</SelectItem>
))}
+5 -5
View File
@@ -17,7 +17,7 @@ export type DateRangeValue = {
type Preset = 'full_year' | 'ytd' | 'this_month' | 'last_month' | 'this_quarter' | 'custom'
interface Props {
/** Selected fiscal period — bounds the range. */
/** Selected fiscal period: bounds the range. */
periodStart: string
periodEnd: string
value: DateRangeValue
@@ -30,7 +30,7 @@ const STORAGE_KEY_PREFIX = 'Accounted:report-range-preset:'
const PRESETS: Preset[] = ['full_year', 'ytd', 'this_month', 'last_month', 'this_quarter', 'custom']
function todayIso(): string {
// Local calendar date — using toISOString() returns UTC, which falls a day
// Local calendar date: using toISOString() returns UTC, which falls a day
// behind for Swedish users between midnight and 01:00/02:00 local time and
// would silently truncate "today" from YTD / this-month / this-quarter.
const d = new Date()
@@ -49,7 +49,7 @@ function clampToPeriod(date: string, periodStart: string, periodEnd: string): st
/**
* Resolve a preset to a concrete range inside the fiscal period.
*
* Endpoints are always clamped to the period — e.g. "this month" outside the
* Endpoints are always clamped to the period: e.g. "this month" outside the
* period collapses to a zero-width range at whichever boundary you're nearest.
* `full_year` returns `{}` so the API call omits the params entirely and the
* report falls back to its full-period default (preserves cache parity with
@@ -113,7 +113,7 @@ function resolvePreset(
*
* Default = "Hittills i år" (YTD) which matches Fortnox/Visma. A "Hela året"
* preset clears the range entirely so the API falls back to full-period
* behaviour. Custom range is clamped to the fiscal period — cross-year
* behaviour. Custom range is clamped to the fiscal period: cross-year
* ranges are out of scope.
*/
export function ReportDateRange({
@@ -128,7 +128,7 @@ export function ReportDateRange({
const [preset, setPreset] = useState<Preset>('ytd')
// Restore last-used preset per company, then resolve it against the
// current fiscal period. The period selector lives upstream — when it
// current fiscal period. The period selector lives upstream: when it
// changes, we re-resolve so the dates always sit inside the visible year.
useEffect(() => {
if (!company?.id || typeof window === 'undefined') return
+1 -1
View File
@@ -241,7 +241,7 @@ export default function CustomerForm({
</div>
</div>
{/* Identification — depends on customer type */}
{/* Identification: depends on customer type */}
{customerType === 'individual' ? (
<div className="space-y-4 pt-4 border-t">
<h3 className="font-medium">{t('individual_section')}</h3>
+2 -2
View File
@@ -10,7 +10,7 @@ interface Props {
// agent_profile yet. Mounted from the dashboard server component which
// already does the existence check, so this component itself doesn't fetch.
//
// Single CTA, no dismiss — building the agent is part of onboarding and
// Single CTA, no dismiss: building the agent is part of onboarding and
// once verified it disappears on its own.
export default function AgentSetupBanner({ companyName }: Props) {
return (
@@ -28,7 +28,7 @@ export default function AgentSetupBanner({ companyName }: Props) {
<Badge variant="secondary" className="uppercase tracking-wider">Beta</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
Skräddarsy en hjälp som kan ditt företag — laddas på under en halv minut för {companyName}.
Skräddarsy en hjälp som kan ditt företag: laddas på under en halv minut för {companyName}.
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors shrink-0" />
+4 -4
View File
@@ -28,12 +28,12 @@ import {
import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types'
/**
* AttGoraSection — the dashboard's unified worklist ("Att göra").
* AttGoraSection: the dashboard's unified worklist ("Att göra").
*
* One flat ledger of everything actionable, grouped into three bands by
* session intent: Bokför (the daily loop), Granska & komplettera (close the
* gaps), Bevaka (time-driven). Every count comes from lib/worklist — the same
* source as the sidebar badges — so the numbers can never disagree.
* gaps), Bevaka (time-driven). Every count comes from lib/worklist (the same
* source as the sidebar badges) so the numbers can never disagree.
*
* Suggested transaction↔invoice matches render inline with one-click confirm:
* the row posts to the existing match endpoints, fades out optimistically,
@@ -113,7 +113,7 @@ export default function AttGoraSection({
setTotal(json.data.total)
}
} catch (err) {
// Stale counts self-correct on the next page load — never block the
// Stale counts self-correct on the next page load: never block the
// flow, but keep the failure observable (Sentry captures console.error)
// so a systematically broken counts endpoint doesn't hide behind
// silently frozen numbers.
+4 -4
View File
@@ -91,7 +91,7 @@ export default function CompanySwitcher() {
return
}
// Notify every other open tab of the same user so they hard-reload
// onto the new company. BroadcastChannel is best-effort — if the
// onto the new company. BroadcastChannel is best-effort: if the
// browser doesn't support it (very old) we still hard-reload
// ourselves, and other tabs will self-correct via the visibilitychange
// / pageshow listeners in CompanyTabSync on their next focus event.
@@ -101,10 +101,10 @@ export default function CompanySwitcher() {
channel.postMessage({ companyId })
channel.close()
} catch {
// Ignore — hard reload still happens below
// Ignore: hard reload still happens below
}
}
// Hard navigation — tears down React state, router cache, in-flight
// Hard navigation: tears down React state, router cache, in-flight
// fetches, blob URLs, etc. This is the whole point: nothing from the
// previous company can survive the switch.
window.location.assign('/')
@@ -113,7 +113,7 @@ export default function CompanySwitcher() {
// Always allow opening the dropdown (to show "Lägg till företag")
const hasMultiple = companies.length > 1
// No companies yet — show a direct "Lägg till företag" link instead of
// No companies yet: show a direct "Lägg till företag" link instead of
// the switcher so the user can still create one. Hidden in sandbox mode.
if (!company && companies.length === 0) {
if (isSandbox) return null
+8 -8
View File
@@ -4,7 +4,7 @@ import { useEffect } from 'react'
import { useCompany } from '@/contexts/CompanyContext'
/**
* CompanyTabSync — cross-tab active company enforcement.
* CompanyTabSync: cross-tab active company enforcement.
*
* Mounted once inside the dashboard layout (via CompanyProvider), this
* component guarantees that every open tab of the same user always shows
@@ -17,7 +17,7 @@ import { useCompany } from '@/contexts/CompanyContext'
*
* 2. visibilitychange
* Catches tabs that were hidden/minimized during a switch. On focus,
* the tab checks /api/company/current and reloads on mismatch — before
* the tab checks /api/company/current and reloads on mismatch, before
* any pixel of stale data is painted to the user.
*
* 3. pageshow with event.persisted === true
@@ -26,8 +26,8 @@ import { useCompany } from '@/contexts/CompanyContext'
* neither BroadcastChannel nor visibilitychange fires. pageshow is the
* only reliable signal and is guaranteed to fire on bfcache restore.
*
* All three layers converge on the same action: window.location.assign('/')
* — a hard navigation that wipes React state, the router cache, in-flight
* All three layers converge on the same action: window.location.assign('/'),
* a hard navigation that wipes React state, the router cache, in-flight
* requests, blob URLs, and every other in-tab leak vector.
*
* No-op when the user has no active company (renders nothing, attaches no
@@ -42,7 +42,7 @@ export default function CompanyTabSync() {
window.location.assign('/')
}
// Layer 1: BroadcastChannel — live cross-tab sync
// Layer 1: BroadcastChannel: live cross-tab sync
let channel: BroadcastChannel | null = null
if (typeof BroadcastChannel !== 'undefined') {
channel = new BroadcastChannel('gnubok-company-switch')
@@ -54,7 +54,7 @@ export default function CompanyTabSync() {
}
}
// Layer 2: visibilitychange — on focus, verify against server
// Layer 2: visibilitychange: on focus, verify against server
const checkServer = async () => {
try {
const res = await fetch('/api/company/current', {
@@ -67,7 +67,7 @@ export default function CompanyTabSync() {
hardReload()
}
} catch {
// Network error / offline — do nothing (don't accidentally reload-loop)
// Network error / offline: do nothing (don't accidentally reload-loop)
}
}
@@ -78,7 +78,7 @@ export default function CompanyTabSync() {
}
document.addEventListener('visibilitychange', handleVisibilityChange)
// Layer 3: pageshow (persisted === true) — bfcache restore
// Layer 3: pageshow (persisted === true): bfcache restore
const handlePageShow = (event: PageTransitionEvent) => {
if (event.persisted) {
void checkServer()
+6 -6
View File
@@ -35,7 +35,7 @@ interface DashboardContentProps {
deadlines: Deadline[]
staleUncategorizedCount: number
}
/** Unified pending-work counts from lib/worklist — same source as the sidebar badges. */
/** Unified pending-work counts from lib/worklist: same source as the sidebar badges. */
worklist: WorklistCounts
/** High-confidence transaction↔invoice matches for inline one-click confirm. */
suggestedMatches: SuggestedMatch[]
@@ -54,7 +54,7 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
// The setup gate exists to nudge brand-new users into a data-import step
// before they hit the dashboard. Once the assistant is built we treat the
// user as past that phase — they've already committed to using the tool —
// user as past that phase (they've already committed to using the tool)
// and let the dashboard render normally. This also keeps the sandbox
// (which ships with a pre-built assistant + seeded data but no bank
// connection / SIE import) from showing a checklist that re-links to
@@ -113,7 +113,7 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
return (
<div className="stagger-enter space-y-8">
{/* Build-assistant hero — shown only until the company has a verified
{/* Build-assistant hero: shown only until the company has a verified
agent_profile, so existing/migrated users get a clear prompt instead
of a full-screen onboarding takeover. Once the assistant is built the
dashboard leads with the metrics + the unified "Att göra" worklist
@@ -146,7 +146,7 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
</section>
)}
{/* Key metrics — 4 compact cards */}
{/* Key metrics: 4 compact cards */}
<section>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
@@ -228,7 +228,7 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
</div>
</section>
{/* Att göra — the unified worklist. One section, every actionable item,
{/* Att göra: the unified worklist. One section, every actionable item,
same counts as the sidebar badges (lib/worklist). */}
<AttGoraSection
worklist={worklist}
@@ -237,7 +237,7 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
staleUncategorizedCount={summary.staleUncategorizedCount}
/>
{/* Result — revenue / expenses (always visible) */}
{/* Result: revenue / expenses (always visible) */}
<section>
<div className="grid md:grid-cols-2 gap-4">
<Card>
+28 -28
View File
@@ -71,17 +71,17 @@ interface DashboardNavProps {
entityType: EntityType
// Whether the company has registered as an employer (company_settings.
// pays_salaries). Drives visibility of the payroll (Personal) section for
// non-aktiebolag — notably an enskild firma that hires staff. See #782.
// non-aktiebolag, notably an enskild firma that hires staff. See #782.
paysSalaries?: boolean
// Whether the dimensions register (company_settings.dimensions_enabled) is
// switched on. Drives visibility of the Kostnadsställen & projekt row —
// switched on. Drives visibility of the Kostnadsställen & projekt row:
// same mechanism as paysSalaries: fetched by the dashboard layout.
dimensionsEnabled?: boolean
uncategorizedTransactionCount?: number
pendingOperationsCount?: number
isSandbox?: boolean
extensionNavItems?: ExtensionNavItem[]
// Signed-in user's full name + email — drives the bottom-left account
// Signed-in user's full name + email: drives the bottom-left account
// popover trigger so the user can see WHO they're logged in as,
// distinct from the active COMPANY shown by CompanySwitcher up top.
userName?: string | null
@@ -116,21 +116,21 @@ type NavLabelKey =
| 'help'
| 'settings'
// Nav layout (July 2026 — interaction-mode grouping, dev_docs/nav_ia_redesign.md
// Nav layout (July 2026, interaction-mode grouping, dev_docs/nav_ia_redesign.md
// Phase 0): same routes as before, regrouped by what the user is doing.
// top-of-sidebar — CompanySwitcher (active company / org context).
// top section — flat, no dropdown: Hem, Assistent. Hem doubles as
// top-of-sidebar : CompanySwitcher (active company / org context).
// top section : flat, no dropdown: Hem, Assistent. Hem doubles as
// the "what needs me" surface until a dedicated
// /inbox exists.
// four dropdowns — Arbeta (produce: the bookkeeping funnel first,
// four dropdowns : Arbeta (produce: the bookkeeping funnel first,
// then invoices, supplier invoices, payroll),
// Analys (KPI + reports), Data (master-data
// registers + import/export), Skatt & bokslut
// (statutory: VAT, tax account, deadlines, year-end).
// bottom-left popover — signed-in user's name + initial, opens upward
// bottom-left popover : signed-in user's name + initial, opens upward
// to Inställningar, Hjälp, Support, Logga ut.
// Help + Settings are NOT in `navItems`; they live in the account popover.
// Pending (Granskning) stays visible at all times — the badge carries the count.
// Pending (Granskning) stays visible at all times: the badge carries the count.
type GroupKey = 'top' | 'arbeta' | 'analys' | 'data' | 'skatt'
interface NavItem {
@@ -138,13 +138,13 @@ interface NavItem {
labelKey: NavLabelKey
icon: typeof LayoutDashboard
group: GroupKey
// Payroll surfaces — visible only to employers: every aktiebolag (unchanged
// Payroll surfaces: visible only to employers: every aktiebolag (unchanged
// behaviour) plus any company that has registered as an employer via
// company_settings.pays_salaries (e.g. an enskild firma with staff). #782
employerOnly?: boolean
// Dimension surfaces — visible only when the company has opted in via
// Dimension surfaces: visible only when the company has opted in via
// company_settings.dimensions_enabled (UI-visibility gate only; the pages
// and APIs work regardless — dimensions plan §2).
// and APIs work regardless, dimensions plan §2).
requiresDimensions?: boolean
hidden?: boolean
comingSoon?: boolean
@@ -153,11 +153,11 @@ interface NavItem {
}
const navItems: NavItem[] = [
// Top section — flat list, always visible, no header
// Top section: flat list, always visible, no header
{ href: '/', labelKey: 'home', icon: Home, group: 'top' },
{ href: '/chat', labelKey: 'assistant', icon: Sparkles, group: 'top' },
// Arbeta — everything the user produces. The bookkeeping funnel leads
// (Bokföring · Underlag · Transaktioner · Granskning — kept as separate
// Arbeta: everything the user produces. The bookkeeping funnel leads
// (Bokföring · Underlag · Transaktioner · Granskning: kept as separate
// rows until the unified workspace lands), then the transactional flows.
// Löner: "Beta" badge while we validate the end-to-end salary + AGI flow.
// employerOnly: shown to aktiebolag and to any employer (pays_salaries), so an
@@ -170,11 +170,11 @@ const navItems: NavItem[] = [
{ href: '/invoices', labelKey: 'invoices', icon: ReceiptText, group: 'arbeta' },
{ href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'arbeta' },
{ href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'arbeta', employerOnly: true, betaBadge: true },
// Analys — read the numbers. KPI stays a separate row until the fused
// Analys: read the numbers. KPI stays a separate row until the fused
// Rapporter surface (nav_ia_redesign §F) is built.
{ href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'analys' },
{ href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'analys' },
// Data — master-data registers + data plumbing. Anställda is a register
// Data: master-data registers + data plumbing. Anställda is a register
// (you edit an employee rarely, you run payroll monthly), so it lives here
// while the Löner flow stays in Arbeta.
{ href: '/customers', labelKey: 'customers', icon: Users, group: 'data' },
@@ -185,7 +185,7 @@ const navItems: NavItem[] = [
{ href: '/chart-of-accounts', labelKey: 'chart_of_accounts', icon: ListTree, group: 'data' },
{ href: '/dimensions', labelKey: 'dimensions', icon: Tags, group: 'data', requiresDimensions: true },
{ href: '/import', labelKey: 'import', icon: Upload, group: 'data' },
// Skatt & bokslut — everything submitted to the state. Rescues the
// Skatt & bokslut: everything submitted to the state. Rescues the
// previously nav-orphaned /skattekonto and /deadlines, and promotes the
// VAT declaration out of the report catalog.
{ href: '/reports/vat-declaration', labelKey: 'vat_declaration', icon: Percent, group: 'skatt' },
@@ -226,7 +226,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const router = useRouter()
const supabase = useRealtimeSupabase()
const { company } = useCompany()
// Agent identity drives the "Assistent" nav icon — when the user has
// Agent identity drives the "Assistent" nav icon: when the user has
// built their assistant we show its chosen avatar instead of the
// generic Sparkles glyph.
const { identity: agentIdentity } = useAgentSheet()
@@ -244,7 +244,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const hasCompany = !!company
const ALWAYS_ENABLED = new Set(['/settings'])
const isItemEnabled = (href: string) => hasCompany || ALWAYS_ENABLED.has(href)
// Per-group collapse state. Default: ALL groups expanded — the user
// Per-group collapse state. Default: ALL groups expanded. The user
// can see every child link without hunting. Clicking the chevron
// collapses the group; opening it again restores the children.
// Active route still forces a group expanded even when the user has
@@ -283,7 +283,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
return pathname === '/salary' || pathname.startsWith('/salary/runs')
}
// Bokslut (/bookkeeping/year-end) and Moms (/reports/vat-declaration)
// have their own rows under Skatt & bokslut — carve them out of their
// have their own rows under Skatt & bokslut: carve them out of their
// parent routes so exactly one row lights up.
if (href === '/bookkeeping') {
return pathname.startsWith('/bookkeeping') && !pathname.startsWith('/bookkeeping/year-end')
@@ -393,17 +393,17 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const filteredItems = navItems.filter(item => {
if (item.hidden) return false
if (hiddenNavHrefs.has(item.href)) return false
// Payroll (employerOnly) is hidden until the company is an employer — an
// Payroll (employerOnly) is hidden until the company is an employer, an
// aktiebolag, or any entity that has flagged pays_salaries. #782
if (item.employerOnly && !isEmployer) return false
// Dimension surfaces are hidden until the company opts in via the
// bookkeeping settings toggle (company_settings.dimensions_enabled).
if (item.requiresDimensions && !dimensionsEnabled) return false
// Hide the Assistent (/chat) tab until the agent is built — mirrors the
// Hide the Assistent (/chat) tab until the agent is built: mirrors the
// floating AgentTrigger and avoids a nav entry that only bounces to the
// home checklist (chat/layout redirects unverified users to /).
if (item.href === '/chat' && !agentIdentity.isVerified) return false
// Granskning stays in the top nav at all times now — the badge
// Granskning stays in the top nav at all times now: the badge
// surfaces the count when there are pending ops, but the link is
// always present so users can navigate there manually.
return true
@@ -458,7 +458,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
<aside className="hidden md:fixed md:inset-y-0 md:flex md:w-64 md:flex-col">
<div className="flex min-h-0 flex-1 flex-col border-r border-border bg-background">
<div className="flex flex-1 flex-col overflow-y-auto pt-7 pb-4">
{/* Company switcher pinned to the top — the active company is
{/* Company switcher pinned to the top: the active company is
the strongest piece of context for everything below it. */}
<div className="px-5 mb-8">
<CompanySwitcher />
@@ -659,7 +659,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
{/* Account popover (bottom-left). Triggered by the signed-in
user's name + initial. Holds Inställningar, Hjälp, Support,
Logga ut. CompanySwitcher lives at the top of the sidebar,
not in here — different concept ("which company am I working
not in here, different concept ("which company am I working
with" vs "who am I logged in as"). */}
<div className="flex-shrink-0 px-3 py-3 border-t border-border">
<DropdownMenu>
@@ -787,7 +787,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
</div>
</nav>
{/* Mobile menu — bottom sheet */}
{/* Mobile menu: bottom sheet */}
{isMobileMenuOpen && (
<>
{/* Backdrop */}
@@ -939,7 +939,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
</div>
))}
{/* Tillägg (extensions) — only when there's at least one */}
{/* Tillägg (extensions): only when there's at least one */}
{visibleExtensionNavItems.length > 0 && (
<>
<div className="flex items-center gap-3 my-1.5 px-3">
+1 -1
View File
@@ -23,7 +23,7 @@ export function MainContainer({
}) {
const pathname = usePathname()
// Full-bleed routes own their own padding + multi-pane layout. They
// shouldn't sit inside max-w-5xl or any horizontal padding — that's what
// shouldn't sit inside max-w-5xl or any horizontal padding: that's what
// causes a visible gap between the dashboard sidebar and the chat-sidebar
// pane on wide viewports.
const isFullBleed = pathname.startsWith('/e/') || pathname.startsWith('/chat')
+1 -1
View File
@@ -20,7 +20,7 @@ export function SandboxBanner() {
return (
<div className="relative z-50 flex items-center justify-center gap-x-3 gap-y-1 bg-warning px-10 py-2 text-sm text-warning-foreground sm:px-4 flex-wrap">
<span className="font-medium text-center text-xs sm:text-sm">
Sandlådemiljö — AI och externa tjänster är avstängda. Data raderas efter 24h.
Sandlådemiljö: AI och externa tjänster är avstängda. Data raderas efter 24h.
</span>
<button
onClick={handleCreateAccount}
+9 -9
View File
@@ -50,8 +50,8 @@ function logError(message: string, extra?: Record<string, unknown>) {
}).catch(() => {})
}
// Parse TIC v2's `startMonthDay` ("MM-DD" — e.g. "07-01") into a month
// number 1–12. Returns null on missing / malformed input so the caller
// Parse TIC v2's `startMonthDay` ("MM-DD": e.g. "07-01") into a month
// number 1-12. Returns null on missing / malformed input so the caller
// can fall through to the manual picker default.
function parseStartMonthDay(value: string | null | undefined): number | null {
if (!value) return null
@@ -64,7 +64,7 @@ function parseStartMonthDay(value: string | null | undefined): number | null {
// Derive the Step-3 first-year defaults from TIC's `registrationDate`.
// A company is treated as "first year" when registered less than 12 months
// ago — fits BFL's 6-18 month opening-period window comfortably. Returns
// ago: fits BFL's 6-18 month opening-period window comfortably. Returns
// both the toggle state and a seeded `first_year_start` (always the 1st of
// the registration month, the format Step 3's date inputs expect).
function deriveFirstYearDefaults(registrationDate: number | null | undefined): {
@@ -98,7 +98,7 @@ interface WelcomeOnboardingProps {
/** Legal name from CompanyRoles. Pre-fills Step 2's company_name field. */
initialLegalName?: string
/** Set when the orgnr came from BankID CompanyRoles. Step 2 treats the
* field as pre-verified — skips the client-side Lens `/lookup` since
* field as pre-verified: skips the client-side Lens `/lookup` since
* CompanyRoles already confirms the company exists. Cleared the moment
* the user edits the orgnr. */
preverifiedOrgNumber?: string
@@ -123,7 +123,7 @@ export default function WelcomeOnboarding({
const [isSaving, setIsSaving] = useState(false)
const [currentStep, setCurrentStep] = useState(1)
// Seed settings with what BankID CompanyRoles already told us. address /
// F-skatt / VAT come later — user types address in Step 2 and confirms
// F-skatt / VAT come later: user types address in Step 2 and confirms
// F-skatt/VAT in Step 4. We deliberately don't pre-fetch from Lens to
// preserve the 3000/mo budget for the manual-orgnr path.
const [settings, setSettings] = useState<Partial<CompanySettings>>(() => {
@@ -179,7 +179,7 @@ export default function WelcomeOnboarding({
// Step 4 (final): create everything via server action.
// Going through a server action ensures that if the Next.js server is
// unreachable, nothing touches Supabase — no ghost companies.
// unreachable, nothing touches Supabase: no ghost companies.
const periodResult = computeFiscalPeriod(mergedSettings)
if (periodResult.error) {
toast({
@@ -247,7 +247,7 @@ export default function WelcomeOnboarding({
const stepInfo = STEP_INFO[currentStep - 1]
// Welcome screen — show before user clicks "Lägg till ditt första företag"
// Welcome screen: show before user clicks "Lägg till ditt första företag"
if (!started) {
return (
<div className="flex flex-col items-start justify-center min-h-[60vh] animate-fade-in">
@@ -360,7 +360,7 @@ export default function WelcomeOnboarding({
{currentStep === 3 && (() => {
// Derive both first-year defaults from TIC's registrationDate
// (null-safe — returns { false, undefined } for established
// (null-safe: returns { false, undefined } for established
// companies and for missing registrationDate).
const firstYearDefaults = deriveFirstYearDefaults(
ticLookup?.registrationDate,
@@ -379,7 +379,7 @@ export default function WelcomeOnboarding({
?? parseStartMonthDay(ticLookup?.fiscalYear?.startMonthDay)
?? undefined,
// Companies registered <12 months ago land in their first
// fiscal year — pre-check the toggle and seed the start
// fiscal year: pre-check the toggle and seed the start
// date so the user only confirms the end date.
is_first_fiscal_year:
settings.is_first_fiscal_year ?? firstYearDefaults.isFirstFiscalYear,
+11 -11
View File
@@ -116,17 +116,17 @@ function voucherTagState(v: TaggingVoucher): {
* Bulk retro-tagging workbench (dimensions plan PR6 §3, voucher-level
* rework): browse posted VERIFIKAT, select whole vouchers (shift-click
* ranges), pick KS/Projekt values and apply them to every line through the
* audited retag RPC — retroactive tagging produces exactly what tagging at
* audited retag RPC: retroactive tagging produces exactly what tagging at
* creation would have (the producers stamp all lines too). Rows expand to
* their lines for the mixed case (a voucher split across projects).
*
* Reversal pairs are hidden by default (they net to zero in every dimension
* bucket when kept symmetric — tagging them is a no-op, and tagging one side
* bucket when kept symmetric: tagging them is a no-op, and tagging one side
* only is the one way to skew project P&L). "Visa annullerade" opts them in;
* the blocking motverifikat confirmation survives only there.
*
* Merge mode (default) layers picked values onto each line's existing map;
* "Ersätt tagg" replaces the whole map — used to consolidate typo/phantom
* "Ersätt tagg" replaces the whole map: used to consolidate typo/phantom
* codes. Strings hardcoded Swedish per the dimensions-surface convention.
*/
export default function BulkTagWorkbench() {
@@ -147,7 +147,7 @@ export default function BulkTagWorkbench() {
const [totalCapped, setTotalCapped] = useState(false)
const [isLoading, setIsLoading] = useState(false)
// Selection (line-id based — the retag RPC is per line), expansion + apply
// Selection (line-id based: the retag RPC is per line), expansion + apply
const [selected, setSelected] = useState<Set<string>>(new Set())
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const anchorIndexRef = useRef<number | null>(null)
@@ -280,7 +280,7 @@ export default function BulkTagWorkbench() {
return vouchers.filter((v) => v.lines.some((l) => selected.has(l.id))).length
}, [vouchers, selected])
// Reversal-pair guard — only reachable when annullerade are shown: a
// Reversal-pair guard: only reachable when annullerade are shown: a
// selected voucher whose counter-entry is loaded but not fully selected.
const missingPairLineIds = useMemo(() => {
if (!vouchers || selected.size === 0) return [] as string[]
@@ -297,7 +297,7 @@ export default function BulkTagWorkbench() {
.filter((id) => !selected.has(id))
}, [vouchers, selected])
// Voucher labels of the unselected counter-vouchers — the blocking
// Voucher labels of the unselected counter-vouchers: the blocking
// confirmation names them so the skew risk is concrete (#867 review:
// Srf U 14 gross reporting; an asymmetric storno pair silently skews
// project P&L, so the advisory alone is not enough).
@@ -344,7 +344,7 @@ export default function BulkTagWorkbench() {
// Storno-pair guard: tagging one leg of a reversal pair without the
// other skews project P&L. Blocking confirmation, not just the banner.
// Only reachable when "Visa annullerade" is on — the default view
// Only reachable when "Visa annullerade" is on: the default view
// excludes pairs entirely.
if (missingPairLineIds.length > 0) {
const ok = await confirm({
@@ -592,7 +592,7 @@ export default function BulkTagWorkbench() {
</span>
{totalCapped && (
<span className="ml-auto text-xs text-muted-foreground">
Visar de första {vouchers?.length ?? 0} verifikaten — förfina filtren för
Visar de första {vouchers?.length ?? 0} verifikaten: förfina filtren för
att se fler.
</span>
)}
@@ -702,7 +702,7 @@ export default function BulkTagWorkbench() {
</DataListMeta>
{hasError && !isExpanded && (
<p className="mt-1 text-xs text-destructive">
Vissa rader kunde inte taggas — visa raderna för detaljer.
Vissa rader kunde inte taggas: visa raderna för detaljer.
</p>
)}
</DataListRow>
@@ -772,7 +772,7 @@ export default function BulkTagWorkbench() {
{/* Spacer so the fixed apply panel never covers the last rows */}
{selected.size > 0 && <div aria-hidden="true" className="h-64 sm:h-48" />}
{/* Apply panel — fixed footer bar while a selection is active */}
{/* Apply panel: fixed footer bar while a selection is active */}
{selected.size > 0 && (
<div className="fixed bottom-20 left-1/2 z-40 w-[calc(100vw-2rem)] max-w-2xl -translate-x-1/2 rounded-lg border border-border bg-background p-4 shadow-lg md:bottom-6">
{missingPairLineIds.length > 0 && (
@@ -783,7 +783,7 @@ export default function BulkTagWorkbench() {
aria-hidden="true"
/>
<p className="text-sm">
Du taggar ett verifikat men inte dess motverifikat —
Du taggar ett verifikat men inte dess motverifikat:
projektresultatet kan bli skevt.
</p>
</div>
+4 -4
View File
@@ -22,7 +22,7 @@ interface DimensionComboboxProps {
}
/**
* Searchable picker for dimension values — sibling of
* Searchable picker for dimension values: sibling of
* components/bookkeeping/AccountCombobox.tsx and deliberately mirrors its
* interaction model: plain Input trigger, keyboard-first dropdown
* (arrows/Enter/Escape), close-on-outside-click, and an inline "Skapa ny…"
@@ -30,7 +30,7 @@ interface DimensionComboboxProps {
* selects it.
*
* Fetches the registry lazily on first open and filters client-side (value
* lists are small). Only active values are offered — archived codes stay
* lists are small). Only active values are offered: archived codes stay
* pickable in history but are never suggested.
*
* Strings are hardcoded Swedish per the AccountCombobox convention: the
@@ -57,7 +57,7 @@ export default function DimensionCombobox({
// Refs mirroring the committed `value` prop and the fetched values. The
// blur timeout below runs 150ms after render, so reading `value`/`values`
// directly would act on a stale snapshot — a selection landing during that
// directly would act on a stale snapshot: a selection landing during that
// window (updating the prop via onChange) must win over the revert.
const committedRef = useRef(value)
const valuesRef = useRef(values)
@@ -227,7 +227,7 @@ export default function DimensionCombobox({
const handleBlur = () => {
setIsOpen(false)
// Small delay so a dropdown mousedown fires first (same trick as
// AccountCombobox — option clicks preventDefault, so they never blur).
// AccountCombobox: option clicks preventDefault, so they never blur).
// An emptied field clears the dimension; anything that isn't a known
// code reverts to the committed value. Committed value and values are
// read through refs so a selection that lands during the 150ms window
+1 -1
View File
@@ -40,7 +40,7 @@ interface DimensionValueFormProps {
/**
* Create/edit form for a dimension value (#OBJEKT), hosted in the
* DimensionsManager dialog. Code is immutable in v1 — the field is disabled
* DimensionsManager dialog. Code is immutable in v1: the field is disabled
* when editing. Start/end dates appear only for Projekt (dim 6), matching the
* SIE model where projects span a date range while cost centres do not.
*/
+9 -9
View File
@@ -70,7 +70,7 @@ function compareStrings(a: string, b: string): number {
}
/**
* Register for dimension values (kostnadsställen & projekt) — the
* Register for dimension values (kostnadsställen & projekt): the
* customers-page register recipe hosted behind one segmented tab per registry
* dimension. Archive rides the edit form's aktiv switch (PATCH is_active);
* delete lives only in the edit dialog and surfaces the DB retention
@@ -212,7 +212,7 @@ export default function DimensionsManager() {
if (!res.ok) throw json ?? new Error()
toast({ title: t('updated_title') })
} else {
// "Create as archived" rides the create contract's is_active field —
// "Create as archived" rides the create contract's is_active field:
// one atomic POST, no follow-up PATCH.
const body: Record<string, unknown> = {
code: input.code,
@@ -295,7 +295,7 @@ export default function DimensionsManager() {
)
if (!res.ok) {
const json = await res.json().catch(() => null)
// Values referenced by posted lines cannot be deleted — the DB
// Values referenced by posted lines cannot be deleted: the DB
// retention trigger's Swedish message ("…arkivera det istället")
// rides the error envelope; surface it verbatim.
toast({
@@ -379,7 +379,7 @@ export default function DimensionsManager() {
return (
<div className="space-y-8">
{/* Segmented tabs — one per registry dimension (1 Kostnadsställe,
{/* Segmented tabs: one per registry dimension (1 Kostnadsställe,
6 Projekt, plus any custom 20+ dims) */}
<div className="space-y-2">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
@@ -488,10 +488,10 @@ export default function DimensionsManager() {
{isProjectTab && (
<>
<TableCell className="tabular-nums text-muted-foreground">
{value.start_date ? formatDate(value.start_date) : '—'}
{value.start_date ? formatDate(value.start_date) : '-'}
</TableCell>
<TableCell className="tabular-nums text-muted-foreground">
{value.end_date ? formatDate(value.end_date) : '—'}
{value.end_date ? formatDate(value.end_date) : '-'}
</TableCell>
</>
)}
@@ -524,9 +524,9 @@ export default function DimensionsManager() {
{isProjectTab && (value.start_date || value.end_date) && (
<CardContent>
<p className="text-sm text-muted-foreground tabular-nums">
{value.start_date ? formatDate(value.start_date) : '—'}
{' – '}
{value.end_date ? formatDate(value.end_date) : '—'}
{value.start_date ? formatDate(value.start_date) : '-'}
{' till '}
{value.end_date ? formatDate(value.end_date) : '-'}
</p>
</CardContent>
)}
@@ -9,7 +9,7 @@ import {
} from '@/components/dimensions/types'
interface LineDimensionFieldsProps {
/** Current dimensions map ({sie_dim_no: object_code}) — a line's map or the header default. */
/** Current dimensions map ({sie_dim_no: object_code}), a line's map or the header default. */
dimensions: Record<string, string> | undefined
/** Fired per dimension; `code === null` clears the value. */
onChange: (sieDimNo: string, code: string | null) => void
@@ -22,7 +22,7 @@ interface LineDimensionFieldsProps {
/**
* While the registry loads (or if the fetch fails) we render the seeded
* system pair (SIE dims 1/6) so the tagging affordance never disappears —
* system pair (SIE dims 1/6) so the tagging affordance never disappears:
* the registry always contains at least these two.
*/
const FALLBACK_FIELDS: { sieDimNo: string; label: string }[] = [
@@ -35,7 +35,7 @@ const FALLBACK_FIELDS: { sieDimNo: string; label: string }[] = [
* default, the per-row tag popover, and the mobile line cards. One combobox
* per active registry dimension, ordered by sort_order then sie_dim_no (the
* seeded 1/6 pair sorts first). Labels are the registry dimension names and
* hardcoded-Swedish fallbacks — the component mounts on the voucher editor,
* hardcoded-Swedish fallbacks: the component mounts on the voucher editor,
* a stays-Swedish surface per .claude/rules/i18n.md (same convention as
* DimensionCombobox).
*/
+2 -2
View File
@@ -38,7 +38,7 @@ interface Props {
* Tier-2 retro-tagging on a posted voucher line (dimensions plan PR6).
* Edits ONLY the dimension tags via the audited retag RPC; the verifikat
* itself is untouchable. Dims other than 1/6 pass through unedited (same
* merge semantics as the voucher editor). Hardcoded Swedish — voucher
* merge semantics as the voucher editor). Hardcoded Swedish: voucher
* detail is a stays-Swedish surface.
*/
export default function RetagLineDialog({ open, onOpenChange, line, onRetagged }: Props) {
@@ -114,7 +114,7 @@ export default function RetagLineDialog({ open, onOpenChange, line, onRetagged }
<div className="rounded-lg border border-border p-3 text-sm">
<AccountNumber number={line.account_number} showName />
<div className="mt-1 flex items-center justify-between text-muted-foreground">
<span className="truncate">{line.line_description || '—'}</span>
<span className="truncate">{line.line_description || '-'}</span>
<span className="tabular-nums shrink-0 ml-3">
{amount.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} kr {side}
</span>
+1 -1
View File
@@ -3,7 +3,7 @@
* dev_docs/dimensions_implementation_plan.md).
*
* The routes live under /api/dimensions and are built against the same locked
* contract — this module codes against the contract, not the route files, so
* contract: this module codes against the contract, not the route files, so
* the register UI (DimensionsManager) and the shared picker (DimensionCombobox)
* can ship independently of the API package.
*/
+1 -1
View File
@@ -12,7 +12,7 @@
* Aesthetic: paper-white surfaces, hairline borders, Hedvig display
* headlines, Geist body. Sidebar entries use the same warm-beige hover
* + active background as the dashboard sidebar so the docs feel like the
* same instrument as the app — not a separate marketing site.
* same instrument as the app: not a separate marketing site.
*/
import Link from 'next/link'
@@ -41,10 +41,10 @@ import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registr
type ArcimProvider = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden'
// `sieViaApi`: the provider serves its general ledger as SIE over the API —
// `sieViaApi`: the provider serves its general ledger as SIE over the API:
// no manual SIE upload needed. Deliberately duplicated from
// extensions/general/arcim-migration/types.ts (core code must not import from
// @/extensions/ — CI enforces it). Keep both lists in sync.
// @/extensions/: CI enforces it). Keep both lists in sync.
const ARCIM_PROVIDERS: { id: ArcimProvider; name: string; authType: 'oauth' | 'token'; sieViaApi: boolean }[] = [
{ id: 'fortnox', name: 'Fortnox', authType: 'oauth', sieViaApi: true },
{ id: 'visma', name: 'Visma', authType: 'oauth', sieViaApi: false },
@@ -56,7 +56,7 @@ const ARCIM_PROVIDERS: { id: ArcimProvider; name: string; authType: 'oauth' | 't
/**
* Extract a human-readable message from an API error body. Routes answer in
* two shapes: legacy `{ error: 'text' }` and the structured envelope
* `{ error: { code, message } }` — naively rendering the latter shows
* `{ error: { code, message } }`: naively rendering the latter shows
* "[object Object]".
*/
function apiErrorMessage(data: unknown, fallback: string): string {
@@ -167,7 +167,7 @@ interface PreviewData {
interface SIEFileStatus {
fiscalYear: number
// Legacy field for older builds — read previousImport instead.
// Legacy field for older builds: read previousImport instead.
alreadyImported: boolean
importedAt: string | null
// New (period-based) detection. When present, this fiscal year already has a
@@ -190,7 +190,7 @@ interface SIEData {
newFileCount: number
replacedFileCount?: number
// Fiscal years whose provider export failed. Importing the remaining years
// anyway leaves an IB/UB gap — the options step warns before proceeding.
// anyway leaves an IB/UB gap: the options step warns before proceeding.
failedYears?: { year: number; error: string }[]
basAccounts: BASAccount[]
}
@@ -255,14 +255,14 @@ function ProviderStep({
return (
<div className="space-y-4">
{/* SIE-required banner (not relevant for Fortnox/Briox — they fetch SIE via API) */}
{/* SIE-required banner (not relevant for Fortnox/Briox: they fetch SIE via API) */}
{showSieRequiredBanner && (
<div className="flex gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 p-4">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-500" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">SIE-import krävs först</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Bokio och Visma hämtar endast kunder, leverantörer och fakturor via API:et. Bokföringsdata (kontoplan, verifikationer och balanser) måste importeras via SIE-fil först. Gäller inte Fortnox, Briox och Björn Lundén — där hämtar vi SIE direkt via API:et.
Bokio och Visma hämtar endast kunder, leverantörer och fakturor via API:et. Bokföringsdata (kontoplan, verifikationer och balanser) måste importeras via SIE-fil först. Gäller inte Fortnox, Briox och Björn Lundén: där hämtar vi SIE direkt via API:et.
</p>
<Link
href="/import?mode=sie"
@@ -317,7 +317,7 @@ function ProviderStep({
{lastImport ? (
<p className="text-xs text-muted-foreground">
Senaste import: {new Date(lastImport.imported_at ?? lastImport.created_at).toLocaleDateString('sv-SE')}
{lastImport.transactions_count != null && ` — ${lastImport.transactions_count} verifikationer`}
{lastImport.transactions_count != null && `, ${lastImport.transactions_count} verifikationer`}
</p>
) : (
<p className="text-xs text-muted-foreground">
@@ -385,7 +385,7 @@ function ProviderStep({
const comingSoon = COMING_SOON_PROVIDERS.has(provider.id)
const alreadyConnected = activeConsents.some(c => c.provider === provider.id)
// Providers without SIE-over-API only expose entity data
// (customers, suppliers, invoices) — the ledger must arrive via
// (customers, suppliers, invoices): the ledger must arrive via
// SIE upload first. Gate the connection entry until a completed
// SIE import exists so users don't authenticate into a flow that
// can't import anything yet. The /migrate route enforces this
@@ -469,7 +469,7 @@ function ConnectStep({
const [apiToken, setApiToken] = useState('')
const [companyId, setCompanyId] = useState('')
// BL uses server-side client credentials — only needs company ID, no API key
// BL uses server-side client credentials: only needs company ID, no API key
const isClientCredentials = provider === 'bjornlunden'
const needsApiToken = !isClientCredentials
// Briox: the account ID is the `clientid` half of the token exchange
@@ -767,7 +767,7 @@ function InfoItem({ label, value }: { label: string; value: string | null }) {
return (
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-sm font-medium">{value || '—'}</p>
<p className="text-sm font-medium">{value || '-'}</p>
</div>
)
}
@@ -915,7 +915,7 @@ function OptionsStep({
{sieAvailable && (
<>
{/* Years whose provider export failed — must be visible before
{/* Years whose provider export failed: must be visible before
the user proceeds, otherwise an IB/UB gap slips through. */}
{failedYears.length > 0 && (
<div className="rounded-md border border-amber-500/30 bg-amber-50/50 p-3 dark:bg-amber-950/20">
@@ -945,7 +945,7 @@ function OptionsStep({
replacedFileCount > 0 && newFileCount > 0
? `${newFileCount} nya och ${replacedFileCount} uppdaterade räkenskapsår`
: replacedFileCount > 0
? `${replacedFileCount} räkenskapsår med uppdaterad data — tidigare import ersätts`
? `${replacedFileCount} räkenskapsår med uppdaterad data: tidigare import ersätts`
: newFileCount > 0
? `${newFileCount} ny(a) räkenskapsår att importera`
: 'Kontoplan, ingående balanser och verifikationer'
@@ -962,7 +962,7 @@ function OptionsStep({
<>
<RefreshCw className="h-3.5 w-3.5 text-amber-500" />
<span className="text-muted-foreground">
Räkenskapsår {fs.fiscalYear} — ersätter tidigare import
Räkenskapsår {fs.fiscalYear}: ersätter tidigare import
{fs.previousImport.importedAt
? ` från ${new Date(fs.previousImport.importedAt).toLocaleDateString('sv-SE')}`
: ''}
@@ -971,7 +971,7 @@ function OptionsStep({
) : (
<>
<Calendar className="h-3.5 w-3.5 text-primary" />
<span className="font-medium">Räkenskapsår {fs.fiscalYear} — ny data att importera</span>
<span className="font-medium">Räkenskapsår {fs.fiscalYear}: ny data att importera</span>
</>
)}
</div>
@@ -1194,7 +1194,7 @@ function FiscalYearResult({ result, index }: { result: ImportResult; index: numb
return (
<div className="rounded-lg border border-border">
{/* Header — always visible */}
{/* Header: always visible */}
<button
type="button"
onClick={() => setExpanded(!expanded)}
@@ -1235,7 +1235,7 @@ function FiscalYearResult({ result, index }: { result: ImportResult; index: numb
{/* Expanded details */}
{expanded && (
<div className="border-t border-border px-4 pb-4 pt-3 space-y-3">
{/* Errors — shown prominently */}
{/* Errors: shown prominently */}
{result.errors.length > 0 && (
<div className="rounded-md border border-destructive/20 bg-destructive/5 p-3">
<div className="flex items-start gap-2">
@@ -1264,7 +1264,7 @@ function FiscalYearResult({ result, index }: { result: ImportResult; index: numb
<>
Differens på <span className="tabular-nums font-medium">{Math.abs(d.openingBalance.imbalance).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK</span> bokförd
på konto {d.openingBalance.bookedToAccount}. Detta beror troligen på att föregående
års resultat inte allokerats till eget kapital i källsystemet — vanligt vid byte
års resultat inte allokerats till eget kapital i källsystemet, vanligt vid byte
av bokföringsprogram.
</>
)}
@@ -1662,7 +1662,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingStatus, setIsLoadingStatus] = useState(true)
const [error, setError] = useState<string | null>(null)
// Per-item details behind `error` — e.g. the SIE validation errors from
// Per-item details behind `error`: e.g. the SIE validation errors from
// /sie-data, which would otherwise be swallowed (the envelope's `error`
// field is just the string "validation").
const [errorDetails, setErrorDetails] = useState<string[] | null>(null)
@@ -1699,7 +1699,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
const [migrationResults, setMigrationResults] = useState<MigrationResults | null>(null)
const [sieImportResults, setSieImportResults] = useState<ImportResult[]>([])
// Wizard progress — only user-interactive steps
// Wizard progress: only user-interactive steps
const userSteps = STEPS.filter(s => {
if (s === 'migrating' || s === 'result') return false
if (s === 'mapping' && !preview?.sieAvailable) return false
@@ -1722,7 +1722,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
setConnectionStatus(data)
}
} catch {
// Non-critical — just means we can't show existing connections
// Non-critical: just means we can't show existing connections
} finally {
setIsLoadingStatus(false)
}
@@ -1748,7 +1748,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
// A dead connection (expired/revoked refresh token) is recoverable in
// place — flag it so the UI offers "Återanslut" instead of a dead end.
// place: flag it so the UI offers "Återanslut" instead of a dead end.
// A missing Fortnox integration license shows the same CTA but keeps the
// SIE fallback, because re-auth loops until the license is re-ordered.
const code = apiErrorCode(data)
@@ -1798,7 +1798,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
setAuthType(data.authType)
if (data.alreadyConnected) {
// Existing connection — skip auth, go straight to preview
// Existing connection: skip auth, go straight to preview
await loadPreview(data.consentId)
return
}
@@ -1814,7 +1814,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
}
}, [loadPreview])
// Re-sync with existing consent — go straight to preview
// Re-sync with existing consent: go straight to preview
const handleResync = useCallback(async (provider: ArcimProvider, existingConsentId: string) => {
setSelectedProvider(provider)
setConsentId(existingConsentId)
@@ -1826,7 +1826,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
}, [loadPreview])
// Re-authorize a dead connection in place. Re-runs provider auth against the
// SAME consent so fresh tokens overwrite the expired pair — no disconnect.
// SAME consent so fresh tokens overwrite the expired pair: no disconnect.
// OAuth providers open the login popup (the existing postMessage listener
// reloads the preview on success); token providers drop to the credential
// form. Triggered from the "Återanslut" CTA after a sync hits
@@ -1854,7 +1854,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
setAuthType(data.authType)
if (data.authType === 'oauth' && data.authUrl) {
// Open immediately — this runs inside the button's click handler, so
// Open immediately: this runs inside the button's click handler, so
// the popup is a trusted user gesture and won't be blocked.
const w = 600
const h = 700
@@ -1917,7 +1917,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
throw new Error(apiErrorMessage(data, `HTTP ${res.status}`))
}
// Token stored — consent is now accepted, proceed to preview
// Token stored: consent is now accepted, proceed to preview
await loadPreview(consentId)
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte ansluta')
@@ -2032,7 +2032,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
// Load SIE data for mapping step
loadSIEData()
} else {
// Skip mapping step — no SIE available
// Skip mapping step: no SIE available
setStep('options')
}
}, [preview, loadSIEData])
@@ -2084,7 +2084,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
for (let i = 0; i < filesToImport.length; i++) {
const progress = 10 + Math.round((i / filesToImport.length) * 40)
setMigrationProgress(progress)
setMigrationStep(`Importerar bokföringsdata (SIE) — fil ${i + 1} av ${filesToImport.length}...`)
setMigrationStep(`Importerar bokföringsdata (SIE): fil ${i + 1} av ${filesToImport.length}...`)
const res = await fetch('/api/extensions/ext/arcim-migration/import-sie', {
method: 'POST',
@@ -2110,7 +2110,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
setSieImportResults(prev => [...prev, result])
// The endpoint returns HTTP 200 with success:false when the import
// itself failed (e.g. räkenskapsår mismatch). Stop here — continuing
// itself failed (e.g. räkenskapsår mismatch). Stop here: continuing
// to /migrate would hit its SIE-guard, whose "SIE måste importeras
// först" message masks the real error.
if (!result.success) {
@@ -2198,7 +2198,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
return (
<div className="space-y-6">
{/* Progress bar — only during interactive steps */}
{/* Progress bar: only during interactive steps */}
{step !== 'provider' && isInteractiveStep && (
<Card>
<CardContent className="pt-6">
@@ -82,7 +82,7 @@ interface Props {
// Compute the prefill lines. Booking is always in SEK (BFL/BFNAR), so when
// a transaction is selected and the document is in a foreign currency, the
// transaction's SEK amount is the canonical figure. The cost-account row
// stays blank — the user must pick a cost account themselves.
// stays blank: the user must pick a cost account themselves.
// bankAccount defaults to '1930' but is replaced by the resolved ledger account
// once the cash-accounts fetch completes.
function buildPrefillLines(
@@ -94,7 +94,7 @@ function buildPrefillLines(
const docVat = item.extracted_data?.totals?.vatAmount ?? null
const docCurrency = item.extracted_data?.invoice?.currency ?? 'SEK'
// Prefer the transaction amount when available — it's already in SEK and
// Prefer the transaction amount when available: it's already in SEK and
// matches the bank movement we'll be marking as booked.
const total = selectedTransactionAmount != null
? Math.abs(selectedTransactionAmount)
@@ -143,7 +143,7 @@ function buildPrefillLines(
// Rank candidates by closeness to the underlag's SEK value. `targetSek` is the
// document total already converted to SEK (the bank charge for a 216 USD
// receipt is ~2 109 kr, not 216) — ranking against the raw foreign total used
// receipt is ~2 109 kr, not 216): ranking against the raw foreign total used
// to bury the real match far down the list. Null target → leave order intact.
function rankBySekCloseness(
rows: PickerTransaction[],
@@ -166,7 +166,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
// pending, or unsupported.
const [fxRate, setFxRate] = useState<number | null>(null)
// null = fetch pending; array = loaded (may be empty on error — falls back to '1930')
// null = fetch pending; array = loaded (may be empty on error: falls back to '1930')
const [cashAccounts, setCashAccounts] = useState<CashAccount[] | null>(null)
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
@@ -186,7 +186,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
// pattern of gating JournalEntryForm on bankAccount !== null.
const [lines, setLines] = useState<FormLine[]>(() => buildPrefillLines(item))
// Transaction picker — optional selection.
// Transaction picker: optional selection.
const [selectedTransactionId, setSelectedTransactionId] = useState<string | null>(
item.matched_transaction_id
)
@@ -197,7 +197,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
const [isSubmitting, setIsSubmitting] = 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
// 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
// the settlement line once the fetch resolves.
useEffect(() => {
@@ -231,7 +231,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
const rate = body?.data?.rate
if (typeof rate === 'number' && rate > 0) setFxRate(rate)
})
.catch(() => { /* leave null — ranking falls back to face amounts */ })
.catch(() => { /* leave null: ranking falls back to face amounts */ })
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, targetCurrency, item.id])
@@ -252,14 +252,14 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
setCashAccounts((json.data ?? []) as CashAccount[])
})
.catch(() => {
// Fall back to empty list — resolveAccount will return '1930'
// Fall back to empty list: resolveAccount will return '1930'
if (!cancelled) setCashAccounts([])
})
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, item.id])
// SEK-equivalent of the underlag total — the anchor for ranking candidates.
// SEK-equivalent of the underlag total: the anchor for ranking candidates.
const targetSek = useMemo(() => {
if (targetAmount == null) return null
if (targetCurrency === 'SEK') return targetAmount
@@ -269,7 +269,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
// When the user picks a transaction (or the toggle changes), re-derive
// the prefilled amounts so foreign-currency invoices follow the SEK
// figure on the actual bank movement. Normalised to SEK — a foreign bank
// figure on the actual bank movement. Normalised to SEK: a foreign bank
// row is booked at its SEK value, never its face amount.
const selectedTransactionAmount = useMemo(() => {
if (!selectedTransactionId) return null
@@ -292,7 +292,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
return targetCurrency
}, [selectedTransactionId, transactions, targetCurrency])
// Resolved bank account — null while the cash-accounts fetch is in flight.
// Resolved bank account: null while the cash-accounts fetch is in flight.
// Derived from the cash accounts list; falls back to '1930' if the list is
// empty or no single-currency match exists.
const bankAccount = useMemo<string | null>(() => {
@@ -305,10 +305,10 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
if (!open) return
// Update amounts when the transaction selection or resolved bank account
// changes, but preserve user-entered account numbers. This handles "user
// typed cost account, then picked an SEK-denominated transaction" — we
// typed cost account, then picked an SEK-denominated transaction": we
// want the SEK figure to flow into the line amounts without forgetting
// their account pick. bankAccount may be null while the fetch is in flight;
// pass '1930' as a safe placeholder in that case — the effect re-runs once
// pass '1930' as a safe placeholder in that case: the effect re-runs once
// the fetch resolves and bankAccount becomes non-null.
setLines((current) => {
const next = buildPrefillLines(item, selectedTransactionAmount, bankAccount ?? '1930')
@@ -358,7 +358,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
}
}, [entryDate, periods, periodId])
// Fetch unmatched transactions whenever the dialog opens — the picker
// Fetch unmatched transactions whenever the dialog opens: the picker
// is always visible now (selection is optional).
useEffect(() => {
if (!open) return
@@ -403,7 +403,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
}, [rankedTransactions, txSearch])
// Pin the already-selected/matched transaction to the top so it's always
// visible — otherwise a correct match that ranks past the rendered cap looks
// visible: otherwise a correct match that ranks past the rendered cap looks
// unselected and the user re-picks it. The pinned row carries a "Matchad"
// badge when it's the one matched in the inbox.
const displayedTransactions = useMemo(() => {
@@ -440,7 +440,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
// Replace the line set with a booking template's computed rows. The picker
// hands back JournalEntryForm-shaped lines; we keep only the three fields
// book-direct posts. A meaningful supplier description is preserved — the
// book-direct posts. A meaningful supplier description is preserved: the
// template name only fills an empty field.
const handleTemplateApply = useCallback(
(
@@ -516,7 +516,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
onOpenChange(false)
} catch (err) {
if (err instanceof Error && err.message === 'cancelled') {
// User dismissed the activation dialog — no toast needed
// User dismissed the activation dialog: no toast needed
} else {
const anyErr = err as { body?: unknown; status?: number }
toast({
@@ -544,7 +544,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
</DialogHeader>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,560px)]">
{/* Document column — sticky on desktop so the underlag stays visible
{/* Document column: sticky on desktop so the underlag stays visible
while the form scrolls; stacks above the form on smaller screens. */}
<div className="h-[45vh] lg:sticky lg:top-0 lg:h-[72vh] lg:self-start">
<DocumentViewerPane
@@ -589,7 +589,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
: null
return (
<SelectItem key={p.id} value={p.id}>
{p.period_start} – {p.period_end}
{p.period_start}: {p.period_end}
{lockState && ` (${lockState})`}
</SelectItem>
)
@@ -610,13 +610,13 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
/>
</div>
{/* Transaction picker — always shown, selection is optional. */}
{/* Transaction picker: always shown, selection is optional. */}
<div className="rounded-lg border p-4 space-y-3">
<div className="space-y-0.5">
<Label className="text-sm">Koppla till banktransaktion (valfritt)</Label>
<p className="text-xs text-muted-foreground">
Välj en transaktion om dokumentet motsvarar en redan-bokad
bankhändelse — den bokas då samtidigt. Lämna tom för en
bankhändelse: den bokas då samtidigt. Lämna tom för en
fristående verifikation.
</p>
</div>
@@ -740,7 +740,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
{targetCurrency !== 'SEK' && selectedTransactionAmount != null && (
<p className="text-[11px] text-muted-foreground">
Underlaget är i {targetCurrency}. Bokföringen sker i SEK enligt
transaktionens belopp. Momsraden har lämnats bort — vid behov
transaktionens belopp. Momsraden har lämnats bort: vid behov
lägg till en rad för omvänd skattskyldighet manuellt.
</p>
)}
@@ -36,7 +36,7 @@ interface Props {
onSuccess: () => void | Promise<void>
}
// Swedish category labels — mirrors lib/bookkeeping/category-mapping.ts
// Swedish category labels: mirrors lib/bookkeeping/category-mapping.ts
// (categoryLabels), ordered expenses-first since underlag are overwhelmingly
// costs. Values match TransactionCategorySchema in lib/api/schemas.ts.
const CATEGORY_OPTIONS: { value: string; label: string }[] = [
@@ -65,7 +65,7 @@ const CATEGORY_OPTIONS: { value: string; label: string }[] = [
// so this list can never drift from what the backend accepts: the bulk-book
// route feeds the value straight into buildMappingResultFromCategory, which
// only recognises these six. The 12% and 6% reduced rates are ALREADY covered
// here by `reduced_12` / `reduced_6` — there is deliberately no `standard_12` /
// here by `reduced_12` / `reduced_6`: there is deliberately no `standard_12` /
// `standard_6` (no such treatment exists; the backend would reject it). Keep
// this list in sync with the union, not with rate labels.
const VAT_OPTIONS: { value: VatTreatment; label: string }[] = [
@@ -99,7 +99,7 @@ export default function BulkBookInboxDialog({ open, onOpenChange, items, onSucce
// Reset to the safe default (25% svensk moms) each time the dialog opens.
// Currency is deliberately NOT used to preselect omvänd skattskyldighet: a
// foreign currency does not imply a foreign seller — a Swedish supplier can
// foreign currency does not imply a foreign seller: a Swedish supplier can
// invoice in EUR and still debit 25% moms. Reverse charge is a property of
// the seller (utländsk, utan svenskt momsnr), never of the currency, so
// defaulting to it from currency alone would silently mis-book domestic VAT.
@@ -195,7 +195,7 @@ export default function BulkBookInboxDialog({ open, onOpenChange, items, onSucce
<>
Välj <strong>Omvänd skattskyldighet</strong> för köp från en utländsk säljare utan
svenskt momsnummer (t.ex. EU-tjänster som moln/mjukvara). Svenska fakturor med moms:
välj den sats kvittot visar — valutan avgör inte.
välj den sats kvittot visar: valutan avgör inte.
</>
}
/>
@@ -217,7 +217,7 @@ export default function BulkBookInboxDialog({ open, onOpenChange, items, onSucce
<div className="rounded-md border border-border bg-secondary/40 p-3 text-xs text-muted-foreground">
<strong className="font-medium text-foreground">Kontrollera säljaren.</strong>{' '}
Omvänd skattskyldighet gäller bara köp från en <strong className="font-medium text-foreground">utländsk
säljare utan svenskt momsregistreringsnummer</strong> — t.ex. EU-tjänster, EU-varor,
säljare utan svenskt momsregistreringsnummer</strong>: t.ex. EU-tjänster, EU-varor,
byggtjänster eller viss elektronik. Valutan avgör inte: en svensk säljare kan fakturera i
EUR och ändå debitera 25% moms. Är säljaren svensk och momsen står på kvittot, välj i
stället rätt momssats ovan.
@@ -36,7 +36,7 @@ interface Props {
// Settings dialog for a company's own inbound domain. Claims the domain via
// the extension API, renders the DNS records the user must publish, and
// re-checks verification on demand. Everything mail-routing happens
// server-side — this surface only manages the claim lifecycle.
// server-side: this surface only manages the claim lifecycle.
export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(true)
@@ -152,7 +152,7 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
<DialogTitle>Egen domän för inkorgen</DialogTitle>
<DialogDescription>
Ta emot leverantörsfakturor direkt på bolagets egen adress, t.ex.
faktura@dittbolag.se — utan vidarebefordran.
faktura@dittbolag.se: utan vidarebefordran.
</DialogDescription>
</DialogHeader>
@@ -170,7 +170,7 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
<p className="text-muted-foreground">
Domänens MX-poster pekas om till Accounted. Om domänen redan används för
e-post (Google Workspace, Microsoft 365) slutar din vanliga e-post att
fungera — använd då en underdomän, t.ex.{' '}
fungera: använd då en underdomän, t.ex.{' '}
<code className="font-mono text-xs">faktura.dittbolag.se</code>, eller
fortsätt vidarebefordra till din vanliga inkorgsadress.
</p>
@@ -233,7 +233,7 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
<Check className="h-4 w-4 shrink-0 mt-0.5 text-muted-foreground" />
<div className="space-y-1">
<p className="font-medium">
Klart — ge dina leverantörer{' '}
Klart: ge dina leverantörer{' '}
<code className="font-mono text-xs">faktura@{domain.domain}</code>
</p>
<p className="text-muted-foreground">
@@ -268,7 +268,7 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
<td className="px-3 py-2 font-mono text-xs break-all">{r.name}</td>
<td className="px-3 py-2 font-mono text-xs break-all">{r.value}</td>
<td className="px-3 py-2 font-mono text-xs text-right tabular-nums">
{r.priority ?? '—'}
{r.priority ?? '-'}
</td>
<td className="px-3 py-2 text-right">
<button
@@ -287,7 +287,7 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
</div>
) : (
<p className="text-sm text-muted-foreground">
Inga DNS-poster tillgängliga — klicka på Kontrollera igen.
Inga DNS-poster tillgängliga: klicka på Kontrollera igen.
</p>
)}
</div>
@@ -44,7 +44,7 @@ import type { InvoiceExtractionResult } from '@/types'
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog'
import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDialog'
// InboxCustomDomainDialog (egen domän) is built but gated off — see
// InboxCustomDomainDialog (egen domän) is built but gated off: see
// INBOX_CUSTOM_DOMAINS_ENABLED in extensions/general/invoice-inbox/index.ts.
import TransactionMatchPicker from '@/components/inbox/TransactionMatchPicker'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
@@ -68,7 +68,7 @@ interface InboxItem {
created_supplier_invoice_id: string | null
created_journal_entry_id: string | null
error_message: string | null
// True when AI extraction was skipped — either because the upload caller
// True when AI extraction was skipped: either because the upload caller
// passed skip_extraction=true (MCP/agent path) or because the server's
// page-count gate skipped a PDF above the auto-extract limit (issue #553).
// Distinct from status='error' (extraction failed) and from extracted_data
@@ -113,7 +113,7 @@ function pickSupplierName(item: InboxItem): string | null {
}
// True when extraction produced at least one usable field. Distinguishes a
// deterministically-parsed underlag (fields present — render the editable
// deterministically-parsed underlag (fields present: render the editable
// list) from an item whose extracted_data is null/empty (AI never ran, or ran
// and found nothing). Currency is ignored because emptyExtraction() seeds it
// to 'SEK', so it is never a sign that extraction actually happened.
@@ -135,7 +135,7 @@ function hasAnyExtractedField(data: InvoiceExtractionResult | null): boolean {
//
// Precedence mirrors the FieldsRail: a booked item (supplier invoice OR a
// direct journal entry) is done and drops out of the active inbox. A
// matched-but-unbooked item is "linked" — it STAYS in the inbox as its own
// matched-but-unbooked item is "linked": it STAYS in the inbox as its own
// category because the bank payment still needs booking (a document attached
// to a transaction is not the same as a booked one). An extraction failure is
// "error"; everything else needs a first action.
@@ -210,7 +210,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const [isLoading, setIsLoading] = useState(true)
const [selectedId, setSelectedId] = useState<string | null>(null)
// List filter + search (client-side over the already-fetched items list).
// Defaults to 'todo' — the active inbox (everything not yet booked) — so
// Defaults to 'todo': the active inbox (everything not yet booked), so
// booked underlag drop out of the default view while attached-but-unbooked
// ones stay visible.
const [filter, setFilter] = useState<'todo' | 'linked' | 'booked' | 'error' | 'all'>('todo')
@@ -237,13 +237,13 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const [isRotating, setIsRotating] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [bookDirectOpen, setBookDirectOpen] = useState(false)
// Bulk-book selected underlag (Modell B) — the "Bokför valda" selection-bar
// Bulk-book selected underlag (Modell B): the "Bokför valda" selection-bar
// action. The dialog filters the selection to bookable items itself.
const [bulkBookOpen, setBulkBookOpen] = useState(false)
// Match-to-bank-transaction picker (opens when user clicks "Matcha mot
// transaktion" on an unmatched inbox item).
const [matchPickerOpen, setMatchPickerOpen] = useState(false)
// "Skapa leverantörsfaktura" modal for the selected underlag — opens in
// "Skapa leverantörsfaktura" modal for the selected underlag: opens in
// place (instead of navigating to a form page) so the user lands right back
// here to pick the next document.
const [createSupplierInvoiceOpen, setCreateSupplierInvoiceOpen] = useState(false)
@@ -293,7 +293,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
useEffect(() => {
fetchItems()
fetchInboxAddress()
// Resolve the company's bookkeeping method — drives CTA hierarchy.
// Resolve the company's bookkeeping method: drives CTA hierarchy.
fetch('/api/settings')
.then((r) => (r.ok ? r.json() : null))
.then((body) => {
@@ -306,9 +306,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
}, [fetchItems, fetchInboxAddress])
// Realtime: refetch when any invoice_inbox_items row changes for this
// company. The inbox is routinely resolved "out of band" — the in-app agent
// company. The inbox is routinely resolved "out of band": the in-app agent
// sheet commits a staged create_supplier_invoice_from_inbox / book-direct
// operation, the /pending page approves one, or another tab books it — and
// operation, the /pending page approves one, or another tab books it, and
// none of those paths call this component's fetchItems(). Without this, a
// booked underlag stayed in "Att göra" until a manual reload (issue #600).
// RLS scopes the channel to the user's company, so we never receive other
@@ -333,7 +333,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
}, [fetchItems])
// Read the onboarding-dismissed flag from localStorage after mount
// (SSR-safe — no window access during initial render).
// (SSR-safe: no window access during initial render).
useEffect(() => {
if (typeof window === 'undefined') return
try {
@@ -341,7 +341,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
window.localStorage.getItem('gnubok.inbox.onboarding.dismissed') === '1'
)
} catch {
// private browsing — keep default (show card)
// private browsing: keep default (show card)
}
}, [])
@@ -354,7 +354,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
setOnboardingDismissed(true)
}, [])
// Onboarding card visibility — derived from real progress so a user who
// Onboarding card visibility: derived from real progress so a user who
// already has a working inbox flow never sees the guide. Once they finish
// all three steps, the card auto-hides on next render.
const hasInboxAddress = !!inboxAddress
@@ -384,7 +384,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
}, [items])
// Pills, in order. The error pill only appears when there's something errored
// (or it's the active filter) — keeps the happy-path inbox uncluttered.
// (or it's the active filter): keeps the happy-path inbox uncluttered.
const pills = useMemo(() => {
const list: { key: typeof filter; label: string; count: number }[] = [
{ key: 'todo', label: 'Att göra', count: statusCounts.todo },
@@ -401,7 +401,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const filteredItems = useMemo(() => {
const term = searchTerm.trim().toLowerCase()
return items.filter((item) => {
// Status filter. "todo" is the active inbox — everything except booked.
// Status filter. "todo" is the active inbox: everything except booked.
const status = deriveInboxStatus(item)
if (filter === 'todo' && status === 'booked') return false
if (filter === 'linked' && status !== 'linked') return false
@@ -409,7 +409,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
if (filter === 'error' && status !== 'error') return false
// 'all' → no status narrowing
// Search filter — supplier name, email subject/from, placeholder filename
// Search filter: supplier name, email subject/from, placeholder filename
if (term === '') return true
const haystack = [
item.extracted_data?.supplier?.name,
@@ -473,8 +473,8 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
file: File,
options: { autoSelect: boolean } = { autoSelect: true },
) => {
// Optimistic placeholder — gives the user an immediate visual response
// for the 3–8s while extraction runs. Removed once the real row arrives.
// Optimistic placeholder: gives the user an immediate visual response
// for the 3-8s while extraction runs. Removed once the real row arrives.
const tempId = `temp-${crypto.randomUUID()}`
const placeholder: InboxItem = {
id: tempId,
@@ -515,7 +515,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
toast({
title: 'Dokument uppladdat',
description: pages
? `Stort dokument (${pages} sidor) — AI-tolkning skippad. Du kan koppla det till en transaktion eller skapa leverantörsfaktura manuellt.`
? `Stort dokument (${pages} sidor): AI-tolkning skippad. Du kan koppla det till en transaktion eller skapa leverantörsfaktura manuellt.`
: 'AI-tolkning skippad. Du kan koppla dokumentet till en transaktion eller skapa leverantörsfaktura manuellt.',
})
} else {
@@ -542,14 +542,14 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
}
}, [fetchItems, handleSelect, toast])
// Sequential queue — running multiple extractions concurrently would
// Sequential queue: running multiple extractions concurrently would
// hammer pdfjs on slow boxes. Per-file placeholder rows + the queue
// counter on the upload button surface progress.
const uploadFiles = useCallback(async (files: File[]) => {
if (files.length === 0) return
if (files.length === 1) {
// Single-file drop: keep the historic behavior of jumping the detail
// pane to the new item. Skip the queue counter — it would just flash.
// pane to the new item. Skip the queue counter: it would just flash.
await uploadFile(files[0], { autoSelect: true })
return
}
@@ -656,7 +656,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const succeeded = deletable.length - failed
const parts: string[] = []
if (succeeded > 0) parts.push(`${succeeded} borttagna`)
if (skipped > 0) parts.push(`${skipped} kopplade till leverantörsfaktura — hoppade över`)
if (skipped > 0) parts.push(`${skipped} kopplade till leverantörsfaktura, hoppade över`)
if (failed > 0) parts.push(`${failed} misslyckades`)
toast({
title: 'Bulkborttagning klar',
@@ -801,11 +801,11 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
{/* Three-section body. Below xl (iPad portrait/landscape + phone) the
sections stack vertically as a single scrollable feed. With the app
sidebar eating ~256px, even iPad landscape (1024–1180px viewport)
has only ~570px of workspace — too tight for 3 panes. At xl+ they
sidebar eating ~256px, even iPad landscape (1024-1180px viewport)
has only ~570px of workspace: too tight for 3 panes. At xl+ they
sit side-by-side as three panes. */}
<div className="xl:flex-1 grid grid-cols-1 xl:grid-cols-[280px_minmax(0,1fr)_340px] xl:min-h-0 xl:overflow-hidden">
{/* List — flows naturally below xl; bounded with internal scroll at xl+ */}
{/* List: flows naturally below xl; bounded with internal scroll at xl+ */}
<aside className="border-b xl:border-b-0 xl:border-r bg-muted/20 pt-3 xl:overflow-y-auto xl:block">
{items.length > 0 && (
<div className="px-3 pb-3 space-y-2 border-b">
@@ -854,7 +854,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
<span className="font-medium text-foreground">{selectedIds.size}</span>{' '}
{selectedIds.size === 1 ? 'markerad' : 'markerade'}
</span>
{/* Primary action — the one solid button */}
{/* Primary action: the one solid button */}
<Button
variant="default"
size="sm"
@@ -870,7 +870,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
<Check className="h-3.5 w-3.5 mr-1.5" />
Bokför valda
</Button>
{/* Secondary actions — outlined, so they read as buttons */}
{/* Secondary actions: outlined, so they read as buttons */}
<div className="flex items-center gap-2">
{identity.isVerified && (
<Button
@@ -914,7 +914,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
// On desktop the preview pane is always visible alongside this
// column, so showing the onboarding card here would duplicate it.
// On mobile the layout is a master-detail toggle and the user is
// stuck on the list view until they pick a row — without a card
// stuck on the list view until they pick a row: without a card
// here they'd have no way to reach the explainer at all. So:
// compact card on mobile only, quiet empty state on desktop.
showOnboarding ? (
@@ -945,7 +945,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
) : filteredItems.length === 0 ? (
<div className="p-6 text-center text-xs text-muted-foreground">
{filter === 'todo'
? 'Inget att åtgärda — allt är bearbetat.'
? 'Inget att åtgärda; allt är bearbetat.'
: 'Inga poster matchar filtret.'}
</div>
) : (
@@ -1134,7 +1134,7 @@ function InboxRow({
onClick: () => void
isChecked: boolean
onToggleChecked: () => void
/** True when bulk-select mode is active anywhere in the list — keeps the
/** True when bulk-select mode is active anywhere in the list: keeps the
checkbox visible (otherwise it's hover-only on desktop). */
anyChecked: boolean
}) {
@@ -1266,7 +1266,7 @@ export function DocumentPreview({
/>
</div>
) : (
// PDF: iframe needs explicit height — frame fills the available pane.
// PDF: iframe needs explicit height, frame fills the available pane.
<div className="h-full w-full max-w-3xl bg-background rounded-md border overflow-hidden">
<iframe src={docUrl} className="w-full h-full border-0" title="Underlag" />
</div>
@@ -1352,7 +1352,7 @@ function OnboardingCard({
</Badge>
</div>
<p className={cn('text-muted-foreground', compact ? 'text-[11px]' : 'text-xs')}>
Underlagen samlas här — från mail eller filuppladdning — och kan
Underlagen samlas här (från mail eller filuppladdning) och kan
matchas mot bankhändelser eller bokföras direkt.
</p>
<p className={cn('text-muted-foreground', compact ? 'text-[11px]' : 'text-xs')}>
@@ -1419,7 +1419,7 @@ function OnboardingCard({
})}
</ol>
{/* CTA matches the current step. No CTA for step 3 — it requires the user to pick a row. */}
{/* CTA matches the current step. No CTA for step 3: it requires the user to pick a row. */}
{currentStep === 0 && (
<Button
size={compact ? 'sm' : 'default'}
@@ -1538,7 +1538,7 @@ function FieldsRail({
const data = item.extracted_data
const isProcessed = !!item.created_supplier_invoice_id
const isBookedDirectly = !isProcessed && !!item.created_journal_entry_id
// "Resolved" now means a journal entry exists — matched_transaction_id alone
// "Resolved" now means a journal entry exists: matched_transaction_id alone
// is not resolved, it's the prerequisite for booking against that tx.
const isLinkedToTransaction = !isProcessed && !isBookedDirectly && !!item.matched_transaction_id
const isResolved = isProcessed || isBookedDirectly
@@ -1631,12 +1631,12 @@ function FieldsRail({
</div>
)}
{/* Hint only — creation happens on the leverantörsfaktura form via "Skapa & välj" */}
{/* Hint only: creation happens on the leverantörsfaktura form via "Skapa & välj" */}
{showNoMatchHint && (
<div className="border-b bg-muted/30 px-4 py-2 text-xs text-muted-foreground">
Ingen leverantör matchade{' '}
<span className="text-foreground font-medium">{extractedSupplierName}</span>
{' — leverantören skapas när du klickar Skapa leverantörsfaktura.'}
{': leverantören skapas när du klickar Skapa leverantörsfaktura.'}
</div>
)}
@@ -1667,7 +1667,7 @@ function FieldsRail({
</div>
) : !hasAnyExtractedField(data) && !hasAi ? (
// No fields were extracted (AI never ran) and the company doesn't have
// the AI capability. Show an upsell in place of the blank field list —
// the AI capability. Show an upsell in place of the blank field list:
// upload and manual entry stay available via the actions below.
<div className="rounded-lg border border-border bg-secondary/40 px-4 py-3 text-left">
<div className="flex items-center gap-2 text-sm font-medium">
@@ -1693,7 +1693,7 @@ function FieldsRail({
)}
</div>
{/* Actions — hidden while AI extraction is in flight */}
{/* Actions: hidden while AI extraction is in flight */}
{!item.isPlaceholder && (
<div className="border-t px-4 py-3 space-y-2">
{isProcessed && item.created_supplier_invoice_id ? (
@@ -1713,7 +1713,7 @@ function FieldsRail({
) : isLinkedToTransaction && item.matched_transaction_id ? (
<>
{/* Matched-to-tx state: show the bridge to booking. The user
picks one of two actions — book themselves with the
picks one of two actions: book themselves with the
deterministic dialog, or hand off to the assistant. */}
<div className="rounded-md border border-success/30 bg-success/5 px-3 py-2 text-xs">
<div className="flex items-center gap-1.5 text-success font-medium mb-1">
@@ -1765,7 +1765,7 @@ function FieldsRail({
<>
{/* Unmatched state: the canonical next step is to find the bank
transaction this underlag belongs to. Two escape hatches sit
below it — "Skapa leverantörsfaktura" for users who want
below it: "Skapa leverantörsfaktura" for users who want
supplier-invoice tracking (accrual flow), and "Bokför som
verifikat" for underlag that aren't a supplier invoice at all
(bank fees, owner expenses, the underlag for a correction). The
@@ -1819,11 +1819,11 @@ function FieldsRail({
disabled={isDeleting || isResolved}
title={
isProcessed
? 'Kopplad till leverantörsfaktura — kan inte tas bort'
? 'Kopplad till leverantörsfaktura, kan inte tas bort'
: isBookedDirectly
? 'Bokförd — kan inte tas bort'
? 'Bokförd, kan inte tas bort'
: isLinkedToTransaction
? 'Kopplad till transaktion — koppla loss innan borttagning'
? 'Kopplad till transaktion, koppla loss innan borttagning'
: undefined
}
>
@@ -1952,7 +1952,7 @@ export function EditableFieldsList({
Object.fromEntries(FIELD_DEFS.map((f) => [f.key, readField(data, f.key)])) as Record<FieldKey, string>
)
// Per-field provenance: a populated field starts "AI-filled" (its value came
// from the extraction) and flips to user-verified once the user edits it —
// from the extraction) and flips to user-verified once the user edits it:
// mirrors the create form's AiFilledIndicator. Reset when switching items.
const [edited, setEdited] = useState<Partial<Record<FieldKey, boolean>>>({})
const timersRef = useRef<Partial<Record<FieldKey, ReturnType<typeof setTimeout>>>>({})
@@ -1983,7 +1983,7 @@ export function EditableFieldsList({
// Re-seed drafts when the server returns normalised values (e.g. uppercased
// currency, trimmed strings). Only update fields where the local draft
// matches the previous server value — i.e. the user hasn't typed anything
// matches the previous server value, i.e. the user hasn't typed anything
// newer that we'd otherwise clobber.
useEffect(() => {
let dirty = false
@@ -2099,7 +2099,7 @@ export function EditableFieldsList({
</label>
<AiFilledIndicator
active={drafts[f.key].trim() !== '' && !edited[f.key]}
title="Ifyllt av AI — kontrollera mot dokumentet"
title="Ifyllt av AI: kontrollera mot dokumentet"
/>
</div>
<Input
@@ -2110,7 +2110,7 @@ export function EditableFieldsList({
onChange={(e) => onChange(f.key, e.target.value)}
onBlur={() => onBlur(f.key)}
disabled={disabled}
placeholder="—"
placeholder="-"
className={cn(
'h-8 text-sm border-transparent bg-transparent px-2 -mx-2 hover:border-border focus-visible:border-ring',
drafts[f.key] === '' && 'text-muted-foreground/50 italic'
@@ -2138,7 +2138,7 @@ export function EditableFieldsList({
)}
{disabled && (
<p className="text-[10px] text-muted-foreground/70 pt-2">
Posten är kopplad till en leverantörsfaktura — fälten kan inte ändras.
Posten är kopplad till en leverantörsfaktura: fälten kan inte ändras.
</p>
)}
</div>
+23 -23
View File
@@ -35,22 +35,22 @@ import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registr
import type { TICCompanyProfile } from '@/extensions/general/tic/lib/tic-types'
function formatKSEK(value: number | null): string {
if (value === null) return '—'
if (value === null) return '-'
return `${(value * 1000).toLocaleString('sv-SE')} kr`
}
function formatPercent(value: number | null): string {
if (value === null) return '—'
if (value === null) return '-'
return `${value.toFixed(1)} %`
}
function formatSek(value: number | null): string {
if (value === null || value === undefined) return '—'
if (value === null || value === undefined) return '-'
return `${value.toLocaleString('sv-SE')} kr`
}
function formatIsoDate(iso: string | null): string {
if (!iso) return '—'
if (!iso) return '-'
return iso.slice(0, 10)
}
@@ -79,7 +79,7 @@ function formatPeriod(start: number, end: number): string {
const e = new Date(toMs(end))
const fmt = (d: Date) =>
d.toLocaleDateString('sv-SE', { year: 'numeric', month: '2-digit', day: '2-digit' })
return `${fmt(s)} – ${fmt(e)}`
return `${fmt(s)} to ${fmt(e)}`
}
function timeAgo(isoDate: string, t: (key: string, values?: Record<string, string | number>) => string): string {
@@ -400,7 +400,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
label={t('employees')}
value={profile.financials.numberOfEmployees !== null
? String(profile.financials.numberOfEmployees)
: '—'}
: '-'}
/>
<FinancialCell
label={t('operating_margin')}
@@ -419,7 +419,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
</Card>
</div>
{/* Status entries — most recent first; usually 1-3 rows */}
{/* Status entries: most recent first; usually 1-3 rows */}
{profile.statuses.length > 0 && (
<Card>
<CardHeader>
@@ -434,7 +434,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
<li key={i} className="flex items-center justify-between gap-3 text-sm">
<div className="flex items-center gap-2">
<Badge variant={statusColorToVariant(status.color)}>
{status.description ?? status.code ?? '—'}
{status.description ?? status.code ?? '-'}
</Badge>
{status.isCeased && (
<span className="text-xs text-muted-foreground">
@@ -466,8 +466,8 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
<CardContent className="space-y-2 text-sm">
<p className="font-mono tabular-nums">
{t('fiscal_year_current', {
start: profile.fiscalYear.startMonthDay ?? '—',
end: profile.fiscalYear.endMonthDay ?? '—',
start: profile.fiscalYear.startMonthDay ?? '-',
end: profile.fiscalYear.endMonthDay ?? '-',
})}
</p>
{profile.fiscalYearHistory.length > 1 && (
@@ -547,9 +547,9 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
<TableBody>
{profile.representatives.slice(0, 12).map((p, i) => (
<TableRow key={i}>
<TableCell className="text-sm">{p.name ?? '—'}</TableCell>
<TableCell className="text-sm">{p.name ?? '-'}</TableCell>
<TableCell className="text-sm">
{p.positionDescription ?? p.positionType ?? '—'}
{p.positionDescription ?? p.positionType ?? '-'}
</TableCell>
<TableCell className="text-xs tabular-nums text-muted-foreground">
{formatIsoDate(p.positionStart)}
@@ -565,7 +565,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
</Card>
)}
{/* Payroll history — payroll2 array, newest first */}
{/* Payroll history: payroll2 array, newest first */}
{profile.payrolls.length > 0 && (
<Card>
<CardHeader>
@@ -591,11 +591,11 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
<TableRow key={i}>
<TableCell className="font-mono tabular-nums text-xs">
{p.periodStart && p.periodEnd
? `${formatIsoDate(p.periodStart)} – ${formatIsoDate(p.periodEnd)}`
: '—'}
? `${formatIsoDate(p.periodStart)} to ${formatIsoDate(p.periodEnd)}`
: '-'}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{p.numberOfEmployees !== null ? p.numberOfEmployees.toFixed(0) : '—'}
{p.numberOfEmployees !== null ? p.numberOfEmployees.toFixed(0) : '-'}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{formatSek(p.sumPayrollTax)}
@@ -610,7 +610,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
: 'text-muted-foreground'
}`}
>
{p.deviation !== null ? `${(p.deviation * 100).toFixed(1)} %` : '—'}
{p.deviation !== null ? `${(p.deviation * 100).toFixed(1)} %` : '-'}
</TableCell>
<TableCell
className={`text-right text-sm tabular-nums ${
@@ -657,14 +657,14 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
<TableRow key={report.financialReportSummaryId ?? i}>
<TableCell className="font-mono tabular-nums text-xs">
{report.periodStart && report.periodEnd
? `${report.periodStart.slice(0, 10)} – ${report.periodEnd.slice(0, 10)}`
: '—'}
? `${report.periodStart.slice(0, 10)} to ${report.periodEnd.slice(0, 10)}`
: '-'}
</TableCell>
<TableCell className="text-xs">{report.title ?? '—'}</TableCell>
<TableCell className="text-xs">{report.title ?? '-'}</TableCell>
<TableCell className="text-xs">
{report.arrivalDate
? new Date(report.arrivalDate).toLocaleDateString('sv-SE')
: '—'}
: '-'}
</TableCell>
<TableCell>
{report.isAudited === true ? (
@@ -672,10 +672,10 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
) : report.isAudited === false ? (
<XCircle className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<span className="text-xs text-muted-foreground">—</span>
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-xs">{report.auditOpinion ?? '—'}</TableCell>
<TableCell className="text-xs">{report.auditOpinion ?? '-'}</TableCell>
</TableRow>
))}
</TableBody>
@@ -303,7 +303,7 @@ export default function MockDataImportDialog<T>({
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FileSpreadsheet className="h-4 w-4" />
<span>{fileName} — {csvRows.length} rader</span>
<span>{fileName}: {csvRows.length} rader</span>
</div>
<div className="space-y-3">
@@ -323,7 +323,7 @@ export default function MockDataImportDialog<T>({
<SelectValue placeholder="Välj kolumn..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="___none___">— Välj kolumn —</SelectItem>
<SelectItem value="___none___">- Välj kolumn -</SelectItem>
{csvHeaders.map(h => (
<SelectItem key={h} value={h}>{h}</SelectItem>
))}
@@ -379,7 +379,7 @@ export default function MockDataImportDialog<T>({
<div className="flex items-center gap-2 p-3 rounded-md bg-success/10 text-success text-sm">
<Check className="h-4 w-4 shrink-0" />
<span>Giltig JSON — {jsonSummary}</span>
<span>Giltig JSON: {jsonSummary}</span>
</div>
<DialogFooter>
+1 -1
View File
@@ -139,7 +139,7 @@ export default function AccountMappingStep({
<CardTitle>Kontomappning</CardTitle>
<CardDescription>
Varje konto i SIE-filen kopplas till ett konto i din kontoplan.
De flesta matchas automatiskt — granska de osäkra nedan.
De flesta matchas automatiskt: granska de osäkra nedan.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
+3 -3
View File
@@ -127,14 +127,14 @@ export default function ArticlesEditStep({
</div>
)}
{/* VAT-adjustment notice — rows whose rate was snapped/defaulted */}
{/* VAT-adjustment notice: rows whose rate was snapped/defaulted */}
{adjustedVatCount > 0 && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<p className="text-sm">
<span className="font-medium">{adjustedVatCount} rad{adjustedVatCount === 1 ? '' : 'er'}</span> fick
momssatsen omtolkad (avrundad till närmaste giltiga, eller satt till 25 %).
Kontrollera de markerade raderna innan du importerar — fel momssats ger fel moms på fakturan.
Kontrollera de markerade raderna innan du importerar: fel momssats ger fel moms på fakturan.
</p>
</div>
)}
@@ -218,7 +218,7 @@ export default function ArticlesEditStep({
{row.vat_rate_adjusted && (
<span
className="text-warning shrink-0"
title="Momssatsen tolkades om från filen — kontrollera att den stämmer."
title="Momssatsen tolkades om från filen: kontrollera att den stämmer."
>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
@@ -89,7 +89,7 @@ export default function BankFileColumnMappingStep({
// contain known column-name keywords (bokföringsdag, beskrivning, belopp, …).
// A row needs ≥ 2 keyword hits to qualify, which excludes metadata rows where
// only the label cell happens to match (e.g. "Saldo,251495,41,SEK").
// Falls back to row 0 if nothing qualifies — preserves prior behavior for
// Falls back to row 0 if nothing qualifies: preserves prior behavior for
// simple files where the header truly is the first row.
const DATE_PATTERNS = [/^\d{4}-\d{2}-\d{2}$/, /^\d{2}[./]\d{2}[./]\d{4}$/, /^\d{8}$/]
const detectedHeaderRow = useMemo(() => {
@@ -126,7 +126,7 @@ export default function BankFileColumnMappingStep({
// skip_rows = number of rows to skip before transaction data starts.
// When hasHeader: skip past the header row (detectedHeaderRow + 1).
// When no header: skip nothing — data starts at row 0.
// When no header: skip nothing: data starts at row 0.
const skipRows = hasHeader ? detectedHeaderRow + 1 : 0
const columnHeaders = useMemo(() => {
@@ -139,7 +139,7 @@ export default function BankFileColumnMappingStep({
// Auto-guess date/description/amount/balance columns. Matches header labels
// first (so the trailing Saldo column is never picked as the amount), then
// falls back to value heuristics. Initial defaults only — user can override.
// falls back to value heuristics. Initial defaults only: user can override.
useEffect(() => {
if (dateCol !== -1 || descCol !== -1 || amountCol !== -1) return
if (dataRows.length === 0) return
@@ -401,8 +401,8 @@ export default function BankFileColumnMappingStep({
return (
<TableRow key={i}>
<TableCell className="font-mono text-sm">{row[dateCol] || '–'}</TableCell>
<TableCell className="text-sm">{row[descCol] || '–'}</TableCell>
<TableCell className="font-mono text-sm">{row[dateCol] || '-'}</TableCell>
<TableCell className="text-sm">{row[descCol] || '-'}</TableCell>
<TableCell
className={`text-right font-mono text-sm ${
!isNaN(amount) && amount >= 0 ? 'text-success' : 'text-destructive'
+2 -2
View File
@@ -116,7 +116,7 @@ export default function BankFileConfirmStep({
<span className="text-xs">Period</span>
</div>
<p className="text-sm font-medium">
{date_from} – {date_to}
{date_from}: {date_to}
</p>
</div>
@@ -176,7 +176,7 @@ export default function BankFileConfirmStep({
</CardContent>
</Card>
{/* Skipped rows — surfaced here because the manual-mapping path skips the
{/* Skipped rows: surfaced here because the manual-mapping path skips the
preview step where these warnings would otherwise be shown. */}
{warnings.length > 0 && (
<Card>
+3 -3
View File
@@ -63,7 +63,7 @@ export default function BankFilePreviewStep({
<span className="text-sm">Period</span>
</div>
<p className="text-sm font-medium">
{date_from || '–'} till {date_to || '–'}
{date_from || '-'} till {date_to || '-'}
</p>
</CardContent>
</Card>
@@ -155,12 +155,12 @@ export default function BankFilePreviewStep({
</TableCell>
{transactions.some((t) => t.balance != null) && (
<TableCell className="text-right font-mono text-sm text-muted-foreground">
{tx.balance != null ? formatCurrency(tx.balance) : '–'}
{tx.balance != null ? formatCurrency(tx.balance) : '-'}
</TableCell>
)}
{transactions.some((t) => t.reference) && (
<TableCell className="font-mono text-sm text-muted-foreground">
{tx.reference ? tx.reference : '–'}
{tx.reference ? tx.reference : '-'}
</TableCell>
)}
</TableRow>
+1 -1
View File
@@ -52,7 +52,7 @@ export default function BankFileResultStep({
<p className="font-medium text-destructive">Databasfel</p>
<p className="mt-1 font-mono text-xs text-muted-foreground break-all">
{result.first_error.message}
{result.first_error.details ? ` — ${result.first_error.details}` : ''}
{result.first_error.details ? `: ${result.first_error.details}` : ''}
{result.first_error.code ? ` (${result.first_error.code})` : ''}
</p>
</div>
+2 -2
View File
@@ -165,10 +165,10 @@ export default function CustomersEditStep({
</Select>
</td>
<td className="px-3 py-1.5 text-muted-foreground tabular-nums">
{row.org_number || '—'}
{row.org_number || '-'}
</td>
<td className="px-3 py-1.5 text-muted-foreground truncate max-w-xs">
{row.email || '—'}
{row.email || '-'}
</td>
<td className="px-3 py-1.5">
<div className="flex items-center gap-1.5">
+4 -4
View File
@@ -68,7 +68,7 @@ export default function ImportResultStep({ result, onNewImport, onUndo }: Import
<CardDescription>
{result.success
? skipped && skipped.total > 0
? `Din bokföring har importerats. ${result.journalEntriesCreated} verifikationer skapades, ${skipped.total} hoppades över — se detaljer nedan.`
? `Din bokföring har importerats. ${result.journalEntriesCreated} verifikationer skapades, ${skipped.total} hoppades över: se detaljer nedan.`
: 'Din bokföring har importerats framgångsrikt.'
: 'Det uppstod fel under importen. Läs felmeddelanden nedan för att förstå vad som gick snett och hur du kan åtgärda det.'}
</CardDescription>
@@ -123,7 +123,7 @@ export default function ImportResultStep({ result, onNewImport, onUndo }: Import
)}
.{' '}
{result.dimensionsImported.toggleEnabled && (
<>Dimensioner aktiverades automatiskt för företaget — du hittar registret under{' '}
<>Dimensioner aktiverades automatiskt för företaget: du hittar registret under{' '}
<Link href="/dimensions" className="underline underline-offset-4">
Kostnadsställen &amp; projekt
</Link>
@@ -211,7 +211,7 @@ export default function ImportResultStep({ result, onNewImport, onUndo }: Import
</Card>
)}
{/* Skipped vouchers — structured breakdown */}
{/* Skipped vouchers: structured breakdown */}
{skipped && skipped.total > 0 && (
<Card className="border-muted-foreground/20">
<CardHeader>
@@ -226,7 +226,7 @@ export default function ImportResultStep({ result, onNewImport, onUndo }: Import
<div className="text-sm">
<p className="font-medium">{skipped.empty} tomma verifikationer</p>
<p className="text-muted-foreground">
Platshållare utan bokföringsrader — vanligt i Fortnox och Visma. Påverkar inte din bokföring.
Platshållare utan bokföringsrader: vanligt i Fortnox och Visma. Påverkar inte din bokföring.
</p>
</div>
)}
+6 -6
View File
@@ -149,7 +149,7 @@ export default function ImportReviewStep({
const hasOpeningBalances = preview.openingBalanceTotal > 0
const hasTransactions = preview.voucherCount > 0
// An import whose fiscal year already ended in a prior calendar year is a
// historical/migration import — the underlag live in the old system, so the
// historical/migration import: the underlag live in the old system, so the
// exemption is especially apt. Nudges (does not force) the toggle.
const isHistoricalImport = (() => {
if (!preview.fiscalYearEnd) return false
@@ -157,7 +157,7 @@ export default function ImportReviewStep({
const startOfThisYear = new Date(new Date().getFullYear(), 0, 1)
return !isNaN(end.getTime()) && end < startOfThisYear
})()
// Identity-mapped accounts whose #KONTO name differs from the BAS default —
// Identity-mapped accounts whose #KONTO name differs from the BAS default:
// mirrors the filter in syncMappedAccounts, so the count matches what the
// import would actually rename/create with a custom name.
const customNameCount = mappings.filter(
@@ -347,9 +347,9 @@ export default function ImportReviewStep({
const isDefault = defaultSeries === letter
const isExisting = existingSeries.has(letter)
const suffix = isDefault
? ' — standard'
? ', standard'
: isExisting
? ' — används redan'
? ', används redan'
: ''
return (
<SelectItem key={letter} value={letter}>
@@ -365,7 +365,7 @@ export default function ImportReviewStep({
</div>
)}
{/* No-underlag exemption — keeps a multi-year migration from flooding
{/* No-underlag exemption: keeps a multi-year migration from flooding
"Att hantera: saknade underlag" with thousands of items. */}
<div className="flex items-start justify-between border-t pt-6">
<div className="space-y-0.5 pr-4">
@@ -379,7 +379,7 @@ export default function ImportReviewStep({
</Label>
<p className="text-sm text-muted-foreground">
Märker alla importerade verifikationer som att de inte behöver något
separat underlag — underlagen finns kvar i ditt tidigare system. Annars
separat underlag: underlagen finns kvar i ditt tidigare system. Annars
hamnar de under &quot;Att hantera: saknade underlag&quot;. Kan ändras per
verifikation efteråt.
</p>
@@ -64,7 +64,7 @@ export default function OpeningBalancePeriodStep({
}
}
} catch {
// Silent — user can still select period
// Silent, user can still select period
} finally {
setLoadingPeriods(false)
}
@@ -78,7 +78,7 @@ export default function OpeningBalancePeriodStep({
const periodIsLocked = !!selectedPeriod?.locked_at
// A period that already has IB can still be corrected, as long as it is open
// and unlocked — the existing IB verifikat is stornoed and replaced.
// and unlocked: the existing IB verifikat is stornoed and replaced.
const canExecute =
!!selectedPeriodId &&
!periodIsClosed &&
@@ -123,10 +123,10 @@ export default function OpeningBalancePeriodStep({
<SelectContent>
{periods.map((p) => (
<SelectItem key={p.id} value={p.id} disabled={p.is_closed || !!p.locked_at}>
{p.name} ({p.period_start} — {p.period_end})
{p.opening_balances_set && ' — har redan IB'}
{p.is_closed && ' — stängd'}
{p.locked_at && !p.is_closed && ' — låst'}
{p.name} ({p.period_start} till {p.period_end})
{p.opening_balances_set && ', har redan IB'}
{p.is_closed && ', stängd'}
{p.locked_at && !p.is_closed && ', låst'}
</SelectItem>
))}
</SelectContent>
@@ -134,7 +134,7 @@ export default function OpeningBalancePeriodStep({
)}
</div>
{/* Replace notice — selecting a period that already has IB corrects it */}
{/* Replace notice: selecting a period that already has IB corrects it */}
{periodHasOB && !periodIsClosed && !periodIsLocked && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<AlertCircle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
@@ -21,16 +21,16 @@ export interface EditableRow {
export interface OpeningBalanceEditorState {
rows: EditableRow[]
totals: { debit: number; credit: number; diff: number; isBalanced: boolean }
/** Balanced, no row errors, and at least two lines — safe to book/correct. */
/** Balanced, no row errors, and at least two lines: safe to book/correct. */
canSubmit: boolean
}
interface OpeningBalanceRowEditorProps {
/** Seed rows. Read once on mount (later prop changes are ignored — remount
/** Seed rows. Read once on mount (later prop changes are ignored: remount
* via `key` or a conditional render to reset the grid). */
initialRows: EditableRow[]
/** Fires whenever rows / totals / validity change. Held in a ref internally,
* so it does NOT need to be referentially stable — passing an inline arrow
* so it does NOT need to be referentially stable: passing an inline arrow
* is safe and will not cause an update-loop. */
onChange: (state: OpeningBalanceEditorState) => void
}
@@ -109,7 +109,7 @@ function dedupeRows(initialRows: EditableRow[]): EditableRow[] {
* BAS autocomplete, a live debit-credit balance check, blocking of resultat-
* konton (class 3-8), and a "round to 2099" auto-balance for sub-1-SEK drift.
*
* Stateless about WHY it's editing — the wizard seeds it from a parsed file,
* Stateless about WHY it's editing: the wizard seeds it from a parsed file,
* the verifikat correction dialog seeds it from the booked IB's lines. Both
* read the current rows + validity through `onChange`.
*/
@@ -98,7 +98,7 @@ export default function OpeningBalanceUploadStep({
</Button>
</label>
<p className="text-xs text-muted-foreground mt-3">
XLSX, XLS, CSV, ODS — max 10 MB
XLSX, XLS, CSV, ODS: max 10 MB
</p>
</>
)}
@@ -119,7 +119,7 @@ export default function OpeningBalanceUploadStep({
<p className="font-medium text-foreground">Filformat</p>
<p>
Filen bör ha en rubrikrad med kolumner för kontonummer och belopp.
Vanliga format stöds automatiskt — t.ex. kolumner som heter &quot;Konto&quot;,
Vanliga format stöds automatiskt: t.ex. kolumner som heter &quot;Konto&quot;,
&quot;Debet&quot;, &quot;Kredit&quot; eller &quot;Saldo&quot;.
</p>
</div>
+2 -2
View File
@@ -94,7 +94,7 @@ export default function RegisterResultStep({
{result.errors.map((e, i) => (
<li key={i} className="px-3 py-2 text-sm">
<span className="font-medium">Rad {e.row_index}: {e.name}</span>
<span className="text-muted-foreground"> — {e.reason}</span>
<span className="text-muted-foreground">: {e.reason}</span>
</li>
))}
</ul>
@@ -102,7 +102,7 @@ export default function RegisterResultStep({
</div>
)}
{/* Warnings (non-fatal — e.g. dropped revenue-account overrides) */}
{/* Warnings (non-fatal: e.g. dropped revenue-account overrides) */}
{result.warnings && result.warnings.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium">Att notera</h4>

Some files were not shown because too many files have changed in this diff Show More