From bf628f67b606fe2732d389d55bfcdfdce9e9ab39 Mon Sep 17 00:00:00 2001 From: Henry Winkel Date: Sat, 16 May 2026 21:04:09 +0200 Subject: [PATCH] feat: add StockTable component with search, sort, pagination, inline editing --- app/components/StockTable.tsx | 204 ++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 app/components/StockTable.tsx diff --git a/app/components/StockTable.tsx b/app/components/StockTable.tsx new file mode 100644 index 0000000..7c71037 --- /dev/null +++ b/app/components/StockTable.tsx @@ -0,0 +1,204 @@ +import React, { useState, useMemo } from "react"; + +interface Stock { + id: string; + ticker: string; + notes: string | null; + lastDecision: string | null; + lastJobId: string | null; + createdAt: string; + updatedAt: string; +} + +interface StockTableProps { + stocks: Stock[]; + onNotesSave: (ticker: string, notes: string) => Promise; + saveError: string | null; +} + +type SortField = "ticker" | "createdAt" | "updatedAt"; +type SortDirection = "asc" | "desc"; + +export default function StockTable({ stocks, onNotesSave, saveError }: StockTableProps) { + const [search, setSearch] = useState(""); + const [sortField, setSortField] = useState("ticker"); + const [sortDirection, setSortDirection] = useState("asc"); + const [editingTicker, setEditingTicker] = useState(null); + const [editingNotes, setEditingNotes] = useState(""); + const [savingNotes, setSavingNotes] = useState(null); + const [page, setPage] = useState(0); + const pageSize = 20; + + const filtered = useMemo(() => { + const q = search.toLowerCase(); + const result = stocks.filter((s) => s.ticker.toLowerCase().includes(q)); + result.sort((a, b) => { + const aVal = a[sortField] ?? ""; + const bVal = b[sortField] ?? ""; + const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; + return sortDirection === "asc" ? cmp : -cmp; + }); + return result; + }, [stocks, search, sortField, sortDirection]); + + const totalPages = Math.ceil(filtered.length / pageSize); + const paged = filtered.slice(page * pageSize, (page + 1) * pageSize); + + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortDirection((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortField(field); + setSortDirection("asc"); + } + }; + + const startEditing = (stock: Stock) => { + setEditingTicker(stock.ticker); + setEditingNotes(stock.notes ?? ""); + }; + + const saveNotes = async () => { + if (!editingTicker) return; + setSavingNotes(editingTicker); + try { + await onNotesSave(editingTicker, editingNotes); + } catch (e) { + console.error("Failed to save notes:", e); + } finally { + setSavingNotes(null); + setEditingTicker(null); + } + }; + + const SortHeader = ({ field, children }: { field: SortField; children: React.ReactNode }) => ( + handleSort(field)} + > + {children} + {sortField === field && {sortDirection === "asc" ? "↑" : "↓"}} + + ); + + if (stocks.length === 0) { + return ( +
+
+

Stock Database

+

Manage tracked stocks and their analysis notes.

+
+

No stocks tracked yet. Visit the stocks page to add some.

+
+ ); + } + + return ( +
+
+

Stock Database

+

Manage tracked stocks and their analysis notes.

+
+ + {saveError && ( +
{saveError}
+ )} + +
+ { setSearch(e.target.value); setPage(0); }} + className="border border-gray-300 rounded-lg px-4 py-2.5 w-64 focus:ring-2 focus:ring-blue-500" + /> + {filtered.length} stock{filtered.length !== 1 ? "s" : ""} +
+ +
+ + + + Ticker + + + + Created + Updated + + + + {paged.map((stock) => ( + + + + + + + + + ))} + +
NotesLast DecisionLast Job
{stock.ticker} + {editingTicker === stock.ticker ? ( + setEditingNotes(e.target.value)} + onBlur={saveNotes} + onKeyDown={(e) => { if (e.key === "Enter") saveNotes(); if (e.key === "Escape") setEditingTicker(null); }} + className="w-full border border-blue-300 rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500" + autoFocus + /> + ) : ( + startEditing(stock)} + > + {stock.notes || Click to add notes...} + + )} + + {stock.lastDecision ? ( + {stock.lastDecision.toUpperCase()} + ) : "-"} + + {stock.lastJobId ? ( + {stock.lastJobId.slice(0, 12)}... + ) : "-"} + {new Date(stock.createdAt).toLocaleDateString()}{new Date(stock.updatedAt).toLocaleDateString()}
+
+ + {filtered.length === 0 && ( +

No stocks found matching "{search}"

+ )} + + {totalPages > 1 && ( +
+ + Showing {page * pageSize + 1}-{Math.min((page + 1) * pageSize, filtered.length)} of {filtered.length} + +
+ + +
+
+ )} +
+ ); +}