Files
kvitt/web/src/components/LedgerRow.tsx
T
jacobh2 2ae65a5a9f
Build Kvitt images / build (push) Failing after 1h14m5s
W0 web foundation: Vite + React SPA under /app/, sync-pull ledger, auth, tokens, shared components
web/ is the browser client's scaffold: Vite 8, React 19, TypeScript 5.9 strict, React Router 7 (basename /app, every
fixed route with a lazy placeholder page), TanStack Query 5 (refetch on focus and every 60 s), openapi-typescript
types generated from contracts/openapi.yaml with openapi-fetch. Reads go through GET /sync (no REST reads exist for
the ledger, SPEC §7): useLedger() pulls from cursor 0 and then incrementally, restarting on a new epoch or
cursor_ahead. Login pairs the browser as "Web · <browser>" with an invite code (E-3); every call sends the SPEC
§14.3.1 headers, a 401 signs out. kvitt-domain's split, balances, allocation and settlement rules are ported to
web/src/lib and pass every case of contracts/test-vectors. Design tokens from design-spec §2 in tokens.css
(light-dark()), Material Symbols Rounded (Apache-2.0) as generated inline SVG, sv/en catalogs generated from
docs/ux/strings.csv plus web-strings.csv. Gate: cd web && npm run check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-21 21:03:25 +02:00

124 lines
4.1 KiB
TypeScript

import type { ReactNode } from 'react';
import { Link } from 'react-router';
import { Icon } from '../design/Icon';
import { useI18n } from '../i18n/useI18n';
import type { ExpenseEffect } from '../lib/effect';
import { formatMoney } from '../lib/money';
import styles from './LedgerRow.module.css';
import { Money } from './Money';
interface Common {
/** Route to open (rendered as a link), or a click handler (rendered as a button); neither = static row. */
to?: string;
onClick?: () => void;
/** Date text in the row meta ("16 okt.", SPEC §9.8). */
date: string;
}
export type LedgerRowProps = Common &
(
| {
kind: 'expense';
/** The CategoryIcon tile (size 40, solid). */
leading: ReactNode;
description: string;
amountOre: number;
effect: ExpenseEffect;
}
| {
kind: 'settlement';
/** "Anna betalade dig 800,00 kr" (`ledger_settlement_*`), already formatted. */
title: string;
accountCount: number;
}
);
function EffectText({ effect }: { effect: ExpenseEffect }) {
const { t, language } = useI18n();
switch (effect.kind) {
case 'lent':
return <span className={styles.owed}>{t('effect_lent', formatMoney(effect.ore, language))}</span>;
case 'borrowed':
return <span className={styles.owing}>{t('effect_borrowed', formatMoney(effect.ore, language))}</span>;
case 'not_involved':
return <span className={styles.muted}>{t('effect_not_involved')}</span>;
case 'paid_for_yourself':
return <span className={styles.muted}>{t('effect_paid_for_yourself')}</span>;
}
}
/**
* design-spec §3.5 ledger rows (S-5, S-12, search): an expense (category tile, description, effect line, amount, date)
* or a settlement (handshake tile on the settlement tint, title, "Betalning", date). Items of a SegmentedGroup.
*/
export function LedgerRow(props: LedgerRowProps) {
const { t, tp } = useI18n();
const content =
props.kind === 'expense' ? (
<>
{props.leading}
<span className={styles.centre}>
<span className={styles.title}>{props.description}</span>
<span className={styles.detail}>
<EffectText effect={props.effect} />
</span>
</span>
<span className={styles.end}>
<Money ore={props.amountOre} amountStyle="small" />
<span className={styles.meta}>{props.date}</span>
</span>
</>
) : (
<>
<span className={styles.settlementTile} aria-hidden="true">
<Icon name="handshake" filled size={22} />
</span>
<span className={styles.centre}>
<span className={styles.settlementTitle}>
{props.title}
{props.accountCount > 1
? `${t('common_separator_dot')}${tp('ledger_settlement_accounts', props.accountCount)}`
: ''}
</span>
<span className={`${styles.detail} ${styles.muted}`}>{t('ledger_settlement_label')}</span>
</span>
<span className={styles.end}>
<span className={styles.meta}>{props.date}</span>
</span>
</>
);
const className = [styles.row, props.kind === 'settlement' ? styles.settlement : undefined].filter(Boolean).join(' ');
if (props.to !== undefined) {
return (
<Link to={props.to} className={`${className} ${styles.interactive}`}>
{content}
</Link>
);
}
if (props.onClick !== undefined) {
return (
<button type="button" onClick={props.onClick} className={`${className} ${styles.interactive}`}>
{content}
</button>
);
}
return <div className={className}>{content}</div>;
}
/** design-spec §3.5 `SegmentedGroup`: items 3 px apart, outer corners 24, inner 8, one card shadow. */
export function SegmentedGroup({ children, label }: { children: ReactNode; label?: string }) {
return (
<div className={styles.group} role="list" aria-label={label}>
{children}
</div>
);
}
export function SegmentedItem({ children }: { children: ReactNode }) {
return (
<div role="listitem" className={styles.item}>
{children}
</div>
);
}