302 lines
8.5 KiB
TypeScript
302 lines
8.5 KiB
TypeScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import type { Priority, TaskStatus } from '@prisma/client';
|
|
import { getHrSeedPath, getUploadDir } from '../src/lib/projectPaths';
|
|
|
|
export interface HrProject {
|
|
idx?: number;
|
|
name: string;
|
|
pm?: string;
|
|
priority: string;
|
|
startDate?: string;
|
|
endDate?: string;
|
|
status?: string;
|
|
category: string;
|
|
summary?: string;
|
|
content?: string;
|
|
briefIntro?: string;
|
|
progress?: number;
|
|
progressLog?: string;
|
|
progressStatus?: string;
|
|
issues?: string;
|
|
statusText?: string;
|
|
isIssue?: boolean;
|
|
keywords?: string[];
|
|
refLinks?: { name: string; url: string }[];
|
|
referenceUrl?: string;
|
|
subPhases?: { name: string; status?: string; text?: string }[];
|
|
timelineItems?: { startDate?: string; endDate?: string; desc?: string }[];
|
|
showOnDashboard?: boolean;
|
|
owners?: string[];
|
|
}
|
|
|
|
export interface HrTeamEntry {
|
|
name: string;
|
|
photo?: string;
|
|
}
|
|
|
|
export interface ParsedHrTeamMember {
|
|
name: string;
|
|
rank: string | null;
|
|
role: string | null;
|
|
cell: string | null;
|
|
photoUrl: string | null;
|
|
sortOrder: number;
|
|
}
|
|
|
|
export interface MappedTask {
|
|
title: string;
|
|
description: string | null;
|
|
status: TaskStatus;
|
|
priority: Priority;
|
|
quarter: string;
|
|
category: string;
|
|
section: string;
|
|
taskType: string;
|
|
progress: number;
|
|
issueNote: string | null;
|
|
startDate: Date | null;
|
|
dueDate: Date | null;
|
|
showDate: boolean;
|
|
showDescription: boolean;
|
|
showStatus: boolean;
|
|
showIssue: boolean;
|
|
showProgress: boolean;
|
|
milestones: {
|
|
title: string;
|
|
description: string | null;
|
|
progress: number;
|
|
links: string | null;
|
|
}[];
|
|
detailContent: string | null;
|
|
}
|
|
|
|
const SECTION_MAP: Record<string, string> = {
|
|
인사관리: '인사관리',
|
|
성장지원: '학습성장',
|
|
운영관리: '운영관리',
|
|
운영지원: '운영관리',
|
|
전산관리: '운영관리',
|
|
};
|
|
|
|
const STATUS_MAP: Record<string, TaskStatus> = {
|
|
진행: 'IN_PROGRESS',
|
|
진행중: 'IN_PROGRESS',
|
|
대기: 'TODO',
|
|
완료: 'DONE',
|
|
};
|
|
|
|
const PHASE_PROGRESS: Record<string, number> = {
|
|
완료: 100,
|
|
진행중: 50,
|
|
미착수: 0,
|
|
};
|
|
|
|
export function defaultHrDataPath(): string {
|
|
return getHrSeedPath();
|
|
}
|
|
|
|
export function loadHrProjects(filePath = defaultHrDataPath()): HrProject[] {
|
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
const data = JSON.parse(raw) as { PROJECTS?: HrProject[] };
|
|
return (data.PROJECTS ?? []).filter((p) => p.showOnDashboard !== false);
|
|
}
|
|
|
|
export function loadHrTeam(filePath = defaultHrDataPath()): HrTeamEntry[] {
|
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
const data = JSON.parse(raw) as { TEAM?: HrTeamEntry[] };
|
|
return data.TEAM ?? [];
|
|
}
|
|
|
|
/** "조태희 수석(팀장)" → name / rank / role */
|
|
export function parseHrTeamLabel(label: string, sortOrder: number): ParsedHrTeamMember {
|
|
const s = label.trim();
|
|
const withRole = s.match(/^(.+?)\s+(.+?)\((.+)\)$/);
|
|
if (withRole) {
|
|
const role = withRole[3].trim();
|
|
return {
|
|
name: withRole[1].trim(),
|
|
rank: withRole[2].trim(),
|
|
role,
|
|
cell: role === '팀장' ? null : 'HR',
|
|
photoUrl: null,
|
|
sortOrder,
|
|
};
|
|
}
|
|
const plain = s.match(/^(.+?)\s+(.+)$/);
|
|
if (plain) {
|
|
return {
|
|
name: plain[1].trim(),
|
|
rank: plain[2].trim(),
|
|
role: null,
|
|
cell: 'HR',
|
|
photoUrl: null,
|
|
sortOrder,
|
|
};
|
|
}
|
|
return { name: s, rank: null, role: null, cell: 'HR', photoUrl: null, sortOrder };
|
|
}
|
|
|
|
export function mapHrTeamMembers(filePath = defaultHrDataPath()): ParsedHrTeamMember[] {
|
|
return loadHrTeam(filePath).map((entry, i) => {
|
|
const parsed = parseHrTeamLabel(entry.name, i);
|
|
const photo = resolveTeamPhotoPath(entry.photo?.trim() || null);
|
|
return {
|
|
...parsed,
|
|
photoUrl: photo,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** seed용 — 파일이 프로젝트 uploads/ 에 실제 있을 때만 경로 사용 */
|
|
export function resolveTeamPhotoPath(photo: string | null): string | null {
|
|
if (!photo?.trim()) return null;
|
|
const trimmed = photo.trim();
|
|
if (/^https?:\/\//i.test(trimmed) || trimmed.startsWith('data:')) return null;
|
|
|
|
const uploadDir = getUploadDir();
|
|
const relative = trimmed.replace(/^\//, '').replace(/^uploads\//, '');
|
|
const abs = path.join(uploadDir, relative);
|
|
if (fs.existsSync(abs)) {
|
|
return trimmed.startsWith('/') ? trimmed : `/uploads/${relative}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** PM·담당자 문자열 → team_members.name 매칭용 */
|
|
export function normalizePersonName(value: string): string {
|
|
return value.trim().replace(/\s+/g, '');
|
|
}
|
|
|
|
function parseDate(value?: string): Date | null {
|
|
if (!value?.trim()) return null;
|
|
const d = new Date(value);
|
|
return Number.isNaN(d.getTime()) ? null : d;
|
|
}
|
|
|
|
function mapSection(category: string): string {
|
|
return SECTION_MAP[category] ?? category;
|
|
}
|
|
|
|
function mapBoardSection(p: HrProject): string {
|
|
const name = p.name.trim();
|
|
if (/회사생활|C\.E\.L|조직문화|복리후생|문화\s*진단|직원\s*소통/i.test(name)) {
|
|
return '조직문화';
|
|
}
|
|
return mapSection(p.category);
|
|
}
|
|
|
|
function mapStatus(status?: string, isRoutine = false): TaskStatus {
|
|
if (!status?.trim()) return isRoutine ? 'IN_PROGRESS' : 'TODO';
|
|
return STATUS_MAP[status] ?? 'IN_PROGRESS';
|
|
}
|
|
|
|
function mapPriority(priority: string): Priority {
|
|
if (priority === '상시') return 'MEDIUM';
|
|
if (priority === '높음') return 'HIGH';
|
|
if (priority === '낮음') return 'LOW';
|
|
return 'MEDIUM';
|
|
}
|
|
|
|
function mapProgress(value?: number): number {
|
|
if (value == null || Number.isNaN(value)) return 0;
|
|
if (value <= 1) return Math.round(value * 100);
|
|
return Math.min(100, Math.round(value));
|
|
}
|
|
|
|
function pickDescription(p: HrProject): string | null {
|
|
const candidates = [p.content, p.summary, p.briefIntro].map((v) => v?.trim()).filter(Boolean) as string[];
|
|
const best = candidates.find((v) => v.length > 2 && v !== '12');
|
|
return best ?? null;
|
|
}
|
|
|
|
function pickIssueNote(p: HrProject): string | null {
|
|
const parts: string[] = [];
|
|
if (p.isIssue && p.statusText?.trim()) parts.push(p.statusText.trim());
|
|
if (p.issues?.trim()) parts.push(p.issues.trim());
|
|
if (p.progressLog?.trim() && p.progressLog !== '이슈사항') parts.push(p.progressLog.trim());
|
|
return parts.length ? parts.join('\n') : null;
|
|
}
|
|
|
|
function buildLinks(p: HrProject): string | null {
|
|
const links: { label: string; url: string }[] = [];
|
|
for (const ref of p.refLinks ?? []) {
|
|
if (ref.url?.trim()) links.push({ label: ref.name || '참고자료', url: ref.url.trim() });
|
|
}
|
|
if (p.referenceUrl?.trim()) links.push({ label: '참고링크', url: p.referenceUrl.trim() });
|
|
return links.length ? JSON.stringify(links) : null;
|
|
}
|
|
|
|
function buildMilestones(p: HrProject): MappedTask['milestones'] {
|
|
const milestones: MappedTask['milestones'] = [];
|
|
|
|
for (const [i, phase] of (p.subPhases ?? []).entries()) {
|
|
milestones.push({
|
|
title: phase.name,
|
|
description: phase.text?.trim() || null,
|
|
progress: PHASE_PROGRESS[phase.status ?? ''] ?? 0,
|
|
links: null,
|
|
});
|
|
}
|
|
|
|
for (const item of p.timelineItems ?? []) {
|
|
if (!item.desc?.trim()) continue;
|
|
milestones.push({
|
|
title: item.desc.trim(),
|
|
description: [item.startDate, item.endDate].filter(Boolean).join(' ~ ') || null,
|
|
progress: 0,
|
|
links: null,
|
|
});
|
|
}
|
|
|
|
if (milestones.length === 0 && buildLinks(p)) {
|
|
milestones.push({
|
|
title: '참고자료',
|
|
description: null,
|
|
progress: 0,
|
|
links: buildLinks(p),
|
|
});
|
|
}
|
|
|
|
return milestones;
|
|
}
|
|
|
|
/** @deprecated progressStatus는 milestone·periodEntries로 이관 — TaskDetail(피드백)에 넣지 않음 */
|
|
function buildDetailContent(_p: HrProject): string | null {
|
|
return null;
|
|
}
|
|
|
|
export function mapHrProjectToTask(p: HrProject, quarter = '2026-Q2'): MappedTask {
|
|
const isRoutine = p.priority === '상시';
|
|
const taskType = isRoutine ? '기반업무' : '실행과제';
|
|
const visible = !isRoutine;
|
|
|
|
return {
|
|
title: p.name.trim(),
|
|
description: pickDescription(p),
|
|
status: mapStatus(p.status, isRoutine),
|
|
priority: mapPriority(p.priority),
|
|
quarter,
|
|
category: mapBoardSection(p),
|
|
section: mapBoardSection(p),
|
|
taskType,
|
|
progress: mapProgress(p.progress),
|
|
issueNote: pickIssueNote(p),
|
|
startDate: parseDate(p.startDate),
|
|
dueDate: parseDate(p.endDate),
|
|
showDate: visible,
|
|
showDescription: visible,
|
|
showStatus: visible,
|
|
showIssue: visible,
|
|
showProgress: visible,
|
|
milestones: buildMilestones(p),
|
|
detailContent: buildDetailContent(p),
|
|
};
|
|
}
|
|
|
|
export function mapAllHrProjects(filePath?: string, quarter = '2026-Q2'): MappedTask[] {
|
|
return loadHrProjects(filePath)
|
|
.filter((p) => p.priority !== '상시')
|
|
.map((p) => mapHrProjectToTask(p, quarter));
|
|
}
|