feat: 대시보드 작업장 현황 지도 구현

- 실시간 작업장 현황을 지도로 시각화
- 작업장 관리 페이지에서 정의한 구역 정보 활용
- TBM 작업자 및 방문자 현황 표시

주요 변경사항:
- dashboard.html: 작업장 현황 섹션 추가 (기존 작업 현황 테이블 제거)
- workplace-status.js: 지도 렌더링 및 데이터 통합 로직 구현
- modern-dashboard.js: 삭제된 DOM 요소 조건부 체크 추가

시각화 방식:
- 인원 없음: 회색 테두리 + 작업장 이름
- 내부 작업자: 파란색 영역 + 인원 수
- 외부 방문자: 보라색 영역 + 인원 수
- 둘 다: 초록색 영역 + 총 인원 수

기술 구현:
- Canvas API 기반 사각형 영역 렌더링
- map-regions API를 통한 데이터 일관성 보장
- 클릭 이벤트로 상세 정보 모달 표시

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-01-29 15:46:47 +09:00
parent e1227a69fe
commit b6485e3140
87 changed files with 17509 additions and 698 deletions

View File

@@ -0,0 +1,37 @@
/**
* 마이그레이션: tbm_team_assignments 테이블 확장
* 작업자별 프로젝트/공정/작업/작업장 정보 저장 가능하도록 컬럼 추가 및 외래키 설정
*/
exports.up = async function(knex) {
// 1. workplace_category_id와 workplace_id를 UNSIGNED로 변경
await knex.raw(`
ALTER TABLE tbm_team_assignments
MODIFY COLUMN workplace_category_id INT UNSIGNED NULL COMMENT '작업자별 작업장 대분류 (공장)',
MODIFY COLUMN workplace_id INT UNSIGNED NULL COMMENT '작업자별 작업장 ID'
`);
// 2. 외래키 제약조건 추가
return knex.schema.alterTable('tbm_team_assignments', function(table) {
// 외래키 제약조건 추가
table.foreign('workplace_category_id')
.references('category_id')
.inTable('workplace_categories')
.onDelete('SET NULL')
.onUpdate('CASCADE');
table.foreign('workplace_id')
.references('workplace_id')
.inTable('workplaces')
.onDelete('SET NULL')
.onUpdate('CASCADE');
});
};
exports.down = function(knex) {
return knex.schema.alterTable('tbm_team_assignments', function(table) {
// 외래키 제약조건 제거
table.dropForeign('workplace_category_id');
table.dropForeign('workplace_id');
});
};

View File

@@ -0,0 +1,20 @@
/**
* 마이그레이션: tbm_sessions 테이블에서 불필요한 컬럼 제거
* work_description, safety_notes, start_time 컬럼 제거
*/
exports.up = function(knex) {
return knex.schema.alterTable('tbm_sessions', function(table) {
table.dropColumn('work_description');
table.dropColumn('safety_notes');
table.dropColumn('start_time');
});
};
exports.down = function(knex) {
return knex.schema.alterTable('tbm_sessions', function(table) {
table.text('work_description').nullable().comment('작업 내용');
table.text('safety_notes').nullable().comment('안전 관련 특이사항');
table.time('start_time').nullable().comment('시작 시간');
});
};

View File

@@ -0,0 +1,53 @@
/**
* 마이그레이션: 작업장 지도 이미지 기능 추가
* - workplace_categories에 layout_image 필드 추가
* - workplace_map_regions 테이블 생성 (클릭 가능한 영역 정의)
*/
exports.up = async function(knex) {
// 1. workplace_categories 테이블에 layout_image 필드 추가
await knex.schema.alterTable('workplace_categories', function(table) {
table.string('layout_image', 500).nullable().comment('공장 배치도 이미지 경로');
});
// 2. 작업장 지도 클릭 영역 정의 테이블 생성
await knex.schema.createTable('workplace_map_regions', function(table) {
table.increments('region_id').primary().comment('영역 ID');
table.integer('workplace_id').unsigned().notNullable().comment('작업장 ID');
table.integer('category_id').unsigned().notNullable().comment('공장 카테고리 ID');
// 좌표 정보 (비율 기반: 0~100%)
table.decimal('x_start', 5, 2).notNullable().comment('시작 X 좌표 (%)');
table.decimal('y_start', 5, 2).notNullable().comment('시작 Y 좌표 (%)');
table.decimal('x_end', 5, 2).notNullable().comment('끝 X 좌표 (%)');
table.decimal('y_end', 5, 2).notNullable().comment('끝 Y 좌표 (%)');
table.string('shape', 20).defaultTo('rect').comment('영역 모양 (rect, circle, polygon)');
table.text('polygon_points').nullable().comment('다각형인 경우 좌표 배열 (JSON)');
table.timestamps(true, true);
// 외래키
table.foreign('workplace_id')
.references('workplace_id')
.inTable('workplaces')
.onDelete('CASCADE')
.onUpdate('CASCADE');
table.foreign('category_id')
.references('category_id')
.inTable('workplace_categories')
.onDelete('CASCADE')
.onUpdate('CASCADE');
});
};
exports.down = async function(knex) {
// 테이블 삭제
await knex.schema.dropTableIfExists('workplace_map_regions');
// 필드 제거
await knex.schema.alterTable('workplace_categories', function(table) {
table.dropColumn('layout_image');
});
};

View File

@@ -0,0 +1,17 @@
/**
* 마이그레이션: 작업장 용도 및 표시 순서 필드 추가
*/
exports.up = function(knex) {
return knex.schema.alterTable('workplaces', function(table) {
table.string('workplace_purpose', 50).nullable().comment('작업장 용도 (작업구역, 설비, 휴게시설, 회의실 등)');
table.integer('display_priority').defaultTo(0).comment('표시 우선순위 (숫자가 작을수록 먼저 표시)');
});
};
exports.down = function(knex) {
return knex.schema.alterTable('workplaces', function(table) {
table.dropColumn('workplace_purpose');
table.dropColumn('display_priority');
});
};

View File

@@ -0,0 +1,30 @@
/**
* leader_id를 nullable로 변경
* 관리자가 TBM을 입력할 때 leader_id를 NULL로 설정하고 created_by를 사용
*/
exports.up = async function(knex) {
// 1. 외래 키 제약조건 삭제 (존재하는 경우에만)
try {
await knex.raw('ALTER TABLE tbm_sessions DROP FOREIGN KEY tbm_sessions_leader_id_foreign');
} catch (err) {
console.log('외래 키가 이미 존재하지 않음 (정상)');
}
// 2. leader_id를 nullable로 변경 (UNSIGNED 제거하여 workers.worker_id와 타입 일치)
await knex.raw('ALTER TABLE tbm_sessions MODIFY leader_id INT(11) NULL');
// 3. 외래 키 제약조건 다시 추가 (nullable 허용)
await knex.raw('ALTER TABLE tbm_sessions ADD CONSTRAINT tbm_sessions_leader_id_foreign FOREIGN KEY (leader_id) REFERENCES workers(worker_id) ON DELETE SET NULL');
};
exports.down = async function(knex) {
// 1. 외래 키 제약조건 삭제
await knex.raw('ALTER TABLE tbm_sessions DROP FOREIGN KEY tbm_sessions_leader_id_foreign');
// 2. leader_id를 NOT NULL로 되돌림
await knex.raw('ALTER TABLE tbm_sessions MODIFY leader_id INT(11) NOT NULL');
// 3. 외래 키 제약조건 다시 추가
await knex.raw('ALTER TABLE tbm_sessions ADD CONSTRAINT tbm_sessions_leader_id_foreign FOREIGN KEY (leader_id) REFERENCES workers(worker_id) ON DELETE CASCADE');
};

View File

@@ -0,0 +1,55 @@
/**
* daily_work_reports 테이블에 TBM 연동 필드 추가
* - TBM 세션 및 팀 배정과 연결
* - 작업 시간 및 오류 시간 추적
*/
exports.up = async function(knex) {
await knex.schema.table('daily_work_reports', (table) => {
// TBM 연동 필드
table.integer('tbm_session_id').unsigned().nullable()
.comment('연결된 TBM 세션 ID');
table.integer('tbm_assignment_id').unsigned().nullable()
.comment('연결된 TBM 팀 배정 ID');
// 작업 시간 추적
table.time('start_time').nullable()
.comment('작업 시작 시간');
table.time('end_time').nullable()
.comment('작업 종료 시간');
table.decimal('total_hours', 5, 2).nullable()
.comment('총 작업 시간');
table.decimal('regular_hours', 5, 2).nullable()
.comment('정규 작업 시간 (총 시간 - 오류 시간)');
table.decimal('error_hours', 5, 2).nullable()
.comment('부적합 사항 처리 시간');
// 외래 키 제약조건
table.foreign('tbm_session_id')
.references('session_id')
.inTable('tbm_sessions')
.onDelete('SET NULL');
table.foreign('tbm_assignment_id')
.references('assignment_id')
.inTable('tbm_team_assignments')
.onDelete('SET NULL');
});
};
exports.down = async function(knex) {
await knex.schema.table('daily_work_reports', (table) => {
// 외래 키 제약조건 삭제
table.dropForeign('tbm_session_id');
table.dropForeign('tbm_assignment_id');
// 컬럼 삭제
table.dropColumn('tbm_session_id');
table.dropColumn('tbm_assignment_id');
table.dropColumn('start_time');
table.dropColumn('end_time');
table.dropColumn('total_hours');
table.dropColumn('regular_hours');
table.dropColumn('error_hours');
});
};

View File

@@ -0,0 +1,152 @@
/**
* 현재 사용 중인 페이지를 pages 테이블에 업데이트
*/
exports.up = async function(knex) {
// 기존 페이지 모두 삭제
await knex('pages').del();
// 현재 사용 중인 페이지들을 등록
await knex('pages').insert([
// 공통 페이지
{
page_key: 'dashboard',
page_name: '대시보드',
page_path: '/pages/dashboard.html',
category: 'common',
description: '전체 현황 대시보드',
is_admin_only: 0,
display_order: 1
},
// 작업 관련 페이지
{
page_key: 'work.tbm',
page_name: 'TBM',
page_path: '/pages/work/tbm.html',
category: 'work',
description: 'TBM (Tool Box Meeting) 관리',
is_admin_only: 0,
display_order: 10
},
{
page_key: 'work.report_create',
page_name: '작업보고서 작성',
page_path: '/pages/work/report-create.html',
category: 'work',
description: '일일 작업보고서 작성',
is_admin_only: 0,
display_order: 11
},
{
page_key: 'work.report_view',
page_name: '작업보고서 조회',
page_path: '/pages/work/report-view.html',
category: 'work',
description: '작업보고서 조회 및 검색',
is_admin_only: 0,
display_order: 12
},
{
page_key: 'work.analysis',
page_name: '작업 분석',
page_path: '/pages/work/analysis.html',
category: 'work',
description: '작업 통계 및 분석',
is_admin_only: 0,
display_order: 13
},
// Admin 페이지
{
page_key: 'admin.accounts',
page_name: '계정 관리',
page_path: '/pages/admin/accounts.html',
category: 'admin',
description: '사용자 계정 관리',
is_admin_only: 1,
display_order: 20
},
{
page_key: 'admin.page_access',
page_name: '페이지 권한 관리',
page_path: '/pages/admin/page-access.html',
category: 'admin',
description: '사용자별 페이지 접근 권한 관리',
is_admin_only: 1,
display_order: 21
},
{
page_key: 'admin.workers',
page_name: '작업자 관리',
page_path: '/pages/admin/workers.html',
category: 'admin',
description: '작업자 정보 관리',
is_admin_only: 1,
display_order: 22
},
{
page_key: 'admin.projects',
page_name: '프로젝트 관리',
page_path: '/pages/admin/projects.html',
category: 'admin',
description: '프로젝트 관리',
is_admin_only: 1,
display_order: 23
},
{
page_key: 'admin.workplaces',
page_name: '작업장 관리',
page_path: '/pages/admin/workplaces.html',
category: 'admin',
description: '작업장소 관리',
is_admin_only: 1,
display_order: 24
},
{
page_key: 'admin.codes',
page_name: '코드 관리',
page_path: '/pages/admin/codes.html',
category: 'admin',
description: '시스템 코드 관리',
is_admin_only: 1,
display_order: 25
},
{
page_key: 'admin.tasks',
page_name: '작업 관리',
page_path: '/pages/admin/tasks.html',
category: 'admin',
description: '작업 유형 관리',
is_admin_only: 1,
display_order: 26
},
// 프로필 페이지
{
page_key: 'profile.info',
page_name: '내 정보',
page_path: '/pages/profile/info.html',
category: 'profile',
description: '내 프로필 정보',
is_admin_only: 0,
display_order: 30
},
{
page_key: 'profile.password',
page_name: '비밀번호 변경',
page_path: '/pages/profile/password.html',
category: 'profile',
description: '비밀번호 변경',
is_admin_only: 0,
display_order: 31
}
]);
console.log('✅ 현재 사용 중인 페이지 목록 업데이트 완료');
};
exports.down = async function(knex) {
await knex('pages').del();
console.log('✅ 페이지 목록 삭제 완료');
};

View File

@@ -0,0 +1,57 @@
/**
* Migration: Create vacation_requests table
* Purpose: Track vacation request workflow (request, approval/rejection)
* Date: 2026-01-29
*/
exports.up = async function(knex) {
// Create vacation_requests table
await knex.schema.createTable('vacation_requests', (table) => {
table.increments('request_id').primary().comment('휴가 신청 ID');
// 작업자 정보
table.integer('worker_id').notNullable().comment('작업자 ID');
table.foreign('worker_id').references('worker_id').inTable('workers').onDelete('CASCADE');
// 휴가 정보
table.integer('vacation_type_id').unsigned().notNullable().comment('휴가 유형 ID');
table.foreign('vacation_type_id').references('id').inTable('vacation_types').onDelete('RESTRICT');
table.date('start_date').notNullable().comment('휴가 시작일');
table.date('end_date').notNullable().comment('휴가 종료일');
table.decimal('days_used', 4, 1).notNullable().comment('사용 일수 (0.5일 단위)');
table.text('reason').nullable().comment('휴가 사유');
// 신청 및 승인 정보
table.enum('status', ['pending', 'approved', 'rejected'])
.notNullable()
.defaultTo('pending')
.comment('승인 상태: pending(대기), approved(승인), rejected(거부)');
table.integer('requested_by').notNullable().comment('신청자 user_id');
table.foreign('requested_by').references('user_id').inTable('users').onDelete('RESTRICT');
table.integer('reviewed_by').nullable().comment('승인/거부자 user_id');
table.foreign('reviewed_by').references('user_id').inTable('users').onDelete('SET NULL');
table.timestamp('reviewed_at').nullable().comment('승인/거부 일시');
table.text('review_note').nullable().comment('승인/거부 메모');
// 타임스탬프
table.timestamp('created_at').defaultTo(knex.fn.now()).comment('신청 일시');
table.timestamp('updated_at').defaultTo(knex.fn.now()).comment('수정 일시');
// 인덱스
table.index('worker_id', 'idx_vacation_requests_worker');
table.index('status', 'idx_vacation_requests_status');
table.index(['start_date', 'end_date'], 'idx_vacation_requests_dates');
});
console.log('✅ vacation_requests 테이블 생성 완료');
};
exports.down = async function(knex) {
await knex.schema.dropTableIfExists('vacation_requests');
console.log('✅ vacation_requests 테이블 삭제 완료');
};

View File

@@ -0,0 +1,84 @@
/**
* Migration: Register attendance management pages
* Purpose: Add 4 new pages to pages table for attendance management system
* Date: 2026-01-29
*/
exports.up = async function(knex) {
// 페이지 등록 (실제 pages 테이블 컬럼에 맞춤)
await knex('pages').insert([
{
page_key: 'daily-attendance',
page_name: '일일 출퇴근 입력',
page_path: '/pages/common/daily-attendance.html',
description: '일일 출퇴근 기록 입력 페이지 (관리자/조장)',
category: 'common',
is_admin_only: false,
display_order: 50
},
{
page_key: 'monthly-attendance',
page_name: '월별 출퇴근 현황',
page_path: '/pages/common/monthly-attendance.html',
description: '월별 출퇴근 현황 조회 페이지',
category: 'common',
is_admin_only: false,
display_order: 51
},
{
page_key: 'vacation-management',
page_name: '휴가 관리',
page_path: '/pages/common/vacation-management.html',
description: '휴가 신청 및 승인 관리 페이지',
category: 'common',
is_admin_only: false,
display_order: 52
},
{
page_key: 'attendance-report-comparison',
page_name: '출퇴근-작업보고서 대조',
page_path: '/pages/admin/attendance-report-comparison.html',
description: '출퇴근 기록과 작업보고서 대조 페이지 (관리자)',
category: 'admin',
is_admin_only: true,
display_order: 120
}
]);
console.log('✅ 출퇴근 관리 페이지 4개 등록 완료');
// Admin 사용자(user_id=1)에게 페이지 접근 권한 부여
const adminUserId = 1;
const pages = await knex('pages')
.whereIn('page_key', [
'daily-attendance',
'monthly-attendance',
'vacation-management',
'attendance-report-comparison'
])
.select('id');
const accessRecords = pages.map(page => ({
user_id: adminUserId,
page_id: page.id,
can_access: true,
granted_by: adminUserId
}));
await knex('user_page_access').insert(accessRecords);
console.log('✅ Admin 사용자에게 출퇴근 관리 페이지 접근 권한 부여 완료');
};
exports.down = async function(knex) {
// 페이지 삭제 (user_page_access는 FK CASCADE로 자동 삭제됨)
await knex('pages')
.whereIn('page_key', [
'daily-attendance',
'monthly-attendance',
'vacation-management',
'attendance-report-comparison'
])
.delete();
console.log('✅ 출퇴근 관리 페이지 삭제 완료');
};

View File

@@ -0,0 +1,35 @@
/**
* 출퇴근 출근 여부 필드 추가
* 아침 출근 확인용 간단한 필드
*/
exports.up = async function(knex) {
// 컬럼 존재 여부 확인
const hasColumn = await knex.schema.hasColumn('daily_attendance_records', 'is_present');
if (!hasColumn) {
await knex.schema.table('daily_attendance_records', (table) => {
// 출근 여부 (아침에 체크)
table.boolean('is_present').defaultTo(true).comment('출근 여부');
});
// 기존 데이터는 모두 출근으로 처리
await knex('daily_attendance_records')
.whereNotNull('id')
.update({ is_present: true });
console.log('✅ is_present 컬럼 추가 완료');
} else {
console.log('⏭️ is_present 컬럼이 이미 존재합니다');
}
};
exports.down = async function(knex) {
const hasColumn = await knex.schema.hasColumn('daily_attendance_records', 'is_present');
if (hasColumn) {
await knex.schema.table('daily_attendance_records', (table) => {
table.dropColumn('is_present');
});
}
};

View File

@@ -0,0 +1,57 @@
/**
* 휴가 관리 페이지 분리 및 등록
* - 기존 vacation-management.html을 2개 페이지로 분리
* - vacation-request.html: 작업자 휴가 신청 및 본인 내역 확인
* - vacation-management.html: 관리자 휴가 승인/직접입력/전체내역 (3개 탭)
*/
exports.up = async function(knex) {
// 기존 vacation-management 페이지 삭제
await knex('pages')
.where('page_key', 'vacation-management')
.del();
// 새로운 휴가 관리 페이지 2개 등록
await knex('pages').insert([
{
page_key: 'vacation-request',
page_name: '휴가 신청',
page_path: '/pages/common/vacation-request.html',
category: 'common',
description: '작업자가 휴가를 신청하고 본인의 신청 내역을 확인하는 페이지',
is_admin_only: 0,
display_order: 51
},
{
page_key: 'vacation-management',
page_name: '휴가 관리',
page_path: '/pages/common/vacation-management.html',
category: 'common',
description: '관리자가 휴가 승인, 직접 입력, 전체 내역을 관리하는 페이지',
is_admin_only: 1,
display_order: 52
}
]);
console.log('✅ 휴가 관리 페이지 분리 완료 (기존 1개 → 신규 2개)');
};
exports.down = async function(knex) {
// 새로운 페이지 삭제
await knex('pages')
.whereIn('page_key', ['vacation-request', 'vacation-management'])
.del();
// 기존 vacation-management 페이지 복원
await knex('pages').insert({
page_key: 'vacation-management',
page_name: '휴가 관리',
page_path: '/pages/common/vacation-management.html.old',
category: 'common',
description: '휴가 신청 및 승인 관리',
is_admin_only: 0,
display_order: 50
});
console.log('✅ 휴가 관리 페이지 롤백 완료');
};

View File

@@ -0,0 +1,55 @@
/**
* vacation_types 테이블 확장
* - 특별 휴가 유형 추가 기능
* - 차감 우선순위 관리
* - 시스템 기본 휴가 보호
*/
exports.up = async function(knex) {
// vacation_types 테이블 확장
await knex.schema.table('vacation_types', (table) => {
table.boolean('is_special').defaultTo(false).comment('특별 휴가 여부 (장기근속, 출산 등)');
table.integer('priority').defaultTo(99).comment('차감 우선순위 (낮을수록 먼저 차감)');
table.text('description').nullable().comment('휴가 설명');
table.boolean('is_system').defaultTo(true).comment('시스템 기본 휴가 (삭제 불가)');
});
// 기존 휴가 유형에 우선순위 설정
await knex('vacation_types').where('type_code', 'ANNUAL').update({
priority: 10,
is_system: true,
description: '근로기준법에 따른 연차 유급휴가'
});
await knex('vacation_types').where('type_code', 'HALF_ANNUAL').update({
priority: 10,
is_system: true,
description: '반일 연차 (0.5일)'
});
await knex('vacation_types').where('type_code', 'SICK').update({
priority: 20,
is_system: true,
description: '병가'
});
await knex('vacation_types').where('type_code', 'SPECIAL').update({
priority: 0,
is_system: true,
description: '경조사 휴가 (무급)'
});
console.log('✅ vacation_types 테이블 확장 완료');
};
exports.down = async function(knex) {
// 컬럼 삭제
await knex.schema.table('vacation_types', (table) => {
table.dropColumn('is_special');
table.dropColumn('priority');
table.dropColumn('description');
table.dropColumn('is_system');
});
console.log('✅ vacation_types 테이블 롤백 완료');
};

View File

@@ -0,0 +1,87 @@
/**
* vacation_balance_details 테이블 생성 및 데이터 마이그레이션
* - 작업자별, 휴가 유형별, 연도별 휴가 잔액 관리
* - 기존 worker_vacation_balance 데이터 이관
*/
exports.up = async function(knex) {
// vacation_balance_details 테이블 생성
await knex.schema.createTable('vacation_balance_details', (table) => {
table.increments('id').primary();
table.integer('worker_id').notNullable().comment('작업자 ID');
table.integer('vacation_type_id').unsigned().notNullable().comment('휴가 유형 ID');
table.integer('year').notNullable().comment('연도');
table.decimal('total_days', 4, 1).defaultTo(0).comment('총 발생 일수');
table.decimal('used_days', 4, 1).defaultTo(0).comment('사용 일수');
table.text('notes').nullable().comment('비고');
table.integer('created_by').notNullable().comment('생성자 ID');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// 인덱스
table.unique(['worker_id', 'vacation_type_id', 'year'], 'unique_worker_vacation_year');
table.index(['worker_id', 'year'], 'idx_worker_year');
table.index('vacation_type_id', 'idx_vacation_type');
// 외래키
table.foreign('worker_id').references('worker_id').inTable('workers').onDelete('CASCADE');
table.foreign('vacation_type_id').references('id').inTable('vacation_types').onDelete('RESTRICT');
table.foreign('created_by').references('user_id').inTable('users');
});
// remaining_days를 generated column으로 추가 (Raw SQL)
await knex.raw(`
ALTER TABLE vacation_balance_details
ADD COLUMN remaining_days DECIMAL(4,1)
GENERATED ALWAYS AS (total_days - used_days) STORED
COMMENT '잔여 일수'
`);
console.log('✅ vacation_balance_details 테이블 생성 완료');
// 기존 worker_vacation_balance 데이터를 vacation_balance_details로 마이그레이션
const existingBalances = await knex('worker_vacation_balance').select('*');
if (existingBalances.length > 0) {
// ANNUAL 휴가 유형 ID 조회
const annualType = await knex('vacation_types')
.where('type_code', 'ANNUAL')
.first();
if (!annualType) {
throw new Error('ANNUAL 휴가 유형을 찾을 수 없습니다');
}
// 관리자 사용자 ID 조회 (created_by 용)
// role_id 1 = System Admin, 2 = Admin
const adminUser = await knex('users')
.whereIn('role_id', [1, 2])
.first();
const createdById = adminUser ? adminUser.user_id : 1;
// 데이터 변환 및 삽입
const balanceDetails = existingBalances.map(balance => ({
worker_id: balance.worker_id,
vacation_type_id: annualType.id,
year: balance.year,
total_days: balance.total_annual_leave || 0,
used_days: balance.used_annual_leave || 0,
notes: 'Migrated from worker_vacation_balance',
created_by: createdById,
created_at: balance.created_at,
updated_at: balance.updated_at
}));
await knex('vacation_balance_details').insert(balanceDetails);
console.log(`${balanceDetails.length}건의 기존 휴가 데이터 마이그레이션 완료`);
}
};
exports.down = async function(knex) {
// vacation_balance_details 테이블 삭제
await knex.schema.dropTableIfExists('vacation_balance_details');
console.log('✅ vacation_balance_details 테이블 롤백 완료');
};

View File

@@ -0,0 +1,38 @@
/**
* 새로운 휴가 관리 페이지 등록
* - annual-vacation-overview: 연간 연차 현황 (차트)
* - vacation-allocation: 휴가 발생 입력 및 관리
*/
exports.up = async function(knex) {
await knex('pages').insert([
{
page_key: 'annual-vacation-overview',
page_name: '연간 연차 현황',
page_path: '/pages/common/annual-vacation-overview.html',
category: 'common',
description: '모든 작업자의 연간 연차 현황을 차트로 시각화',
is_admin_only: 1,
display_order: 54
},
{
page_key: 'vacation-allocation',
page_name: '휴가 발생 입력',
page_path: '/pages/common/vacation-allocation.html',
category: 'common',
description: '작업자별 휴가 발생 입력 및 특별 휴가 관리',
is_admin_only: 1,
display_order: 55
}
]);
console.log('✅ 휴가 관리 신규 페이지 2개 등록 완료');
};
exports.down = async function(knex) {
await knex('pages')
.whereIn('page_key', ['annual-vacation-overview', 'vacation-allocation'])
.del();
console.log('✅ 휴가 관리 페이지 롤백 완료');
};

View File

@@ -0,0 +1,153 @@
/**
* 마이그레이션: 출입 신청 및 안전교육 시스템
* - 방문 목적 타입 테이블
* - 출입 신청 테이블
* - 안전교육 기록 테이블
*/
exports.up = async function(knex) {
// 1. 방문 목적 타입 테이블 생성
await knex.schema.createTable('visit_purpose_types', function(table) {
table.increments('purpose_id').primary().comment('방문 목적 ID');
table.string('purpose_name', 100).notNullable().comment('방문 목적명');
table.integer('display_order').defaultTo(0).comment('표시 순서');
table.boolean('is_active').defaultTo(true).comment('활성 여부');
table.timestamp('created_at').defaultTo(knex.fn.now());
});
// 초기 데이터 삽입
await knex('visit_purpose_types').insert([
{ purpose_name: '외주작업', display_order: 1, is_active: true },
{ purpose_name: '검사', display_order: 2, is_active: true },
{ purpose_name: '견학', display_order: 3, is_active: true },
{ purpose_name: '기타', display_order: 99, is_active: true }
]);
// 2. 출입 신청 테이블 생성
await knex.schema.createTable('workplace_visit_requests', function(table) {
table.increments('request_id').primary().comment('신청 ID');
// 신청자 정보
table.integer('requester_id').notNullable().comment('신청자 user_id');
// 방문자 정보
table.string('visitor_company', 200).notNullable().comment('방문자 소속 (회사명 또는 "일용직")');
table.integer('visitor_count').defaultTo(1).comment('방문 인원');
// 방문 장소
table.integer('category_id').unsigned().notNullable().comment('방문 구역 (공장)');
table.integer('workplace_id').unsigned().notNullable().comment('방문 작업장');
// 방문 일시
table.date('visit_date').notNullable().comment('방문 날짜');
table.time('visit_time').notNullable().comment('방문 시간');
// 방문 목적
table.integer('purpose_id').unsigned().notNullable().comment('방문 목적 ID');
table.text('notes').nullable().comment('비고');
// 상태 관리
table.enum('status', ['pending', 'approved', 'rejected', 'training_completed'])
.defaultTo('pending')
.comment('신청 상태');
// 승인 정보
table.integer('approved_by').nullable().comment('승인자 user_id');
table.timestamp('approved_at').nullable().comment('승인 시간');
table.text('rejection_reason').nullable().comment('반려 사유');
// 타임스탬프
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// 외래키
table.foreign('requester_id')
.references('user_id')
.inTable('users')
.onDelete('RESTRICT')
.onUpdate('CASCADE');
table.foreign('category_id')
.references('category_id')
.inTable('workplace_categories')
.onDelete('RESTRICT')
.onUpdate('CASCADE');
table.foreign('workplace_id')
.references('workplace_id')
.inTable('workplaces')
.onDelete('RESTRICT')
.onUpdate('CASCADE');
table.foreign('purpose_id')
.references('purpose_id')
.inTable('visit_purpose_types')
.onDelete('RESTRICT')
.onUpdate('CASCADE');
table.foreign('approved_by')
.references('user_id')
.inTable('users')
.onDelete('SET NULL')
.onUpdate('CASCADE');
// 인덱스
table.index('visit_date', 'idx_visit_date');
table.index('status', 'idx_status');
table.index(['visit_date', 'status'], 'idx_visit_date_status');
});
// 3. 안전교육 기록 테이블 생성
await knex.schema.createTable('safety_training_records', function(table) {
table.increments('training_id').primary().comment('교육 기록 ID');
table.integer('request_id').unsigned().notNullable().comment('출입 신청 ID');
// 교육 진행 정보
table.integer('trainer_id').notNullable().comment('교육 진행자 user_id');
table.date('training_date').notNullable().comment('교육 날짜');
table.time('training_start_time').notNullable().comment('교육 시작 시간');
table.time('training_end_time').nullable().comment('교육 종료 시간');
// 교육 내용
table.text('training_topics').nullable().comment('교육 내용 (JSON 배열)');
// 서명 데이터 (Base64 이미지)
table.text('signature_data', 'longtext').nullable().comment('교육 이수자 서명 (Base64 PNG)');
// 완료 정보
table.timestamp('completed_at').nullable().comment('교육 완료 시간');
// 타임스탬프
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// 외래키
table.foreign('request_id')
.references('request_id')
.inTable('workplace_visit_requests')
.onDelete('CASCADE')
.onUpdate('CASCADE');
table.foreign('trainer_id')
.references('user_id')
.inTable('users')
.onDelete('RESTRICT')
.onUpdate('CASCADE');
// 인덱스
table.index('training_date', 'idx_training_date');
table.index('request_id', 'idx_request_id');
});
console.log('✅ 출입 신청 및 안전교육 시스템 테이블 생성 완료');
};
exports.down = async function(knex) {
// 역순으로 테이블 삭제
await knex.schema.dropTableIfExists('safety_training_records');
await knex.schema.dropTableIfExists('workplace_visit_requests');
await knex.schema.dropTableIfExists('visit_purpose_types');
console.log('✅ 출입 신청 및 안전교육 시스템 테이블 삭제 완료');
};

View File

@@ -0,0 +1,50 @@
/**
* 마이그레이션: 출입 신청 및 안전관리 페이지 등록
*/
exports.up = async function(knex) {
// 1. 출입 신청 페이지 등록
await knex('pages').insert({
page_key: 'visit-request',
page_name: '출입 신청',
page_path: '/pages/work/visit-request.html',
category: 'work',
description: '작업장 출입 신청 및 안전교육 신청',
is_admin_only: 0,
display_order: 15
});
// 2. 안전관리 대시보드 페이지 등록
await knex('pages').insert({
page_key: 'safety-management',
page_name: '안전관리',
page_path: '/pages/admin/safety-management.html',
category: 'admin',
description: '출입 신청 승인 및 안전교육 관리',
is_admin_only: 0,
display_order: 60
});
// 3. 안전교육 진행 페이지 등록
await knex('pages').insert({
page_key: 'safety-training-conduct',
page_name: '안전교육 진행',
page_path: '/pages/admin/safety-training-conduct.html',
category: 'admin',
description: '안전교육 실시 및 서명 관리',
is_admin_only: 0,
display_order: 61
});
console.log('✅ 출입 신청 및 안전관리 페이지 등록 완료');
};
exports.down = async function(knex) {
await knex('pages').whereIn('page_key', [
'visit-request',
'safety-management',
'safety-training-conduct'
]).delete();
console.log('✅ 출입 신청 및 안전관리 페이지 삭제 완료');
};