Files
HMDesign/packages/org-chart/src/data/moves.ts
T

151 lines
8.8 KiB
TypeScript

import { normalizeOrgChartDataset } from './normalize.js';
import { validateOrgChartDataset } from './validate.js';
import type { OrgChartDataset } from './types.js';
/** Source IDs retain their original collection identity after a browser-state move. */
export interface OrgChartMoveRequest {
kind: 'employee' | 'organization';
sourceId: string;
targetOrganizationId: string;
}
export type OrgChartMoveFailureCode =
| 'EDITING_DISABLED'
| 'INVALID_DATA' | 'INVALID_REQUEST' | 'SOURCE_NOT_FOUND' | 'TARGET_NOT_FOUND'
| 'COMPANY_UNKNOWN' | 'TARGET_AFFILIATION_MISSING' | 'LEADER_PROTECTED' | 'ROOT_PROTECTED'
| 'SELF_TARGET' | 'DESCENDANT_TARGET' | 'SAME_ORGANIZATION';
export interface OrgChartMoveFailure {
code: OrgChartMoveFailureCode;
message: string;
}
export type OrgChartMoveValidation =
| { allowed: true; previousOrganizationId: string; previousCompanyId: string; companyId: string }
| ({ allowed: false } & OrgChartMoveFailure);
export type OrgChartMoveResult =
| { moved: true; data: OrgChartDataset; request: OrgChartMoveRequest; previousOrganizationId: string }
| ({ moved: false } & OrgChartMoveFailure);
const messages: Record<OrgChartMoveFailureCode, string> = {
EDITING_DISABLED: 'Drag & Drop이 OFF입니다. 조직 이동을 사용하려면 ON으로 설정하세요.',
INVALID_DATA: '조직 데이터의 관계가 유효하지 않아 이동할 수 없습니다.',
INVALID_REQUEST: '이동 요청이 올바르지 않습니다.',
SOURCE_NOT_FOUND: '이동할 직원 또는 조직이 존재하지 않습니다.',
TARGET_NOT_FOUND: '이동할 대상 조직이 존재하지 않습니다.',
COMPANY_UNKNOWN: '회사 소속을 확인할 수 없어 이동할 수 없습니다.',
TARGET_AFFILIATION_MISSING: '대상 회사의 소속 정보가 없어 회사 간 이동을 완료할 수 없습니다.',
LEADER_PROTECTED: '조직장은 조직장 지정 정책을 먼저 변경해야 이동할 수 있습니다.',
ROOT_PROTECTED: '회사 최상위 조직은 이동할 수 없습니다.',
SELF_TARGET: '조직을 자기 자신에게 이동할 수 없습니다.',
DESCENDANT_TARGET: '하위 조직으로 이동하면 순환 관계가 생겨 이동할 수 없습니다.',
SAME_ORGANIZATION: '이미 해당 조직에 속해 있습니다.',
};
function deny(code: OrgChartMoveFailureCode): OrgChartMoveValidation {
return { allowed: false, code, message: messages[code] };
}
/** Validate once for all highlighted drop targets. The closure owns an immutable
* dataset snapshot, so later consumer mutations cannot change a drag's policy.
* Company is the organization tree's identity, not an employee's affiliation.
* Legacy roots may establish it through their leader's affiliation; no arbitrary
* fallback company is invented when ownership cannot be established.
*/
export function createOrgChartMoveValidator(data: OrgChartDataset): (request: OrgChartMoveRequest) => OrgChartMoveValidation {
const validation = validateOrgChartDataset(data);
if (!validation.valid) return () => deny('INVALID_DATA');
const owned = validation.data;
const normalized = normalizeOrgChartDataset(owned);
const employees = new Map(owned.employees.map(employee => [employee.id, employee]));
const affiliations = new Set(owned.affiliations.map(affiliation => affiliation.id));
const leaders = new Set(owned.organizations.flatMap(org => org.leaderId === null ? [] : [org.leaderId]));
const companies = new Map<string, string | undefined>();
const pending = normalized.roots.map(node => ({ node, inherited: undefined as string | undefined }));
while (pending.length) {
const { node, inherited } = pending.pop()!;
const org = node.organization;
const company = org.companyId ?? inherited ?? (org.parentId === null && org.leaderId ? employees.get(org.leaderId)?.affiliationId : undefined);
if (inherited && company !== inherited) return () => deny('INVALID_DATA');
companies.set(org.id, company);
pending.push(...node.children.map(child => ({ node: child, inherited: company })));
}
return request => {
if (!request || !['employee', 'organization'].includes(request.kind)
|| typeof request.sourceId !== 'string' || !request.sourceId
|| typeof request.targetOrganizationId !== 'string' || !request.targetOrganizationId) return deny('INVALID_REQUEST');
const source = request.kind === 'employee' ? employees.get(request.sourceId) : normalized.organizationsById.get(request.sourceId);
if (!source) return deny('SOURCE_NOT_FOUND');
const target = normalized.organizationsById.get(request.targetOrganizationId);
if (!target) return deny('TARGET_NOT_FOUND');
const sourceOrganization = request.kind === 'employee'
? normalized.organizationsById.get(employees.get(request.sourceId)!.organizationId)!
: normalized.organizationsById.get(request.sourceId)!;
const previousOrganizationId = request.kind === 'employee' ? sourceOrganization.id : sourceOrganization.parentId;
if (request.kind === 'employee') {
if (sourceOrganization.id === target.id) return deny('SAME_ORGANIZATION');
if (leaders.has(request.sourceId)) return deny('LEADER_PROTECTED');
} else {
if (sourceOrganization.id === target.id) return deny('SELF_TARGET');
if (sourceOrganization.parentId === null || sourceOrganization.type === 'root') return deny('ROOT_PROTECTED');
if (sourceOrganization.parentId === target.id) return deny('SAME_ORGANIZATION');
let ancestor: typeof target | undefined = target;
while (ancestor) {
if (ancestor.id === sourceOrganization.id) return deny('DESCENDANT_TARGET');
ancestor = ancestor.parentId === null ? undefined : normalized.organizationsById.get(ancestor.parentId);
}
}
const sourceCompany = companies.get(sourceOrganization.id);
const targetCompany = companies.get(target.id);
if (!sourceCompany || !targetCompany) return deny('COMPANY_UNKNOWN');
if (sourceCompany !== targetCompany && !affiliations.has(targetCompany)) return deny('TARGET_AFFILIATION_MISSING');
return { allowed: true, previousOrganizationId: previousOrganizationId!, previousCompanyId: sourceCompany, companyId: targetCompany };
};
}
/** Pure policy check for a single keyboard or pointer move. */
export function validateOrgChartMove(data: OrgChartDataset, request: OrgChartMoveRequest): OrgChartMoveValidation {
return createOrgChartMoveValidator(data)(request);
}
/** Create a new validated, deeply frozen browser-state dataset. Same-company
* moves change membership alone. Cross-company employee moves also change the
* existing affiliationId; group moves retarget companyId for its whole subtree
* and affiliationId for every subtree employee. No new contract fields are used.
* IDs, descendants, leaders, personal data and display ordering are retained.
* Failed moves return no replacement data and never alter the input.
*/
export function applyOrgChartMove(data: OrgChartDataset, request: OrgChartMoveRequest): OrgChartMoveResult {
const policy = validateOrgChartMove(data, request);
if (!policy.allowed) return { moved: false, code: policy.code, message: policy.message };
const crossCompany = policy.previousCompanyId !== policy.companyId;
let candidate: OrgChartDataset;
if (request.kind === 'employee') {
candidate = { ...data, employees: data.employees.map(employee => employee.id === request.sourceId
? { ...employee, organizationId: request.targetOrganizationId, ...(crossCompany ? { affiliationId: policy.companyId } : {}) } : employee) };
} else {
const subtree = new Set<string>([request.sourceId]);
if (crossCompany) {
const children = new Map<string, string[]>();
for (const org of data.organizations) {
if (org.parentId === null) continue;
const siblings = children.get(org.parentId) ?? [];
siblings.push(org.id); children.set(org.parentId, siblings);
}
const pending = [request.sourceId];
while (pending.length) for (const id of children.get(pending.pop()!) ?? []) { subtree.add(id); pending.push(id); }
}
candidate = {
...data,
organizations: data.organizations.map(org => org.id === request.sourceId
? { ...org, parentId: request.targetOrganizationId, ...(crossCompany ? { companyId: policy.companyId } : {}) }
: crossCompany && subtree.has(org.id) ? { ...org, companyId: policy.companyId } : org),
employees: crossCompany ? data.employees.map(employee => subtree.has(employee.organizationId)
? { ...employee, affiliationId: policy.companyId } : employee) : data.employees,
};
}
const validation = validateOrgChartDataset(candidate);
if (!validation.valid) return { moved: false, code: 'INVALID_DATA', message: messages.INVALID_DATA };
return { moved: true, data: validation.data, request: Object.freeze({ ...request }), previousOrganizationId: policy.previousOrganizationId };
}