feat: view and edit a transaction's note from the list

Transactions can carry a `notes` field (set via the API — e.g. the
currency-conversion metadata on this user's imported historical data), but
there was no way to see it in the UI at all.

TransactionsTable now shows a note icon next to edit/delete, only when a
transaction actually has one, opening a dialog with the text. The edit
form's `notes` field was already wired end to end (state, payload) but had
no input in the JSX — added it, since a view-only note with no way to edit
or clear it would be a dead end.

Also fixed e2e/run.sh's readiness check, found while running the full
suite for this change: it polled `/` (nginx, answers almost immediately)
instead of `/api/health`, so the first test could start before the backend
finished migrating/seeding and fail trying to parse nginx's HTML error
page as JSON.
This commit is contained in:
ZProger
2026-08-16 23:50:10 +03:00
parent 7235d74f80
commit d1458eb160
6 changed files with 98 additions and 56 deletions

View File

@@ -6,7 +6,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
# Single source of truth for the running app's version — surfaced in the API # Single source of truth for the running app's version — surfaced in the API
# title and embedded in exported backups so an old file can be told apart # title and embedded in exported backups so an old file can be told apart
# from a current one. # from a current one.
APP_VERSION = "0.19.2" APP_VERSION = "0.20.0"
class Settings(BaseSettings): class Settings(BaseSettings):

View File

@@ -23,9 +23,13 @@ trap cleanup EXIT
$COMPOSE up -d --build $COMPOSE up -d --build
echo "waiting for http://localhost:${AURUM_WEB_PORT} ..." echo "waiting for http://localhost:${AURUM_WEB_PORT}/api/health ..."
# /api/health, not just / — nginx (the `web` container) answers the bare root
# well before the backend has finished running migrations + seeding, and a
# request racing ahead of that gets nginx's own HTML error page back instead
# of JSON, which the first test to touch the API then fails to parse.
for _ in $(seq 1 60); do for _ in $(seq 1 60); do
if curl -sf "http://localhost:${AURUM_WEB_PORT}" >/dev/null; then if curl -sf "http://localhost:${AURUM_WEB_PORT}/api/health" >/dev/null; then
echo "stack is up" echo "stack is up"
break break
fi fi

View File

@@ -1,7 +1,7 @@
{ {
"name": "aurum-frontend", "name": "aurum-frontend",
"private": true, "private": true,
"version": "0.19.2", "version": "0.20.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -233,6 +233,15 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
/> />
</div> </div>
<div>
<Label htmlFor="notes">{t("transactions.form.notesLabel")}</Label>
<Input
id="notes"
value={form.notes}
onChange={(event) => setForm((prev) => ({ ...prev, notes: event.target.value }))}
/>
</div>
{error && <p className="text-sm text-danger">{error}</p>} {error && <p className="text-sm text-danger">{error}</p>}
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">

View File

@@ -1,4 +1,6 @@
import { ArrowLeftRight, Pencil, Trash2 } from "lucide-react"; import { useState } from "react";
import { ArrowLeftRight, Pencil, StickyNote, Trash2 } from "lucide-react";
import { Dialog } from "@/components/ui/Dialog";
import { getCategoryIcon } from "@/lib/icons"; import { getCategoryIcon } from "@/lib/icons";
import { formatCurrency, formatTransactionDate } from "@/lib/format"; import { formatCurrency, formatTransactionDate } from "@/lib/format";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
@@ -13,67 +15,88 @@ interface TransactionsTableProps {
export function TransactionsTable({ items, onEdit, onDelete }: TransactionsTableProps) { export function TransactionsTable({ items, onEdit, onDelete }: TransactionsTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [noteTransaction, setNoteTransaction] = useState<Transaction | null>(null);
if (items.length === 0) { if (items.length === 0) {
return <p className="py-12 text-center text-sm text-text-muted">{t("transactions.noneFound")}</p>; return <p className="py-12 text-center text-sm text-text-muted">{t("transactions.noneFound")}</p>;
} }
return ( return (
<ul className="divide-y divide-gridline"> <>
{items.map((tx) => { <ul className="divide-y divide-gridline">
const isTransfer = tx.type === "transfer"; {items.map((tx) => {
const isExpense = tx.type === "expense"; const isTransfer = tx.type === "transfer";
const Icon = isTransfer ? ArrowLeftRight : getCategoryIcon(tx.category?.icon); const isExpense = tx.type === "expense";
const color = isTransfer ? "var(--text-muted)" : tx.category?.color ?? "var(--text-muted)"; const Icon = isTransfer ? ArrowLeftRight : getCategoryIcon(tx.category?.icon);
const color = isTransfer ? "var(--text-muted)" : tx.category?.color ?? "var(--text-muted)";
return ( return (
<li key={tx.id} className="group flex items-center gap-3 py-3"> <li key={tx.id} className="group flex items-center gap-3 py-3">
<span <span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full" className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full"
style={{ backgroundColor: typeof color === "string" && color.startsWith("#") ? `${color}26` : "var(--surface-2)" }} style={{ backgroundColor: typeof color === "string" && color.startsWith("#") ? `${color}26` : "var(--surface-2)" }}
> >
<Icon size={16} style={{ color }} /> <Icon size={16} style={{ color }} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-text-primary">{tx.description}</span>
<span className="block truncate text-xs text-text-muted">
{formatTransactionDate(tx.date)} · {tx.account.name}
{isTransfer && tx.transfer_account_id ? ` ${t("transactions.transferSuffix")}` : ""}
{tx.category ? ` · ${translateCategoryName(tx.category.name)}` : ""}
</span> </span>
</span>
<span <span className="min-w-0 flex-1">
className={`shrink-0 text-sm font-medium tabular-nums ${ <span className="block truncate text-sm font-medium text-text-primary">{tx.description}</span>
isTransfer ? "text-text-muted" : isExpense ? "text-text-primary" : "text-success" <span className="block truncate text-xs text-text-muted">
}`} {formatTransactionDate(tx.date)} · {tx.account.name}
> {isTransfer && tx.transfer_account_id ? ` ${t("transactions.transferSuffix")}` : ""}
{isTransfer ? "" : isExpense ? "-" : "+"} {tx.category ? ` · ${translateCategoryName(tx.category.name)}` : ""}
{formatCurrency(tx.amount)} </span>
</span> </span>
<span className="flex shrink-0 gap-1"> <span
<button className={`shrink-0 text-sm font-medium tabular-nums ${
type="button" isTransfer ? "text-text-muted" : isExpense ? "text-text-primary" : "text-success"
aria-label={t("common.edit")} }`}
onClick={() => onEdit(tx)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
> >
<Pencil size={15} /> {isTransfer ? "" : isExpense ? "-" : "+"}
</button> {formatCurrency(tx.amount)}
<button </span>
type="button"
aria-label={t("common.delete")} <span className="flex shrink-0 gap-1">
onClick={() => onDelete(tx)} {tx.notes && (
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger" <button
> type="button"
<Trash2 size={15} /> aria-label={t("transactions.viewNote")}
</button> onClick={() => setNoteTransaction(tx)}
</span> className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
</li> >
); <StickyNote size={15} />
})} </button>
</ul> )}
<button
type="button"
aria-label={t("common.edit")}
onClick={() => onEdit(tx)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
>
<Pencil size={15} />
</button>
<button
type="button"
aria-label={t("common.delete")}
onClick={() => onDelete(tx)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger"
>
<Trash2 size={15} />
</button>
</span>
</li>
);
})}
</ul>
<Dialog
open={noteTransaction !== null}
onClose={() => setNoteTransaction(null)}
title={t("transactions.noteDialogTitle")}
>
<p className="whitespace-pre-wrap text-sm text-text-primary">{noteTransaction?.notes}</p>
</Dialog>
</>
); );
} }

View File

@@ -160,6 +160,9 @@ const ru = {
"transactions.form.categoryLabel": "Категория", "transactions.form.categoryLabel": "Категория",
"transactions.form.noCategory": "Без категории", "transactions.form.noCategory": "Без категории",
"transactions.form.merchantLabel": "Продавец (необязательно)", "transactions.form.merchantLabel": "Продавец (необязательно)",
"transactions.form.notesLabel": "Заметка (необязательно)",
"transactions.viewNote": "Посмотреть заметку",
"transactions.noteDialogTitle": "Заметка",
"transactions.form.errorSelectAccount": "Выберите счёт", "transactions.form.errorSelectAccount": "Выберите счёт",
"transactions.form.errorSelectDestination": "Выберите счёт назначения для перевода", "transactions.form.errorSelectDestination": "Выберите счёт назначения для перевода",
"transactions.form.errorSameAccount": "Счёт назначения должен отличаться от исходного счёта", "transactions.form.errorSameAccount": "Счёт назначения должен отличаться от исходного счёта",
@@ -486,6 +489,9 @@ const en: Record<keyof typeof ru, string> = {
"transactions.form.categoryLabel": "Category", "transactions.form.categoryLabel": "Category",
"transactions.form.noCategory": "No category", "transactions.form.noCategory": "No category",
"transactions.form.merchantLabel": "Merchant (optional)", "transactions.form.merchantLabel": "Merchant (optional)",
"transactions.form.notesLabel": "Note (optional)",
"transactions.viewNote": "View note",
"transactions.noteDialogTitle": "Note",
"transactions.form.errorSelectAccount": "Select an account", "transactions.form.errorSelectAccount": "Select an account",
"transactions.form.errorSelectDestination": "Select a destination account for the transfer", "transactions.form.errorSelectDestination": "Select a destination account for the transfer",
"transactions.form.errorSameAccount": "The destination account must be different from the source account", "transactions.form.errorSameAccount": "The destination account must be different from the source account",