feat(transactions): add imported date column, split filter, and sortable columns
- Show created_at as "Imported" column in transactions and shared views - For reconciled transactions, show original CSV import date (not statement processing date) via LEFT JOIN on reconciled_with_id - Add has_split filter (all/split only/unsplit only) to transactions page - Transactions table: sortable by imported date; split filter dropdown - Shared table: client-side sort by date, imported, and amount
This commit is contained in:
@@ -24,6 +24,7 @@ export async function GET(req: NextRequest) {
|
||||
offset: sp.get("offset") ? Number(sp.get("offset")) : undefined,
|
||||
amount_min: sp.get("amount_min") ? Number(sp.get("amount_min")) : undefined,
|
||||
amount_max: sp.get("amount_max") ? Number(sp.get("amount_max")) : undefined,
|
||||
has_split: sp.get("has_split") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
|
||||
+44
-7
@@ -270,10 +270,30 @@ function PaymentHistory({ participantId, currentUserId }: { participantId: numbe
|
||||
}
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||
type SortCol = "transaction_date" | "created_at" | "amount";
|
||||
|
||||
export default function SharedPage() {
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
const [sortCol, setSortCol] = useState<SortCol>("transaction_date");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
const realTagIds = tagIds.filter((id) => id !== "untagged");
|
||||
const { data: transactions = [], isLoading: txLoading } = useSharedTransactions(tagIds);
|
||||
const { data: rawTransactions = [], isLoading: txLoading } = useSharedTransactions(tagIds);
|
||||
|
||||
const transactions = [...rawTransactions].sort((a, b) => {
|
||||
const av = sortCol === "amount" ? Number(a.amount) : new Date(a[sortCol]).getTime();
|
||||
const bv = sortCol === "amount" ? Number(b.amount) : new Date(b[sortCol]).getTime();
|
||||
return sortDir === "desc" ? bv - av : av - bv;
|
||||
});
|
||||
|
||||
function toggleSort(col: SortCol) {
|
||||
if (sortCol === col) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
|
||||
else { setSortCol(col); setSortDir("desc"); }
|
||||
}
|
||||
|
||||
function SortIcon({ col }: { col: SortCol }) {
|
||||
if (sortCol !== col) return <span className="text-zinc-600 ml-0.5">↕</span>;
|
||||
return <span className="ml-0.5">{sortDir === "desc" ? "↓" : "↑"}</span>;
|
||||
}
|
||||
const { data: balances = [], isLoading: balLoading } = useParticipantBalances(realTagIds);
|
||||
const { data: me } = useCurrentUser();
|
||||
const [addingParticipant, setAddingParticipant] = useState(false);
|
||||
@@ -353,7 +373,7 @@ export default function SharedPage() {
|
||||
</div>
|
||||
|
||||
{/* Transaction list */}
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-x-auto">
|
||||
<div className="px-4 py-3 border-b border-zinc-800">
|
||||
<h3 className="text-sm font-medium">Split Transactions</h3>
|
||||
</div>
|
||||
@@ -364,12 +384,28 @@ export default function SharedPage() {
|
||||
No split transactions yet. Use the Split button on any transaction.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full text-sm min-w-[520px]">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Date</th>
|
||||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Description</th>
|
||||
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">Amount</th>
|
||||
<th
|
||||
className="text-left px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white whitespace-nowrap"
|
||||
onClick={() => toggleSort("transaction_date")}
|
||||
>
|
||||
Date <SortIcon col="transaction_date" />
|
||||
</th>
|
||||
<th
|
||||
className="text-left px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white whitespace-nowrap"
|
||||
onClick={() => toggleSort("created_at")}
|
||||
>
|
||||
Imported <SortIcon col="created_at" />
|
||||
</th>
|
||||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">Description</th>
|
||||
<th
|
||||
className="text-right px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white"
|
||||
onClick={() => toggleSort("amount")}
|
||||
>
|
||||
Amount <SortIcon col="amount" />
|
||||
</th>
|
||||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Splits</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
@@ -380,7 +416,8 @@ export default function SharedPage() {
|
||||
return (
|
||||
<tr key={tx.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
||||
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">{formatDate(tx.transaction_date)}</td>
|
||||
<td className="px-4 py-3 max-w-xs">
|
||||
<td className="px-4 py-3 text-zinc-500 text-xs whitespace-nowrap">{formatDate(tx.created_at)}</td>
|
||||
<td className="px-4 py-3 max-w-xs sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">
|
||||
<p className="font-medium break-words">{tx.effective_merchant || tx.description}</p>
|
||||
{tx.effective_merchant && (
|
||||
<p className="text-xs text-zinc-500 break-words">{tx.description}</p>
|
||||
|
||||
@@ -134,12 +134,12 @@ export default function StatementsPage() {
|
||||
) : !filtered.length ? (
|
||||
<p className="text-zinc-500 text-sm">{hasFilters ? "No statements match filters" : "No statements found"}</p>
|
||||
) : (
|
||||
<div className="border border-zinc-700 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<div className="border border-zinc-700 rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[800px]">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-900">
|
||||
<th className="text-left px-3 py-2.5 text-xs text-zinc-600 font-medium w-8">#</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Bank</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">Bank</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Account</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Period</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Due / End</th>
|
||||
@@ -147,7 +147,7 @@ export default function StatementsPage() {
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Amount</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Txns</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Owner</th>
|
||||
<th className="px-4 py-2.5"></th>
|
||||
<th className="px-4 py-2.5 hidden sm:table-cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -164,13 +164,19 @@ export default function StatementsPage() {
|
||||
return (
|
||||
<tr key={s.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/20 transition-colors">
|
||||
<td className="px-3 py-3 text-xs text-zinc-600 tabular-nums">{idx + 1}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium truncate max-w-[180px]" title={s.bank_name}>
|
||||
<td className="px-4 py-3 sticky left-0 z-10 bg-zinc-950 border-r border-zinc-800/80">
|
||||
<div className="font-medium truncate max-w-[160px]" title={s.bank_name}>
|
||||
{s.bank_name}
|
||||
</div>
|
||||
{s.card_name && (
|
||||
<div className="text-xs text-zinc-500 truncate max-w-[180px]">{s.card_name}</div>
|
||||
<div className="text-xs text-zinc-500 truncate max-w-[160px]">{s.card_name}</div>
|
||||
)}
|
||||
<Link
|
||||
href={`/transactions?statement_id=${s.id}`}
|
||||
className="sm:hidden text-xs text-indigo-400 hover:text-indigo-300 mt-1 inline-block"
|
||||
>
|
||||
View →
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-400 font-mono text-xs">
|
||||
{s.account_number}
|
||||
@@ -211,7 +217,7 @@ export default function StatementsPage() {
|
||||
<span className="text-zinc-600 text-xs">{s.owner_name}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<td className="px-4 py-3 hidden sm:table-cell">
|
||||
<Link
|
||||
href={`/transactions?statement_id=${s.id}`}
|
||||
className="px-3 py-1 bg-zinc-800 hover:bg-zinc-700 rounded text-xs transition-colors whitespace-nowrap"
|
||||
|
||||
@@ -476,6 +476,7 @@ function TransactionsContent() {
|
||||
offset: 0,
|
||||
amount_min: undefined as number | undefined,
|
||||
amount_max: undefined as number | undefined,
|
||||
has_split: "" as string,
|
||||
});
|
||||
const [queryInput, setQueryInput] = useState("");
|
||||
const [queryTokens, setQueryTokens] = useState<QueryToken[]>([]);
|
||||
@@ -610,7 +611,7 @@ function TransactionsContent() {
|
||||
value={queryInput}
|
||||
onChange={(e) => handleQueryChange(e.target.value)}
|
||||
placeholder="Search… or >500 <=1500 200-800"
|
||||
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm w-64 font-mono placeholder:font-sans placeholder:text-zinc-600"
|
||||
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm w-full sm:w-64 font-mono placeholder:font-sans placeholder:text-zinc-600"
|
||||
/>
|
||||
{queryTokens.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -677,6 +678,15 @@ function TransactionsContent() {
|
||||
onChange={(v) => setFilters((f) => ({ ...f, transaction_types: v, offset: 0 }))}
|
||||
placeholder="All Types"
|
||||
/>
|
||||
<select
|
||||
value={filters.has_split}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, has_split: e.target.value, offset: 0 }))}
|
||||
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm text-zinc-300"
|
||||
>
|
||||
<option value="">All Splits</option>
|
||||
<option value="yes">Split only</option>
|
||||
<option value="no">Unsplit only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Bulk action bar */}
|
||||
@@ -749,10 +759,10 @@ function TransactionsContent() {
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto border border-zinc-800 rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full text-sm min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-900/50">
|
||||
<th className="p-2 w-8">
|
||||
<th className="p-2 w-8 sticky left-0 z-10 bg-zinc-900">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data?.data.length ? selected.size === data.data.length : false}
|
||||
@@ -761,11 +771,17 @@ function TransactionsContent() {
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
className="p-2 text-left cursor-pointer hover:text-white"
|
||||
className="p-2 text-left cursor-pointer hover:text-white sticky left-8 z-10 bg-zinc-900 border-r border-zinc-800/80 whitespace-nowrap"
|
||||
onClick={() => toggleSort("transaction_date")}
|
||||
>
|
||||
Date {filters.sort_by === "transaction_date" && (filters.sort_dir === "desc" ? "\u2193" : "\u2191")}
|
||||
</th>
|
||||
<th
|
||||
className="p-2 text-left text-zinc-500 cursor-pointer hover:text-white whitespace-nowrap"
|
||||
onClick={() => toggleSort("created_at")}
|
||||
>
|
||||
Imported {filters.sort_by === "created_at" && (filters.sort_dir === "desc" ? "\u2193" : "\u2191")}
|
||||
</th>
|
||||
<th className="p-2 text-left">Description</th>
|
||||
<th className="p-2 text-left">Merchant</th>
|
||||
<th
|
||||
@@ -783,9 +799,9 @@ function TransactionsContent() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr><td colSpan={10} className="p-8 text-center text-zinc-500">Loading...</td></tr>
|
||||
<tr><td colSpan={11} className="p-8 text-center text-zinc-500">Loading...</td></tr>
|
||||
) : !data?.data.length ? (
|
||||
<tr><td colSpan={10} className="p-8 text-center text-zinc-500">No transactions found</td></tr>
|
||||
<tr><td colSpan={11} className="p-8 text-center text-zinc-500">No transactions found</td></tr>
|
||||
) : (
|
||||
data.data.map((t) => (
|
||||
<tr
|
||||
@@ -794,7 +810,7 @@ function TransactionsContent() {
|
||||
selected.has(t.id) ? "bg-zinc-800/40" : ""
|
||||
}`}
|
||||
>
|
||||
<td className="p-2">
|
||||
<td className={`p-2 sticky left-0 z-10 ${selected.has(t.id) ? "bg-zinc-800" : "bg-zinc-950"}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(t.id)}
|
||||
@@ -802,7 +818,8 @@ function TransactionsContent() {
|
||||
className="accent-blue-600"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2 whitespace-nowrap">{formatDate(t.transaction_date)}</td>
|
||||
<td className={`p-2 whitespace-nowrap sticky left-8 z-10 border-r border-zinc-800/80 ${selected.has(t.id) ? "bg-zinc-800" : "bg-zinc-950"}`}>{formatDate(t.transaction_date)}</td>
|
||||
<td className="p-2 whitespace-nowrap text-zinc-500 text-xs">{formatDate(t.created_at)}</td>
|
||||
<td className="p-2 max-w-xs">
|
||||
<p className="truncate" title={t.description}>{t.description}</p>
|
||||
{t.notes && (
|
||||
|
||||
@@ -26,6 +26,7 @@ interface TransactionFilters {
|
||||
offset?: number;
|
||||
amount_min?: number;
|
||||
amount_max?: number;
|
||||
has_split?: string;
|
||||
}
|
||||
|
||||
function buildParams(filters: TransactionFilters): string {
|
||||
|
||||
+12
-2
@@ -82,6 +82,7 @@ interface TransactionFilters {
|
||||
offset?: number;
|
||||
amount_min?: number;
|
||||
amount_max?: number;
|
||||
has_split?: string;
|
||||
}
|
||||
|
||||
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
|
||||
@@ -148,10 +149,15 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
||||
conditions.push(`t.amount <= $${paramIdx++}`);
|
||||
params.push(filters.amount_max);
|
||||
}
|
||||
if (filters.has_split === "yes") {
|
||||
conditions.push(`EXISTS (SELECT 1 FROM transaction_splits ts_f WHERE ts_f.transaction_id = t.id)`);
|
||||
} else if (filters.has_split === "no") {
|
||||
conditions.push(`NOT EXISTS (SELECT 1 FROM transaction_splits ts_f WHERE ts_f.transaction_id = t.id)`);
|
||||
}
|
||||
|
||||
const where = `WHERE ${conditions.join(" AND ")}`;
|
||||
|
||||
const sortCol = filters.sort_by === "amount" ? "t.amount" : "t.transaction_date";
|
||||
const sortCol = filters.sort_by === "amount" ? "t.amount" : filters.sort_by === "created_at" ? "t.created_at" : "t.transaction_date";
|
||||
const sortDir = filters.sort_dir === "asc" ? "ASC" : "DESC";
|
||||
const limit = filters.limit || 50;
|
||||
const offset = filters.offset || 0;
|
||||
@@ -174,12 +180,14 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
||||
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
||||
p.name as owner_name,
|
||||
COALESCE(src.created_at, t.created_at) as created_at,
|
||||
txn_tags.tags,
|
||||
txn_splits.splits
|
||||
FROM transactions t
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id)
|
||||
LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(json_agg(json_build_object('id', tg.id, 'name', tg.name, 'color', tg.color) ORDER BY tg.name), '[]'::json) as tags
|
||||
FROM transaction_tags tt
|
||||
@@ -552,6 +560,7 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
|
||||
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
||||
p_owner.name as owner_name,
|
||||
COALESCE(src.created_at, t.created_at) as created_at,
|
||||
json_agg(json_build_object(
|
||||
'split_id', ts.id,
|
||||
'participant_id', ts.participant_id,
|
||||
@@ -565,6 +574,7 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
LEFT JOIN participants p_owner ON p_owner.id = COALESCE(t.owner_id, s.owner_id)
|
||||
LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL
|
||||
WHERE (
|
||||
(
|
||||
COALESCE(t.owner_id, s.owner_id) = $1
|
||||
@@ -575,7 +585,7 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
|
||||
)
|
||||
)
|
||||
${tagClause}
|
||||
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name
|
||||
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name, src.created_at
|
||||
ORDER BY t.transaction_date DESC
|
||||
`, params);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user