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