feat: Regeln (Betrag/Transfer/Konto-Texte) + Münz-Icon + pastellgrünes Theme
Regeln: - Schlagwörter mit optionaler Betragsbedingung (z. B. „SV = 5.00 → Restaurant"); Betrags-Regeln gewinnen vor betragslosen - Transfer-Schlagwörter (Text → Transfer) auf der Konten-Seite - Konto-Identifikator-Art „Text": erkennt das Zielkonto eines Transfers aus dem Buchungstext (längster passender Text gewinnt) - Migration 0007, Endpunkte, Tests (Betrag/Transfer/Konflikt), gesamt 30 grün Optik: - App-Icon: Münzhaufen (aus Nutzer-SVG) weiß auf grünem Grund, klein gehalten - Farbschema auf pastelliges Grün (Akzent hell/dunkel + Favicon) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 463 B After Width: | Height: | Size: 11 KiB |
+6
-3
@@ -141,13 +141,16 @@ export const api = {
|
||||
updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }),
|
||||
deleteCategory: (id) => request(`/categories/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Schlagwörter je Kategorie (manuelle Auto-Zuordnung) + Prüfmeldungen
|
||||
// Schlagwörter je Kategorie (manuelle Auto-Zuordnung, optional mit Betrag) + Prüfmeldungen
|
||||
listKeywords: (categoryId) => request(`/categories/${categoryId}/keywords`),
|
||||
addKeyword: (categoryId, word) => request(`/categories/${categoryId}/keywords`, { method: "POST", body: { word } }),
|
||||
updateKeyword: (ruleId, word) => request(`/categories/keywords/${ruleId}`, { method: "PATCH", body: { word } }),
|
||||
addKeyword: (categoryId, body) => request(`/categories/${categoryId}/keywords`, { method: "POST", body }),
|
||||
updateKeyword: (ruleId, body) => request(`/categories/keywords/${ruleId}`, { method: "PATCH", body }),
|
||||
deleteKeyword: (ruleId) => request(`/categories/keywords/${ruleId}`, { method: "DELETE" }),
|
||||
flaggedKeywords: () => request("/categories/keywords/flagged"),
|
||||
confirmKeyword: (ruleId) => request(`/categories/keywords/${ruleId}/confirm`, { method: "POST" }),
|
||||
// Transfer-Schlagwörter (Text -> Transfer, Zielkonto über Konto-Texte)
|
||||
listTransferKeywords: () => request("/categories/transfer-keywords"),
|
||||
addTransferKeyword: (body) => request("/categories/transfer-keywords", { method: "POST", body }),
|
||||
|
||||
// Transaktionen
|
||||
listTransactions: (params = {}) => {
|
||||
|
||||
@@ -1,33 +1,17 @@
|
||||
import { APP_NAME } from "../brand";
|
||||
|
||||
// Wort-Bild-Marke für die Seitenleiste. Das Zeichen ist ein Inline-SVG
|
||||
// (gestapelte Münzen) im Akzentton – bewusst kein Emoji.
|
||||
// Wort-Bild-Marke für die Seitenleiste. Das Zeichen ist der Münzhaufen aus
|
||||
// public/favicon.svg (identisch zum Browser-Tab-Icon) – bewusst kein Emoji.
|
||||
export default function BrandMark({ showName = true, size = 26 }) {
|
||||
return (
|
||||
<div className="brand-mark">
|
||||
<svg
|
||||
<img
|
||||
className="brand-logo"
|
||||
src="/favicon.svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 32 32"
|
||||
role="img"
|
||||
aria-label={APP_NAME}
|
||||
focusable="false"
|
||||
>
|
||||
<rect x="1" y="1" width="30" height="30" rx="8" fill="var(--accent)" />
|
||||
<g
|
||||
transform="translate(2.2 2.2) scale(1.15)"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="8" cy="8" r="6" />
|
||||
<path d="M18.09 10.37A6 6 0 1 1 10.34 18" />
|
||||
<path d="M7 6h1v4" />
|
||||
<path d="m16.71 13.88.7.71-2.82 2.82" />
|
||||
</g>
|
||||
</svg>
|
||||
alt={APP_NAME}
|
||||
/>
|
||||
{showName && <span className="brand-name">{APP_NAME}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.brand-logo {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.brand-name {
|
||||
font-weight: 650;
|
||||
font-size: 1rem;
|
||||
@@ -739,6 +743,11 @@ tr.row-dup {
|
||||
border: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.kw-amount {
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.kw-chip button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
+10
-1
@@ -128,6 +128,7 @@
|
||||
"openingDate": "Stichtag",
|
||||
"identifiers": "Erkennungs-Nummern (IBAN etc.)",
|
||||
"addIdentifier": "Nummer hinzufügen",
|
||||
"textIdentPlaceholder": "z. B. Sparkonto, Tagesgeld",
|
||||
"identShort": "Nr.",
|
||||
"total": "Gesamt",
|
||||
"empty": "Noch keine Konten. Lege dein erstes Konto an.",
|
||||
@@ -144,7 +145,14 @@
|
||||
"identifierKinds": {
|
||||
"iban": "IBAN",
|
||||
"account_no": "Kontonummer",
|
||||
"card_no": "Kartennummer"
|
||||
"card_no": "Kartennummer",
|
||||
"text": "Text (Erkennung)"
|
||||
},
|
||||
"transferRules": {
|
||||
"title": "Transfer-Erkennung",
|
||||
"hint": "Buchungen, deren Text eines dieser Wörter enthält, werden als Transfer erkannt. Das Zielkonto wird aus dem Buchungstext über die Erkennungs-Texte je Konto (Konto bearbeiten, Art Text) erraten.",
|
||||
"wordPlaceholder": "Wort (z. B. Übertrag)",
|
||||
"empty": "Noch keine Transfer-Schlagwörter."
|
||||
},
|
||||
"categories": {
|
||||
"title": "Kategorien",
|
||||
@@ -163,6 +171,7 @@
|
||||
"keywordsTitle": "Schlagwörter – {{name}}",
|
||||
"keywordsHint": "Buchungen, die eines dieser Wörter im Empfänger oder Verwendungszweck enthalten, werden bei künftigen Importen automatisch dieser Kategorie zugeordnet.",
|
||||
"keywordAdd": "Wort eingeben und Enter",
|
||||
"keywordAmount": "Betrag (optional)",
|
||||
"keywordEmpty": "Noch keine Schlagwörter.",
|
||||
"keywordEditHint": "Doppelklick auf ein Schlagwort, um es zu bearbeiten.",
|
||||
"searchPlaceholder": "Kategorie suchen …"
|
||||
|
||||
+10
-1
@@ -128,6 +128,7 @@
|
||||
"openingDate": "As of",
|
||||
"identifiers": "Identifiers (IBAN etc.)",
|
||||
"addIdentifier": "Add identifier",
|
||||
"textIdentPlaceholder": "e.g. Savings, Deposit",
|
||||
"identShort": "id(s)",
|
||||
"total": "Total",
|
||||
"empty": "No accounts yet. Create your first account.",
|
||||
@@ -144,7 +145,14 @@
|
||||
"identifierKinds": {
|
||||
"iban": "IBAN",
|
||||
"account_no": "Account number",
|
||||
"card_no": "Card number"
|
||||
"card_no": "Card number",
|
||||
"text": "Text (detection)"
|
||||
},
|
||||
"transferRules": {
|
||||
"title": "Transfer detection",
|
||||
"hint": "Transactions whose text contains one of these words are recognized as a transfer. The target account is guessed from the booking text via each account's detection texts (edit account, kind Text).",
|
||||
"wordPlaceholder": "Word (e.g. Transfer)",
|
||||
"empty": "No transfer keywords yet."
|
||||
},
|
||||
"categories": {
|
||||
"title": "Categories",
|
||||
@@ -163,6 +171,7 @@
|
||||
"keywordsTitle": "Keywords – {{name}}",
|
||||
"keywordsHint": "Transactions whose payee or description contains one of these words are automatically assigned to this category on future imports.",
|
||||
"keywordAdd": "Type a word and press Enter",
|
||||
"keywordAmount": "Amount (optional)",
|
||||
"keywordEmpty": "No keywords yet.",
|
||||
"keywordEditHint": "Double-click a keyword to edit it.",
|
||||
"searchPlaceholder": "Search category …"
|
||||
|
||||
@@ -34,6 +34,80 @@ function emptyForm() {
|
||||
};
|
||||
}
|
||||
|
||||
// Globale Transfer-Schlagwörter: Text im Buchungstext -> Transfer. Das Zielkonto
|
||||
// wird über die Erkennungs-Texte je Konto (Identifikator-Art „Text") erraten.
|
||||
function TransferKeywords() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [words, setWords] = useState([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function laden() {
|
||||
try { setWords(await api.listTransferKeywords()); }
|
||||
catch (err) { toast(err.message, "error"); }
|
||||
}
|
||||
useEffect(() => { laden(); }, []);
|
||||
|
||||
async function hinzufuegen() {
|
||||
const w = input.trim();
|
||||
if (!w) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const body = { word: w };
|
||||
const c = parseAmountToCents(amount);
|
||||
if (c) body.amount_cents = c;
|
||||
await api.addTransferKeyword(body);
|
||||
setInput("");
|
||||
setAmount("");
|
||||
await laden();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function entfernen(id) {
|
||||
try { await api.deleteKeyword(id); setWords((ws) => ws.filter((x) => x.id !== id)); }
|
||||
catch (err) { toast(err.message, "error"); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginTop: "var(--sp-4)" }}>
|
||||
<h2>{t("transferRules.title")}</h2>
|
||||
<p className="muted small">{t("transferRules.hint")}</p>
|
||||
<div className="kw-add">
|
||||
<input value={input} placeholder={t("transferRules.wordPlaceholder")}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} />
|
||||
<input value={amount} inputMode="decimal" placeholder={t("categories.keywordAmount")}
|
||||
style={{ flex: "0 0 120px", minWidth: 0, marginTop: 0 }}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} />
|
||||
<button className="btn" onClick={hinzufuegen} disabled={busy || !input.trim()}>
|
||||
<Icon name="plus" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
{words.length === 0 ? (
|
||||
<div className="muted small">{t("transferRules.empty")}</div>
|
||||
) : (
|
||||
<div className="kw-chips">
|
||||
{words.map((w) => (
|
||||
<span className="kw-chip" key={w.id}>
|
||||
{w.word}
|
||||
{w.amount_cents ? <span className="kw-amount">= {centsToInput(w.amount_cents)}</span> : null}
|
||||
<button type="button" title={t("common.delete")} onClick={() => entfernen(w.id)}>
|
||||
<Icon name="close" size={13} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Accounts() {
|
||||
const { t } = useTranslation();
|
||||
const { baseCurrency } = useSettings();
|
||||
@@ -165,6 +239,8 @@ export default function Accounts() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TransferKeywords />
|
||||
|
||||
{form && (
|
||||
<Modal
|
||||
title={editing ? t("accounts.edit") : t("accounts.new")}
|
||||
@@ -238,9 +314,10 @@ export default function Accounts() {
|
||||
<option value="iban">IBAN</option>
|
||||
<option value="account_no">{t("identifierKinds.account_no")}</option>
|
||||
<option value="card_no">{t("identifierKinds.card_no")}</option>
|
||||
<option value="text">{t("identifierKinds.text")}</option>
|
||||
</select>
|
||||
<input className="grow" style={{ minWidth: 0 }} value={ident.value}
|
||||
placeholder="DE.. / Nr."
|
||||
placeholder={ident.kind === "text" ? t("accounts.textIdentPlaceholder") : "DE.. / Nr."}
|
||||
onChange={(e) => {
|
||||
const next = [...form.identifiers];
|
||||
next[idx] = { ...ident, value: e.target.value };
|
||||
|
||||
@@ -7,14 +7,17 @@ import Icon from "../components/Icon";
|
||||
import IconChip from "../components/IconChip";
|
||||
import Modal from "../components/Modal";
|
||||
import IconPicker, { CATEGORY_ICONS } from "../components/IconPicker";
|
||||
import { centsToInput, parseAmountToCents } from "../format";
|
||||
|
||||
// Popup zum Pflegen der Schlagwörter einer Kategorie. Wörter werden beim Import
|
||||
// gegen Empfänger/Verwendungszweck geprüft und ordnen Buchungen automatisch zu.
|
||||
// Optional lässt sich ein fester Betrag als Zusatzbedingung angeben.
|
||||
function KeywordModal({ category, onClose }) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [words, setWords] = useState([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [editVal, setEditVal] = useState("");
|
||||
@@ -30,8 +33,12 @@ function KeywordModal({ category, onClose }) {
|
||||
if (!w) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.addKeyword(category.id, w);
|
||||
const body = { word: w };
|
||||
const c = parseAmountToCents(amount);
|
||||
if (c) body.amount_cents = c;
|
||||
await api.addKeyword(category.id, body);
|
||||
setInput("");
|
||||
setAmount("");
|
||||
await laden();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
@@ -61,7 +68,7 @@ function KeywordModal({ category, onClose }) {
|
||||
const orig = words.find((x) => x.id === id);
|
||||
if (!val || (orig && val === orig.word)) { abbrechenBearbeiten(); return; }
|
||||
try {
|
||||
await api.updateKeyword(id, val);
|
||||
await api.updateKeyword(id, { word: val });
|
||||
abbrechenBearbeiten();
|
||||
await laden();
|
||||
} catch (err) {
|
||||
@@ -87,6 +94,14 @@ function KeywordModal({ category, onClose }) {
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }}
|
||||
/>
|
||||
<input
|
||||
value={amount}
|
||||
inputMode="decimal"
|
||||
placeholder={t("categories.keywordAmount")}
|
||||
style={{ flex: "0 0 120px", minWidth: 0, marginTop: 0 }}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }}
|
||||
/>
|
||||
<button className="btn" onClick={hinzufuegen} disabled={busy || !input.trim()}>
|
||||
<Icon name="plus" size={15} />
|
||||
</button>
|
||||
@@ -120,6 +135,7 @@ function KeywordModal({ category, onClose }) {
|
||||
onDoubleClick={() => starteBearbeiten(w)}
|
||||
>
|
||||
{w.word}
|
||||
{w.amount_cents ? <span className="kw-amount">= {centsToInput(w.amount_cents)}</span> : null}
|
||||
<button type="button" title={t("common.delete")} onClick={() => entfernen(w.id)}>
|
||||
<Icon name="close" size={13} />
|
||||
</button>
|
||||
|
||||
+11
-11
@@ -6,9 +6,9 @@
|
||||
--border-strong: #d1d6dc;
|
||||
--text: #1a1f27;
|
||||
--muted: #6b7480;
|
||||
--accent: #3f51b5;
|
||||
--accent-hover: #34429a;
|
||||
--accent-soft: #eceefb;
|
||||
--accent: #3f9c72;
|
||||
--accent-hover: #34825f;
|
||||
--accent-soft: #e6f3ec;
|
||||
--danger: #c0392b;
|
||||
--danger-soft: #fbeceb;
|
||||
--warn: #b06f14;
|
||||
@@ -536,7 +536,7 @@ td select { width: auto; min-width: 0; max-width: 100%; }
|
||||
}
|
||||
.alert .icon { margin-top: 1px; }
|
||||
.alert.error { background: var(--danger-soft); color: var(--danger); border-color: #f0c8c4; }
|
||||
.alert.info { background: var(--accent-soft); color: var(--accent-hover); border-color: #cfd6f4; }
|
||||
.alert.info { background: var(--accent-soft); color: var(--accent-hover); border-color: #c3e6d3; }
|
||||
.alert.warn { background: var(--warn-soft); color: var(--warn); border-color: #f0dcb8; }
|
||||
.alert.ok { background: #e6f4ec; color: var(--ok); border-color: #c3e3d0; }
|
||||
|
||||
@@ -1008,9 +1008,9 @@ select.zeitraum:hover { color: var(--text); }
|
||||
--border-strong: #333c4a;
|
||||
--text: #e6edf3;
|
||||
--muted: #8b949e;
|
||||
--accent: #7c8ae8;
|
||||
--accent-hover: #93a0ef;
|
||||
--accent-soft: #1e2340;
|
||||
--accent: #6cc39d;
|
||||
--accent-hover: #82d0af;
|
||||
--accent-soft: #16291f;
|
||||
--danger: #f0857a;
|
||||
--danger-soft: #2a1a1a;
|
||||
--warn: #e0a955;
|
||||
@@ -1023,14 +1023,14 @@ select.zeitraum:hover { color: var(--text); }
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0d1117; --surface: #161b22; --surface-2: #1c2230; --border: #262d38;
|
||||
--border-strong: #333c4a; --text: #e6edf3; --muted: #8b949e; --accent: #7c8ae8;
|
||||
--accent-hover: #93a0ef; --accent-soft: #1e2340; --danger: #f0857a; --danger-soft: #2a1a1a;
|
||||
--border-strong: #333c4a; --text: #e6edf3; --muted: #8b949e; --accent: #6cc39d;
|
||||
--accent-hover: #82d0af; --accent-soft: #16291f; --danger: #f0857a; --danger-soft: #2a1a1a;
|
||||
--warn: #e0a955; --warn-soft: #2a2312; --ok: #5fbf8c;
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f6f7f9; --surface: #ffffff; --surface-2: #f1f3f5; --border: #e3e6ea;
|
||||
--border-strong: #d1d6dc; --text: #1a1f27; --muted: #6b7480; --accent: #3f51b5;
|
||||
--accent-hover: #34429a; --accent-soft: #eceefb; --danger: #c0392b; --danger-soft: #fbeceb;
|
||||
--border-strong: #d1d6dc; --text: #1a1f27; --muted: #6b7480; --accent: #3f9c72;
|
||||
--accent-hover: #34825f; --accent-soft: #e6f3ec; --danger: #c0392b; --danger-soft: #fbeceb;
|
||||
--warn: #b06f14; --warn-soft: #fbf3e6; --ok: #2e7d55;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user