EENE Dashboard upload to Gitea

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
EENE Dashboard
2026-06-17 16:59:34 +09:00
parent cf72281c6d
commit b3f2da203b
138 changed files with 13013 additions and 1929 deletions

View File

@@ -1,6 +1,7 @@
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;
@@ -26,6 +27,21 @@ export interface HrProject {
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 {
@@ -59,6 +75,8 @@ const SECTION_MAP: Record<string, string> = {
: '인사관리',
: '학습성장',
: '운영관리',
: '운영관리',
: '운영관리',
};
const STATUS_MAP: Record<string, TaskStatus> = {
@@ -75,7 +93,7 @@ const PHASE_PROGRESS: Record<string, number> = {
};
export function defaultHrDataPath(): string {
return path.resolve(__dirname, '../../../HR_Dashboard/data.json');
return getHrSeedPath();
}
export function loadHrProjects(filePath = defaultHrDataPath()): HrProject[] {
@@ -84,6 +102,72 @@ export function loadHrProjects(filePath = defaultHrDataPath()): 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);
@@ -94,6 +178,14 @@ 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';
@@ -186,8 +278,8 @@ export function mapHrProjectToTask(p: HrProject, quarter = '2026-Q2'): MappedTas
status: mapStatus(p.status, isRoutine),
priority: mapPriority(p.priority),
quarter,
category: mapSection(p.category),
section: mapSection(p.category),
category: mapBoardSection(p),
section: mapBoardSection(p),
taskType,
progress: mapProgress(p.progress),
issueNote: pickIssueNote(p),
@@ -204,5 +296,7 @@ export function mapHrProjectToTask(p: HrProject, quarter = '2026-Q2'): MappedTas
}
export function mapAllHrProjects(filePath?: string, quarter = '2026-Q2'): MappedTask[] {
return loadHrProjects(filePath).map((p) => mapHrProjectToTask(p, quarter));
return loadHrProjects(filePath)
.filter((p) => p.priority !== '상시')
.map((p) => mapHrProjectToTask(p, quarter));
}

View File

@@ -0,0 +1,28 @@
ALTER TABLE "milestones" ADD COLUMN IF NOT EXISTS "pmMemberId" TEXT;
CREATE TABLE IF NOT EXISTS "milestone_assignees" (
"milestoneId" TEXT NOT NULL,
"memberId" TEXT NOT NULL,
CONSTRAINT "milestone_assignees_pkey" PRIMARY KEY ("milestoneId","memberId")
);
CREATE INDEX IF NOT EXISTS "milestone_assignees_memberId_idx" ON "milestone_assignees"("memberId");
CREATE INDEX IF NOT EXISTS "milestones_pmMemberId_idx" ON "milestones"("pmMemberId");
DO $$ BEGIN
ALTER TABLE "milestones" ADD CONSTRAINT "milestones_pmMemberId_fkey"
FOREIGN KEY ("pmMemberId") REFERENCES "team_members"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "milestone_assignees" ADD CONSTRAINT "milestone_assignees_milestoneId_fkey"
FOREIGN KEY ("milestoneId") REFERENCES "milestones"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "milestone_assignees" ADD CONSTRAINT "milestone_assignees_memberId_fkey"
FOREIGN KEY ("memberId") REFERENCES "team_members"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "tasks" ADD COLUMN "issueEntries" JSONB;

View File

@@ -0,0 +1,8 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "hub_configs" (
"id" TEXT NOT NULL,
"config" JSONB NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "hub_configs_pkey" PRIMARY KEY ("id")
);

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "milestones" ADD COLUMN IF NOT EXISTS "subtitle" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "milestones" ADD COLUMN "periodEntries" JSONB;

View File

@@ -46,6 +46,8 @@ model TeamMember {
pmTasks Task[] @relation("PmTasks")
taskAssignees TaskAssignee[]
milestonePmTasks Milestone[] @relation("MilestonePm")
milestoneAssignees MilestoneAssignee[]
@@index([cell])
@@index([isActive])
@@ -72,7 +74,8 @@ model Task {
tag String? // Growth | Policy | Performance | Culture | Asset | Space | Safety | Environment
taskType String? // 상시업무 | 프로젝트
progress Int @default(0)
issueNote String?
issueNote String?
issueEntries Json?
startDate DateTime?
dueDate DateTime?
showDate Boolean @default(true)
@@ -200,24 +203,42 @@ model Milestone {
id String @id @default(cuid())
taskId String
title String
subtitle String?
description String?
startDate DateTime?
dueDate DateTime?
periodEntries Json?
progress Int @default(0)
links String? // JSON: [{ "label": string, "url": string }]
completedAt DateTime?
order Int @default(0)
pmMemberId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
details TaskDetail[]
files File[]
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
pmMember TeamMember? @relation("MilestonePm", fields: [pmMemberId], references: [id])
milestoneAssignees MilestoneAssignee[]
details TaskDetail[]
files File[]
@@index([taskId])
@@index([pmMemberId])
@@map("milestones")
}
model MilestoneAssignee {
milestoneId String
memberId String
milestone Milestone @relation(fields: [milestoneId], references: [id], onDelete: Cascade)
member TeamMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
@@id([milestoneId, memberId])
@@index([memberId])
@@map("milestone_assignees")
}
// ─── 컬럼 설정 ───────────────────────────────────────────────
model ColumnConfig {
@@ -231,6 +252,16 @@ model ColumnConfig {
@@map("column_configs")
}
// ─── 허브 설정 (분기 중점 과제·일정·상시 라벨) ─────────────────
model HubConfig {
id String @id @default("default")
config Json
updatedAt DateTime @updatedAt
@@map("hub_configs")
}
// ─── 감사 로그 ───────────────────────────────────────────────
model AuditLog {

View File

@@ -1,67 +1,273 @@
import 'dotenv/config';
import bcrypt from 'bcrypt';
import { PrismaClient } from '@prisma/client';
import { mapAllHrProjects } from './mapHrProjects';
import {
loadHrProjects,
mapAllHrProjects,
mapHrProjectToTask,
mapHrTeamMembers,
normalizePersonName,
} from './mapHrProjects';
const prisma = new PrismaClient();
const HUB_CONFIG = {
sloganTitle: '분기 중점 과제',
sloganLines: ['인사 · 육성 · 문화 · 총무', '개선과제', '정상 추진'],
scheduleTitle: '분기 주요 일정',
scheduleItems: [
{ id: '1', date: '2026-04-01', text: '상반기 채용·온보딩' },
{ id: '2', date: '2026-05-15', text: '조직문화 진단·리더십 교육' },
{ id: '3', date: '2026-06-20', text: '분기 성과 점검·평가' },
],
routineLabels: ['채용 운영', '학습 지원', '직원 소통', '자산·시설', '문서·행정'],
};
async function main() {
console.log('🌱 Seeding database...');
console.log('🌱 Seeding database from data/seed/hr-data.json ...');
const adminPw = await bcrypt.hash('admin1234!', 12);
const memberPw = await bcrypt.hash('member1234!', 12);
const admin = await prisma.user.upsert({
where: { email: 'admin@eene.com' },
update: {},
create: { email: 'admin@eene.com', password: adminPw, name: '관리자', role: 'ADMIN', department: 'EENE' },
});
const member = await prisma.user.upsert({
where: { email: 'member@eene.com' },
update: { name: '정성호' },
create: { email: 'member@eene.com', password: memberPw, name: '정성호', role: 'MEMBER', department: 'EENE' },
});
console.log('✅ Users ready');
const mapped = mapAllHrProjects();
await prisma.milestoneAssignee.deleteMany({});
await prisma.taskAssignee.deleteMany({});
await prisma.file.deleteMany({});
await prisma.taskDetail.deleteMany({});
await prisma.milestone.deleteMany({});
await prisma.kpiMetric.deleteMany({});
await prisma.task.deleteMany({});
for (const t of mapped) {
const { milestones, detailContent, ...taskData } = t;
const task = await prisma.task.create({
await prisma.teamMember.deleteMany({});
const teamParsed = mapHrTeamMembers();
const memberIdByName = new Map<string, string>();
for (const tm of teamParsed) {
const created = await prisma.teamMember.create({
data: {
...taskData,
creatorId: admin.id,
assigneeId: member.id,
name: tm.name,
rank: tm.rank,
role: tm.role,
cell: tm.cell,
photoUrl: tm.photoUrl,
sortOrder: tm.sortOrder,
isActive: true,
},
});
for (const [order, ms] of milestones.entries()) {
await prisma.milestone.create({
data: { ...ms, taskId: task.id, order },
});
}
memberIdByName.set(normalizePersonName(tm.name), created.id);
if (detailContent) {
await prisma.taskDetail.create({
data: {
taskId: task.id,
content: detailContent,
updatedBy: admin.id,
},
});
}
}
console.log(`✅ Tasks created: ${mapped.length}개 (HR_Dashboard 데이터)`);
console.log(`✅ Team members: ${teamParsed.length}명 (hr-data.json TEAM)`);
const hrProjects = loadHrProjects();
let taskCount = 0;
for (const hp of hrProjects) {
if (hp.priority === '상시') continue;
const t = mapHrProjectToTask(hp);
const { milestones, detailContent, ...taskData } = t;
const pmKey = hp.pm?.trim() ? normalizePersonName(hp.pm) : '';
const pmMemberId = pmKey ? memberIdByName.get(pmKey) ?? null : null;
const ownerIds = [...new Set(
(hp.owners ?? [])
.map((o) => normalizePersonName(o))
.filter(Boolean)
.map((key) => memberIdByName.get(key))
.filter((id): id is string => !!id),
)];
const task = await prisma.task.create({
data: {
...taskData,
creatorId: admin.id,
assigneeId: member.id,
pmMemberId,
...(ownerIds.length > 0
? { taskAssignees: { create: ownerIds.map((memberId) => ({ memberId })) } }
: {}),
},
});
for (const [order, ms] of milestones.entries()) {
await prisma.milestone.create({
data: { ...ms, taskId: task.id, order },
});
}
if (detailContent) {
await prisma.taskDetail.create({
data: {
taskId: task.id,
content: detailContent,
updatedBy: admin.id,
},
});
}
taskCount += 1;
}
await prisma.hubConfig.upsert({
where: { id: 'default' },
update: { config: HUB_CONFIG },
create: { id: 'default', config: HUB_CONFIG },
});
console.log(`✅ Tasks: ${taskCount}개 (PROJECTS)`);
console.log('✅ Hub config reset (5 대분류 상시업무)');
console.log('🎉 Seeding complete!');
console.log(' → 브라우저에서 Ctrl+F5 후, 필요 시 DevTools에서 localStorage 허브 키 삭제');
}
main().catch(console.error).finally(() => prisma.$disconnect());