mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-12 18:46:40 +07:00
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:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "aurum-frontend",
|
||||
"private": true,
|
||||
"version": "0.19.2",
|
||||
"version": "0.20.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user