290 lines
9.5 KiB
TypeScript
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>
|
|
);
|
|
}
|