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;
|
status?: string;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
|
commentCount?: number;
|
||||||
date: string;
|
date: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TransactionComment = {
|
||||||
|
id: string;
|
||||||
|
body: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
author: {
|
||||||
|
displayName: string;
|
||||||
|
email?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
type Account = {
|
type Account = {
|
||||||
viewRef: string;
|
viewRef: string;
|
||||||
institutionName: string;
|
institutionName: string;
|
||||||
@ -125,6 +137,10 @@ export default function TransactionsPage() {
|
|||||||
splitYoursPercent: "50",
|
splitYoursPercent: "50",
|
||||||
});
|
});
|
||||||
const [editingRef, setEditingRef] = useState<string | null>(null);
|
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({
|
const [editForm, setEditForm] = useState({
|
||||||
category: "",
|
category: "",
|
||||||
note: "",
|
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 () => {
|
const saveEdit = async () => {
|
||||||
if (!editingRef) return;
|
if (!editingRef) return;
|
||||||
setStatus("Saving edits...");
|
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 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 labelCls = "text-xs font-semibold text-muted-foreground uppercase tracking-wider";
|
||||||
|
const selectedCommentRow = rows.find((row) => row.viewRef === commentRef) ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell title="Transactions" subtitle="View, sync, and categorize your transactions.">
|
<AppShell title="Transactions" subtitle="View, sync, and categorize your transactions.">
|
||||||
@ -780,6 +828,68 @@ export default function TransactionsPage() {
|
|||||||
{status && (
|
{status && (
|
||||||
<div className="px-6 py-3 bg-secondary/30 border-b border-border text-sm text-muted-foreground">{status}</div>
|
<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">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-xs text-muted-foreground">
|
<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">
|
<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}
|
{formatAmount(row.amount).display}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user