import { normalizeOrgChartDataset } from './normalize.js'; import type { Employee, OrgChartDataset, OrgChartSummary, Organization, WorkStatus } from './types.js'; /** Internal content selectors: these do not expand the package's public data API. */ function uniqueEmployees(data: OrgChartDataset): Employee[] { const seen = new Set(); return data.employees.filter(employee => { if (seen.has(employee.id)) return false; seen.add(employee.id); return true; }); } /** Literal unique IDs in their primary organization, including leaders and all statuses. * Inclusive organization counts stay in Summary; they must never be summed as a subtotal. * Map keys safely retain opaque organization IDs and position names such as __proto__. */ export function selectOrganizationPositionCounts(data: OrgChartDataset): ReadonlyMap> { const counts = new Map>(); for (const organization of data.organizations) counts.set(organization.id, new Map()); for (const employee of uniqueEmployees(data)) { const row = counts.get(employee.organizationId); if (row) row.set(employee.position, (row.get(employee.position) ?? 0) + 1); } return counts; } export function selectAffiliationSummary(data: OrgChartDataset, summary: OrgChartSummary) { // Include all declared affiliations, including zero, without recounting employees. return data.affiliations.map(affiliation => ({ affiliation, count: summary.raw.byAffiliation[affiliation.id] ?? 0, })); } export interface MajorOrganizationTotal { organization: Organization; /** 'direct' for a root's own members (e.g. its leader); 'inclusive' for a major organization. */ scope: 'direct' | 'inclusive'; count: number; } /** Compact totals for the read-only composition: each root's direct members, then * each major (root child) organization including its cells/teams. Primary * membership is single, so the rows are disjoint and add up to the raw total. */ export function selectMajorOrganizationTotals(data: OrgChartDataset, summary: OrgChartSummary): MajorOrganizationTotal[] { const rows: MajorOrganizationTotal[] = []; for (const root of normalizeOrgChartDataset(data).roots) { const counts = summary.raw.byOrganization[root.organization.id]; if (!root.children.length) { rows.push({ organization: root.organization, scope: 'inclusive', count: counts?.inclusive ?? 0 }); continue; } if (counts?.direct) rows.push({ organization: root.organization, scope: 'direct', count: counts.direct }); for (const child of root.children) { rows.push({ organization: child.organization, scope: 'inclusive', count: summary.raw.byOrganization[child.organization.id]?.inclusive ?? 0 }); } } return rows; } export interface StatusContentGroup { status: Extract; employees: readonly Employee[]; count: number; } export function selectStatusGroups(data: OrgChartDataset, summary: OrgChartSummary): StatusContentGroup[] { const employees = uniqueEmployees(data); const groups: StatusContentGroup[] = []; if (data.presentation?.groups.showDispatched !== false) { groups.push({ status: 'dispatched', employees: employees.filter(employee => employee.workStatus === 'dispatched'), count: summary.raw.byStatus.dispatched }); } if (data.presentation?.groups.showOnLeave !== false) { groups.push({ status: 'onLeave', employees: employees.filter(employee => employee.workStatus === 'onLeave'), count: summary.raw.byStatus.onLeave }); } // Cards retain original Employee references. A second display never changes Summary. return groups; }