From 1d7007637dc9397abd3bc20af2f758b03624ea2e Mon Sep 17 00:00:00 2001 From: MOHAN Date: Thu, 16 Jul 2026 18:13:30 +0530 Subject: [PATCH] feat: add collaborator management UI --- app/api/households/[id]/invites/route.ts | 14 ++ .../[id]/members/[memberId]/route.ts | 10 ++ app/api/households/[id]/members/route.ts | 10 ++ app/settings/households/page.tsx | 143 +++++++++++++++++- 4 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 app/api/households/[id]/invites/route.ts create mode 100644 app/api/households/[id]/members/[memberId]/route.ts create mode 100644 app/api/households/[id]/members/route.ts diff --git a/app/api/households/[id]/invites/route.ts b/app/api/households/[id]/invites/route.ts new file mode 100644 index 0000000..38b0af2 --- /dev/null +++ b/app/api/households/[id]/invites/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +type RouteContext = { + params: { id: string }; +}; + +export async function GET(req: NextRequest, { params }: RouteContext) { + return proxyRequest(req, `households/${params.id}/invites`); +} + +export async function POST(req: NextRequest, { params }: RouteContext) { + return proxyRequest(req, `households/${params.id}/invites`); +} diff --git a/app/api/households/[id]/members/[memberId]/route.ts b/app/api/households/[id]/members/[memberId]/route.ts new file mode 100644 index 0000000..9a4597b --- /dev/null +++ b/app/api/households/[id]/members/[memberId]/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +type RouteContext = { + params: { id: string; memberId: string }; +}; + +export async function PATCH(req: NextRequest, { params }: RouteContext) { + return proxyRequest(req, `households/${params.id}/members/${params.memberId}`); +} diff --git a/app/api/households/[id]/members/route.ts b/app/api/households/[id]/members/route.ts new file mode 100644 index 0000000..d246ad1 --- /dev/null +++ b/app/api/households/[id]/members/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +type RouteContext = { + params: { id: string }; +}; + +export async function GET(req: NextRequest, { params }: RouteContext) { + return proxyRequest(req, `households/${params.id}/members`); +} diff --git a/app/settings/households/page.tsx b/app/settings/households/page.tsx index e80ce4b..3387f25 100644 --- a/app/settings/households/page.tsx +++ b/app/settings/households/page.tsx @@ -14,6 +14,7 @@ type HouseholdMember = { id: string; userId: string; role: string; + status?: string; joinedAt: string; user?: { email: string; @@ -21,6 +22,16 @@ type HouseholdMember = { }; }; +type HouseholdInvite = { + id: string; + email: string; + role: string; + status: string; + expiresAt: string; + acceptedAt?: string | null; + createdAt: string; +}; + type HouseholdAccount = { displayId: string; institutionName: string; @@ -121,6 +132,7 @@ const money = new Intl.NumberFormat("en-US", { const inputClass = "w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"; const cardClass = "rounded-xl border border-border bg-secondary/10 p-5"; +const collaboratorRoles = ["admin", "member", "viewer", "accountant", "advisor"] as const; function formatMoney(value: number) { return money.format(value || 0); @@ -147,6 +159,9 @@ export default function HouseholdSettingsPage() { const [dashboard, setDashboard] = useState(null); const [newName, setNewName] = useState(""); const [goals, setGoals] = useState([]); + const [invites, setInvites] = useState([]); + const [inviteDraft, setInviteDraft] = useState({ email: "", role: "accountant" }); + const [collaborationStatus, setCollaborationStatus] = useState(""); const [goalStatus, setGoalStatus] = useState(""); const [goalSaving, setGoalSaving] = useState(false); const [goalDraft, setGoalDraft] = useState({ @@ -175,10 +190,12 @@ export default function HouseholdSettingsPage() { if (!selectedId) { setDashboard(null); setGoals([]); + setInvites([]); return; } loadDashboard(selectedId); loadGoals(selectedId); + loadInvites(selectedId); }, [selectedId]); const loadHouseholds = async () => { @@ -216,6 +233,15 @@ export default function HouseholdSettingsPage() { setGoalStatus(""); }; + const loadInvites = async (id: string) => { + const res = await apiFetch(`/api/households/${id}/invites`); + if (res.error) { + setInvites([]); + return; + } + setInvites(res.data ?? []); + }; + const createHousehold = async (event: FormEvent) => { event.preventDefault(); const name = newName.trim(); @@ -304,6 +330,40 @@ export default function HouseholdSettingsPage() { await loadGoals(selectedId); }; + const inviteCollaborator = async (event: FormEvent) => { + event.preventDefault(); + if (!selectedId || !inviteDraft.email.trim()) return; + const res = await apiFetch(`/api/households/${selectedId}/invites`, { + method: "POST", + body: JSON.stringify({ + email: inviteDraft.email, + role: inviteDraft.role, + }), + }); + if (res.error) { + setCollaborationStatus(res.error.message ?? "Unable to invite collaborator."); + return; + } + setInviteDraft({ email: "", role: "accountant" }); + setCollaborationStatus("Invite sent."); + await loadInvites(selectedId); + }; + + const updateMemberAccess = async (member: HouseholdMember, payload: { role?: string; status?: string }) => { + if (!selectedId) return; + const res = await apiFetch(`/api/households/${selectedId}/members/${member.id}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); + if (res.error) { + setCollaborationStatus(res.error.message ?? "Unable to update collaborator access."); + return; + } + setCollaborationStatus("Collaborator access updated."); + await loadDashboard(selectedId); + await loadInvites(selectedId); + }; + const maxCashflow = Math.max( 1, ...(dashboard?.cashflow ?? []).map((month) => Math.max(month.income, month.expenses)), @@ -598,20 +658,93 @@ export default function HouseholdSettingsPage() {
-

Members

+
+
+

Collaborators

+

Invite accountants or advisors with view-only household access.

+
+ + {dashboard.members.length} active + +
+
+ setInviteDraft((prev) => ({ ...prev, email: event.target.value }))} + type="email" + placeholder="advisor@example.com" + className={inputClass} + /> + + +
+ {collaborationStatus &&

{collaborationStatus}

}
{dashboard.members.map((member) => ( -
+

{member.user?.fullName || member.user?.email || member.userId}

{member.user?.email}

- - {member.role} - +
+ + + + {member.role} + +
))}
+ {!!invites.length && ( +
+

Pending invites

+
+ {invites.filter((invite) => invite.status === "pending").map((invite) => ( +
+
+

{invite.email}

+

{invite.role} ยท expires {new Date(invite.expiresAt).toLocaleDateString()}

+
+ {invite.status} +
+ ))} +
+
+ )}