feat: add collaborator management UI

This commit is contained in:
MOHAN 2026-07-16 18:13:30 +05:30
parent ab273ea7d0
commit 1d7007637d
4 changed files with 172 additions and 5 deletions

View File

@ -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`);
}

View File

@ -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}`);
}

View File

@ -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`);
}

View File

@ -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<DashboardData | null>(null);
const [newName, setNewName] = useState("");
const [goals, setGoals] = useState<HouseholdGoal[]>([]);
const [invites, setInvites] = useState<HouseholdInvite[]>([]);
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<HouseholdInvite[]>(`/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<HouseholdInvite>(`/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<HouseholdMember>(`/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() {
</div>
<div className="glass-panel rounded-2xl p-6 shadow-sm">
<h2 className="text-lg font-bold text-foreground">Members</h2>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 className="text-lg font-bold text-foreground">Collaborators</h2>
<p className="mt-1 text-sm text-muted-foreground">Invite accountants or advisors with view-only household access.</p>
</div>
<span className="rounded-full border border-border bg-secondary/60 px-3 py-1 text-xs font-semibold text-muted-foreground">
{dashboard.members.length} active
</span>
</div>
<form onSubmit={inviteCollaborator} className="mt-4 grid gap-2 sm:grid-cols-[1fr_130px_auto]">
<input
value={inviteDraft.email}
onChange={(event) => setInviteDraft((prev) => ({ ...prev, email: event.target.value }))}
type="email"
placeholder="advisor@example.com"
className={inputClass}
/>
<select
value={inviteDraft.role}
onChange={(event) => setInviteDraft((prev) => ({ ...prev, role: event.target.value }))}
className={inputClass}
>
<option value="accountant">Accountant</option>
<option value="advisor">Advisor</option>
<option value="viewer">Viewer</option>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
<button
type="submit"
disabled={!inviteDraft.email.trim()}
className="rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Invite
</button>
</form>
{collaborationStatus && <p className="mt-3 text-sm text-muted-foreground">{collaborationStatus}</p>}
<div className="mt-4 space-y-3">
{dashboard.members.map((member) => (
<div key={member.id} className="flex items-center justify-between gap-3 rounded-xl border border-border bg-background/40 p-4">
<div key={member.id} className="rounded-xl border border-border bg-background/40 p-4">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{member.user?.fullName || member.user?.email || member.userId}</p>
<p className="truncate text-sm text-muted-foreground">{member.user?.email}</p>
</div>
<span className="rounded-full border border-border bg-secondary/60 px-3 py-1 text-xs font-semibold capitalize text-muted-foreground">
<div className="mt-3 grid gap-2 sm:grid-cols-[1fr_1fr_auto]">
<select
value={member.role}
onChange={(event) => updateMemberAccess(member, { role: event.target.value })}
className={inputClass}
>
<option value="owner">Owner</option>
{collaboratorRoles.map((role) => (
<option key={role} value={role}>{role}</option>
))}
</select>
<select
value={member.status ?? "active"}
onChange={(event) => updateMemberAccess(member, { status: event.target.value })}
className={inputClass}
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="removed">Removed</option>
</select>
<span className="rounded-lg border border-border bg-secondary/60 px-3 py-2 text-xs font-semibold capitalize text-muted-foreground">
{member.role}
</span>
</div>
</div>
))}
</div>
{!!invites.length && (
<div className="mt-5">
<p className="text-sm font-semibold text-foreground">Pending invites</p>
<div className="mt-3 space-y-2">
{invites.filter((invite) => invite.status === "pending").map((invite) => (
<div key={invite.id} className="flex items-center justify-between gap-3 rounded-xl border border-border bg-background/40 p-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{invite.email}</p>
<p className="text-xs capitalize text-muted-foreground">{invite.role} · expires {new Date(invite.expiresAt).toLocaleDateString()}</p>
</div>
<span className="rounded-full border border-border bg-secondary/60 px-2 py-1 text-[11px] font-semibold capitalize text-muted-foreground">{invite.status}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>