Add transaction comments UI
This commit is contained in:
parent
5ed97c2a90
commit
1dbca1bcd4
16
app/api/transactions/[id]/comments/route.ts
Normal file
16
app/api/transactions/[id]/comments/route.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
return proxyRequest(req, `transactions/${params.id}/comments`);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
return proxyRequest(req, `transactions/${params.id}/comments`);
|
||||
}
|
||||
@ -22,9 +22,21 @@ type TransactionRow = {
|
||||
};
|
||||
status?: string;
|
||||
hidden?: boolean;
|
||||
commentCount?: number;
|
||||
date: string;
|
||||
};
|
||||
|
||||
type TransactionComment = {
|
||||
id: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author: {
|
||||
displayName: string;
|
||||
email?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
type Account = {
|
||||
viewRef: string;
|
||||
institutionName: string;
|
||||
@ -125,6 +137,10 @@ export default function TransactionsPage() {
|
||||
splitYoursPercent: "50",
|
||||
});
|
||||
const [editingRef, setEditingRef] = useState<string | null>(null);
|
||||
const [commentRef, setCommentRef] = useState<string | null>(null);
|
||||
const [comments, setComments] = useState<TransactionComment[]>([]);
|
||||
const [commentDraft, setCommentDraft] = useState("");
|
||||
const [commentStatus, setCommentStatus] = useState("");
|
||||
const [editForm, setEditForm] = useState({
|
||||
category: "",
|
||||
note: "",
|
||||
@ -400,6 +416,37 @@ export default function TransactionsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const openComments = async (row: TransactionRow) => {
|
||||
setCommentRef(row.viewRef);
|
||||
setComments([]);
|
||||
setCommentDraft("");
|
||||
setCommentStatus("Loading comments...");
|
||||
const res = await apiFetch<TransactionComment[]>(`/api/transactions/${row.viewRef}/comments`);
|
||||
if (res.error) {
|
||||
setCommentStatus(res.error.message ?? "Unable to load comments.");
|
||||
return;
|
||||
}
|
||||
setComments(res.data ?? []);
|
||||
setCommentStatus("");
|
||||
};
|
||||
|
||||
const createComment = async () => {
|
||||
if (!commentRef || !commentDraft.trim()) return;
|
||||
setCommentStatus("Posting comment...");
|
||||
const res = await apiFetch<TransactionComment>(`/api/transactions/${commentRef}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body: commentDraft }),
|
||||
});
|
||||
if (res.error) {
|
||||
setCommentStatus(res.error.message ?? "Unable to post comment.");
|
||||
return;
|
||||
}
|
||||
if (res.data) setComments((prev) => [...prev, res.data]);
|
||||
setCommentDraft("");
|
||||
setCommentStatus("");
|
||||
await load();
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editingRef) return;
|
||||
setStatus("Saving edits...");
|
||||
@ -423,6 +470,7 @@ export default function TransactionsPage() {
|
||||
|
||||
const inputCls = "mt-2 w-full rounded-md border border-border bg-background/50 px-3 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
|
||||
const labelCls = "text-xs font-semibold text-muted-foreground uppercase tracking-wider";
|
||||
const selectedCommentRow = rows.find((row) => row.viewRef === commentRef) ?? null;
|
||||
|
||||
return (
|
||||
<AppShell title="Transactions" subtitle="View, sync, and categorize your transactions.">
|
||||
@ -780,6 +828,68 @@ export default function TransactionsPage() {
|
||||
{status && (
|
||||
<div className="px-6 py-3 bg-secondary/30 border-b border-border text-sm text-muted-foreground">{status}</div>
|
||||
)}
|
||||
{selectedCommentRow && (
|
||||
<div className="border-b border-border bg-secondary/10 p-5">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Transaction chat</p>
|
||||
<h2 className="mt-1 text-base font-bold text-foreground">{selectedCommentRow.description ?? selectedCommentRow.name ?? "Transaction"}</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{new Date(selectedCommentRow.date).toLocaleDateString()} · {formatAmount(selectedCommentRow.amount).display}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCommentRef(null);
|
||||
setComments([]);
|
||||
setCommentDraft("");
|
||||
setCommentStatus("");
|
||||
}}
|
||||
className="rounded border border-border px-3 py-1 text-xs font-semibold text-foreground hover:bg-secondary"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-[1fr_320px]">
|
||||
<div className="max-h-64 space-y-3 overflow-y-auto rounded-lg border border-border bg-background/40 p-3">
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className="rounded-lg border border-border bg-background p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold text-foreground">{comment.author.displayName}</p>
|
||||
<p className="text-xs text-muted-foreground">{new Date(comment.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{comment.body}</p>
|
||||
</div>
|
||||
))}
|
||||
{!comments.length && !commentStatus && (
|
||||
<p className="text-sm text-muted-foreground">No comments yet.</p>
|
||||
)}
|
||||
{commentStatus && <p className="text-sm text-muted-foreground">{commentStatus}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<textarea
|
||||
value={commentDraft}
|
||||
onChange={(event) => setCommentDraft(event.target.value)}
|
||||
maxLength={1000}
|
||||
placeholder="Add a note for this transaction"
|
||||
className="h-32 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-muted-foreground">{commentDraft.length}/1000</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={createComment}
|
||||
disabled={!commentDraft.trim()}
|
||||
className="rounded bg-primary px-4 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Post comment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs text-muted-foreground">
|
||||
<thead className="text-[0.65rem] uppercase tracking-[0.18em] font-semibold bg-secondary/20">
|
||||
@ -897,7 +1007,12 @@ export default function TransactionsPage() {
|
||||
{formatAmount(row.amount).display}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button onClick={() => startEdit(row)} className="text-xs text-primary hover:underline">Edit</button>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => openComments(row)} className="text-xs text-primary hover:underline">
|
||||
Comments{row.commentCount ? ` (${row.commentCount})` : ""}
|
||||
</button>
|
||||
<button onClick={() => startEdit(row)} className="text-xs text-primary hover:underline">Edit</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user