151 lines
8.8 KiB
TypeScript
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 };
|
|
}
|