diff --git a/api.hyungi.net/config/routes.js b/api.hyungi.net/config/routes.js index d1701f2..73e463e 100644 --- a/api.hyungi.net/config/routes.js +++ b/api.hyungi.net/config/routes.js @@ -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 문서 diff --git a/api.hyungi.net/db/migrations/20260106083251_create_permissions_tables.js b/api.hyungi.net/db/migrations/20260106083251_create_permissions_tables.js new file mode 100644 index 0000000..a53dfac --- /dev/null +++ b/api.hyungi.net/db/migrations/20260106083251_create_permissions_tables.js @@ -0,0 +1,57 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +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 } + */ +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'); + }); +}; \ No newline at end of file diff --git a/api.hyungi.net/db/migrations/20260106083318_populate_permissions_data.js b/api.hyungi.net/db/migrations/20260106083318_populate_permissions_data.js new file mode 100644 index 0000000..353c94f --- /dev/null +++ b/api.hyungi.net/db/migrations/20260106083318_populate_permissions_data.js @@ -0,0 +1,103 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +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 } + */ +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"); +}; \ No newline at end of file diff --git a/api.hyungi.net/db/migrations/20260107140000_update_worker_schema.js b/api.hyungi.net/db/migrations/20260107140000_update_worker_schema.js new file mode 100644 index 0000000..1592c7d --- /dev/null +++ b/api.hyungi.net/db/migrations/20260107140000_update_worker_schema.js @@ -0,0 +1,62 @@ + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +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 } + */ +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)' + `); + } +}; diff --git a/api.hyungi.net/db/migrations/20260119000000_simplify_permissions_and_add_page_access.js b/api.hyungi.net/db/migrations/20260119000000_simplify_permissions_and_add_page_access.js new file mode 100644 index 0000000..7d7a1e2 --- /dev/null +++ b/api.hyungi.net/db/migrations/20260119000000_simplify_permissions_and_add_page_access.js @@ -0,0 +1,151 @@ +/** + * 권한 시스템 단순화 및 페이지 접근 권한 추가 + * - Leader와 Worker를 User로 통합 + * - 페이지 접근 권한 테이블 생성 + * - Admin이 사용자별 페이지 접근 권한을 설정할 수 있도록 함 + * + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +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 } + */ +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: '그룹장. 팀원 작업 현황 조회 등 중간 관리자 권한.' } + ]); +}; diff --git a/api.hyungi.net/db/migrations/20260119120000_add_worker_fields.js b/api.hyungi.net/db/migrations/20260119120000_add_worker_fields.js new file mode 100644 index 0000000..607564b --- /dev/null +++ b/api.hyungi.net/db/migrations/20260119120000_add_worker_fields.js @@ -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 테이블 컬럼 제거 완료'); +}; diff --git a/api.hyungi.net/db/migrations/20260119120001_create_attendance_tables.js b/api.hyungi.net/db/migrations/20260119120001_create_attendance_tables.js new file mode 100644 index 0000000..b94cb86 --- /dev/null +++ b/api.hyungi.net/db/migrations/20260119120001_create_attendance_tables.js @@ -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('✅ 모든 출근/근태 관련 테이블 제거 완료'); +}; diff --git a/api.hyungi.net/db/migrations/20260119120002_create_accounts_for_existing_workers.js b/api.hyungi.net/db/migrations/20260119120002_create_accounts_for_existing_workers.js new file mode 100644 index 0000000..0de152c --- /dev/null +++ b/api.hyungi.net/db/migrations/20260119120002_create_accounts_for_existing_workers.js @@ -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 테이블을 관리하세요.'); +}; diff --git a/api.hyungi.net/db/migrations/20260119120003_add_guest_role.js b/api.hyungi.net/db/migrations/20260119120003_add_guest_role.js new file mode 100644 index 0000000..ff88ca9 --- /dev/null +++ b/api.hyungi.net/db/migrations/20260119120003_add_guest_role.js @@ -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('✅ 게스트 역할 제거 완료'); +}; diff --git a/api.hyungi.net/models/roleModel.js b/api.hyungi.net/models/roleModel.js new file mode 100644 index 0000000..5224eae --- /dev/null +++ b/api.hyungi.net/models/roleModel.js @@ -0,0 +1,43 @@ +const { getDb } = require('../dbPool'); + +class RoleModel { + /** + * 모든 역할 목록을 조회합니다. + * @returns {Promise} 역할 목록 + */ + 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} 역할 객체 + */ + 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} 권한 이름 목록 + */ + 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; diff --git a/api.hyungi.net/routes/authRoutes.js b/api.hyungi.net/routes/authRoutes.js index a4bf573..566e963 100644 --- a/api.hyungi.net/routes/authRoutes.js +++ b/api.hyungi.net/routes/authRoutes.js @@ -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, diff --git a/api.hyungi.net/routes/pageAccessRoutes.js b/api.hyungi.net/routes/pageAccessRoutes.js new file mode 100644 index 0000000..9c46223 --- /dev/null +++ b/api.hyungi.net/routes/pageAccessRoutes.js @@ -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; diff --git a/api.hyungi.net/routes/userRoutes.js b/api.hyungi.net/routes/userRoutes.js index 0c46952..a66356a 100644 --- a/api.hyungi.net/routes/userRoutes.js +++ b/api.hyungi.net/routes/userRoutes.js @@ -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 ========== /** * 모든 라우트에 관리자 권한 적용 */ diff --git a/api.hyungi.net/utils/hangulToRoman.js b/api.hyungi.net/utils/hangulToRoman.js new file mode 100644 index 0000000..5d93d9f --- /dev/null +++ b/api.hyungi.net/utils/hangulToRoman.js @@ -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} 고유한 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 +}; diff --git a/docker-compose.yml b/docker-compose.yml index 2b6b02e..ada56b8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,6 +65,7 @@ services: - ./api.hyungi.net/middlewares:/usr/src/app/middlewares - ./api.hyungi.net/utils:/usr/src/app/utils - ./api.hyungi.net/services:/usr/src/app/services + - ./api.hyungi.net/db/migrations:/usr/src/app/db/migrations - ./api.hyungi.net/index.js:/usr/src/app/index.js logging: driver: "json-file" diff --git a/web-ui/components/navbar.html b/web-ui/components/navbar.html index f5df8ff..efbe4bd 100644 --- a/web-ui/components/navbar.html +++ b/web-ui/components/navbar.html @@ -32,6 +32,10 @@ + + 📊 + 나의 대시보드 + 👤 내 프로필 diff --git a/web-ui/css/my-dashboard.css b/web-ui/css/my-dashboard.css new file mode 100644 index 0000000..89e545d --- /dev/null +++ b/web-ui/css/my-dashboard.css @@ -0,0 +1,350 @@ +/* My Dashboard CSS */ + +.dashboard-container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem 1.5rem; + background: #f8f9fa; + min-height: 100vh; +} + +.back-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: rgba(255, 255, 255, 0.9); + color: #495057; + text-decoration: none; + border-radius: 0.5rem; + font-weight: 500; + margin-bottom: 1.5rem; + transition: all 0.3s ease; + box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.1); +} + +.back-button:hover { + background: white; + color: #007bff; + transform: translateY(-0.0625rem); +} + +.page-header { + text-align: center; + margin-bottom: 2rem; +} + +.page-header h1 { + font-size: 2rem; + color: #333; + margin-bottom: 0.5rem; +} + +.page-header p { + color: #666; + font-size: 1.1rem; +} + +/* 사용자 정보 카드 */ +.user-info-card { + background: white; + border-radius: 0.75rem; + padding: 1.5rem; + margin-bottom: 2rem; + box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,0.1); +} + +.info-row { + display: flex; + gap: 2rem; + flex-wrap: wrap; +} + +.info-item { + display: flex; + gap: 0.5rem; +} + +.info-item .label { + font-weight: 600; + color: #555; +} + +/* 연차 정보 위젯 */ +.vacation-widget { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-radius: 0.75rem; + padding: 2rem; + margin-bottom: 2rem; + box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,0.15); +} + +.vacation-widget h2 { + margin-bottom: 1.5rem; +} + +.vacation-summary { + display: flex; + justify-content: space-around; + margin-bottom: 1.5rem; +} + +.vacation-summary .stat { + text-align: center; +} + +.vacation-summary .label { + display: block; + font-size: 0.9rem; + opacity: 0.9; + margin-bottom: 0.5rem; +} + +.vacation-summary .value { + display: block; + font-size: 2rem; + font-weight: 700; +} + +.progress-bar { + height: 1.5rem; + background: rgba(255,255,255,0.2); + border-radius: 0.75rem; + overflow: hidden; +} + +.progress { + height: 100%; + background: rgba(255,255,255,0.8); + transition: width 0.5s ease; +} + +/* 캘린더 섹션 */ +.calendar-section { + background: white; + border-radius: 0.75rem; + padding: 2rem; + margin-bottom: 2rem; + box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,0.1); +} + +.calendar-section h2 { + margin-bottom: 1rem; +} + +.calendar-controls { + display: flex; + justify-content: center; + align-items: center; + gap: 2rem; + margin-bottom: 1.5rem; +} + +.calendar-controls button { + background: #667eea; + color: white; + border: none; + border-radius: 0.5rem; + padding: 0.5rem 1rem; + cursor: pointer; + font-size: 1rem; +} + +.calendar-controls button:hover { + background: #764ba2; +} + +.calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 0.5rem; +} + +.calendar-header { + text-align: center; + font-weight: 600; + padding: 0.5rem; + background: #f8f9fa; + border-radius: 0.25rem; +} + +.calendar-day { + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.25rem; + background: #f8f9fa; + cursor: pointer; + transition: all 0.2s ease; +} + +.calendar-day:hover:not(.empty) { + transform: scale(1.05); +} + +.calendar-day.empty { + background: transparent; +} + +.calendar-day.normal { + background: #d4edda; + color: #155724; +} + +.calendar-day.late { + background: #fff3cd; + color: #856404; +} + +.calendar-day.vacation { + background: #d1ecf1; + color: #0c5460; +} + +.calendar-day.absent { + background: #f8d7da; + color: #721c24; +} + +.calendar-legend { + display: flex; + justify-content: center; + gap: 1.5rem; + margin-top: 1.5rem; + flex-wrap: wrap; +} + +.calendar-legend span { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; +} + +.dot { + width: 1rem; + height: 1rem; + border-radius: 50%; +} + +.dot.normal { + background: #d4edda; +} + +.dot.late { + background: #fff3cd; +} + +.dot.vacation { + background: #d1ecf1; +} + +.dot.absent { + background: #f8d7da; +} + +/* 근무 시간 통계 */ +.work-hours-stats { + background: white; + border-radius: 0.75rem; + padding: 2rem; + margin-bottom: 2rem; + box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,0.1); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; + margin-top: 1.5rem; +} + +.stat-card { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 1.5rem; + border-radius: 0.75rem; + text-align: center; +} + +.stat-card .label { + display: block; + font-size: 0.9rem; + opacity: 0.9; + margin-bottom: 0.5rem; +} + +.stat-card .value { + display: block; + font-size: 2rem; + font-weight: 700; +} + +/* 최근 작업 보고서 */ +.recent-reports { + background: white; + border-radius: 0.75rem; + padding: 2rem; + margin-bottom: 2rem; + box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,0.1); +} + +.recent-reports h2 { + margin-bottom: 1.5rem; +} + +.report-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-bottom: 1px solid #e9ecef; +} + +.report-item:last-child { + border-bottom: none; +} + +.report-item .date { + color: #666; + font-size: 0.9rem; +} + +.report-item .project { + flex: 1; + margin: 0 1rem; + font-weight: 500; +} + +.report-item .hours { + color: #667eea; + font-weight: 600; +} + +.empty-message { + text-align: center; + color: #999; + padding: 2rem; +} + +/* 반응형 */ +@media (max-width: 768px) { + .dashboard-container { + padding: 1rem; + } + + .vacation-summary { + flex-direction: column; + gap: 1rem; + } + + .calendar-grid { + gap: 0.25rem; + } + + .calendar-legend { + gap: 0.75rem; + } +} diff --git a/web-ui/js/manage-user.js b/web-ui/js/manage-user.js index e4edf55..99c671e 100644 --- a/web-ui/js/manage-user.js +++ b/web-ui/js/manage-user.js @@ -208,12 +208,38 @@ async function loadUsers() { list.forEach(item => { item.access_level = accessLabels[item.access_level] || item.access_level; item.worker_id = item.worker_id || '-'; - const row = createRow(item, [ - 'user_id', 'username', 'name', 'access_level', 'worker_id' - ], async u => { + + // 행 생성 + const tr = document.createElement('tr'); + + // 데이터 컬럼 + ['user_id', 'username', 'name', 'access_level', 'worker_id'].forEach(key => { + const td = document.createElement('td'); + td.textContent = item[key] || '-'; + tr.appendChild(td); + }); + + // 작업 컬럼 (페이지 권한 버튼 + 삭제 버튼) + const actionTd = document.createElement('td'); + + // 페이지 권한 버튼 (Admin/System이 아닌 경우에만) + if (item.access_level !== '관리자' && item.access_level !== '시스템') { + const pageAccessBtn = document.createElement('button'); + pageAccessBtn.textContent = '페이지 권한'; + pageAccessBtn.className = 'btn btn-info btn-sm'; + pageAccessBtn.style.marginRight = '5px'; + pageAccessBtn.onclick = () => openPageAccessModal(item.user_id, item.username, item.name); + actionTd.appendChild(pageAccessBtn); + } + + // 삭제 버튼 + const delBtn = document.createElement('button'); + delBtn.textContent = '삭제'; + delBtn.className = 'btn-delete'; + delBtn.onclick = async () => { if (!confirm('삭제하시겠습니까?')) return; try { - const delRes = await fetch(`${API}/auth/users/${u.user_id}`, { + const delRes = await fetch(`${API}/auth/users/${item.user_id}`, { method: 'DELETE', headers: getAuthHeaders() }); @@ -226,8 +252,11 @@ async function loadUsers() { } catch (error) { alert('🚨 삭제 중 오류 발생'); } - }); - tbody.appendChild(row); + }; + actionTd.appendChild(delBtn); + + tr.appendChild(actionTd); + tbody.appendChild(tr); }); } else { tbody.innerHTML = '데이터 형식 오류'; @@ -288,6 +317,195 @@ function showToast(message) { setTimeout(() => toast.remove(), 2000); } +// ========== 페이지 접근 권한 관리 ========== + +let currentEditingUserId = null; +let currentUserPageAccess = []; + +/** + * 페이지 권한 관리 모달 열기 + */ +async function openPageAccessModal(userId, username, name) { + currentEditingUserId = userId; + + const modal = document.getElementById('pageAccessModal'); + const modalUserInfo = document.getElementById('modalUserInfo'); + const modalUserRole = document.getElementById('modalUserRole'); + + modalUserInfo.textContent = `${name} (${username})`; + modalUserRole.textContent = `사용자 ID: ${userId}`; + + try { + // 사용자의 페이지 접근 권한 조회 + const res = await fetch(`${API}/users/${userId}/page-access`, { + headers: getAuthHeaders() + }); + + if (!res.ok) { + throw new Error('페이지 접근 권한을 불러오는데 실패했습니다.'); + } + + const result = await res.json(); + if (result.success) { + currentUserPageAccess = result.data.pageAccess; + renderPageAccessList(result.data.pageAccess); + modal.style.display = 'block'; + } else { + throw new Error(result.error || '데이터 로드 실패'); + } + } catch (error) { + console.error('페이지 권한 로드 오류:', error); + alert('❌ 페이지 권한을 불러오는데 실패했습니다: ' + error.message); + } +} + +/** + * 페이지 접근 권한 목록 렌더링 + */ +function renderPageAccessList(pageAccess) { + const categories = { + dashboard: document.getElementById('dashboardPageList'), + management: document.getElementById('managementPageList'), + common: document.getElementById('commonPageList') + }; + + // 카테고리별로 초기화 + Object.values(categories).forEach(el => { + if (el) el.innerHTML = ''; + }); + + // 카테고리별로 그룹화 + const grouped = pageAccess.reduce((acc, page) => { + if (!acc[page.category]) acc[page.category] = []; + acc[page.category].push(page); + return acc; + }, {}); + + // 각 카테고리별로 렌더링 + Object.keys(grouped).forEach(category => { + const container = categories[category]; + if (!container) return; + + grouped[category].forEach(page => { + const pageItem = document.createElement('div'); + pageItem.className = 'page-item'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.id = `page_${page.page_id}`; + checkbox.checked = page.can_access === 1 || page.can_access === true; + checkbox.dataset.pageId = page.page_id; + + const label = document.createElement('label'); + label.htmlFor = `page_${page.page_id}`; + label.textContent = page.page_name; + + const pathSpan = document.createElement('span'); + pathSpan.className = 'page-path'; + pathSpan.textContent = page.page_path; + + pageItem.appendChild(checkbox); + pageItem.appendChild(label); + pageItem.appendChild(pathSpan); + + container.appendChild(pageItem); + }); + }); +} + +/** + * 페이지 권한 변경 사항 저장 + */ +async function savePageAccessChanges() { + if (!currentEditingUserId) { + alert('사용자 정보가 없습니다.'); + return; + } + + // 모든 체크박스 상태 가져오기 + const checkboxes = document.querySelectorAll('.page-item input[type="checkbox"]'); + const pageAccessUpdates = {}; + + checkboxes.forEach(checkbox => { + const pageId = parseInt(checkbox.dataset.pageId); + const canAccess = checkbox.checked; + pageAccessUpdates[pageId] = canAccess; + }); + + try { + // 변경된 페이지 권한을 서버로 전송 + const pageIds = Object.keys(pageAccessUpdates).map(id => parseInt(id)); + const canAccessValues = pageIds.map(id => pageAccessUpdates[id]); + + // 접근 가능한 페이지 + const accessiblePages = pageIds.filter((id, index) => canAccessValues[index]); + // 접근 불가능한 페이지 + const inaccessiblePages = pageIds.filter((id, index) => !canAccessValues[index]); + + // 접근 가능 페이지 업데이트 + if (accessiblePages.length > 0) { + await fetch(`${API}/users/${currentEditingUserId}/page-access`, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ + pageIds: accessiblePages, + canAccess: true + }) + }); + } + + // 접근 불가능 페이지 업데이트 + if (inaccessiblePages.length > 0) { + await fetch(`${API}/users/${currentEditingUserId}/page-access`, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ + pageIds: inaccessiblePages, + canAccess: false + }) + }); + } + + showToast('✅ 페이지 접근 권한이 저장되었습니다.'); + closePageAccessModal(); + } catch (error) { + console.error('페이지 권한 저장 오류:', error); + alert('❌ 페이지 권한 저장에 실패했습니다: ' + error.message); + } +} + +/** + * 페이지 권한 관리 모달 닫기 + */ +function closePageAccessModal() { + const modal = document.getElementById('pageAccessModal'); + modal.style.display = 'none'; + currentEditingUserId = null; + currentUserPageAccess = []; +} + +// 모달 닫기 버튼 이벤트 +document.addEventListener('DOMContentLoaded', () => { + const modal = document.getElementById('pageAccessModal'); + const closeBtn = modal?.querySelector('.close'); + + if (closeBtn) { + closeBtn.onclick = closePageAccessModal; + } + + // 모달 외부 클릭 시 닫기 + window.onclick = (event) => { + if (event.target === modal) { + closePageAccessModal(); + } + }; +}); + +// 전역 함수로 노출 +window.openPageAccessModal = openPageAccessModal; +window.closePageAccessModal = closePageAccessModal; +window.savePageAccessChanges = savePageAccessChanges; + window.addEventListener('DOMContentLoaded', () => { loadUsers(); loadWorkerOptions(); diff --git a/web-ui/js/my-dashboard.js b/web-ui/js/my-dashboard.js new file mode 100644 index 0000000..c61a5f5 --- /dev/null +++ b/web-ui/js/my-dashboard.js @@ -0,0 +1,189 @@ +// My Dashboard - 나의 대시보드 JavaScript + +import './api-config.js'; + +// 전역 변수 +let currentYear = new Date().getFullYear(); +let currentMonth = new Date().getMonth() + 1; + +// 페이지 초기화 +document.addEventListener('DOMContentLoaded', async () => { + console.log('📊 나의 대시보드 초기화 시작'); + + await loadUserInfo(); + await loadVacationBalance(); + await loadMonthlyCalendar(); + await loadWorkHoursStats(); + await loadRecentReports(); + + console.log('✅ 나의 대시보드 초기화 완료'); +}); + +// 사용자 정보 로드 +async function loadUserInfo() { + try { + const response = await apiCall('/users/me', 'GET'); + const user = response.data || response; + + document.getElementById('userName').textContent = user.name || '사용자'; + document.getElementById('department').textContent = user.department || '-'; + document.getElementById('jobType').textContent = user.job_type || '-'; + document.getElementById('hireDate').textContent = user.hire_date || '-'; + } catch (error) { + console.error('사용자 정보 로드 실패:', error); + } +} + +// 연차 정보 로드 +async function loadVacationBalance() { + try { + const response = await apiCall('/users/me/vacation-balance', 'GET'); + const balance = response.data || response; + + const total = balance.total_annual_leave || 15; + const used = balance.used_annual_leave || 0; + const remaining = total - used; + + document.getElementById('totalLeave').textContent = total; + document.getElementById('usedLeave').textContent = used; + document.getElementById('remainingLeave').textContent = remaining; + + // 프로그레스 바 업데이트 + const percentage = (used / total) * 100; + document.getElementById('vacationProgress').style.width = `${percentage}%`; + } catch (error) { + console.error('연차 정보 로드 실패:', error); + } +} + +// 월별 캘린더 로드 +async function loadMonthlyCalendar() { + try { + const response = await apiCall( + `/users/me/attendance-records?year=${currentYear}&month=${currentMonth}`, + 'GET' + ); + const records = response.data || response; + + renderCalendar(currentYear, currentMonth, records); + document.getElementById('currentMonth').textContent = `${currentYear}년 ${currentMonth}월`; + } catch (error) { + console.error('캘린더 로드 실패:', error); + renderCalendar(currentYear, currentMonth, []); + } +} + +// 캘린더 렌더링 +function renderCalendar(year, month, records) { + const calendar = document.getElementById('calendar'); + const firstDay = new Date(year, month - 1, 1).getDay(); + const daysInMonth = new Date(year, month, 0).getDate(); + + let html = ''; + + // 요일 헤더 + const weekdays = ['일', '월', '화', '수', '목', '금', '토']; + weekdays.forEach(day => { + html += `
${day}
`; + }); + + // 빈 칸 + for (let i = 0; i < firstDay; i++) { + html += '
'; + } + + // 날짜 + for (let day = 1; day <= daysInMonth; day++) { + const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const record = Array.isArray(records) ? records.find(r => r.record_date === dateStr) : null; + + let statusClass = ''; + if (record) { + const typeCode = record.attendance_type_code || record.type_code || ''; + statusClass = typeCode.toLowerCase(); + } + + html += ` +
+ ${day} +
+ `; + } + + calendar.innerHTML = html; +} + +// 근무 시간 통계 로드 +async function loadWorkHoursStats() { + try { + const response = await apiCall( + `/users/me/monthly-stats?year=${currentYear}&month=${currentMonth}`, + 'GET' + ); + const stats = response.data || response; + + document.getElementById('monthHours').textContent = stats.month_hours || 0; + document.getElementById('workDays').textContent = stats.work_days || 0; + } catch (error) { + console.error('근무 시간 통계 로드 실패:', error); + } +} + +// 최근 작업 보고서 로드 +async function loadRecentReports() { + try { + const endDate = new Date().toISOString().split('T')[0]; + const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) + .toISOString().split('T')[0]; + + const response = await apiCall( + `/users/me/work-reports?startDate=${startDate}&endDate=${endDate}`, + 'GET' + ); + const reports = response.data || response; + + const list = document.getElementById('recentReportsList'); + + if (!Array.isArray(reports) || reports.length === 0) { + list.innerHTML = '

최근 7일간의 작업 보고서가 없습니다.

'; + return; + } + + list.innerHTML = reports.map(r => ` +
+ ${r.report_date} + ${r.project_name || 'N/A'} + ${r.work_hours}시간 +
+ `).join(''); + } catch (error) { + console.error('최근 작업 보고서 로드 실패:', error); + } +} + +// 이전 달 +function previousMonth() { + currentMonth--; + if (currentMonth < 1) { + currentMonth = 12; + currentYear--; + } + loadMonthlyCalendar(); + loadWorkHoursStats(); +} + +// 다음 달 +function nextMonth() { + currentMonth++; + if (currentMonth > 12) { + currentMonth = 1; + currentYear++; + } + loadMonthlyCalendar(); + loadWorkHoursStats(); +} + +// 전역 함수 노출 +window.previousMonth = previousMonth; +window.nextMonth = nextMonth; +window.loadMonthlyCalendar = loadMonthlyCalendar; diff --git a/web-ui/pages/admin/manage-user.html b/web-ui/pages/admin/manage-user.html index 54c9998..68e09d6 100644 --- a/web-ui/pages/admin/manage-user.html +++ b/web-ui/pages/admin/manage-user.html @@ -99,6 +99,187 @@ + + + + + diff --git a/web-ui/pages/analysis/work-analysis-legacy.html b/web-ui/pages/analysis/work-analysis-legacy.html index 52e1158..b75c130 100644 --- a/web-ui/pages/analysis/work-analysis-legacy.html +++ b/web-ui/pages/analysis/work-analysis-legacy.html @@ -10,7 +10,7 @@ - + diff --git a/web-ui/pages/analysis/work-analysis-modular.html b/web-ui/pages/analysis/work-analysis-modular.html index 94b9e26..f30aa3e 100644 --- a/web-ui/pages/analysis/work-analysis-modular.html +++ b/web-ui/pages/analysis/work-analysis-modular.html @@ -10,7 +10,7 @@ - + diff --git a/web-ui/pages/profile/admin-settings.html b/web-ui/pages/profile/admin-settings.html index 35c391b..603b942 100644 --- a/web-ui/pages/profile/admin-settings.html +++ b/web-ui/pages/profile/admin-settings.html @@ -13,20 +13,9 @@
- - -
-

⚙️ 관리자 설정

-

시스템 사용자 계정 및 권한을 관리합니다

-
- - - ← 뒤로가기 - -