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:
co-authored by
Claude Sonnet 5
parent
a5154ee884
commit
ec27228a8e
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
//
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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 & validera (iXBRL)</CardTitle>
|
||||
<CardTitle className="text-base">Digital inlämning: granska & 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>
|
||||
)}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 & 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>
|
||||
)}
|
||||
|
||||
@@ -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 "Att hantera: saknade underlag". 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 "Konto",
|
||||
Vanliga format stöds automatiskt: t.ex. kolumner som heter "Konto",
|
||||
"Debet", "Kredit" eller "Saldo".
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user