feat: 작업자-계정 통합 및 연차/출근 관리 시스템 구축

모든 작업자가 개인 계정으로 로그인하여 본인의 연차와 출근 기록을 확인할 수 있는 시스템을 구축했습니다.

주요 기능:
- 작업자-계정 1:1 통합 (기존 작업자 자동 계정 생성)
- 연차 관리 시스템 (연도별 잔액 관리)
- 출근 기록 시스템 (일일 근태 기록)
- 나의 대시보드 페이지 (개인 정보 조회)

데이터베이스:
- workers 테이블에 salary, base_annual_leave 컬럼 추가
- work_attendance_types, vacation_types 테이블 생성
- daily_attendance_records 테이블 생성
- worker_vacation_balance 테이블 생성
- 기존 작업자 자동 계정 생성 (username: 이름 기반)
- Guest 역할 추가

백엔드 API:
- 한글→영문 변환 유틸리티 (hangulToRoman.js)
- UserRoutes에 개인 정보 조회 API 추가
  - GET /api/users/me (내 정보)
  - GET /api/users/me/attendance-records (출근 기록)
  - GET /api/users/me/vacation-balance (연차 잔액)
  - GET /api/users/me/work-reports (작업 보고서)
  - GET /api/users/me/monthly-stats (월별 통계)

프론트엔드:
- 나의 대시보드 페이지 (my-dashboard.html)
- 연차 정보 위젯 (총/사용/잔여)
- 월별 출근 캘린더
- 근무 시간 통계
- 최근 작업 보고서 목록
- 네비게이션 바에 "나의 대시보드" 메뉴 추가

배포 시 주의사항:
- 마이그레이션 실행 필요
- 자동 생성된 계정 초기 비밀번호: 1234
- 작업자들에게 첫 로그인 후 비밀번호 변경 안내 필요

🤖 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-19 09:49:48 +09:00
parent 337cd14a15
commit 70630b380a
26 changed files with 2729 additions and 43 deletions

View File

@@ -39,6 +39,7 @@ function setupRoutes(app) {
const workReportAnalysisRoutes = require('../routes/workReportAnalysisRoutes');
const attendanceRoutes = require('../routes/attendanceRoutes');
const monthlyStatusRoutes = require('../routes/monthlyStatusRoutes');
const pageAccessRoutes = require('../routes/pageAccessRoutes');
// Rate Limiters 설정
const rateLimit = require('express-rate-limit');
@@ -125,6 +126,7 @@ function setupRoutes(app) {
app.use('/api/projects', projectRoutes);
app.use('/api/tools', toolsRoute);
app.use('/api/users', userRoutes);
app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리
app.use('/api', uploadBgRoutes);
// Swagger API 문서

View File

@@ -0,0 +1,57 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
return knex.schema
// 1. roles 테이블 생성
.createTable('roles', function(table) {
table.increments('id').primary();
table.string('name', 50).notNullable().unique();
table.string('description', 255);
table.timestamps(true, true);
})
// 2. permissions 테이블 생성
.createTable('permissions', function(table) {
table.increments('id').primary();
table.string('name', 100).notNullable().unique(); // 예: 'user:create'
table.string('description', 255);
table.timestamps(true, true);
})
// 3. role_permissions (역할-권한) 조인 테이블 생성
.createTable('role_permissions', function(table) {
table.integer('role_id').unsigned().notNullable().references('id').inTable('roles').onDelete('CASCADE');
table.integer('permission_id').unsigned().notNullable().references('id').inTable('permissions').onDelete('CASCADE');
table.primary(['role_id', 'permission_id']);
})
// 4. users 테이블에 role_id 추가 및 기존 컬럼 삭제
.table('users', function(table) {
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('SET NULL').after('email');
// 기존 컬럼들은 삭제 또는 비활성화 (데이터 보존을 위해 일단 이름 변경)
table.renameColumn('role', '_role_old');
table.renameColumn('access_level', '_access_level_old');
})
// 5. user_permissions (사용자-개별 권한) 조인 테이블 생성
.createTable('user_permissions', function(table) {
table.integer('user_id').notNullable().references('user_id').inTable('users').onDelete('CASCADE');
table.integer('permission_id').unsigned().notNullable().references('id').inTable('permissions').onDelete('CASCADE');
table.primary(['user_id', 'permission_id']);
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function(knex) {
return knex.schema
.dropTableIfExists('user_permissions')
.dropTableIfExists('role_permissions')
.dropTableIfExists('permissions')
.dropTableIfExists('roles')
.table('users', function(table) {
table.dropColumn('role_id');
table.renameColumn('_role_old', 'role');
table.renameColumn('_access_level_old', 'access_level');
});
};

View File

@@ -0,0 +1,103 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function(knex) {
// 1. Roles 생성
await knex('roles').insert([
{ id: 1, name: 'System Admin', description: '시스템 전체 관리자. 모든 권한을 가짐.' },
{ id: 2, name: 'Admin', description: '관리자. 사용자 및 프로젝트 관리 등 대부분의 권한을 가짐.' },
{ id: 3, name: 'Leader', description: '그룹장. 팀원 작업 현황 조회 등 중간 관리자 권한.' },
{ id: 4, name: 'Worker', description: '일반 작업자. 자신의 작업 보고서 작성 및 조회 권한.' },
]);
// 2. Permissions 생성 (예시)
const permissions = [
// User
{ name: 'user:create', description: '사용자 생성' },
{ name: 'user:read', description: '사용자 정보 조회' },
{ name: 'user:update', description: '사용자 정보 수정' },
{ name: 'user:delete', description: '사용자 삭제' },
// Project
{ name: 'project:create', description: '프로젝트 생성' },
{ name: 'project:read', description: '프로젝트 조회' },
{ name: 'project:update', description: '프로젝트 수정' },
{ name: 'project:delete', description: '프로젝트 삭제' },
// Work Report
{ name: 'work-report:create', description: '작업 보고서 생성' },
{ name: 'work-report:read-own', description: '자신의 작업 보고서 조회' },
{ name: 'work-report:read-team', description: '팀의 작업 보고서 조회' },
{ name: 'work-report:read-all', description: '모든 작업 보고서 조회' },
{ name: 'work-report:update', description: '작업 보고서 수정' },
{ name: 'work-report:delete', description: '작업 보고서 삭제' },
// System
{ name: 'system:read-logs', description: '시스템 로그 조회' },
{ name: 'system:manage-settings', description: '시스템 설정 관리' },
];
await knex('permissions').insert(permissions);
// 3. Role-Permissions 매핑
const allPermissions = await knex('permissions').select('id', 'name');
const permissionMap = allPermissions.reduce((acc, p) => {
acc[p.name] = p.id;
return acc;
}, {});
const rolePermissions = {
// System Admin (모든 권한)
'System Admin': allPermissions.map(p => p.id),
// Admin
'Admin': [
permissionMap['user:create'], permissionMap['user:read'], permissionMap['user:update'], permissionMap['user:delete'],
permissionMap['project:create'], permissionMap['project:read'], permissionMap['project:update'], permissionMap['project:delete'],
permissionMap['work-report:read-all'], permissionMap['work-report:update'], permissionMap['work-report:delete'],
],
// Leader
'Leader': [
permissionMap['user:read'],
permissionMap['project:read'],
permissionMap['work-report:read-team'],
permissionMap['work-report:read-own'],
permissionMap['work-report:create'],
],
// Worker
'Worker': [
permissionMap['work-report:create'],
permissionMap['work-report:read-own'],
],
};
const rolePermissionInserts = [];
for (const roleName in rolePermissions) {
const roleId = (await knex('roles').where('name', roleName).first()).id;
rolePermissions[roleName].forEach(permissionId => {
rolePermissionInserts.push({ role_id: roleId, permission_id: permissionId });
});
}
await knex('role_permissions').insert(rolePermissionInserts);
// 4. 기존 사용자에게 역할 부여 (예: 기존 admin -> Admin, leader -> Leader, user -> Worker)
await knex.raw(`
UPDATE users SET role_id =
CASE
WHEN _role_old = 'system' THEN (SELECT id FROM roles WHERE name = 'System Admin')
WHEN _role_old = 'admin' THEN (SELECT id FROM roles WHERE name = 'Admin')
WHEN _role_old = 'leader' THEN (SELECT id FROM roles WHERE name = 'Leader')
ELSE (SELECT id FROM roles WHERE name = 'Worker')
END
`);
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function(knex) {
await knex('role_permissions').del();
await knex('user_permissions').del();
await knex('roles').del();
await knex('permissions').del();
// 역할 롤백 (단순화된 버전)
await knex.raw("UPDATE users SET _role_old = 'user' WHERE role_id IS NOT NULL");
};

View File

@@ -0,0 +1,62 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function (knex) {
const hasHireDate = await knex.schema.hasColumn('workers', 'hire_date');
if (!hasHireDate) {
await knex.schema.alterTable('workers', function (table) {
// Modify status to ENUM
// Note: Knex might not support modifying to ENUM easily across DBs, but valid for MySQL
// We use raw SQL for status modification to be safe with existing data
// Add new columns
table.string('phone_number', 20).nullable().comment('전화번호');
table.string('email', 100).nullable().comment('이메일');
table.date('hire_date').nullable().comment('입사일');
table.string('department', 100).nullable().comment('부서');
table.text('notes').nullable().comment('비고');
});
// Update status column using raw query
await knex.raw(`
ALTER TABLE workers
MODIFY COLUMN status ENUM('active', 'inactive') DEFAULT 'active' COMMENT '작업자 상태 (active: 활성, inactive: 비활성)'
`);
// Add indexes
await knex.raw(`CREATE INDEX IF NOT EXISTS idx_workers_status ON workers(status)`);
await knex.raw(`CREATE INDEX IF NOT EXISTS idx_workers_hire_date ON workers(hire_date)`);
// Set NULL status to active
await knex('workers').whereNull('status').update({ status: 'active' });
}
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function (knex) {
// We generally don't want to lose data on rollback of this critical schema fix,
// but technically we should revert changes.
// For safety, we might skip dropping columns or implement it carefully.
const hasHireDate = await knex.schema.hasColumn('workers', 'hire_date');
if (hasHireDate) {
await knex.schema.alterTable('workers', function (table) {
table.dropColumn('phone_number');
table.dropColumn('email');
table.dropColumn('hire_date');
table.dropColumn('department');
table.dropColumn('notes');
});
await knex.raw(`
ALTER TABLE workers
MODIFY COLUMN status VARCHAR(20) DEFAULT 'active' COMMENT '상태 (active, inactive)'
`);
}
};

View File

@@ -0,0 +1,151 @@
/**
* 권한 시스템 단순화 및 페이지 접근 권한 추가
* - Leader와 Worker를 User로 통합
* - 페이지 접근 권한 테이블 생성
* - Admin이 사용자별 페이지 접근 권한을 설정할 수 있도록 함
*
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function(knex) {
// 1. 페이지 목록 테이블 생성
await knex.schema.createTable('pages', function(table) {
table.increments('id').primary();
table.string('page_key', 100).notNullable().unique(); // 예: 'worker-management', 'project-management'
table.string('page_name', 100).notNullable(); // 예: '작업자 관리', '프로젝트 관리'
table.string('page_path', 255).notNullable(); // 예: '/pages/management/worker-management.html'
table.string('category', 50); // 예: 'management', 'dashboard', 'admin'
table.string('description', 255);
table.boolean('is_admin_only').defaultTo(false); // Admin 전용 페이지 여부
table.integer('display_order').defaultTo(0); // 표시 순서
table.timestamps(true, true);
});
// 2. 사용자별 페이지 접근 권한 테이블 생성
await knex.schema.createTable('user_page_access', function(table) {
table.integer('user_id').unsigned().notNullable()
.references('user_id').inTable('users').onDelete('CASCADE');
table.integer('page_id').unsigned().notNullable()
.references('id').inTable('pages').onDelete('CASCADE');
table.boolean('can_access').defaultTo(true); // 접근 가능 여부
table.timestamp('granted_at').defaultTo(knex.fn.now());
table.integer('granted_by').unsigned() // 권한을 부여한 Admin의 user_id
.references('user_id').inTable('users').onDelete('SET NULL');
table.primary(['user_id', 'page_id']);
});
// 3. 기본 페이지 목록 삽입
await knex('pages').insert([
// Dashboard
{ page_key: 'dashboard-user', page_name: '사용자 대시보드', page_path: '/pages/dashboard/user.html', category: 'dashboard', is_admin_only: false, display_order: 1 },
{ page_key: 'dashboard-leader', page_name: '그룹장 대시보드', page_path: '/pages/dashboard/group-leader.html', category: 'dashboard', is_admin_only: false, display_order: 2 },
// Management
{ page_key: 'worker-management', page_name: '작업자 관리', page_path: '/pages/management/worker-management.html', category: 'management', is_admin_only: false, display_order: 10 },
{ page_key: 'project-management', page_name: '프로젝트 관리', page_path: '/pages/management/project-management.html', category: 'management', is_admin_only: false, display_order: 11 },
{ page_key: 'work-management', page_name: '작업 관리', page_path: '/pages/management/work-management.html', category: 'management', is_admin_only: false, display_order: 12 },
{ page_key: 'code-management', page_name: '코드 관리', page_path: '/pages/management/code-management.html', category: 'management', is_admin_only: false, display_order: 13 },
// Common
{ page_key: 'daily-work-report', page_name: '작업 현황 확인', page_path: '/pages/common/daily-work-report-viewer.html', category: 'common', is_admin_only: false, display_order: 20 },
// Admin
{ page_key: 'user-management', page_name: '사용자 관리', page_path: '/pages/admin/manage-user.html', category: 'admin', is_admin_only: true, display_order: 100 },
]);
// 4. roles 테이블 업데이트: Leader와 Worker를 User로 통합
// Leader와 Worker 역할을 가진 사용자를 모두 User로 변경
const userRoleId = await knex('roles').where('name', 'Worker').first().then(r => r.id);
const leaderRoleId = await knex('roles').where('name', 'Leader').first().then(r => r ? r.id : null);
if (leaderRoleId) {
// Leader를 User로 변경
await knex('users').where('role_id', leaderRoleId).update({ role_id: userRoleId });
}
// 5. role_permissions 업데이트: Worker 권한을 확장하여 모든 일반 기능 사용 가능하게
const allPermissions = await knex('permissions').select('id', 'name');
const permissionMap = allPermissions.reduce((acc, p) => {
acc[p.name] = p.id;
return acc;
}, {});
// Worker 역할의 기존 권한 삭제
await knex('role_permissions').where('role_id', userRoleId).del();
// Worker(이제 User) 역할에 모든 일반 권한 부여 (Admin/System 권한 제외)
const userPermissions = [
permissionMap['user:read'],
permissionMap['project:read'],
permissionMap['project:create'],
permissionMap['project:update'],
permissionMap['work-report:create'],
permissionMap['work-report:read-own'],
permissionMap['work-report:read-team'],
permissionMap['work-report:read-all'],
permissionMap['work-report:update'],
permissionMap['work-report:delete'],
].filter(Boolean); // undefined 제거
const rolePermissionInserts = userPermissions.map(permissionId => ({
role_id: userRoleId,
permission_id: permissionId
}));
await knex('role_permissions').insert(rolePermissionInserts);
// 6. Leader 역할 삭제 (더 이상 사용하지 않음)
if (leaderRoleId) {
await knex('role_permissions').where('role_id', leaderRoleId).del();
await knex('roles').where('id', leaderRoleId).del();
}
// 7. Worker 역할 이름을 'User'로 변경
await knex('roles').where('id', userRoleId).update({
name: 'User',
description: '일반 사용자. 작업 보고서 및 프로젝트 관리 등 모든 일반 기능을 사용할 수 있음.'
});
// 8. 모든 일반 사용자에게 모든 페이지 접근 권한 부여 (Admin 페이지 제외)
const normalPages = await knex('pages').where('is_admin_only', false).select('id');
const normalUsers = await knex('users').where('role_id', userRoleId).select('user_id');
const userPageAccessInserts = [];
normalUsers.forEach(user => {
normalPages.forEach(page => {
userPageAccessInserts.push({
user_id: user.user_id,
page_id: page.id,
can_access: true
});
});
});
if (userPageAccessInserts.length > 0) {
await knex('user_page_access').insert(userPageAccessInserts);
}
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function(knex) {
// 테이블 삭제 (역순)
await knex.schema.dropTableIfExists('user_page_access');
await knex.schema.dropTableIfExists('pages');
// User 역할을 다시 Worker로 변경
const userRoleId = await knex('roles').where('name', 'User').first().then(r => r ? r.id : null);
if (userRoleId) {
await knex('roles').where('id', userRoleId).update({
name: 'Worker',
description: '일반 작업자. 자신의 작업 보고서 작성 및 조회 권한.'
});
}
// Leader 역할 재생성
await knex('roles').insert([
{ id: 3, name: 'Leader', description: '그룹장. 팀원 작업 현황 조회 등 중간 관리자 권한.' }
]);
};

View File

@@ -0,0 +1,33 @@
/**
* 마이그레이션: Workers 테이블에 급여 및 기본 연차 컬럼 추가
* 작성일: 2026-01-19
*
* 변경사항:
* - salary 컬럼 추가 (NULL 허용, 선택 사항)
* - base_annual_leave 컬럼 추가 (기본값: 15일)
*/
exports.up = async function(knex) {
console.log('⏳ Workers 테이블에 salary, base_annual_leave 컬럼 추가 중...');
await knex.schema.alterTable('workers', (table) => {
// 급여 정보 (선택 사항, NULL 허용)
table.decimal('salary', 12, 2).nullable().comment('급여 (선택)');
// 기본 연차 일수 (기본값: 15일)
table.integer('base_annual_leave').defaultTo(15).notNullable().comment('기본 연차 일수');
});
console.log('✅ Workers 테이블 컬럼 추가 완료');
};
exports.down = async function(knex) {
console.log('⏳ Workers 테이블에서 salary, base_annual_leave 컬럼 제거 중...');
await knex.schema.alterTable('workers', (table) => {
table.dropColumn('salary');
table.dropColumn('base_annual_leave');
});
console.log('✅ Workers 테이블 컬럼 제거 완료');
};

View File

@@ -0,0 +1,112 @@
/**
* 마이그레이션: 출근/근태 관련 테이블 생성
* 작성일: 2026-01-19
*
* 생성 테이블:
* - work_attendance_types: 출근 유형 (정상, 지각, 조퇴, 결근, 휴가)
* - vacation_types: 휴가 유형 (연차, 반차, 병가, 경조사)
* - daily_attendance_records: 일일 출근 기록
* - worker_vacation_balance: 작업자 연차 잔액 (연도별)
*/
exports.up = async function(knex) {
console.log('⏳ 출근/근태 관련 테이블 생성 중...');
// 1. 출근 유형 테이블
await knex.schema.createTable('work_attendance_types', (table) => {
table.increments('id').primary();
table.string('type_code', 20).unique().notNullable().comment('유형 코드');
table.string('type_name', 50).notNullable().comment('유형 이름');
table.text('description').nullable().comment('설명');
table.boolean('is_active').defaultTo(true).comment('활성 여부');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
console.log('✅ work_attendance_types 테이블 생성 완료');
// 초기 데이터 입력
await knex('work_attendance_types').insert([
{ type_code: 'NORMAL', type_name: '정상 출근', description: '정상 출근' },
{ type_code: 'LATE', type_name: '지각', description: '지각' },
{ type_code: 'EARLY_LEAVE', type_name: '조퇴', description: '조퇴' },
{ type_code: 'ABSENT', type_name: '결근', description: '무단 결근' },
{ type_code: 'VACATION', type_name: '휴가', description: '승인된 휴가' }
]);
console.log('✅ work_attendance_types 초기 데이터 입력 완료');
// 2. 휴가 유형 테이블
await knex.schema.createTable('vacation_types', (table) => {
table.increments('id').primary();
table.string('type_code', 20).unique().notNullable().comment('휴가 코드');
table.string('type_name', 50).notNullable().comment('휴가 이름');
table.decimal('deduct_days', 3, 1).defaultTo(1.0).comment('차감 일수');
table.boolean('is_active').defaultTo(true).comment('활성 여부');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
console.log('✅ vacation_types 테이블 생성 완료');
// 초기 데이터 입력
await knex('vacation_types').insert([
{ type_code: 'ANNUAL', type_name: '연차', deduct_days: 1.0 },
{ type_code: 'HALF_ANNUAL', type_name: '반차', deduct_days: 0.5 },
{ type_code: 'SICK', type_name: '병가', deduct_days: 1.0 },
{ type_code: 'SPECIAL', type_name: '경조사', deduct_days: 0 }
]);
console.log('✅ vacation_types 초기 데이터 입력 완료');
// 3. 일일 출근 기록 테이블
await knex.schema.createTable('daily_attendance_records', (table) => {
table.increments('id').primary();
table.integer('worker_id').unsigned().notNullable().comment('작업자 ID');
table.date('record_date').notNullable().comment('기록 날짜');
table.integer('attendance_type_id').unsigned().notNullable().comment('출근 유형 ID');
table.integer('vacation_type_id').unsigned().nullable().comment('휴가 유형 ID');
table.time('check_in_time').nullable().comment('출근 시간');
table.time('check_out_time').nullable().comment('퇴근 시간');
table.decimal('total_work_hours', 4, 2).defaultTo(0).comment('총 근무 시간');
table.boolean('is_overtime_approved').defaultTo(false).comment('초과근무 승인 여부');
table.text('notes').nullable().comment('비고');
table.integer('created_by').unsigned().notNullable().comment('등록자 user_id');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// 인덱스 및 제약조건
table.unique(['worker_id', 'record_date']);
table.foreign('worker_id').references('workers.worker_id').onDelete('CASCADE');
table.foreign('attendance_type_id').references('work_attendance_types.id');
table.foreign('vacation_type_id').references('vacation_types.id');
table.foreign('created_by').references('users.user_id');
});
console.log('✅ daily_attendance_records 테이블 생성 완료');
// 4. 작업자 연차 잔액 테이블
await knex.schema.createTable('worker_vacation_balance', (table) => {
table.increments('id').primary();
table.integer('worker_id').unsigned().notNullable().comment('작업자 ID');
table.integer('year').notNullable().comment('연도');
table.decimal('total_annual_leave', 4, 1).defaultTo(15.0).comment('총 연차');
table.decimal('used_annual_leave', 4, 1).defaultTo(0).comment('사용 연차');
// remaining_annual_leave는 애플리케이션 레벨에서 계산
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// 인덱스 및 제약조건
table.unique(['worker_id', 'year']);
table.foreign('worker_id').references('workers.worker_id').onDelete('CASCADE');
});
console.log('✅ worker_vacation_balance 테이블 생성 완료');
console.log('✅ 모든 출근/근태 관련 테이블 생성 완료');
};
exports.down = async function(knex) {
console.log('⏳ 출근/근태 관련 테이블 제거 중...');
await knex.schema.dropTableIfExists('worker_vacation_balance');
await knex.schema.dropTableIfExists('daily_attendance_records');
await knex.schema.dropTableIfExists('vacation_types');
await knex.schema.dropTableIfExists('work_attendance_types');
console.log('✅ 모든 출근/근태 관련 테이블 제거 완료');
};

View File

@@ -0,0 +1,104 @@
/**
* 마이그레이션: 기존 작업자에게 계정 자동 생성
* 작성일: 2026-01-19
*
* 작업 내용:
* 1. 계정이 없는 기존 작업자 조회
* 2. 각 작업자에 대해 users 테이블에 계정 생성
* 3. username은 이름 기반으로 자동 생성 (예: 홍길동 → hong.gildong)
* 4. 초기 비밀번호는 '1234'로 통일 (첫 로그인 시 변경 권장)
* 5. 현재 연도 연차 잔액 초기화
*/
const bcrypt = require('bcrypt');
const { generateUniqueUsername } = require('../../utils/hangulToRoman');
exports.up = async function(knex) {
console.log('⏳ 기존 작업자들에게 계정 자동 생성 중...');
// 1. 계정이 없는 작업자 조회
const workersWithoutAccount = await knex('workers')
.leftJoin('users', 'workers.worker_id', 'users.worker_id')
.whereNull('users.user_id')
.select(
'workers.worker_id',
'workers.worker_name',
'workers.email',
'workers.status',
'workers.base_annual_leave'
);
console.log(`📊 계정이 없는 작업자: ${workersWithoutAccount.length}`);
if (workersWithoutAccount.length === 0) {
console.log(' 계정이 필요한 작업자가 없습니다.');
return;
}
// 2. 각 작업자에 대해 계정 생성
const initialPassword = '1234'; // 초기 비밀번호
const hashedPassword = await bcrypt.hash(initialPassword, 10);
// User 역할 ID 조회
const userRole = await knex('roles')
.where('role_name', 'User')
.first();
if (!userRole) {
throw new Error('User 역할이 존재하지 않습니다. 권한 마이그레이션을 먼저 실행하세요.');
}
let successCount = 0;
let errorCount = 0;
for (const worker of workersWithoutAccount) {
try {
// username 생성 (중복 체크 포함)
const username = await generateUniqueUsername(worker.worker_name, knex);
// 계정 생성
await knex('users').insert({
username: username,
password: hashedPassword,
name: worker.worker_name,
email: worker.email,
worker_id: worker.worker_id,
role_id: userRole.id,
is_active: worker.status === 'active' ? 1 : 0,
created_at: knex.fn.now(),
updated_at: knex.fn.now()
});
console.log(`${worker.worker_name} (ID: ${worker.worker_id}) → username: ${username}`);
successCount++;
// 현재 연도 연차 잔액 초기화
const currentYear = new Date().getFullYear();
await knex('worker_vacation_balance').insert({
worker_id: worker.worker_id,
year: currentYear,
total_annual_leave: worker.base_annual_leave || 15,
used_annual_leave: 0,
created_at: knex.fn.now(),
updated_at: knex.fn.now()
});
} catch (error) {
console.error(`${worker.worker_name} 계정 생성 실패:`, error.message);
errorCount++;
}
}
console.log(`\n📊 작업 완료: 성공 ${successCount}명, 실패 ${errorCount}`);
console.log(`🔐 초기 비밀번호: ${initialPassword} (모든 계정 공통)`);
console.log('⚠️ 사용자들에게 첫 로그인 후 비밀번호를 변경하도록 안내해주세요!');
};
exports.down = async function(knex) {
console.log('⏳ 자동 생성된 계정 제거 중...');
// 이 마이그레이션으로 생성된 계정은 구분하기 어려우므로
// rollback 시 주의가 필요합니다.
console.log('⚠️ 경고: 이 마이그레이션의 rollback은 권장하지 않습니다.');
console.log(' 필요시 수동으로 users 테이블을 관리하세요.');
};

View File

@@ -0,0 +1,51 @@
/**
* 마이그레이션: 게스트 역할 추가
* 작성일: 2026-01-19
*
* 변경사항:
* - Guest 역할 추가 (계정 없이 특정 기능 접근 가능)
* - 게스트 전용 페이지 추가 (신고 채널 등)
*/
exports.up = async function(knex) {
console.log('⏳ 게스트 역할 추가 중...');
// 1. Guest 역할 추가
const [guestRoleId] = await knex('roles').insert({
role_name: 'Guest',
role_description: '게스트 (계정 없이 특정 기능 접근 가능)',
created_at: knex.fn.now(),
updated_at: knex.fn.now()
});
console.log(`✅ Guest 역할 추가 완료 (ID: ${guestRoleId})`);
// 2. 게스트 전용 페이지 추가
await knex('pages').insert({
page_key: 'guest_report',
page_name: '신고 채널',
page_path: '/pages/guest/report.html',
category: 'guest',
is_admin_only: false,
created_at: knex.fn.now(),
updated_at: knex.fn.now()
});
console.log('✅ 게스트 전용 페이지 추가 완료 (신고 채널)');
console.log('✅ 게스트 역할 추가 완료');
};
exports.down = async function(knex) {
console.log('⏳ 게스트 역할 제거 중...');
// 페이지 제거
await knex('pages')
.where('page_key', 'guest_report')
.delete();
// 역할 제거
await knex('roles')
.where('role_name', 'Guest')
.delete();
console.log('✅ 게스트 역할 제거 완료');
};

View File

@@ -0,0 +1,43 @@
const { getDb } = require('../dbPool');
class RoleModel {
/**
* 모든 역할 목록을 조회합니다.
* @returns {Promise<Array>} 역할 목록
*/
static async findAll() {
const db = await getDb();
const [rows] = await db.query('SELECT id, name, description FROM roles ORDER BY id');
return rows;
}
/**
* ID로 특정 역할을 조회합니다.
* @param {number} id - 역할 ID
* @returns {Promise<Object>} 역할 객체
*/
static async findById(id) {
const db = await getDb();
const [rows] = await db.query('SELECT id, name, description FROM roles WHERE id = ?', [id]);
return rows[0];
}
/**
* 역할에 속한 모든 권한을 조회합니다.
* @param {number} roleId - 역할 ID
* @returns {Promise<Array>} 권한 이름 목록
*/
static async findPermissionsByRoleId(roleId) {
const db = await getDb();
const [rows] = await db.query(
`SELECT p.name
FROM permissions p
JOIN role_permissions rp ON p.id = rp.permission_id
WHERE rp.role_id = ?`,
[roleId]
);
return rows.map(p => p.name);
}
}
module.exports = RoleModel;

View File

@@ -598,37 +598,41 @@ router.get('/users', verifyToken, async (req, res) => {
try {
connection = await mysql.createConnection(dbConfig);
// 기본 쿼리
// 기본 쿼리 (role 테이블과 JOIN)
let query = `
SELECT
user_id,
username,
name,
email,
access_level,
worker_id,
is_active,
last_login_at,
created_at
FROM Users
SELECT
u.user_id,
u.username,
u.name,
u.email,
u.role_id,
r.name as role_name,
u._access_level_old as access_level,
u.worker_id,
u.is_active,
u.last_login_at,
u.created_at
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE 1=1
`;
const params = [];
// 필터링 옵션
if (req.query.active !== undefined) {
query += ' AND is_active = ?';
query += ' AND u.is_active = ?';
params.push(req.query.active === 'true');
}
// role_name으로 필터링 (access_level 대신)
if (req.query.access_level) {
query += ' AND access_level = ?';
params.push(req.query.access_level);
query += ' AND (u._access_level_old = ? OR r.name = ?)';
params.push(req.query.access_level, req.query.access_level);
}
query += ' ORDER BY created_at DESC';
query += ' ORDER BY u.created_at DESC';
const [rows] = await connection.execute(query, params);
const userList = rows.map(user => ({
@@ -636,7 +640,9 @@ router.get('/users', verifyToken, async (req, res) => {
username: user.username,
name: user.name || user.username,
email: user.email,
access_level: user.access_level,
role_id: user.role_id,
role_name: user.role_name,
access_level: user.access_level || user.role_name?.toLowerCase(), // 하위 호환성
worker_id: user.worker_id,
is_active: user.is_active,
last_login_at: user.last_login_at,

View File

@@ -0,0 +1,237 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../dbPool');
const { requireAuth, requireAdmin } = require('../middlewares/auth');
/**
* 모든 페이지 목록 조회
* GET /api/pages
*/
router.get('/pages', requireAuth, async (req, res) => {
try {
const db = await getDb();
const [pages] = await db.query(`
SELECT id, page_key, page_name, page_path, category, description, is_admin_only, display_order
FROM pages
ORDER BY display_order, page_name
`);
res.json({ success: true, data: pages });
} catch (error) {
console.error('페이지 목록 조회 오류:', error);
res.status(500).json({ success: false, error: '페이지 목록을 불러오는데 실패했습니다.' });
}
});
/**
* 특정 사용자의 페이지 접근 권한 조회
* GET /api/users/:userId/page-access
*/
router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
try {
const { userId } = req.params;
const db = await getDb();
// 사용자의 역할 확인
const [userRows] = await db.query(`
SELECT u.user_id, u.username, u.role_id, r.name as role_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.user_id = ?
`, [userId]);
if (userRows.length === 0) {
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다.' });
}
const user = userRows[0];
// Admin/System Admin인 경우 모든 페이지 접근 가능
if (user.role_name === 'Admin' || user.role_name === 'System Admin') {
const [allPages] = await db.query(`
SELECT id, page_key, page_name, page_path, category, is_admin_only
FROM pages
ORDER BY display_order, page_name
`);
const pageAccess = allPages.map(page => ({
page_id: page.id,
page_key: page.page_key,
page_name: page.page_name,
page_path: page.page_path,
category: page.category,
is_admin_only: page.is_admin_only,
can_access: true,
is_default: true // Admin은 기본적으로 모든 권한 보유
}));
return res.json({ success: true, data: { user, pageAccess } });
}
// 일반 사용자의 페이지 접근 권한 조회
const [pageAccess] = await db.query(`
SELECT
p.id as page_id,
p.page_key,
p.page_name,
p.page_path,
p.category,
p.is_admin_only,
COALESCE(upa.can_access, 0) as can_access,
upa.granted_at,
u2.username as granted_by_username
FROM pages p
LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
LEFT JOIN users u2 ON upa.granted_by = u2.user_id
WHERE p.is_admin_only = 0
ORDER BY p.display_order, p.page_name
`, [userId]);
res.json({ success: true, data: { user, pageAccess } });
} catch (error) {
console.error('페이지 접근 권한 조회 오류:', error);
res.status(500).json({ success: false, error: '페이지 접근 권한을 불러오는데 실패했습니다.' });
}
});
/**
* 사용자에게 페이지 접근 권한 부여/회수
* POST /api/users/:userId/page-access
* Body: { pageIds: [1, 2, 3], canAccess: true }
*/
router.post('/users/:userId/page-access', requireAuth, async (req, res) => {
try {
const { userId } = req.params;
const { pageIds, canAccess } = req.body;
const adminUserId = req.user.user_id; // 권한을 부여하는 Admin의 user_id
// Admin 권한 확인
const db = await getDb();
const [adminRows] = await db.query(`
SELECT u.role_id, r.name as role_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.user_id = ?
`, [adminUserId]);
if (adminRows.length === 0 || (adminRows[0].role_name !== 'Admin' && adminRows[0].role_name !== 'System Admin')) {
return res.status(403).json({ success: false, error: '권한이 없습니다. Admin 계정만 사용자 권한을 관리할 수 있습니다.' });
}
// 사용자 존재 확인
const [userRows] = await db.query('SELECT user_id FROM users WHERE user_id = ?', [userId]);
if (userRows.length === 0) {
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다.' });
}
// 페이지 접근 권한 업데이트
for (const pageId of pageIds) {
// 기존 권한 확인
const [existing] = await db.query(
'SELECT * FROM user_page_access WHERE user_id = ? AND page_id = ?',
[userId, pageId]
);
if (existing.length > 0) {
// 업데이트
await db.query(
'UPDATE user_page_access SET can_access = ?, granted_at = NOW(), granted_by = ? WHERE user_id = ? AND page_id = ?',
[canAccess ? 1 : 0, adminUserId, userId, pageId]
);
} else {
// 삽입
await db.query(
'INSERT INTO user_page_access (user_id, page_id, can_access, granted_by) VALUES (?, ?, ?, ?)',
[userId, pageId, canAccess ? 1 : 0, adminUserId]
);
}
}
res.json({ success: true, message: '페이지 접근 권한이 업데이트되었습니다.' });
} catch (error) {
console.error('페이지 접근 권한 부여 오류:', error);
res.status(500).json({ success: false, error: '페이지 접근 권한을 업데이트하는데 실패했습니다.' });
}
});
/**
* 특정 페이지의 접근 권한 회수
* DELETE /api/users/:userId/page-access/:pageId
*/
router.delete('/users/:userId/page-access/:pageId', requireAuth, async (req, res) => {
try {
const { userId, pageId } = req.params;
const adminUserId = req.user.user_id;
// Admin 권한 확인
const db = await getDb();
const [adminRows] = await db.query(`
SELECT u.role_id, r.name as role_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.user_id = ?
`, [adminUserId]);
if (adminRows.length === 0 || (adminRows[0].role_name !== 'Admin' && adminRows[0].role_name !== 'System Admin')) {
return res.status(403).json({ success: false, error: '권한이 없습니다.' });
}
// 접근 권한 삭제
await db.query(
'DELETE FROM user_page_access WHERE user_id = ? AND page_id = ?',
[userId, pageId]
);
res.json({ success: true, message: '페이지 접근 권한이 회수되었습니다.' });
} catch (error) {
console.error('페이지 접근 권한 회수 오류:', error);
res.status(500).json({ success: false, error: '페이지 접근 권한을 회수하는데 실패했습니다.' });
}
});
/**
* 모든 사용자의 페이지 접근 권한 요약 조회 (Admin용)
* GET /api/page-access/summary
*/
router.get('/page-access/summary', requireAuth, async (req, res) => {
try {
const adminUserId = req.user.user_id;
// Admin 권한 확인
const db = await getDb();
const [adminRows] = await db.query(`
SELECT u.role_id, r.name as role_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.user_id = ?
`, [adminUserId]);
if (adminRows.length === 0 || (adminRows[0].role_name !== 'Admin' && adminRows[0].role_name !== 'System Admin')) {
return res.status(403).json({ success: false, error: '권한이 없습니다.' });
}
// 모든 사용자와 페이지 권한 조회
const [summary] = await db.query(`
SELECT
u.user_id,
u.username,
u.name,
r.name as role_name,
COUNT(DISTINCT upa.page_id) as accessible_pages_count,
(SELECT COUNT(*) FROM pages WHERE is_admin_only = 0) as total_pages_count
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
LEFT JOIN user_page_access upa ON u.user_id = upa.user_id AND upa.can_access = 1
WHERE r.name NOT IN ('Admin', 'System Admin')
GROUP BY u.user_id, u.username, u.name, r.name
ORDER BY u.username
`);
res.json({ success: true, data: summary });
} catch (error) {
console.error('페이지 접근 권한 요약 조회 오류:', error);
res.status(500).json({ success: false, error: '페이지 접근 권한 요약을 불러오는데 실패했습니다.' });
}
});
module.exports = router;

View File

@@ -38,6 +38,71 @@ const adminOnly = (req, res, next) => {
}
};
// ========== 개인 정보 조회 API (관리자 권한 불필요) ==========
// 내 정보 조회
router.get('/me', userController.getMyInfo || ((req, res) => res.json({ success: true, data: req.user })));
// 내 출근 기록 조회
router.get('/me/attendance-records', async (req, res) => {
try {
const { year, month } = req.query;
const AttendanceModel = require('../models/attendanceModel');
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
const endDate = `${year}-${String(month).padStart(2, '0')}-31`;
const records = await AttendanceModel.getDailyRecords(startDate, endDate, req.user.worker_id);
res.json({ success: true, data: records });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// 내 연차 잔액 조회
router.get('/me/vacation-balance', async (req, res) => {
try {
const AttendanceModel = require('../models/attendanceModel');
const year = req.query.year || new Date().getFullYear();
const balance = await AttendanceModel.getWorkerVacationBalance(req.user.worker_id, year);
res.json({ success: true, data: balance });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// 내 작업 보고서 조회
router.get('/me/work-reports', async (req, res) => {
try {
const { startDate, endDate } = req.query;
const db = require('../config/database');
const reports = await db.query(
'SELECT * FROM daily_work_reports WHERE worker_id = ? AND report_date BETWEEN ? AND ? ORDER BY report_date DESC',
[req.user.worker_id, startDate, endDate]
);
res.json({ success: true, data: reports });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// 내 월별 통계
router.get('/me/monthly-stats', async (req, res) => {
try {
const { year, month } = req.query;
const db = require('../config/database');
const stats = await db.query(
`SELECT
SUM(total_work_hours) as month_hours,
COUNT(DISTINCT record_date) as work_days
FROM daily_attendance_records
WHERE worker_id = ? AND YEAR(record_date) = ? AND MONTH(record_date) = ?`,
[req.user.worker_id, year, month]
);
res.json({ success: true, data: stats[0] || { month_hours: 0, work_days: 0 } });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// ========== 관리자 전용 API ==========
/**
* 모든 라우트에 관리자 권한 적용
*/

View File

@@ -0,0 +1,156 @@
/**
* 한글 이름을 영문(로마자)으로 변환하는 유틸리티
*
* 사용 예시:
* hangulToRoman('홍길동') => 'hong.gildong'
* hangulToRoman('김철수') => 'kim.cheolsu'
*/
// 한글 로마자 변환 매핑 (국립국어원 표기법 기준)
const CHOSUNG_MAP = {
'ㄱ': 'g', 'ㄲ': 'kk', 'ㄴ': 'n', 'ㄷ': 'd', 'ㄸ': 'tt',
'ㄹ': 'r', 'ㅁ': 'm', 'ㅂ': 'b', 'ㅃ': 'pp', 'ㅅ': 's',
'ㅆ': 'ss', 'ㅇ': '', 'ㅈ': 'j', 'ㅉ': 'jj', 'ㅊ': 'ch',
'ㅋ': 'k', 'ㅌ': 't', 'ㅍ': 'p', 'ㅎ': 'h'
};
const JUNGSUNG_MAP = {
'ㅏ': 'a', 'ㅐ': 'ae', 'ㅑ': 'ya', 'ㅒ': 'yae', 'ㅓ': 'eo',
'ㅔ': 'e', 'ㅕ': 'yeo', 'ㅖ': 'ye', 'ㅗ': 'o', 'ㅘ': 'wa',
'ㅙ': 'wae', 'ㅚ': 'oe', 'ㅛ': 'yo', 'ㅜ': 'u', 'ㅝ': 'wo',
'ㅞ': 'we', 'ㅟ': 'wi', 'ㅠ': 'yu', 'ㅡ': 'eu', 'ㅢ': 'ui',
'ㅣ': 'i'
};
const JONGSUNG_MAP = {
'': '', 'ㄱ': 'k', 'ㄲ': 'k', 'ㄳ': 'k', 'ㄴ': 'n', 'ㄵ': 'n',
'ㄶ': 'n', 'ㄷ': 't', 'ㄹ': 'l', 'ㄺ': 'k', 'ㄻ': 'm', 'ㄼ': 'p',
'ㄽ': 'l', 'ㄾ': 'l', 'ㄿ': 'p', 'ㅀ': 'l', 'ㅁ': 'm', 'ㅂ': 'p',
'ㅄ': 'p', 'ㅅ': 't', 'ㅆ': 't', 'ㅇ': 'ng', 'ㅈ': 't', 'ㅊ': 't',
'ㅋ': 'k', 'ㅌ': 't', 'ㅍ': 'p', 'ㅎ': 't'
};
const CHOSUNG = [
'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ',
'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
];
const JUNGSUNG = [
'ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ',
'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ'
];
const JONGSUNG = [
'', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ',
'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ',
'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
];
/**
* 한글 한 글자를 초성, 중성, 종성으로 분리
* @param {string} char - 한글 한 글자
* @returns {object} { cho, jung, jong }
*/
function decomposeHangul(char) {
const code = char.charCodeAt(0) - 0xAC00;
if (code < 0 || code > 11171) {
return null; // 한글이 아님
}
const choIndex = Math.floor(code / 588);
const jungIndex = Math.floor((code % 588) / 28);
const jongIndex = code % 28;
return {
cho: CHOSUNG[choIndex],
jung: JUNGSUNG[jungIndex],
jong: JONGSUNG[jongIndex]
};
}
/**
* 한글 이름을 로마자로 변환
* @param {string} koreanName - 한글 이름
* @returns {string} 로마자 이름 (예: 'hong.gildong')
*/
function hangulToRoman(koreanName) {
if (!koreanName || typeof koreanName !== 'string') {
return '';
}
// 공백 제거
const trimmed = koreanName.trim();
// 성과 이름 분리 (첫 글자를 성으로 간주)
const surname = trimmed[0];
const givenName = trimmed.substring(1);
// 각 부분을 로마자로 변환
const romanSurname = convertToRoman(surname);
const romanGivenName = convertToRoman(givenName);
// 점(.)으로 연결
return `${romanSurname}.${romanGivenName}`.toLowerCase();
}
/**
* 한글 문자열을 로마자로 변환
* @param {string} text - 한글 문자열
* @returns {string} 로마자 문자열
*/
function convertToRoman(text) {
let result = '';
for (let i = 0; i < text.length; i++) {
const char = text[i];
const decomposed = decomposeHangul(char);
if (decomposed) {
result += CHOSUNG_MAP[decomposed.cho] || '';
result += JUNGSUNG_MAP[decomposed.jung] || '';
result += JONGSUNG_MAP[decomposed.jong] || '';
} else {
// 한글이 아닌 경우 그대로 추가
result += char;
}
}
return result;
}
/**
* 사용자명 생성 (중복 확인 및 처리)
* @param {string} koreanName - 한글 이름
* @param {object} knex - Knex 인스턴스
* @returns {Promise<string>} 고유한 username
*/
async function generateUniqueUsername(koreanName, knex) {
const baseUsername = hangulToRoman(koreanName);
let username = baseUsername;
let counter = 1;
// 중복 확인
while (true) {
const existing = await knex('users')
.where('username', username)
.first();
if (!existing) {
break; // 중복 없음
}
// 중복 시 숫자 추가
username = `${baseUsername}${counter}`;
counter++;
}
return username;
}
module.exports = {
hangulToRoman,
convertToRoman,
generateUniqueUsername,
decomposeHangul
};