diff --git a/gateway/nginx.conf b/gateway/nginx.conf index c393bc5..498ee72 100644 --- a/gateway/nginx.conf +++ b/gateway/nginx.conf @@ -7,11 +7,6 @@ server { # ===== Gateway 자체 페이지 (포털, 로그인) ===== root /usr/share/nginx/html; - # 포털 메인 페이지 (정확히 / 만) - location = / { - try_files /portal.html =404; - } - # 로그인 페이지 location = /login { try_files /login.html =404; diff --git a/system1-factory/api/db/migrations/20260223000001_add_category_type_to_work_issues.js b/system1-factory/api/db/migrations/20260223000001_add_category_type_to_work_issues.js new file mode 100644 index 0000000..c350d3d --- /dev/null +++ b/system1-factory/api/db/migrations/20260223000001_add_category_type_to_work_issues.js @@ -0,0 +1,51 @@ +/** + * 마이그레이션: work_issue_reports에 category_type 컬럼 추가 + * - issue_report_categories ENUM에 'facility' 추가 + * - work_issue_reports에 category_type 직접 저장 (유형 이관 기능 지원) + * - 기존 데이터 백필 + */ + +exports.up = async function(knex) { + // 1) issue_report_categories ENUM에 'facility' 추가 + await knex.raw(` + ALTER TABLE issue_report_categories + MODIFY COLUMN category_type ENUM('nonconformity', 'safety', 'facility') NOT NULL + `); + + // 2) work_issue_reports에 category_type 컬럼 추가 + await knex.raw(` + ALTER TABLE work_issue_reports + ADD COLUMN category_type ENUM('nonconformity', 'safety', 'facility') NULL AFTER issue_item_id + `); + + // 3) 기존 데이터 백필: issue_report_categories에서 category_type 복사 + await knex.raw(` + UPDATE work_issue_reports wir + INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id + SET wir.category_type = irc.category_type + `); + + // 4) NOT NULL로 변경 + await knex.raw(` + ALTER TABLE work_issue_reports + MODIFY COLUMN category_type ENUM('nonconformity', 'safety', 'facility') NOT NULL + `); + + // 5) 인덱스 추가 + await knex.raw(` + ALTER TABLE work_issue_reports ADD INDEX idx_wir_category_type (category_type) + `); + + console.log('work_issue_reports에 category_type 컬럼 추가 완료'); +}; + +exports.down = async function(knex) { + await knex.raw(`ALTER TABLE work_issue_reports DROP INDEX idx_wir_category_type`); + await knex.raw(`ALTER TABLE work_issue_reports DROP COLUMN category_type`); + await knex.raw(` + ALTER TABLE issue_report_categories + MODIFY COLUMN category_type ENUM('nonconformity', 'safety') NOT NULL + `); + + console.log('work_issue_reports에서 category_type 컬럼 제거 완료'); +}; diff --git a/system1-factory/web/Dockerfile b/system1-factory/web/Dockerfile index ac30d1f..bff6595 100644 --- a/system1-factory/web/Dockerfile +++ b/system1-factory/web/Dockerfile @@ -3,6 +3,9 @@ FROM nginx:alpine # 정적 파일 복사 COPY . /usr/share/nginx/html/ +# 디렉토리 권한 보정 (macOS에서 복사 시 700이 되는 문제 방지) +RUN find /usr/share/nginx/html -type d -exec chmod 755 {} + + COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 diff --git a/system1-factory/web/components/navbar.html b/system1-factory/web/components/navbar.html index 53922b4..5718092 100644 --- a/system1-factory/web/components/navbar.html +++ b/system1-factory/web/components/navbar.html @@ -120,18 +120,28 @@ body { justify-content: space-between; width: 100%; padding: 0 var(--space-4); + flex-wrap: nowrap; +} + +.header-left { + display: flex; + align-items: center; + min-width: 0; + flex-shrink: 1; } .header-left .brand { display: flex; align-items: center; gap: var(--space-3); + min-width: 0; } .header-right { display: flex; align-items: center; gap: var(--space-4); + flex-shrink: 0; } .brand-logo { @@ -634,25 +644,41 @@ body { @media (max-width: 768px) { .dashboard-header { - padding: var(--space-2) var(--space-3); - height: 64px; + padding: 0 0.5rem; + height: 52px; } body { - padding-top: 64px; + padding-top: 52px; } .header-content { - padding: 0 var(--space-2); + padding: 0; + gap: 0.25rem; + } + + .header-left { + flex: 1; + min-width: 0; + overflow: hidden; + } + + .header-left .brand { + gap: 0.5rem; + min-width: 0; } .brand-logo { - width: 36px; - height: 36px; + width: 30px; + height: 30px; + flex-shrink: 0; } .brand-title { - font-size: var(--text-base); + font-size: 0.8125rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .brand-subtitle { @@ -663,13 +689,19 @@ body { display: none; } + .header-right { + gap: 0.25rem; + flex-shrink: 0; + } + .user-info { display: none; } .user-avatar { - width: 36px; - height: 36px; + width: 30px; + height: 30px; + font-size: 0.8rem; } .dashboard-btn .btn-text, @@ -679,48 +711,52 @@ body { .dashboard-btn, .report-btn { - padding: var(--space-2); - width: 40px; - height: 40px; + padding: 0; + width: 32px; + height: 32px; justify-content: center; + border: none; + background: rgba(255, 255, 255, 0.12); } .dashboard-btn .btn-icon, .report-btn .btn-icon { margin: 0; + font-size: 1rem; } .notification-btn { - width: 40px; - height: 40px; + width: 32px; + height: 32px; } .notification-dropdown { position: fixed; - top: 64px; - left: var(--space-3); - right: var(--space-3); + top: 52px; + left: 0.5rem; + right: 0.5rem; width: auto; } - .header-right { - gap: var(--space-2); - } - .mobile-menu-btn { - width: 40px; - height: 40px; - margin-right: var(--space-2); + width: 32px; + height: 32px; + margin-right: 0.25rem; + font-size: 1.125rem; + background: rgba(255, 255, 255, 0.12); + border: none; } .user-profile { - padding: var(--space-1) var(--space-2); + padding: 0.125rem 0.25rem; + background: transparent; + border: none; } .profile-menu { position: fixed; - top: 64px; - right: var(--space-3); + top: 52px; + right: 0.5rem; left: auto; width: 200px; } diff --git a/system1-factory/web/components/sidebar-nav.html b/system1-factory/web/components/sidebar-nav.html index db39bd8..120618c 100644 --- a/system1-factory/web/components/sidebar-nav.html +++ b/system1-factory/web/components/sidebar-nav.html @@ -66,8 +66,11 @@ + + +
× diff --git a/system2-report/web/pages/safety/issue-report.html b/system2-report/web/pages/safety/issue-report.html index 3ec8076..7228a9b 100644 --- a/system2-report/web/pages/safety/issue-report.html +++ b/system2-report/web/pages/safety/issue-report.html @@ -18,39 +18,6 @@ -webkit-font-smoothing: antialiased; } - /* Header */ - .report-header { - position: sticky; - top: 0; - z-index: 100; - background: white; - padding: 0.875rem 1rem; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); - display: flex; - align-items: center; - gap: 0.75rem; - } - .report-header .back-btn { - background: none; - border: none; - font-size: 1.5rem; - cursor: pointer; - padding: 0.25rem; - color: #374151; - line-height: 1; - } - .report-header h1 { - font-size: 1.125rem; - font-weight: 700; - color: #1f2937; - } - .report-header .header-link { - margin-left: auto; - font-size: 0.8125rem; - color: #6b7280; - text-decoration: none; - } - /* Step indicator */ .step-indicator { display: flex; @@ -506,13 +473,6 @@ - -
- -

신고 등록

-
신고현황 → -
-
1유형
diff --git a/system2-report/web/pages/safety/my-reports.html b/system2-report/web/pages/safety/my-reports.html new file mode 100644 index 0000000..4aa698a --- /dev/null +++ b/system2-report/web/pages/safety/my-reports.html @@ -0,0 +1,327 @@ + + + + + + 내 신고 현황 | (주)테크니컬코리아 + + + + + + + + + + +
+ + +
+
+ + + +
+
+
-
+
신고
+
+
+
-
+
접수
+
+
+
-
+
처리중
+
+
+
-
+
완료
+
+
+ + +
+ + + + + + + + + 신고하기 +
+ + +
+
+
로딩 중...
+
+
+
+
+
+ + + + diff --git a/system3-nonconformance/api/database/models.py b/system3-nonconformance/api/database/models.py index 2c21835..6ab7da3 100644 --- a/system3-nonconformance/api/database/models.py +++ b/system3-nonconformance/api/database/models.py @@ -68,6 +68,7 @@ class User(Base): # Relationships issues = relationship("Issue", back_populates="reporter", foreign_keys="Issue.reporter_id") reviewed_issues = relationship("Issue", foreign_keys="Issue.reviewed_by_id") + daily_works = relationship("DailyWork", back_populates="created_by") page_permissions = relationship("UserPagePermission", back_populates="user", foreign_keys="UserPagePermission.user_id") class UserPagePermission(Base): @@ -104,6 +105,7 @@ class Issue(Base): status = Column(Enum(IssueStatus), default=IssueStatus.new) reporter_id = Column(Integer, ForeignKey("sso_users.user_id")) project_id = Column(Integer) # FK 제거 — projects는 tkuser에서 관리 + location_info = Column(String(200)) # 공장/작업장 위치 정보 (System 2 연동) report_date = Column(DateTime, default=get_kst_now) work_hours = Column(Float, default=0) detail_notes = Column(Text) @@ -182,6 +184,37 @@ class Project(Base): primaryjoin="Project.id == Issue.project_id", foreign_keys="[Issue.project_id]") +class DailyWork(Base): + __tablename__ = "qc_daily_works" + + id = Column(Integer, primary_key=True, index=True) + date = Column(DateTime, nullable=False, index=True) + worker_count = Column(Integer, nullable=False) + regular_hours = Column(Float, nullable=False) + overtime_workers = Column(Integer, default=0) + overtime_hours = Column(Float, default=0) + overtime_total = Column(Float, default=0) + total_hours = Column(Float, nullable=False) + created_by_id = Column(Integer, ForeignKey("sso_users.user_id")) + created_at = Column(DateTime, default=get_kst_now) + + # Relationships + created_by = relationship("User", back_populates="daily_works") + +class ProjectDailyWork(Base): + __tablename__ = "qc_project_daily_works" + + id = Column(Integer, primary_key=True, index=True) + date = Column(DateTime, nullable=False, index=True) + project_id = Column(Integer, ForeignKey("projects.project_id"), nullable=False) + hours = Column(Float, nullable=False) + created_by_id = Column(Integer, ForeignKey("sso_users.user_id")) + created_at = Column(DateTime, default=get_kst_now) + + # Relationships + project = relationship("Project") + created_by = relationship("User") + class DeletionLog(Base): __tablename__ = "qc_deletion_logs" diff --git a/system3-nonconformance/api/database/schemas.py b/system3-nonconformance/api/database/schemas.py index aabac7c..bce2760 100644 --- a/system3-nonconformance/api/database/schemas.py +++ b/system3-nonconformance/api/database/schemas.py @@ -91,6 +91,7 @@ class IssueBase(BaseModel): project_id: int class IssueCreate(IssueBase): + location_info: Optional[str] = None # 공장/작업장 위치 정보 photo: Optional[str] = None # Base64 encoded image photo2: Optional[str] = None photo3: Optional[str] = None @@ -112,6 +113,7 @@ class IssueUpdate(BaseModel): class Issue(IssueBase): id: int + location_info: Optional[str] = None photo_path: Optional[str] = None photo_path2: Optional[str] = None photo_path3: Optional[str] = None @@ -259,6 +261,7 @@ class InboxIssue(BaseModel): id: int category: IssueCategory description: str + location_info: Optional[str] = None photo_path: Optional[str] = None photo_path2: Optional[str] = None project_id: Optional[int] = None @@ -300,6 +303,50 @@ class Project(ProjectBase): class Config: from_attributes = True +# DailyWork schemas +class DailyWorkBase(BaseModel): + date: datetime + worker_count: int = Field(gt=0) + overtime_workers: Optional[int] = 0 + overtime_hours: Optional[float] = 0 + +class DailyWorkCreate(DailyWorkBase): + pass + +class DailyWorkUpdate(BaseModel): + worker_count: Optional[int] = Field(None, gt=0) + overtime_workers: Optional[int] = None + overtime_hours: Optional[float] = None + +class DailyWork(DailyWorkBase): + id: int + regular_hours: float + overtime_total: float + total_hours: float + created_by_id: int + created_by: User + created_at: datetime + + class Config: + from_attributes = True + +class ProjectDailyWorkBase(BaseModel): + date: datetime + project_id: int + hours: float + +class ProjectDailyWorkCreate(ProjectDailyWorkBase): + pass + +class ProjectDailyWork(ProjectDailyWorkBase): + id: int + created_by_id: int + created_at: datetime + project: Project + + class Config: + from_attributes = True + # Report schemas class ReportRequest(BaseModel): start_date: datetime diff --git a/system3-nonconformance/api/routers/issues.py b/system3-nonconformance/api/routers/issues.py index 02b6b0a..08dcfe4 100644 --- a/system3-nonconformance/api/routers/issues.py +++ b/system3-nonconformance/api/routers/issues.py @@ -35,6 +35,7 @@ async def create_issue( db_issue = Issue( category=issue.category, description=issue.description, + location_info=issue.location_info, photo_path=photo_paths.get('photo_path'), photo_path2=photo_paths.get('photo_path2'), photo_path3=photo_paths.get('photo_path3'), diff --git a/system3-nonconformance/web/static/js/pages/issue-view.js b/system3-nonconformance/web/static/js/pages/issue-view.js index d83daff..6bdf11f 100644 --- a/system3-nonconformance/web/static/js/pages/issue-view.js +++ b/system3-nonconformance/web/static/js/pages/issue-view.js @@ -564,6 +564,8 @@ function createIssueCard(issue, isCompleted) {

${issue.description}

+ ${issue.location_info ? `
${issue.location_info}
` : ''} +
${issue.reporter?.full_name || issue.reporter?.username || '알 수 없음'} diff --git a/system3-nonconformance/web/static/js/pages/issues-inbox.js b/system3-nonconformance/web/static/js/pages/issues-inbox.js index 5c0215b..439600d 100644 --- a/system3-nonconformance/web/static/js/pages/issues-inbox.js +++ b/system3-nonconformance/web/static/js/pages/issues-inbox.js @@ -292,6 +292,10 @@ function displayIssues() { ${getCategoryText(issue.category || issue.final_category)}
+ ${issue.location_info ? `
+ + ${issue.location_info} +
` : ''}
${photoInfo} diff --git a/system3-nonconformance/web/static/js/pages/issues-management.js b/system3-nonconformance/web/static/js/pages/issues-management.js index f4180d9..81bc8a1 100644 --- a/system3-nonconformance/web/static/js/pages/issues-management.js +++ b/system3-nonconformance/web/static/js/pages/issues-management.js @@ -419,6 +419,13 @@ function createInProgressRow(issue, project) {
+ ${issue.location_info ? `
+ +
+ ${issue.location_info} +
+
` : ''} +
${(() => { diff --git a/user-management/api/controllers/permissionController.js b/user-management/api/controllers/permissionController.js index e520980..2849488 100644 --- a/user-management/api/controllers/permissionController.js +++ b/user-management/api/controllers/permissionController.js @@ -149,11 +149,77 @@ async function deletePermission(req, res, next) { } } +/** + * GET /api/permissions/departments/:deptId/permissions - 부서 권한 조회 + */ +async function getDepartmentPermissions(req, res, next) { + try { + const deptId = parseInt(req.params.deptId); + const permissions = await permissionModel.getDepartmentPermissions(deptId); + res.json({ success: true, data: permissions }); + } catch (err) { + next(err); + } +} + +/** + * POST /api/permissions/departments/:deptId/bulk-set - 부서 권한 일괄 설정 + */ +async function bulkSetDepartmentPermissions(req, res, next) { + try { + const deptId = parseInt(req.params.deptId); + const { permissions } = req.body; + const grantedById = req.user.user_id || req.user.id; + + const result = await permissionModel.bulkSetDepartmentPermissions({ + department_id: deptId, + permissions, + granted_by_id: grantedById + }); + + res.json({ + success: true, + message: `${result.updated_count}개의 부서 권한이 설정되었습니다`, + updated_count: result.updated_count + }); + } catch (err) { + next(err); + } +} + +/** + * GET /api/permissions/users/:userId/effective-permissions - 출처 포함 권한 조회 + */ +async function getUserPermissionsWithSource(req, res, next) { + try { + const userId = parseInt(req.params.userId); + const requesterId = req.user.user_id || req.user.id; + + // 관리자이거나 본인만 조회 가능 + if (req.user.role !== 'admin' && requesterId !== userId) { + return res.status(403).json({ success: false, error: '권한이 없습니다' }); + } + + const user = await userModel.findById(userId); + if (!user) { + return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다' }); + } + + const result = await permissionModel.getUserPermissionsWithSource(userId); + res.json({ success: true, ...result }); + } catch (err) { + next(err); + } +} + module.exports = { getUserPermissions, grantPermission, bulkGrant, checkAccess, getAvailablePages, - deletePermission + deletePermission, + getDepartmentPermissions, + bulkSetDepartmentPermissions, + getUserPermissionsWithSource }; diff --git a/user-management/api/controllers/userController.js b/user-management/api/controllers/userController.js index 82f504e..d4eb0fb 100644 --- a/user-management/api/controllers/userController.js +++ b/user-management/api/controllers/userController.js @@ -23,7 +23,7 @@ async function getUsers(req, res, next) { */ async function createUser(req, res, next) { try { - const { username, password, name, full_name, department, role } = req.body; + const { username, password, name, full_name, department, department_id, role } = req.body; if (!username || !password) { return res.status(400).json({ success: false, error: '사용자명과 비밀번호는 필수입니다' }); @@ -39,6 +39,7 @@ async function createUser(req, res, next) { password, name: name || full_name, department, + department_id: department_id || null, role }); res.status(201).json({ success: true, data: user }); diff --git a/user-management/api/models/permissionModel.js b/user-management/api/models/permissionModel.js index c879921..baca921 100644 --- a/user-management/api/models/permissionModel.js +++ b/user-management/api/models/permissionModel.js @@ -104,20 +104,36 @@ async function bulkGrant({ user_id, permissions, granted_by_id }) { } /** - * 접근 권한 확인 + * 접근 권한 확인 (우선순위: 개인 > 부서 > 기본값) */ async function checkAccess(userId, pageName) { const db = getPool(); + + // 1. 명시적 개인 권한 const [rows] = await db.query( 'SELECT can_access FROM user_page_permissions WHERE user_id = ? AND page_name = ?', [userId, pageName] ); - if (rows.length > 0) { return { can_access: rows[0].can_access, reason: 'explicit_permission' }; } - // 기본 권한 + // 2. 부서 권한 + const [userRows] = await db.query( + 'SELECT department_id FROM sso_users WHERE user_id = ?', + [userId] + ); + if (userRows.length > 0 && userRows[0].department_id) { + const [deptRows] = await db.query( + 'SELECT can_access FROM department_page_permissions WHERE department_id = ? AND page_name = ?', + [userRows[0].department_id, pageName] + ); + if (deptRows.length > 0) { + return { can_access: deptRows[0].can_access, reason: 'department_permission' }; + } + } + + // 3. 기본 권한 const pageConfig = DEFAULT_PAGES[pageName]; if (!pageConfig) { return { can_access: false, reason: 'invalid_page' }; @@ -125,6 +141,110 @@ async function checkAccess(userId, pageName) { return { can_access: pageConfig.default_access, reason: 'default_permission' }; } +/** + * 부서별 페이지 권한 조회 + */ +async function getDepartmentPermissions(departmentId) { + const db = getPool(); + const [rows] = await db.query( + 'SELECT * FROM department_page_permissions WHERE department_id = ? ORDER BY page_name', + [departmentId] + ); + return rows; +} + +/** + * 부서 권한 단건 UPSERT + */ +async function setDepartmentPermission({ department_id, page_name, can_access, granted_by_id }) { + const db = getPool(); + const [result] = await db.query( + `INSERT INTO department_page_permissions (department_id, page_name, can_access, granted_by_id) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE can_access = VALUES(can_access), granted_by_id = VALUES(granted_by_id), granted_at = CURRENT_TIMESTAMP`, + [department_id, page_name, can_access, granted_by_id] + ); + return { id: result.insertId, department_id, page_name, can_access }; +} + +/** + * 부서 권한 일괄 설정 + */ +async function bulkSetDepartmentPermissions({ department_id, permissions, granted_by_id }) { + const db = getPool(); + let count = 0; + + for (const perm of permissions) { + if (!DEFAULT_PAGES[perm.page_name]) continue; + await db.query( + `INSERT INTO department_page_permissions (department_id, page_name, can_access, granted_by_id) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE can_access = VALUES(can_access), granted_by_id = VALUES(granted_by_id), granted_at = CURRENT_TIMESTAMP`, + [department_id, perm.page_name, perm.can_access, granted_by_id] + ); + count++; + } + + return { updated_count: count }; +} + +/** + * 부서 권한 삭제 (기본값으로 복귀) + */ +async function deleteDepartmentPermission(departmentId, pageName) { + const db = getPool(); + const [result] = await db.query( + 'DELETE FROM department_page_permissions WHERE department_id = ? AND page_name = ?', + [departmentId, pageName] + ); + return result.affectedRows > 0; +} + +/** + * 사용자 권한 + 출처 조회 (각 페이지별 현재 적용 권한과 출처 반환) + */ +async function getUserPermissionsWithSource(userId) { + const db = getPool(); + + // 개인 권한 조회 + const [userPerms] = await db.query( + 'SELECT page_name, can_access FROM user_page_permissions WHERE user_id = ?', + [userId] + ); + const userPermMap = {}; + userPerms.forEach(p => { userPermMap[p.page_name] = !!p.can_access; }); + + // 부서 권한 조회 + const [userRows] = await db.query( + 'SELECT department_id FROM sso_users WHERE user_id = ?', + [userId] + ); + const deptId = userRows.length > 0 ? userRows[0].department_id : null; + + const deptPermMap = {}; + if (deptId) { + const [deptPerms] = await db.query( + 'SELECT page_name, can_access FROM department_page_permissions WHERE department_id = ?', + [deptId] + ); + deptPerms.forEach(p => { deptPermMap[p.page_name] = !!p.can_access; }); + } + + // 모든 페이지에 대해 결과 조합 + const result = {}; + for (const [pageName, config] of Object.entries(DEFAULT_PAGES)) { + if (pageName in userPermMap) { + result[pageName] = { can_access: userPermMap[pageName], source: 'explicit' }; + } else if (pageName in deptPermMap) { + result[pageName] = { can_access: deptPermMap[pageName], source: 'department' }; + } else { + result[pageName] = { can_access: config.default_access, source: 'default' }; + } + } + + return { permissions: result, department_id: deptId }; +} + /** * 권한 삭제 (기본값으로 되돌림) */ @@ -146,5 +266,10 @@ module.exports = { grantPermission, bulkGrant, checkAccess, - deletePermission + deletePermission, + getDepartmentPermissions, + setDepartmentPermission, + bulkSetDepartmentPermissions, + deleteDepartmentPermission, + getUserPermissionsWithSource }; diff --git a/user-management/api/models/userModel.js b/user-management/api/models/userModel.js index 5c5863e..8a6477e 100644 --- a/user-management/api/models/userModel.js +++ b/user-management/api/models/userModel.js @@ -97,18 +97,18 @@ async function findById(userId) { async function findAll() { const db = getPool(); const [rows] = await db.query( - 'SELECT user_id, username, name, department, role, system1_access, system2_access, system3_access, is_active, last_login, created_at FROM sso_users ORDER BY user_id' + 'SELECT user_id, username, name, department, department_id, role, system1_access, system2_access, system3_access, is_active, last_login, created_at FROM sso_users ORDER BY user_id' ); return rows; } -async function create({ username, password, name, department, role }) { +async function create({ username, password, name, department, department_id, role }) { const db = getPool(); const password_hash = await hashPassword(password); const [result] = await db.query( - `INSERT INTO sso_users (username, password_hash, name, department, role) - VALUES (?, ?, ?, ?, ?)`, - [username, password_hash, name || null, department || null, role || 'user'] + `INSERT INTO sso_users (username, password_hash, name, department, department_id, role) + VALUES (?, ?, ?, ?, ?, ?)`, + [username, password_hash, name || null, department || null, department_id || null, role || 'user'] ); return findById(result.insertId); } @@ -120,6 +120,7 @@ async function update(userId, data) { if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); } if (data.department !== undefined) { fields.push('department = ?'); values.push(data.department); } + if (data.department_id !== undefined) { fields.push('department_id = ?'); values.push(data.department_id); } if (data.role !== undefined) { fields.push('role = ?'); values.push(data.role); } if (data.system1_access !== undefined) { fields.push('system1_access = ?'); values.push(data.system1_access); } if (data.system2_access !== undefined) { fields.push('system2_access = ?'); values.push(data.system2_access); } diff --git a/user-management/api/routes/permissionRoutes.js b/user-management/api/routes/permissionRoutes.js index c24d355..f9cd3d4 100644 --- a/user-management/api/routes/permissionRoutes.js +++ b/user-management/api/routes/permissionRoutes.js @@ -17,6 +17,13 @@ router.get('/check/:uid/:page', requireAuth, permissionController.checkAccess); // 설정 가능 페이지 목록 (auth) router.get('/available-pages', requireAuth, permissionController.getAvailablePages); +// 부서별 권한 (admin) +router.get('/departments/:deptId/permissions', requireAdmin, permissionController.getDepartmentPermissions); +router.post('/departments/:deptId/bulk-set', requireAdmin, permissionController.bulkSetDepartmentPermissions); + +// 출처 포함 사용자 권한 조회 (admin or self) +router.get('/users/:userId/effective-permissions', requireAuth, permissionController.getUserPermissionsWithSource); + // 권한 삭제 (admin) router.delete('/:id', requireAdmin, permissionController.deletePermission); diff --git a/user-management/migration-dept-permissions.sql b/user-management/migration-dept-permissions.sql new file mode 100644 index 0000000..7539130 --- /dev/null +++ b/user-management/migration-dept-permissions.sql @@ -0,0 +1,19 @@ +-- 부서별 페이지 권한 관리 기능 DB 마이그레이션 +-- NAS SSH 접속 후 MariaDB 컨테이너에서 실행 + +-- 1) sso_users에 department_id FK 추가 +ALTER TABLE sso_users + ADD COLUMN department_id INT NULL AFTER department, + ADD CONSTRAINT fk_sso_users_dept FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE SET NULL; + +-- 2) department_page_permissions 테이블 생성 +CREATE TABLE department_page_permissions ( + id INT AUTO_INCREMENT PRIMARY KEY, + department_id INT NOT NULL, + page_name VARCHAR(50) NOT NULL, + can_access BOOLEAN DEFAULT FALSE, + granted_by_id INT NULL, + granted_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY unique_dept_page (department_id, page_name), + FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE CASCADE +); diff --git a/user-management/web/index.html b/user-management/web/index.html index 98f6ef8..1c65c35 100644 --- a/user-management/web/index.html +++ b/user-management/web/index.html @@ -7,7 +7,7 @@ - + @@ -46,6 +46,9 @@ + @@ -86,13 +89,8 @@
- - - - - -
@@ -118,66 +116,9 @@
- -
-
-

페이지 접근 권한

- -
- - - -
- -

사용자를 선택하면 권한을 설정할 수 있습니다

-
-
+ + + +
@@ -723,13 +790,8 @@
- - - - - -
@@ -1208,18 +1270,18 @@
- + - + - - - - - - - - + + + + + + + + diff --git a/user-management/web/nginx.conf b/user-management/web/nginx.conf index f5d25f7..1fdd303 100644 --- a/user-management/web/nginx.conf +++ b/user-management/web/nginx.conf @@ -33,8 +33,8 @@ server { try_files $uri $uri/ /index.html; } - # 업로드 파일 프록시 - location /uploads/ { + # 업로드 파일 프록시 (^~ 로 regex location보다 우선 매칭) + location ^~ /uploads/ { proxy_pass http://tkuser-api:3000/uploads/; proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/user-management/web/static/js/tkuser-core.js b/user-management/web/static/js/tkuser-core.js index 226f32c..479c004 100644 --- a/user-management/web/static/js/tkuser-core.js +++ b/user-management/web/static/js/tkuser-core.js @@ -33,8 +33,21 @@ function showToast(msg, type = 'success') { } /* ===== Helpers ===== */ -const DEPT = { production:'생산', quality:'품질', purchasing:'구매', design:'설계', sales:'영업' }; -function deptLabel(d) { return DEPT[d] || d || ''; } +const DEPT_FALLBACK = { production:'생산', quality:'품질', purchasing:'구매', design:'설계', sales:'영업' }; +let departmentsCache = []; +async function loadDepartmentsCache() { + try { + const r = await api('/departments'); + departmentsCache = (r.data || r).filter(d => d.is_active !== 0 && d.is_active !== false); + } catch(e) { console.warn('부서 캐시 로드 실패:', e); } +} +function deptLabel(d, deptId) { + if (deptId && departmentsCache.length) { + const dept = departmentsCache.find(x => x.department_id === deptId); + if (dept) return dept.department_name; + } + return DEPT_FALLBACK[d] || d || ''; +} function formatDate(d) { if (!d) return ''; return d.substring(0, 10); } function escHtml(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } @@ -64,6 +77,7 @@ async function init() { if (currentUser.role === 'admin') { document.getElementById('tabNav').classList.remove('hidden'); document.getElementById('adminSection').classList.remove('hidden'); + await loadDepartmentsCache(); await loadUsers(); } else { document.getElementById('passwordChangeSection').classList.remove('hidden'); diff --git a/user-management/web/static/js/tkuser-departments.js b/user-management/web/static/js/tkuser-departments.js index 1aa90e0..cef1c76 100644 --- a/user-management/web/static/js/tkuser-departments.js +++ b/user-management/web/static/js/tkuser-departments.js @@ -24,11 +24,13 @@ function populateParentDeptSelects() { }); } +let selectedDeptForMembers = null; + function displayDepartments() { const c = document.getElementById('departmentList'); if (!departments.length) { c.innerHTML = '

등록된 부서가 없습니다.

'; return; } c.innerHTML = departments.map(d => ` -
+
${d.department_name}
@@ -37,13 +39,58 @@ function displayDepartments() { ${d.is_active === 0 || d.is_active === false ? '비활성' : '활성'}
-
+
${d.is_active !== 0 && d.is_active !== false ? `` : ''}
`).join(''); } +async function showDeptMembers(deptId) { + selectedDeptForMembers = deptId; + displayDepartments(); + const panel = document.getElementById('deptMembersPanel'); + const list = document.getElementById('deptMembersList'); + panel.classList.remove('hidden'); + list.innerHTML = '
'; + + let deptUsers = users; + if (!deptUsers || !deptUsers.length) { + try { + const r = await api('/users'); + deptUsers = r.data || r; + } catch (e) { + list.innerHTML = `

${e.message}

`; + return; + } + } + + const members = deptUsers.filter(u => u.department_id === deptId); + const dept = departments.find(d => d.department_id === deptId); + const title = panel.querySelector('h3'); + if (title) title.innerHTML = `소속 인원 — ${dept ? dept.department_name : ''}`; + + if (!members.length) { + list.innerHTML = '

소속 인원이 없습니다

'; + return; + } + + list.innerHTML = members.map(u => ` +
+
+
${(u.name || u.username).charAt(0)}
+
+
${u.name || u.username}
+
${u.username}
+
+
+
+ ${u.role === 'admin' ? '관리자' : '사용자'} + ${u.is_active === 0 || u.is_active === false ? '비활성' : '활성'} +
+
`).join(''); +} + document.getElementById('addDepartmentForm').addEventListener('submit', async e => { e.preventDefault(); try { diff --git a/user-management/web/static/js/tkuser-tabs.js b/user-management/web/static/js/tkuser-tabs.js index bf388d9..509dcf7 100644 --- a/user-management/web/static/js/tkuser-tabs.js +++ b/user-management/web/static/js/tkuser-tabs.js @@ -21,4 +21,5 @@ function switchTab(name) { if (name === 'workplaces' && !workplacesLoaded) loadWorkplaces(); if (name === 'tasks' && !tasksLoaded) loadTasksTab(); if (name === 'vacations' && !vacationsLoaded) loadVacationsTab(); + if (name === 'permissions' && !permissionsTabLoaded) loadPermissionsTab(); } diff --git a/user-management/web/static/js/tkuser-users.js b/user-management/web/static/js/tkuser-users.js index 718b329..47e7a93 100644 --- a/user-management/web/static/js/tkuser-users.js +++ b/user-management/web/static/js/tkuser-users.js @@ -56,18 +56,38 @@ const SYSTEM3_PAGES = { ] }; +/* ===== Permissions Tab State ===== */ +let permissionsTabLoaded = false; + +function loadPermissionsTab() { + populateDeptPermSelect(); + updatePermissionUserSelect(); + permissionsTabLoaded = true; +} + /* ===== Users State ===== */ -let users = [], selectedUserId = null, currentPermissions = {}; +let users = [], selectedUserId = null, currentPermissions = {}, currentPermSources = {}; /* ===== Users CRUD ===== */ async function loadUsers() { try { const r = await api('/users'); users = r.data || r; displayUsers(); updatePermissionUserSelect(); + populateUserDeptSelects(); } catch (err) { document.getElementById('userList').innerHTML = `

${err.message}

`; } } + +function populateUserDeptSelects() { + ['newDepartmentId','editDepartmentId'].forEach(id => { + const sel = document.getElementById(id); if (!sel) return; + const val = sel.value; + sel.innerHTML = ''; + departmentsCache.forEach(d => { const o = document.createElement('option'); o.value = d.department_id; o.textContent = d.department_name; sel.appendChild(o); }); + sel.value = val; + }); +} function displayUsers() { const c = document.getElementById('userList'); if (!users.length) { c.innerHTML = '

등록된 사용자가 없습니다.

'; return; } @@ -77,7 +97,7 @@ function displayUsers() {
${u.name||u.username}
${u.username} - ${u.department?`${deptLabel(u.department)}`:''} + ${u.department||u.department_id?`${deptLabel(u.department, u.department_id)}`:''} ${u.role==='admin'?'관리자':'사용자'} ${u.is_active===0||u.is_active===false?'비활성':''}
@@ -92,8 +112,9 @@ function displayUsers() { document.getElementById('addUserForm').addEventListener('submit', async e => { e.preventDefault(); + const deptIdVal = document.getElementById('newDepartmentId').value; try { - await api('/users', { method:'POST', body: JSON.stringify({ username: document.getElementById('newUsername').value.trim(), name: document.getElementById('newFullName').value.trim(), password: document.getElementById('newPassword').value, department: document.getElementById('newDepartment').value||null, role: document.getElementById('newRole').value }) }); + await api('/users', { method:'POST', body: JSON.stringify({ username: document.getElementById('newUsername').value.trim(), name: document.getElementById('newFullName').value.trim(), password: document.getElementById('newPassword').value, department_id: deptIdVal ? parseInt(deptIdVal) : null, role: document.getElementById('newRole').value }) }); showToast('사용자가 추가되었습니다.'); document.getElementById('addUserForm').reset(); await loadUsers(); } catch(e) { showToast(e.message,'error'); } }); @@ -101,15 +122,19 @@ document.getElementById('addUserForm').addEventListener('submit', async e => { function editUser(id) { const u = users.find(x=>x.user_id===id); if(!u) return; document.getElementById('editUserId').value=u.user_id; document.getElementById('editUsername').value=u.username; - document.getElementById('editFullName').value=u.name||''; document.getElementById('editDepartment').value=u.department||''; document.getElementById('editRole').value=u.role; + document.getElementById('editFullName').value=u.name||''; + populateUserDeptSelects(); + document.getElementById('editDepartmentId').value=u.department_id||''; + document.getElementById('editRole').value=u.role; document.getElementById('editUserModal').classList.remove('hidden'); } function closeEditModal() { document.getElementById('editUserModal').classList.add('hidden'); } document.getElementById('editUserForm').addEventListener('submit', async e => { e.preventDefault(); + const deptIdVal = document.getElementById('editDepartmentId').value; try { - await api(`/users/${document.getElementById('editUserId').value}`, { method:'PUT', body: JSON.stringify({ name: document.getElementById('editFullName').value.trim()||null, department: document.getElementById('editDepartment').value||null, role: document.getElementById('editRole').value }) }); + await api(`/users/${document.getElementById('editUserId').value}`, { method:'PUT', body: JSON.stringify({ name: document.getElementById('editFullName').value.trim()||null, department_id: deptIdVal ? parseInt(deptIdVal) : null, role: document.getElementById('editRole').value }) }); showToast('수정되었습니다.'); closeEditModal(); await loadUsers(); } catch(e) { showToast(e.message,'error'); } }); @@ -154,13 +179,18 @@ document.getElementById('permissionUserSelect').addEventListener('change', async }); async function loadUserPermissions(userId) { - // 기본값 세팅 currentPermissions = {}; + currentPermSources = {}; const allDefs = { ...SYSTEM1_PAGES, ...SYSTEM3_PAGES }; - Object.values(allDefs).flat().forEach(p => { currentPermissions[p.key] = p.def; }); + Object.values(allDefs).flat().forEach(p => { currentPermissions[p.key] = p.def; currentPermSources[p.key] = 'default'; }); try { - const perms = await api(`/users/${userId}/page-permissions`); - (Array.isArray(perms)?perms:[]).forEach(p => { currentPermissions[p.page_name] = !!p.can_access; }); + const result = await api(`/permissions/users/${userId}/effective-permissions`); + if (result.permissions) { + for (const [pageName, info] of Object.entries(result.permissions)) { + currentPermissions[pageName] = info.can_access; + currentPermSources[pageName] = info.source; + } + } } catch(e) { console.warn('권한 로드 실패:', e); } } @@ -169,6 +199,12 @@ function renderPermissionGrid() { renderSystemPerms('s3-perms', SYSTEM3_PAGES, 'purple'); } +function sourceLabel(src) { + if (src === 'explicit') return '개인'; + if (src === 'department') return '부서'; + return ''; +} + function renderSystemPerms(containerId, pageDef, color) { const container = document.getElementById(containerId); let html = ''; @@ -192,12 +228,14 @@ function renderSystemPerms(containerId, pageDef, color) {
${pages.map(p => { const checked = currentPermissions[p.key] || false; + const src = currentPermSources[p.key] || 'default'; return ` `; }).join('')}
@@ -268,6 +306,157 @@ document.getElementById('savePermissionsBtn').addEventListener('click', async () } finally { btn.disabled = false; btn.innerHTML = '권한 저장'; } }); +/* ===== Department Permissions ===== */ +let selectedDeptId = null, deptPermissions = {}; + +function populateDeptPermSelect() { + const sel = document.getElementById('deptPermSelect'); if (!sel) return; + sel.innerHTML = ''; + departmentsCache.forEach(d => { const o = document.createElement('option'); o.value = d.department_id; o.textContent = d.department_name; sel.appendChild(o); }); +} + +document.addEventListener('DOMContentLoaded', () => { + const sel = document.getElementById('deptPermSelect'); + if (sel) sel.addEventListener('change', async e => { + selectedDeptId = e.target.value; + if (selectedDeptId) { + await loadDeptPermissions(selectedDeptId); + renderDeptPermissionGrid(); + document.getElementById('deptPermPanel').classList.remove('hidden'); + document.getElementById('deptPermEmpty').classList.add('hidden'); + } else { + document.getElementById('deptPermPanel').classList.add('hidden'); + document.getElementById('deptPermEmpty').classList.remove('hidden'); + } + }); + + const saveBtn = document.getElementById('saveDeptPermBtn'); + if (saveBtn) saveBtn.addEventListener('click', saveDeptPermissions); + + const resetBtn = document.getElementById('resetToDefaultBtn'); + if (resetBtn) resetBtn.addEventListener('click', resetUserPermToDefault); +}); + +async function loadDeptPermissions(deptId) { + deptPermissions = {}; + const allDefs = { ...SYSTEM1_PAGES, ...SYSTEM3_PAGES }; + Object.values(allDefs).flat().forEach(p => { deptPermissions[p.key] = p.def; }); + try { + const result = await api(`/permissions/departments/${deptId}/permissions`); + (result.data || []).forEach(p => { deptPermissions[p.page_name] = !!p.can_access; }); + } catch(e) { console.warn('부서 권한 로드 실패:', e); } +} + +function renderDeptPermissionGrid() { + renderDeptSystemPerms('dept-s1-perms', SYSTEM1_PAGES, 'blue'); + renderDeptSystemPerms('dept-s3-perms', SYSTEM3_PAGES, 'purple'); +} + +function renderDeptSystemPerms(containerId, pageDef, color) { + const container = document.getElementById(containerId); + if (!container) return; + let html = ''; + Object.entries(pageDef).forEach(([groupName, pages]) => { + const groupId = containerId + '-' + groupName.replace(/\s/g,''); + const allChecked = pages.every(p => deptPermissions[p.key]); + html += ` +
+
+
+ + ${groupName} + ${pages.length} +
+ +
+
+ ${pages.map(p => { + const checked = deptPermissions[p.key] || false; + return ` + `; + }).join('')} +
+
`; + }); + container.innerHTML = html; +} + +function onDeptPermChange(cb) { + const item = cb.closest('.perm-item'); + const icon = item.querySelector('i[data-color]'); + const color = icon.dataset.color; + item.classList.toggle('checked', cb.checked); + icon.classList.toggle(`text-${color}-500`, cb.checked); + icon.classList.toggle('text-gray-400', !cb.checked); + const group = item.dataset.group; + const groupCbs = document.querySelectorAll(`[data-group="${group}"] input[type="checkbox"]`); + const allChecked = [...groupCbs].every(c => c.checked); + const groupHeader = document.getElementById(group)?.previousElementSibling; + if (groupHeader) { const gc = groupHeader.querySelector('input[type="checkbox"]'); if(gc) gc.checked = allChecked; } +} + +function toggleDeptGroupAll(groupId, checked) { + document.querySelectorAll(`#${groupId} input[type="checkbox"]`).forEach(cb => { + cb.checked = checked; + onDeptPermChange(cb); + }); +} + +function toggleDeptSystemAll(prefix, checked) { + const containerId = prefix === 's1' ? 'dept-s1-perms' : 'dept-s3-perms'; + document.querySelectorAll(`#${containerId} input[type="checkbox"]`).forEach(cb => { + cb.checked = checked; + onDeptPermChange(cb); + }); + document.querySelectorAll(`#${containerId} .group-header input[type="checkbox"]`).forEach(cb => cb.checked = checked); +} + +async function saveDeptPermissions() { + if (!selectedDeptId) return; + const btn = document.getElementById('saveDeptPermBtn'); + const st = document.getElementById('deptPermSaveStatus'); + btn.disabled = true; btn.innerHTML = '저장 중...'; + + try { + const allPages = [...Object.values(SYSTEM1_PAGES).flat(), ...Object.values(SYSTEM3_PAGES).flat()]; + const permissions = allPages.map(p => { + const cb = document.getElementById('dperm_' + p.key); + return { page_name: p.key, can_access: cb ? cb.checked : false }; + }); + await api(`/permissions/departments/${selectedDeptId}/bulk-set`, { method:'POST', body: JSON.stringify({ permissions }) }); + st.textContent = '저장 완료'; st.className = 'text-sm text-emerald-600'; + showToast('부서 권한이 저장되었습니다.'); + setTimeout(() => { st.textContent = ''; }, 3000); + } catch(e) { + st.textContent = e.message; st.className = 'text-sm text-red-500'; + showToast('저장 실패: ' + e.message, 'error'); + } finally { btn.disabled = false; btn.innerHTML = '부서 권한 저장'; } +} + +async function resetUserPermToDefault() { + if (!selectedUserId) return; + if (!confirm('이 사용자의 개인 권한을 모두 삭제하고 부서 기본 권한으로 되돌리시겠습니까?')) return; + try { + const perms = await api(`/users/${selectedUserId}/page-permissions`); + const permArr = Array.isArray(perms) ? perms : []; + for (const p of permArr) { + await api(`/permissions/${p.id}`, { method: 'DELETE' }); + } + showToast('부서 기본 권한으로 초기화되었습니다.'); + await loadUserPermissions(selectedUserId); + renderPermissionGrid(); + } catch(e) { showToast('초기화 실패: ' + e.message, 'error'); } +} + /* ===== Workers CRUD ===== */ let workers = [], workersLoaded = false, departmentsForSelect = [];