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:
@@ -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');
|
||||
});
|
||||
};
|
||||
@@ -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('시작 시간');
|
||||
});
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
};
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
};
|
||||
@@ -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('✅ 페이지 목록 삭제 완료');
|
||||
};
|
||||
@@ -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 테이블 삭제 완료');
|
||||
};
|
||||
@@ -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('✅ 출퇴근 관리 페이지 삭제 완료');
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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('✅ 휴가 관리 페이지 롤백 완료');
|
||||
};
|
||||
@@ -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 테이블 롤백 완료');
|
||||
};
|
||||
@@ -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 테이블 롤백 완료');
|
||||
};
|
||||
@@ -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('✅ 휴가 관리 페이지 롤백 완료');
|
||||
};
|
||||
@@ -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('✅ 출입 신청 및 안전교육 시스템 테이블 삭제 완료');
|
||||
};
|
||||
@@ -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('✅ 출입 신청 및 안전관리 페이지 삭제 완료');
|
||||
};
|
||||
Reference in New Issue
Block a user