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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user