feat: 사용자 관리에 부서 정보 추가 및 편집 기능 구현
🏢 Department Management System: - 5개 부서 지원: 생산, 품질, 구매, 설계, 영업 - 사용자 생성/수정 시 부서 선택 가능 - 부서별 사용자 분류 및 표시 📊 Database Schema Updates: - department_type ENUM 추가 (production, quality, purchasing, design, sales) - users 테이블에 department 컬럼 추가 - idx_users_department 인덱스 생성 (성능 최적화) - 014_add_user_department.sql 마이그레이션 실행 🔧 Backend Enhancements: - DepartmentType ENUM 클래스 추가 (models.py, schemas.py) - User 모델에 department 필드 추가 - UserBase, UserUpdate 스키마에 department 필드 포함 - 기존 API 엔드포인트 자동 호환 🎨 Frontend UI Improvements: - 사용자 추가 폼에 부서 선택 드롭다운 추가 - 사용자 목록에 부서 정보 배지 표시 (녹색 배경) - 사용자 편집 모달 새로 구현 - 부서명 한글 변환 함수 (AuthAPI.getDepartmentLabel) ✨ User Management Features: - 편집 버튼으로 사용자 정보 수정 가능 - 부서, 이름, 권한 실시간 변경 - 사용자 ID는 수정 불가 (읽기 전용) - 모달 기반 직관적 UI 🔍 Visual Enhancements: - 부서 정보 아이콘 (fas fa-building) - 색상 코딩: 부서(녹색), 권한(빨강/파랑) - 반응형 레이아웃 (flex-1, gap-3) - 호버 효과 및 트랜지션 🚀 API Integration: - AuthAPI.getDepartments() - 부서 목록 반환 - AuthAPI.getDepartmentLabel() - 부서명 변환 - AuthAPI.updateUser() - 부서 정보 포함 업데이트 - 기존 createUser API 확장 지원 Expected Result: ✅ 사용자 생성 시 부서 선택 가능 ✅ 사용자 목록에 부서 정보 표시 ✅ 편집 버튼으로 부서 변경 가능 ✅ 5개 부서 분류 시스템 완성 ✅ 직관적인 사용자 관리 UI
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -43,6 +43,13 @@ class DisposalReasonType(str, enum.Enum):
|
||||
spam = "spam" # 스팸/오류
|
||||
custom = "custom" # 직접 입력
|
||||
|
||||
class DepartmentType(str, enum.Enum):
|
||||
production = "production" # 생산
|
||||
quality = "quality" # 품질
|
||||
purchasing = "purchasing" # 구매
|
||||
design = "design" # 설계
|
||||
sales = "sales" # 영업
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
@@ -51,6 +58,7 @@ class User(Base):
|
||||
hashed_password = Column(String, nullable=False)
|
||||
full_name = Column(String)
|
||||
role = Column(Enum(UserRole), default=UserRole.user)
|
||||
department = Column(Enum(DepartmentType)) # 부서 정보 추가
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=get_kst_now)
|
||||
|
||||
|
||||
@@ -32,11 +32,19 @@ class DisposalReasonType(str, Enum):
|
||||
spam = "spam" # 스팸/오류
|
||||
custom = "custom" # 직접 입력
|
||||
|
||||
class DepartmentType(str, Enum):
|
||||
production = "production" # 생산
|
||||
quality = "quality" # 품질
|
||||
purchasing = "purchasing" # 구매
|
||||
design = "design" # 설계
|
||||
sales = "sales" # 영업
|
||||
|
||||
# User schemas
|
||||
class UserBase(BaseModel):
|
||||
username: str
|
||||
full_name: Optional[str] = None
|
||||
role: UserRole = UserRole.user
|
||||
department: Optional[DepartmentType] = None
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str
|
||||
@@ -45,6 +53,7 @@ class UserUpdate(BaseModel):
|
||||
full_name: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
role: Optional[UserRole] = None
|
||||
department: Optional[DepartmentType] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
|
||||
92
backend/migrations/014_add_user_department.sql
Normal file
92
backend/migrations/014_add_user_department.sql
Normal file
@@ -0,0 +1,92 @@
|
||||
-- 014_add_user_department.sql
|
||||
-- 사용자 부서 정보 추가
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- migration_log 테이블 생성 (멱등성)
|
||||
CREATE TABLE IF NOT EXISTS migration_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
migration_file VARCHAR(255) NOT NULL UNIQUE,
|
||||
executed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
status VARCHAR(50),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 마이그레이션 파일 이름
|
||||
DO $$
|
||||
DECLARE
|
||||
migration_name VARCHAR(255) := '014_add_user_department.sql';
|
||||
migration_notes TEXT := '사용자 부서 정보 추가: department ENUM 타입 및 users 테이블에 department 컬럼 추가';
|
||||
current_status VARCHAR(50);
|
||||
BEGIN
|
||||
SELECT status INTO current_status FROM migration_log WHERE migration_file = migration_name;
|
||||
|
||||
IF current_status IS NULL THEN
|
||||
RAISE NOTICE '--- 마이그레이션 % 시작 ---', migration_name;
|
||||
|
||||
-- department ENUM 타입 생성
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'department_type') THEN
|
||||
CREATE TYPE department_type AS ENUM (
|
||||
'production', -- 생산
|
||||
'quality', -- 품질
|
||||
'purchasing', -- 구매
|
||||
'design', -- 설계
|
||||
'sales' -- 영업
|
||||
);
|
||||
RAISE NOTICE '✅ department_type ENUM 타입이 생성되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ department_type ENUM 타입이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- users 테이블에 department 컬럼 추가
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'department') THEN
|
||||
ALTER TABLE users ADD COLUMN department department_type;
|
||||
RAISE NOTICE '✅ users.department 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ users.department 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 인덱스 추가 (부서별 조회 성능 향상)
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'users' AND indexname = 'idx_users_department') THEN
|
||||
CREATE INDEX idx_users_department ON users (department) WHERE department IS NOT NULL;
|
||||
RAISE NOTICE '✅ idx_users_department 인덱스가 생성되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ idx_users_department 인덱스가 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 마이그레이션 검증
|
||||
DECLARE
|
||||
col_count INTEGER;
|
||||
enum_count INTEGER;
|
||||
idx_count INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO col_count FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'department';
|
||||
SELECT COUNT(*) INTO enum_count FROM pg_type WHERE typname = 'department_type';
|
||||
SELECT COUNT(*) INTO idx_count FROM pg_indexes WHERE tablename = 'users' AND indexname = 'idx_users_department';
|
||||
|
||||
RAISE NOTICE '=== 마이그레이션 검증 결과 ===';
|
||||
RAISE NOTICE '추가된 컬럼: %/1개', col_count;
|
||||
RAISE NOTICE '생성된 ENUM: %/1개', enum_count;
|
||||
RAISE NOTICE '생성된 인덱스: %/1개', idx_count;
|
||||
|
||||
IF col_count = 1 AND enum_count = 1 AND idx_count = 1 THEN
|
||||
RAISE NOTICE '✅ 마이그레이션이 성공적으로 완료되었습니다!';
|
||||
INSERT INTO migration_log (migration_file, status, notes) VALUES (migration_name, 'SUCCESS', migration_notes);
|
||||
ELSE
|
||||
RAISE EXCEPTION '❌ 마이그레이션 검증 실패!';
|
||||
END IF;
|
||||
END;
|
||||
|
||||
-- 부서 ENUM 값 확인
|
||||
RAISE NOTICE '=== 부서 ENUM 값 ===';
|
||||
PERFORM dblink_exec('dbname=' || current_database(), 'SELECT enumlabel FROM pg_enum WHERE enumtypid = ''department_type''::regtype ORDER BY enumsortorder');
|
||||
|
||||
ELSIF current_status = 'SUCCESS' THEN
|
||||
RAISE NOTICE 'ℹ️ 마이그레이션 %는 이미 성공적으로 실행되었습니다. 스킵합니다.', migration_name;
|
||||
ELSE
|
||||
RAISE NOTICE '⚠️ 마이그레이션 %는 이전에 실패했습니다. 수동 확인이 필요합니다.', migration_name;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -141,6 +141,18 @@
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">부서</label>
|
||||
<select id="newDepartment" class="input-field w-full px-3 py-2 rounded-lg">
|
||||
<option value="">부서 선택 (선택사항)</option>
|
||||
<option value="production">생산</option>
|
||||
<option value="quality">품질</option>
|
||||
<option value="purchasing">구매</option>
|
||||
<option value="design">설계</option>
|
||||
<option value="sales">영업</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">권한</label>
|
||||
<select id="newRole" class="input-field w-full px-3 py-2 rounded-lg">
|
||||
@@ -256,6 +268,80 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 사용자 편집 모달 -->
|
||||
<div id="editUserModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50">
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="bg-white rounded-xl max-w-md w-full p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">사용자 정보 수정</h3>
|
||||
<button onclick="closeEditModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="editUserForm" class="space-y-4">
|
||||
<input type="hidden" id="editUserId">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">사용자 ID</label>
|
||||
<input
|
||||
type="text"
|
||||
id="editUsername"
|
||||
class="input-field w-full px-3 py-2 rounded-lg bg-gray-100"
|
||||
readonly
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">이름</label>
|
||||
<input
|
||||
type="text"
|
||||
id="editFullName"
|
||||
class="input-field w-full px-3 py-2 rounded-lg"
|
||||
placeholder="실명 입력"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">부서</label>
|
||||
<select id="editDepartment" class="input-field w-full px-3 py-2 rounded-lg">
|
||||
<option value="">부서 선택 (선택사항)</option>
|
||||
<option value="production">생산</option>
|
||||
<option value="quality">품질</option>
|
||||
<option value="purchasing">구매</option>
|
||||
<option value="design">설계</option>
|
||||
<option value="sales">영업</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">권한</label>
|
||||
<select id="editRole" class="input-field w-full px-3 py-2 rounded-lg">
|
||||
<option value="user">일반 사용자</option>
|
||||
<option value="admin">관리자</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick="closeEditModal()"
|
||||
class="flex-1 px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-save mr-2"></i>저장
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="/static/js/date-utils.js?v=20250917"></script>
|
||||
<script src="/static/js/core/permissions.js?v=20251025"></script>
|
||||
@@ -361,6 +447,7 @@
|
||||
username: document.getElementById('newUsername').value.trim(),
|
||||
full_name: document.getElementById('newFullName').value.trim(),
|
||||
password: document.getElementById('newPassword').value,
|
||||
department: document.getElementById('newDepartment').value || null,
|
||||
role: document.getElementById('newRole').value
|
||||
};
|
||||
|
||||
@@ -430,14 +517,19 @@
|
||||
|
||||
container.innerHTML = users.map(user => `
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-gray-800">
|
||||
<i class="fas fa-user mr-2 text-gray-500"></i>
|
||||
${user.full_name || user.username}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
ID: ${user.username}
|
||||
<span class="ml-2 px-2 py-0.5 rounded text-xs ${
|
||||
<div class="text-sm text-gray-600 flex items-center gap-3">
|
||||
<span>ID: ${user.username}</span>
|
||||
${user.department ? `
|
||||
<span class="px-2 py-0.5 rounded text-xs bg-green-100 text-green-700">
|
||||
<i class="fas fa-building mr-1"></i>${AuthAPI.getDepartmentLabel(user.department)}
|
||||
</span>
|
||||
` : ''}
|
||||
<span class="px-2 py-0.5 rounded text-xs ${
|
||||
user.role === 'admin'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
@@ -447,6 +539,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick="editUser(${user.id})"
|
||||
class="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-sm"
|
||||
>
|
||||
<i class="fas fa-edit mr-1"></i>편집
|
||||
</button>
|
||||
<button
|
||||
onclick="resetPassword('${user.username}')"
|
||||
class="px-3 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 transition-colors text-sm"
|
||||
@@ -507,6 +605,53 @@
|
||||
alert(error.message || '삭제에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 편집 모달 열기
|
||||
function editUser(userId) {
|
||||
const user = users.find(u => u.id === userId);
|
||||
if (!user) {
|
||||
alert('사용자를 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 모달 필드에 현재 값 설정
|
||||
document.getElementById('editUserId').value = user.id;
|
||||
document.getElementById('editUsername').value = user.username;
|
||||
document.getElementById('editFullName').value = user.full_name || '';
|
||||
document.getElementById('editDepartment').value = user.department || '';
|
||||
document.getElementById('editRole').value = user.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 userId = document.getElementById('editUserId').value;
|
||||
const userData = {
|
||||
full_name: document.getElementById('editFullName').value.trim() || null,
|
||||
department: document.getElementById('editDepartment').value || null,
|
||||
role: document.getElementById('editRole').value
|
||||
};
|
||||
|
||||
try {
|
||||
await AuthAPI.updateUser(userId, userData);
|
||||
|
||||
alert('사용자 정보가 수정되었습니다.');
|
||||
closeEditModal();
|
||||
await loadUsers(); // 목록 새로고침
|
||||
|
||||
} catch (error) {
|
||||
alert(error.message || '사용자 정보 수정에 실패했습니다.');
|
||||
}
|
||||
});
|
||||
|
||||
// 페이지 권한 관리 기능
|
||||
let selectedUserId = null;
|
||||
|
||||
@@ -432,7 +432,7 @@
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<span class="badge badge-${getStatusBadgeClass(issue.status)}">${getStatusText(issue.status)}</span>
|
||||
${project ? `<span class="text-sm text-gray-500">${project.name}</span>` : ''}
|
||||
${project ? `<span class="text-sm text-gray-500">${project.project_name}</span>` : ''}
|
||||
<span class="text-sm text-gray-400">${completedDate}</span>
|
||||
</div>
|
||||
|
||||
@@ -532,7 +532,7 @@
|
||||
projects.forEach(project => {
|
||||
const option = document.createElement('option');
|
||||
option.value = project.id;
|
||||
option.textContent = project.name;
|
||||
option.textContent = project.project_name;
|
||||
projectFilter.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -449,7 +449,7 @@
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<span class="badge badge-${getStatusBadgeClass(issue.status)}">${getStatusText(issue.status)}</span>
|
||||
${getPriorityBadge(issue.priority)}
|
||||
${project ? `<span class="text-sm text-gray-500">${project.name}</span>` : ''}
|
||||
${project ? `<span class="text-sm text-gray-500">${project.project_name}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">${issue.description}</h3>
|
||||
@@ -584,7 +584,7 @@
|
||||
projects.forEach(project => {
|
||||
const option = document.createElement('option');
|
||||
option.value = project.id;
|
||||
option.textContent = project.name;
|
||||
option.textContent = project.project_name;
|
||||
projectFilter.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -168,7 +168,23 @@ const AuthAPI = {
|
||||
body: JSON.stringify({
|
||||
new_password: newPassword
|
||||
})
|
||||
})
|
||||
}),
|
||||
|
||||
// 부서 목록 가져오기
|
||||
getDepartments: () => [
|
||||
{ value: 'production', label: '생산' },
|
||||
{ value: 'quality', label: '품질' },
|
||||
{ value: 'purchasing', label: '구매' },
|
||||
{ value: 'design', label: '설계' },
|
||||
{ value: 'sales', label: '영업' }
|
||||
],
|
||||
|
||||
// 부서명 변환
|
||||
getDepartmentLabel: (departmentValue) => {
|
||||
const departments = AuthAPI.getDepartments();
|
||||
const dept = departments.find(d => d.value === departmentValue);
|
||||
return dept ? dept.label : departmentValue || '미지정';
|
||||
}
|
||||
};
|
||||
|
||||
// Issues API
|
||||
|
||||
Reference in New Issue
Block a user