Files
gutscheintracker/app/settings/household-panel.tsx
T

198 lines
7.6 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
type Member = { id: number; name: string; email: string | null; role: string; oidcVerknuepft: boolean };
type InviteRow = { id: number; email: string; expiresAt: string };
const inputClass =
"h-11 w-full rounded-field border border-line bg-white px-3.5 text-sm text-ink placeholder:text-muted/60";
/** Eigener Haushalt (für Haushalts-Admins, die kein Server-Admin sind). */
export function HouseholdPanel({ householdId, householdName, ownId, ownEmail }: { householdId: number; householdName: string; ownId: number; ownEmail: string | null }) {
const [members, setMembers] = useState<Member[]>([]);
const [invites, setInvites] = useState<InviteRow[]>([]);
const [inviteMail, setInviteMail] = useState("");
const [inviteLink, setInviteLink] = useState("");
const [myEmail, setMyEmail] = useState(ownEmail ?? "");
const [notice, setNotice] = useState("");
async function load() {
const [u, inv] = await Promise.all([
fetch("/api/users").then((r) => (r.ok ? r.json() : [])),
fetch("/api/users/invites").then((r) => (r.ok ? r.json() : [])),
]);
setMembers(Array.isArray(u) ? u : []);
setInvites(Array.isArray(inv) ? inv : []);
}
useEffect(() => {
load();
}, []);
async function invite() {
setNotice("");
setInviteLink("");
const res = await fetch("/api/users/invites", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: inviteMail }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setNotice(data.message ?? "Einladung fehlgeschlagen.");
return;
}
setInviteMail("");
setInviteLink(data.url ?? "");
if (data.mailError) setNotice(`Einladung angelegt, aber E-Mail-Versand schlug fehl: ${data.mailError}`);
else setNotice("Einladung erstellt (7 Tage gültig).");
load();
}
async function revoke(id: number) {
await fetch(`/api/users/invites?id=${id}`, { method: "DELETE" });
load();
}
async function removeMember(id: number, name: string) {
if (id === ownId) return;
if (!window.confirm(`„${name}“ aus „${householdName}“ entfernen? Private Gutscheine bleiben erhalten.`)) return;
const res = await fetch(`/api/users/${id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ householdId: null }),
});
if (!res.ok) setNotice("Entfernen fehlgeschlagen.");
else setNotice("");
load();
}
async function saveMyEmail() {
setNotice("");
const res = await fetch("/api/users/me", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: myEmail }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) setNotice(data.message ?? "E-Mail konnte nicht gespeichert werden.");
else setNotice("Eigene E-Mail gespeichert.");
}
return (
<div className="grid gap-4">
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Haushalt: {householdName}</h2>
{members.length === 0 ? (
<p className="mt-2 text-sm text-muted">Noch keine Mitglieder.</p>
) : (
<ul className="mt-3 divide-y divide-line">
{members.map((m) => (
<li key={m.id} className="flex items-center justify-between gap-3 py-2.5 text-sm">
<div className="min-w-0">
<p className="font-semibold">
{m.name}
{m.id === ownId && <span className="ml-2 font-normal text-muted">(du)</span>}
</p>
<p className="truncate text-[13px] text-muted">
{m.email ?? "keine E-Mail"} · {m.role === "admin" ? "Haushalts-Admin" : "Mitglied"}
{m.oidcVerknuepft ? " · SSO verknüpft" : ""}
</p>
</div>
{m.id !== ownId && m.role !== "admin" && (
<button
type="button"
onClick={() => removeMember(m.id, m.name)}
className="shrink-0 rounded-lg px-2 py-1 text-[13px] font-medium text-muted hover:text-danger"
>
Entfernen
</button>
)}
</li>
))}
</ul>
)}
</section>
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Eigene E-Mail-Adresse</h2>
<p className="mt-1 text-[13px] text-muted">Für die SSO-Verknüpfung nötig.</p>
<div className="mt-3 flex gap-2">
<input
value={myEmail}
onChange={(e) => setMyEmail(e.target.value)}
type="email"
autoComplete="email"
placeholder="name@beispiel.de"
aria-label="Eigene E-Mail-Adresse"
className="h-11 min-w-0 flex-1 rounded-field border border-line bg-white px-3.5 text-sm text-ink"
/>
<button
type="button"
onClick={saveMyEmail}
className="h-11 shrink-0 rounded-field bg-brand-600 px-5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
>
Speichern
</button>
</div>
</section>
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Per E-Mail einladen</h2>
<p className="mt-1 text-[13px] text-muted">Einladung ist 7 Tage gültig und fügt direkt „{householdName}“ hinzu.</p>
<div className="mt-3 flex gap-2">
<input
value={inviteMail}
onChange={(e) => setInviteMail(e.target.value)}
type="email"
autoComplete="off"
placeholder="name@beispiel.de"
aria-label="E-Mail-Adresse für Einladung"
className="h-11 min-w-0 flex-1 rounded-field border border-line bg-white px-3.5 text-sm text-ink"
/>
<button
type="button"
onClick={invite}
className="h-11 shrink-0 rounded-field bg-brand-600 px-5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
>
Einladen
</button>
</div>
{inviteLink && (
<p className="mt-2 break-all text-[13px]">
Einladungslink:{" "}
<a href={inviteLink} className="font-medium text-brand-700 underline">
{inviteLink}
</a>
</p>
)}
{invites.length > 0 && (
<ul className="mt-3 divide-y divide-line">
{invites.map((inv) => (
<li key={inv.id} className="flex items-center justify-between gap-3 py-2 text-sm">
<div className="min-w-0">
<p className="truncate font-medium">{inv.email}</p>
<p className="text-[13px] text-muted">gültig bis {new Date(inv.expiresAt).toLocaleDateString("de-DE")}</p>
</div>
<button
type="button"
onClick={() => revoke(inv.id)}
className="shrink-0 rounded-lg px-2 py-1 text-[13px] font-medium text-muted hover:text-danger"
>
Zurückziehen
</button>
</li>
))}
</ul>
)}
{notice && (
<p role="status" className="mt-3 text-sm text-muted">
{notice}
</p>
)}
</section>
</div>
);
}