import type { Employee } from '../data/index.js'; import personPlaceholder from '../assets/person-placeholder.svg?inline'; import { createAffiliationMark } from './affiliation-mark.js'; import type { DepartmentCardAsset } from './department-card-assets.js'; import type { RepresentativeCardAsset } from './representative-card-asset.js'; import { observePersonNameOverflow } from './person-name-overflow.js'; const statusNames = { active: '재직', dispatched: '파견', onLeave: '휴직' } as const; export interface EmployeeCardOptions { kind: 'topLeader' | 'leader' | 'employee'; affiliationName?: string; cardAsset?: DepartmentCardAsset | RepresentativeCardAsset; /** Full Composition presentation only; the employee dataset is untouched. */ workdeskPresentation?: boolean; /** Auto content presentation, independent of Fixed SVG geometry. */ expandedPresentation?: boolean; /** Compact read-only status groups already convey this label in their header. */ hideStatusLabel?: boolean; } function text(document: Document, tag: string, className: string, value: string): HTMLElement { const element = document.createElement(tag); element.className = className; element.textContent = value; return element; } /** Content renderer shared by the hierarchy and supplementary status groups. */ export function createEmployeeCard( document: Document, employee: Employee, options: EmployeeCardOptions, signal: AbortSignal, ): HTMLElement { const leader = options.kind !== 'employee'; const representative = options.kind === 'topLeader' || options.cardAsset?.size === 'representative'; const compactStatus = Boolean(options.workdeskPresentation && options.hideStatusLabel && !options.cardAsset); const styledCard = Boolean(options.cardAsset) || compactStatus || options.expandedPresentation; const presentationName = options.workdeskPresentation || (representative && !options.expandedPresentation); const displayName = presentationName ? '홍길동' : employee.name; const displayedName = presentationName ? '홍 길 동' : options.expandedPresentation && /^[가-힣]{3}$/u.test(displayName) ? [...displayName].join(' ') : displayName; const card = document.createElement('div'); card.className = `hm-orgchart-employee${leader ? ' hm-orgchart-leader' : ''}`; card.dataset.employeeId = employee.id; card.dataset.cardKind = options.kind; card.dataset.workStatus = employee.workStatus; if (options.expandedPresentation) card.dataset.cardPresentation = 'expanded'; if (options.workdeskPresentation) card.dataset.workdeskPresentation = 'true'; if (compactStatus) card.dataset.cardLayout = 'compact'; if (options.cardAsset) { const asset = options.cardAsset; card.dataset.cardAsset = 'departmentId' in asset ? asset.departmentId : 'representative'; card.dataset.cardAssetSize = asset.size; const background = document.createElement('img'); background.className = 'hm-orgchart-card-background'; background.alt = ''; background.setAttribute('aria-hidden', 'true'); background.width = asset.width; background.height = asset.height; background.src = asset.url; card.append(background); } // One neutral icon is shared by every card type and both runtime adapters. // The approved rectangular photo slots keep their existing dimensions. const avatar = document.createElement('span'); avatar.className = 'hm-orgchart-avatar'; avatar.setAttribute('aria-hidden', 'true'); const image = document.createElement('img'); image.alt = ''; image.loading = 'lazy'; image.decoding = 'async'; image.referrerPolicy = 'no-referrer'; const showPlaceholder = () => { card.dataset.photoState = 'placeholder'; image.dataset.photoPlaceholder = 'true'; image.src = personPlaceholder; }; if (employee.profileImage) { card.dataset.photoState = 'image'; image.addEventListener('error', showPlaceholder, { once: true, signal }); // Images have already passed the dataset URL validation. No HTML parsing. image.src = employee.profileImage; } else showPlaceholder(); avatar.append(image); const details = document.createElement('div'); details.className = 'hm-orgchart-employee-details'; const displayedPosition = representative && !options.expandedPresentation ? '대표이사' : employee.position.replace(/\s+/gu, ''); const position = text(document, 'span', 'hm-orgchart-person-role', displayedPosition); const name = text(document, 'strong', 'hm-orgchart-person-name', displayedName); if (presentationName) { position.setAttribute('aria-label', representative ? displayedPosition : employee.position); name.setAttribute('aria-label', displayName); } if (options.expandedPresentation) position.setAttribute('aria-label', employee.position); const inlineRoleBadge = styledCard && !representative && !options.expandedPresentation; const positionRow = inlineRoleBadge ? document.createElement('div') : null; if (positionRow) { positionRow.className = 'hm-orgchart-position-row'; positionRow.append(position); } if (styledCard) { const personText = document.createElement('div'); personText.className = 'hm-orgchart-person-text'; personText.append(positionRow ?? position, name); details.append(personText); observePersonNameOverflow(card, name, signal); } else details.append(position, name); const attributes = document.createElement('div'); attributes.className = 'hm-orgchart-person-attributes'; // Representative cards show only position/name in every consumer. Raw employee // roles remain available to dialogs/statistics; other cards keep their policy. const hideExecutiveBadge = options.workdeskPresentation && employee.jobTitle?.trim() === '경영진'; if (!representative && !options.expandedPresentation && !hideExecutiveBadge && (employee.jobTitle || (leader && !options.cardAsset))) { const rawBadge = employee.jobTitle || '조직장'; const badge = text(document, 'span', 'hm-orgchart-job-title', options.workdeskPresentation ? rawBadge.replace(/\s+/gu, '') : rawBadge); if (options.workdeskPresentation) badge.setAttribute('aria-label', employee.jobTitle || '조직장'); badge.title = badge.textContent!; (positionRow ?? attributes).append(badge); } if (!representative && !options.hideStatusLabel) attributes.append(text(document, 'span', 'hm-orgchart-status-label', statusNames[employee.workStatus])); if (styledCard) { card.append(avatar, details); if (attributes.childElementCount) card.append(attributes); } else { if (attributes.childElementCount) details.append(attributes); card.append(avatar, details); } if (options.affiliationName) { const affiliation = createAffiliationMark(document, employee.affiliationId, options.affiliationName, signal); card.dataset.hasAffiliation = 'true'; card.append(affiliation); } return card; }