2ae65a5a9f
Build Kvitt images / build (push) Failing after 1h14m5s
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
124 lines
4.1 KiB
TypeScript
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>
|
|
);
|
|
}
|