feat(categories): kategori ağacı, varsayılan Türkçe set ve yönetim ekranı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { getHouseholdActionContext } from "@/lib/dal";
|
||||||
|
import { categoryFormSchema } from "@/shared/schemas/category";
|
||||||
|
import { createCategory, reorderCategory, setCategoryArchived, updateCategory } from "./service";
|
||||||
|
|
||||||
|
const PATH = "/kategoriler";
|
||||||
|
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||||
|
const checkFields = tr.adminUsers.errors.checkFields;
|
||||||
|
|
||||||
|
export async function createCategoryAction(input: unknown): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const ctx = await getHouseholdActionContext("member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const parsed = categoryFormSchema.safeParse(input);
|
||||||
|
if (!parsed.success) return fail(checkFields);
|
||||||
|
const result = await createCategory(ctx.user.id, ctx.household.id, parsed.data);
|
||||||
|
if (result.ok) revalidatePath(PATH);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCategoryAction(
|
||||||
|
categoryId: unknown,
|
||||||
|
input: unknown,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await getHouseholdActionContext("member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const id = idSchema.safeParse(categoryId);
|
||||||
|
const parsed = categoryFormSchema.safeParse(input);
|
||||||
|
if (!id.success || !parsed.success) return fail(checkFields);
|
||||||
|
const result = await updateCategory(ctx.user.id, ctx.household.id, id.data, parsed.data);
|
||||||
|
if (result.ok) revalidatePath(PATH);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reorderCategoryAction(
|
||||||
|
categoryId: unknown,
|
||||||
|
direction: unknown,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await getHouseholdActionContext("member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const id = idSchema.safeParse(categoryId);
|
||||||
|
const dir = z.enum(["up", "down"]).safeParse(direction);
|
||||||
|
if (!id.success || !dir.success) return fail(tr.common.genericError);
|
||||||
|
const result = await reorderCategory(ctx.user.id, ctx.household.id, id.data, dir.data);
|
||||||
|
if (result.ok) revalidatePath(PATH);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setCategoryArchivedAction(
|
||||||
|
categoryId: unknown,
|
||||||
|
archived: unknown,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await getHouseholdActionContext("member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const id = idSchema.safeParse(categoryId);
|
||||||
|
const a = z.boolean().safeParse(archived);
|
||||||
|
if (!id.success || !a.success) return fail(tr.common.genericError);
|
||||||
|
const result = await setCategoryArchived(ctx.user.id, ctx.household.id, id.data, a.data);
|
||||||
|
if (result.ok) revalidatePath(PATH);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Archive,
|
||||||
|
ArchiveRestore,
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo, useState, useTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { IconBadge } from "@/components/appearance/swatch";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { CategoryKind } from "@/shared/schemas/category";
|
||||||
|
import { reorderCategoryAction, setCategoryArchivedAction } from "../actions";
|
||||||
|
import type { CategoryView } from "../service";
|
||||||
|
import { buildTree, MAX_DEPTH, type CategoryNode } from "../tree";
|
||||||
|
import { CategoryFormDialog, type CategoryDialogState } from "./category-form-dialog";
|
||||||
|
|
||||||
|
const t = tr.categories;
|
||||||
|
|
||||||
|
export function CategoriesManager({
|
||||||
|
categories,
|
||||||
|
canEdit,
|
||||||
|
}: {
|
||||||
|
categories: CategoryView[];
|
||||||
|
canEdit: boolean;
|
||||||
|
}) {
|
||||||
|
const [kind, setKind] = useState<CategoryKind>("expense");
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [dialog, setDialog] = useState<CategoryDialogState>(null);
|
||||||
|
const [archiving, setArchiving] = useState<CategoryView | null>(null);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const tree = useMemo(
|
||||||
|
() => buildTree(categories.filter((c) => c.kind === kind && (showArchived || !c.archived))),
|
||||||
|
[categories, kind, showArchived],
|
||||||
|
);
|
||||||
|
const archivedCount = categories.filter((c) => c.kind === kind && c.archived).length;
|
||||||
|
|
||||||
|
function run(promise: Promise<{ ok: boolean; error?: string }>, success?: string) {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await promise;
|
||||||
|
if (result.ok) {
|
||||||
|
if (success) toast.success(success);
|
||||||
|
} else toast.error(result.error ?? tr.common.genericError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
node,
|
||||||
|
index,
|
||||||
|
siblings,
|
||||||
|
}: {
|
||||||
|
node: CategoryNode<CategoryView>;
|
||||||
|
index: number;
|
||||||
|
siblings: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<li
|
||||||
|
className={cn("flex items-center gap-3 py-2 pr-2", node.archived && "opacity-60")}
|
||||||
|
style={{ paddingInlineStart: `${0.75 + node.depth * 1.5}rem` }}
|
||||||
|
>
|
||||||
|
<IconBadge icon={node.icon} color={node.color} size={node.depth ? "sm" : "md"} />
|
||||||
|
<span className={cn("min-w-0 flex-1 truncate", node.depth === 0 && "font-medium")}>
|
||||||
|
{node.name}
|
||||||
|
</span>
|
||||||
|
{node.archived ? <Badge variant="outline">{t.archived}</Badge> : null}
|
||||||
|
{canEdit ? (
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`${node.name} — ${tr.common.actions}`}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
<MoreHorizontal />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-52">
|
||||||
|
{!node.archived ? (
|
||||||
|
<>
|
||||||
|
{node.depth < MAX_DEPTH - 1 ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => setDialog({ mode: "create", kind, parentId: node.id })}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
{t.addChild}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
<DropdownMenuItem onSelect={() => setDialog({ mode: "edit", category: node })}>
|
||||||
|
<Pencil />
|
||||||
|
{t.edit}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={index === 0}
|
||||||
|
onSelect={() => run(reorderCategoryAction(node.id, "up"))}
|
||||||
|
>
|
||||||
|
<ArrowUp />
|
||||||
|
{t.moveUp}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={index === siblings - 1}
|
||||||
|
onSelect={() => run(reorderCategoryAction(node.id, "down"))}
|
||||||
|
>
|
||||||
|
<ArrowDown />
|
||||||
|
{t.moveDown}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem variant="destructive" onSelect={() => setArchiving(node)}>
|
||||||
|
<Archive />
|
||||||
|
{t.archive}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
run(setCategoryArchivedAction(node.id, false), t.unarchivedToast(node.name))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ArchiveRestore />
|
||||||
|
{t.unarchive}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
{node.children.map((child, i) => (
|
||||||
|
<Row key={child.id} node={child} index={i} siblings={node.children.length} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Tabs value={kind} onValueChange={(v) => setKind(v as CategoryKind)}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="expense">{t.expense}</TabsTrigger>
|
||||||
|
<TabsTrigger value="income">{t.income}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
{archivedCount ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="cat-show-archived"
|
||||||
|
checked={showArchived}
|
||||||
|
onCheckedChange={(v) => setShowArchived(v === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="cat-show-archived" className="font-normal">
|
||||||
|
{t.showArchived} ({archivedCount})
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{canEdit ? (
|
||||||
|
<Button
|
||||||
|
className="ml-auto"
|
||||||
|
onClick={() => setDialog({ mode: "create", kind, parentId: null })}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
{t.newCategory}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{!canEdit ? <p className="text-sm text-muted-foreground">{t.readOnly}</p> : null}
|
||||||
|
|
||||||
|
{tree.length ? (
|
||||||
|
<ul className="divide-y rounded-xl border bg-card" aria-busy={pending}>
|
||||||
|
{tree.map((node, i) => (
|
||||||
|
<Row key={node.id} node={node} index={i} siblings={tree.length} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-xl border border-dashed p-8 text-center text-muted-foreground">
|
||||||
|
{t.empty}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CategoryFormDialog
|
||||||
|
state={dialog}
|
||||||
|
categories={categories}
|
||||||
|
onOpenChange={(o) => !o && setDialog(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog open={Boolean(archiving)} onOpenChange={(o) => !o && setArchiving(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t.archiveTitle}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{archiving ? t.archiveDescription(archiving.name) : null}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
const c = archiving!;
|
||||||
|
setArchiving(null);
|
||||||
|
run(setCategoryArchivedAction(c.id, true), t.archivedToast(c.name));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.archive}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { IconPicker, SwatchPicker } from "@/components/appearance/pickers";
|
||||||
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
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 { tr } from "@/i18n/tr";
|
||||||
|
import {
|
||||||
|
categoryFormSchema,
|
||||||
|
type CategoryForm,
|
||||||
|
type CategoryFormInput,
|
||||||
|
type CategoryKind,
|
||||||
|
} from "@/shared/schemas/category";
|
||||||
|
import { createCategoryAction, updateCategoryAction } from "../actions";
|
||||||
|
import type { CategoryView } from "../service";
|
||||||
|
import { buildTree, descendantIds, MAX_DEPTH, toOptions } from "../tree";
|
||||||
|
|
||||||
|
const t = tr.categories;
|
||||||
|
const ROOT = "__root__";
|
||||||
|
|
||||||
|
export type CategoryDialogState =
|
||||||
|
| { mode: "create"; kind: CategoryKind; parentId: string | null }
|
||||||
|
| { mode: "edit"; category: CategoryView }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function CategoryFormDialog({
|
||||||
|
state,
|
||||||
|
categories,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
state: CategoryDialogState;
|
||||||
|
categories: CategoryView[];
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const form = useForm<CategoryFormInput, unknown, CategoryForm>({
|
||||||
|
resolver: zodResolver(categoryFormSchema),
|
||||||
|
});
|
||||||
|
const kind = state?.mode === "edit" ? state.category.kind : (state?.kind ?? "expense");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state) return;
|
||||||
|
if (state.mode === "edit") {
|
||||||
|
const c = state.category;
|
||||||
|
form.reset({
|
||||||
|
kind: c.kind,
|
||||||
|
name: c.name,
|
||||||
|
parentId: c.parentId ?? "",
|
||||||
|
icon: c.icon,
|
||||||
|
color: c.color,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const parent = categories.find((c) => c.id === state.parentId);
|
||||||
|
form.reset({
|
||||||
|
kind: state.kind,
|
||||||
|
name: "",
|
||||||
|
parentId: state.parentId ?? "",
|
||||||
|
icon: parent?.icon ?? "tag",
|
||||||
|
color: parent?.color ?? "blue",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [state, categories, form]);
|
||||||
|
|
||||||
|
/** Üst kategori seçenekleri: aynı tür, aktif; düzenlemede kendisi ve alt kategorileri hariç. */
|
||||||
|
const parentOptions = useMemo(() => {
|
||||||
|
const sameKind = categories.filter((c) => c.kind === kind);
|
||||||
|
const excluded = new Set(
|
||||||
|
state?.mode === "edit"
|
||||||
|
? [state.category.id, ...descendantIds(sameKind, state.category.id)]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
return toOptions(buildTree(sameKind)).filter(
|
||||||
|
(o) => !excluded.has(o.id) && o.depth < MAX_DEPTH - 1,
|
||||||
|
);
|
||||||
|
}, [categories, kind, state]);
|
||||||
|
|
||||||
|
/** Ham form değerleri gönderilir; sunucu aynı şemayla yeniden ayrıştırır (tek doğruluk kaynağı). */
|
||||||
|
async function onSubmit() {
|
||||||
|
const values = form.getValues();
|
||||||
|
const result =
|
||||||
|
state?.mode === "edit"
|
||||||
|
? await updateCategoryAction(state.category.id, values)
|
||||||
|
: await createCategoryAction(values);
|
||||||
|
if (result.ok) {
|
||||||
|
toast.success(state?.mode === "edit" ? t.updated(values.name) : t.created(values.name));
|
||||||
|
onOpenChange(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [name, message] of Object.entries(result.fieldErrors ?? {})) {
|
||||||
|
if (message) form.setError(name as keyof CategoryFormInput, { message });
|
||||||
|
}
|
||||||
|
if (!result.fieldErrors) form.setError("root", { message: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const color = useWatch({ control: form.control, name: "color" }) ?? "blue";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={Boolean(state)}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={state?.mode === "edit" ? t.editTitle : t.createTitle}
|
||||||
|
description={t.formDescription}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="category-form" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting ? <Loader2 className="animate-spin" /> : null}
|
||||||
|
{state?.mode === "edit" ? tr.common.save : tr.common.create}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="category-form" onSubmit={form.handleSubmit(onSubmit)} noValidate>
|
||||||
|
<FieldGroup>
|
||||||
|
<Controller
|
||||||
|
name="name"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="cat-name">{t.name}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
id="cat-name"
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
/>
|
||||||
|
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="parentId"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => {
|
||||||
|
const current = parentOptions.find((o) => o.id === field.value);
|
||||||
|
return (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="cat-parent">{t.parent}</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value || ROOT}
|
||||||
|
onValueChange={(v) => field.onChange(v === ROOT ? "" : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="cat-parent"
|
||||||
|
className="w-full"
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
>
|
||||||
|
<SelectValue>{current?.path ?? t.noParent}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
<SelectItem value={ROOT}>{t.noParent}</SelectItem>
|
||||||
|
{parentOptions.map((o) => (
|
||||||
|
<SelectItem key={o.id} value={o.id}>
|
||||||
|
<span style={{ paddingInlineStart: `${o.depth * 0.75}rem` }}>
|
||||||
|
{o.label}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="color"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel id="cat-color-label">{t.color}</FieldLabel>
|
||||||
|
<SwatchPicker
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
labelledBy="cat-color-label"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="icon"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel id="cat-icon-label">{t.icon}</FieldLabel>
|
||||||
|
<IconPicker
|
||||||
|
value={field.value}
|
||||||
|
color={color}
|
||||||
|
onChange={field.onChange}
|
||||||
|
labelledBy="cat-icon-label"
|
||||||
|
/>
|
||||||
|
<FieldDescription className="sr-only">{t.icon}</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{form.formState.errors.root ? (
|
||||||
|
<FieldError>{form.formState.errors.root.message}</FieldError>
|
||||||
|
) : null}
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||||
|
import type { CategoryKind } from "@/shared/schemas/category";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hane oluşturulunca yüklenen varsayılan Türkçe kategori seti (Bölüm 6.2).
|
||||||
|
* `key` sabittir: set tekrar yüklendiğinde kopya oluşmaz, kullanıcı adı değiştirse bile tanınır.
|
||||||
|
*/
|
||||||
|
export type DefaultCategory = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
icon: IconKey;
|
||||||
|
color: Swatch;
|
||||||
|
children?: Omit<DefaultCategory, "children">[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_CATEGORIES: Record<CategoryKind, DefaultCategory[]> = {
|
||||||
|
expense: [
|
||||||
|
{
|
||||||
|
key: "market",
|
||||||
|
name: "Market",
|
||||||
|
icon: "shopping-cart",
|
||||||
|
color: "green",
|
||||||
|
children: [
|
||||||
|
{ key: "market.gida", name: "Gıda", icon: "shopping-basket", color: "green" },
|
||||||
|
{ key: "market.temizlik", name: "Temizlik", icon: "spray-can", color: "teal" },
|
||||||
|
{ key: "market.kisisel-bakim", name: "Kişisel bakım", icon: "sparkles", color: "pink" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "faturalar",
|
||||||
|
name: "Faturalar",
|
||||||
|
icon: "receipt",
|
||||||
|
color: "amber",
|
||||||
|
children: [
|
||||||
|
{ key: "faturalar.elektrik", name: "Elektrik", icon: "zap", color: "amber" },
|
||||||
|
{ key: "faturalar.su", name: "Su", icon: "droplet", color: "sky" },
|
||||||
|
{ key: "faturalar.dogalgaz", name: "Doğalgaz", icon: "flame", color: "orange" },
|
||||||
|
{ key: "faturalar.internet", name: "İnternet", icon: "wifi", color: "indigo" },
|
||||||
|
{ key: "faturalar.telefon", name: "Telefon", icon: "smartphone", color: "purple" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: "kira", name: "Kira", icon: "house", color: "blue" },
|
||||||
|
{ key: "aidat", name: "Aidat", icon: "building", color: "slate" },
|
||||||
|
{
|
||||||
|
key: "ulasim",
|
||||||
|
name: "Ulaşım",
|
||||||
|
icon: "car",
|
||||||
|
color: "sky",
|
||||||
|
children: [
|
||||||
|
{ key: "ulasim.yakit", name: "Yakıt", icon: "fuel", color: "orange" },
|
||||||
|
{ key: "ulasim.toplu-tasima", name: "Toplu taşıma", icon: "bus", color: "sky" },
|
||||||
|
{ key: "ulasim.arac-bakim", name: "Araç bakım ve sigorta", icon: "wrench", color: "slate" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "saglik",
|
||||||
|
name: "Sağlık",
|
||||||
|
icon: "heart-pulse",
|
||||||
|
color: "red",
|
||||||
|
children: [
|
||||||
|
{ key: "saglik.ilac", name: "İlaç", icon: "pill", color: "red" },
|
||||||
|
{ key: "saglik.muayene", name: "Muayene ve tahlil", icon: "heart-pulse", color: "rose" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: "egitim", name: "Eğitim", icon: "graduation-cap", color: "indigo" },
|
||||||
|
{ key: "giyim", name: "Giyim", icon: "shirt", color: "purple" },
|
||||||
|
{
|
||||||
|
key: "eglence",
|
||||||
|
name: "Eğlence",
|
||||||
|
icon: "film",
|
||||||
|
color: "pink",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "eglence.disarida-yemek",
|
||||||
|
name: "Dışarıda yemek",
|
||||||
|
icon: "utensils",
|
||||||
|
color: "orange",
|
||||||
|
},
|
||||||
|
{ key: "eglence.kafe", name: "Kafe", icon: "coffee", color: "amber" },
|
||||||
|
{ key: "eglence.tatil", name: "Tatil", icon: "plane", color: "sky" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: "abonelikler", name: "Abonelikler", icon: "repeat", color: "purple" },
|
||||||
|
{ key: "ev", name: "Ev ve mobilya", icon: "sofa", color: "teal" },
|
||||||
|
{ key: "cocuk", name: "Çocuk", icon: "baby", color: "rose" },
|
||||||
|
{ key: "evcil-hayvan", name: "Evcil hayvan", icon: "paw-print", color: "lime" },
|
||||||
|
{ key: "hediye", name: "Hediye ve bağış", icon: "gift", color: "pink" },
|
||||||
|
{
|
||||||
|
key: "banka",
|
||||||
|
name: "Banka ve kart giderleri",
|
||||||
|
icon: "percent",
|
||||||
|
color: "slate",
|
||||||
|
children: [
|
||||||
|
{ key: "banka.faiz", name: "Faiz", icon: "percent", color: "red" },
|
||||||
|
{ key: "banka.vergi", name: "KKDF / BSMV", icon: "receipt", color: "slate" },
|
||||||
|
{
|
||||||
|
key: "banka.ucret",
|
||||||
|
name: "Kart ve hesap ücretleri",
|
||||||
|
icon: "credit-card",
|
||||||
|
color: "slate",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: "vergi", name: "Vergi ve harçlar", icon: "landmark", color: "slate" },
|
||||||
|
{ key: "diger-gider", name: "Diğer", icon: "circle-ellipsis", color: "slate" },
|
||||||
|
],
|
||||||
|
income: [
|
||||||
|
{ key: "maas", name: "Maaş", icon: "briefcase", color: "green" },
|
||||||
|
{ key: "ek-gelir", name: "Ek gelir", icon: "coins", color: "teal" },
|
||||||
|
{ key: "yemek-karti", name: "Yemek kartı yüklemesi", icon: "utensils", color: "lime" },
|
||||||
|
{ key: "kira-geliri", name: "Kira geliri", icon: "house", color: "blue" },
|
||||||
|
{ key: "getiri", name: "Faiz ve getiri", icon: "trending-up", color: "indigo" },
|
||||||
|
{ key: "iade", name: "İade", icon: "undo", color: "sky" },
|
||||||
|
{ key: "hediye-gelir", name: "Hediye", icon: "gift", color: "pink" },
|
||||||
|
{ key: "diger-gelir", name: "Diğer", icon: "circle-ellipsis", color: "slate" },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { Types } from "mongoose";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import {
|
||||||
|
createCategory,
|
||||||
|
installDefaultCategories,
|
||||||
|
listCategories,
|
||||||
|
setCategoryArchived,
|
||||||
|
updateCategory,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let annemler = "";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
annemler = ids.household("Annemler");
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
const byKey = (key: string, householdId = cakir) =>
|
||||||
|
CategoryModel.findOne({ householdId, defaultKey: key })
|
||||||
|
.lean()
|
||||||
|
.then((c) => String(c!._id));
|
||||||
|
const form = (over: Partial<Parameters<typeof createCategory>[2]> = {}) => ({
|
||||||
|
kind: "expense" as const,
|
||||||
|
name: "Test",
|
||||||
|
parentId: null,
|
||||||
|
icon: "tag" as const,
|
||||||
|
color: "blue" as const,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("varsayılan kategoriler", () => {
|
||||||
|
it("tekrar yüklemede kopya oluşmaz", async () => {
|
||||||
|
const before = await CategoryModel.countDocuments({ householdId: cakir });
|
||||||
|
const added = await installDefaultCategories(
|
||||||
|
new Types.ObjectId(cakir),
|
||||||
|
new Types.ObjectId(ids.user("admin")),
|
||||||
|
);
|
||||||
|
expect(added).toBe(0);
|
||||||
|
expect(await CategoryModel.countDocuments({ householdId: cakir })).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("alt kategoriler doğru ancestors zinciriyle yüklenir", async () => {
|
||||||
|
const elektrik = await CategoryModel.findOne({
|
||||||
|
householdId: cakir,
|
||||||
|
defaultKey: "faturalar.elektrik",
|
||||||
|
}).lean();
|
||||||
|
const faturalar = await byKey("faturalar");
|
||||||
|
expect(elektrik?.ancestors.map(String)).toEqual([faturalar]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("izolasyon ve roller", () => {
|
||||||
|
it("başka hanenin kategorilerini okuyamaz ve değiştiremez", async () => {
|
||||||
|
expect(await listCategories(ids.user("fatma"), cakir)).toBeNull();
|
||||||
|
const kira = await byKey("kira");
|
||||||
|
// fatma kendi hanesinin kimliğiyle, Çakır'ın kategorisini hedefler → bulunamaz.
|
||||||
|
const r = await updateCategory(
|
||||||
|
ids.user("fatma"),
|
||||||
|
annemler,
|
||||||
|
kira,
|
||||||
|
form({ name: "Ele geçirildi" }),
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect((await CategoryModel.findById(kira).lean())?.name).toBe("Kira");
|
||||||
|
const r2 = await setCategoryArchived(ids.user("fatma"), annemler, kira, true);
|
||||||
|
expect(r2.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("izleyici ekleyemez, üye ekler", async () => {
|
||||||
|
expect((await createCategory(ids.user("zeynep"), cakir, form({ name: "Harçlık" }))).ok).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect((await createCategory(ids.user("emre"), cakir, form({ name: "Harçlık" }))).ok).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("başka hanenin kategorisini üst kategori yapamaz", async () => {
|
||||||
|
const annemlerMarket = await byKey("market", annemler);
|
||||||
|
const r = await createCategory(
|
||||||
|
ids.user("ayse"),
|
||||||
|
cakir,
|
||||||
|
form({ name: "Sızma", parentId: annemlerMarket }),
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ağaç kuralları", () => {
|
||||||
|
it("aynı düzeyde aynı ad (Türkçe normalize) reddedilir", async () => {
|
||||||
|
const r = await createCategory(ids.user("ayse"), cakir, form({ name: " KİRA " }));
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
// farklı düzeyde aynı ad serbest
|
||||||
|
const market = await byKey("market");
|
||||||
|
expect(
|
||||||
|
(await createCategory(ids.user("ayse"), cakir, form({ name: "Kira", parentId: market }))).ok,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("taşıma alt ağacın ancestors alanlarını günceller", async () => {
|
||||||
|
const ulasim = await byKey("ulasim");
|
||||||
|
const yakit = await byKey("ulasim.yakit");
|
||||||
|
const created = await createCategory(
|
||||||
|
ids.user("ayse"),
|
||||||
|
cakir,
|
||||||
|
form({ name: "LPG", parentId: yakit }),
|
||||||
|
);
|
||||||
|
const lpg = created.ok ? created.data.id : "";
|
||||||
|
const ev = await byKey("ev");
|
||||||
|
// Yakıt'ı (alt kategorisi LPG ile) "Ev ve mobilya" altına taşı.
|
||||||
|
const r = await updateCategory(
|
||||||
|
ids.user("ayse"),
|
||||||
|
cakir,
|
||||||
|
yakit,
|
||||||
|
form({ name: "Yakıt", parentId: ev, icon: "fuel" }),
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect((await CategoryModel.findById(yakit).lean())?.ancestors.map(String)).toEqual([ev]);
|
||||||
|
expect((await CategoryModel.findById(lpg).lean())?.ancestors.map(String)).toEqual([ev, yakit]);
|
||||||
|
expect((await CategoryModel.findById(yakit).lean())?.parentId?.toString()).not.toBe(ulasim);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("döngü ve tür uyuşmazlığı reddedilir", async () => {
|
||||||
|
const market = await byKey("market");
|
||||||
|
const gida = await byKey("market.gida");
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await updateCategory(
|
||||||
|
ids.user("ayse"),
|
||||||
|
cakir,
|
||||||
|
market,
|
||||||
|
form({ name: "Market", parentId: gida }),
|
||||||
|
)
|
||||||
|
).ok,
|
||||||
|
).toBe(false);
|
||||||
|
const maas = await byKey("maas");
|
||||||
|
expect(
|
||||||
|
(await updateCategory(ids.user("ayse"), cakir, gida, form({ name: "Gıda", parentId: maas })))
|
||||||
|
.ok,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("arşivleme alt ağaçla birlikte yapılır; üstü arşivliyken alt kategori geri alınamaz", async () => {
|
||||||
|
const saglik = await byKey("saglik");
|
||||||
|
const ilac = await byKey("saglik.ilac");
|
||||||
|
expect((await setCategoryArchived(ids.user("emre"), cakir, saglik, true)).ok).toBe(true);
|
||||||
|
const active = (await listCategories(ids.user("emre"), cakir))!.map((c) => c.id);
|
||||||
|
expect(active).not.toContain(saglik);
|
||||||
|
expect(active).not.toContain(ilac);
|
||||||
|
expect((await setCategoryArchived(ids.user("emre"), cakir, ilac, false)).ok).toBe(false);
|
||||||
|
expect((await setCategoryArchived(ids.user("emre"), cakir, saglik, false)).ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types, type ClientSession } from "mongoose";
|
||||||
|
import { resolveMembership } from "@/features/households/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { writeAudit } from "@/lib/audit";
|
||||||
|
import { connectMongoose } from "@/lib/db/mongoose";
|
||||||
|
import { toObjectId } from "@/lib/object-id";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||||
|
import type { CategoryForm, CategoryKind } from "@/shared/schemas/category";
|
||||||
|
import { foldTr } from "@/shared/search";
|
||||||
|
import { DEFAULT_CATEGORIES } from "./defaults";
|
||||||
|
import {
|
||||||
|
checkCreate,
|
||||||
|
checkMove,
|
||||||
|
descendantIds,
|
||||||
|
type FlatCategory,
|
||||||
|
type MoveViolation,
|
||||||
|
} from "./tree";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kategori servisi. Okuma: viewer; yazma: member. Hane kapsamı her fonksiyonda doğrulanır.
|
||||||
|
* Silme yoktur: arşivleme (alt ağaçla birlikte). İşlemler arşivli kategoriyi göstermeye devam eder.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = tr.categories;
|
||||||
|
|
||||||
|
export type CategoryView = FlatCategory & { icon: IconKey; color: Swatch; isDefault: boolean };
|
||||||
|
|
||||||
|
type Lean = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
kind: CategoryKind;
|
||||||
|
name: string;
|
||||||
|
parentId: Types.ObjectId | null;
|
||||||
|
order: number;
|
||||||
|
archivedAt: Date | null;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
defaultKey: string | null;
|
||||||
|
ancestors: Types.ObjectId[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const toView = (c: Lean): CategoryView => ({
|
||||||
|
id: String(c._id),
|
||||||
|
kind: c.kind,
|
||||||
|
name: c.name,
|
||||||
|
parentId: c.parentId ? String(c.parentId) : null,
|
||||||
|
order: c.order,
|
||||||
|
archived: Boolean(c.archivedAt),
|
||||||
|
icon: c.icon as IconKey,
|
||||||
|
color: c.color as Swatch,
|
||||||
|
isDefault: Boolean(c.defaultKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
const violationMessage = (v: MoveViolation) => t.violations[v];
|
||||||
|
|
||||||
|
export async function listCategories(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
opts: { kind?: CategoryKind; includeArchived?: boolean } = {},
|
||||||
|
): Promise<CategoryView[] | null> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "viewer");
|
||||||
|
if (!m) return null;
|
||||||
|
const filter: Record<string, unknown> = { householdId: m.householdId };
|
||||||
|
if (opts.kind) filter.kind = opts.kind;
|
||||||
|
if (!opts.includeArchived) filter.archivedAt = null;
|
||||||
|
const rows = await CategoryModel.find(filter).lean<Lean[]>();
|
||||||
|
return rows.map(toView);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadKind(householdId: Types.ObjectId, kind: CategoryKind, session?: ClientSession) {
|
||||||
|
return CategoryModel.find({ householdId, kind })
|
||||||
|
.session(session ?? null)
|
||||||
|
.lean<Lean[]>();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSiblingNamed(all: Lean[], parentId: string | null, name: string, exceptId?: string) {
|
||||||
|
const key = foldTr(name);
|
||||||
|
return all.some(
|
||||||
|
(c) =>
|
||||||
|
!c.archivedAt &&
|
||||||
|
String(c._id) !== exceptId &&
|
||||||
|
(c.parentId ? String(c.parentId) : null) === parentId &&
|
||||||
|
foldTr(c.name) === key,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Varsayılan seti yükler (idempotent: defaultKey'e göre). Yeni hane oluşturulurken aynı
|
||||||
|
* transaction içinde (session) ya da migration/seed'den çağrılır. Eklenen kategori sayısını döner.
|
||||||
|
*/
|
||||||
|
export async function installDefaultCategories(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
actor: Types.ObjectId,
|
||||||
|
session?: ClientSession,
|
||||||
|
): Promise<number> {
|
||||||
|
await connectMongoose();
|
||||||
|
let inserted = 0;
|
||||||
|
const upsert = async (
|
||||||
|
kind: CategoryKind,
|
||||||
|
def: { key: string; name: string; icon: string; color: string },
|
||||||
|
order: number,
|
||||||
|
parent: { _id: Types.ObjectId; ancestors: Types.ObjectId[] } | null,
|
||||||
|
) => {
|
||||||
|
const res = await CategoryModel.findOneAndUpdate(
|
||||||
|
{ householdId, defaultKey: def.key },
|
||||||
|
{
|
||||||
|
$setOnInsert: {
|
||||||
|
householdId,
|
||||||
|
kind,
|
||||||
|
name: def.name,
|
||||||
|
nameKey: foldTr(def.name),
|
||||||
|
parentId: parent?._id ?? null,
|
||||||
|
ancestors: parent ? [...parent.ancestors, parent._id] : [],
|
||||||
|
icon: def.icon,
|
||||||
|
color: def.color,
|
||||||
|
order,
|
||||||
|
defaultKey: def.key,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ upsert: true, returnDocument: "after", includeResultMetadata: true, session },
|
||||||
|
);
|
||||||
|
if (!res.lastErrorObject?.updatedExisting) inserted++;
|
||||||
|
return res.value!;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const kind of ["expense", "income"] as const) {
|
||||||
|
for (const [i, def] of DEFAULT_CATEGORIES[kind].entries()) {
|
||||||
|
const parent = await upsert(kind, def, i, null);
|
||||||
|
for (const [j, child] of (def.children ?? []).entries()) {
|
||||||
|
await upsert(kind, child, j, { _id: parent._id, ancestors: parent.ancestors });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCategory(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
input: CategoryForm,
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const all = await loadKind(m.householdId, input.kind);
|
||||||
|
const violation = checkCreate(all.map(toView), input.kind, input.parentId);
|
||||||
|
if (violation)
|
||||||
|
return fail(violationMessage(violation), { parentId: violationMessage(violation) });
|
||||||
|
if (hasSiblingNamed(all, input.parentId, input.name))
|
||||||
|
return fail(t.duplicate, { name: t.duplicate });
|
||||||
|
|
||||||
|
const parent = input.parentId ? all.find((c) => String(c._id) === input.parentId) : null;
|
||||||
|
const siblings = all.filter((c) => (c.parentId ? String(c.parentId) : null) === input.parentId);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const doc = await CategoryModel.create({
|
||||||
|
householdId: m.householdId,
|
||||||
|
kind: input.kind,
|
||||||
|
name: input.name,
|
||||||
|
nameKey: foldTr(input.name),
|
||||||
|
parentId: parent?._id ?? null,
|
||||||
|
ancestors: parent ? [...parent.ancestors, parent._id] : [],
|
||||||
|
icon: input.icon,
|
||||||
|
color: input.color,
|
||||||
|
order: siblings.length ? Math.max(...siblings.map((s) => s.order)) + 1 : 0,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
return ok({ id: String(doc._id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOne(householdId: Types.ObjectId, categoryId: string) {
|
||||||
|
const id = toObjectId(categoryId);
|
||||||
|
if (!id) return null;
|
||||||
|
// householdId filtresi: başka hanenin kategorisi asla bulunmaz.
|
||||||
|
return CategoryModel.findOne({ _id: id, householdId }).lean<Lean>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ad, ikon, renk ve (değiştiyse) üst kategori. Taşıma alt ağaçla birlikte transaction'da. */
|
||||||
|
export async function updateCategory(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
categoryId: string,
|
||||||
|
input: CategoryForm,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const current = await loadOne(m.householdId, categoryId);
|
||||||
|
if (!current || current.kind !== input.kind) return fail(tr.common.notFound);
|
||||||
|
const all = await loadKind(m.householdId, current.kind);
|
||||||
|
const currentParent = current.parentId ? String(current.parentId) : null;
|
||||||
|
const moving = currentParent !== input.parentId;
|
||||||
|
|
||||||
|
if (moving) {
|
||||||
|
const violation = checkMove(all.map(toView), categoryId, input.parentId);
|
||||||
|
if (violation)
|
||||||
|
return fail(violationMessage(violation), { parentId: violationMessage(violation) });
|
||||||
|
}
|
||||||
|
if (hasSiblingNamed(all, input.parentId, input.name, categoryId))
|
||||||
|
return fail(t.duplicate, { name: t.duplicate });
|
||||||
|
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const mongoose = await connectMongoose();
|
||||||
|
await mongoose.connection.transaction(async (session) => {
|
||||||
|
const set: Record<string, unknown> = {
|
||||||
|
name: input.name,
|
||||||
|
nameKey: foldTr(input.name),
|
||||||
|
icon: input.icon,
|
||||||
|
color: input.color,
|
||||||
|
updatedBy: actor,
|
||||||
|
};
|
||||||
|
if (moving) {
|
||||||
|
const parent = input.parentId ? all.find((c) => String(c._id) === input.parentId)! : null;
|
||||||
|
const newAncestors = parent ? [...parent.ancestors, parent._id] : [];
|
||||||
|
const siblings = all.filter(
|
||||||
|
(c) => (c.parentId ? String(c.parentId) : null) === input.parentId,
|
||||||
|
);
|
||||||
|
Object.assign(set, {
|
||||||
|
parentId: parent?._id ?? null,
|
||||||
|
ancestors: newAncestors,
|
||||||
|
order: siblings.length ? Math.max(...siblings.map((s) => s.order)) + 1 : 0,
|
||||||
|
});
|
||||||
|
// Alt ağaç: yeni üst zincir + (taşınan düğüm) + düğümün altındaki eski yol.
|
||||||
|
for (const d of all.filter((c) => c.ancestors.some((a) => a.equals(current._id)))) {
|
||||||
|
const idx = d.ancestors.findIndex((a) => a.equals(current._id));
|
||||||
|
await CategoryModel.updateOne(
|
||||||
|
{ _id: d._id, householdId: m.householdId },
|
||||||
|
{ $set: { ancestors: [...newAncestors, current._id, ...d.ancestors.slice(idx + 1)] } },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await writeAudit(
|
||||||
|
{
|
||||||
|
action: "category.move",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: m.householdId,
|
||||||
|
targetType: "category",
|
||||||
|
targetId: categoryId,
|
||||||
|
diff: { parentId: { from: currentParent, to: input.parentId } },
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await CategoryModel.updateOne(
|
||||||
|
{ _id: current._id, householdId: m.householdId },
|
||||||
|
{ $set: set },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kardeşler arasında bir yukarı/aşağı taşır. */
|
||||||
|
export async function reorderCategory(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
categoryId: string,
|
||||||
|
direction: "up" | "down",
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const current = await loadOne(m.householdId, categoryId);
|
||||||
|
if (!current) return fail(tr.common.notFound);
|
||||||
|
const siblings = (await loadKind(m.householdId, current.kind))
|
||||||
|
.filter((c) => !c.archivedAt && String(c.parentId ?? "") === String(current.parentId ?? ""))
|
||||||
|
.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name, "tr"));
|
||||||
|
const i = siblings.findIndex((c) => c._id.equals(current._id));
|
||||||
|
const j = direction === "up" ? i - 1 : i + 1;
|
||||||
|
if (i < 0 || j < 0 || j >= siblings.length) return ok(undefined);
|
||||||
|
[siblings[i], siblings[j]] = [siblings[j]!, siblings[i]!];
|
||||||
|
await CategoryModel.bulkWrite(
|
||||||
|
siblings.map((c, order) => ({
|
||||||
|
updateOne: {
|
||||||
|
filter: { _id: c._id, householdId: m.householdId },
|
||||||
|
update: { $set: { order } },
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arşivler (alt kategorilerle birlikte) veya arşivden çıkarır (üstü aktif olmalı). */
|
||||||
|
export async function setCategoryArchived(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
categoryId: string,
|
||||||
|
archived: boolean,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const current = await loadOne(m.householdId, categoryId);
|
||||||
|
if (!current) return fail(tr.common.notFound);
|
||||||
|
const all = await loadKind(m.householdId, current.kind);
|
||||||
|
|
||||||
|
if (!archived && current.parentId) {
|
||||||
|
const parent = all.find((c) => c._id.equals(current.parentId!));
|
||||||
|
if (parent?.archivedAt) return fail(t.violations.parentArchived);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!archived &&
|
||||||
|
hasSiblingNamed(
|
||||||
|
all,
|
||||||
|
current.parentId ? String(current.parentId) : null,
|
||||||
|
current.name,
|
||||||
|
categoryId,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return fail(t.duplicate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = [
|
||||||
|
current._id,
|
||||||
|
...descendantIds(all.map(toView), categoryId).map((id) => new Types.ObjectId(id)),
|
||||||
|
];
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
await CategoryModel.updateMany(
|
||||||
|
{ _id: { $in: ids }, householdId: m.householdId },
|
||||||
|
{ $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } },
|
||||||
|
);
|
||||||
|
await writeAudit({
|
||||||
|
action: archived ? "category.archive" : "category.unarchive",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: m.householdId,
|
||||||
|
targetType: "category",
|
||||||
|
targetId: categoryId,
|
||||||
|
diff: { name: current.name, affected: ids.length },
|
||||||
|
});
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
ancestorIds,
|
||||||
|
buildTree,
|
||||||
|
checkCreate,
|
||||||
|
checkMove,
|
||||||
|
descendantIds,
|
||||||
|
rollupTotals,
|
||||||
|
toOptions,
|
||||||
|
type FlatCategory,
|
||||||
|
} from "./tree";
|
||||||
|
|
||||||
|
const c = (
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
parentId: string | null,
|
||||||
|
order = 0,
|
||||||
|
kind: "expense" | "income" = "expense",
|
||||||
|
): FlatCategory => ({
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
name,
|
||||||
|
parentId,
|
||||||
|
order,
|
||||||
|
archived: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items: FlatCategory[] = [
|
||||||
|
c("fat", "Faturalar", null, 1),
|
||||||
|
c("elk", "Elektrik", "fat", 0),
|
||||||
|
c("su", "Su", "fat", 1),
|
||||||
|
c("mar", "Market", null, 0),
|
||||||
|
c("gida", "Gıda", "mar", 0),
|
||||||
|
c("meyve", "Meyve", "gida", 0),
|
||||||
|
c("maas", "Maaş", null, 0, "income"),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("buildTree / toOptions", () => {
|
||||||
|
it("sıra ve derinlikle ağaç kurar, yol etiketleri üretir", () => {
|
||||||
|
const tree = buildTree(items.filter((i) => i.kind === "expense"));
|
||||||
|
expect(tree.map((n) => n.name)).toEqual(["Market", "Faturalar"]);
|
||||||
|
expect(toOptions(tree).map((o) => [o.path, o.depth])).toEqual([
|
||||||
|
["Market", 0],
|
||||||
|
["Market › Gıda", 1],
|
||||||
|
["Market › Gıda › Meyve", 2],
|
||||||
|
["Faturalar", 0],
|
||||||
|
["Faturalar › Elektrik", 1],
|
||||||
|
["Faturalar › Su", 1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("arşivlenenleri varsayılan olarak seçeneklere koymaz", () => {
|
||||||
|
const tree = buildTree([c("a", "A", null), { ...c("b", "B", "a"), archived: true }]);
|
||||||
|
expect(toOptions(tree).map((o) => o.id)).toEqual(["a"]);
|
||||||
|
expect(toOptions(tree, { includeArchived: true }).map((o) => o.id)).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("üstü olmayan düğümü köke alır, döngüde takılmaz", () => {
|
||||||
|
const loop = [c("x", "X", "y"), c("y", "Y", "x"), c("z", "Z", "yok")];
|
||||||
|
expect(() => buildTree(loop)).not.toThrow();
|
||||||
|
expect(buildTree(loop).map((n) => n.id)).toContain("z");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ancestorIds / descendantIds", () => {
|
||||||
|
it("üst ve alt kategorileri bulur", () => {
|
||||||
|
expect(ancestorIds(items, "meyve")).toEqual(["mar", "gida"]);
|
||||||
|
expect(descendantIds(items, "mar").sort()).toEqual(["gida", "meyve"]);
|
||||||
|
expect(descendantIds(items, "elk")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkMove", () => {
|
||||||
|
it("kendine, alt kategorisine ve farklı türe taşımayı reddeder", () => {
|
||||||
|
expect(checkMove(items, "mar", "mar")).toBe("self");
|
||||||
|
expect(checkMove(items, "mar", "meyve")).toBe("descendant");
|
||||||
|
expect(checkMove(items, "elk", "maas")).toBe("kind");
|
||||||
|
expect(checkMove(items, "elk", "yok")).toBe("parentNotFound");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("geçerli taşımaya ve köke taşımaya izin verir", () => {
|
||||||
|
expect(checkMove(items, "su", "mar")).toBeNull();
|
||||||
|
expect(checkMove(items, "gida", null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derinlik sınırını uygular (en fazla 5 seviye: derinlik 0–4)", () => {
|
||||||
|
const deep = [
|
||||||
|
c("1", "1", null),
|
||||||
|
c("2", "2", "1"),
|
||||||
|
c("3", "3", "2"),
|
||||||
|
c("4", "4", "3"),
|
||||||
|
c("5", "5", "4"),
|
||||||
|
c("x", "X", null),
|
||||||
|
c("y", "Y", "x"),
|
||||||
|
];
|
||||||
|
expect(checkCreate(deep, "expense", "4")).toBeNull(); // yeni düğüm derinlik 4
|
||||||
|
expect(checkCreate(deep, "expense", "5")).toBe("depth"); // derinlik 5
|
||||||
|
expect(checkMove(deep, "y", "4")).toBeNull();
|
||||||
|
expect(checkMove(deep, "y", "5")).toBe("depth");
|
||||||
|
expect(checkMove(deep, "x", "4")).toBe("depth"); // x derinlik 4, alt kategorisi y derinlik 5
|
||||||
|
expect(checkCreate(deep, "income", "3")).toBe("kind");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rollupTotals", () => {
|
||||||
|
it("alt kategori tutarlarını üst kategorilere toplar", () => {
|
||||||
|
const totals = rollupTotals(
|
||||||
|
items,
|
||||||
|
new Map([
|
||||||
|
["meyve", 300],
|
||||||
|
["gida", 500],
|
||||||
|
["elk", 1000],
|
||||||
|
["su", 250],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(totals.get("mar")).toBe(800);
|
||||||
|
expect(totals.get("gida")).toBe(800);
|
||||||
|
expect(totals.get("fat")).toBe(1250);
|
||||||
|
expect(totals.get("maas") ?? 0).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import type { CategoryKind } from "@/shared/schemas/category";
|
||||||
|
import { compareTr } from "@/shared/search";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kategori ağacı saf fonksiyonları (framework'ten bağımsız, birim testli).
|
||||||
|
* Derinlik pratikte 3 seviye; güvenlik için en fazla MAX_DEPTH seviye.
|
||||||
|
*/
|
||||||
|
export const MAX_DEPTH = 5;
|
||||||
|
|
||||||
|
export type FlatCategory = {
|
||||||
|
id: string;
|
||||||
|
kind: CategoryKind;
|
||||||
|
name: string;
|
||||||
|
parentId: string | null;
|
||||||
|
order: number;
|
||||||
|
archived: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CategoryNode<T extends FlatCategory = FlatCategory> = T & {
|
||||||
|
depth: number;
|
||||||
|
children: CategoryNode<T>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const bySortOrder = (a: FlatCategory, b: FlatCategory) =>
|
||||||
|
a.order - b.order || compareTr(a.name, b.name);
|
||||||
|
|
||||||
|
/** Düz listeden ağaç kurar. Üstü bulunamayan (veya döngülü) düğümler köke alınır. */
|
||||||
|
export function buildTree<T extends FlatCategory>(items: readonly T[]): CategoryNode<T>[] {
|
||||||
|
const nodes = new Map(
|
||||||
|
items.map((c) => [c.id, { ...c, depth: 0, children: [] as CategoryNode<T>[] }]),
|
||||||
|
);
|
||||||
|
const roots: CategoryNode<T>[] = [];
|
||||||
|
for (const node of nodes.values()) {
|
||||||
|
const parent = node.parentId ? nodes.get(node.parentId) : undefined;
|
||||||
|
if (parent && parent !== node) parent.children.push(node);
|
||||||
|
else roots.push(node);
|
||||||
|
}
|
||||||
|
const visit = (list: CategoryNode<T>[], depth: number, seen: Set<string>) => {
|
||||||
|
list.sort(bySortOrder);
|
||||||
|
for (const n of list) {
|
||||||
|
if (seen.has(n.id)) continue;
|
||||||
|
seen.add(n.id);
|
||||||
|
n.depth = depth;
|
||||||
|
visit(n.children, depth + 1, seen);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(roots, 0, new Set());
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CategoryOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
depth: number;
|
||||||
|
kind: CategoryKind;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Seçim listesi (işlem formları): derinlik sırasıyla, "Faturalar › Elektrik" yoluyla. */
|
||||||
|
export function toOptions<T extends FlatCategory>(
|
||||||
|
tree: CategoryNode<T>[],
|
||||||
|
{ includeArchived = false } = {},
|
||||||
|
): CategoryOption[] {
|
||||||
|
const out: CategoryOption[] = [];
|
||||||
|
const walk = (list: CategoryNode<T>[], prefix: string[]) => {
|
||||||
|
for (const n of list) {
|
||||||
|
if (n.archived && !includeArchived) continue;
|
||||||
|
const path = [...prefix, n.name];
|
||||||
|
out.push({ id: n.id, label: n.name, path: path.join(" › "), depth: n.depth, kind: n.kind });
|
||||||
|
walk(n.children, path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(tree, []);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `id`'nin tüm alt kategorileri (kendisi hariç). */
|
||||||
|
export function descendantIds(items: readonly FlatCategory[], id: string): string[] {
|
||||||
|
const children = new Map<string, string[]>();
|
||||||
|
for (const c of items)
|
||||||
|
if (c.parentId) children.set(c.parentId, [...(children.get(c.parentId) ?? []), c.id]);
|
||||||
|
const out: string[] = [];
|
||||||
|
const stack = [...(children.get(id) ?? [])];
|
||||||
|
while (stack.length) {
|
||||||
|
const next = stack.pop()!;
|
||||||
|
if (out.includes(next)) continue;
|
||||||
|
out.push(next);
|
||||||
|
stack.push(...(children.get(next) ?? []));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kökten `id`'ye kadar üst kategori kimlikleri (id hariç). */
|
||||||
|
export function ancestorIds(items: readonly FlatCategory[], id: string): string[] {
|
||||||
|
const byId = new Map(items.map((c) => [c.id, c]));
|
||||||
|
const out: string[] = [];
|
||||||
|
let cur = byId.get(id)?.parentId ?? null;
|
||||||
|
while (cur && !out.includes(cur)) {
|
||||||
|
out.unshift(cur);
|
||||||
|
cur = byId.get(cur)?.parentId ?? null;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alt ağacın yüksekliği (yaprak = 0). */
|
||||||
|
function subtreeHeight(items: readonly FlatCategory[], id: string): number {
|
||||||
|
const kids = items.filter((c) => c.parentId === id);
|
||||||
|
return kids.length ? 1 + Math.max(...kids.map((k) => subtreeHeight(items, k.id))) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MoveViolation =
|
||||||
|
"self" | "descendant" | "kind" | "depth" | "parentNotFound" | "parentArchived";
|
||||||
|
|
||||||
|
/** `id` kategorisi `newParentId` altına (null = kök) taşınabilir mi? */
|
||||||
|
export function checkMove(
|
||||||
|
items: readonly FlatCategory[],
|
||||||
|
id: string,
|
||||||
|
newParentId: string | null,
|
||||||
|
): MoveViolation | null {
|
||||||
|
if (newParentId === null) return null;
|
||||||
|
if (newParentId === id) return "self";
|
||||||
|
const parent = items.find((c) => c.id === newParentId);
|
||||||
|
const self = items.find((c) => c.id === id);
|
||||||
|
if (!parent || !self) return "parentNotFound";
|
||||||
|
if (parent.kind !== self.kind) return "kind";
|
||||||
|
if (parent.archived) return "parentArchived";
|
||||||
|
if (descendantIds(items, id).includes(newParentId)) return "descendant";
|
||||||
|
const newDepth = ancestorIds(items, newParentId).length + 1;
|
||||||
|
if (newDepth + subtreeHeight(items, id) >= MAX_DEPTH) return "depth";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yeni kategori `parentId` altına eklenebilir mi? */
|
||||||
|
export function checkCreate(
|
||||||
|
items: readonly FlatCategory[],
|
||||||
|
kind: CategoryKind,
|
||||||
|
parentId: string | null,
|
||||||
|
): MoveViolation | null {
|
||||||
|
if (!parentId) return null;
|
||||||
|
const parent = items.find((c) => c.id === parentId);
|
||||||
|
if (!parent) return "parentNotFound";
|
||||||
|
if (parent.kind !== kind) return "kind";
|
||||||
|
if (parent.archived) return "parentArchived";
|
||||||
|
if (ancestorIds(items, parentId).length + 1 >= MAX_DEPTH) return "depth";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kategori bazlı tutarları üst kategorilere toplar (raporlar ve bütçe için):
|
||||||
|
* her kategori kendi tutarı + tüm alt kategorilerinin tutarı.
|
||||||
|
*/
|
||||||
|
export function rollupTotals(
|
||||||
|
items: readonly FlatCategory[],
|
||||||
|
totals: ReadonlyMap<string, number>,
|
||||||
|
): Map<string, number> {
|
||||||
|
const out = new Map<string, number>();
|
||||||
|
for (const c of items) {
|
||||||
|
const own = totals.get(c.id) ?? 0;
|
||||||
|
for (const target of [c.id, ...ancestorIds(items, c.id)])
|
||||||
|
out.set(target, (out.get(target) ?? 0) + own);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
|
import { ICON_KEYS, SWATCHES } from "@/shared/appearance";
|
||||||
|
import { CATEGORY_KINDS } from "@/shared/schemas/category";
|
||||||
|
|
||||||
|
/** Gelir/gider kategori ağacı (Bölüm 6.2). Silme yok; arşivleme (alt ağaçla birlikte). */
|
||||||
|
const categorySchema = new Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
kind: { type: String, enum: CATEGORY_KINDS, required: true },
|
||||||
|
name: { type: String, required: true, trim: true, maxlength: 80 },
|
||||||
|
/** Kardeşler arasında ad tekrarını önlemek için normalize ad (foldTr). */
|
||||||
|
nameKey: { type: String, required: true },
|
||||||
|
parentId: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
/** Kökten üst kategoriye kadar kimlikler (alt ağaç sorguları ve toplama için). */
|
||||||
|
ancestors: { type: [Schema.Types.ObjectId], default: [] },
|
||||||
|
icon: { type: String, enum: ICON_KEYS, default: "tag" },
|
||||||
|
color: { type: String, enum: SWATCHES, default: "slate" },
|
||||||
|
order: { type: Number, default: 0 },
|
||||||
|
archivedAt: { type: Date, default: null },
|
||||||
|
/** Varsayılan setten geldiyse sabit anahtar (tekrar yüklemede kopya olmaz). */
|
||||||
|
defaultKey: { type: String, default: null },
|
||||||
|
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
},
|
||||||
|
{ timestamps: true, collection: "categories" },
|
||||||
|
);
|
||||||
|
|
||||||
|
categorySchema.index({ householdId: 1, kind: 1, parentId: 1, order: 1 });
|
||||||
|
categorySchema.index({ householdId: 1, ancestors: 1 });
|
||||||
|
categorySchema.index(
|
||||||
|
{ householdId: 1, defaultKey: 1 },
|
||||||
|
{ unique: true, partialFilterExpression: { defaultKey: { $type: "string" } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Category = InferSchemaType<typeof categorySchema>;
|
||||||
|
|
||||||
|
export const CategoryModel: Model<Category> =
|
||||||
|
(models.Category as Model<Category> | undefined) ?? model<Category>("Category", categorySchema);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "../../i18n/tr";
|
||||||
|
import { ICON_KEYS, SWATCHES } from "../appearance";
|
||||||
|
|
||||||
|
export const CATEGORY_KINDS = ["expense", "income"] as const;
|
||||||
|
export type CategoryKind = (typeof CATEGORY_KINDS)[number];
|
||||||
|
|
||||||
|
export const categoryFormSchema = z.object({
|
||||||
|
kind: z.enum(CATEGORY_KINDS),
|
||||||
|
name: z.string().trim().min(1, tr.common.required).max(80, tr.validation.nameMax),
|
||||||
|
parentId: z
|
||||||
|
.string()
|
||||||
|
.regex(/^([a-f0-9]{24})?$/i)
|
||||||
|
.transform((s) => (s === "" ? null : s)),
|
||||||
|
icon: z.enum(ICON_KEYS),
|
||||||
|
color: z.enum(SWATCHES),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CategoryFormInput = z.input<typeof categoryFormSchema>;
|
||||||
|
export type CategoryForm = z.output<typeof categoryFormSchema>;
|
||||||
Reference in New Issue
Block a user