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,12 +15,14 @@ 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"> <ul className="divide-y divide-gridline">
{items.map((tx) => { {items.map((tx) => {
const isTransfer = tx.type === "transfer"; const isTransfer = tx.type === "transfer";
@@ -54,6 +58,16 @@ export function TransactionsTable({ items, onEdit, onDelete }: TransactionsTable
</span> </span>
<span className="flex shrink-0 gap-1"> <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 <button
type="button" type="button"
aria-label={t("common.edit")} aria-label={t("common.edit")}
@@ -75,5 +89,14 @@ export function TransactionsTable({ items, onEdit, onDelete }: TransactionsTable
); );
})} })}
</ul> </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",