Finanzen: Vorschau, Steuer und Abos ohne Karten, Container-Query auf Wrapper

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-06 14:38:00 +00:00
1 parent 6d015df6fa
commit bd1eccb4e3
4 files changed
+168 -175

No files matched your search

+34 -39
View File
@@ -1,6 +1,6 @@
import { FaTriangleExclamation } from 'react-icons/fa6' import { Badge, Card, EmptyState, Stat } from '../ui'
import { Badge, Card, EmptyState } from '../ui'
import { aboView } from '../../lib/financeView' import { aboView } from '../../lib/financeView'
import { isNegText } from '../../lib/financeUi'
/** /**
* Block 5: „Abos“. Einnahmen pro Monat (aktiv und Zahlung fehlgeschlagen), zahlende Kunden, gefährdete Einnahmen * Block 5: „Abos“. Einnahmen pro Monat (aktiv und Zahlung fehlgeschlagen), zahlende Kunden, gefährdete Einnahmen
@@ -12,50 +12,45 @@ export default function AboCard({ abos }) {
if (v.error) { if (v.error) {
return ( return (
<Card title="Abos" className="fin-abo-card"> <Card title="Abos">
<p className="fin-hint fin-hint-warn" role="alert"> <div className="fin-body">
<FaTriangleExclamation aria-hidden="true" /> {v.error} <p className="msg warn" role="alert">
</p> {v.error}
</p>
</div>
</Card> </Card>
) )
} }
return ( return (
<Card title="Abos" className="fin-abo-card" bodyStyle={{ padding: 0 }}> <Card title="Abos">
<div className="fin-stats"> <div className="fin-body">
<span className="fin-amount fin-stat-value">{v.monthlyText}</span> <div className="stats fin-abo-stats">
<span className="fin-stat-label">Abo-Einnahmen pro Monat</span> <Stat label="Abo-Einnahmen pro Monat" value={v.monthlyText} tone={isNegText(v.monthlyText) ? 'neg' : ''} />
<span className="fin-amount fin-stat-value">{v.payingText}</span> <Stat label="zahlende Kunden" value={v.payingText} />
<span className="fin-stat-label">zahlende Kunden</span> {v.atRiskText && <Stat label="gefährdet" value={v.atRiskText} tone="neg" />}
{v.atRiskText && ( </div>
<>
<span className="fin-amount fin-stat-value fin-flag">{v.atRiskText}</span> {v.rows.length === 0 ? (
<span className="fin-stat-label fin-flag"> <EmptyState title="Noch keine Abos." />
<FaTriangleExclamation aria-hidden="true" /> gefährdet ) : (
</span> <ul className="fin-abos">
</> {v.rows.map((row) => (
<li key={row.key} className="fin-abo">
<div className="fin-abo-main">
<span className="fin-abo-customer">{row.customer}</span>
<span className="fin-abo-product muted"> · {row.product}</span>
</div>
<div className={`fin-amount fin-abo-amount${isNegText(row.amountText) ? ' fin-neg' : ''}`}>{row.amountText}</div>
<div className="fin-abo-meta">
<Badge tone={row.tone}>{row.statusLabel}</Badge>
<span className="fin-abo-next muted">nächste Abbuchung: {row.nextText}</span>
</div>
</li>
))}
</ul>
)} )}
</div> </div>
{v.rows.length === 0 ? (
<EmptyState title="Noch keine Abos." />
) : (
<ul className="fin-abos">
{v.rows.map((row) => (
<li key={row.key} className="fin-abo">
<div className="fin-abo-main">
<span className="fin-abo-customer">{row.customer}</span>
<span className="fin-abo-product muted"> · {row.product}</span>
</div>
<div className="fin-amount fin-abo-amount">{row.amountText}</div>
<div className="fin-abo-meta">
<Badge tone={row.tone}>{row.statusLabel}</Badge>
<span className="fin-abo-next muted">nächste Abbuchung: {row.nextText}</span>
</div>
</li>
))}
</ul>
)}
</Card> </Card>
) )
} }
+69 -62
View File
@@ -1,12 +1,12 @@
import { FaTriangleExclamation } from 'react-icons/fa6'
import { Badge, Card, EmptyState } from '../ui' import { Badge, Card, EmptyState } from '../ui'
import { forecastNotes, forecastRows } from '../../lib/financeView' import { forecastNotes, forecastRows } from '../../lib/financeView'
import { isNegText } from '../../lib/financeUi'
/** /**
* Block 3: „Vorschau 30 Tage“. Je Posten Datum, Bezeichnung, „sicher“/„erwartet“ (als Text), Betrag mit Vorzeichen * Block 3: „Vorschau 30 Tage“. Je Posten Datum, Bezeichnung, „sicher“/„erwartet“ (als Text), Betrag mit Vorzeichen
* und der laufende Saldo beider Linien ab dem Kontostand. Ohne Kontostand entfallen die Saldo-Spalten (mit Hinweis). * und der laufende Saldo beider Linien ab dem Kontostand. Ohne Kontostand entfallen die Saldo-Spalten (mit Hinweis).
* Darunter die Annahme des Servers und Hinweise, wenn Stripe oder die Abos fehlen. Schmal (Karte bis 560 px) * Darunter die Annahme des Servers und Hinweise, wenn Stripe oder die Abos fehlen. Schmal (Wrapper .fin-body bis
* wird jede Zeile dreizeilig: Datum, Sicherheit und Betrag / Posten / beide Salden; ohne Salden sind es zwei Zeilen. * 560 px) wird jede Zeile dreizeilig: Datum, Sicherheit und Betrag / Posten / beide Salden; ohne Salden sind es zwei Zeilen.
*/ */
export default function ForecastTable({ vorschau, kontostandCents, stripeDown = false, abosFehler = false }) { export default function ForecastTable({ vorschau, kontostandCents, stripeDown = false, abosFehler = false }) {
const v = vorschau && typeof vorschau === 'object' ? vorschau : {} const v = vorschau && typeof vorschau === 'object' ? vorschau : {}
@@ -15,70 +15,77 @@ export default function ForecastTable({ vorschau, kontostandCents, stripeDown =
const assumption = typeof v.annahme === 'string' ? v.annahme.trim() : '' const assumption = typeof v.annahme === 'string' ? v.annahme.trim() : ''
return ( return (
<Card title="Vorschau 30 Tage" className="fin-forecast-card" bodyStyle={{ padding: 0 }}> <Card title="Vorschau 30 Tage">
{rows.length === 0 ? ( <div className="fin-body">
<EmptyState title="Keine Zahlungen in den nächsten 30 Tagen bekannt." /> {rows.length === 0 ? (
) : ( <EmptyState title="Keine Zahlungen in den nächsten 30 Tagen bekannt." />
<div className="fin-table-wrap"> ) : (
<table className={`ui-table fin-forecast${hasBalance ? '' : ' fin-forecast-nobal'}`} role="table"> <div className="fin-table-wrap">
<caption className="fin-sr">Erwartete Zahlungen der nächsten 30 Tage</caption> <table className={`table fin-forecast${hasBalance ? '' : ' fin-forecast-nobal'}`} role="table">
<thead role="rowgroup"> <caption className="fin-sr">Erwartete Zahlungen der nächsten 30 Tage</caption>
<tr role="row"> <thead role="rowgroup">
<th role="columnheader" scope="col">Datum</th> <tr role="row">
<th role="columnheader" scope="col">Posten</th> <th role="columnheader" scope="col">Datum</th>
<th role="columnheader" scope="col">Sicherheit</th> <th role="columnheader" scope="col">Posten</th>
<th role="columnheader" scope="col" className="fin-amount">Betrag</th> <th role="columnheader" scope="col">Sicherheit</th>
{hasBalance && ( <th role="columnheader" scope="col" className="fin-amount">Betrag</th>
<>
<th role="columnheader" scope="col" className="fin-amount">Saldo sicher</th>
<th role="columnheader" scope="col" className="fin-amount">Saldo erwartet</th>
</>
)}
</tr>
</thead>
<tbody role="rowgroup">
{rows.map((row) => (
<tr key={row.key} role="row">
<td role="cell" className="fin-forecast-date">{row.dateText}</td>
<td role="cell" className="fin-forecast-label">{row.label}</td>
<td role="cell" className="fin-forecast-kind">
<Badge tone={row.sicherheit === 'sicher' ? 'ok' : 'info'}>{row.sicherheit}</Badge>
</td>
<td role="cell" className="fin-amount fin-forecast-amount">{row.amountText}</td>
{hasBalance && ( {hasBalance && (
<> <>
<td <th role="columnheader" scope="col" className="fin-amount">Saldo sicher</th>
role="cell" <th role="columnheader" scope="col" className="fin-amount">Saldo erwartet</th>
data-label="Saldo sicher"
className={`fin-amount fin-forecast-sicher${row.saldoSicherNegative ? ' fin-neg' : ''}`}
>
<span className="fin-saldo">{row.saldoSicherText}</span>
</td>
<td
role="cell"
data-label="Saldo erwartet"
className={`fin-amount fin-forecast-erwartet${row.saldoErwartetNegative ? ' fin-neg' : ''}`}
>
<span className="fin-saldo">{row.saldoErwartetText}</span>
</td>
</> </>
)} )}
</tr> </tr>
))} </thead>
</tbody> <tbody role="rowgroup">
</table> {rows.map((row) => (
</div> <tr key={row.key} role="row">
)} <td role="cell" className="fin-forecast-date">{row.dateText}</td>
{(notes.length > 0 || assumption) && ( <td role="cell" className="fin-forecast-label">{row.label}</td>
<div className="fin-forecast-foot"> <td role="cell" className="fin-forecast-kind">
{notes.map((note) => ( <Badge tone={row.sicherheit === 'sicher' ? 'ok' : 'info'}>{row.sicherheit}</Badge>
<p key={note.key} className="fin-hint fin-hint-warn" role="status"> </td>
<FaTriangleExclamation aria-hidden="true" /> {note.text} <td
</p> role="cell"
))} className={`fin-amount fin-forecast-amount${isNegText(row.amountText) ? ' fin-neg' : ''}`}
{assumption && <p className="fin-hint">{assumption}</p>} >
</div> {row.amountText}
)} </td>
{hasBalance && (
<>
<td
role="cell"
data-label="Saldo sicher"
className={`fin-amount fin-forecast-sicher${row.saldoSicherNegative ? ' fin-neg' : ''}`}
>
<span className="fin-saldo">{row.saldoSicherText}</span>
</td>
<td
role="cell"
data-label="Saldo erwartet"
className={`fin-amount fin-forecast-erwartet${row.saldoErwartetNegative ? ' fin-neg' : ''}`}
>
<span className="fin-saldo">{row.saldoErwartetText}</span>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{(notes.length > 0 || assumption) && (
<div className="fin-forecast-foot">
{notes.map((note) => (
<p key={note.key} className="msg warn" role="status">
{note.text}
</p>
))}
{assumption && <p className="msg muted">{assumption}</p>}
</div>
)}
</div>
</Card> </Card>
) )
} }
+39 -32
View File
@@ -1,5 +1,6 @@
import { Card } from '../ui' import { Card } from '../ui'
import { taxView } from '../../lib/financeView' import { taxView } from '../../lib/financeView'
import { isNegText } from '../../lib/financeUi'
/** /**
* Block 4: „Steuer“. Rücklage (Betrag und woraus sie sich ergibt), Kleinunternehmer-Zähler gegen die Grenzen, * Block 4: „Steuer“. Rücklage (Betrag und woraus sie sich ergibt), Kleinunternehmer-Zähler gegen die Grenzen,
@@ -10,43 +11,49 @@ import { taxView } from '../../lib/financeView'
export default function TaxCard({ steuer }) { export default function TaxCard({ steuer }) {
const v = taxView(steuer) const v = taxView(steuer)
return ( return (
<Card title="Steuer" className="fin-tax-card"> <Card title="Steuer">
<div className="fin-facts"> <div className="fin-body">
<div className="fin-fact"> <div className="fin-facts">
<div className="fin-fact-row">
<span className="fin-fact-label">Steuer-Rücklage</span>
<span className="fin-amount fin-fact-big">{v.reserve.amountText}</span>
</div>
{v.reserve.basisText && <div className="fin-fact-sub">{v.reserve.basisText}</div>}
</div>
<div className="fin-fact">
<div className="fin-fact-label">Kleinunternehmer (§ 19 UStG)</div>
<div className={`fin-fact-value${v.small.known ? '' : ' fin-fact-unknown'}`}>{v.small.counterText}</div>
{v.small.shareText && <div className="fin-fact-sub">{v.small.shareText}</div>}
<div className="fin-fact-sub">{v.small.limitText}</div>
<div className="fin-fact-sub">{v.small.hardText}</div>
{v.small.previousText && <div className="fin-fact-sub">{v.small.previousText}</div>}
</div>
{v.gewerbeText && (
<div className="fin-fact">
<div className="fin-fact-label">Gewerbesteuer</div>
<div className="fin-fact-sub fin-fact-sub-strong">{v.gewerbeText}</div>
</div>
)}
{v.prepayment && (
<div className="fin-fact"> <div className="fin-fact">
<div className="fin-fact-row"> <div className="fin-fact-row">
<span className="fin-fact-label">Nächste ESt-Vorauszahlung</span> <span className="fin-fact-label">Steuer-Rücklage</span>
<span className="fin-amount fin-fact-strong">{v.prepayment.amountText}</span> <span className={`fin-amount fin-fact-big${isNegText(v.reserve.amountText) ? ' fin-neg' : ''}`}>
{v.reserve.amountText}
</span>
</div> </div>
<div className="fin-fact-sub">am {v.prepayment.dateText}</div> {v.reserve.basisText && <div className="fin-fact-sub">{v.reserve.basisText}</div>}
</div> </div>
)}
<p className="fin-hint fin-tax-note">{v.noteText}</p> <div className="fin-fact">
<div className="fin-fact-label">Kleinunternehmer (§ 19 UStG)</div>
<div className={`fin-fact-value${v.small.known ? '' : ' fin-fact-unknown'}`}>{v.small.counterText}</div>
{v.small.shareText && <div className="fin-fact-sub">{v.small.shareText}</div>}
<div className="fin-fact-sub">{v.small.limitText}</div>
<div className="fin-fact-sub">{v.small.hardText}</div>
{v.small.previousText && <div className="fin-fact-sub">{v.small.previousText}</div>}
</div>
{v.gewerbeText && (
<div className="fin-fact">
<div className="fin-fact-label">Gewerbesteuer</div>
<div className="fin-fact-sub fin-fact-sub-strong">{v.gewerbeText}</div>
</div>
)}
{v.prepayment && (
<div className="fin-fact">
<div className="fin-fact-row">
<span className="fin-fact-label">Nächste ESt-Vorauszahlung</span>
<span className={`fin-amount fin-fact-strong${isNegText(v.prepayment.amountText) ? ' fin-neg' : ''}`}>
{v.prepayment.amountText}
</span>
</div>
<div className="fin-fact-sub">am {v.prepayment.dateText}</div>
</div>
)}
<p className="msg muted fin-tax-note">{v.noteText}</p>
</div>
</div> </div>
</Card> </Card>
) )
+26 -42
View File
@@ -708,72 +708,56 @@ button.link-danger { background: none; border: 0; padding: 0; font: inherit; fon
/* Bloecke 3-5: Vorschau, Steuer, Abos */ /* Bloecke 3-5: Vorschau, Steuer, Abos */
/* Block 3: Vorschau (Tabelle; in einer schmalen Karte wird jede Zeile zweizeilig) */ /* Block 3: Vorschau. Die Tabelle liegt in .fin-body (Container); schmal wird jede Zeile ein Raster. Zellen: Abstaende wie .table, nur oben und unten 9 px. */
.fin-forecast-card { container-type: inline-size; overflow: hidden; } .fin-forecast th, .fin-forecast td { vertical-align: baseline; }
.fin-forecast th, .fin-forecast td { padding: 9px 10px; vertical-align: baseline; } .fin-forecast td { padding-block: 9px; }
.fin-forecast th.fin-amount { text-align: right; } .fin-forecast th.fin-amount { text-align: right; }
.fin-forecast th:first-child, .fin-forecast td:first-child { padding-left: var(--sp-4); }
.fin-forecast th:last-child, .fin-forecast td:last-child { padding-right: var(--sp-4); }
.fin-forecast tbody tr:last-child td { border-bottom: none; }
.fin-forecast-date { white-space: nowrap; font-variant-numeric: tabular-nums; } .fin-forecast-date { white-space: nowrap; font-variant-numeric: tabular-nums; }
.fin-forecast-label { overflow-wrap: anywhere; } .fin-forecast-label { overflow-wrap: anywhere; }
.fin-forecast-foot { display: grid; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--border); } .fin-forecast-foot { display: grid; gap: 6px; margin-top: 12px; }
.fin-forecast-foot .fin-hint svg { vertical-align: -2px; }
@container (max-width: 560px) { @container (max-width: 560px) {
.fin-forecast thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } .fin-forecast thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.fin-forecast, .fin-forecast tbody { display: block; } .fin-forecast, .fin-forecast tbody { display: block; }
.fin-forecast tr { .fin-forecast tr {
display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 4px 0; align-items: baseline; display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 4px 0; align-items: baseline;
grid-template-areas: "date date kind kind amount amount" "label label label label label label" "sicher sicher sicher erwartet erwartet erwartet"; grid-template-areas: "date date kind kind amount amount" "label label label label label label" "sicher sicher sicher erwartet erwartet erwartet";
padding: 10px var(--sp-4); border-bottom: 1px solid var(--border); padding: 10px 0; border-bottom: 1px solid var(--line);
} }
.fin-forecast-nobal tr { grid-template-areas: "date date kind kind amount amount" "label label label label label label"; } .fin-forecast-nobal tr { grid-template-areas: "date date kind kind amount amount" "label label label label label label"; }
.fin-forecast tbody tr:last-child { border-bottom: none; }
.fin-forecast td, .fin-forecast td:first-child, .fin-forecast td:last-child { display: block; padding: 0; border: 0; min-width: 0; } .fin-forecast td, .fin-forecast td:first-child, .fin-forecast td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
.fin-forecast tbody tr:hover td { background: transparent; }
.fin-forecast-date { grid-area: date; } .fin-forecast-date { grid-area: date; }
.fin-forecast-kind { grid-area: kind; } .fin-forecast-kind { grid-area: kind; }
.fin-forecast-amount { grid-area: amount; font-weight: 700; } .fin-forecast-amount { grid-area: amount; font-weight: 600; }
.fin-forecast-label { grid-area: label; } .fin-forecast-label { grid-area: label; }
.fin-forecast td.fin-forecast-sicher, .fin-forecast td.fin-forecast-erwartet { .fin-forecast td.fin-forecast-sicher, .fin-forecast td.fin-forecast-erwartet {
display: flex; flex-wrap: wrap; align-items: baseline; column-gap: var(--sp-2); font-size: 13px; display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 8px; font-size: 13px;
} }
.fin-forecast-sicher { grid-area: sicher; } .fin-forecast-sicher { grid-area: sicher; }
.fin-forecast-erwartet { grid-area: erwartet; } .fin-forecast-erwartet { grid-area: erwartet; }
.fin-forecast td.fin-forecast-sicher { padding-right: var(--sp-3); } .fin-forecast td.fin-forecast-sicher { padding-right: 12px; }
.fin-forecast td.fin-forecast-erwartet { padding-left: var(--sp-3); } .fin-forecast td.fin-forecast-erwartet { padding-left: 12px; }
.fin-saldo { margin-left: auto; } .fin-saldo { margin-left: auto; }
.fin-forecast td[data-label]::before { content: attr(data-label); color: var(--text-muted); font-size: 12px; font-weight: 500; white-space: nowrap; } .fin-forecast td[data-label]::before { content: attr(data-label); color: var(--text-2); font-size: 12px; font-weight: 500; white-space: nowrap; }
} }
/* Block 4: Steuer */ /* Block 4: Steuer (Liste mit Haarlinien; die Fussnote steht nach dem letzten Posten) */
.fin-facts { display: grid; gap: var(--sp-4); } .fin-facts { display: grid; gap: 16px; }
.fin-fact { display: grid; gap: 3px; padding-bottom: var(--sp-4); border-bottom: 1px solid var(--border); } .fin-fact { display: grid; gap: 3px; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
.fin-fact:last-child { padding-bottom: 0; border-bottom: none; } .fin-fact:last-of-type { padding-bottom: 0; border-bottom: 0; }
.fin-fact-row { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-3); } .fin-fact-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.fin-fact-label { color: var(--text-muted); font-size: 13px; } .fin-fact-label { color: var(--text-2); font-size: 13px; }
.fin-fact-big { font-size: 24px; font-weight: 800; } .fin-fact-big { font-size: 22px; line-height: 30px; font-weight: 600; }
.fin-fact-strong { font-size: 16px; font-weight: 700; } .fin-fact-strong { font-size: 16px; font-weight: 600; }
.fin-fact-value { font-weight: 700; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } .fin-fact-value { font-weight: 600; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.fin-fact-unknown { color: var(--warn); } .fin-fact-unknown { color: var(--orange-fg); }
.fin-fact-sub { font-size: 13px; color: var(--text-muted); overflow-wrap: anywhere; } .fin-fact-sub { font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; }
.fin-fact-sub-strong { color: var(--text); } .fin-fact-sub-strong { color: var(--text); }
/* Block 5: Abos */ /* Block 5: Abos (Kennzahlen: .stats.fin-abo-stats ohne eigene Regel) */
.fin-abo-card { overflow: hidden; } .fin-abos { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
.fin-stats { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 6px var(--sp-3); align-items: baseline; padding: var(--sp-4); border-bottom: 1px solid var(--border); } .fin-abo { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; align-items: baseline; padding: 12px 0; border-bottom: 1px solid var(--line); }
.fin-stat-value { font-size: 20px; font-weight: 800; } .fin-abo-customer { font-weight: 600; overflow-wrap: anywhere; }
.fin-stat-label { color: var(--text-muted); } .fin-abo-meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; font-size: 13px; }
.fin-stat-label.fin-flag { color: var(--danger); }
.fin-stat-label svg { vertical-align: -2px; }
.fin-abos { list-style: none; margin: 0; padding: 0; }
.fin-abo {
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px var(--sp-3); align-items: baseline;
padding: 11px var(--sp-4); border-bottom: 1px solid var(--border);
}
.fin-abo:last-child { border-bottom: none; }
.fin-abo-customer { font-weight: 700; overflow-wrap: anywhere; }
.fin-abo-meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 4px var(--sp-3); font-size: 13px; }
/* Block 6 und 7: Rechnungen und Buchungen (Tabellen; in einer schmalen Karte wird jede Zeile ein Raster) */ /* Block 6 und 7: Rechnungen und Buchungen (Tabellen; in einer schmalen Karte wird jede Zeile ein Raster) */
.fin-invoices-card, .fin-tx-card { container-type: inline-size; overflow: hidden; } .fin-invoices-card, .fin-tx-card { container-type: inline-size; overflow: hidden; }