feat: add collaborator management UI
This commit is contained in:
parent
ab273ea7d0
commit
1d7007637d
14
app/api/households/[id]/invites/route.ts
Normal file
14
app/api/households/[id]/invites/route.ts
Normal 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`);
|
||||
}
|
||||
10
app/api/households/[id]/members/[memberId]/route.ts
Normal file
10
app/api/households/[id]/members/[memberId]/route.ts
Normal 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}`);
|
||||
}
|
||||
10
app/api/households/[id]/members/route.ts
Normal file
10
app/api/households/[id]/members/route.ts
Normal 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`);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user