Files
gutscheintracker/app/settings/server-households.tsx
T

290 lines
9.5 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
type Household = {
id: number;
name: string;
createdAt: string;
mitgliederAnzahl: number;
gutscheineAnzahl: number;
mitglieder: { userId: number; name: string; email: string | null; rolle: string }[];
};
type UnassignedUser = { id: number; name: string; email: string | null };
/** Haushalte anlegen/auflösen und unzugeordnete Nutzer zuweisen (nur Server-Admin). */
export default function ServerHouseholds() {
const [households, setHouseholds] = useState<Household[]>([]);
const [unassigned, setUnassigned] = useState<UnassignedUser[]>([]);
const [name, setName] = useState("");
const [notice, setNotice] = useState("");
async function load() {
const [hh, users] = await Promise.all([
fetch("/api/households").then((r) => (r.ok ? r.json() : [])),
fetch("/api/users").then((r) => (r.ok ? r.json() : [])),
]);
setHouseholds(Array.isArray(hh) ? hh : []);
setUnassigned(
(Array.isArray(users) ? users : []).filter(
(u: { householdId: number | null }) => u.householdId === null
)
);
}
useEffect(() => {
load();
}, []);
async function create() {
setNotice("");
const trimmed = name.trim();
if (!trimmed) {
setNotice("Bitte einen Haushaltsnamen eingeben.");
return;
}
const res = await fetch("/api/households", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: trimmed }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) setNotice(data.message ?? "Haushalt konnte nicht angelegt werden.");
else {
setName("");
setNotice(`Haushalt „${trimmed}“ angelegt.`);
load();
}
}
async function remove(id: number, householdName: string, gutscheineAnzahl: number, mitgliederAnzahl: number) {
if (gutscheineAnzahl > 0 || mitgliederAnzahl > 0) {
if (
!window.confirm(
`„${householdName}“ wirklich auflösen? Dabei werden ${gutscheineAnzahl} Gutschein(e) samt Erinnerungen unwiderruflich gelöscht. Mitglieder behalten nur ihre privaten Gutscheine.`
)
) {
return;
}
} else if (!window.confirm(`Leeren Haushalt „${householdName}“ löschen?`)) {
return;
}
const res = await fetch(`/api/households?id=${id}`, { method: "DELETE" });
if (!res.ok) setNotice("Löschen fehlgeschlagen.");
else {
setNotice("");
load();
}
}
async function assign(userId: number, householdId: number | null, asAdmin: boolean) {
const res = await fetch(`/api/users/${userId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ householdId, householdRole: asAdmin ? "admin" : "member" }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) setNotice(data.message ?? "Zuordnen fehlgeschlagen.");
else {
setNotice("");
load();
}
}
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 anlegen</h2>
<div className="mt-3 flex gap-2">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="z. B. Familie Müller"
aria-label="Haushaltsname"
maxLength={100}
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={create}
className="h-11 shrink-0 rounded-field bg-brand-600 px-5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
>
Anlegen
</button>
</div>
</section>
{households.map((h) => (
<HouseholdCard key={h.id} household={h} unassigned={unassigned} onAssign={assign} onDelete={remove} />
))}
{unassigned.length > 0 && (
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Nutzer ohne Haushalt</h2>
<p className="mt-1 text-[13px] text-muted">Selbst registriert, bisher nur mit privatem Bestand.</p>
<ul className="mt-3 divide-y divide-line">
{unassigned.map((u) => (
<AssignRow key={u.id} user={u} households={households} onAssign={assign} />
))}
</ul>
</section>
)}
{notice && (
<p role="status" className="text-sm font-medium text-muted">
{notice}
</p>
)}
</div>
);
}
function HouseholdCard({
household,
unassigned,
onAssign,
onDelete,
}: {
household: Household;
unassigned: UnassignedUser[];
onAssign: (userId: number, householdId: number | null, asAdmin: boolean) => void;
onDelete: (id: number, name: string, gutscheine: number, mitglieder: number) => void;
}) {
return (
<section className="rounded-card border border-line bg-white p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-[15px] font-semibold tracking-tight">{household.name}</h2>
<button
type="button"
onClick={() => onDelete(household.id, household.name, household.gutscheineAnzahl, household.mitgliederAnzahl)}
className="shrink-0 rounded-lg px-2 py-1 text-[13px] font-medium text-muted hover:text-danger"
>
Haushalt auflösen
</button>
</div>
<p className="mt-1 text-[13px] text-muted">
{household.mitgliederAnzahl} Mitglied(er) · {household.gutscheineAnzahl} Gutschein(e) im gemeinsamen Bestand
</p>
{household.mitglieder.length > 0 && (
<ul className="mt-3 divide-y divide-line">
{household.mitglieder.map((m) => (
<li key={m.userId} className="flex items-center justify-between gap-3 py-2 text-sm">
<div className="min-w-0">
<p className="truncate font-medium">{m.name}</p>
<p className="truncate text-[13px] text-muted">
{m.email ?? "keine E-Mail"} · {m.rolle === "admin" ? "Haushalts-Admin" : "Mitglied"}
</p>
</div>
<button
type="button"
onClick={() => onAssign(m.userId, null, false)}
className="shrink-0 rounded-lg px-2 py-1 text-[13px] font-medium text-muted hover:text-danger"
title="Aus dem Haushalt entfernen (bleibt als Nutzer mit privatem Bestand erhalten)"
>
Entfernen
</button>
</li>
))}
</ul>
)}
{unassigned.length > 0 && (
<AssignRowList householdId={household.id} unassigned={unassigned} onAssign={onAssign} />
)}
</section>
);
}
function AssignRowList({
householdId,
unassigned,
onAssign,
}: {
householdId: number;
unassigned: UnassignedUser[];
onAssign: (userId: number, householdId: number | null, asAdmin: boolean) => void;
}) {
return (
<details className="mt-3">
<summary className="cursor-pointer text-[13px] font-semibold text-brand-700">Unzugeordneten Nutzer hinzufügen</summary>
<ul className="mt-2 divide-y divide-line">
{unassigned.map((u) => (
<AssignRow key={u.id} user={u} households={[]} onAssign={onAssign} fixedHouseholdId={householdId} />
))}
</ul>
</details>
);
}
function AssignRow({
user,
households,
onAssign,
fixedHouseholdId,
}: {
user: UnassignedUser;
households: { id: number; name: string }[];
onAssign: (userId: number, householdId: number | null, asAdmin: boolean) => void;
fixedHouseholdId?: number;
}) {
return (
<li className="flex flex-wrap items-center justify-between gap-2 py-2 text-sm">
<span className="font-medium">
{user.name} <span className="font-normal text-muted">{user.email ?? ""}</span>
</span>
<span className="flex gap-2">
{fixedHouseholdId !== undefined ? (
<>
<button
type="button"
onClick={() => onAssign(user.id, fixedHouseholdId, false)}
className="rounded-lg border border-line px-2 py-1 text-[13px] font-medium hover:border-brand-600 hover:text-brand-700"
>
Als Mitglied
</button>
<button
type="button"
onClick={() => onAssign(user.id, fixedHouseholdId, true)}
className="rounded-lg border border-line px-2 py-1 text-[13px] font-medium hover:border-brand-600 hover:text-brand-700"
>
Als Admin
</button>
</>
) : (
<AssignSelect userId={user.id} households={households} onAssign={onAssign} />
)}
</span>
</li>
);
}
function AssignSelect({
userId,
households,
onAssign,
}: {
userId: number;
households: { id: number; name: string }[];
onAssign: (userId: number, householdId: number | null, asAdmin: boolean) => void;
}) {
return (
<span className="flex gap-2">
<select
aria-label="Haushalt auswählen"
defaultValue=""
onChange={(e) => {
if (e.target.value) onAssign(userId, Number(e.target.value), false);
}}
className="h-9 rounded-field border border-line bg-white px-2 text-[13px]"
>
<option value="">Haushalt …</option>
{households.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
</span>
);
}