Files
accounted/components/ui/split-button.tsx
T
Jakob Wennberg 1ccf7ab81f feat(transactions): concept shell and row language (UI migration PR 5) (#1124)
* feat(transactions): concept shell for scene 10 (header, toolbar, footer)

- Header: title + Importera SplitButton (import guide / manual entry,
  last-used mode persisted via ui_state.create_mode.transactions),
  replacing the three-button row
- Toolbar in concept order: [Att bokföra/Alla seg with count chip]
  [search] [Välj flera as quiet toggle] ... [source ContextPicker far
  right merging the old in-list source dropdown (bank/Skatteverket)]
- Bank sync status + sync-now + Bankavstämning move to the concept's
  footer status line together with the honest visible-row counter
  ("n att hantera")
- skv-reconnect banner and all dialogs/actions untouched

Row cards -> concept dry-table conversion follows separately.

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

* feat(transactions): concept row language for the inbox cards

Rows read like the scene 10 table: date as the first tabular column,
description on one line, amount right (+income in sage), auto-detected
invoice matches as beige suggestion chips instead of primary pills,
tighter hairline rows at 13px. Every action unchanged: Bokför pill,
match/assistant/overflow menu, batch checkboxes, exit animations,
extraction status.

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

* refactor(transactions): de-bloat rows and chrome to match the concept

- Rows carry only the essentials: date, description, amount, Bokför and
  one overflow menu. The inline match and ask-assistant icon buttons
  fold into the overflow menu (all actions still one click away)
- The "n nya banktransaktioner sen ditt senaste besök" banner is removed;
  the footer sync line covers it
- The Skatteverket reconnect banner becomes the one-ochre-sentence
  AttnLine with an inline action (convention 6)

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

* refactor(ui): extract dry-table primitives into components/ui/dry-table

The concept table styles (TH/TD/VTH/VTD classes, quiet links, RowFoldout)
move out of JournalEntryList so the transaction inbox can render the exact
same table language.

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

* feat(transactions): Synka bank nu in the Importera menu

Extracts the per-connection sync/reconnect logic into a shared useBankSync()
hook (BankSyncNowButton keeps using it) and adds the concept's first menu row
to the Importera split button: sync all active connections, with the
last-synced age as the row description. Hidden without a connection or the
bank_sync capability; the footer button stays the gated conversion surface.

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

* feat(transactions): per-account source picker with balances

The context chip becomes the concept's account chooser: always visible in
inbox mode, one row per enabled cash account (PSD2 balance annotation),
an Övriga bucket for bank rows without a registered account, and a
Skattekonto row annotated with the cached saldo. The trigger reads
'Alla källor · <sum>' where the sum covers SEK ledgers + skattekonto.

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

* feat(transactions): concept dry-table for the inbox

The inbox list becomes the exact Bokforing table: borderless dry-table with
hover-revealed checkboxes and chevrons, one-line rows (date, description,
amount, quiet primary pill + overflow + foldout), and a RowFoldout expansion
carrying the row detail and the full action set as quiet links. Skattekonto
rows render as chip-marked table rows with inline actions. The 'Valj flera'
toggle and the floating batch bar are replaced by the concept bulkbar that
pops in above the table once a row is selected.

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

* fix(i18n): singular form for bulkbar selection count

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

* feat(transactions): suggestion-first Bokfor flow (concept scene 10)

Bokfor on a row with a suggestion now goes straight to the compact
kontering confirm (QuickReview) showing the proposed verifikat; the full
template picker becomes the fallback and the 'Byt mall' path. Rows carry
the concept's 'Forslag: <mall>' chip (with D/K in the tooltip and the
foldout meta). The picker dialog is de-bloated: alternate paths (manuellt,
matcha, ignorera) collapse into quiet links. Modal scroll containers
(DialogContent, SlideOverBody) keep a visible scrollbar thumb: with the
app-wide auto-hide, a long dialog read as cut off at the fold.

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

* refactor(transactions): drop suggestion UI and foldout action links

Founder direction: no booking suggestions in the list for now (logic comes
later), and the foldout duplicated the overflow menu: the three dots are
the clearer surface. The foldout keeps row detail only (FX conversion,
original bank name, 1930/1630 hint, extraction status) and rows without
any detail no longer expand. The Skattekonto picker row drops its saldo
annotation; the Alla kallor sum covers bank ledgers only.

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

* feat(transactions): dry-table for the Alla view + pill chips app-wide

The history list joins the inbox's table language: Datum, Beskrivning,
Belopp, Status columns with Bokford as muted text + Visa verifikat quiet
link (normal state) and Ej bokford as the beige exception chip with an
inline Bokfor pill; the business/private tabs become the house seg and the
KALLA header row becomes the standard chip-picker. Skattekonto rows get
the same treatment. SEK conversion moves to a tooltip (one-line rows).

Badge itself becomes the concept chip: pill radius (99px), 11.5px, quiet
padding, replacing the boxy rounded-md look the founder flagged on the
Underlag saknas badge.

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

* fix(ui): audit round: viewer-disabled split options, touch fallback

Regression-audit fixes: SplitButton options support disabled+disabledTitle
so viewer-gated create paths render inert with the viewer tooltip instead
of silently no-opping (wired on Ny transaktion); the history list gets its
Kopplad till faktura indicator back; dead transactionsWithMatches memo
removed; and coarse-pointer devices now always show hover-revealed
controls (checkboxes, chevrons, quiet row actions) since touch has no
hover.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:41:45 +02:00

190 lines
6.4 KiB
TypeScript

'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { Button, type ButtonProps } from '@/components/ui/button'
import { rememberCreateMode } from '@/lib/ui-state/client'
import { Check, ChevronDown, type LucideIcon } from 'lucide-react'
export interface SplitButtonOption {
key: string
label: string
icon?: LucideIcon
/** Muted second line in the menu describing what the mode does. */
description?: string
/** Disable the option (e.g. viewers): renders inert with disabledTitle
* as the tooltip instead of silently no-opping. */
disabled?: boolean
disabledTitle?: string
onSelect: () => void
}
interface SplitButtonProps {
options: SplitButtonOption[]
/**
* ui_state.create_mode key for last-used persistence (e.g. 'bookkeeping').
* Omit to keep the split button stateless.
*/
persistKey?: string
/**
* Which option renders as the primary action on first paint: the
* server-read last-used mode (resolveInitialMode) or the first option.
*/
initialModeKey?: string
variant?: ButtonProps['variant']
className?: string
}
/**
* Primary action + caret menu (UI-migration convention 9): multiple create
* paths collapse into one button whose primary face is the last-used mode,
* persisted per user in user_preferences.ui_state.create_mode.
*/
export function SplitButton({
options,
persistKey,
initialModeKey,
variant = 'default',
className,
}: SplitButtonProps) {
const tCommon = useTranslations('common')
const [activeKey, setActiveKey] = useState(
() => options.find((o) => o.key === initialModeKey)?.key ?? options[0]?.key,
)
const [open, setOpen] = useState(false)
const caretRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState({ top: 0, left: 0 })
const active = options.find((o) => o.key === activeKey) ?? options[0]
const updatePosition = useCallback(() => {
if (!caretRef.current || !menuRef.current) return
const t = caretRef.current.getBoundingClientRect()
const m = menuRef.current.getBoundingClientRect()
const margin = 8
const left = Math.max(margin, Math.min(t.right - m.width, window.innerWidth - m.width - margin))
const top = Math.min(t.bottom + 4, window.innerHeight - m.height - margin)
setPos({ top, left })
}, [])
useEffect(() => {
if (!open) return
const raf = requestAnimationFrame(() => updatePosition())
return () => cancelAnimationFrame(raf)
}, [open, updatePosition])
useEffect(() => {
if (!open) return
function handleClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.isConnected) return
if (
(!caretRef.current || !caretRef.current.contains(target)) &&
(!menuRef.current || !menuRef.current.contains(target))
) {
setOpen(false)
}
}
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleKey)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleKey)
}
}, [open])
if (!active) return null
const runOption = (option: SplitButtonOption) => {
if (option.disabled) return
setActiveKey(option.key)
if (persistKey) rememberCreateMode(persistKey, option.key)
option.onSelect()
}
return (
<div className={cn('inline-flex items-stretch', className)}>
<Button
variant={variant}
className="rounded-r-none"
disabled={active.disabled}
title={active.disabled ? active.disabledTitle : undefined}
onClick={() => runOption(active)}
>
{active.icon && <active.icon className="mr-1.5 h-4 w-4" />}
{active.label}
</Button>
<Button
ref={caretRef}
variant={variant}
aria-label={tCommon('more_options')}
aria-expanded={open}
aria-haspopup="menu"
className={cn(
'rounded-l-none px-2',
variant === 'default' && 'border-l border-primary-foreground/20',
variant === 'outline' && 'border-l-0',
variant === 'secondary' && 'border-l border-foreground/10',
)}
onClick={() => setOpen((v) => !v)}
>
<ChevronDown className="h-4 w-4" />
</Button>
{open &&
createPortal(
<div
ref={menuRef}
role="menu"
className="fixed z-[60] min-w-[240px] rounded-lg border border-border bg-popover py-1 shadow-lg animate-in fade-in slide-in-from-top-1 duration-150"
style={{ top: pos.top, left: pos.left }}
>
<div className="px-1">
{options.map((option) => (
<button
key={option.key}
type="button"
role="menuitem"
disabled={option.disabled}
title={option.disabled ? option.disabledTitle : undefined}
onClick={() => {
setOpen(false)
runOption(option)
}}
className={cn(
'flex w-full items-start gap-2.5 rounded-md px-2.5 py-2 text-left transition-colors',
option.disabled
? 'cursor-not-allowed text-muted-foreground/40'
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
)}
>
{option.icon && (
<option.icon className="mt-0.5 h-4 w-4 flex-shrink-0" />
)}
<span className="min-w-0 flex-1">
<span className="block text-[13px] text-foreground">{option.label}</span>
{option.description && (
<span className="block text-[11px] leading-snug text-muted-foreground">
{option.description}
</span>
)}
</span>
{option.key === activeKey && (
<Check className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-primary" />
)}
</button>
))}
</div>
</div>,
document.body,
)}
</div>
)
}