feat(households): hane seçici, ayarlar, admin hane yönetimi ve hane-yok sayfası

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 02:35:01 +03:00
parent de91f2505a
commit 3644e081b1
21 changed files with 1427 additions and 29 deletions
+74
View File
@@ -0,0 +1,74 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import {
adminAddMember,
adminCreateHousehold,
adminRemoveMember,
adminSetMemberRole,
} from "@/features/households/service";
import { tr } from "@/i18n/tr";
import { fail, type ActionResult } from "@/lib/action-result";
import { getActionUser } from "@/lib/dal";
import {
addMemberSchema,
householdCreateSchema,
householdRoleSchema,
} from "@/shared/schemas/household";
/** Sistem admini: hane ve üyelik yönetimi. Hanenin finansal verisine dokunmaz. */
const PATH = "/admin/haneler";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
export async function createHouseholdAction(input: unknown): Promise<ActionResult<{ id: string }>> {
const admin = await getActionUser("admin");
if (!admin) return fail(tr.common.unauthorized);
const parsed = householdCreateSchema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields);
const result = await adminCreateHousehold(admin.id, parsed.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
export async function addMemberAction(householdId: unknown, input: unknown): Promise<ActionResult> {
const admin = await getActionUser("admin");
if (!admin) return fail(tr.common.unauthorized);
const hid = idSchema.safeParse(householdId);
const parsed = addMemberSchema.safeParse(input);
if (!hid.success || !parsed.success) return fail(tr.adminUsers.errors.checkFields);
const result = await adminAddMember(admin.id, hid.data, parsed.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
export async function adminSetMemberRoleAction(
householdId: unknown,
userId: unknown,
role: unknown,
): Promise<ActionResult> {
const admin = await getActionUser("admin");
if (!admin) return fail(tr.common.unauthorized);
const hid = idSchema.safeParse(householdId);
const uid = idSchema.safeParse(userId);
const r = householdRoleSchema.safeParse(role);
if (!hid.success || !uid.success || !r.success) return fail(tr.common.genericError);
const result = await adminSetMemberRole(admin.id, hid.data, uid.data, r.data);
if (result.ok) revalidatePath(PATH);
return result;
}
export async function adminRemoveMemberAction(
householdId: unknown,
userId: unknown,
): Promise<ActionResult> {
const admin = await getActionUser("admin");
if (!admin) return fail(tr.common.unauthorized);
const hid = idSchema.safeParse(householdId);
const uid = idSchema.safeParse(userId);
if (!hid.success || !uid.success) return fail(tr.common.genericError);
const result = await adminRemoveMember(admin.id, hid.data, uid.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
@@ -0,0 +1,306 @@
"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>
);
}
@@ -0,0 +1,48 @@
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { UserOption } from "@/features/households/service";
import { tr } from "@/i18n/tr";
/** Kullanıcı seçici (askıdakiler işaretli). */
export function UserSelect({
id,
users,
value,
onChange,
invalid,
}: {
id: string;
users: UserOption[];
value: string;
onChange: (id: string) => void;
invalid?: boolean;
}) {
const selected = users.find((u) => u.id === value);
return (
<Select value={value || undefined} onValueChange={onChange}>
<SelectTrigger id={id} className="w-full" aria-invalid={invalid}>
<SelectValue placeholder={tr.adminHouseholds.selectUser}>
{selected ? `${selected.name} (@${selected.username})` : null}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-72">
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name}
<span className="text-xs text-muted-foreground">
@{u.username}
{u.banned ? ` · ${tr.settings.banned}` : ""}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
+10
View File
@@ -0,0 +1,10 @@
import "server-only";
import { adminListHouseholds, adminListUserOptions } from "@/features/households/service";
import { requireRole } from "@/lib/dal";
/** Admin: haneler (üyeleriyle) ve hane atamaları için kullanıcı listesi. */
export async function getAdminHouseholdsData() {
await requireRole("admin");
const [households, users] = await Promise.all([adminListHouseholds(), adminListUserOptions()]);
return { households, users };
}