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 & { depth: number; children: CategoryNode[]; }; 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(items: readonly T[]): CategoryNode[] { const nodes = new Map( items.map((c) => [c.id, { ...c, depth: 0, children: [] as CategoryNode[] }]), ); const roots: CategoryNode[] = []; 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[], depth: number, seen: Set) => { 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( tree: CategoryNode[], { includeArchived = false } = {}, ): CategoryOption[] { const out: CategoryOption[] = []; const walk = (list: CategoryNode[], 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(); 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, ): Map { const out = new Map(); 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; }