diff --git a/web/src/components/editor/AccountDot.tsx b/web/src/components/editor/AccountDot.tsx
new file mode 100644
index 0000000..7d9925f
--- /dev/null
+++ b/web/src/components/editor/AccountDot.tsx
@@ -0,0 +1,30 @@
+import styles from './Editor.module.css';
+
+/** Chart colours for accounts (design-spec §2.4): `--chart-cat-(position mod 8 + 1)`. */
+const CHART_COLOURS = 8;
+
+/** An account's colour position: its index among the household's accounts in created order (then id). */
+export function accountColorIndex(accountId: string, accounts: Iterable<{ id: string; created_at: string }>): number {
+ const ordered = [...accounts].sort((a, b) =>
+ a.created_at < b.created_at ? -1 : a.created_at > b.created_at ? 1 : a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
+ );
+ return Math.max(
+ 0,
+ ordered.findIndex((a) => a.id === accountId),
+ );
+}
+
+/** The account chip's leading dot (design-spec §3.7): a 12 px circle in the account's chart colour. Decorative. */
+export function AccountDot({ colorIndex, size = 12 }: { colorIndex: number; size?: number }) {
+ return (
+
+ );
+}
diff --git a/web/src/components/editor/CategoryPicker.tsx b/web/src/components/editor/CategoryPicker.tsx
new file mode 100644
index 0000000..3d27e42
--- /dev/null
+++ b/web/src/components/editor/CategoryPicker.tsx
@@ -0,0 +1,115 @@
+import { useId, useState } from 'react';
+import type { Category } from '../../api/types';
+import { Icon } from '../../design/Icon';
+import { useI18n } from '../../i18n/useI18n';
+import { CategoryIcon } from '../CategoryIcon';
+import { Dialog } from '../Dialog';
+import { categoryName } from './categories';
+import styles from './Editor.module.css';
+
+/** Tile size in the quick row and the grid (design-spec §3.4: 52). */
+const TILE = 52;
+
+function CategoryItem({ category, selected, onPick }: { category: Category; selected: boolean; onPick: () => void }) {
+ const { t } = useI18n();
+ const name = categoryName(category, t);
+ return (
+
+
+
+ {selected ? (
+
+
+
+ ) : null}
+
+ {name}
+
+ );
+}
+
+interface Props {
+ /** The five quick-row categories (`quickRowCategories`). */
+ quick: readonly Category[];
+ /** Every active category in `sort_order`, for the "Alla" grid. */
+ all: readonly Category[];
+ selectedId: string | null;
+ /** Called on every pick, also of the category already selected (CS-2's "picked by hand" flag). */
+ onPick: (categoryId: string) => void;
+}
+
+/**
+ * `CategoryQuickRow` and `CategoryGridSheet` (design-spec §3.7): a radio group "Kategori" of five tiles and "Alla",
+ * which opens a dialog "Välj kategori" with every active category; picking there selects and closes.
+ */
+export function CategoryPicker({ quick, all, selectedId, onPick }: Props) {
+ const { t } = useI18n();
+ const labelId = useId();
+ const [gridOpen, setGridOpen] = useState(false);
+ const close = () => {
+ setGridOpen(false);
+ };
+ return (
+
+
+ {t('editor_category')}
+
+
+ {quick.map((category) => (
+ {
+ onPick(category.id);
+ }}
+ />
+ ))}
+ {
+ setGridOpen(true);
+ }}
+ >
+
+
+
+
+ {t('editor_category_all')}
+
+
+
+
+
+ {all.map((category) => (
+ {
+ onPick(category.id);
+ close();
+ }}
+ />
+ ))}
+
+
+
+ );
+}
diff --git a/web/src/components/editor/ConfirmDialog.tsx b/web/src/components/editor/ConfirmDialog.tsx
new file mode 100644
index 0000000..01e572e
--- /dev/null
+++ b/web/src/components/editor/ConfirmDialog.tsx
@@ -0,0 +1,73 @@
+import { Button } from '../Button';
+import { Dialog } from '../Dialog';
+import { Icon } from '../../design/Icon';
+import type { OutlineIconName } from '../../design/icons.generated';
+import styles from './Editor.module.css';
+
+interface Props {
+ open: boolean;
+ title: string;
+ /** Body text; discard dialogs have none (D-080). */
+ body?: string;
+ confirmLabel: string;
+ dismissLabel: string;
+ onConfirm: () => void;
+ onDismiss: () => void;
+ /** Error colours for the icon and the confirm button (deletes). */
+ destructive?: boolean;
+ icon?: OutlineIconName;
+ /** Disables the confirm button while the action runs. */
+ busy?: boolean;
+}
+
+/**
+ * `ConfirmDialog` (design-spec §3.11): an alert dialog with an optional icon tile, title, optional body, dismiss (text)
+ * and confirm (filled, or error filled when destructive). Esc and the scrim dismiss.
+ */
+export function ConfirmDialog({
+ open,
+ title,
+ body,
+ confirmLabel,
+ dismissLabel,
+ onConfirm,
+ onDismiss,
+ destructive = false,
+ icon,
+ busy = false,
+}: Props) {
+ return (
+
+
+ {dismissLabel}
+
+
+ {confirmLabel}
+
+ >
+ }
+ >
+ {icon === undefined && body === undefined ? undefined : (
+ <>
+ {icon === undefined ? null : (
+
+
+
+ )}
+ {body === undefined ? null : {body}
}
+ >
+ )}
+
+ );
+}
diff --git a/web/src/components/editor/DateField.tsx b/web/src/components/editor/DateField.tsx
new file mode 100644
index 0000000..7ceb64a
--- /dev/null
+++ b/web/src/components/editor/DateField.tsx
@@ -0,0 +1,89 @@
+import { useId } from 'react';
+import styles from './Editor.module.css';
+
+interface Props {
+ label: string;
+ /** `YYYY-MM-DD`, or '' when empty. */
+ value: string;
+ onChange: (value: string) => void;
+ /** Inclusive bounds, `YYYY-MM-DD` (SPEC §9.6 for expense dates: 2000-01-01 to today + 365 days). */
+ min?: string;
+ max?: string;
+ error?: string | null;
+ id?: string;
+ className?: string;
+}
+
+/**
+ * A labelled native date input in the filled field style: the browser's own date picker and keyboard entry, the value
+ * always ISO (`YYYY-MM-DD`). Used for the search date range and wherever a plain date field is needed.
+ */
+export function DateField({ label, value, onChange, min, max, error, id, className }: Props) {
+ const generated = useId();
+ const inputId = id ?? generated;
+ const errorId = `${inputId}-error`;
+ return (
+
+
+ {label}
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+}
+
+/** The bare date input (no label), for a row that renders its own label (`OptionRow` with `controlId`). */
+export function DateControl({
+ id,
+ value,
+ onChange,
+ min,
+ max,
+ invalid,
+ describedBy,
+ className,
+ required,
+}: {
+ id: string;
+ value: string;
+ onChange: (value: string) => void;
+ min?: string;
+ max?: string;
+ invalid?: boolean;
+ describedBy?: string;
+ className?: string;
+ required?: boolean;
+}) {
+ return (
+ {
+ onChange(event.target.value);
+ }}
+ />
+ );
+}
diff --git a/web/src/components/editor/Editor.module.css b/web/src/components/editor/Editor.module.css
new file mode 100644
index 0000000..f6da01c
--- /dev/null
+++ b/web/src/components/editor/Editor.module.css
@@ -0,0 +1,379 @@
+/* Shared expense-editor fields (design-spec §3.7). Tokens only. */
+
+/* Native select and date controls, filled like TextField (design-spec §2.9). */
+.fieldRoot {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s1);
+ min-width: 0;
+}
+.fieldLabel {
+ font: var(--type-label-medium);
+ letter-spacing: var(--tracking-label-small);
+ color: var(--on-surface-variant);
+}
+.fieldBox {
+ display: flex;
+ align-items: center;
+ min-height: var(--row-min);
+ padding: 0 var(--s4);
+ border-radius: var(--radius-large);
+ background: var(--surface-container-high);
+ color: var(--on-surface);
+}
+.fieldBox:focus-within {
+ outline: 2px solid var(--primary);
+ outline-offset: -2px;
+}
+.fieldInvalid .fieldLabel {
+ color: var(--error);
+}
+.fieldInvalid .fieldBox {
+ box-shadow: inset 0 0 0 2px var(--error);
+}
+.fieldError {
+ padding: 0 var(--s4);
+ font: var(--type-body-small);
+ letter-spacing: var(--tracking-body-small);
+ color: var(--error);
+}
+.control {
+ flex: 1;
+ min-width: 0;
+ border: none;
+ background: transparent;
+ color: inherit;
+ font: var(--type-body-large);
+ padding: var(--s3) 0;
+ outline: none;
+ text-overflow: ellipsis;
+}
+.control option {
+ background: var(--surface-container-high);
+ color: var(--on-surface);
+}
+
+/* EditorOptionRows (D-102): four fixed full-width rows in a segmented group. */
+.rows {
+ display: flex;
+ flex-direction: column;
+ gap: var(--gap-segmented);
+}
+.row {
+ display: grid;
+ grid-template-columns: var(--s6) minmax(5.5rem, max-content) minmax(0, 1fr) var(--s6);
+ align-items: center;
+ column-gap: var(--s3);
+ width: 100%;
+ min-height: var(--touch-target);
+ padding: var(--s2) var(--s4);
+ border: none;
+ border-radius: var(--radius-group-inner);
+ background: var(--surface-container-high);
+ color: var(--on-surface);
+ font: inherit;
+ text-align: start;
+}
+.rows > .row:first-child {
+ border-top-left-radius: var(--radius-group-outer);
+ border-top-right-radius: var(--radius-group-outer);
+}
+.rows > .row:last-child {
+ border-bottom-left-radius: var(--radius-group-outer);
+ border-bottom-right-radius: var(--radius-group-outer);
+}
+button.row {
+ cursor: pointer;
+}
+button.row:focus-visible {
+ outline: 2px solid var(--primary);
+ outline-offset: -2px;
+}
+.row:focus-within {
+ outline: 2px solid var(--primary);
+ outline-offset: -2px;
+}
+.rowActive {
+ background: var(--secondary-container);
+ color: var(--on-secondary-container);
+}
+.rowLeading,
+.rowTrailing {
+ display: inline-grid;
+ place-items: center;
+ color: var(--on-surface-variant);
+}
+.rowLabel {
+ font: var(--type-label-large);
+ letter-spacing: var(--tracking-label);
+ color: var(--on-surface-variant);
+}
+.rowValue {
+ display: flex;
+ align-items: center;
+ gap: var(--s2);
+ min-width: 0;
+ font: var(--type-body-large);
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+.rowValueText {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.rowHint {
+ color: var(--on-surface-variant);
+ font: var(--type-body-medium);
+}
+.rowControl {
+ flex: 1;
+ min-width: 0;
+ border: none;
+ background: transparent;
+ color: inherit;
+ font: var(--type-body-large);
+ padding: 0;
+ outline: none;
+}
+.accountDot {
+ display: inline-block;
+ border-radius: 50%;
+}
+
+/* CategoryQuickRow and the "Alla" grid (design-spec §3.7). */
+.categoryRow,
+.categoryGrid {
+ display: grid;
+ gap: var(--s1);
+}
+.categoryRow {
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+}
+.categoryGrid {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ row-gap: var(--s3);
+}
+.categoryItem {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--s1);
+ min-width: 0;
+ padding: var(--s1) 0;
+ border: none;
+ border-radius: var(--radius-medium);
+ background: transparent;
+ color: var(--on-surface-variant);
+ cursor: pointer;
+ font: var(--type-label-small);
+ text-align: center;
+}
+.categoryItem:focus-visible {
+ outline: 2px solid var(--primary);
+}
+.categoryItemSelected {
+ color: var(--on-surface);
+ font-weight: 700;
+}
+.categoryTileBox {
+ position: relative;
+ display: inline-grid;
+}
+.categoryLabel {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ overflow: hidden;
+ overflow-wrap: anywhere;
+ hyphens: auto;
+ max-width: 100%;
+}
+.checkBadge {
+ position: absolute;
+ top: calc(-1 * var(--s1));
+ right: calc(-1 * var(--s2));
+ display: inline-grid;
+ place-items: center;
+ width: var(--s5);
+ height: var(--s5);
+ border-radius: 50%;
+ background: var(--on-surface);
+ color: var(--surface);
+ box-shadow: 0 0 0 2px var(--surface);
+}
+.allTile {
+ display: inline-grid;
+ place-items: center;
+ border-radius: var(--radius-category-tile);
+ background: var(--surface-container-highest);
+ color: var(--on-surface-variant);
+}
+
+/* SplitSection (design-spec §3.7). */
+.splitSection {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s3);
+ padding: var(--s4);
+ border-radius: var(--radius-extra-large);
+ background: var(--surface-container-low);
+}
+.splitHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font: var(--type-title-small);
+}
+.iconButton {
+ display: inline-grid;
+ place-items: center;
+ width: var(--touch-target);
+ height: var(--touch-target);
+ border: none;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--on-surface-variant);
+ cursor: pointer;
+}
+.iconButton:focus-visible {
+ outline: 2px solid var(--primary);
+}
+.chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--s2);
+}
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--s1);
+ min-height: var(--s8);
+ padding: 0 var(--s3);
+ border: 1px solid var(--outline-variant);
+ border-radius: var(--radius-small);
+ background: transparent;
+ color: var(--on-surface-variant);
+ font: var(--type-label-large);
+ cursor: pointer;
+}
+.chip:focus-visible {
+ outline: 2px solid var(--primary);
+ outline-offset: 2px;
+}
+.chipSelected {
+ border-color: transparent;
+ background: var(--secondary-container);
+ color: var(--on-secondary-container);
+}
+.splitPair {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: var(--s3);
+}
+.splitPerson {
+ display: flex;
+ align-items: flex-end;
+ gap: var(--s3);
+}
+.splitPerson > :last-child {
+ flex: 1;
+ min-width: 0;
+}
+.presets {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--s2);
+}
+.derived input {
+ color: var(--on-surface-variant);
+}
+.errorBox {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s1);
+ padding: var(--s3) var(--s4);
+ border-radius: var(--radius-medium);
+ background: var(--error-container);
+ color: var(--on-error-container);
+ font: var(--type-title-small);
+}
+.question {
+ font: var(--type-label-large);
+ color: var(--on-surface-variant);
+}
+.bearers {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: var(--s2);
+}
+.bearer {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--s2);
+ min-height: var(--s12);
+ padding: 0 var(--s3);
+ border: 1px solid var(--outline-variant);
+ border-radius: var(--radius-full);
+ background: transparent;
+ color: var(--on-surface);
+ font: var(--type-label-large);
+ cursor: pointer;
+}
+.bearer:focus-visible {
+ outline: 2px solid var(--primary);
+ outline-offset: 2px;
+}
+.bearerSelected {
+ border-color: transparent;
+ background: var(--secondary-container);
+ color: var(--on-secondary-container);
+}
+.splitBar {
+ display: flex;
+ gap: 3px;
+ width: 100%;
+}
+.splitBarThin {
+ max-width: 72px;
+}
+.splitSegment {
+ border-radius: var(--radius-full);
+ min-width: 0;
+}
+.preview {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s2);
+ font: var(--type-body-medium);
+ letter-spacing: var(--tracking-body-medium);
+ color: var(--on-surface-variant);
+}
+.preview strong {
+ font-weight: 700;
+ color: var(--on-surface);
+}
+
+/* NotesField. */
+.notesButton {
+ align-self: flex-start;
+}
+
+/* ConfirmDialog (design-spec §3.11). */
+.confirmIcon {
+ display: inline-grid;
+ place-items: center;
+ align-self: center;
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ background: var(--secondary-container);
+ color: var(--on-secondary-container);
+}
+.confirmIconDestructive {
+ background: var(--error-container);
+ color: var(--on-error-container);
+}
diff --git a/web/src/components/editor/NotesField.tsx b/web/src/components/editor/NotesField.tsx
new file mode 100644
index 0000000..6e936d2
--- /dev/null
+++ b/web/src/components/editor/NotesField.tsx
@@ -0,0 +1,65 @@
+import { useEffect, useRef, useState } from 'react';
+import { Icon } from '../../design/Icon';
+import { useI18n } from '../../i18n/useI18n';
+import { Button } from '../Button';
+import { TextField } from '../TextField';
+import styles from './Editor.module.css';
+
+/** Notes limit of SPEC §9.6 (code points). */
+export const NOTES_MAX = 1000;
+
+/** Length in Unicode code points (SPEC §9.6 counts code points). */
+export function codePoints(text: string): number {
+ return Array.from(text).length;
+}
+
+/**
+ * `NotesField` (design-spec §3.7): collapsed as "Lägg till anteckning"; expanded (or when there are notes) a multi-line
+ * field "Anteckningar" with the counter "120 / 1000" as supporting text.
+ */
+export function NotesField({
+ value,
+ onChange,
+ error,
+ max = NOTES_MAX,
+}: {
+ value: string;
+ onChange: (value: string) => void;
+ error?: string | null;
+ max?: number;
+}) {
+ const { t } = useI18n();
+ const [expanded, setExpanded] = useState(value !== '');
+ const [focusOnOpen, setFocusOnOpen] = useState(false);
+ const ref = useRef(null);
+ useEffect(() => {
+ if (expanded && focusOnOpen) ref.current?.focus();
+ }, [expanded, focusOnOpen]);
+ if (!expanded) {
+ return (
+ }
+ onClick={() => {
+ setExpanded(true);
+ setFocusOnOpen(true);
+ }}
+ >
+ {t('editor_add_note')}
+
+ );
+ }
+ return (
+
+ );
+}
diff --git a/web/src/components/editor/OptionRows.tsx b/web/src/components/editor/OptionRows.tsx
new file mode 100644
index 0000000..5fa5470
--- /dev/null
+++ b/web/src/components/editor/OptionRows.tsx
@@ -0,0 +1,78 @@
+import type { ReactNode } from 'react';
+import styles from './Editor.module.css';
+
+/**
+ * `EditorOptionRows` (design-spec §3.7, D-102): full-width rows in a fixed order (Konto, Datum, Betalt av, Delning) as
+ * a segmented group; a row's place and size never depend on its value.
+ */
+export function OptionRows({ label, children }: { label?: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+interface OptionRowProps {
+ /** The row's label ("Konto", "Datum", "Betalt av", "Delning"). */
+ label: string;
+ /** A 24 px element: an icon, an account dot, an avatar. */
+ leading: ReactNode;
+ /** The value: text, or a native control whose id is `controlId`. */
+ children: ReactNode;
+ trailing?: ReactNode;
+ /** The row is one button (payer toggle, split section); omit for a row holding a control. */
+ onClick?: () => void;
+ /** The id of the control inside the row; the label is then its ``. */
+ controlId?: string;
+ /** Accessible name of a button row (e.g. "Betalt av du. Byt till Anna."). */
+ ariaLabel?: string;
+ /** `aria-expanded` of a button row that opens a section in place. */
+ expanded?: boolean;
+ /** Secondary-container fill (the Delning row while the split section is open). */
+ active?: boolean;
+}
+
+/** One row: leading element, label column, value (one line, ellipsis) and trailing affordance. */
+export function OptionRow({
+ label,
+ leading,
+ children,
+ trailing,
+ onClick,
+ controlId,
+ ariaLabel,
+ expanded,
+ active = false,
+}: OptionRowProps) {
+ const className = [styles.row, active ? styles.rowActive : undefined].filter(Boolean).join(' ');
+ const content = (
+ <>
+
+ {leading}
+
+ {controlId === undefined ? (
+ {label}
+ ) : (
+
+ {label}
+
+ )}
+ {children}
+
+ {trailing}
+
+ >
+ );
+ if (onClick === undefined) return {content}
;
+ return (
+
+ {content}
+
+ );
+}
+
+/** The value class for a native control inside a row (no fill, inherits the row's type). */
+export const optionRowControlClass = styles.rowControl;
+/** Muted text after a row's value ("Idag"). */
+export const optionRowHintClass = styles.rowHint;
diff --git a/web/src/components/editor/PayerRow.tsx b/web/src/components/editor/PayerRow.tsx
new file mode 100644
index 0000000..5ef6a38
--- /dev/null
+++ b/web/src/components/editor/PayerRow.tsx
@@ -0,0 +1,38 @@
+import { Icon } from '../../design/Icon';
+import { useI18n } from '../../i18n/useI18n';
+import { Avatar } from '../Avatar';
+import { OptionRow } from './OptionRows';
+import type { EditorPeople } from './people';
+
+/**
+ * The "Betalt av" row (design-spec §3.7, D-102): the payer's avatar and "Du" / "Anna"; a tap switches to the other user
+ * (AC-8.3), no picker. Announced as "Betalt av du. Dubbeltryck för att byta till Anna." (`editor_paid_by_toggle`).
+ */
+export function PayerRow({
+ people,
+ payerId,
+ onChange,
+}: {
+ people: EditorPeople;
+ payerId: string;
+ onChange: (payerId: string) => void;
+}) {
+ const { t } = useI18n();
+ const payer = payerId === people.viewer.id ? people.viewer : people.partner;
+ const other = payerId === people.viewer.id ? people.partner : people.viewer;
+ const name = (person: typeof payer, lower: boolean) =>
+ person.id === people.viewer.id ? t(lower ? 'common_you_lower' : 'common_you') : person.displayName;
+ return (
+ }
+ trailing={ }
+ ariaLabel={t('editor_paid_by_toggle', name(payer, true), name(other, true))}
+ onClick={() => {
+ onChange(other.id);
+ }}
+ >
+ {name(payer, false)}
+
+ );
+}
diff --git a/web/src/components/editor/SelectField.tsx b/web/src/components/editor/SelectField.tsx
new file mode 100644
index 0000000..cda8ac9
--- /dev/null
+++ b/web/src/components/editor/SelectField.tsx
@@ -0,0 +1,103 @@
+import { useId } from 'react';
+import styles from './Editor.module.css';
+
+export interface SelectOption {
+ value: T;
+ label: string;
+ disabled?: boolean;
+}
+
+interface Props {
+ label: string;
+ value: T;
+ options: readonly SelectOption[];
+ onChange: (value: T) => void;
+ error?: string | null;
+ id?: string;
+ className?: string;
+ disabled?: boolean;
+}
+
+/**
+ * A labelled native `` in the filled field style (design-spec §2.9): keyboard and screen-reader behaviour come
+ * from the browser. Used for the editor's account row, the search filters and the template editor.
+ */
+export function SelectField({
+ label,
+ value,
+ options,
+ onChange,
+ error,
+ id,
+ className,
+ disabled,
+}: Props) {
+ const generated = useId();
+ const selectId = id ?? generated;
+ const errorId = `${selectId}-error`;
+ return (
+
+
+ {label}
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+}
+
+/** The bare `` (no label), for a row that renders its own label (`OptionRow` with `controlId`). */
+export function SelectControl({
+ id,
+ value,
+ options,
+ onChange,
+ disabled,
+ invalid,
+ describedBy,
+ className,
+}: {
+ id: string;
+ value: T;
+ options: readonly SelectOption[];
+ onChange: (value: T) => void;
+ disabled?: boolean;
+ invalid?: boolean;
+ describedBy?: string;
+ className?: string;
+}) {
+ return (
+ {
+ const next = options.find((o) => o.value === event.target.value);
+ if (next !== undefined) onChange(next.value);
+ }}
+ >
+ {options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+ );
+}
diff --git a/web/src/components/editor/SplitSection.tsx b/web/src/components/editor/SplitSection.tsx
new file mode 100644
index 0000000..3d19955
--- /dev/null
+++ b/web/src/components/editor/SplitSection.tsx
@@ -0,0 +1,411 @@
+import { useId } from 'react';
+import type { SplitMode } from '../../api/types';
+import { Icon } from '../../design/Icon';
+import { useI18n } from '../../i18n/useI18n';
+import { basisPoints, BP_TOTAL, formatMoney } from '../../lib/money';
+import { AmountField } from '../AmountField';
+import { Avatar } from '../Avatar';
+import { Button } from '../Button';
+import { TextField } from '../TextField';
+import styles from './Editor.module.css';
+import type { EditorPeople, EditorPerson } from './people';
+import {
+ exactDerivedOre,
+ moneyText,
+ moneyTextOre,
+ percentText,
+ percentTextBp,
+ ratioDerivedBp,
+ viewerBpOf,
+ withMode,
+ type SplitDraft,
+ type SplitResolution,
+} from './split';
+
+const MODES: readonly SplitMode[] = ['default', 'ratio', 'exact', 'full'];
+/** Ratio presets (design-spec §3.7), the viewer's percent first. */
+const PRESETS = [40, 50, 60] as const;
+
+type T = ReturnType['t'];
+
+/** "60/40" from the viewer's basis points (`common_ratio`). */
+function ratioText(viewerBp: number, t: T, language: 'sv' | 'en'): string {
+ return t('common_ratio', basisPoints(viewerBp, language), basisPoints(BP_TOTAL - viewerBp, language));
+}
+
+/** "Kontots standard 60/40" (`editor_split_mode_default`) with the viewer's share of the account default first. */
+export function defaultModeLabel(viewerBp: number, t: T, language: 'sv' | 'en'): string {
+ return t('editor_split_mode_default', ratioText(viewerBp, t, language));
+}
+
+/** "Allt på dig" / "Allt på Anna" (`editor_split_full_on_you`, `editor_split_full_on`). */
+export function fullOnLabel(bearerId: string, people: EditorPeople, t: T): string {
+ return bearerId === people.viewer.id
+ ? t('editor_split_full_on_you')
+ : t('editor_split_full_on', people.partner.displayName);
+}
+
+/**
+ * The split as one line (the Delning row, S-8's split fact): "Kontots standard 50/50", "Egen fördelning 70/30",
+ * "Exakta belopp", "Allt på Anna". `viewerBp` is the viewer's basis points (modes `default` and `ratio`), when known.
+ */
+export function splitLabel(
+ mode: SplitMode,
+ viewerBp: number | null,
+ bearerId: string | null,
+ people: EditorPeople,
+ t: T,
+ language: 'sv' | 'en',
+): string {
+ switch (mode) {
+ case 'default':
+ return defaultModeLabel(viewerBp ?? BP_TOTAL / 2, t, language);
+ case 'ratio':
+ return viewerBp === null
+ ? t('editor_split_mode_ratio')
+ : `${t('editor_split_mode_ratio')} ${ratioText(viewerBp, t, language)}`;
+ case 'exact':
+ return t('editor_split_mode_exact');
+ case 'full':
+ return bearerId === null ? t('editor_split_mode_full') : fullOnLabel(bearerId, people, t);
+ }
+}
+
+/** The label of a draft for the Delning row. */
+export function draftSplitLabel(
+ draft: SplitDraft,
+ accountBp: Readonly> | null,
+ people: EditorPeople,
+ t: T,
+ language: 'sv' | 'en',
+): string {
+ const ratioViewerBp = (() => {
+ const typed = percentTextBp(draft.ratio.text);
+ if (typed === null) return null;
+ return draft.ratio.userId === people.viewer.id ? typed : BP_TOTAL - typed;
+ })();
+ const viewerBp = draft.mode === 'default' ? viewerBpOf(accountBp, people.viewer.id) : ratioViewerBp;
+ return splitLabel(draft.mode, viewerBp, draft.fullOn, people, t, language);
+}
+
+/**
+ * `SplitBar` (design-spec §3.7): the viewer's segment in the viewer's avatar colour, the partner's in theirs, widths
+ * proportional; `thin` is 8 px high (72 px wide), else 16. Decorative: the numbers are printed beside it.
+ */
+export function SplitBar({
+ viewerShare,
+ people,
+ thin = false,
+ error = false,
+}: {
+ /** The viewer's share of the whole, 0 to 1. */
+ viewerShare: number;
+ people: EditorPeople;
+ thin?: boolean;
+ error?: boolean;
+}) {
+ const share = Math.min(1, Math.max(0, viewerShare));
+ const height = thin ? 8 : 16;
+ const segment = (person: EditorPerson, fraction: number, isViewer: boolean) =>
+ fraction <= 0 ? null : (
+
+ );
+ return (
+
+ {segment(people.viewer, share, true)}
+ {segment(people.partner, 1 - share, false)}
+
+ );
+}
+
+/** `SplitPreview` (design-spec §3.7): a thin bar, then "Du **206,00 kr** · Anna **206,00 kr**". */
+export function SplitPreview({
+ shares,
+ people,
+ bar = true,
+}: {
+ shares: Readonly>;
+ people: EditorPeople;
+ bar?: boolean;
+}) {
+ const { t, language } = useI18n();
+ const viewerOre = shares[people.viewer.id] ?? 0;
+ const partnerOre = shares[people.partner.id] ?? 0;
+ const total = viewerOre + partnerOre;
+ return (
+
+ {bar ? : null}
+
+ {t('common_you')} {formatMoney(viewerOre, language)}
+ {t('common_separator_dot')}
+ {people.partner.displayName} {formatMoney(partnerOre, language)}
+
+
+ );
+}
+
+interface Props {
+ draft: SplitDraft;
+ onChange: (draft: SplitDraft) => void;
+ people: EditorPeople;
+ amountOre: number | null;
+ payerId: string;
+ /** The selected account's default ratio (mode `default`). */
+ accountBp: Readonly> | null;
+ resolution: SplitResolution;
+ onCollapse: () => void;
+}
+
+/**
+ * `SplitSection` (design-spec §3.7, §5.4): header "Delning" with a collapse button, the four mode chips, and the
+ * controls of the chosen mode with a live preview of each person's share.
+ */
+export function SplitSection({
+ draft,
+ onChange,
+ people,
+ amountOre,
+ payerId,
+ accountBp,
+ resolution,
+ onCollapse,
+}: Props) {
+ const { t, language } = useI18n();
+ const ctx = { viewerId: people.viewer.id, partnerId: people.partner.id, payerId, accountBp };
+ const modeLabel = (mode: SplitMode) =>
+ mode === 'default'
+ ? defaultModeLabel(viewerBpOf(accountBp, people.viewer.id), t, language)
+ : t(
+ mode === 'ratio'
+ ? 'editor_split_mode_ratio'
+ : mode === 'exact'
+ ? 'editor_split_mode_exact'
+ : 'editor_split_mode_full',
+ );
+ const preview = resolution.kind === 'ok' ? : null;
+ return (
+
+
+
{t('editor_split')}
+
+
+
+
+
+ {MODES.map((mode) => {
+ const checked = draft.mode === mode;
+ return (
+ {
+ onChange(withMode(draft, mode, ctx, language));
+ }}
+ >
+ {checked ? : null}
+ {modeLabel(mode)}
+
+ );
+ })}
+
+ {draft.mode === 'ratio' ? (
+
+ ) : draft.mode === 'exact' ? (
+
+ ) : draft.mode === 'full' ? (
+
+ ) : null}
+ {draft.mode === 'default' && resolution.kind === 'ok' ? (
+
+ ) : null}
+ {preview}
+
+ );
+}
+
+function RatioEditor({
+ draft,
+ onChange,
+ people,
+}: {
+ draft: SplitDraft;
+ onChange: (d: SplitDraft) => void;
+ people: EditorPeople;
+}) {
+ const { t, language } = useI18n();
+ const derived = ratioDerivedBp(draft);
+ const textOf = (person: EditorPerson) =>
+ person.id === draft.ratio.userId ? draft.ratio.text : derived === null ? '' : percentText(derived, language);
+ const field = (person: EditorPerson) => (
+
+
+
{t('editor_percent_suffix')}}
+ onChange={(text) => {
+ if (percentTextBp(text) !== null || text.trim() === '')
+ onChange({ ...draft, ratio: { userId: person.id, text } });
+ }}
+ />
+
+ );
+ return (
+ <>
+
+ {field(people.viewer)}
+ {field(people.partner)}
+
+
+ {PRESETS.map((percent) => (
+ {
+ onChange({ ...draft, ratio: { userId: people.viewer.id, text: String(percent) } });
+ }}
+ >
+ {t('common_ratio', String(percent), String(100 - percent))}
+
+ ))}
+
+ >
+ );
+}
+
+function ExactEditor({
+ draft,
+ onChange,
+ people,
+ amountOre,
+}: {
+ draft: SplitDraft;
+ onChange: (d: SplitDraft) => void;
+ people: EditorPeople;
+ amountOre: number | null;
+}) {
+ const { t, language } = useI18n();
+ const typedOre = moneyTextOre(draft.exact.text);
+ const derived = exactDerivedOre(draft, amountOre);
+ const tooLarge = typedOre !== null && amountOre !== null && typedOre > amountOre;
+ const nameOf = (person: EditorPerson) => (person.id === people.viewer.id ? t('common_you') : person.displayName);
+ const field = (person: EditorPerson) => {
+ const typed = person.id === draft.exact.userId;
+ return (
+
+
+ {typed ? (
+
{
+ onChange({ ...draft, exact: { userId: person.id, text } });
+ }}
+ />
+ ) : (
+ undefined}
+ inputClassName="amount"
+ supporting={t('editor_split_exact_computed')}
+ onFocus={() => {
+ onChange({
+ ...draft,
+ exact: { userId: person.id, text: derived === null ? '' : moneyText(derived, language) },
+ });
+ }}
+ />
+ )}
+
+ );
+ };
+ const typedPerson = draft.exact.userId === people.viewer.id ? people.viewer : people.partner;
+ return (
+ <>
+
+ {field(people.viewer)}
+ {field(people.partner)}
+
+ {tooLarge ? (
+
+
+ {t(
+ 'editor_split_exact_of',
+ typedPerson.id === people.viewer.id ? t('common_you') : typedPerson.displayName,
+ formatMoney(typedOre, language),
+ formatMoney(amountOre, language),
+ )}
+
+ {t('common_too_much', formatMoney(typedOre - amountOre, language))}
+
+ ) : null}
+ >
+ );
+}
+
+function FullPicker({
+ draft,
+ onChange,
+ people,
+}: {
+ draft: SplitDraft;
+ onChange: (d: SplitDraft) => void;
+ people: EditorPeople;
+}) {
+ const { t } = useI18n();
+ const questionId = useId();
+ return (
+ <>
+
+ {t('editor_split_full_question')}
+
+
+ {[people.viewer, people.partner].map((person) => {
+ const checked = draft.fullOn === person.id;
+ return (
+
{
+ onChange({ ...draft, fullOn: person.id });
+ }}
+ >
+
+ {person.id === people.viewer.id ? t('common_you') : person.displayName}
+
+ );
+ })}
+
+
+ >
+ );
+}
diff --git a/web/src/components/editor/categories.test.ts b/web/src/components/editor/categories.test.ts
new file mode 100644
index 0000000..99bfad8
--- /dev/null
+++ b/web/src/components/editor/categories.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from 'vitest';
+import type { Category } from '../../api/types';
+import { activeCategoriesOf, frequentCategoryIds, quickRowCategories } from './categories';
+
+function category(id: string, sortOrder: number, archived = false): Category {
+ return {
+ id,
+ default_key: null,
+ name: id,
+ icon: 'category',
+ color: 'grey',
+ sort_order: sortOrder,
+ created_at: '2026-10-01T00:00:00.000Z',
+ updated_at: '2026-10-01T00:00:00.000Z',
+ archived_at: archived ? '2026-10-02T00:00:00.000Z' : null,
+ deleted_at: null,
+ server_version: 1,
+ };
+}
+
+const CATS = [
+ category('a', 10),
+ category('b', 20),
+ category('c', 30),
+ category('d', 40),
+ category('e', 50),
+ category('f', 60),
+ category('g', 70),
+ category('x', 5, true),
+];
+const use = (category_id: string, account_id = 'acc', deleted_at: string | null = null) => ({
+ account_id,
+ category_id,
+ deleted_at,
+});
+
+describe('frequentCategoryIds (UX-9, D-060)', () => {
+ it('counts the account’s live expenses per active category, ties by sort_order, at most five', () => {
+ const active = activeCategoriesOf(CATS);
+ const expenses = [
+ use('g'),
+ use('g'),
+ use('f'),
+ use('c'),
+ use('e'),
+ use('d'),
+ use('b'),
+ use('x'),
+ use('a', 'other-account'),
+ use('a', 'acc', '2026-10-03T00:00:00.000Z'),
+ ];
+ expect(frequentCategoryIds(expenses, 'acc', active)).toEqual(['g', 'b', 'c', 'd', 'e']);
+ expect(frequentCategoryIds([], 'acc', active)).toEqual([]);
+ expect(frequentCategoryIds(expenses, null, active)).toEqual([]);
+ });
+});
+
+describe('quickRowCategories (D-075)', () => {
+ it('fills up from sort_order and gives the selected category the fifth place when absent', () => {
+ expect(quickRowCategories(CATS, ['g'], 'g').map((c) => c.id)).toEqual(['g', 'a', 'b', 'c', 'd']);
+ expect(quickRowCategories(CATS, ['g'], 'f').map((c) => c.id)).toEqual(['g', 'a', 'b', 'c', 'f']);
+ expect(quickRowCategories(CATS, [], null).map((c) => c.id)).toEqual(['a', 'b', 'c', 'd', 'e']);
+ // An archived category (edit mode) still shows in the fifth place while selected.
+ expect(quickRowCategories(CATS, [], 'x').map((c) => c.id)).toEqual(['a', 'b', 'c', 'd', 'x']);
+ });
+});
diff --git a/web/src/components/editor/categories.ts b/web/src/components/editor/categories.ts
new file mode 100644
index 0000000..608ac73
--- /dev/null
+++ b/web/src/components/editor/categories.ts
@@ -0,0 +1,66 @@
+/**
+ * Category helpers for editors (design-spec §3.7 `CategoryQuickRow`; D-060, D-075): display names, the per-account
+ * usage list (`frequentCategoryIds`) and the quick-row fill. Pure.
+ */
+import type { Category, DefaultCategoryKey, Expense } from '../../api/types';
+import type { I18n } from '../../i18n/I18nProvider';
+
+/** Tiles in the quick row besides "Alla" (design-spec §3.7). */
+export const QUICK_ROW_TILES = 5;
+
+/** A category's name: the custom name, else the default category's name in the page language (AC-7.2). */
+export function categoryName(category: Pick, t: I18n['t']): string {
+ if (category.name !== null) return category.name;
+ const key: DefaultCategoryKey = category.default_key ?? 'other';
+ return t(`category_default_${key}`);
+}
+
+/** Categories offered in pickers: neither archived nor deleted, in `sort_order` (then id). */
+export function activeCategoriesOf(categories: Iterable): Category[] {
+ return [...categories]
+ .filter((c) => c.archived_at === null && c.deleted_at === null)
+ .sort((a, b) => a.sort_order - b.sort_order || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
+}
+
+/**
+ * UX-9 `frequentCategoryIds` for one account (D-060): the active categories used by the account's non-deleted
+ * expenses, most used first, ties by `sort_order`; at most five, possibly fewer or none.
+ */
+export function frequentCategoryIds(
+ expenses: Iterable>,
+ accountId: string | null,
+ activeCategories: readonly Category[],
+): string[] {
+ if (accountId === null) return [];
+ const order = new Map(activeCategories.map((c, index) => [c.id, index]));
+ const counts = new Map();
+ for (const e of expenses) {
+ if (e.deleted_at !== null || e.account_id !== accountId || !order.has(e.category_id)) continue;
+ counts.set(e.category_id, (counts.get(e.category_id) ?? 0) + 1);
+ }
+ return [...counts.entries()]
+ .sort(([a, ca], [b, cb]) => cb - ca || (order.get(a) ?? 0) - (order.get(b) ?? 0))
+ .slice(0, QUICK_ROW_TILES)
+ .map(([id]) => id);
+}
+
+/**
+ * The quick row's five categories (D-075 `quickRowCategories`): the frequent ones, filled up from `sort_order`; the
+ * selected category takes the fifth place when it is not among them (it may be archived in edit mode).
+ */
+export function quickRowCategories(
+ categories: readonly Category[],
+ frequent: readonly string[],
+ selectedId: string | null,
+): Category[] {
+ const active = activeCategoriesOf(categories);
+ const byId = new Map(active.map((c) => [c.id, c]));
+ const chosen: Category[] = [];
+ for (const candidate of [...frequent.flatMap((id) => byId.get(id) ?? []), ...active]) {
+ if (chosen.length === QUICK_ROW_TILES) break;
+ if (!chosen.some((c) => c.id === candidate.id)) chosen.push(candidate);
+ }
+ const selected = selectedId === null ? undefined : categories.find((c) => c.id === selectedId);
+ if (selected === undefined || chosen.some((c) => c.id === selected.id)) return chosen;
+ return [...chosen.slice(0, QUICK_ROW_TILES - 1), selected];
+}
diff --git a/web/src/components/editor/index.ts b/web/src/components/editor/index.ts
new file mode 100644
index 0000000..f662839
--- /dev/null
+++ b/web/src/components/editor/index.ts
@@ -0,0 +1,49 @@
+/**
+ * The shared expense-editor fields of design-spec §3.7 (D-019), used by the expense editor (W2, S-7 / W-5) and the
+ * recurring-template editor (W5, S-19 / W-16). Generic: every component takes props, none holds expense state.
+ */
+export { AccountDot, accountColorIndex } from './AccountDot';
+export {
+ activeCategoriesOf,
+ categoryName,
+ frequentCategoryIds,
+ quickRowCategories,
+ QUICK_ROW_TILES,
+} from './categories';
+export { CategoryPicker } from './CategoryPicker';
+export { ConfirmDialog } from './ConfirmDialog';
+export { DateControl, DateField } from './DateField';
+export { codePoints, NotesField, NOTES_MAX } from './NotesField';
+export { OptionRow, OptionRows, optionRowControlClass, optionRowHintClass } from './OptionRows';
+export { PayerRow } from './PayerRow';
+export type { EditorPeople, EditorPerson } from './people';
+export { SelectControl, SelectField, type SelectOption } from './SelectField';
+export {
+ exactDerivedOre,
+ moneyText,
+ moneyTextOre,
+ newSplitDraft,
+ percentText,
+ percentTextBp,
+ ratioDerivedBp,
+ resolveSplit,
+ splitDraftOf,
+ viewerBpOf,
+ wireSplits,
+ withMode,
+ type SplitContext,
+ type SplitDraft,
+ type SplitPeople,
+ type SplitResolution,
+ type TypedShare,
+ type WireSplit,
+} from './split';
+export {
+ defaultModeLabel,
+ draftSplitLabel,
+ fullOnLabel,
+ SplitBar,
+ SplitPreview,
+ SplitSection,
+ splitLabel,
+} from './SplitSection';
diff --git a/web/src/components/editor/people.ts b/web/src/components/editor/people.ts
new file mode 100644
index 0000000..bdd5ff0
--- /dev/null
+++ b/web/src/components/editor/people.ts
@@ -0,0 +1,12 @@
+/** A household user as editor fields show them: id, display name and avatar slot (design-spec §2.5). */
+export interface EditorPerson {
+ id: string;
+ displayName: string;
+ slot: 1 | 2;
+}
+
+/** The viewer and the partner (SPEC: a household has exactly two users). */
+export interface EditorPeople {
+ viewer: EditorPerson;
+ partner: EditorPerson;
+}
diff --git a/web/src/components/editor/split.test.ts b/web/src/components/editor/split.test.ts
new file mode 100644
index 0000000..824f1df
--- /dev/null
+++ b/web/src/components/editor/split.test.ts
@@ -0,0 +1,152 @@
+import { describe, expect, it } from 'vitest';
+import {
+ exactDerivedOre,
+ newSplitDraft,
+ resolveSplit,
+ splitDraftOf,
+ wireSplits,
+ withMode,
+ type SplitContext,
+ type SplitDraft,
+} from './split';
+
+const ME = '01929c5e-0000-7000-8000-000000000a01';
+const ANNA = '01929c5e-0000-7000-8000-000000000a02';
+const people = { viewerId: ME, partnerId: ANNA };
+const fiftyFifty = { [ME]: 5000, [ANNA]: 5000 };
+const sixtyForty = { [ME]: 6000, [ANNA]: 4000 };
+
+function ctx(patch: Partial = {}): SplitContext {
+ return { ...people, amountOre: 41_200, payerId: ME, accountBp: fiftyFifty, ...patch };
+}
+
+function draft(patch: Partial = {}): SplitDraft {
+ return { ...newSplitDraft(people, fiftyFifty, 'sv'), ...patch };
+}
+
+describe('resolveSplit (SPEC §9.1 through computeSplit)', () => {
+ it('default: copies the account ratio; the payer absorbs the odd öre', () => {
+ expect(resolveSplit(draft(), ctx({ amountOre: 1001 }))).toEqual({
+ kind: 'ok',
+ shares: { [ME]: 501, [ANNA]: 500 },
+ bp: fiftyFifty,
+ });
+ expect(resolveSplit(draft(), ctx({ accountBp: sixtyForty, payerId: ANNA, amountOre: 100 }))).toEqual({
+ kind: 'ok',
+ shares: { [ME]: 60, [ANNA]: 40 },
+ bp: sixtyForty,
+ });
+ });
+
+ it('ratio: the typed percent (with decimals) and 100 minus it for the other', () => {
+ const ratio = draft({ mode: 'ratio', ratio: { userId: ANNA, text: '33,33' } });
+ expect(resolveSplit(ratio, ctx({ amountOre: 999 }))).toEqual({
+ kind: 'ok',
+ shares: { [ME]: 667, [ANNA]: 332 },
+ bp: { [ANNA]: 3333, [ME]: 6667 },
+ });
+ expect(resolveSplit({ ...ratio, ratio: { userId: ME, text: '' } }, ctx())).toEqual({ kind: 'incomplete' });
+ });
+
+ it('exact: one typed share, the other derived; a typed share above the amount is an error (AC-8.7)', () => {
+ const exact = draft({ mode: 'exact', exact: { userId: ME, text: '1 200,00' } });
+ expect(resolveSplit(exact, ctx({ amountOre: 106_000 }))).toEqual({
+ kind: 'error',
+ error: 'exact_share_too_large',
+ });
+ expect(exactDerivedOre(exact, 106_000)).toBeNull();
+ const ok = { ...exact, exact: { userId: ME, text: '400,5' } };
+ expect(resolveSplit(ok, ctx({ amountOre: 106_000 }))).toEqual({
+ kind: 'ok',
+ shares: { [ME]: 40_050, [ANNA]: 65_950 },
+ bp: null,
+ });
+ expect(exactDerivedOre(ok, 106_000)).toBe(65_950);
+ expect(resolveSplit({ ...exact, exact: { userId: ME, text: '' } }, ctx())).toEqual({ kind: 'incomplete' });
+ });
+
+ it('full: the bearer carries the whole amount, payer or not', () => {
+ expect(resolveSplit(draft({ mode: 'full', fullOn: ANNA }), ctx())).toEqual({
+ kind: 'ok',
+ shares: { [ME]: 0, [ANNA]: 41_200 },
+ bp: { [ANNA]: 10_000, [ME]: 0 },
+ });
+ expect(resolveSplit(draft({ mode: 'full', fullOn: ME }), ctx())).toEqual({
+ kind: 'ok',
+ shares: { [ME]: 41_200, [ANNA]: 0 },
+ bp: { [ME]: 10_000, [ANNA]: 0 },
+ });
+ });
+
+ it('is incomplete without an amount or an account (mode default)', () => {
+ expect(resolveSplit(draft(), ctx({ amountOre: null }))).toEqual({ kind: 'incomplete' });
+ expect(resolveSplit(draft(), ctx({ accountBp: null }))).toEqual({ kind: 'incomplete' });
+ });
+
+ it('reports the domain errors of computeSplit', () => {
+ expect(resolveSplit(draft(), ctx({ amountOre: 0 }))).toEqual({ kind: 'error', error: 'amount_out_of_range' });
+ });
+});
+
+describe('wire splits and drafts', () => {
+ it('sorts by user id, share_bp null in mode exact', () => {
+ const resolved = resolveSplit(draft({ mode: 'exact', exact: { userId: ANNA, text: '12' } }), ctx());
+ if (resolved.kind !== 'ok') throw new Error('expected ok');
+ expect(wireSplits(resolved)).toEqual([
+ { user_id: ME, share_ore: 40_000, share_bp: null },
+ { user_id: ANNA, share_ore: 1_200, share_bp: null },
+ ]);
+ });
+
+ it('reopens a saved split in its mode with the viewer typed', () => {
+ const ratio = splitDraftOf(
+ 'ratio',
+ [
+ { user_id: ME, share_ore: 700, share_bp: 7000 },
+ { user_id: ANNA, share_ore: 300, share_bp: 3000 },
+ ],
+ people,
+ fiftyFifty,
+ 'sv',
+ );
+ expect(ratio.mode).toBe('ratio');
+ expect(ratio.ratio).toEqual({ userId: ME, text: '70' });
+ const exact = splitDraftOf(
+ 'exact',
+ [
+ { user_id: ME, share_ore: 124_760, share_bp: null },
+ { user_id: ANNA, share_ore: 0, share_bp: null },
+ ],
+ people,
+ fiftyFifty,
+ 'sv',
+ );
+ expect(exact.exact).toEqual({ userId: ME, text: '1\u00A0247,60' });
+ const full = splitDraftOf(
+ 'full',
+ [
+ { user_id: ME, share_ore: 0, share_bp: 0 },
+ { user_id: ANNA, share_ore: 500, share_bp: 10_000 },
+ ],
+ people,
+ fiftyFifty,
+ 'sv',
+ );
+ expect(full.fullOn).toBe(ANNA);
+ });
+
+ it('picks modes as design-spec §5.4 says: ratio at the account ratio, full on the one who did not pay', () => {
+ const start = newSplitDraft(people, sixtyForty, 'sv');
+ expect(start.ratio.text).toBe('60');
+ expect(withMode(start, 'full', { ...people, payerId: ME, accountBp: sixtyForty }, 'sv').fullOn).toBe(ANNA);
+ expect(withMode(start, 'full', { ...people, payerId: ANNA, accountBp: sixtyForty }, 'sv').fullOn).toBe(ME);
+ expect(
+ withMode(
+ { ...start, ratio: { userId: ME, text: '' } },
+ 'ratio',
+ { ...people, payerId: ME, accountBp: sixtyForty },
+ 'en',
+ ).ratio.text,
+ ).toBe('60');
+ });
+});
diff --git a/web/src/components/editor/split.ts b/web/src/components/editor/split.ts
new file mode 100644
index 0000000..d9336a3
--- /dev/null
+++ b/web/src/components/editor/split.ts
@@ -0,0 +1,219 @@
+/**
+ * The split state of an editor (SPEC §9.1, AC-8.5, AC-8.7, AC-8.8; design-spec §3.7 `SplitSection`, §5.4): one draft
+ * holds the four modes' inputs as typed, and `resolveSplit` turns it into each user's share through `computeSplit`.
+ * Pure and generic: the expense editor (W2) and the recurring-template editor (W5) use the same state.
+ */
+import type { SplitMode } from '../../api/types';
+import {
+ amountInputDisplay,
+ amountInputFromText,
+ amountInputOf,
+ amountInputOre,
+ BP_TOTAL,
+ percentInputOf,
+} from '../../lib/money';
+import { computeSplit, type SplitError } from '../../lib/split';
+import type { Language } from '../../i18n/language';
+
+/** A typed money or percent field: whose value was typed (the other is derived) and the text as typed. */
+export interface TypedShare {
+ userId: string;
+ text: string;
+}
+
+export interface SplitDraft {
+ mode: SplitMode;
+ /** `ratio`: one user's percent as typed ("60", "60,5"); the other is 100 minus it. */
+ ratio: TypedShare;
+ /** `exact`: one user's share in kronor as typed; the other is the amount minus it (AC-8.7). */
+ exact: TypedShare;
+ /** `full`: the user who bears the whole amount. */
+ fullOn: string;
+}
+
+/** The two household users: the viewer and the partner (ids). */
+export interface SplitPeople {
+ viewerId: string;
+ partnerId: string;
+}
+
+export interface SplitContext extends SplitPeople {
+ /** The amount in öre, null while empty. */
+ amountOre: number | null;
+ payerId: string;
+ /** The account's default ratio in basis points per user (mode `default`), null when there is no account. */
+ accountBp: Readonly> | null;
+}
+
+export type SplitResolution =
+ | { kind: 'ok'; shares: Record; bp: Record | null }
+ /** Something needed is still empty (the amount, the typed field, the account). */
+ | { kind: 'incomplete' }
+ /** `exact_share_too_large`: the typed exact share is larger than the amount (AC-8.7, `error_exact_share_too_large`). */
+ | { kind: 'error'; error: SplitError | 'exact_share_too_large' };
+
+/** A wire `Split` row (§14.3.3): sorted by user id, `share_bp` null in mode `exact`. */
+export interface WireSplit {
+ user_id: string;
+ share_ore: number;
+ share_bp: number | null;
+}
+
+function otherOf(people: SplitPeople, userId: string): string {
+ return userId === people.viewerId ? people.partnerId : people.viewerId;
+}
+
+/** Basis points of a typed percent ("60,5" -> 6050), or null when empty. */
+export function percentTextBp(text: string): number | null {
+ const input = amountInputFromText(text, BP_TOTAL);
+ return input === null ? null : amountInputOre(input);
+}
+
+/** Öre of a typed money field, or null when empty or not an amount. */
+export function moneyTextOre(text: string): number | null {
+ const input = amountInputFromText(text, Number.MAX_SAFE_INTEGER);
+ return input === null ? null : amountInputOre(input);
+}
+
+/** A percent for a field, from basis points: 6000 -> "60", 6050 -> "60,5" (sv). */
+export function percentText(bp: number, language: Language): string {
+ return amountInputDisplay(percentInputOf(bp), language);
+}
+
+/** Money for a field, from öre, with both decimals: 124 760 -> "1 247,60" (sv). */
+export function moneyText(ore: number, language: Language): string {
+ return amountInputDisplay(amountInputOf(ore), language);
+}
+
+/** The viewer's basis points of an account default (`default_shares`), 5000 when the account lacks them. */
+export function viewerBpOf(accountBp: Readonly> | null, viewerId: string): number {
+ return accountBp?.[viewerId] ?? BP_TOTAL / 2;
+}
+
+/** A new draft: mode `default`, the ratio fields at the account's ratio, the exact field empty, full on the partner. */
+export function newSplitDraft(
+ people: SplitPeople,
+ accountBp: Readonly> | null,
+ language: Language,
+): SplitDraft {
+ return {
+ mode: 'default',
+ ratio: { userId: people.viewerId, text: percentText(viewerBpOf(accountBp, people.viewerId), language) },
+ exact: { userId: people.viewerId, text: '' },
+ fullOn: people.partnerId,
+ };
+}
+
+/** A draft from a saved split (edit mode): the stored mode with its values in the viewer's field. */
+export function splitDraftOf(
+ mode: SplitMode,
+ splits: readonly WireSplit[],
+ people: SplitPeople,
+ accountBp: Readonly> | null,
+ language: Language,
+): SplitDraft {
+ const base = newSplitDraft(people, accountBp, language);
+ const viewer = splits.find((s) => s.user_id === people.viewerId);
+ switch (mode) {
+ case 'default':
+ return base;
+ case 'ratio':
+ return { ...base, mode, ratio: { userId: people.viewerId, text: percentText(viewer?.share_bp ?? 0, language) } };
+ case 'exact':
+ return { ...base, mode, exact: { userId: people.viewerId, text: moneyText(viewer?.share_ore ?? 0, language) } };
+ case 'full': {
+ const bearer = splits.find((s) => s.share_bp === BP_TOTAL) ?? splits.find((s) => s.share_ore > 0);
+ return { ...base, mode, fullOn: bearer?.user_id ?? people.partnerId };
+ }
+ }
+}
+
+/**
+ * Picks a mode (design-spec §5.4): `ratio` starts at the account's ratio unless one was typed, `exact` keeps what was
+ * typed (empty at first), `full` starts on the person who did not pay.
+ */
+export function withMode(
+ draft: SplitDraft,
+ mode: SplitMode,
+ ctx: { payerId: string; accountBp: Readonly> | null } & SplitPeople,
+ language: Language,
+): SplitDraft {
+ if (draft.mode === mode) return draft;
+ switch (mode) {
+ case 'ratio':
+ return draft.ratio.text === ''
+ ? {
+ ...draft,
+ mode,
+ ratio: { userId: ctx.viewerId, text: percentText(viewerBpOf(ctx.accountBp, ctx.viewerId), language) },
+ }
+ : { ...draft, mode };
+ case 'full':
+ return { ...draft, mode, fullOn: otherOf(ctx, ctx.payerId) };
+ default:
+ return { ...draft, mode };
+ }
+}
+
+/** The basis points of the two users in modes `default`, `ratio` and `full`, or null when incomplete. */
+function bpOf(draft: SplitDraft, ctx: SplitContext): Record | null {
+ switch (draft.mode) {
+ case 'default':
+ return ctx.accountBp === null ? null : { ...ctx.accountBp };
+ case 'ratio': {
+ const typed = percentTextBp(draft.ratio.text);
+ if (typed === null) return null;
+ return { [draft.ratio.userId]: typed, [otherOf(ctx, draft.ratio.userId)]: BP_TOTAL - typed };
+ }
+ case 'full':
+ return { [draft.fullOn]: BP_TOTAL, [otherOf(ctx, draft.fullOn)]: 0 };
+ case 'exact':
+ return null;
+ }
+}
+
+/** Each user's share (SPEC §9.1) for the draft, or why there is none yet. */
+export function resolveSplit(draft: SplitDraft, ctx: SplitContext): SplitResolution {
+ const amount = ctx.amountOre;
+ if (amount === null) return { kind: 'incomplete' };
+ const users = [ctx.viewerId, ctx.partnerId];
+ if (draft.mode === 'exact') {
+ const typed = moneyTextOre(draft.exact.text);
+ if (typed === null) return { kind: 'incomplete' };
+ if (typed > amount) return { kind: 'error', error: 'exact_share_too_large' };
+ const ore = { [draft.exact.userId]: typed, [otherOf(ctx, draft.exact.userId)]: amount - typed };
+ const result = computeSplit(amount, ctx.payerId, users, { mode: 'exact', ore });
+ return 'error' in result ? { kind: 'error', error: result.error } : { kind: 'ok', shares: result.ok, bp: null };
+ }
+ const bp = bpOf(draft, ctx);
+ if (bp === null) return { kind: 'incomplete' };
+ const result =
+ draft.mode === 'full'
+ ? computeSplit(amount, ctx.payerId, users, { mode: 'full', bearer_user_id: draft.fullOn })
+ : computeSplit(amount, ctx.payerId, users, { mode: 'ratio', bp });
+ return 'error' in result ? { kind: 'error', error: result.error } : { kind: 'ok', shares: result.ok, bp };
+}
+
+/** The wire `splits` of a resolved split, sorted by user id (§14.4.7). */
+export function wireSplits(resolution: Extract): WireSplit[] {
+ return Object.keys(resolution.shares)
+ .sort()
+ .map((userId) => ({
+ user_id: userId,
+ share_ore: resolution.shares[userId] ?? 0,
+ share_bp: resolution.bp === null ? null : (resolution.bp[userId] ?? 0),
+ }));
+}
+
+/** The derived (not typed) value of an exact split: the amount minus the typed share, or null. */
+export function exactDerivedOre(draft: SplitDraft, amountOre: number | null): number | null {
+ const typed = moneyTextOre(draft.exact.text);
+ if (typed === null || amountOre === null || typed > amountOre) return null;
+ return amountOre - typed;
+}
+
+/** The derived percent of a ratio split: 100 minus the typed percent, in basis points, or null. */
+export function ratioDerivedBp(draft: SplitDraft): number | null {
+ const typed = percentTextBp(draft.ratio.text);
+ return typed === null ? null : BP_TOTAL - typed;
+}
diff --git a/web/src/features/expenses/ExpenseDetailPage.test.tsx b/web/src/features/expenses/ExpenseDetailPage.test.tsx
new file mode 100644
index 0000000..433013e
--- /dev/null
+++ b/web/src/features/expenses/ExpenseDetailPage.test.tsx
@@ -0,0 +1,175 @@
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it } from 'vitest';
+import type { PushedExpense } from '../../api/types';
+import { errorResponse, FakeServer } from '../../test/fakeServer';
+import { renderApp } from '../../test/render';
+import {
+ ANNA_ID,
+ audit,
+ expenseRow,
+ GROCERIES,
+ HOME,
+ householdServer,
+ HUSET,
+ JACOB_ID,
+ OTHER,
+ pushedMutations,
+ template,
+} from './testLedger';
+
+const SP = ' ';
+
+/** An element whose whole text (children included) is `text`, U+00A0 read as a space. */
+const whole = (text: string) => (_: string, element: Element | null) =>
+ element?.tagName === 'SPAN' && element.textContent.replaceAll('\u00A0', ' ') === text;
+
+const timelineItems = (name = 'Historik') => within(screen.getByRole('region', { name })).getAllByRole('listitem');
+const ID = '01929c5e-0000-7000-8000-00000000e301';
+const EXPENSE = expenseRow(ID, {
+ amount_ore: 124_760,
+ notes: 'Storhandling inför helgen',
+ created_by_user_id: ANNA_ID,
+});
+const CREATED = audit('d1', ID, 'create', ANNA_ID, '2026-10-16T12:02:00.000Z');
+const CHANGED = audit('d2', ID, 'update', JACOB_ID, '2026-10-16T17:40:00.000Z', [
+ { field: 'category_id', old: OTHER.id, new: GROCERIES.id },
+ { field: 'amount_ore', old: 114_760, new: 124_760 },
+]);
+
+function openDetail(server: FakeServer, id = ID) {
+ const result = renderApp(`/expenses/${id}`, { server });
+ return { ...result, user: userEvent.setup() };
+}
+
+describe('W-6 expense detail (S-8)', () => {
+ it('shows every field, the shares and mode, and the timeline oldest first', async () => {
+ openDetail(householdServer({ expenses: [EXPENSE], auditEntries: [CHANGED, CREATED] }));
+ expect(await screen.findByRole('heading', { level: 1, name: 'ICA Maxi Lindhagen' })).toBeInTheDocument();
+ const hero = screen.getByRole('region', { name: 'ICA Maxi Lindhagen' });
+ expect(hero).toHaveTextContent('Mat & hushåll · Vardag');
+ expect(hero).toHaveTextContent(`1${SP}247,60${SP}kr`);
+ expect(screen.getByText('fredag 16 oktober 2026')).toBeInTheDocument();
+ expect(screen.getByText('Storhandling inför helgen')).toBeInTheDocument();
+ expect(screen.getByText('Kontots standard 50/50')).toBeInTheDocument();
+ expect(screen.getByText(whole(`Du 623,80${SP}kr · Anna 623,80${SP}kr`))).toBeInTheDocument();
+ const timeline = screen.getByRole('region', { name: 'Historik' });
+ const items = within(timeline)
+ .getAllByRole('listitem')
+ .map((li) => li.textContent);
+ expect(items).toHaveLength(3);
+ expect(items[0]).toMatch(/^Anna skapade den här · 16 okt\. \d\d:02/);
+ expect(items[1]).toMatch(/^Du ändrade kategorin från Övrigt till Mat & hushåll · /);
+ expect(items[2]).toMatch(/^Du ändrade beloppet från 1.147,60.kr till 1.247,60.kr · /);
+ expect(screen.getByRole('link', { name: 'Ändra utgift' })).toHaveAttribute('href', `/app/expenses/${ID}/edit`);
+ expect(screen.getByRole('button', { name: 'Ta bort utgift' })).toBeInTheDocument();
+ });
+
+ it('names a generated expense’s template and shows the other split modes from the viewer’s side', async () => {
+ const hyra = template('01929c5e-0000-7000-8000-00000000f001', 'Hyra');
+ const generated = expenseRow(ID, {
+ description: 'Hyra september',
+ account_id: HUSET.id,
+ category_id: HOME.id,
+ amount_ore: 1_200_000,
+ split_mode: 'ratio',
+ splits: [
+ { user_id: JACOB_ID, share_ore: 840_000, share_bp: 7000 },
+ { user_id: ANNA_ID, share_ore: 360_000, share_bp: 3000 },
+ ],
+ recurring_template_id: hyra.id,
+ recurring_period: '2026-09',
+ });
+ openDetail(
+ householdServer({
+ expenses: [generated],
+ templates: [hyra],
+ auditEntries: [audit('r1', ID, 'create', JACOB_ID, '2026-09-25T04:00:00.000Z', [], 'recurring')],
+ }),
+ );
+ expect(await screen.findByText('Återkommande: Hyra')).toBeInTheDocument();
+ expect(screen.getByText('Egen fördelning 70/30')).toBeInTheDocument();
+ expect(timelineItems()[0]).toHaveTextContent(/^Skapad automatiskt \(återkommande\) · 25 sep\./);
+ });
+
+ it('says "Allt på Anna" for a full split borne by the partner', async () => {
+ const full = expenseRow(ID, {
+ split_mode: 'full',
+ splits: [
+ { user_id: JACOB_ID, share_ore: 0, share_bp: 0 },
+ { user_id: ANNA_ID, share_ore: 41_200, share_bp: 10_000 },
+ ],
+ });
+ openDetail(householdServer({ expenses: [full] }));
+ expect(await screen.findByText('Allt på Anna')).toBeInTheDocument();
+ });
+
+ it('shows the deleted state with who and when, without Edit and Delete', async () => {
+ const deleted = { ...EXPENSE, deleted_at: '2026-10-17T07:12:00.000Z', updated_by_user_id: ANNA_ID };
+ openDetail(
+ householdServer({
+ expenses: [deleted],
+ auditEntries: [
+ CREATED,
+ audit('d3', ID, 'delete', ANNA_ID, '2026-10-17T07:12:00.000Z', [
+ { field: 'deleted_at', old: null, new: '2026-10-17T07:12:00.000Z' },
+ ]),
+ ],
+ }),
+ );
+ expect(await screen.findByText(/^Anna tog bort den här utgiften · 17 okt\. \d\d:12$/)).toBeInTheDocument();
+ expect(screen.getByText('Den räknas inte längre i saldon eller statistik.')).toBeInTheDocument();
+ expect(screen.queryByRole('link', { name: 'Ändra utgift' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Ta bort utgift' })).not.toBeInTheDocument();
+ expect(timelineItems().at(-1)).toHaveTextContent(/^Anna tog bort den här · /);
+ });
+
+ it('deletes after confirming and goes back with "Utgiften togs bort"', async () => {
+ const server = householdServer({ expenses: [EXPENSE] });
+ const { user, router } = openDetail(server);
+ await user.click(await screen.findByRole('button', { name: 'Ta bort utgift' }));
+ const dialog = await screen.findByRole('alertdialog', { name: 'Ta bort utgiften?' });
+ await user.click(within(dialog).getByRole('button', { name: 'Ta bort' }));
+ expect(await screen.findByText('Utgiften togs bort')).toBeInTheDocument();
+ const push = server.calls('POST', '/sync')[0];
+ const entity = pushedMutations(push ?? ({ body: { mutations: [] } } as never))[0]?.entity as PushedExpense;
+ expect(entity).toMatchObject({ id: ID, amount_ore: 124_760, notes: EXPENSE.notes });
+ expect(entity.deleted_at).toBe(entity.updated_at);
+ await waitFor(() => {
+ expect(router.state.location.pathname).toMatch(/^\/app\/?$/);
+ });
+ });
+
+ it('shows a missing expense with "Uppdatera nu", which pulls again', async () => {
+ const server = householdServer();
+ const { user } = openDetail(server, '01929c5e-0000-7000-8000-00000000dead');
+ const heading = await screen.findByRole('heading', { name: 'Utgiften finns inte' });
+ const before = server.calls('GET', '/sync').length;
+ await user.click(within(heading.parentElement ?? document.body).getByRole('button', { name: 'Uppdatera nu' }));
+ await waitFor(() => {
+ expect(server.calls('GET', '/sync').length).toBeGreaterThan(before);
+ });
+ });
+
+ it('shows the loading and error states', async () => {
+ const pending = new FakeServer().on('GET', '/sync', () => new Promise(() => undefined));
+ const first = renderApp(`/expenses/${ID}`, { server: pending });
+ expect(await screen.findByText('Hämtar era utgifter…')).toBeInTheDocument();
+ first.unmount();
+ renderApp(`/expenses/${ID}`, {
+ server: new FakeServer().on('GET', '/sync', () => errorResponse('unavailable', 503)),
+ });
+ expect(await screen.findByText('Något gick fel på servern. Försök igen senare.')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Försök igen' })).toBeInTheDocument();
+ });
+
+ it('works in English', async () => {
+ renderApp(`/expenses/${ID}`, {
+ server: householdServer({ expenses: [EXPENSE], auditEntries: [CREATED] }),
+ language: 'en',
+ });
+ expect(await screen.findByText('Friday, 16 October 2026')).toBeInTheDocument();
+ expect(screen.getByText('Account default 50/50')).toBeInTheDocument();
+ expect(timelineItems('History')[0]).toHaveTextContent(/^Anna created this · 16 Oct /);
+ });
+});
diff --git a/web/src/features/expenses/ExpenseDetailPage.tsx b/web/src/features/expenses/ExpenseDetailPage.tsx
index ef15e5d..492f624 100644
--- a/web/src/features/expenses/ExpenseDetailPage.tsx
+++ b/web/src/features/expenses/ExpenseDetailPage.tsx
@@ -1,9 +1,251 @@
-import { PlaceholderPage } from '../../app/PlaceholderPage';
+import { useMemo, useState, type ReactNode } from 'react';
+import { useNavigate, useParams } from 'react-router';
+import { useLedger, useLedgerWriter, type Ledger } from '../../api/ledger';
+import type { Expense } from '../../api/types';
import { paths } from '../../app/routes';
+import { TopBar } from '../../app/TopBar';
+import { useSession } from '../../app/useSession';
+import { Avatar } from '../../components/Avatar';
+import { Button, ButtonLink } from '../../components/Button';
+import { CategoryIcon, isColorToken } from '../../components/CategoryIcon';
+import { categoryName, ConfirmDialog, SplitBar, splitLabel, type EditorPeople } from '../../components/editor';
+import { EmptyState } from '../../components/EmptyState';
+import { SegmentedGroup, SegmentedItem } from '../../components/LedgerRow';
+import { Money } from '../../components/Money';
+import { useToast } from '../../components/Toast';
+import { Icon } from '../../design/Icon';
+import type { OutlineIconName } from '../../design/icons.generated';
import { useI18n } from '../../i18n/useI18n';
+import { detailDate, listDateTime, parseDate } from '../../lib/dates';
+import { BP_TOTAL, formatMoney } from '../../lib/money';
+import { requestErrorMessage, rejectionMessage } from './messages';
+import { peopleOf, pushedExpense } from './model';
+import { ErrorState, LoadingState } from './PageStates';
+import { Timeline } from './Timeline';
+import { auditEntriesOf, localToday, timelineLines } from './timelineLines';
+import styles from './Expenses.module.css';
+import { usePushExpense } from './usePushExpense';
-/** S-8 Expense detail, /app/expenses/:id. */
+function FactRow({ icon, label, children }: { icon: OutlineIconName; label: string; children: ReactNode }) {
+ return (
+
+
+
+
+ {label}
+ {children}
+
+
+
+ );
+}
+
+/** S-8 Expense detail (W-6), /app/expenses/:id: hero, facts, the audit timeline; deleted and missing states. */
export default function ExpenseDetailPage() {
const { t } = useI18n();
- return ;
+ const { id = '' } = useParams();
+ const session = useSession();
+ const ledger = useLedger();
+ const { refresh } = useLedgerWriter();
+ const title = t('web_expense_detail_title');
+
+ if (ledger.isPending) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+ if (ledger.isError) {
+ return (
+ <>
+
+ void ledger.refetch()} />
+ >
+ );
+ }
+ const expense = ledger.data.expenses.get(id);
+ const people = peopleOf(ledger.data, session?.userId ?? '');
+ if (expense === undefined || people === null) {
+ return (
+ <>
+
+ } onClick={() => void refresh()}>
+ {t('web_update_now')}
+
+ }
+ />
+ >
+ );
+ }
+ return ;
+}
+
+function ExpenseDetail({ ledger, expense, people }: { ledger: Ledger; expense: Expense; people: EditorPeople }) {
+ const { t, language } = useI18n();
+ const toast = useToast();
+ const navigate = useNavigate();
+ const push = usePushExpense();
+ const [confirmDelete, setConfirmDelete] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const today = localToday();
+ const ctx = { ledger, viewerId: people.viewer.id, t, language, today };
+ const entries = useMemo(() => auditEntriesOf(ledger, 'expense', expense.id), [ledger, expense.id]);
+ const lines = timelineLines(entries, ctx);
+ const deleted = expense.deleted_at !== null;
+ const category = ledger.categories.get(expense.category_id);
+ const account = ledger.accounts.get(expense.account_id);
+ const payer = expense.payer_user_id === people.viewer.id ? people.viewer : people.partner;
+ const shares = Object.fromEntries(expense.splits.map((s) => [s.user_id, s.share_ore]));
+ const viewerSplit = expense.splits.find((s) => s.user_id === people.viewer.id);
+ const bearer = expense.splits.find((s) => s.share_bp === BP_TOTAL)?.user_id ?? null;
+ const template =
+ expense.recurring_template_id === null ? undefined : ledger.recurringTemplates.get(expense.recurring_template_id);
+ const colour = category !== undefined && isColorToken(category.color) ? category.color : 'grey';
+ const categoryLabel = category === undefined ? '' : categoryName(category, t);
+ const deletion = entries.findLast((e) => e.action === 'delete');
+ const deleterId = deletion?.actor_user_id ?? expense.updated_by_user_id;
+ const deleter = deleterId === people.viewer.id ? t('common_you') : (ledger.users.get(deleterId)?.display_name ?? '');
+ const deletedAt = deletion?.client_at ?? expense.deleted_at;
+ const viewerOre = shares[people.viewer.id] ?? 0;
+
+ const remove = async () => {
+ setBusy(true);
+ const now = new Date().toISOString();
+ const outcome = await push({ ...pushedExpense(expense), updated_at: now, deleted_at: now });
+ setBusy(false);
+ setConfirmDelete(false);
+ if (outcome.kind === 'accepted') {
+ toast({ message: t('snackbar_expense_deleted') });
+ const index = (window.history.state as { idx?: number } | null)?.idx ?? 0;
+ if (index > 0) void navigate(-1);
+ else void navigate(paths.home, { replace: true });
+ } else if (outcome.kind === 'rejected') {
+ toast({ message: rejectionMessage(outcome.reason, outcome.current, ledger, people.viewer.id, t) });
+ } else {
+ toast({ message: requestErrorMessage(outcome.error, t) });
+ }
+ };
+
+ return (
+ <>
+
+ }
+ aria-label={t('expense_editor_edit')}
+ >
+ {t('common_edit')}
+
+ }
+ aria-label={t('expense_editor_delete')}
+ onClick={() => {
+ setConfirmDelete(true);
+ }}
+ >
+ {t('common_delete')}
+
+
+ )
+ }
+ />
+
+ {deleted && deletedAt !== null ? (
+
+
+
+
{t('expense_detail_deleted_banner', deleter, listDateTime(new Date(deletedAt), today, language))}
+
{t('expense_detail_deleted_body')}
+
+
+ ) : null}
+
+
+
+ {category === undefined ? null : (
+
+ )}
+
+ {expense.description}
+
+ {[categoryLabel, account?.name ?? ''].filter((part) => part !== '').join(t('common_separator_dot'))}
+
+
+
+
+ {template === undefined ? null : (
+
+
+ {t('expense_detail_recurring', template.description)}
+
+ )}
+
+
+
+
+
+ {detailDate(parseDate(expense.expense_date), language)}
+
+
+
+ {payer.id === people.viewer.id ? t('common_you') : payer.displayName}
+
+
+ {splitLabel(expense.split_mode, viewerSplit?.share_bp ?? null, bearer, people, t, language)}
+
+
+ {t('common_you')} {formatMoney(viewerOre, language)}
+ {t('common_separator_dot')}
+ {people.partner.displayName}{' '}
+ {formatMoney(shares[people.partner.id] ?? 0, language)}
+
+
+ {expense.notes === '' ? null : (
+
+ {expense.notes}
+
+ )}
+
+
+
+
+ void remove()}
+ onDismiss={() => {
+ setConfirmDelete(false);
+ }}
+ />
+ >
+ );
}
diff --git a/web/src/features/expenses/ExpenseEditorPage.test.tsx b/web/src/features/expenses/ExpenseEditorPage.test.tsx
new file mode 100644
index 0000000..2a30f19
--- /dev/null
+++ b/web/src/features/expenses/ExpenseEditorPage.test.tsx
@@ -0,0 +1,456 @@
+import { act, fireEvent, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it } from 'vitest';
+import type { PushedExpense } from '../../api/types';
+import { todayIso } from '../../lib/dates';
+import { errorResponse, FakeServer, json } from '../../test/fakeServer';
+import { ANNA, JACOB, page } from '../../test/fixtures';
+import { renderApp } from '../../test/render';
+import {
+ ANNA_ID,
+ ENTERTAINMENT,
+ expenseRow,
+ GROCERIES,
+ HEALTH,
+ HOME,
+ householdServer,
+ HUSET,
+ JACOB_ID,
+ OTHER,
+ pushedMutations,
+ SHOPPING,
+ TRANSPORT,
+ VARDAG,
+} from './testLedger';
+
+const NBSP = '\u00A0';
+/** Testing Library normalises the page's U+00A0 to a space; matchers do the same. */
+const SP = ' ';
+const HOME_PATH = /^\/app\/?$/;
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
+
+/** The text of an element, exactly (money uses U+00A0). */
+const exactText = (text: string) => (_: string, element: Element | null) =>
+ element?.tagName === 'SPAN' && element.textContent === text;
+
+async function openEditor(path: string, server: FakeServer, language: 'sv' | 'en' = 'sv') {
+ const result = renderApp(path, { server, language });
+ await screen.findByLabelText(language === 'sv' ? 'Belopp' : 'Amount');
+ return { ...result, user: userEvent.setup() };
+}
+
+function lastPushed(server: FakeServer): PushedExpense {
+ const push = server.calls('POST', '/sync').at(-1);
+ if (push === undefined) throw new Error('nothing pushed');
+ const [mutation] = pushedMutations(push);
+ return mutation?.entity as PushedExpense;
+}
+
+function categoryRadios() {
+ return within(screen.getByRole('radiogroup', { name: 'Kategori' })).getAllByRole('radio');
+}
+
+describe('W-5 new expense (S-7)', () => {
+ it('opens with the defaults, suggests the category, previews the shares and saves one mutation', async () => {
+ const server = householdServer();
+ const { user, router } = await openEditor('/expenses/new', server);
+ expect(screen.getByRole('heading', { level: 1, name: 'Ny utgift' })).toBeInTheDocument();
+ const amount = screen.getByLabelText('Belopp');
+ expect(amount).toHaveFocus();
+ // AC-8.6: the oldest active account; archived accounts are not offered (AC-6.4).
+ const accountSelect = screen.getByLabelText('Konto');
+ expect(accountSelect).toHaveValue(VARDAG.id);
+ expect(
+ within(accountSelect)
+ .getAllByRole('option')
+ .map((o) => o.textContent),
+ ).toEqual(['Vardag', 'Huset']);
+ expect(screen.getByLabelText('Datum')).toHaveValue(todayIso());
+ expect(screen.getByText('Idag')).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Betalt av du. Dubbeltryck för att byta till Anna.' }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Delning/ })).toHaveTextContent('Kontots standard 50/50');
+ // CS-5 step 4 for an empty description: `other`.
+ expect(screen.getByRole('radio', { name: 'Övrigt' })).toHaveAttribute('aria-checked', 'true');
+ const save = screen.getByRole('button', { name: 'Spara' });
+ expect(save).toBeDisabled();
+
+ await user.type(amount, '412');
+ expect(screen.getByText(exactText(`Du 206,00${NBSP}kr · Anna 206,00${NBSP}kr`))).toBeInTheDocument();
+ expect(save).toBeDisabled();
+ await user.type(screen.getByLabelText('Beskrivning'), 'Bio Filmstaden');
+ // CS-6: the keyword `bio` (token) gives Nöje, with no marker (CS-7).
+ expect(screen.getByRole('radio', { name: 'Nöje' })).toHaveAttribute('aria-checked', 'true');
+ expect(save).toBeEnabled();
+
+ await user.click(save);
+ expect(await screen.findByText('Utgiften sparades')).toBeInTheDocument();
+ const entity = lastPushed(server);
+ expect(entity).toMatchObject({
+ account_id: VARDAG.id,
+ category_id: ENTERTAINMENT.id,
+ amount_ore: 41_200,
+ description: 'Bio Filmstaden',
+ notes: '',
+ expense_date: todayIso(),
+ payer_user_id: JACOB_ID,
+ split_mode: 'default',
+ splits: [
+ { user_id: JACOB_ID, share_ore: 20_600, share_bp: 5000 },
+ { user_id: ANNA_ID, share_ore: 20_600, share_bp: 5000 },
+ ],
+ deleted_at: null,
+ });
+ expect(entity.id).toMatch(UUID);
+ expect(entity.created_at).toBe(entity.updated_at);
+ expect(pushedMutations(server.calls('POST', '/sync')[0] ?? ({} as never))).toHaveLength(1);
+ await waitFor(() => {
+ expect(router.state.location.pathname).toMatch(HOME_PATH);
+ });
+ // AC-8.6 "on this phone": this browser remembers the account and its category.
+ expect(window.localStorage.getItem('kvitt.editor.lastAccount')).toBe(VARDAG.id);
+ expect(window.localStorage.getItem('kvitt.editor.lastCategory')).toBe(
+ JSON.stringify({ [VARDAG.id]: ENTERTAINMENT.id }),
+ );
+ });
+
+ it('takes ?account= from the opener and copies its default ratio (AC-8.8 on account change)', async () => {
+ const server = householdServer();
+ const { user } = await openEditor(`/expenses/new?account=${HUSET.id}`, server);
+ expect(screen.getByLabelText('Konto')).toHaveValue(HUSET.id);
+ expect(screen.getByRole('button', { name: /Delning/ })).toHaveTextContent('Kontots standard 60/40');
+ await user.selectOptions(screen.getByLabelText('Konto'), VARDAG.id);
+ expect(screen.getByRole('button', { name: /Delning/ })).toHaveTextContent('Kontots standard 50/50');
+ await user.click(screen.getByRole('button', { name: /Betalt av du/ }));
+ expect(
+ screen.getByRole('button', { name: 'Betalt av Anna. Dubbeltryck för att byta till du.' }),
+ ).toBeInTheDocument();
+ });
+
+ it('follows the history first, and a hand pick wins for the rest of the session (CS-2, CS-3)', async () => {
+ const server = householdServer({
+ expenses: [
+ expenseRow('01929c5e-0000-7000-8000-00000000e101', {
+ description: 'Willys Hornsberg',
+ category_id: SHOPPING.id,
+ }),
+ expenseRow('01929c5e-0000-7000-8000-00000000e102', {
+ description: 'willys hornsberg',
+ category_id: SHOPPING.id,
+ }),
+ ],
+ });
+ const { user } = await openEditor('/expenses/new', server);
+ const description = screen.getByLabelText('Beskrivning');
+ await user.type(description, 'Willys: Hornsberg');
+ expect(screen.getByRole('radio', { name: 'Shopping' })).toHaveAttribute('aria-checked', 'true');
+ await user.clear(description);
+ // CS-7: clearing returns to the step-4 default.
+ expect(screen.getByRole('radio', { name: 'Övrigt' })).toHaveAttribute('aria-checked', 'true');
+ await user.type(description, 'Willys');
+ // History, first token (CS-5 step 2) beats the keyword `willys` (groceries).
+ expect(screen.getByRole('radio', { name: 'Shopping' })).toHaveAttribute('aria-checked', 'true');
+ await user.click(screen.getByRole('radio', { name: 'Shopping' }));
+ await user.clear(description);
+ await user.type(description, 'Bio');
+ expect(screen.getByRole('radio', { name: 'Shopping' })).toHaveAttribute('aria-checked', 'true');
+ });
+
+ it('fills the quick row from the account’s most used categories and opens "Alla" (D-060, D-075)', async () => {
+ const used = (n: number, categoryId: string) =>
+ expenseRow(`01929c5e-0000-7000-8000-0000000e${String(n).padStart(4, '0')}`, { category_id: categoryId });
+ const server = householdServer({
+ expenses: [
+ used(1, HEALTH.id),
+ used(2, HEALTH.id),
+ used(3, HEALTH.id),
+ used(4, TRANSPORT.id),
+ used(5, TRANSPORT.id),
+ used(6, HOME.id),
+ { ...used(7, SHOPPING.id), account_id: HUSET.id },
+ ],
+ });
+ const { user } = await openEditor('/expenses/new', server);
+ // Three used in Vardag, filled from sort_order, the selected `other` in the fifth place.
+ expect(categoryRadios().map((r) => r.textContent)).toEqual([
+ 'Hälsa',
+ 'Transport',
+ 'Hem',
+ 'Mat & hushåll',
+ 'Övrigt',
+ ]);
+ await user.click(screen.getByRole('button', { name: 'Alla kategorier' }));
+ const grid = await screen.findByRole('dialog', { name: 'Välj kategori' });
+ // Every active category in sort_order; the archived Resor is not offered.
+ expect(
+ within(grid)
+ .getAllByRole('radio')
+ .map((r) => r.textContent),
+ ).toEqual(['Mat & hushåll', 'Restaurang & café', 'Hem', 'Transport', 'Nöje', 'Hälsa', 'Shopping', 'Övrigt']);
+ await user.click(within(grid).getByRole('radio', { name: 'Restaurang & café' }));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(categoryRadios().map((r) => r.textContent)).toEqual([
+ 'Hälsa',
+ 'Transport',
+ 'Hem',
+ 'Mat & hushåll',
+ 'Restaurang & café',
+ ]);
+ expect(screen.getByRole('radio', { name: 'Restaurang & café' })).toHaveAttribute('aria-checked', 'true');
+ });
+
+ it('computes every split mode live and blocks saving on a too large exact share (AC-8.7)', async () => {
+ const server = householdServer();
+ const { user } = await openEditor('/expenses/new', server);
+ await user.type(screen.getByLabelText('Belopp'), '1060');
+ await user.type(screen.getByLabelText('Beskrivning'), 'Ullared');
+ const splitRow = screen.getByRole('button', { name: /Delning/ });
+ await user.click(splitRow);
+ expect(splitRow).toHaveAttribute('aria-expanded', 'true');
+ const section = screen.getByRole('region', { name: 'Delning' });
+
+ await user.click(within(section).getByRole('radio', { name: 'Exakta belopp' }));
+ await user.type(within(section).getByLabelText('Du'), '1200');
+ const box = within(section).getByText(`140,00${SP}kr för mycket`).parentElement;
+ expect(box).toHaveAttribute('role', 'alert');
+ expect(box).toHaveTextContent(`Du 1${SP}200,00${SP}kr av 1${SP}060,00${SP}kr`);
+ expect(within(section).getByText('Andelen är större än beloppet')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Spara' })).toBeDisabled();
+ await user.clear(within(section).getByLabelText('Du'));
+ await user.type(within(section).getByLabelText('Du'), '400');
+ expect(within(section).getByLabelText('Anna')).toHaveValue('660,00');
+ expect(screen.getByRole('button', { name: 'Spara' })).toBeEnabled();
+
+ await user.click(within(section).getByRole('radio', { name: 'Allt på en person' }));
+ // Default bearer: the one who did not pay (design-spec §5.4).
+ expect(within(section).getByRole('radio', { name: /Anna/ })).toHaveAttribute('aria-checked', 'true');
+ expect(within(section).getByText(exactText(`Du 0,00${NBSP}kr · Anna 1${NBSP}060,00${NBSP}kr`))).toBeInTheDocument();
+ expect(splitRow).toHaveTextContent('Allt på Anna');
+
+ await user.click(within(section).getByRole('radio', { name: 'Egen fördelning' }));
+ const mine = within(section).getByLabelText('Du');
+ expect(mine).toHaveValue('50');
+ await user.clear(mine);
+ await user.type(mine, '60');
+ expect(within(section).getByLabelText('Anna')).toHaveValue('40');
+ expect(within(section).getByText(exactText(`Du 636,00${NBSP}kr · Anna 424,00${NBSP}kr`))).toBeInTheDocument();
+ expect(splitRow).toHaveTextContent('Egen fördelning 60/40');
+
+ await user.click(screen.getByRole('button', { name: 'Spara' }));
+ await screen.findByText('Utgiften sparades');
+ expect(lastPushed(server)).toMatchObject({
+ amount_ore: 106_000,
+ split_mode: 'ratio',
+ splits: [
+ { user_id: JACOB_ID, share_ore: 63_600, share_bp: 6000 },
+ { user_id: ANNA_ID, share_ore: 42_400, share_bp: 4000 },
+ ],
+ });
+ });
+
+ it('asks before discarding changes (title only, D-080) and closes at once without changes', async () => {
+ const { user, router } = await openEditor('/expenses/new', householdServer());
+ await user.click(screen.getByRole('button', { name: 'Avbryt' }));
+ await waitFor(() => {
+ expect(router.state.location.pathname).toMatch(HOME_PATH);
+ });
+
+ await act(async () => {
+ await router.navigate('/expenses/new');
+ });
+ await user.type(await screen.findByLabelText('Belopp'), '99');
+ await user.click(screen.getByRole('button', { name: 'Avbryt' }));
+ const dialog = await screen.findByRole('alertdialog', { name: 'Slänga utgiften?' });
+ expect(within(dialog).queryByRole('paragraph')).not.toBeInTheDocument();
+ expect(dialog.textContent).toBe('Slänga utgiften?Fortsätt redigeraSläng');
+ await user.click(within(dialog).getByRole('button', { name: 'Fortsätt redigera' }));
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
+ expect(screen.getByLabelText('Belopp')).toHaveValue('99');
+ // Esc follows the same rule (AC-21.9).
+ await user.keyboard('{Escape}');
+ const again = await screen.findByRole('alertdialog', { name: 'Slänga utgiften?' });
+ await user.click(within(again).getByRole('button', { name: 'Släng' }));
+ await waitFor(() => {
+ expect(router.state.location.pathname).toMatch(HOME_PATH);
+ });
+ });
+
+ it('validates inline and moves focus to the first field in error (§9.6)', async () => {
+ const server = householdServer();
+ const { user } = await openEditor('/expenses/new', server);
+ await user.type(screen.getByLabelText('Belopp'), '0');
+ expect(
+ screen.getByText(`Beloppet måste vara mellan 0,01${SP}kr och 10${SP}000${SP}000,00${SP}kr`),
+ ).toBeInTheDocument();
+ await user.clear(screen.getByLabelText('Belopp'));
+ await user.type(screen.getByLabelText('Belopp'), '12');
+ const description = screen.getByLabelText('Beskrivning');
+ await user.type(description, 'x'.repeat(101));
+ fireEvent.change(screen.getByLabelText('Datum'), { target: { value: '1999-12-31' } });
+ await user.click(screen.getByRole('button', { name: 'Spara' }));
+ expect(screen.getByText('Högst 100 tecken')).toBeInTheDocument();
+ expect(screen.getByText('Datumet ligger för långt fram eller bak')).toBeInTheDocument();
+ expect(description).toHaveFocus();
+ expect(server.calls('POST', '/sync')).toHaveLength(0);
+ // Enter in the description tries to save the same way.
+ await user.clear(description);
+ await user.type(description, 'Kaffe{Enter}');
+ expect(screen.getByText('Datumet ligger för långt fram eller bak')).toBeInTheDocument();
+ expect(screen.getByLabelText('Datum')).toHaveFocus();
+ });
+
+ it('keeps the form and says so when the server cannot be reached (§15.5)', async () => {
+ const server = householdServer().on('POST', '/sync', () => {
+ throw new TypeError('Failed to fetch');
+ });
+ const { user } = await openEditor('/expenses/new', server);
+ await user.type(screen.getByLabelText('Belopp'), '25');
+ await user.type(screen.getByLabelText('Beskrivning'), 'Kaffe');
+ await user.click(screen.getByRole('button', { name: 'Spara' }));
+ expect(await screen.findByText('Kunde inte nå servern')).toBeInTheDocument();
+ expect(screen.getByLabelText('Beskrivning')).toHaveValue('Kaffe');
+ expect(screen.getByRole('heading', { level: 1, name: 'Ny utgift' })).toBeInTheDocument();
+ });
+
+ it('shows the loading, error and no-account states (SPEC §15.6)', async () => {
+ const pending = new FakeServer().on('GET', '/sync', () => new Promise(() => undefined));
+ const first = renderApp('/expenses/new', { server: pending });
+ expect(await screen.findByText('Hämtar era utgifter…')).toBeInTheDocument();
+ first.unmount();
+
+ let fail = true;
+ const flaky = householdServer().on('GET', '/sync', () =>
+ fail
+ ? errorResponse('internal', 500)
+ : json(page(0, { users: [JACOB, ANNA], accounts: [VARDAG], categories: [OTHER] })),
+ );
+ const second = renderApp('/expenses/new', { server: flaky });
+ expect(await screen.findByText('Något gick fel på servern. Försök igen senare.')).toBeInTheDocument();
+ fail = false;
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Försök igen' }));
+ expect(await screen.findByLabelText('Belopp')).toBeInTheDocument();
+ second.unmount();
+
+ const empty = new FakeServer().on('GET', '/sync', () =>
+ json(page(0, { users: [JACOB, ANNA], categories: [OTHER] })),
+ );
+ renderApp('/expenses/new', { server: empty });
+ expect(await screen.findByRole('heading', { name: 'Skapa ert första konto' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Nytt konto' })).toHaveAttribute('href', '/app/accounts/new');
+ });
+
+ it('works in English', async () => {
+ await openEditor('/expenses/new', householdServer(), 'en');
+ expect(screen.getByRole('heading', { level: 1, name: 'New expense' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Split/ })).toHaveTextContent('Account default 50/50');
+ expect(screen.getByRole('button', { name: 'Paid by you. Double-tap to switch to Anna.' })).toBeInTheDocument();
+ });
+});
+
+describe('W-5 edit mode', () => {
+ const EXISTING = expenseRow('01929c5e-0000-7000-8000-00000000e201', {
+ amount_ore: 124_760,
+ description: 'ICA Maxi Lindhagen',
+ notes: 'Storhandling',
+ payer_user_id: ANNA_ID,
+ split_mode: 'full',
+ splits: [
+ { user_id: JACOB_ID, share_ore: 0, share_bp: 0 },
+ { user_id: ANNA_ID, share_ore: 124_760, share_bp: 10_000 },
+ ],
+ });
+
+ it('loads the saved values without focus, the split section open for a non-default mode', async () => {
+ await openEditor(`/expenses/${EXISTING.id}/edit`, householdServer({ expenses: [EXISTING] }));
+ expect(screen.getByRole('heading', { level: 1, name: 'Ändra utgift' })).toBeInTheDocument();
+ const amount = screen.getByLabelText('Belopp');
+ expect(amount).toHaveValue(`1${NBSP}247,60`);
+ expect(amount).not.toHaveFocus();
+ expect(screen.getByLabelText('Beskrivning')).toHaveValue('ICA Maxi Lindhagen');
+ expect(screen.getByLabelText('Anteckningar')).toHaveValue('Storhandling');
+ expect(screen.getByRole('radio', { name: 'Mat & hushåll' })).toHaveAttribute('aria-checked', 'true');
+ expect(screen.getByRole('button', { name: /Betalt av Anna/ })).toBeInTheDocument();
+ const section = screen.getByRole('region', { name: 'Delning' });
+ expect(within(section).getByRole('radio', { name: 'Allt på en person' })).toHaveAttribute('aria-checked', 'true');
+ expect(screen.getByRole('button', { name: 'Ta bort utgift' })).toBeInTheDocument();
+ });
+
+ it('never suggests in edit mode (CS-1) and pushes the edit with the stored created_at', async () => {
+ const server = householdServer({ expenses: [EXISTING] });
+ const { user } = await openEditor(`/expenses/${EXISTING.id}/edit`, server);
+ const description = screen.getByLabelText('Beskrivning');
+ await user.clear(description);
+ await user.type(description, 'Bio');
+ expect(screen.getByRole('radio', { name: 'Mat & hushåll' })).toHaveAttribute('aria-checked', 'true');
+ await user.click(screen.getByRole('button', { name: 'Spara' }));
+ await screen.findByText('Utgiften sparades');
+ const entity = lastPushed(server);
+ expect(entity).toMatchObject({
+ id: EXISTING.id,
+ description: 'Bio',
+ created_at: EXISTING.created_at,
+ split_mode: 'full',
+ });
+ expect(entity.updated_at > EXISTING.updated_at).toBe(true);
+ });
+
+ it('shows a stale rejection with S-16’s copy, reloads the row and keeps the form', async () => {
+ const current = { ...EXISTING, description: 'ICA Kvantum', updated_by_user_id: ANNA_ID, server_version: 999 };
+ const server = householdServer({ expenses: [EXISTING] }, (mutation) => ({
+ mutation_id: mutation.mutation_id,
+ entity_type: 'expense',
+ entity_id: EXISTING.id,
+ status: 'rejected',
+ reason: 'stale',
+ message: 'a newer version exists',
+ current,
+ }));
+ const { user, queryClient } = await openEditor(`/expenses/${EXISTING.id}/edit`, server);
+ await user.type(screen.getByLabelText('Beskrivning'), ' 2');
+ await user.click(screen.getByRole('button', { name: 'Spara' }));
+ const alert = await screen.findByText('Din ändring ersattes av en nyare ändring från Anna');
+ expect(alert).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Visa nuvarande version' })).toHaveAttribute(
+ 'href',
+ `/app/expenses/${EXISTING.id}`,
+ );
+ expect(screen.getByLabelText('Beskrivning')).toHaveValue('ICA Maxi Lindhagen 2');
+ const ledger = queryClient.getQueryData<{ expenses: Map }>(['ledger']);
+ expect(ledger?.expenses.get(EXISTING.id)?.description).toBe('ICA Kvantum');
+ });
+
+ it('deletes after confirming, as a tombstone of the stored row', async () => {
+ const server = householdServer({ expenses: [EXISTING] });
+ const { user, router } = await openEditor(`/expenses/${EXISTING.id}/edit`, server);
+ await user.click(screen.getByRole('button', { name: 'Ta bort utgift' }));
+ const dialog = await screen.findByRole('alertdialog', { name: 'Ta bort utgiften?' });
+ expect(dialog).toHaveTextContent('Den försvinner för er båda.');
+ await user.click(within(dialog).getByRole('button', { name: 'Ta bort' }));
+ expect(await screen.findByText('Utgiften togs bort')).toBeInTheDocument();
+ const entity = lastPushed(server);
+ expect(entity).toMatchObject({ id: EXISTING.id, description: EXISTING.description, amount_ore: 124_760 });
+ expect(entity.deleted_at).not.toBeNull();
+ expect(entity.deleted_at).toBe(entity.updated_at);
+ await waitFor(() => {
+ expect(router.state.location.pathname).toMatch(HOME_PATH);
+ });
+ });
+
+ it('says so when the expense does not exist', async () => {
+ renderApp('/expenses/01929c5e-0000-7000-8000-00000000dead/edit', { server: householdServer() });
+ expect(await screen.findByText('Det här finns inte längre')).toBeInTheDocument();
+ });
+});
+
+describe('category fallback', () => {
+ it('uses the category last used in the account in this browser (AC-8.6)', async () => {
+ window.localStorage.setItem('kvitt.editor.lastCategory', JSON.stringify({ [VARDAG.id]: GROCERIES.id }));
+ window.localStorage.setItem('kvitt.editor.lastAccount', HUSET.id);
+ await openEditor('/expenses/new', householdServer());
+ expect(screen.getByLabelText('Konto')).toHaveValue(HUSET.id);
+ expect(screen.getByRole('radio', { name: 'Övrigt' })).toHaveAttribute('aria-checked', 'true');
+ await userEvent.setup().selectOptions(screen.getByLabelText('Konto'), VARDAG.id);
+ expect(screen.getByRole('radio', { name: 'Mat & hushåll' })).toHaveAttribute('aria-checked', 'true');
+ });
+});
diff --git a/web/src/features/expenses/ExpenseEditorPage.tsx b/web/src/features/expenses/ExpenseEditorPage.tsx
index fbd24a6..750687e 100644
--- a/web/src/features/expenses/ExpenseEditorPage.tsx
+++ b/web/src/features/expenses/ExpenseEditorPage.tsx
@@ -1,15 +1,662 @@
-import { useParams } from 'react-router';
-import { PlaceholderPage } from '../../app/PlaceholderPage';
+import { useCallback, useId, useMemo, useRef, useState } from 'react';
+import { useBlocker, useNavigate, useParams, useSearchParams } from 'react-router';
+import { live, useLedger, type Ledger } from '../../api/ledger';
+import type { Expense, PushedExpense } from '../../api/types';
import { paths } from '../../app/routes';
+import { useShortcut } from '../../app/shortcuts';
+import { TopBar } from '../../app/TopBar';
+import { useSession } from '../../app/useSession';
+import { AmountField } from '../../components/AmountField';
+import { Button, ButtonLink } from '../../components/Button';
+import {
+ AccountDot,
+ accountColorIndex,
+ CategoryPicker,
+ codePoints,
+ ConfirmDialog,
+ DateControl,
+ draftSplitLabel,
+ frequentCategoryIds,
+ newSplitDraft,
+ NOTES_MAX,
+ NotesField,
+ OptionRow,
+ OptionRows,
+ optionRowControlClass,
+ optionRowHintClass,
+ PayerRow,
+ quickRowCategories,
+ resolveSplit,
+ SelectControl,
+ splitDraftOf,
+ SplitPreview,
+ SplitSection,
+ wireSplits,
+ type EditorPeople,
+ type SplitDraft,
+} from '../../components/editor';
+import { EmptyState } from '../../components/EmptyState';
+import { TextField } from '../../components/TextField';
+import { useToast } from '../../components/Toast';
+import { Icon } from '../../design/Icon';
import { useI18n } from '../../i18n/useI18n';
+import { todayIso } from '../../lib/dates';
+import { uuidv7, timestamp } from '../../lib/ids';
+import {
+ AMOUNT_MAX_ORE,
+ AMOUNT_MIN_ORE,
+ amountInputDisplay,
+ amountInputOf,
+ amountInRange,
+ formatMoney,
+} from '../../lib/money';
+import { requestErrorMessage, rejectionMessage } from './messages';
+import {
+ accountBpOf,
+ activeAccountsOf,
+ activeCategories,
+ DATE_MIN,
+ dateInRange,
+ dateMax,
+ defaultAccountId,
+ DESCRIPTION_MAX,
+ peopleOf,
+ pushedExpense,
+ rememberEditorChoice,
+ yesterdayOf,
+} from './model';
+import { ErrorState, LoadingState } from './PageStates';
+import { suggestFor, suggestionHistory } from './suggest/forLedger';
+import styles from './Expenses.module.css';
+import { usePushExpense, type PushOutcome } from './usePushExpense';
-/** S-7 Expense editor: /app/expenses/new and /app/expenses/:id/edit. */
+interface Form {
+ amountText: string;
+ amountOre: number | null;
+ description: string;
+ categoryId: string | null;
+ /** CS-2: set by any pick in the category picker; the suggestion then never changes the category. */
+ pickedByHand: boolean;
+ accountId: string;
+ date: string;
+ payerId: string;
+ split: SplitDraft;
+ notes: string;
+}
+
+type FieldName = 'amount' | 'description' | 'category' | 'date' | 'notes';
+type Errors = Partial>;
+
+/** A banner over the form after a failed write; `currentId` links to the server's version (stale, deleted). */
+interface Problem {
+ message: string;
+ currentId: string | null;
+}
+
+/** The comparable part of a form, for the discard rule (S-7: close with changes asks first). */
+function fingerprint(form: Form): string {
+ return JSON.stringify([
+ form.amountText,
+ form.description,
+ form.categoryId,
+ form.accountId,
+ form.date,
+ form.payerId,
+ form.split,
+ form.notes,
+ ]);
+}
+
+/** Back to where the editor was opened from, or to `fallback` when it was the first page of the visit. */
+function useLeave(fallback: string) {
+ const navigate = useNavigate();
+ return useCallback(
+ (steps = 1, otherwise: string = fallback) => {
+ const index = (window.history.state as { idx?: number } | null)?.idx ?? 0;
+ if (index >= steps) void navigate(-steps);
+ else void navigate(otherwise, { replace: true });
+ },
+ [navigate, fallback],
+ );
+}
+
+/** S-7 Expense editor (W-5): /app/expenses/new (optionally `?account=`) and /app/expenses/:id/edit. */
export default function ExpenseEditorPage() {
const { t } = useI18n();
const { id } = useParams();
- return id === undefined ? (
-
- ) : (
-
+ const [searchParams] = useSearchParams();
+ const session = useSession();
+ const ledger = useLedger();
+ const title = id === undefined ? t('expense_editor_new') : t('expense_editor_edit');
+ const back = id === undefined ? paths.home : paths.expense(id);
+
+ if (ledger.isPending) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+ if (ledger.isError) {
+ return (
+ <>
+
+ void ledger.refetch()} />
+ >
+ );
+ }
+ const viewerId = session?.userId ?? '';
+ const people = peopleOf(ledger.data, viewerId);
+ if (people === null) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+ const existing = id === undefined ? undefined : ledger.data.expenses.get(id);
+ if (id !== undefined && (existing === undefined || existing.deleted_at !== null)) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+ if (existing === undefined && activeAccountsOf(ledger.data).length === 0) {
+ return (
+ <>
+
+ }>
+ {t('account_editor_new')}
+
+ }
+ />
+ >
+ );
+ }
+ return (
+
+ );
+}
+
+function initialForm(
+ ledger: Ledger,
+ people: EditorPeople,
+ existing: Expense | undefined,
+ requestedAccountId: string | null,
+ language: 'sv' | 'en',
+): Form {
+ const ids = { viewerId: people.viewer.id, partnerId: people.partner.id };
+ if (existing !== undefined) {
+ const account = ledger.accounts.get(existing.account_id);
+ const amount = amountInputOf(existing.amount_ore);
+ return {
+ amountText: amountInputDisplay(amount, language),
+ amountOre: existing.amount_ore,
+ description: existing.description,
+ categoryId: existing.category_id,
+ pickedByHand: true,
+ accountId: existing.account_id,
+ date: existing.expense_date,
+ payerId: existing.payer_user_id,
+ split: splitDraftOf(existing.split_mode, existing.splits, ids, accountBpOf(account), language),
+ notes: existing.notes,
+ };
+ }
+ const accountId = defaultAccountId(activeAccountsOf(ledger), requestedAccountId) ?? '';
+ return {
+ amountText: '',
+ amountOre: null,
+ description: '',
+ categoryId: suggestFor(ledger, [], '', accountId),
+ pickedByHand: false,
+ accountId,
+ date: todayIso(),
+ payerId: people.viewer.id,
+ split: newSplitDraft(ids, accountBpOf(ledger.accounts.get(accountId)), language),
+ notes: '',
+ };
+}
+
+function ExpenseEditor({
+ ledger,
+ people,
+ existing,
+ requestedAccountId,
+ title,
+}: {
+ ledger: Ledger;
+ people: EditorPeople;
+ existing: Expense | undefined;
+ requestedAccountId: string | null;
+ title: string;
+}) {
+ const { t, tp, language } = useI18n();
+ const toast = useToast();
+ const push = usePushExpense();
+ const isNew = existing === undefined;
+ const leave = useLeave(isNew ? paths.home : paths.expense(existing.id));
+ const [initial] = useState(() => initialForm(ledger, people, existing, requestedAccountId, language));
+ const [form, setForm] = useState