3644e081b1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
307 lines
9.8 KiB
TypeScript
307 lines
9.8 KiB
TypeScript
"use client";
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { Loader2, Plus, UserPlus } from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
import { Controller, useForm } from "react-hook-form";
|
|
import { toast } from "sonner";
|
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Card,
|
|
CardAction,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { MembersList } from "@/features/households/components/members-list";
|
|
import type { AdminHouseholdRow, UserOption } from "@/features/households/service";
|
|
import { HOUSEHOLD_ROLES } from "@/features/households/types";
|
|
import { tr } from "@/i18n/tr";
|
|
import {
|
|
addMemberSchema,
|
|
householdCreateSchema,
|
|
type AddMemberInput,
|
|
type HouseholdCreateInput,
|
|
} from "@/shared/schemas/household";
|
|
import {
|
|
addMemberAction,
|
|
adminRemoveMemberAction,
|
|
adminSetMemberRoleAction,
|
|
createHouseholdAction,
|
|
} from "../actions";
|
|
import { UserSelect } from "./user-select";
|
|
|
|
const t = tr.adminHouseholds;
|
|
|
|
function CreateHouseholdDialog({
|
|
open,
|
|
onOpenChange,
|
|
users,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (o: boolean) => void;
|
|
users: UserOption[];
|
|
}) {
|
|
const form = useForm<HouseholdCreateInput>({
|
|
resolver: zodResolver(householdCreateSchema),
|
|
defaultValues: { name: "", ownerUserId: "" },
|
|
});
|
|
useEffect(() => {
|
|
if (open) form.reset({ name: "", ownerUserId: "" });
|
|
}, [open, form]);
|
|
|
|
async function onSubmit(values: HouseholdCreateInput) {
|
|
const result = await createHouseholdAction(values);
|
|
if (result.ok) {
|
|
toast.success(t.created(values.name));
|
|
onOpenChange(false);
|
|
} else {
|
|
form.setError("root", { message: result.error });
|
|
}
|
|
}
|
|
|
|
return (
|
|
<ResponsiveDialog
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
title={t.createTitle}
|
|
description={t.createDescription}
|
|
footer={
|
|
<>
|
|
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
|
{tr.common.cancel}
|
|
</Button>
|
|
<Button type="submit" form="household-create" disabled={form.formState.isSubmitting}>
|
|
{form.formState.isSubmitting ? <Loader2 className="animate-spin" /> : null}
|
|
{tr.common.create}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<form id="household-create" onSubmit={form.handleSubmit(onSubmit)} noValidate>
|
|
<FieldGroup>
|
|
<Controller
|
|
name="name"
|
|
control={form.control}
|
|
render={({ field, fieldState }) => (
|
|
<Field data-invalid={fieldState.invalid}>
|
|
<FieldLabel htmlFor="hh-create-name">{t.name}</FieldLabel>
|
|
<Input {...field} id="hh-create-name" aria-invalid={fieldState.invalid} />
|
|
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
|
</Field>
|
|
)}
|
|
/>
|
|
<Controller
|
|
name="ownerUserId"
|
|
control={form.control}
|
|
render={({ field, fieldState }) => (
|
|
<Field data-invalid={fieldState.invalid}>
|
|
<FieldLabel htmlFor="hh-create-owner">{t.owner}</FieldLabel>
|
|
<UserSelect
|
|
id="hh-create-owner"
|
|
users={users}
|
|
value={field.value}
|
|
onChange={field.onChange}
|
|
invalid={fieldState.invalid}
|
|
/>
|
|
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
|
</Field>
|
|
)}
|
|
/>
|
|
{form.formState.errors.root ? (
|
|
<FieldError>{form.formState.errors.root.message}</FieldError>
|
|
) : null}
|
|
</FieldGroup>
|
|
</form>
|
|
</ResponsiveDialog>
|
|
);
|
|
}
|
|
|
|
function AddMemberDialog({
|
|
household,
|
|
users,
|
|
onOpenChange,
|
|
}: {
|
|
household: AdminHouseholdRow | null;
|
|
users: UserOption[];
|
|
onOpenChange: (o: boolean) => void;
|
|
}) {
|
|
const form = useForm<AddMemberInput>({
|
|
resolver: zodResolver(addMemberSchema),
|
|
defaultValues: { userId: "", role: "member" },
|
|
});
|
|
useEffect(() => {
|
|
if (household) form.reset({ userId: "", role: "member" });
|
|
}, [household, form]);
|
|
|
|
async function onSubmit(values: AddMemberInput) {
|
|
if (!household) return;
|
|
const result = await addMemberAction(household.id, values);
|
|
if (result.ok) {
|
|
toast.success(t.memberAdded(users.find((u) => u.id === values.userId)?.name ?? ""));
|
|
onOpenChange(false);
|
|
} else {
|
|
form.setError("root", { message: result.error });
|
|
}
|
|
}
|
|
|
|
return (
|
|
<ResponsiveDialog
|
|
open={Boolean(household)}
|
|
onOpenChange={onOpenChange}
|
|
title={household ? t.addMemberTitle(household.name) : t.addMember}
|
|
description={t.addMemberDescription}
|
|
footer={
|
|
<>
|
|
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
|
{tr.common.cancel}
|
|
</Button>
|
|
<Button type="submit" form="household-add-member" disabled={form.formState.isSubmitting}>
|
|
{form.formState.isSubmitting ? <Loader2 className="animate-spin" /> : null}
|
|
{t.addMember}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<form id="household-add-member" onSubmit={form.handleSubmit(onSubmit)} noValidate>
|
|
<FieldGroup>
|
|
<Controller
|
|
name="userId"
|
|
control={form.control}
|
|
render={({ field, fieldState }) => (
|
|
<Field data-invalid={fieldState.invalid}>
|
|
<FieldLabel htmlFor="hh-member-user">{t.user}</FieldLabel>
|
|
<UserSelect
|
|
id="hh-member-user"
|
|
users={users}
|
|
value={field.value}
|
|
onChange={field.onChange}
|
|
invalid={fieldState.invalid}
|
|
/>
|
|
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
|
</Field>
|
|
)}
|
|
/>
|
|
<Controller
|
|
name="role"
|
|
control={form.control}
|
|
render={({ field }) => (
|
|
<Field>
|
|
<FieldLabel htmlFor="hh-member-role">{t.role}</FieldLabel>
|
|
<Select value={field.value} onValueChange={field.onChange}>
|
|
<SelectTrigger
|
|
id="hh-member-role"
|
|
className="w-full"
|
|
aria-describedby="hh-member-role-hint"
|
|
>
|
|
<SelectValue>{tr.household.roles[field.value]}</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{HOUSEHOLD_ROLES.map((r) => (
|
|
<SelectItem key={r} value={r}>
|
|
{tr.household.roles[r]}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<FieldDescription id="hh-member-role-hint">
|
|
{tr.household.roleHints[field.value]}
|
|
</FieldDescription>
|
|
</Field>
|
|
)}
|
|
/>
|
|
{form.formState.errors.root ? (
|
|
<FieldError>{form.formState.errors.root.message}</FieldError>
|
|
) : null}
|
|
</FieldGroup>
|
|
</form>
|
|
</ResponsiveDialog>
|
|
);
|
|
}
|
|
|
|
export function HouseholdsAdmin({
|
|
households,
|
|
users,
|
|
currentUserId,
|
|
}: {
|
|
households: AdminHouseholdRow[];
|
|
users: UserOption[];
|
|
currentUserId: string;
|
|
}) {
|
|
const [creating, setCreating] = useState(false);
|
|
const [addingTo, setAddingTo] = useState<AdminHouseholdRow | null>(null);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<p className="max-w-2xl text-sm text-muted-foreground">{t.privacyNote}</p>
|
|
<Button className="ml-auto" onClick={() => setCreating(true)}>
|
|
<Plus />
|
|
{t.newHousehold}
|
|
</Button>
|
|
</div>
|
|
|
|
{households.length === 0 ? (
|
|
<p className="rounded-xl border border-dashed p-8 text-center text-muted-foreground">
|
|
{t.empty}
|
|
</p>
|
|
) : (
|
|
<div className="grid gap-4 xl:grid-cols-2">
|
|
{households.map((h) => (
|
|
<Card key={h.id}>
|
|
<CardHeader>
|
|
<CardTitle>{h.name}</CardTitle>
|
|
<CardDescription className="flex flex-wrap items-center gap-2">
|
|
<span>{t.members(h.memberCount)}</span>
|
|
{h.owners.length ? (
|
|
<span>
|
|
· {t.owners}: {h.owners.join(", ")}
|
|
</span>
|
|
) : (
|
|
<Badge variant="destructive">{t.noOwner}</Badge>
|
|
)}
|
|
</CardDescription>
|
|
<CardAction>
|
|
<Button variant="outline" size="sm" onClick={() => setAddingTo(h)}>
|
|
<UserPlus />
|
|
{t.addMember}
|
|
</Button>
|
|
</CardAction>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<MembersList
|
|
members={h.members}
|
|
householdName={h.name}
|
|
currentUserId={currentUserId}
|
|
canManage
|
|
onSetRole={(userId, role) => adminSetMemberRoleAction(h.id, userId, role)}
|
|
onRemove={(userId) => adminRemoveMemberAction(h.id, userId)}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<CreateHouseholdDialog open={creating} onOpenChange={setCreating} users={users} />
|
|
<AddMemberDialog
|
|
household={addingTo}
|
|
users={users}
|
|
onOpenChange={(o) => !o && setAddingTo(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|