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
# title and embedded in exported backups so an old file can be told apart
# from a current one.
APP_VERSION = "0.19.2"
APP_VERSION = "0.20.0"
class Settings(BaseSettings):

View File

@@ -23,9 +23,13 @@ trap cleanup EXIT
$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
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"
break
fi

View File

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

View File

@@ -233,6 +233,15 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
/>
</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>}
<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 { formatCurrency, formatTransactionDate } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
@@ -13,67 +15,88 @@ interface TransactionsTableProps {
export function TransactionsTable({ items, onEdit, onDelete }: TransactionsTableProps) {
const { t } = useTranslation();
const [noteTransaction, setNoteTransaction] = useState<Transaction | null>(null);
if (items.length === 0) {
return <p className="py-12 text-center text-sm text-text-muted">{t("transactions.noneFound")}</p>;
}
return (
<ul className="divide-y divide-gridline">
{items.map((tx) => {
const isTransfer = tx.type === "transfer";
const isExpense = tx.type === "expense";
const Icon = isTransfer ? ArrowLeftRight : getCategoryIcon(tx.category?.icon);
const color = isTransfer ? "var(--text-muted)" : tx.category?.color ?? "var(--text-muted)";
<>
<ul className="divide-y divide-gridline">
{items.map((tx) => {
const isTransfer = tx.type === "transfer";
const isExpense = tx.type === "expense";
const Icon = isTransfer ? ArrowLeftRight : getCategoryIcon(tx.category?.icon);
const color = isTransfer ? "var(--text-muted)" : tx.category?.color ?? "var(--text-muted)";
return (
<li key={tx.id} className="group flex items-center gap-3 py-3">
<span
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)" }}
>
<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)}` : ""}
return (
<li key={tx.id} className="group flex items-center gap-3 py-3">
<span
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)" }}
>
<Icon size={16} style={{ color }} />
</span>
</span>
<span
className={`shrink-0 text-sm font-medium tabular-nums ${
isTransfer ? "text-text-muted" : isExpense ? "text-text-primary" : "text-success"
}`}
>
{isTransfer ? "" : isExpense ? "-" : "+"}
{formatCurrency(tx.amount)}
</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 className="flex shrink-0 gap-1">
<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"
<span
className={`shrink-0 text-sm font-medium tabular-nums ${
isTransfer ? "text-text-muted" : isExpense ? "text-text-primary" : "text-success"
}`}
>
<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>
{isTransfer ? "" : isExpense ? "-" : "+"}
{formatCurrency(tx.amount)}
</span>
<span className="flex shrink-0 gap-1">
{tx.notes && (
<button
type="button"
aria-label={t("transactions.viewNote")}
onClick={() => setNoteTransaction(tx)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
>
<StickyNote size={15} />
</button>
)}
<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.noCategory": "Без категории",
"transactions.form.merchantLabel": "Продавец (необязательно)",
"transactions.form.notesLabel": "Заметка (необязательно)",
"transactions.viewNote": "Посмотреть заметку",
"transactions.noteDialogTitle": "Заметка",
"transactions.form.errorSelectAccount": "Выберите счёт",
"transactions.form.errorSelectDestination": "Выберите счёт назначения для перевода",
"transactions.form.errorSameAccount": "Счёт назначения должен отличаться от исходного счёта",
@@ -486,6 +489,9 @@ const en: Record<keyof typeof ru, string> = {
"transactions.form.categoryLabel": "Category",
"transactions.form.noCategory": "No category",
"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.errorSelectDestination": "Select a destination account for the transfer",
"transactions.form.errorSameAccount": "The destination account must be different from the source account",