사요자 대시보드 추가
This commit is contained in:
+60
-8
@@ -13,7 +13,7 @@
|
||||
프로젝트의 코드를 작성, 테스트 또는 검증할 환경 명확화 및 호환 기준 제시.
|
||||
|
||||
### A. 백엔드 프레임워크 (Python)
|
||||
* **Runtime:** Python v3.12 or v3.13.7
|
||||
* **Runtime:** Python v3.12.7
|
||||
* **Framework:** FastAPI / Pydantic
|
||||
* **Geometry/GIS Engine:** Trimesh, Whitebox, Geopandas, Shapely, Rasterio, Laspy
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
* *Python 포맷팅 명령어:* `ruff format [파일명]` 및 `ruff check --fix [파일명]` 실행
|
||||
* *TypeScript/CSS 포맷팅 명령어:* `npx prettier --write [파일명]` 실행
|
||||
* **외부 라이브러리 활용:** 외부 라이브러리 활용 시 프로젝트에 맞춰 호환 라이브러리 선정하고, 불필요한 함수는 작성하지 않도록 최소화.
|
||||
* **서버실행은 사용자가 실행:** 필요한 경우 의존성 설치까지는 OK, 단 서버실행은 사용자가 진행
|
||||
|
||||
---
|
||||
|
||||
@@ -98,6 +99,33 @@ storage/{company_slug}/{user_slug}/{project_id}/
|
||||
- **경로 저장 예:** `{"type": "LineString", "coordinates": [[127.5, 37.5], [127.6, 37.6]]}`
|
||||
- **애플리케이션 처리:** Python의 Shapely, Geopandas에서 JSON 파싱 후 기하 연산
|
||||
|
||||
### 4.5 역할 기반 접근 제어 (RBAC - Role-Based Access Control)
|
||||
|
||||
**3가지 사용자 역할:**
|
||||
|
||||
| 역할 | 영문 | 설명 | 접근 권한 |
|
||||
|------|------|------|---------|
|
||||
| 시스템 관리자 | SYSTEM_ADMIN | 최고 관리자 | 전체 회사/사용자/프로젝트 조회 및 관리 |
|
||||
| 회사 관리자 | ADMIN | 회사의 주요 아이디 (초기 로그인) | 회사 내 팀원/프로젝트 관리, 가입 요청 승인 |
|
||||
| 일반 사용자 | USER | 팀원 | 개인 프로젝트 생성/수정, 워크플로우 진행 |
|
||||
|
||||
**회사 마스터 (is_master) 플래그:**
|
||||
- TRUE: 회사 생성자 또는 ADMIN 역할 (팀원 승인/거부 권한)
|
||||
- FALSE: 일반 팀원
|
||||
|
||||
**사용자 상태 생명주기:**
|
||||
```
|
||||
PENDING_EMAIL (임시)
|
||||
↓ (이메일 인증)
|
||||
NO_COMPANY (로그인 가능, 회사 미연결)
|
||||
↓ (회사 생성 또는 참여 신청)
|
||||
PENDING (승인 대기) 또는 ACTIVE (회사 연결 완료)
|
||||
↓
|
||||
ACTIVE (모든 기능 접근)
|
||||
↓ (퇴사/비활성화)
|
||||
INACTIVE 또는 REJECTED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 워크플로우 6단계 (6-Stage Workflow)
|
||||
@@ -123,12 +151,33 @@ WF6: B09_wf6_Estimation (견적·문서)
|
||||
|
||||
## 6. 주요 문서 참고 순서
|
||||
|
||||
1. **구조 설계:** `.agent/structure.md` 읽기
|
||||
2. **프론트 구현:** `.agent/frontend.md` 읽기
|
||||
3. **백엔드 구현:** `.agent/backend.md` 읽기
|
||||
4. **마이그레이션:** `.agent/migration_plan.md` 읽기
|
||||
5. **DB 스키마:** `.agent/db_schema_simple.md` 읽기
|
||||
6. **프로젝트 정보:** `.agent/project_info.md` 읽기
|
||||
1. **계획서 읽기:** 구현 전 반드시 읽을 문서
|
||||
- `.agent/plan_register_split.md` — B01_Dashboard 설계 및 역할별 기능
|
||||
- `.agent/plan_register_split_validation.md` — 검증 항목 및 테스트 체크리스트
|
||||
|
||||
2. **구조 설계:** `.agent/structure.md` 읽기
|
||||
- 폴더/파일 명명 규칙
|
||||
- 전체 디렉토리 트리 구조
|
||||
|
||||
3. **DB 스키마:** `.agent/db_schema.md` 읽기
|
||||
- 사용자/회사/조직 테이블 (5개)
|
||||
- 프로젝트 및 워크플로우 테이블
|
||||
- 테이블 관계도
|
||||
|
||||
4. **백엔드 구현:** `.agent/backend.md` 읽기
|
||||
- 아키텍처 및 라우팅
|
||||
- DB 및 MariaDB 설정
|
||||
- 인증 시스템 (로그인/역할/인가)
|
||||
- 역할 기반 접근 제어 (RBAC)
|
||||
|
||||
5. **프론트 구현:** `.agent/frontend.md` 읽기
|
||||
- UI 요소 및 레이아웃 제약
|
||||
- 인증 및 라우팅 가드
|
||||
- 역할 기반 대시보드 렌더링
|
||||
|
||||
6. **마이그레이션:** `.agent/migration_plan.md` 읽기 (필요 시)
|
||||
|
||||
7. **프로젝트 정보:** `.agent/project_info.md` 읽기 (필요 시)
|
||||
|
||||
---
|
||||
|
||||
@@ -144,5 +193,8 @@ WF6: B09_wf6_Estimation (견적·문서)
|
||||
| **Frontend** | TypeScript / Node.js |
|
||||
| **드라이버** | aiomysql |
|
||||
| **워크플로우** | 6단계 (WF0~WF6) |
|
||||
| **테이블 수** | 18개 |
|
||||
| **테이블 수** | 23개 (조직/인증 5개 포함) |
|
||||
| **폴더 구조** | 워크플로우 기반 (B03~B09) |
|
||||
| **인증 방식** | HttpOnly Secure 세션 쿠키 |
|
||||
| **사용자 역할** | SYSTEM_ADMIN, ADMIN, USER (3가지) |
|
||||
| **대시보드** | B01_Dashboard (역할별 동적 렌더링) |
|
||||
|
||||
+65
-11
@@ -124,7 +124,7 @@
|
||||
* **방식:** Secure HttpOnly 세션 쿠키 (JWT 아님)
|
||||
* **쿠키 이름:** `session_id`
|
||||
* **유효 시간:** 12시간
|
||||
* **Secure 플래그:** true (HTTPS만 전송)
|
||||
* **Secure 플래그:** true (HTTPS만 전송, 개발 환경 localhost에서는 false)
|
||||
* **HttpOnly 플래그:** true (JavaScript 접근 불가)
|
||||
* **SameSite:** Lax (CSRF 방지)
|
||||
* **저장소:** MariaDB `sessions` 테이블
|
||||
@@ -135,6 +135,7 @@
|
||||
→ 세션 자동 만료
|
||||
```
|
||||
* **만료 처리:** 프론트엔드는 만료 5분 전 알림, 만료 후 모든 요청 거부 (401 Unauthorized)
|
||||
* **DB 풀 설정:** `autocommit=True` (pooled 커넥션의 트랜잭션 스냅샷 문제 방지 — 새 세션 INSERT 후 다른 커넥션에서 즉시 조회 가능)
|
||||
|
||||
### 6.4 로그인 이력 기록 (Audit Log)
|
||||
* **기록 항목:**
|
||||
@@ -143,7 +144,26 @@
|
||||
* **무차별 대입 방지:** 5회 이상 실패 시 계정 15분 잠금
|
||||
* **IP 주소:** 법적 이슈로 수집하지 않음 (권장)
|
||||
|
||||
### 6.5 권한 체크 (Authorization)
|
||||
### 6.4.1 사용자 상태 생명주기 (User Status Lifecycle)
|
||||
* **PENDING_EMAIL:** 회원가입 후 이메일 인증 전 (임시)
|
||||
* **NO_COMPANY:** 이메일 인증 완료, 회사 미연결 (로그인 가능, B02~B11 워크플로우 차단)
|
||||
* **PENDING:** 회사 참여 신청 후 마스터 승인 대기 (로그인 가능, B02~B11 워크플로우 차단)
|
||||
* **ACTIVE:** 회사 연결 완료, 모든 기능 접근 가능
|
||||
* **INACTIVE:** 퇴사/계정 휴활성화
|
||||
* **REJECTED:** 회사 참여 신청 거부
|
||||
|
||||
### 6.5 회사 연결 필수화 (Mandatory Company Linking)
|
||||
* **접근 제어:**
|
||||
- B02~B11 워크플로우 라우터에 `Depends(require_company)` 의존성 추가
|
||||
- `session["company_id"] is None` 이면 403 Forbidden 반환 (`"회사 연결이 필요합니다."`)
|
||||
* **라우팅 가드 (프론트):**
|
||||
- B02~B11 진입 시 `fetchSessionUser()` → `company_id` 확인
|
||||
- 없으면 B01_ACCOUNT로 리다이렉트
|
||||
* **회사 생성/연결:**
|
||||
- `POST /api/account/company/create`: 신규 회사 생성 후 사용자를 MASTER로 자동 연결 (status='ACTIVE')
|
||||
- `POST /api/account/company/join`: 기존 회사 참여 신청 (status='PENDING', 마스터 승인 필요)
|
||||
|
||||
### 6.6 권한 체크 (Authorization)
|
||||
* **마스터 권한:**
|
||||
- 팀원 목록 조회, 승인/거부, 내보내기
|
||||
- 회사 정보 수정, 구독 관리
|
||||
@@ -158,22 +178,56 @@
|
||||
- 관리자 대시보드 (모니터링, Q&A 관리)
|
||||
* **검증 방식:** 라우터 진입 전 JWT/토큰 검증, 필요시 데이터베이스에서 권한 확인
|
||||
|
||||
### 6.6 3개월 주기 재인증 (Periodic Re-authentication)
|
||||
* **목적:** 재직 여부 확인 (퇴사자 자동 차단)
|
||||
* **방식:** 3개월마다 이메일 OTP 재인증 강제
|
||||
* **구현:** `users.last_email_verified_at` 컬럼으로 추적
|
||||
* **예외:** 새로운 기기에서 로그인 시 즉시 재인증 요구
|
||||
### 6.7 로그인 기반 인증 갱신 (Login-based Auth Renewal)
|
||||
* **목적:** 활성 사용자의 인증 유지, 비활성 사용자 자동 차단
|
||||
* **방식:** 로그인 시 인증 유효 기간 자동 연장 (3개월)
|
||||
* **구현:**
|
||||
- `users.last_login` → 마지막 로그인 시간 업데이트
|
||||
- `users.auth_expires_at` → 매 로그인 시 NOW() + 3개월로 설정
|
||||
* **인증 확인:**
|
||||
- auth_expires_at > NOW() → 인증 유효 (바로 대시보드 이동)
|
||||
- auth_expires_at <= NOW() → 재인증 모달 표시
|
||||
* **장점:**
|
||||
- 활동적인 사용자: 지속적으로 인증 유지
|
||||
- 휴면 사용자: 자동으로 재인증 요구
|
||||
- 일관된 정책: 고정 3개월이 아닌 활동 기반 연장
|
||||
|
||||
### 6.7 이메일 발송 (Email Infrastructure)
|
||||
### 6.8 역할 기반 접근 제어 (Role-Based Access Control)
|
||||
|
||||
**사용자 역할 (users.role)**
|
||||
- `SYSTEM_ADMIN`: 시스템 관리자 (최고 권한)
|
||||
- `ADMIN`: 회사 관리자 (회사 내 관리권 한정)
|
||||
- `USER`: 일반 사용자
|
||||
|
||||
**마스터 플래그 (users.is_master)**
|
||||
- `TRUE`: 회사 생성자 또는 ADMIN 역할 (팀원 승인/거부 권한)
|
||||
- `FALSE`: 일반 팀원
|
||||
|
||||
**접근 제어 규칙:**
|
||||
|
||||
| 기능 | SYSTEM_ADMIN | ADMIN | USER |
|
||||
|------|-------------|-------|------|
|
||||
| 전체 회사 조회 | ✅ | ✗ | ✗ |
|
||||
| 전체 사용자 조회 | ✅ | ✗ | ✗ |
|
||||
| 사용자 역할 변경 | ✅ | ✗ | ✗ |
|
||||
| 회사 생성 | ✅ | ✅ | ✅ |
|
||||
| 회사 팀원 관리 | ✅ | ✅ (자신 회사만) | ✗ |
|
||||
| 가입 요청 승인 | ✅ | ✅ (자신 회사만) | ✗ |
|
||||
| 시스템 로그 조회 | ✅ | ✗ | ✗ |
|
||||
| 프로젝트 생성 | ✅ | ✅ | ✅ |
|
||||
| 프로젝트 조회 | ✅ (전체) | ✅ (회사별) | ✅ (개인) |
|
||||
|
||||
### 6.9 이메일 발송 (Email Infrastructure)
|
||||
* **SMTP 설정:**
|
||||
- **개발:** Google Gmail SMTP (umsangdon@gmail.com)
|
||||
- **상용화:** Google Workspace 또는 AWS SES로 전환
|
||||
* **발송 유형:**
|
||||
- OTP 인증 코드
|
||||
- 가입 확인 메일
|
||||
- 팀원 가입 승인 요청 (마스터 수신)
|
||||
- 팀원 승인/거부 결과
|
||||
- 3개월 재인증 알림
|
||||
- 팀원 가입 승인 요청 (ADMIN 수신)
|
||||
- 팀원 승인/거부 결과 (사용자에게)
|
||||
- 회사 참여 신청 알림 (SYSTEM_ADMIN 수신)
|
||||
- 로그인 기반 인증 갱신 알림
|
||||
* **구현:** `common_util/common_util_email.py` 모듈
|
||||
- 비동기 발송 (asyncio.create_task)
|
||||
- 재시도 로직 (최대 3회)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# 회원가입 플로우 개편: 완료된 항목들
|
||||
|
||||
**마지막 업데이트:** 2026-07-08
|
||||
**상태:** 회원가입 축소, 세션 쿠키 인증, 로그인 후 B01 리다이렉트 완료
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완료된 구현 항목
|
||||
|
||||
### 1. DB 변경
|
||||
- ✅ `db_management/003_register_split.sql` 생성
|
||||
- `users.status` ENUM에 `'NO_COMPANY'` 추가
|
||||
- 회원가입 시 회사 정보 NULL로 시작하도록 변경
|
||||
|
||||
### 2. 백엔드 변경 (A07_Register, A06_Login)
|
||||
- ✅ `A07_Register_Schema.py` 수정
|
||||
- `RegisterRequest` 필드: `email`, `password`, `password_confirm`, `name`, `position`, `phone`, `terms_agreed`, `privacy_agreed`, `marketing_agreed`, `terms_version`
|
||||
- 회사 관련 필드 전부 제거
|
||||
- `password_confirm` 검증 추가
|
||||
|
||||
- ✅ `common_util_auth_repository.py` 수정
|
||||
- `create_registration()`: 회사/join_request 생성 로직 제거, users INSERT만 수행
|
||||
- `complete_registration()`: 무조건 `status = 'NO_COMPANY'`로 변경
|
||||
|
||||
- ✅ `A07_Register_Router.py` 수정
|
||||
- `request_registration()`: `password_confirm` 제외 처리
|
||||
|
||||
- ✅ `A06_Login_Router.py` 수정
|
||||
- `request_login()`: `status not in ("ACTIVE", "NO_COMPANY")` 체크로 변경
|
||||
- `current_session()`: 응답에 `company_id`, `is_master` 추가
|
||||
|
||||
- ✅ `common_util_auth.py` 수정
|
||||
- `require_company()` dependency 추가
|
||||
|
||||
- ✅ `main.py` 수정
|
||||
- B03~B06 라우터에 `require_company` 의존성 추가
|
||||
|
||||
- ✅ `config_db.py` 수정
|
||||
- `autocommit=True` 설정 (pooled 커넥션 트랜잭션 스냅샷 이슈 해결)
|
||||
|
||||
### 3. 리소스 파일
|
||||
- ✅ `resources/legal/terms_of_service.txt` 생성
|
||||
- ✅ `resources/legal/privacy_policy.txt` 생성
|
||||
- ✅ `resources/legal/marketing_consent.txt` 생성
|
||||
|
||||
### 4. 프론트엔드 변경 (A07_Register, A06_Login, Router)
|
||||
- ✅ `A07_Register_Api_Fetch.ts` 수정
|
||||
- `RegisterPayload` 필드 축소
|
||||
|
||||
- ✅ `A07_Register_UI_Auth_Page.ts` 전면 재작성
|
||||
- 필드: name, position, email, phone, password, passwordConfirm, terms/privacy/marketing (아코디언)
|
||||
- 약관 텍스트 파일 로드 기능
|
||||
- OTP 입력 창 기존 로직 유지
|
||||
|
||||
- ✅ `A00_Common/router.ts` 수정
|
||||
- B02~B11 라우트: company_id 없으면 B01로 리다이렉트
|
||||
|
||||
- ✅ `ui_template_locale.ts` 수정
|
||||
- `A07_Register_Field_Position`, `A07_Register_Field_Phone`, `A07_Register_Field_PasswordConfirm`
|
||||
- `A07_Register_Error_PasswordMismatch` 추가
|
||||
|
||||
- ✅ `A07_Register_UI_Style.css` 수정
|
||||
- `.a07-register__agreement` 아코디언 스타일 추가
|
||||
|
||||
- ✅ `A06_Login_Api_Fetch.ts` 수정
|
||||
- `SessionUser` 인터페이스에 `company_id`, `is_master` 추가
|
||||
- `fetchSessionUser()` 구현 (세션 조회 → company_id 확인용)
|
||||
|
||||
- ✅ `A00_Common/app_shell.ts` 수정
|
||||
- 로그아웃 시 `/auth/logout` API 호출 (서버 세션 삭제)
|
||||
|
||||
- ✅ B03~B06 API Fetch 파일 수정
|
||||
- Bearer 토큰 제거, `credentials: "include"` 추가 (세션 쿠키 기반)
|
||||
- `B03_FileInput_Api_Fetch.ts`, `B04_wf1_Surface_Api_Fetch.ts`, `B05_wf2_Route_Api_Fetch.ts`, `B06_wf3_ProfileCross_Api_Fetch.ts`
|
||||
|
||||
- ✅ 구식 파일 삭제
|
||||
- `A06_Login_UI_Page.ts` (localStorage 기반 구식 로그인 UI)
|
||||
|
||||
---
|
||||
|
||||
## 검증 항목 (사용자 확인됨)
|
||||
|
||||
### 1차 검증: 가입 및 이메일 인증 ✅
|
||||
- 회원가입 폼 필드 축소 확인
|
||||
- 약관 아코디언 펼쳐서 내용 표시 확인
|
||||
- OTP 입력 칸 노출 확인
|
||||
- 이메일 인증 완료 후 로그인 페이지 리다이렉트 확인
|
||||
|
||||
### 2차 검증: DB 상태 확인 ✅
|
||||
- `users.status = 'NO_COMPANY'`
|
||||
- `company_id = NULL`
|
||||
- `role = 'MEMBER'`
|
||||
- `is_master = FALSE`
|
||||
|
||||
### 3차 검증: 로그인 및 상태 ✅
|
||||
- NO_COMPANY 상태에서도 로그인 성공 (기존: status != ACTIVE로 실패)
|
||||
|
||||
### 4차 검증: 회사 없이 B02+ 접근 차단 ✅
|
||||
- B02~B11 진입 시 B01로 리다이렉트 동작 확인
|
||||
- 백엔드 require_company 에러 발생
|
||||
|
||||
---
|
||||
|
||||
## 최종 상태
|
||||
|
||||
🟢 **회원가입 플로우 개편 완료 (A~D 단계 중 A~C 완료)**
|
||||
- DB 마이그레이션 ✅
|
||||
- 백엔드 로직 수정 ✅
|
||||
- 프론트엔드 UI/라우팅 ✅
|
||||
- 세션 쿠키 기반 인증 통일 ✅
|
||||
- localStorage 토큰 완전 제거 ✅
|
||||
- 회사 연결 필수화 (라우팅 가드) ✅
|
||||
|
||||
🟡 **B01_AccountDetail 팝업 기능은 별도 작업 (D 단계 — 다음 세션)**
|
||||
@@ -0,0 +1,136 @@
|
||||
# 회원가입 플로우 개편 검증 보고서 (완료된 항목)
|
||||
|
||||
**마지막 업데이트:** 2026-07-08
|
||||
**상태:** 회원가입 축소 및 세션 쿠키 인증 검증 완료
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완료된 구현 항목
|
||||
|
||||
### 1. DB 변경
|
||||
- ✅ `db_management/003_register_split.sql` 생성
|
||||
- `users.status` ENUM에 `'NO_COMPANY'` 추가
|
||||
- 회원가입 시 회사 정보 NULL로 시작하도록 변경
|
||||
|
||||
### 2. 백엔드 변경
|
||||
- ✅ `A07_Register_Schema.py` 수정
|
||||
- `RegisterRequest` 필드: `email`, `password`, `password_confirm`, `name`, `position`, `phone`, `terms_agreed`, `privacy_agreed`, `marketing_agreed`, `terms_version`
|
||||
- 회사 관련 필드 전부 제거
|
||||
- `password_confirm` 검증 추가
|
||||
|
||||
- ✅ `common_util_auth_repository.py` 수정
|
||||
- `create_registration()`: 회사/join_request 생성 로직 제거, users INSERT만 수행
|
||||
- `complete_registration()`: 무조건 `status = 'NO_COMPANY'`로 변경
|
||||
|
||||
- ✅ `A07_Register_Router.py` 수정
|
||||
- `request_registration()`: `password_confirm` 제외 처리
|
||||
|
||||
- ✅ `A06_Login_Router.py` 수정
|
||||
- `request_login()`: `status not in ("ACTIVE", "NO_COMPANY")` 체크로 변경
|
||||
- `current_session()`: 응답에 `company_id`, `is_master` 추가
|
||||
|
||||
- ✅ `common_util_auth.py` 수정
|
||||
- `require_company()` dependency 추가
|
||||
|
||||
- ✅ `main.py` 수정
|
||||
- B03~B06 라우터에 `require_company` 의존성 추가
|
||||
|
||||
### 3. 리소스 파일
|
||||
- ✅ `resources/legal/terms_of_service.txt` 생성
|
||||
- ✅ `resources/legal/privacy_policy.txt` 생성
|
||||
- ✅ `resources/legal/marketing_consent.txt` 생성
|
||||
|
||||
### 4. 프론트엔드 변경
|
||||
- ✅ `A07_Register_Api_Fetch.ts` 수정
|
||||
- `RegisterPayload` 필드 축소
|
||||
|
||||
- ✅ `A07_Register_UI_Auth_Page.ts` 전면 재작성
|
||||
- 필드: name, position, email, phone, password, passwordConfirm, terms/privacy/marketing (아코디언)
|
||||
- 약관 텍스트 파일 로드 기능
|
||||
- OTP 입력 창 기존 로직 유지
|
||||
|
||||
- ✅ `A00_Common/router.ts` 수정
|
||||
- B02~B11 라우트: company_id 없으면 B01로 리다이렉트
|
||||
|
||||
- ✅ `ui_template_locale.ts` 수정
|
||||
- `A07_Register_Field_Position`, `A07_Register_Field_Phone`, `A07_Register_Field_PasswordConfirm`
|
||||
- `A07_Register_Error_PasswordMismatch` 추가
|
||||
|
||||
- ✅ `A07_Register_UI_Style.css` 수정
|
||||
- `.a07-register__agreement` 아코디언 스타일 추가
|
||||
|
||||
- ✅ `A06_Login_Api_Fetch.ts` 수정
|
||||
- `SessionUser` 인터페이스에 `company_id`, `is_master` 추가
|
||||
|
||||
### 추가 변경 사항 (세션 쿠키 통일)
|
||||
|
||||
#### 프론트엔드 개선
|
||||
- ✅ `A06_Login_Api_Fetch.ts`: `fetchSessionUser()` 구현 (세션 조회 → company_id 확인용)
|
||||
- ✅ 모든 API Fetch 파일: Bearer 토큰 제거, `credentials: "include"` 추가 (세션 쿠키 기반)
|
||||
- B03_FileInput, B04_wf1_Surface, B05_wf2_Route, B06_wf3_ProfileCross
|
||||
- ✅ `A00_Common/app_shell.ts`: 로그아웃 시 `/auth/logout` API 호출 (서버 세션 삭제)
|
||||
- ✅ `A00_Common/router.ts`: B02~B11 진입 시 company_id 확인 후 B01로 리다이렉트
|
||||
|
||||
#### 백엔드 개선
|
||||
- ✅ `config_db.py`: `autocommit=True` 설정 (pooled 커넥션 트랜잭션 스냅샷 이슈 해결)
|
||||
- ✅ `A06_Login_Router.py`: `/current-session` 응답에 `company_id`, `status` 추가
|
||||
- ✅ `common_util_auth.py`: `require_company()` dependency 추가 및 B03~B06에 적용
|
||||
|
||||
#### DB 마이그레이션
|
||||
- ✅ `db_management/003_register_split.sql`: users.status에 NO_COMPANY 추가
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완료된 검증 항목
|
||||
|
||||
### 1차 검증: 가입 및 이메일 인증 ✅
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
브라우저에서:
|
||||
1. `/#/a07-register` 접속 — 축소된 필드 확인 ✅
|
||||
2. 필드 확인: name, position, email, phone, password, passwordConfirm, 약관(3개) ✅
|
||||
3. 약관 체크박스 옆 라벨 클릭 → `<details>` 펼쳐서 내용 표시 확인 ✅
|
||||
4. 모든 필드 입력 후 "회원가입" 버튼 클릭 ✅
|
||||
5. 폼 하단에 OTP 입력 칸 노출 확인 ✅
|
||||
6. OTP 입력 후 "이메일 인증 완료" 버튼 클릭 ✅
|
||||
7. 로그인 페이지로 리다이렉트 확인 ✅
|
||||
|
||||
### 2차 검증: DB 상태 확인 ✅
|
||||
```sql
|
||||
SELECT id, email, status, company_id, role, is_master FROM users WHERE email = 'test@example.com';
|
||||
```
|
||||
예상 결과 (확인됨):
|
||||
- `status` = 'NO_COMPANY' ✅
|
||||
- `company_id` = NULL ✅
|
||||
- `role` = 'MEMBER' ✅
|
||||
- `is_master` = FALSE ✅
|
||||
|
||||
### 3차 검증: 로그인 및 상태 ✅
|
||||
1. 가입한 계정으로 로그인 시도 → 성공 확인 ✅
|
||||
(기존: status != ACTIVE로 실패했을 부분이 이제 NO_COMPANY 상태에서도 로그인 가능)
|
||||
2. 로그인 후 B02_PROJ_REGISTER (`/#/b02-proj-register`) 접속 시도 → B01_ACCOUNT로 리다이렉트 확인 ✅
|
||||
|
||||
### 4차 검증: 회사 없이 B02+ 접근 차단 ✅
|
||||
- `/#/b03-file-input` 직접 접속 → B01로 리다이렉트 또는 403 응답 확인 ✅
|
||||
(백엔드 require_company 에러 발생)
|
||||
|
||||
---
|
||||
|
||||
## 최종 상태
|
||||
|
||||
🟢 **회원가입 플로우 개편 완료 (1~3단계)**
|
||||
- DB 마이그레이션 ✅
|
||||
- 백엔드 로직 수정 ✅
|
||||
- 프론트엔드 UI/라우팅 ✅
|
||||
- 세션 쿠키 기반 인증 통일 ✅
|
||||
- localStorage 토큰 완전 제거 ✅
|
||||
- 회사 연결 필수화 ✅
|
||||
|
||||
🟢 **현재 검증 완료 (모든 4차 검증 통과)**
|
||||
- 회원가입 → 이메일 인증 → NO_COMPANY 상태 확인 ✅
|
||||
- 로그인 가능 (NO_COMPANY 상태에서도 가능) ✅
|
||||
- B02+ 접근 시 B01로 리다이렉트 동작 확인 ✅
|
||||
- 세션 쿠키 기반 인증 정상 작동 ✅
|
||||
|
||||
🟡 **B01_AccountDetail 팝업 기능은 별도 작업 (4단계 — 다음 세션)**
|
||||
+123
-6
@@ -1,6 +1,6 @@
|
||||
# DB 스키마 (테이블 및 열 목록)
|
||||
|
||||
## 1. 사용자 & 인증 그룹
|
||||
## 1. 사용자 & 인증 & 조직 그룹
|
||||
|
||||
### 1-1. users 테이블
|
||||
```
|
||||
@@ -12,12 +12,25 @@ users
|
||||
├── position (TEXT) — 직급 (예: 과장, 대리, 사원)
|
||||
├── department (TEXT) — 부서명 (예: 설계팀, 영업팀)
|
||||
├── phone (TEXT) — 연락처 (예: 010-1234-5678)
|
||||
├── company_id (INT, FK → companies.id) — 소속 회사
|
||||
├── company_id (INT, FK → companies.id, NULL) — 소속 회사
|
||||
├── role (ENUM) — 역할: SYSTEM_ADMIN, ADMIN, USER (기본값: USER)
|
||||
├── is_master (BOOLEAN) — 회사 마스터 여부 (기본값: FALSE)
|
||||
├── status (ENUM) — 상태: PENDING_EMAIL, NO_COMPANY, PENDING, ACTIVE, INACTIVE, REJECTED
|
||||
├── last_login (DATETIME, NULL) — 마지막 로그인 시간
|
||||
├── auth_expires_at (DATETIME, NULL) — 인증 유효 만료 시간 (3개월 주기)
|
||||
├── created_at (TIMESTAMP) — 가입일
|
||||
├── updated_at (TIMESTAMP) — 수정일
|
||||
└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete)
|
||||
```
|
||||
|
||||
**상태 생명주기:**
|
||||
- PENDING_EMAIL: 회원가입 후 이메일 인증 전
|
||||
- NO_COMPANY: 이메일 인증 완료, 회사 미연결 (로그인 가능, B02~B11 차단)
|
||||
- PENDING: 회사 참여 신청 후 관리자 승인 대기
|
||||
- ACTIVE: 회사 연결 완료, 모든 기능 접근 가능
|
||||
- INACTIVE: 퇴사/계정 비활성화
|
||||
- REJECTED: 회사 참여 신청 거부
|
||||
|
||||
### 1-2. companies 테이블
|
||||
```
|
||||
companies
|
||||
@@ -26,13 +39,117 @@ companies
|
||||
├── business_registration_number (TEXT, UNIQUE) — 사업자등록번호
|
||||
├── business_address (TEXT) — 사업장 주소
|
||||
├── business_owner (TEXT) — 사업주 이름
|
||||
├── business_status (TEXT) — 기업 상태 (예: 활동중, 폐업, 휴업)
|
||||
├── created_by (INT, FK → users.id) — 회사 생성자
|
||||
├── business_status (TEXT, ENUM) — 기업 상태: 활동중, 폐업, 휴업 (기본값: 활동중)
|
||||
├── created_by (INT, FK → users.id) — 회사 생성자 (첫 ADMIN)
|
||||
├── created_at (TIMESTAMP) — 생성일
|
||||
├── updated_at (TIMESTAMP) — 수정일
|
||||
└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete)
|
||||
```
|
||||
|
||||
### 1-3. join_requests 테이블 (신규)
|
||||
```
|
||||
join_requests
|
||||
├── id (INT, PK) — 요청 고유 번호
|
||||
├── user_id (INT, FK → users.id) — 신청한 사용자
|
||||
├── company_id (INT, FK → companies.id) — 신청 대상 회사
|
||||
├── requested_at (TIMESTAMP) — 신청 시간
|
||||
├── status (ENUM) — 상태: PENDING, APPROVED, REJECTED (기본값: PENDING)
|
||||
├── reviewed_by (INT, FK → users.id, NULL) — 검토한 관리자
|
||||
├── reviewed_at (TIMESTAMP, NULL) — 검토 시간
|
||||
└── UNIQUE KEY (user_id, company_id) — 중복 신청 방지
|
||||
```
|
||||
|
||||
**동작:**
|
||||
- 사용자 가입 신청 → status = PENDING 생성
|
||||
- 관리자 승인 → status = APPROVED, users.status = ACTIVE, users.company_id 세팅
|
||||
- 관리자 거부 → status = REJECTED, 이메일 발송
|
||||
|
||||
### 1-4. system_audit_logs 테이블 (신규)
|
||||
```
|
||||
system_audit_logs
|
||||
├── id (INT, PK) — 로그 고유 번호
|
||||
├── user_id (INT, FK → users.id) — 행동 수행자
|
||||
├── action (VARCHAR(50)) — 행동: LOGIN, LOGOUT, USER_CREATE, USER_UPDATE, COMPANY_CREATE, ROLE_CHANGE, etc
|
||||
├── resource_type (VARCHAR(50)) — 대상 타입: user, company, project, join_request
|
||||
├── resource_id (INT, NULL) — 대상 ID
|
||||
├── timestamp (TIMESTAMP, DEFAULT CURRENT_TIMESTAMP) — 기록 시간
|
||||
├── ip_address (VARCHAR(50), NULL) — IP 주소 (선택사항)
|
||||
├── user_agent (TEXT, NULL) — 브라우저/OS 정보 (선택사항)
|
||||
├── INDEX (timestamp) — 조회 성능 최적화
|
||||
└── INDEX (user_id) — 사용자별 조회 최적화
|
||||
```
|
||||
|
||||
### 1-5. system_resources 테이블 (신규)
|
||||
```
|
||||
system_resources
|
||||
├── id (INT, PK) — 기록 고유 번호
|
||||
├── timestamp (TIMESTAMP, DEFAULT CURRENT_TIMESTAMP) — 기록 시간
|
||||
├── cpu_usage_percent (FLOAT) — CPU 사용률 (%)
|
||||
├── memory_usage_percent (FLOAT) — 메모리 사용률 (%)
|
||||
├── disk_usage_percent (FLOAT) — 디스크 사용률 (%)
|
||||
├── active_user_count (INT) — 활성 사용자 수
|
||||
├── active_project_count (INT) — 활성 프로젝트 수
|
||||
├── total_storage_mb (FLOAT) — 전체 저장소 사용량 (MB)
|
||||
└── INDEX (timestamp) — 시계열 조회 최적화
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 테이블 관계도 (조직 & 인증 그룹)
|
||||
|
||||
```
|
||||
users (사용자)
|
||||
├── id (PK)
|
||||
├── email
|
||||
├── password_hash
|
||||
├── role (SYSTEM_ADMIN | ADMIN | USER)
|
||||
├── is_master (마스터 여부)
|
||||
├── status (생명주기)
|
||||
├── company_id (FK) ──→ companies.id
|
||||
├── last_login
|
||||
└── auth_expires_at
|
||||
|
||||
companies (회사)
|
||||
├── id (PK)
|
||||
├── name
|
||||
├── business_registration_number
|
||||
├── business_address
|
||||
├── business_owner
|
||||
├── business_status
|
||||
├── created_by (FK) ──→ users.id
|
||||
├── created_at
|
||||
└── updated_at
|
||||
|
||||
join_requests (가입 신청)
|
||||
├── id (PK)
|
||||
├── user_id (FK) ──→ users.id
|
||||
├── company_id (FK) ──→ companies.id
|
||||
├── requested_at
|
||||
├── status (PENDING | APPROVED | REJECTED)
|
||||
├── reviewed_by (FK) ──→ users.id
|
||||
└── reviewed_at
|
||||
|
||||
system_audit_logs (시스템 로그)
|
||||
├── id (PK)
|
||||
├── user_id (FK) ──→ users.id
|
||||
├── action
|
||||
├── resource_type
|
||||
├── resource_id
|
||||
├── timestamp
|
||||
├── ip_address
|
||||
└── user_agent
|
||||
|
||||
system_resources (리소스 모니터링)
|
||||
├── id (PK)
|
||||
├── timestamp
|
||||
├── cpu_usage_percent
|
||||
├── memory_usage_percent
|
||||
├── disk_usage_percent
|
||||
├── active_user_count
|
||||
├── active_project_count
|
||||
└── total_storage_mb
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 프로젝트 그룹
|
||||
@@ -586,8 +703,8 @@ ORDER BY timestamp DESC;
|
||||
- `processed_file_path / converted_file_path`: 변환된 파일 위치
|
||||
- `*_file_path`: 각 워크플로우 단계별 산출물 위치
|
||||
|
||||
### 총 18개 테이블:
|
||||
1. **사용자/회사**: users, companies (2개)
|
||||
### 총 23개 테이블:
|
||||
1. **사용자/회사/조직**: users, companies, join_requests, system_audit_logs, system_resources (5개)
|
||||
2. **프로젝트**: projects, project_versions (2개)
|
||||
3. **입력 데이터**: input_files, processed_point_cloud (2개)
|
||||
4. **지표면 분석**: surface_models, terrain_layers (2개)
|
||||
|
||||
+54
-1
@@ -32,4 +32,57 @@
|
||||
export const ui_locales = {
|
||||
A01_Home_Title: ["반갑습니다", "Welcome"],
|
||||
B04_wf1_Surface_Btn: ["지표면 분석 실행", "Run Surface Analysis"]
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 인증, 인가, 역할 및 라우팅 (Authentication, Authorization, Roles & Routing)
|
||||
|
||||
### 5.1 세션 쿠키 기반 인증
|
||||
* **방식:** HttpOnly Secure 세션 쿠키 (localStorage 토큰 미사용)
|
||||
* **API 통신:** 모든 API 요청에 `credentials: "include"` 설정으로 쿠키 자동 전송
|
||||
* **로그아웃:** `/auth/logout` API 호출 → 서버 세션 삭제 → 쿠키 자동 제거
|
||||
* **인증 갱신:** 로그인 시 `users.auth_expires_at = NOW() + 3개월` 자동 설정
|
||||
- 유효한 인증: `auth_expires_at > NOW()` → 바로 대시보드 이동
|
||||
- 만료된 인증: `auth_expires_at <= NOW()` → 재인증 모달 표시
|
||||
|
||||
### 5.1.1 역할 기반 대시보드 렌더링
|
||||
* **USER 역할:**
|
||||
- 프로젝트 리스트 섹션
|
||||
- 회사 정보 섹션 (회사 생성/연결 팝업)
|
||||
- 기본정보 & 보안 섹션
|
||||
|
||||
* **ADMIN 역할:**
|
||||
- 프로젝트 리스트 섹션 (회사 전체 프로젝트)
|
||||
- 회사 정보 섹션
|
||||
- 팀원 관리 섹션
|
||||
- 가입 요청 섹션
|
||||
- 기본정보 & 보안 섹션
|
||||
|
||||
* **SYSTEM_ADMIN 역할:**
|
||||
- 리소스 현황 섹션 (CPU, MEM, DISK, 활성 사용자/프로젝트)
|
||||
- 회사 관리 섹션 (전체 회사 리스트 + 추가/수정/삭제)
|
||||
- 사용자 관리 섹션 (전체 사용자 + 역할 변경 + 회사 할당)
|
||||
- 가입 요청 관리 섹션 (전체 가입 신청 + 승인/거절)
|
||||
- 시스템 로그 섹션 (필터링 지원)
|
||||
- 리소스 모니터링 그래프 (시계열 데이터)
|
||||
- 기본정보 & 보안 섹션
|
||||
|
||||
### 5.2 라우팅 가드
|
||||
* **인증 확인:** `isAuthenticated()` 함수로 세션 유효성 검사 (PROTECTED_ROUTES 진입 전)
|
||||
* **회사 연결 확인:** B02~B11 워크플로우 진입 시 `fetchDashboardMe()` 호출 → `company_id` + `status` 확인
|
||||
- `status` 확인:
|
||||
- NO_COMPANY: B01_Dashboard로 강제 리다이렉트 (회사 연결 필요)
|
||||
- PENDING: B01_Dashboard로 강제 리다이렉트 (승인 대기 중)
|
||||
- ACTIVE: 워크플로우 진입 허용
|
||||
- `company_id` 없으면 B01_Dashboard로 리다이렉트
|
||||
* **역할 기반 라우팅:**
|
||||
- SYSTEM_ADMIN: 모든 페이지 접근 가능
|
||||
- ADMIN: B01_Dashboard (관리 섹션) + B02~B11 워크플로우 접근 가능
|
||||
- USER: B01_Dashboard (사용자 섹션) + B02~B11 워크플로우 접근 가능
|
||||
* **상태 전이:**
|
||||
- 회원가입 후 이메일 인증 완료: `users.status = 'NO_COMPANY'` (로그인 가능, 회사 미연결)
|
||||
- 회사 생성: `users.status = 'ACTIVE'`, `company_id` 세팅, `role = 'ADMIN'`, `is_master = TRUE`
|
||||
- 회사 참여 신청: `users.status = 'PENDING'` (관리자 승인 대기)
|
||||
- 관리자 승인: `users.status = 'ACTIVE'`, `company_id` 세팅
|
||||
+664
-65
@@ -1,94 +1,693 @@
|
||||
# 회원가입 플로우 개편: 계정 생성과 회사 연결 분리
|
||||
# B01_Dashboard 설계 및 구현 계획
|
||||
|
||||
## Context
|
||||
**상태:** 기본 설계 완료. 역할별 대시보드 구축 및 회사 연결 기능 미구현
|
||||
**참고:** 완료된 항목은 `.agent/complete_and_old/plan_register_split_COMPLETED.md` 참조
|
||||
|
||||
현재 `A07_Register` 회원가입 폼은 계정 생성과 동시에 회사 생성/연결(신규 회사명 또는 기존 company_id)을 강제로 요구한다. 사용자는 이 결합이 부적절하다고 판단했다 — 회사 관련 정보(사업자등록번호, 전화번호, 주소 등)는 계정을 만든 사람이 로그인한 이후에 "회사 생성 또는 검색·연결" 단계로 넘겨야 자연스럽다. 또한:
|
||||
---
|
||||
|
||||
- 회원가입 폼 필드를 이름/직급/이메일/전화번호/비밀번호/비밀번호 확인으로 재정의 (department는 여기서 제외, 회사 연결 이후 설정)
|
||||
- 약관/개인정보처리방침/마케팅 동의는 필수 항목이지만 실제 내용이 어디에도 없었다 — 체크박스 옆에 이름을 두고 펼치면 내용이 열리는 UI로 바꾸고, 실제 텍스트는 나중에 쉽게 고칠 수 있도록 리소스 텍스트 파일로 분리
|
||||
- 이메일 인증은 회원가입 제출 즉시 폼 하단에 OTP 입력 칸이 나타나는 현재 방식을 유지
|
||||
## 1. B01_Dashboard 개편 사항
|
||||
|
||||
목표: 계정 생성(A07) → 이메일 인증 → 로그인 가능(company 없이) → 로그인 후 B01_AccountDetail에서 회사 생성/연결 → 연결 완료 후에만 B02~B11 워크플로우 접근 가능.
|
||||
### 1.1 페이지명 변경
|
||||
- **이전:** B01_AccountDetail (계정 상세 페이지)
|
||||
- **현재:** B01_Dashboard (역할별 대시보드)
|
||||
- **목적:** 로그인 후 메인 랜딩 페이지로, 역할에 따라 다양한 정보 표시
|
||||
|
||||
## 현재 구조 파악 결과
|
||||
### 1.2 역할별 대시보드 구조
|
||||
|
||||
- `users` 테이블: `company_id` nullable, `role` ENUM('SYSTEM_ADMIN','MASTER','MEMBER') 기본 MEMBER, `is_master` BOOLEAN, `status` ENUM('PENDING_EMAIL','PENDING','ACTIVE','INACTIVE','REJECTED')
|
||||
- `create_registration()` (`common_util/common_util_auth_repository.py:30`): 현재 회원가입 시 계정+회사(or join_request)+user_consents를 한 트랜잭션에서 같이 생성
|
||||
- `complete_registration()` (`common_util/common_util_auth_repository.py:134`): 이메일 인증 완료 시 MASTER→ACTIVE, MEMBER→PENDING(마스터 승인 대기)로 status 변경
|
||||
- `verify_session()` (`common_util/common_util_auth.py:74`): 세션 조회 시 `company_id`, `role`, `is_master`, `status`를 함께 반환 — 이미 dependency에서 활용 가능한 형태
|
||||
- `main.py:236-240`: B03~B06 라우터에 `protected = [Depends(verify_session)]`만 걸려 있음 (company_id 미검사, B07~B11은 라우터 자체가 아직 없음)
|
||||
- `A00_Common/router.ts`: `PROTECTED_ROUTES` 목록으로 인증 가드만 수행, company 연결 여부는 검사하지 않음
|
||||
- `A09_Security_Terms.ts` + `A09_Security_UI_Page.ts`: 이미 "보안정책 안내 페이지" 용도로 존재하지만 세션/보안 정책 설명이지 회원가입 약관 동의가 아님 — 이 구조(약관 텍스트 분리 + 언어별 배열)를 참고해 새 리소스를 만든다
|
||||
- `B01_AccountDetail`: 현재 `UI_Page.ts`, `UI_Style.css`만 있고 백엔드/API 없음 — 여기에 계정 상세 정보 표시 + 회사 연결 섹션을 새로 구현해야 함
|
||||
- `resources/` 폴더(프로젝트 루트)는 로고/배너 등 에셋용으로 이미 정의돼 있음 (`structure.md:41`) — 약관 텍스트는 이 폴더 하위에 신설 서브폴더로 정리
|
||||
#### 🟡 **일반 사용자 (USER) 대시보드**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ AISLO 대시보드 │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 📊 [나의 프로젝트] │
|
||||
│ • 프로젝트 리스트 (테이블) │
|
||||
│ • 프로젝트명 | 지역 | 진행도 [워크플로우] | 수정일 │
|
||||
│ • 클릭 시 해당 단계로 이동 │
|
||||
│ • [+ 신규 프로젝트] 버튼 │
|
||||
│ │
|
||||
│ 🏢 [회사 정보] │
|
||||
│ • 현재 소속 회사명 │
|
||||
│ • 팀원 수, 진행 중인 프로젝트 수 │
|
||||
│ │
|
||||
│ 📞 [기본정보 & 보안] (섹션 병합) │
|
||||
│ • 사용자명, 직급, 부서, 전화번호 수정 │
|
||||
│ • 비밀번호 변경 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## DB 변경
|
||||
#### 🟢 **회사 관리자 (ADMIN) 대시보드**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ AISLO 회사 관리자 대시보드 │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 🏢 [회사 정보] │
|
||||
│ 회사명: 테스트회사 | 사업자: 123-45-67890 │
|
||||
│ 주소: 서울시 강남구 | 상태: 활동중 │
|
||||
│ │
|
||||
│ 👥 [팀원 관리] │
|
||||
│ • 소속 사용자 리스트 (테이블) │
|
||||
│ • 사용자명 | 직급 | 부서 | 역할 | 상태 │
|
||||
│ • [+ 팀원 추가] (기가입 계정 검색/추가) │
|
||||
│ • [제거] 버튼 │
|
||||
│ │
|
||||
│ 📋 [가입 요청] │
|
||||
│ • 이 회사에 가입을 신청한 사용자 리스트 │
|
||||
│ • [승인] [거절] 버튼 │
|
||||
│ │
|
||||
│ 📊 [회사 프로젝트] │
|
||||
│ • 회사의 모든 프로젝트 리스트 (테이블) │
|
||||
│ • 프로젝트명 | 소유자 | 진행도 | 상태 │
|
||||
│ │
|
||||
│ 📞 [기본정보 & 보안] │
|
||||
│ • 사용자 이름, 직급, 전화번호 수정 │
|
||||
│ • 비밀번호 변경 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
`db_management/003_register_split.sql` 신규 마이그레이션:
|
||||
#### 🔴 **시스템 관리자 (SYSTEM_ADMIN) 대시보드**
|
||||
```
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ AISLO 시스템 관리자 대시보드 │
|
||||
├────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 📊 [리소스 현황] │
|
||||
│ CPU: 45% | MEM: 62% | DISK: 78% │
|
||||
│ 활성 사용자: 23 | 활성 프로젝트: 15 │
|
||||
│ │
|
||||
│ 🏢 [회사 관리] │
|
||||
│ • 전체 회사 리스트 (테이블) │
|
||||
│ • 회사명 | 사업자번호 | 상태 | 사용자 수 │
|
||||
│ • [+ 회사 추가] 버튼 │
|
||||
│ │
|
||||
│ 👤 [사용자 관리] │
|
||||
│ • 전체 사용자 리스트 (테이블) │
|
||||
│ • 이메일 | 이름 | 역할 | 회사 | 상태 │
|
||||
│ • [역할 변경] [회사 할당] [비활성화] 버튼 │
|
||||
│ │
|
||||
│ 📝 [가입 요청 관리] │
|
||||
│ • 회사 참여 대기 중인 요청 (테이블) │
|
||||
│ • 신청자 | 회사명 | 신청일 | 상태 │
|
||||
│ • [승인] [거절] 버튼 │
|
||||
│ │
|
||||
│ 📊 [시스템 로그] │
|
||||
│ • 사용자 로그인/로그아웃 이력 (테이블) │
|
||||
│ • 계정 생성/수정/삭제 이력 │
|
||||
│ • 필터링: 기간, 사용자, 행동 유형 │
|
||||
│ │
|
||||
│ 🖥️ [하드웨어 리소스 모니터링] │
|
||||
│ • CPU 사용량 그래프 (시간대별) │
|
||||
│ • 메모리 사용량 그래프 │
|
||||
│ • 디스크 사용량 그래프 │
|
||||
│ • 동시 접속자 수 추이 │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
1. `users.status` ENUM에 `'NO_COMPANY'` 추가 (이메일 인증 완료, 회사 미연결 상태)
|
||||
2. `users.company_id`, `users.role`, `users.is_master` 는 구조 그대로 유지하되 회원가입 시점에는 전부 NULL/기본값으로 시작
|
||||
3. 기존 `join_requests` 테이블 그대로 재사용 (MEMBER가 기존 회사 연결 신청 시)
|
||||
4. `department` 컬럼은 그대로 두되 회원가입 INSERT에서 제외 (NULL 허용이라 문제 없음)
|
||||
---
|
||||
|
||||
## 백엔드 변경
|
||||
## 2. 핵심 기능 상세 설계
|
||||
|
||||
### A07_Register (회원가입 축소)
|
||||
### 2.1 프로젝트 리스트 섹션 (USER, ADMIN 공통)
|
||||
|
||||
- `A07_Register_Schema.py`: `RegisterRequest`에서 `account_type`, `company_id`, `company_name`, `business_number`, `phone_number`(→ 개인 전화번호로 의미 변경), `address`, `representative_name` 제거. 필드: `email`, `password`, `password_confirm`, `name`, `position`, `phone`, `terms_agreed`, `privacy_agreed`, `marketing_agreed`, `terms_version`. `password_confirm == password` 검증을 `model_validator`에 추가.
|
||||
- `A07_Register_Router.py`의 `request_registration()`: `create_registration()` 호출을 단순화된 데이터로 변경.
|
||||
- `common_util_auth_repository.py`의 `create_registration()`: 회사/join_request 생성 로직 제거. `users` INSERT만 수행 (`role` 기본값 MEMBER, `company_id` NULL, `is_master` FALSE). `user_consents` INSERT는 유지.
|
||||
- `complete_registration()`: 이메일 인증 완료 시 무조건 `status = 'NO_COMPANY'`로 변경 (MASTER/MEMBER 분기 제거).
|
||||
- `A06_Login_Router.py`의 `request_login()`: `user["status"] != "ACTIVE"` 체크를 `status not in ("ACTIVE", "NO_COMPANY")`로 완화 — company 미연결 상태에서도 로그인은 허용.
|
||||
**테이블 구조:**
|
||||
| 프로젝트명 | 지역 | 진행도 | 워크플로우 | 수정일 |
|
||||
|----------|------|--------|----------|--------|
|
||||
| 2025년 산불진화임도 | 울진군 | 60% | [B03●●●○○○] | 2025-07-08 |
|
||||
|
||||
### 회사 생성/연결 API (신규, B01_AccountDetail 백엔드)
|
||||
**진행도 워크플로우:**
|
||||
```
|
||||
[B03 완료] → [B04 진행중 ▶] → [B05 대기] → [B06 대기] → ...
|
||||
↑ (클릭하면 이동) (비활성화)
|
||||
```
|
||||
|
||||
`B01_AccountDetail/B01_AccountDetail_Router.py`, `_Schema.py`, `_Repository.py` 신규 생성 (기존 패턴 준수, `common_util_auth_repository.py`의 `search_companies`/`_company_code` 재사용):
|
||||
**동작:**
|
||||
- **완료/진행 중 단계:** 클릭 가능 → 해당 페이지로 이동
|
||||
- **미진행 단계:** 비활성화 (회색), 클릭 불가
|
||||
- **[+ 신규 프로젝트]:** 새 프로젝트 생성 페이지로 이동 (B02_ProjRegister)
|
||||
|
||||
- `GET /api/account/me` — 현재 계정 상세 (session 기반)
|
||||
- `PATCH /api/account/me` — 이름/직급/전화번호 수정
|
||||
- `GET /api/account/companies?q=` — 기존 `search_companies()` 재사용
|
||||
- `POST /api/account/company/create` — 신규 회사 생성 + 본인을 MASTER/is_master=TRUE로 연결, `role='MASTER'`, `status='ACTIVE'`로 전환. 회사 필드(사업자등록번호/주소/전화번호/대표자명)를 여기서 받음.
|
||||
- `POST /api/account/company/join` — 기존 회사에 `join_requests` 생성 + `users.company_id` 세팅(단, status는 `PENDING`으로, 마스터 승인 전까지 유지)
|
||||
### 2.2 회사 상태 섹션 (USER만 표시)
|
||||
|
||||
### 접근 제어 (company 연결 강제)
|
||||
**상태 표시:**
|
||||
```
|
||||
회사 상태: 테스트회사 (활동중)
|
||||
[회사 변경] ← 다른 회사 검색 및 신청
|
||||
```
|
||||
|
||||
- `common_util/common_util_auth.py`에 `require_company()` dependency 추가: `session["company_id"] is None` 이면 403.
|
||||
- `main.py`: B03~B06 라우터의 `protected` 리스트에 `Depends(require_company)` 추가.
|
||||
**동작:**
|
||||
- `status = 'NO_COMPANY'`: "회사 미연결" 표시 + [회사 생성] [회사 찾기] 버튼
|
||||
- `status = 'PENDING'`: "가입 승인 대기 중 (OOO회사)" 표시
|
||||
- `status = 'ACTIVE'`: "OOO회사 (ACTIVE)" 표시 + [회사 변경] 버튼
|
||||
|
||||
## 프론트엔드 변경
|
||||
### 2.3 팀원 관리 섹션 (ADMIN만 표시)
|
||||
|
||||
### 리소스 텍스트 분리
|
||||
**팀원 추가 동작:**
|
||||
1. [+ 팀원 추가] 클릭 → 모달 팝업 노출
|
||||
2. 기가입 사용자 검색 (이메일 입력)
|
||||
3. 검색 결과 선택 → 회사에 추가
|
||||
4. 중복 확인 (이미 팀원인 경우 "이미 팀원입니다" 메시지)
|
||||
|
||||
`resources/legal/` 신규 폴더:
|
||||
- `terms_of_service.txt` — 이용약관 (Aislo/임도설계 자동화 서비스에 맞는 내용 초안 작성)
|
||||
- `privacy_policy.txt` — 개인정보처리방침
|
||||
- `marketing_consent.txt` — 마케팅 정보 수신 동의
|
||||
**팀원 제거:**
|
||||
- [제거] 버튼 클릭 → 확인 팝업 → 회사에서 제거
|
||||
|
||||
이 텍스트는 빌드 시 정적 자산으로 fetch하거나, 간단히 `A07_Register`에서 TS 상수 모듈로 다시 export하는 두 방식 중 — Vite가 `?raw` import를 지원하므로 `import termsText from "../../resources/legal/terms_of_service.txt?raw"` 형태로 로드 (외부 자산 폴더를 벗어나지 않고 사용자가 텍스트 파일만 고치면 되도록).
|
||||
### 2.4 가입 요청 섹션 (ADMIN은 회사별, SYSTEM_ADMIN은 전체)
|
||||
|
||||
### A07_Register (폼 축소 + 약관 아코디언 + OTP 유지)
|
||||
**ADMIN 대시보드:**
|
||||
```
|
||||
신청자 | 신청일 | 상태
|
||||
김테스트 | 2025-07-08 | [승인] [거절]
|
||||
```
|
||||
|
||||
- `A07_Register_UI_Auth_Page.ts`: `accountType`, `company`, `companyId` 필드 및 관련 로직 제거. `position`(직급), `phone`(전화번호), `passwordConfirm` 필드 추가.
|
||||
- 약관 체크박스 영역: 현재 `check()` 헬퍼(단순 체크박스+라벨)를 확장한 `checkWithAccordion()`로 교체 — 라벨 클릭 시 하단에 `resources/legal/*.txt` 내용이 펼쳐지는 `<details>/<summary>` 또는 토글 버튼 구조.
|
||||
- OTP 입력 칸: 기존처럼 제출 후 폼 하단에 노출되는 현재 로직 그대로 유지 (`verifyStep` 플래그 방식 재사용).
|
||||
- `A07_Register_Api_Fetch.ts`의 `RegisterPayload`: 축소된 필드로 갱신, `searchCompanies` export는 B01로 이동(또는 공용 유지 — B01에서 재사용하도록 export 유지).
|
||||
**SYSTEM_ADMIN 대시보드:**
|
||||
```
|
||||
신청자 | 회사명 | 신청일 | 상태
|
||||
김테스트 | 테스트회사 | 2025-07-08 | [승인] [거절]
|
||||
```
|
||||
|
||||
### B01_AccountDetail (계정 상세 + 회사 연결)
|
||||
**동작:**
|
||||
- [승인]: `join_requests.status = 'APPROVED'` → `users.status = 'ACTIVE'`
|
||||
- [거절]: `join_requests.status = 'REJECTED'` → 이메일 발송
|
||||
|
||||
- `B01_AccountDetail_UI_Page.ts`: 계정 기본정보 표시/수정 폼 + "회사 생성" / "회사 검색·연결" 두 개의 섹션(회사 미연결 시에만 노출, `A07_Register_Api_Fetch.searchCompanies` 재사용 가능).
|
||||
- `B01_AccountDetail_Api_Fetch.ts` 신규: 위 백엔드 API 클라이언트.
|
||||
### 2.5 인증 갱신 로직
|
||||
|
||||
### 라우팅 가드 (company 없으면 B02+ 차단)
|
||||
**회원가입 시:**
|
||||
```
|
||||
1. 회원가입 폼 입력
|
||||
2. [가입하기] 클릭
|
||||
3. 인증번호 입력 필드 노출 ← 가입 후에만 표시
|
||||
4. 인증 완료 → users.auth_expires_at = NOW() + 3개월 설정
|
||||
```
|
||||
|
||||
- `A00_Common/router.ts`의 `renderCurrentRoute()`: `PROTECTED_ROUTES` 검사 이후, B02~B11 라우트에 한해 세션의 `company_id` 확인 로직 추가 (session 조회 API에 `company_id` 이미 포함돼 있음 — `A06_Login_Router.py`의 `/session` 응답에 `company_id` 필드만 추가하면 됨). 없으면 `ROUTES.B01_ACCOUNT`로 리다이렉트.
|
||||
- `A06_Login_Router.py`의 `current_session()`: 응답에 `company_id`, `status` 추가.
|
||||
**로그인 시:**
|
||||
```
|
||||
1. 이메일/비밀번호 입력
|
||||
2. DB 조회 후 auth_expires_at 확인
|
||||
- auth_expires_at > NOW() → 바로 대시보드 이동 ✅
|
||||
- auth_expires_at <= NOW() → 재인증 모달 표시 ⚠️
|
||||
3. 로그인 성공 → last_login = NOW() 업데이트
|
||||
4. 다음 로그인 시 auth_expires_at = NOW() + 3개월로 연장
|
||||
```
|
||||
|
||||
## 검증 방법
|
||||
**인증 만료 기준:**
|
||||
- **고정 기간 방식 (이전):** 가입일로부터 3개월 후 만료 (자주 사용하면 3개월 후 갑자기 재인증)
|
||||
- **로그인 기반 갱신 (현재):** 매 로그인 시 `auth_expires_at` 연장 (지속 사용 시 계속 유효)
|
||||
|
||||
1. `python main.py` 실행 후 브라우저에서 `/#/a07-register` 접속 → 축소된 필드로 가입, 약관 아코디언 펼쳐서 텍스트 노출 확인, 제출 후 OTP 칸 노출 확인
|
||||
2. OTP 인증 완료 → DB에서 `users.status = 'NO_COMPANY'`, `company_id IS NULL` 확인
|
||||
3. 해당 계정으로 로그인 성공 확인 (기존엔 `status != ACTIVE`면 403이었음)
|
||||
4. 로그인 상태에서 `/#/b02-proj-register` 접속 시도 → `/#/b01-account`로 리다이렉트되는지 확인
|
||||
5. B01에서 "회사 생성" 실행 → `role='MASTER'`, `status='ACTIVE'`, `company_id` 세팅 확인 후 B02 접근 가능한지 재확인
|
||||
6. 두 번째 테스트 계정으로 "회사 검색·연결"(join) 플로우 → `join_requests` 생성 확인, 기존 마스터 승인 API(`A09_Security_Router.py`의 `/master/join-requests`)로 승인 후 status ACTIVE 전환 확인
|
||||
---
|
||||
|
||||
## 3. DB 스키마 확장
|
||||
|
||||
### 3.1 users 테이블 추가 컬럼
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN (
|
||||
role ENUM('SYSTEM_ADMIN', 'ADMIN', 'USER') DEFAULT 'USER',
|
||||
is_master BOOLEAN DEFAULT FALSE,
|
||||
status ENUM('PENDING_EMAIL', 'NO_COMPANY', 'PENDING', 'ACTIVE', 'INACTIVE', 'REJECTED') DEFAULT 'PENDING_EMAIL',
|
||||
last_login DATETIME DEFAULT NULL,
|
||||
auth_expires_at DATETIME DEFAULT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### 3.2 신규 테이블: join_requests
|
||||
```sql
|
||||
CREATE TABLE join_requests (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id INT NOT NULL,
|
||||
company_id INT NOT NULL,
|
||||
requested_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
status ENUM('PENDING', 'APPROVED', 'REJECTED') DEFAULT 'PENDING',
|
||||
reviewed_by INT DEFAULT NULL,
|
||||
reviewed_at TIMESTAMP DEFAULT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id),
|
||||
FOREIGN KEY (reviewed_by) REFERENCES users(id),
|
||||
UNIQUE KEY (user_id, company_id) -- 중복 신청 방지
|
||||
);
|
||||
```
|
||||
|
||||
### 3.3 신규 테이블: system_audit_logs
|
||||
```sql
|
||||
CREATE TABLE system_audit_logs (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id INT NOT NULL,
|
||||
action VARCHAR(50), -- LOGIN, LOGOUT, USER_CREATE, USER_UPDATE, COMPANY_CREATE, etc
|
||||
resource_type VARCHAR(50), -- user, company, project
|
||||
resource_id INT,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
ip_address VARCHAR(50),
|
||||
user_agent TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
INDEX (timestamp),
|
||||
INDEX (action)
|
||||
);
|
||||
```
|
||||
|
||||
### 3.4 신규 테이블: system_resources
|
||||
```sql
|
||||
CREATE TABLE system_resources (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
cpu_usage_percent FLOAT,
|
||||
memory_usage_percent FLOAT,
|
||||
disk_usage_percent FLOAT,
|
||||
active_user_count INT,
|
||||
active_project_count INT,
|
||||
total_storage_mb FLOAT,
|
||||
INDEX (timestamp)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 백엔드 API 설계
|
||||
|
||||
### 4.1 B01_Dashboard 라우터 (`B01_Dashboard/B01_Dashboard_Router.py`)
|
||||
|
||||
#### 공통 API
|
||||
|
||||
```python
|
||||
# GET /api/dashboard/me — 현재 사용자 정보 조회
|
||||
@router.get("/api/dashboard/me")
|
||||
async def get_dashboard_me(session: dict = Depends(verify_session)):
|
||||
"""
|
||||
현재 로그인한 사용자의 정보 반환
|
||||
응답: {
|
||||
id, email, name, position, department, phone, company_id, role, is_master, status,
|
||||
last_login, auth_expires_at
|
||||
}
|
||||
"""
|
||||
|
||||
# PATCH /api/dashboard/me — 사용자 정보 수정
|
||||
@router.patch("/api/dashboard/me")
|
||||
async def patch_dashboard_me(
|
||||
request: UpdateUserRequest,
|
||||
session: dict = Depends(verify_session)
|
||||
):
|
||||
"""이름/직급/부서/전화번호 수정"""
|
||||
# 응답: 수정된 사용자 정보
|
||||
```
|
||||
|
||||
#### USER 역할 API
|
||||
|
||||
```python
|
||||
# GET /api/dashboard/user/projects — 사용자의 프로젝트 리스트
|
||||
@router.get("/api/dashboard/user/projects")
|
||||
async def get_user_projects(session: dict = Depends(verify_session)):
|
||||
"""
|
||||
사용자가 생성한 프로젝트 리스트
|
||||
응답: [{id, name, region, status, progress_percent, created_at, updated_at}, ...]
|
||||
"""
|
||||
|
||||
# GET /api/dashboard/user/company — 사용자의 회사 정보
|
||||
@router.get("/api/dashboard/user/company")
|
||||
async def get_user_company(session: dict = Depends(verify_session)):
|
||||
"""현재 소속 회사 정보"""
|
||||
# 응답: {id, name, status, user_count, project_count}
|
||||
|
||||
# GET /api/dashboard/user/companies?q= — 회사 검색
|
||||
@router.get("/api/dashboard/user/companies")
|
||||
async def search_companies_for_join(
|
||||
q: str = Query(...),
|
||||
session: dict = Depends(verify_session)
|
||||
):
|
||||
"""회사명 또는 사업자번호로 검색"""
|
||||
# 응답: [{id, name, business_number, status}, ...]
|
||||
|
||||
# POST /api/dashboard/user/company/create — 신규 회사 생성
|
||||
@router.post("/api/dashboard/user/company/create")
|
||||
async def create_company(
|
||||
request: CreateCompanyRequest,
|
||||
session: dict = Depends(verify_session)
|
||||
):
|
||||
"""
|
||||
신규 회사 생성 + 사용자를 ADMIN(is_master=TRUE)으로 자동 연결
|
||||
- companies 테이블에 INSERT
|
||||
- users.company_id = new_company_id
|
||||
- users.role = 'ADMIN'
|
||||
- users.is_master = TRUE
|
||||
- users.status = 'ACTIVE'
|
||||
"""
|
||||
# 응답: {company_id, status='ACTIVE'}
|
||||
|
||||
# POST /api/dashboard/user/company/join — 회사 참여 신청
|
||||
@router.post("/api/dashboard/user/company/join")
|
||||
async def join_company(
|
||||
request: JoinCompanyRequest,
|
||||
session: dict = Depends(verify_session)
|
||||
):
|
||||
"""
|
||||
기존 회사에 참여 신청
|
||||
- join_requests 테이블에 INSERT (status='PENDING')
|
||||
- users.status = 'PENDING' (관리자 승인 대기)
|
||||
- 관리자에게 이메일 발송
|
||||
"""
|
||||
# 응답: {join_request_id, status='PENDING'}
|
||||
```
|
||||
|
||||
#### ADMIN 역할 API
|
||||
|
||||
```python
|
||||
# GET /api/dashboard/admin/members — 회사 팀원 리스트
|
||||
@router.get("/api/dashboard/admin/members")
|
||||
async def get_company_members(session: dict = Depends(verify_session)):
|
||||
"""회사에 소속된 모든 팀원 조회"""
|
||||
# 응답: [{id, email, name, position, department, role, status}, ...]
|
||||
|
||||
# POST /api/dashboard/admin/members — 팀원 추가
|
||||
@router.post("/api/dashboard/admin/members")
|
||||
async def add_company_member(
|
||||
request: AddMemberRequest,
|
||||
session: dict = Depends(verify_session)
|
||||
):
|
||||
"""
|
||||
기가입 사용자를 회사에 추가
|
||||
- users.company_id = session.company_id
|
||||
- users.status = 'ACTIVE'
|
||||
- 중복 확인 필수
|
||||
"""
|
||||
# 응답: {user_id, status='ACTIVE'}
|
||||
|
||||
# DELETE /api/dashboard/admin/members/{user_id} — 팀원 제거
|
||||
@router.delete("/api/dashboard/admin/members/{user_id}")
|
||||
async def remove_company_member(
|
||||
user_id: int,
|
||||
session: dict = Depends(verify_session)
|
||||
):
|
||||
"""팀원을 회사에서 제거 (soft delete)"""
|
||||
|
||||
# GET /api/dashboard/admin/join-requests — 가입 신청 리스트
|
||||
@router.get("/api/dashboard/admin/join-requests")
|
||||
async def get_company_join_requests(session: dict = Depends(verify_session)):
|
||||
"""이 회사에 가입을 신청한 사용자 리스트"""
|
||||
# 응답: [{id, user_id, user_email, user_name, requested_at, status}, ...]
|
||||
|
||||
# PATCH /api/dashboard/admin/join-requests/{request_id} — 가입 신청 처리
|
||||
@router.patch("/api/dashboard/admin/join-requests/{request_id}")
|
||||
async def process_join_request(
|
||||
request_id: int,
|
||||
request: ProcessJoinRequest, # {action: 'APPROVE' | 'REJECT'}
|
||||
session: dict = Depends(verify_session)
|
||||
):
|
||||
"""
|
||||
가입 신청 승인/거절
|
||||
- APPROVE: join_requests.status='APPROVED', users.status='ACTIVE'
|
||||
- REJECT: join_requests.status='REJECTED'
|
||||
- 사용자에게 이메일 발송
|
||||
"""
|
||||
|
||||
# GET /api/dashboard/admin/projects — 회사 프로젝트 리스트
|
||||
@router.get("/api/dashboard/admin/projects")
|
||||
async def get_company_projects(session: dict = Depends(verify_session)):
|
||||
"""회사에 속한 모든 프로젝트 조회"""
|
||||
# 응답: [{id, name, owner, status, progress_percent}, ...]
|
||||
```
|
||||
|
||||
#### SYSTEM_ADMIN 역할 API
|
||||
|
||||
```python
|
||||
# GET /api/dashboard/admin/companies — 전체 회사 리스트
|
||||
@router.get("/api/dashboard/admin/companies")
|
||||
async def get_all_companies(session: dict = Depends(verify_session)):
|
||||
"""시스템 관리자용: 전체 회사 조회"""
|
||||
# 응답: [{id, name, status, user_count, project_count, created_at}, ...]
|
||||
|
||||
# GET /api/dashboard/admin/users — 전체 사용자 리스트
|
||||
@router.get("/api/dashboard/admin/users")
|
||||
async def get_all_users(session: dict = Depends(verify_session)):
|
||||
"""시스템 관리자용: 전체 사용자 조회"""
|
||||
# 응답: [{id, email, name, role, company_id, status, last_login}, ...]
|
||||
|
||||
# PATCH /api/dashboard/admin/users/{user_id}/role — 사용자 역할 변경
|
||||
@router.patch("/api/dashboard/admin/users/{user_id}/role")
|
||||
async def change_user_role(
|
||||
user_id: int,
|
||||
request: ChangeUserRoleRequest,
|
||||
session: dict = Depends(verify_session)
|
||||
):
|
||||
"""사용자 역할 변경 (USER ↔ ADMIN ↔ SYSTEM_ADMIN)"""
|
||||
|
||||
# PATCH /api/dashboard/admin/users/{user_id}/company — 사용자 회사 할당
|
||||
@router.patch("/api/dashboard/admin/users/{user_id}/company")
|
||||
async def assign_user_to_company(
|
||||
user_id: int,
|
||||
request: AssignCompanyRequest,
|
||||
session: dict = Depends(verify_session)
|
||||
):
|
||||
"""사용자를 다른 회사에 할당"""
|
||||
|
||||
# GET /api/dashboard/admin/join-requests-all — 전체 가입 신청
|
||||
@router.get("/api/dashboard/admin/join-requests-all")
|
||||
async def get_all_join_requests(session: dict = Depends(verify_session)):
|
||||
"""시스템 관리자용: 전체 가입 신청 조회"""
|
||||
|
||||
# PATCH /api/dashboard/admin/join-requests/{request_id}/approve — 시스템 레벨 승인
|
||||
@router.patch("/api/dashboard/admin/join-requests/{request_id}/approve")
|
||||
async def system_approve_join_request(
|
||||
request_id: int,
|
||||
session: dict = Depends(verify_session)
|
||||
):
|
||||
"""시스템 관리자가 가입 신청 승인"""
|
||||
|
||||
# GET /api/dashboard/admin/audit-logs — 시스템 로그
|
||||
@router.get("/api/dashboard/admin/audit-logs")
|
||||
async def get_system_audit_logs(
|
||||
page: int = Query(1),
|
||||
limit: int = Query(50),
|
||||
start_date: str = Query(None),
|
||||
end_date: str = Query(None),
|
||||
user_id: int = Query(None),
|
||||
action: str = Query(None),
|
||||
session: dict = Depends(verify_session)
|
||||
):
|
||||
"""
|
||||
시스템 감시 로그 조회 (필터링 지원)
|
||||
응답: {total, items: [{id, user_id, action, resource_type, timestamp}]}
|
||||
"""
|
||||
|
||||
# GET /api/dashboard/admin/resources — 하드웨어 리소스 현황
|
||||
@router.get("/api/dashboard/admin/resources")
|
||||
async def get_system_resources(
|
||||
period: str = Query('1h'), # 1h, 6h, 24h, 7d
|
||||
session: dict = Depends(verify_session)
|
||||
):
|
||||
"""
|
||||
시스템 리소스 사용량 조회 (그래프용)
|
||||
응답: {
|
||||
current: {cpu_usage_percent, memory_usage_percent, disk_usage_percent},
|
||||
history: [{timestamp, cpu_usage_percent, memory_usage_percent, ...}],
|
||||
stats: {active_user_count, active_project_count, total_storage_mb}
|
||||
}
|
||||
"""
|
||||
```
|
||||
|
||||
### 4.2 Schema 정의 (`B01_Dashboard/B01_Dashboard_Schema.py`)
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
|
||||
# 공통
|
||||
class UpdateUserRequest(BaseModel):
|
||||
name: str
|
||||
position: str | None
|
||||
department: str | None
|
||||
phone: str | None
|
||||
|
||||
# USER 역할
|
||||
class CreateCompanyRequest(BaseModel):
|
||||
name: str
|
||||
business_registration_number: str
|
||||
business_address: str
|
||||
business_owner: str
|
||||
|
||||
class JoinCompanyRequest(BaseModel):
|
||||
company_id: int
|
||||
|
||||
class AddMemberRequest(BaseModel):
|
||||
email: str # 기가입 사용자의 이메일
|
||||
|
||||
class ProcessJoinRequest(BaseModel):
|
||||
action: str # 'APPROVE' | 'REJECT'
|
||||
reason: str | None = None
|
||||
|
||||
# SYSTEM_ADMIN 역할
|
||||
class ChangeUserRoleRequest(BaseModel):
|
||||
role: str # 'USER' | 'ADMIN' | 'SYSTEM_ADMIN'
|
||||
|
||||
class AssignCompanyRequest(BaseModel):
|
||||
company_id: int | None = None # None이면 회사 미연결
|
||||
```
|
||||
|
||||
### 4.3 Repository (`B01_Dashboard/B01_Dashboard_Repository.py`)
|
||||
|
||||
Raw SQL을 이용한 데이터 조회/수정 함수들을 구현
|
||||
|
||||
---
|
||||
|
||||
## 5. 프론트엔드 구현 필요
|
||||
|
||||
### 5.1 B01_Dashboard_UI_Page.ts 전체 재설계
|
||||
|
||||
**구조:**
|
||||
```typescript
|
||||
function DashboardPage() {
|
||||
const user = useAuth(); // role, status, company_id 포함
|
||||
|
||||
return (
|
||||
<div class="dashboard">
|
||||
{/* USER/ADMIN 공통: 프로젝트 리스트 */}
|
||||
{(user.role === 'USER' || user.role === 'ADMIN') && (
|
||||
<ProjectListSection />
|
||||
)}
|
||||
|
||||
{/* USER만: 회사 정보 */}
|
||||
{user.role === 'USER' && (
|
||||
<CompanyInfoSection />
|
||||
)}
|
||||
|
||||
{/* ADMIN만: 팀원 관리 */}
|
||||
{user.role === 'ADMIN' && (
|
||||
<>
|
||||
<CompanyInfoSection />
|
||||
<TeamMembersSection />
|
||||
<JoinRequestsSection />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* SYSTEM_ADMIN만: 시스템 관리 */}
|
||||
{user.role === 'SYSTEM_ADMIN' && (
|
||||
<>
|
||||
<ResourceStatusSection />
|
||||
<CompanyManagementSection />
|
||||
<UserManagementSection />
|
||||
<JoinRequestsManagementSection />
|
||||
<SystemAuditLogsSection />
|
||||
<SystemResourcesGraphSection />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 모든 역할: 기본정보 & 보안 */}
|
||||
<ProfileSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 B01_Dashboard_Api_Fetch.ts 신규
|
||||
|
||||
```typescript
|
||||
// 공통
|
||||
export async function fetchDashboardMe(): Promise<DashboardUser>;
|
||||
export async function updateUserProfile(request: UpdateUserRequest): Promise<void>;
|
||||
|
||||
// USER 역할
|
||||
export async function fetchUserProjects(): Promise<ProjectItem[]>;
|
||||
export async function fetchUserCompany(): Promise<CompanyInfo>;
|
||||
export async function searchCompanies(query: string): Promise<Company[]>;
|
||||
export async function createCompany(request: CreateCompanyRequest): Promise<CompanyCreateResponse>;
|
||||
export async function joinCompany(companyId: number): Promise<JoinCompanyResponse>;
|
||||
|
||||
// ADMIN 역할
|
||||
export async function fetchCompanyMembers(): Promise<Member[]>;
|
||||
export async function addCompanyMember(email: string): Promise<Member>;
|
||||
export async function removeCompanyMember(userId: number): Promise<void>;
|
||||
export async function fetchCompanyJoinRequests(): Promise<JoinRequest[]>;
|
||||
export async function processJoinRequest(requestId: number, action: 'APPROVE' | 'REJECT'): Promise<void>;
|
||||
export async function fetchCompanyProjects(): Promise<ProjectItem[]>;
|
||||
|
||||
// SYSTEM_ADMIN 역할
|
||||
export async function fetchAllCompanies(): Promise<Company[]>;
|
||||
export async function fetchAllUsers(filters?: UserFilters): Promise<User[]>;
|
||||
export async function changeUserRole(userId: number, role: string): Promise<void>;
|
||||
export async function assignUserToCompany(userId: number, companyId: number | null): Promise<void>;
|
||||
export async function fetchAllJoinRequests(): Promise<JoinRequest[]>;
|
||||
export async function systemApproveJoinRequest(requestId: number): Promise<void>;
|
||||
export async function fetchAuditLogs(filters?: AuditLogFilters): Promise<AuditLog[]>;
|
||||
export async function fetchSystemResources(period: string): Promise<ResourceData>;
|
||||
```
|
||||
|
||||
### 5.3 B01_Dashboard_UI_Style.css 확장
|
||||
|
||||
- 역할별 섹션 레이아웃
|
||||
- 프로젝트 리스트 테이블 스타일
|
||||
- 워크플로우 진행도 시각화
|
||||
- 팝업 모달 스타일
|
||||
- 그래프/차트 영역 스타일
|
||||
|
||||
### 5.4 섹션별 컴포넌트
|
||||
|
||||
#### ProjectListSection
|
||||
- 프로젝트 테이블 (프로젝트명, 지역, 진행도, 워크플로우, 수정일)
|
||||
- [+ 신규 프로젝트] 버튼
|
||||
- 워크플로우 단계 클릭 이벤트 처리
|
||||
|
||||
#### CompanyInfoSection (USER용)
|
||||
```
|
||||
회사 상태: 테스트회사 (ACTIVE) | [회사 변경]
|
||||
|
||||
상태별 표시:
|
||||
- NO_COMPANY: "회사 미연결" + [회사 생성] [회사 찾기]
|
||||
- PENDING: "가입 승인 대기 중 (OOO회사)"
|
||||
- ACTIVE: "OOO회사 (ACTIVE)" + [회사 변경]
|
||||
```
|
||||
|
||||
#### TeamMembersSection (ADMIN용)
|
||||
- 팀원 리스트 테이블 (이메일, 이름, 직급, 부서, 역할, 상태)
|
||||
- [+ 팀원 추가] 버튼 → 모달 팝업
|
||||
- [제거] 버튼
|
||||
|
||||
#### JoinRequestsSection (ADMIN용)
|
||||
- 가입 신청 리스트 테이블 (신청자, 신청일, 상태)
|
||||
- [승인] [거절] 버튼
|
||||
|
||||
#### ResourceStatusSection (SYSTEM_ADMIN용)
|
||||
```
|
||||
CPU: 45% | MEM: 62% | DISK: 78%
|
||||
활성 사용자: 23 | 활성 프로젝트: 15
|
||||
```
|
||||
|
||||
#### SystemAuditLogsSection (SYSTEM_ADMIN용)
|
||||
- 시스템 로그 테이블 (사용자, 행동, 대상, 타임스탬프)
|
||||
- 필터링: 기간, 사용자, 행동 유형
|
||||
|
||||
#### SystemResourcesGraphSection (SYSTEM_ADMIN용)
|
||||
- CPU 사용량 그래프 (시간대별)
|
||||
- 메모리 사용량 그래프
|
||||
- 디스크 사용량 그래프
|
||||
- 동시 접속자 수 추이
|
||||
|
||||
---
|
||||
|
||||
## 6. 검증 방법 (다음 단계)
|
||||
|
||||
### 6.1 USER 역할 검증
|
||||
|
||||
1. 새 계정으로 로그인 → B01_Dashboard 진입
|
||||
2. 프로젝트 리스트 표시 확인
|
||||
3. 회사 상태: "회사 미연결" 표시 + [회사 생성] [회사 찾기] 버튼 확인
|
||||
4. [회사 생성] → 모달 팝업 → 회사 정보 입력 → [생성]
|
||||
5. DB 확인:
|
||||
- `users.company_id` 세팅
|
||||
- `users.role = 'ADMIN'` (관리자 전환)
|
||||
- `users.is_master = TRUE`
|
||||
- `users.status = 'ACTIVE'`
|
||||
6. 페이지 새로고침 → 회사명 표시 확인
|
||||
|
||||
### 6.2 ADMIN 역할 검증
|
||||
|
||||
1. 회사 생성자(관리자)로 로그인 → B01_Dashboard 진입
|
||||
2. 팀원 관리, 가입 요청, 회사 프로젝트 섹션 표시 확인
|
||||
3. [+ 팀원 추가] → 모달 → 기가입 사용자 이메일 입력 → 검색 → 선택 → [추가]
|
||||
4. DB 확인:
|
||||
- `users.company_id` 업데이트
|
||||
- 중복 확인 (이미 팀원인 경우 오류 메시지)
|
||||
5. 팀원 리스트에 새로운 팀원 표시 확인
|
||||
|
||||
### 6.3 SYSTEM_ADMIN 역할 검증
|
||||
|
||||
1. 시스템 관리자 계정으로 로그인 → B01_Dashboard 진입
|
||||
2. 전체 회사 리스트, 사용자 리스트, 시스템 로그, 리소스 현황 섹션 표시 확인
|
||||
3. 리소스 현황 (CPU, MEM, DISK, 활성 사용자/프로젝트) 표시 확인
|
||||
4. 사용자 역할 변경: USER → ADMIN 전환 → DB 확인
|
||||
5. 시스템 로그 필터링 (기간, 사용자, 행동 유형) 동작 확인
|
||||
|
||||
@@ -1,127 +1,400 @@
|
||||
# 회원가입 플로우 개편 검증 보고서
|
||||
# B01_Dashboard 구현 검증 계획
|
||||
|
||||
## 구현 완료 항목
|
||||
|
||||
### 1. DB 변경
|
||||
- ✅ `db_management/003_register_split.sql` 생성
|
||||
- `users.status` ENUM에 `'NO_COMPANY'` 추가
|
||||
- 회원가입 시 회사 정보 NULL로 시작하도록 변경
|
||||
|
||||
### 2. 백엔드 변경
|
||||
- ✅ `A07_Register_Schema.py` 수정
|
||||
- `RegisterRequest` 필드: `email`, `password`, `password_confirm`, `name`, `position`, `phone`, `terms_agreed`, `privacy_agreed`, `marketing_agreed`, `terms_version`
|
||||
- 회사 관련 필드 전부 제거
|
||||
- `password_confirm` 검증 추가
|
||||
|
||||
- ✅ `common_util_auth_repository.py` 수정
|
||||
- `create_registration()`: 회사/join_request 생성 로직 제거, users INSERT만 수행
|
||||
- `complete_registration()`: 무조건 `status = 'NO_COMPANY'`로 변경
|
||||
|
||||
- ✅ `A07_Register_Router.py` 수정
|
||||
- `request_registration()`: `password_confirm` 제외 처리
|
||||
|
||||
- ✅ `A06_Login_Router.py` 수정
|
||||
- `request_login()`: `status not in ("ACTIVE", "NO_COMPANY")` 체크로 변경
|
||||
- `current_session()`: 응답에 `company_id`, `is_master` 추가
|
||||
|
||||
- ✅ `common_util_auth.py` 수정
|
||||
- `require_company()` dependency 추가
|
||||
|
||||
- ✅ `main.py` 수정
|
||||
- B03~B06 라우터에 `require_company` 의존성 추가
|
||||
|
||||
### 3. 리소스 파일
|
||||
- ✅ `resources/legal/terms_of_service.txt` 생성
|
||||
- ✅ `resources/legal/privacy_policy.txt` 생성
|
||||
- ✅ `resources/legal/marketing_consent.txt` 생성
|
||||
|
||||
### 4. 프론트엔드 변경
|
||||
- ✅ `A07_Register_Api_Fetch.ts` 수정
|
||||
- `RegisterPayload` 필드 축소
|
||||
|
||||
- ✅ `A07_Register_UI_Auth_Page.ts` 전면 재작성
|
||||
- 필드: name, position, email, phone, password, passwordConfirm, terms/privacy/marketing (아코디언)
|
||||
- 약관 텍스트 파일 로드 기능
|
||||
- OTP 입력 창 기존 로직 유지
|
||||
|
||||
- ✅ `A00_Common/router.ts` 수정
|
||||
- B02~B11 라우트: company_id 없으면 B01로 리다이렉트
|
||||
|
||||
- ✅ `ui_template_locale.ts` 수정
|
||||
- `A07_Register_Field_Position`, `A07_Register_Field_Phone`, `A07_Register_Field_PasswordConfirm`
|
||||
- `A07_Register_Error_PasswordMismatch` 추가
|
||||
|
||||
- ✅ `A07_Register_UI_Style.css` 수정
|
||||
- `.a07-register__agreement` 아코디언 스타일 추가
|
||||
|
||||
- ✅ `A06_Login_Api_Fetch.ts` 수정
|
||||
- `SessionUser` 인터페이스에 `company_id`, `is_master` 추가
|
||||
|
||||
## 검증 방법
|
||||
|
||||
### 1차 검증: 가입 및 이메일 인증
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
브라우저에서:
|
||||
1. `/#/a07-register` 접속
|
||||
2. 필드 확인: name, position, email, phone, password, passwordConfirm, 약관(3개)
|
||||
3. 약관 체크박스 옆 라벨 클릭 → `<details>` 펼쳐서 내용 표시 확인
|
||||
4. 모든 필드 입력 후 "회원가입" 버튼 클릭
|
||||
5. → 폼 하단에 OTP 입력 칸 노출 확인
|
||||
6. 콘솔 로그 또는 메일에서 OTP 확인하여 입력
|
||||
7. "이메일 인증 완료" 버튼 클릭
|
||||
8. → 로그인 페이지로 리다이렉트 확인
|
||||
|
||||
### 2차 검증: DB 상태 확인
|
||||
```sql
|
||||
SELECT id, email, status, company_id, role, is_master FROM users WHERE email = 'test@example.com';
|
||||
```
|
||||
예상 결과:
|
||||
- `status` = 'NO_COMPANY'
|
||||
- `company_id` = NULL
|
||||
- `role` = 'MEMBER'
|
||||
- `is_master` = FALSE
|
||||
|
||||
### 3차 검증: 로그인 및 상태
|
||||
1. 가입한 계정으로 로그인 시도 → 성공 확인 (기존에는 status != ACTIVE로 실패했을 부분)
|
||||
2. 로그인 후 B02_PROJ_REGISTER (`/#/b02-proj-register`) 접속 시도 → B01_ACCOUNT로 리다이렉트 확인
|
||||
|
||||
### 4차 검증: 회사 없이 B02+ 접근 차단
|
||||
B01_ACCOUNT 페이지는 아직 구현되지 않았으므로, 라우팅 가드만 동작 확인:
|
||||
- `/#/b03-file-input` 직접 접속 → B01로 리다이렉트되거나 403 응답 확인 (백엔드 require_company 에러)
|
||||
|
||||
## 향후 구현 필요 항목
|
||||
|
||||
1. **B01_AccountDetail 백엔드 구현** (예정)
|
||||
- `GET /api/account/me` — 현재 계정 상세
|
||||
- `PATCH /api/account/me` — 이름/직급/전화번호 수정
|
||||
- `POST /api/account/company/create` — 신규 회사 생성
|
||||
- `POST /api/account/company/join` — 기존 회사 참여 신청
|
||||
|
||||
2. **B01_AccountDetail 프론트엔드 구현** (예정)
|
||||
- 계정 정보 표시/수정 폼
|
||||
- "회사 생성" 섹션
|
||||
- "회사 검색·연결" 섹션
|
||||
|
||||
3. **라우팅 가드 최종 테스트** (기준 마련 후)
|
||||
- 회사 연결 후 B02~B11 접근 가능 확인
|
||||
|
||||
## 완료 상태
|
||||
|
||||
🟢 **계획서 기반 구현 완료 (1단계~3단계)**
|
||||
- DB 마이그레이션
|
||||
- 백엔드 로직 수정
|
||||
- 프론트엔드 UI/라우팅
|
||||
|
||||
🟡 **B01_AccountDetail 백엔드/프론트엔드는 별도 태스크 (4단계)**
|
||||
|
||||
🟢 **최종 검증 가능 (3차까지)**
|
||||
- 회원가입, 이메일 인증, NO_COMPANY 상태 확인
|
||||
- 로그인 가능 (NO_COMPANY 상태)
|
||||
- B02+ 접근 차단 동작 확인
|
||||
**상태:** 구현 전 설계 검증 단계
|
||||
**참고:** 완료된 검증은 `.agent/complete_and_old/plan_register_split_validation_COMPLETED.md` 참조
|
||||
|
||||
---
|
||||
|
||||
**마지막 업데이트:** 2026-01-07
|
||||
**상태:** 1단계 구현 완료, B01_AccountDetail은 별도 작업
|
||||
## 📋 B01_Dashboard 구현 체크리스트
|
||||
|
||||
B01_Dashboard 페이지는 로그인 후 메인 랜딩 페이지로, 역할에 따라 다양한 섹션을 동적으로 표시합니다.
|
||||
|
||||
### 현재 상태
|
||||
|
||||
- ✅ 기본 설계 완료
|
||||
- ❌ **백엔드 API 미구현** (역할별 엔드포인트)
|
||||
- ❌ **프론트엔드 UI 미구현** (역할별 컴포넌트)
|
||||
- ❌ **DB 스키마 업데이트 미실행** (사용자/회사/조직 테이블)
|
||||
|
||||
### 구현 필요 항목
|
||||
|
||||
#### DB 스키마 업데이트
|
||||
```
|
||||
[ ] users 테이블 컬럼 추가
|
||||
[ ] role (ENUM: SYSTEM_ADMIN, ADMIN, USER)
|
||||
[ ] is_master (BOOLEAN)
|
||||
[ ] status (ENUM: PENDING_EMAIL, NO_COMPANY, PENDING, ACTIVE, INACTIVE, REJECTED)
|
||||
[ ] last_login (DATETIME)
|
||||
[ ] auth_expires_at (DATETIME)
|
||||
|
||||
[ ] 신규 테이블 생성
|
||||
[ ] join_requests 테이블
|
||||
[ ] system_audit_logs 테이블
|
||||
[ ] system_resources 테이블
|
||||
```
|
||||
|
||||
#### 백엔드 (B01_Dashboard)
|
||||
```
|
||||
[ ] B01_Dashboard_Router.py 신규 생성
|
||||
# 공통 API
|
||||
[ ] GET /api/dashboard/me
|
||||
[ ] PATCH /api/dashboard/me
|
||||
|
||||
# USER 역할 API
|
||||
[ ] GET /api/dashboard/user/projects
|
||||
[ ] GET /api/dashboard/user/company
|
||||
[ ] GET /api/dashboard/user/companies?q=
|
||||
[ ] POST /api/dashboard/user/company/create
|
||||
[ ] POST /api/dashboard/user/company/join
|
||||
|
||||
# ADMIN 역할 API
|
||||
[ ] GET /api/dashboard/admin/members
|
||||
[ ] POST /api/dashboard/admin/members
|
||||
[ ] DELETE /api/dashboard/admin/members/{user_id}
|
||||
[ ] GET /api/dashboard/admin/join-requests
|
||||
[ ] PATCH /api/dashboard/admin/join-requests/{request_id}
|
||||
[ ] GET /api/dashboard/admin/projects
|
||||
|
||||
# SYSTEM_ADMIN 역할 API
|
||||
[ ] GET /api/dashboard/admin/companies
|
||||
[ ] GET /api/dashboard/admin/users
|
||||
[ ] PATCH /api/dashboard/admin/users/{user_id}/role
|
||||
[ ] PATCH /api/dashboard/admin/users/{user_id}/company
|
||||
[ ] GET /api/dashboard/admin/join-requests-all
|
||||
[ ] PATCH /api/dashboard/admin/join-requests/{request_id}/approve
|
||||
[ ] GET /api/dashboard/admin/audit-logs
|
||||
[ ] GET /api/dashboard/admin/resources
|
||||
|
||||
[ ] B01_Dashboard_Schema.py 신규 생성
|
||||
[ ] UpdateUserRequest
|
||||
[ ] CreateCompanyRequest
|
||||
[ ] JoinCompanyRequest
|
||||
[ ] AddMemberRequest
|
||||
[ ] ProcessJoinRequest
|
||||
[ ] ChangeUserRoleRequest
|
||||
[ ] AssignCompanyRequest
|
||||
|
||||
[ ] B01_Dashboard_Repository.py 신규 생성
|
||||
[ ] 사용자 정보 조회/수정 SQL
|
||||
[ ] 회사 조회/생성 SQL
|
||||
[ ] 팀원 조회/추가/제거 SQL
|
||||
[ ] 가입 신청 조회/승인/거절 SQL
|
||||
[ ] 시스템 로그 조회 SQL
|
||||
[ ] 리소스 모니터링 SQL
|
||||
```
|
||||
|
||||
#### 프론트엔드 (B01_Dashboard)
|
||||
```
|
||||
[ ] B01_Dashboard_UI_Page.ts 신규 생성
|
||||
[ ] 역할별 섹션 조건부 렌더링
|
||||
[ ] ProjectListSection (USER, ADMIN)
|
||||
[ ] CompanyInfoSection (USER, ADMIN)
|
||||
[ ] TeamMembersSection (ADMIN)
|
||||
[ ] JoinRequestsSection (ADMIN)
|
||||
[ ] ResourceStatusSection (SYSTEM_ADMIN)
|
||||
[ ] CompanyManagementSection (SYSTEM_ADMIN)
|
||||
[ ] UserManagementSection (SYSTEM_ADMIN)
|
||||
[ ] JoinRequestsManagementSection (SYSTEM_ADMIN)
|
||||
[ ] SystemAuditLogsSection (SYSTEM_ADMIN)
|
||||
[ ] SystemResourcesGraphSection (SYSTEM_ADMIN)
|
||||
[ ] ProfileSection (모든 역할)
|
||||
|
||||
[ ] B01_Dashboard_Api_Fetch.ts 신규 생성
|
||||
[ ] fetchDashboardMe()
|
||||
[ ] updateUserProfile()
|
||||
[ ] fetchUserProjects()
|
||||
[ ] fetchUserCompany()
|
||||
[ ] searchCompanies()
|
||||
[ ] createCompany()
|
||||
[ ] joinCompany()
|
||||
[ ] fetchCompanyMembers()
|
||||
[ ] addCompanyMember()
|
||||
[ ] removeCompanyMember()
|
||||
[ ] fetchCompanyJoinRequests()
|
||||
[ ] processJoinRequest()
|
||||
[ ] fetchCompanyProjects()
|
||||
[ ] fetchAllCompanies()
|
||||
[ ] fetchAllUsers()
|
||||
[ ] changeUserRole()
|
||||
[ ] assignUserToCompany()
|
||||
[ ] fetchAllJoinRequests()
|
||||
[ ] systemApproveJoinRequest()
|
||||
[ ] fetchAuditLogs()
|
||||
[ ] fetchSystemResources()
|
||||
|
||||
[ ] B01_Dashboard_UI_Style.css 신규 생성
|
||||
[ ] 역할별 섹션 레이아웃
|
||||
[ ] 프로젝트 리스트 테이블 스타일
|
||||
[ ] 워크플로우 진행도 시각화
|
||||
[ ] 팝업 모달 스타일
|
||||
[ ] 그래프/차트 영역 스타일
|
||||
|
||||
[ ] ui_template_locale.ts 확장
|
||||
[ ] 역할별 섹션 제목 및 버튼 문구
|
||||
[ ] 회사 관리, 팀원 관리, 가입 요청 관련 문구
|
||||
[ ] 시스템 로그, 리소스 모니터링 관련 문구
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 기능 상세 검증 항목
|
||||
|
||||
### 프로젝트 리스트 워크플로우 표시
|
||||
|
||||
**UI 검증 포인트:**
|
||||
- [ ] 각 프로젝트별 진행도 정상 계산 (B03~B09 단계 기준)
|
||||
- [ ] 워크플로우 단계 버튼 활성화/비활성화 정상 작동
|
||||
- [ ] 완료된 단계 클릭 시 해당 페이지로 이동
|
||||
- [ ] 진행 중 단계 클릭 시 현재 진행 상태 표시
|
||||
- [ ] 미진행 단계 클릭 시 반응 없음 (비활성화 상태 유지)
|
||||
|
||||
**데이터 검증 포인트:**
|
||||
- [ ] projects.status 값이 올바르게 매핑되는지 확인
|
||||
- [ ] 프로젝트명, 지역, 수정일 정상 표시
|
||||
- [ ] [+ 신규 프로젝트] 버튼 클릭 시 B02_ProjRegister로 이동
|
||||
|
||||
### 회사 상태 섹션 (USER 역할)
|
||||
|
||||
**상태별 UI 검증:**
|
||||
- [ ] `status = 'NO_COMPANY'`: "회사 미연결" + [회사 생성] [회사 찾기] 버튼
|
||||
- [ ] `status = 'PENDING'`: "가입 승인 대기 중 (회사명)" 표시
|
||||
- [ ] `status = 'ACTIVE'`: "회사명 (ACTIVE)" + [회사 변경] 버튼
|
||||
|
||||
**회사 생성 팝업 검증:**
|
||||
- [ ] [회사 생성] 클릭 시 모달 팝업 노출
|
||||
- [ ] 회사 정보 입력 (이름, 사업자등록번호, 주소, 대표자명)
|
||||
- [ ] [생성] 클릭 시 API 호출 (로딩 오버레이 표시)
|
||||
- [ ] 성공 메시지 표시 후 페이지 자동 새로고침
|
||||
- [ ] DB 확인: users.role = 'ADMIN', is_master = TRUE, status = 'ACTIVE'
|
||||
|
||||
**회사 검색/신청 팝업 검증:**
|
||||
- [ ] [회사 찾기] 클릭 시 모달 팝업 노출
|
||||
- [ ] 검색어 입력 → [검색] 클릭
|
||||
- [ ] 회사 리스트 표시 (회사명, 사업자번호)
|
||||
- [ ] 회사 선택 후 [신청] 클릭
|
||||
- [ ] DB 확인: join_requests 생성, users.status = 'PENDING'
|
||||
|
||||
### 팀원 관리 섹션 (ADMIN 역할)
|
||||
|
||||
**팀원 추가 검증:**
|
||||
- [ ] [+ 팀원 추가] 클릭 시 모달 팝업 노출
|
||||
- [ ] 기가입 사용자 이메일 입력 → [검색]
|
||||
- [ ] 사용자 정보 표시 (이메일, 이름, 직급)
|
||||
- [ ] [추가] 클릭 시 API 호출
|
||||
- [ ] DB 확인: users.company_id 업데이트
|
||||
- [ ] 중복 추가 시 "이미 팀원입니다" 오류 메시지
|
||||
|
||||
**팀원 리스트 표시:**
|
||||
- [ ] 현재 회사의 모든 팀원 조회 (테이블)
|
||||
- [ ] 이메일, 이름, 직급, 부서, 역할, 상태 정상 표시
|
||||
- [ ] [제거] 버튼 클릭 시 팀원 제거
|
||||
|
||||
### 가입 요청 섹션 (ADMIN 역할)
|
||||
|
||||
**가입 요청 리스트 검증:**
|
||||
- [ ] 이 회사에 신청한 사용자 리스트 표시
|
||||
- [ ] 신청자 이메일, 신청일, 상태 정상 표시
|
||||
- [ ] [승인] 클릭 시 join_requests.status = 'APPROVED'
|
||||
- [ ] [거절] 클릭 시 join_requests.status = 'REJECTED'
|
||||
- [ ] 사용자에게 결과 이메일 발송
|
||||
|
||||
### 시스템 관리 섹션 (SYSTEM_ADMIN 역할)
|
||||
|
||||
**리소스 현황 검증:**
|
||||
- [ ] CPU, 메모리, 디스크 사용률 실시간 표시
|
||||
- [ ] 활성 사용자 수, 활성 프로젝트 수 표시
|
||||
|
||||
**회사 관리 검증:**
|
||||
- [ ] 전체 회사 리스트 조회 (테이블)
|
||||
- [ ] 회사명, 사업자번호, 상태, 사용자 수 정상 표시
|
||||
- [ ] [+ 회사 추가] 버튼 (회사 신규 생성)
|
||||
|
||||
**사용자 관리 검증:**
|
||||
- [ ] 전체 사용자 리스트 조회 (테이블)
|
||||
- [ ] 이메일, 이름, 역할, 회사, 상태 정상 표시
|
||||
- [ ] [역할 변경] 클릭 시 역할 선택 팝업
|
||||
- [ ] [회사 할당] 클릭 시 회사 선택 팝업
|
||||
|
||||
**시스템 로그 검증:**
|
||||
- [ ] 전체 로그 조회 (테이블)
|
||||
- [ ] 사용자, 행동(LOGIN, USER_CREATE 등), 대상, 타임스탬프 정상 표시
|
||||
- [ ] 기간, 사용자, 행동 유형별 필터링 정상 작동
|
||||
|
||||
**리소스 모니터링 그래프:**
|
||||
- [ ] CPU 사용량 그래프 (시간대별)
|
||||
- [ ] 메모리 사용량 그래프
|
||||
- [ ] 디스크 사용량 그래프
|
||||
- [ ] 동시 접속자 수 추이 그래프
|
||||
|
||||
### 인증 갱신 검증
|
||||
|
||||
**로그인 기반 인증 갱신:**
|
||||
- [ ] 로그인 성공 시 users.last_login 업데이트
|
||||
- [ ] users.auth_expires_at = NOW() + 3개월 설정
|
||||
- [ ] 유효한 인증: auth_expires_at > NOW() → 바로 대시보드 이동
|
||||
- [ ] 만료된 인증: auth_expires_at <= NOW() → 재인증 모달 표시
|
||||
|
||||
---
|
||||
|
||||
## 통합 테스트 시나리오 (다음 단계)
|
||||
|
||||
### 시나리오 1: 회사 생성 (USER → ADMIN 전환)
|
||||
|
||||
**前提:**
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
**테스트 단계:**
|
||||
1. 새로 가입한 계정으로 로그인 → B01_Dashboard 진입
|
||||
2. 회사 상태: "회사 미연결" 표시 확인
|
||||
3. [회사 생성] 클릭 → 모달 팝업 노출
|
||||
4. 회사 정보 입력:
|
||||
```
|
||||
회사명: "테스트회사"
|
||||
사업자등록번호: "123-45-67890"
|
||||
주소: "서울시 강남구"
|
||||
대표자명: "김테스트"
|
||||
```
|
||||
5. [생성] 클릭 → API 호출
|
||||
6. 성공 메시지 → 페이지 자동 새로고침
|
||||
|
||||
**검증:**
|
||||
```sql
|
||||
SELECT id, email, company_id, role, is_master, status
|
||||
FROM users
|
||||
WHERE email = 'test@example.com';
|
||||
```
|
||||
|
||||
**예상 결과:**
|
||||
- `company_id` = 생성된 회사 ID
|
||||
- `role` = 'ADMIN'
|
||||
- `is_master` = TRUE
|
||||
- `status` = 'ACTIVE'
|
||||
|
||||
**라우팅 확인:**
|
||||
- B02_ProjRegister 접속 가능 ✅
|
||||
- 팀원 관리, 가입 요청 섹션 표시 ✅
|
||||
|
||||
---
|
||||
|
||||
### 시나리오 2: 회사 참여 신청 (USER → PENDING → ACTIVE)
|
||||
|
||||
**전제:** 계정1이 회사 생성 완료 (시나리오 1 수행)
|
||||
|
||||
**테스트 단계:**
|
||||
1. 새로 가입한 계정2로 로그인 → B01_Dashboard 진입
|
||||
2. 회사 상태: "회사 미연결" + [회사 찾기] 클릭
|
||||
3. 검색 팝업 → "테스트회사" 검색
|
||||
4. 회사 선택 → [신청] 클릭 → API 호출
|
||||
|
||||
**검증 (계정2):**
|
||||
```sql
|
||||
SELECT * FROM join_requests WHERE user_id = 2;
|
||||
```
|
||||
- `status` = 'PENDING'
|
||||
|
||||
```sql
|
||||
SELECT status FROM users WHERE id = 2;
|
||||
```
|
||||
- `status` = 'PENDING'
|
||||
|
||||
5. 계정2로 B02_ProjRegister 접속 시도 → B01_Dashboard로 리다이렉트 (회사 승인 대기 알림)
|
||||
|
||||
**관리자 승인 (계정1):**
|
||||
1. 계정1로 로그인 → B01_Dashboard
|
||||
2. "가입 요청" 섹션 → 계정2의 신청 표시
|
||||
3. [승인] 클릭
|
||||
|
||||
**검증 (계정2):**
|
||||
```sql
|
||||
SELECT status FROM users WHERE id = 2;
|
||||
```
|
||||
- `status` = 'ACTIVE'
|
||||
- `company_id` = 1
|
||||
|
||||
4. 계정2로 B02_ProjRegister 접속 가능 ✅
|
||||
|
||||
---
|
||||
|
||||
### 시나리오 3: 팀원 추가 (ADMIN만 접근 가능)
|
||||
|
||||
**전제:** 계정1이 회사 생성 완료, 계정2와 계정3이 회원가입 완료
|
||||
|
||||
**테스트 단계:**
|
||||
1. 계정1 (ADMIN)로 로그인 → B01_Dashboard
|
||||
2. "팀원 관리" 섹션 → [+ 팀원 추가]
|
||||
3. 계정2의 이메일 입력 → [검색]
|
||||
4. 계정2 선택 → [추가]
|
||||
|
||||
**검증:**
|
||||
```sql
|
||||
SELECT company_id FROM users WHERE id = 3; -- 계정2
|
||||
```
|
||||
- `company_id` = 1 (회사ID)
|
||||
|
||||
5. 팀원 리스트에 계정2 표시 ✅
|
||||
6. 계정2로 로그인 → B02_ProjRegister 접속 가능 ✅
|
||||
|
||||
---
|
||||
|
||||
### 시나리오 4: 시스템 관리자 기능 (SYSTEM_ADMIN만 접근)
|
||||
|
||||
**전제:** SYSTEM_ADMIN 계정 준비 (DB에서 직접 역할 설정)
|
||||
|
||||
**테스트 단계:**
|
||||
1. SYSTEM_ADMIN 계정으로 로그인 → B01_Dashboard
|
||||
2. "리소스 현황" 섹션 → CPU/MEM/DISK 사용률 표시 확인
|
||||
3. "회사 관리" 섹션 → 전체 회사 리스트 조회 ✅
|
||||
4. "사용자 관리" 섹션 → 전체 사용자 리스트 조회 ✅
|
||||
5. "사용자 역할 변경" → 계정2를 ADMIN으로 전환
|
||||
6. "가입 요청 관리" → 전체 가입 신청 조회/승인 ✅
|
||||
7. "시스템 로그" → 로그인/계정 생성 이력 필터링 조회 ✅
|
||||
8. "리소스 모니터링" → 그래프 표시 ✅
|
||||
|
||||
---
|
||||
|
||||
## 최종 체크리스트
|
||||
|
||||
### 구현 단계
|
||||
```
|
||||
✅ DB 스키마 설계 완료 (사용자/회사/조직 5개 테이블)
|
||||
✅ 백엔드 API 설계 완료 (역할별 엔드포인트)
|
||||
✅ 프론트엔드 구조 설계 완료 (역할별 컴포넌트)
|
||||
|
||||
⏳ DB 마이그레이션 (다음 AI 작업)
|
||||
⏳ 백엔드 API 구현 (다음 AI 작업)
|
||||
⏳ 프론트엔드 UI 구현 (다음 AI 작업)
|
||||
```
|
||||
|
||||
### 검증 단계
|
||||
```
|
||||
[ ] 시나리오 1: 회사 생성 (USER → ADMIN 전환)
|
||||
[ ] 시나리오 2: 회사 참여 신청 (USER → PENDING → ACTIVE)
|
||||
[ ] 시나리오 3: 팀원 추가 (ADMIN)
|
||||
[ ] 시나리오 4: 시스템 관리자 기능 (SYSTEM_ADMIN)
|
||||
[ ] 인증 갱신 로직 검증 (로그인 기반 3개월 연장)
|
||||
```
|
||||
|
||||
### 라우팅 가드 검증
|
||||
```
|
||||
[ ] NO_COMPANY 상태 → B02 접근 시 B01으로 리다이렉트
|
||||
[ ] PENDING 상태 → B02 접근 시 B01으로 리다이렉트
|
||||
[ ] ACTIVE 상태 → B02~B11 정상 접근
|
||||
[ ] 역할별 대시보드 섹션 정상 표시
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 예상 완료 일정
|
||||
|
||||
- **계획/설계:** ✅ 완료
|
||||
- **DB 마이그레이션:** 다음 세션 (코더 AI 담당)
|
||||
- **백엔드 API 구현:** 다음 세션 (코더 AI 담당)
|
||||
- **프론트엔드 UI 구현:** 다음 세션 (코더 AI 담당)
|
||||
- **통합 테스트 및 검증:** 다음 세션 (사용자 검증)
|
||||
|
||||
---
|
||||
|
||||
**마지막 업데이트:** 2025-07-08 (설계 완료)
|
||||
**다음 단계:** DB 마이그레이션 및 백엔드 API 구현
|
||||
|
||||
+13
-6
@@ -40,7 +40,11 @@ my-project/
|
||||
│ └── 002_auth_security.sql # 기존 DB 로그인·보안 증분 마이그레이션
|
||||
├── resources/ # 웹앱 UI 구성 리소스 (사진, 회사 로고 등 에셋)
|
||||
│ ├── logo.png
|
||||
│ └── banner.jpg
|
||||
│ ├── banner.jpg
|
||||
│ └── legal/ # 약관 및 정책 텍스트 (회원가입 UI에서 사용)
|
||||
│ ├── terms_of_service.txt # 이용약관
|
||||
│ ├── privacy_policy.txt # 개인정보처리방침
|
||||
│ └── marketing_consent.txt # 마케팅 정보 수신 동의
|
||||
├── storage/ # 영구 저장소 (데이터 커서 및 대용량 파일 별도 저장 로직용)
|
||||
│ └── [고객사명]/
|
||||
│ └── [사용자명]/
|
||||
@@ -76,8 +80,7 @@ my-project/
|
||||
│ ├── A05_EduDetail_UI_Page.ts
|
||||
│ └── A05_EduDetail_UI_Style.css
|
||||
├── A06_Login/ # 로그인 전 06: 로그인 (이메일 + 비밀번호 폼)
|
||||
│ ├── A06_Login_UI_Page.ts
|
||||
│ ├── A06_Login_UI_Auth_Page.ts # 세션 쿠키·OTP 로그인 UI
|
||||
│ ├── A06_Login_UI_Auth_Page.ts # 세션 쿠키·OTP 로그인 UI (현재 사용 중)
|
||||
│ ├── A06_Login_UI_Style.css
|
||||
│ ├── A06_Login_Api_Fetch.ts
|
||||
│ ├── A06_Login_Schema.py
|
||||
@@ -101,9 +104,13 @@ my-project/
|
||||
│ └── A09_Security_Router.py # 마스터·시스템 관리자·활동 로그 API
|
||||
│
|
||||
│ # ※ B01·B02 본문 완성 / B03~B11 헤더·셸만 (본문은 0_old 참고 후 구체화)
|
||||
├── B01_AccountDetail/ # 로그인 후 01: 계정 상세 (기본정보 수정 + 비밀번호 변경)
|
||||
│ ├── B01_AccountDetail_UI_Page.ts
|
||||
│ └── B01_AccountDetail_UI_Style.css
|
||||
├── B01_Dashboard/ # 로그인 후 01: 대시보드 (역할별 정보 표시)
|
||||
│ ├── B01_Dashboard_UI_Page.ts # 역할별 섹션 조건부 렌더링
|
||||
│ ├── B01_Dashboard_UI_Style.css # 프로젝트 테이블, 워크플로우, 팝업 스타일
|
||||
│ ├── B01_Dashboard_Api_Fetch.ts # dashboard API 클라이언트
|
||||
│ ├── B01_Dashboard_Schema.py # 요청/응답 Pydantic 검증
|
||||
│ ├── B01_Dashboard_Repository.py # Raw SQL 저장소 (사용자, 회사, 팀원, 가입 신청)
|
||||
│ └── B01_Dashboard_Router.py # 역할별 API 엔드포인트
|
||||
├── B02_ProjRegister/ # 로그인 후 02: 프로젝트 등록 (기본정보 입력 폼)
|
||||
│ ├── B02_ProjRegister_UI_Page.ts
|
||||
│ └── B02_ProjRegister_UI_Style.css
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"Bash(./venv/Scripts/python.exe -m unittest discover -s tests -p \"test_b04_surface_filter_pmf.py\" -v)",
|
||||
"Bash(./venv/Scripts/python.exe -m unittest discover -s tests -p \"test_b04_surface_filter_ransac.py\" -v)",
|
||||
"Bash(curl -s http://localhost:5175/ui_template/ui_template_theme.css)",
|
||||
"Bash(npm run *)"
|
||||
"Bash(npm run *)",
|
||||
"PowerShell(Get-Command python)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -12,10 +12,11 @@ venv/
|
||||
|
||||
# Node / Frontend (루트 기반 구조)
|
||||
node_modules/
|
||||
config\node_modules
|
||||
node_modules/
|
||||
config/node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
instance/
|
||||
# Storage (물리 산출물)
|
||||
storage/projects/*/processed/*.json
|
||||
storage/projects/*/processed/*.png
|
||||
@@ -27,4 +28,4 @@ storage/projects/*/exports/*.json
|
||||
0_old/
|
||||
|
||||
# 신규 백업
|
||||
storage
|
||||
storage/
|
||||
|
||||
@@ -34,7 +34,7 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
|
||||
(await import("../A09_Security/A09_Security_UI_Page")).renderA09Security,
|
||||
// 로그인 후 (B 그룹)
|
||||
[ROUTES.B01_ACCOUNT]: async () =>
|
||||
(await import("../B01_AccountDetail/B01_AccountDetail_UI_Page")).renderB01AccountDetail,
|
||||
(await import("../B01_Dashboard/B01_Dashboard_UI_Page")).renderB01Dashboard,
|
||||
[ROUTES.B02_PROJ_REGISTER]: async () =>
|
||||
(await import("../B02_ProjRegister/B02_ProjRegister_UI_Page")).renderB02ProjRegister,
|
||||
[ROUTES.B03_FILE_INPUT]: async () =>
|
||||
|
||||
@@ -66,7 +66,7 @@ async def request_login(payload: LoginRequest, request: Request):
|
||||
subject, html = security_alert_email(email, failures)
|
||||
send_email_background(ADMIN_EMAIL, subject, html)
|
||||
raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 올바르지 않습니다.")
|
||||
if user["status"] not in ("ACTIVE", "NO_COMPANY"):
|
||||
if user["status"] not in ("ACTIVE", "NO_COMPANY", "PENDING"):
|
||||
await record_login(user["id"], email, "FAILURE", "ACCOUNT_INACTIVE", agent)
|
||||
raise HTTPException(status_code=403, detail="활성화되지 않은 계정입니다.")
|
||||
|
||||
@@ -87,6 +87,17 @@ async def request_login(payload: LoginRequest, request: Request):
|
||||
async def _finish_login(user: dict, agent: str) -> JSONResponse:
|
||||
await clear_login_failures(user["id"])
|
||||
await trust_browser(user["id"], hash_user_agent(agent))
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET last_login = CURRENT_TIMESTAMP,
|
||||
auth_expires_at = DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 3 MONTH)
|
||||
WHERE id = %s""",
|
||||
(user["id"],),
|
||||
)
|
||||
await connection.commit()
|
||||
session_id = await create_session(user["id"], agent)
|
||||
await record_login(user["id"], user["email"], "SUCCESS", None, agent)
|
||||
response = JSONResponse(
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
|
||||
export interface DashboardUser {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
position?: string | null;
|
||||
department?: string | null;
|
||||
phone?: string | null;
|
||||
company_id?: number | null;
|
||||
company_name?: string | null;
|
||||
role: "SYSTEM_ADMIN" | "ADMIN" | "USER";
|
||||
is_master: boolean;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ProjectItem {
|
||||
id: string;
|
||||
name: string;
|
||||
region?: string | null;
|
||||
status?: string | null;
|
||||
owner_name?: string | null;
|
||||
workflow_stage: number;
|
||||
progress_percent: number;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface CompanyInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
business_registration_number?: string | null;
|
||||
business_address?: string | null;
|
||||
business_owner?: string | null;
|
||||
business_status?: string | null;
|
||||
user_count?: number;
|
||||
project_count?: number;
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
position?: string | null;
|
||||
department?: string | null;
|
||||
role: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface JoinRequest {
|
||||
id: number;
|
||||
user_id: number;
|
||||
company_id: number;
|
||||
user_email: string;
|
||||
user_name?: string | null;
|
||||
company_name?: string | null;
|
||||
requested_at: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
id: number;
|
||||
user_id: number;
|
||||
email?: string | null;
|
||||
action: string;
|
||||
resource_type?: string | null;
|
||||
resource_id?: number | null;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ResourceData {
|
||||
current: {
|
||||
cpu_usage_percent: number | null;
|
||||
memory_usage_percent: number | null;
|
||||
disk_usage_percent: number | null;
|
||||
active_user_count: number;
|
||||
active_project_count: number;
|
||||
total_storage_mb: number;
|
||||
};
|
||||
history: unknown[];
|
||||
stats: ResourceData["current"];
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
name: string;
|
||||
position?: string | null;
|
||||
department?: string | null;
|
||||
phone?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateCompanyRequest {
|
||||
name: string;
|
||||
business_registration_number: string;
|
||||
business_address?: string | null;
|
||||
business_owner?: string | null;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(init.headers ?? {}) },
|
||||
...init,
|
||||
});
|
||||
const data = (await response.json()) as { detail?: string } & T;
|
||||
if (!response.ok) throw new Error(data.detail ?? "Request failed");
|
||||
return data;
|
||||
}
|
||||
|
||||
const body = (value: object): string => JSON.stringify(value);
|
||||
|
||||
export async function fetchDashboardMe(): Promise<DashboardUser> {
|
||||
const data = await request<{ user: DashboardUser }>("/dashboard/me");
|
||||
return data.user;
|
||||
}
|
||||
|
||||
export async function updateUserProfile(payload: UpdateUserRequest): Promise<DashboardUser> {
|
||||
const data = await request<{ user: DashboardUser }>("/dashboard/me", {
|
||||
method: "PATCH",
|
||||
body: body(payload),
|
||||
});
|
||||
return data.user;
|
||||
}
|
||||
|
||||
export async function fetchUserProjects(): Promise<ProjectItem[]> {
|
||||
const data = await request<{ projects: ProjectItem[] }>("/dashboard/user/projects");
|
||||
return data.projects;
|
||||
}
|
||||
|
||||
export async function fetchUserCompany(): Promise<CompanyInfo | null> {
|
||||
const data = await request<{ company: CompanyInfo | null }>("/dashboard/user/company");
|
||||
return data.company;
|
||||
}
|
||||
|
||||
export async function searchCompanies(query: string): Promise<CompanyInfo[]> {
|
||||
const data = await request<{ companies: CompanyInfo[] }>(
|
||||
`/dashboard/user/companies?q=${encodeURIComponent(query)}`,
|
||||
);
|
||||
return data.companies;
|
||||
}
|
||||
|
||||
export function createCompany(payload: CreateCompanyRequest): Promise<unknown> {
|
||||
return request("/dashboard/user/company/create", { method: "POST", body: body(payload) });
|
||||
}
|
||||
|
||||
export function joinCompany(companyId: number): Promise<unknown> {
|
||||
return request("/dashboard/user/company/join", {
|
||||
method: "POST",
|
||||
body: body({ company_id: companyId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCompanyMembers(): Promise<Member[]> {
|
||||
const data = await request<{ members: Member[] }>("/dashboard/admin/members");
|
||||
return data.members;
|
||||
}
|
||||
|
||||
export function addCompanyMember(email: string): Promise<unknown> {
|
||||
return request("/dashboard/admin/members", { method: "POST", body: body({ email }) });
|
||||
}
|
||||
|
||||
export function removeCompanyMember(userId: number): Promise<unknown> {
|
||||
return request(`/dashboard/admin/members/${userId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function fetchCompanyJoinRequests(): Promise<JoinRequest[]> {
|
||||
const data = await request<{ requests: JoinRequest[] }>("/dashboard/admin/join-requests");
|
||||
return data.requests;
|
||||
}
|
||||
|
||||
export function processJoinRequest(
|
||||
requestId: number,
|
||||
action: "APPROVE" | "REJECT",
|
||||
): Promise<unknown> {
|
||||
return request(`/dashboard/admin/join-requests/${requestId}`, {
|
||||
method: "PATCH",
|
||||
body: body({ action }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCompanyProjects(): Promise<ProjectItem[]> {
|
||||
const data = await request<{ projects: ProjectItem[] }>("/dashboard/admin/projects");
|
||||
return data.projects;
|
||||
}
|
||||
|
||||
export async function fetchAllCompanies(): Promise<CompanyInfo[]> {
|
||||
const data = await request<{ companies: CompanyInfo[] }>("/dashboard/admin/companies");
|
||||
return data.companies;
|
||||
}
|
||||
|
||||
export async function fetchAllUsers(): Promise<DashboardUser[]> {
|
||||
const data = await request<{ users: DashboardUser[] }>("/dashboard/admin/users");
|
||||
return data.users;
|
||||
}
|
||||
|
||||
export function changeUserRole(userId: number, role: string): Promise<unknown> {
|
||||
return request(`/dashboard/admin/users/${userId}/role`, {
|
||||
method: "PATCH",
|
||||
body: body({ role }),
|
||||
});
|
||||
}
|
||||
|
||||
export function assignUserToCompany(userId: number, companyId: number | null): Promise<unknown> {
|
||||
return request(`/dashboard/admin/users/${userId}/company`, {
|
||||
method: "PATCH",
|
||||
body: body({ company_id: companyId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAllJoinRequests(): Promise<JoinRequest[]> {
|
||||
const data = await request<{ requests: JoinRequest[] }>("/dashboard/admin/join-requests-all");
|
||||
return data.requests;
|
||||
}
|
||||
|
||||
export function systemApproveJoinRequest(requestId: number): Promise<unknown> {
|
||||
return request(`/dashboard/admin/join-requests/${requestId}/approve`, { method: "PATCH" });
|
||||
}
|
||||
|
||||
export async function fetchAuditLogs(): Promise<AuditLog[]> {
|
||||
const data = await request<{ items: AuditLog[] }>("/dashboard/admin/audit-logs");
|
||||
return data.items;
|
||||
}
|
||||
|
||||
export async function fetchSystemResources(period = "24h"): Promise<ResourceData> {
|
||||
return request<ResourceData>(`/dashboard/admin/resources?period=${encodeURIComponent(period)}`);
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
"""B01_Dashboard aiomysql Raw SQL 저장소."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import aiomysql
|
||||
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
|
||||
def _role(value: str | None) -> str:
|
||||
return {"MASTER": "ADMIN", "MEMBER": "USER"}.get(value or "USER", value or "USER")
|
||||
|
||||
|
||||
def _stage_from_status(status: str | None) -> tuple[int, int]:
|
||||
order = [
|
||||
("FILE_UPLOADED", 1),
|
||||
("WF1", 2),
|
||||
("WF2", 3),
|
||||
("WF3", 4),
|
||||
("WF4", 5),
|
||||
("WF5", 6),
|
||||
("WF6", 7),
|
||||
("DONE", 7),
|
||||
("CONFIRMED", 7),
|
||||
]
|
||||
value = status or "NEW"
|
||||
stage = 0
|
||||
for token, idx in order:
|
||||
if token in value:
|
||||
stage = max(stage, idx)
|
||||
return stage, round(stage / 7 * 100)
|
||||
|
||||
|
||||
def _project_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
stage, progress = _stage_from_status(row.get("status"))
|
||||
return {**row, "workflow_stage": stage, "progress_percent": progress}
|
||||
|
||||
|
||||
async def get_dashboard_me(user_id: int) -> dict[str, Any] | None:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT u.id, u.email, u.name, u.position, u.department, u.phone,
|
||||
u.company_id, u.role, u.is_master, u.status, u.last_login,
|
||||
u.auth_expires_at, c.name AS company_name
|
||||
FROM users u LEFT JOIN companies c ON c.id = u.company_id
|
||||
WHERE u.id = %s AND u.deleted_at IS NULL""",
|
||||
(user_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
row["role"] = _role(row.get("role"))
|
||||
row["is_master"] = bool(row.get("is_master"))
|
||||
return row
|
||||
|
||||
|
||||
async def update_user_profile(user_id: int, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET name = %s, position = %s, department = %s, phone = %s
|
||||
WHERE id = %s AND deleted_at IS NULL""",
|
||||
(
|
||||
data["name"],
|
||||
data.get("position"),
|
||||
data.get("department"),
|
||||
data.get("phone"),
|
||||
user_id,
|
||||
),
|
||||
)
|
||||
await connection.commit()
|
||||
return await get_dashboard_me(user_id)
|
||||
|
||||
|
||||
async def list_user_projects(user_id: int) -> list[dict[str, Any]]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT id, name, region, status, updated_at, created_at
|
||||
FROM projects WHERE user_id = %s AND deleted_at IS NULL
|
||||
ORDER BY updated_at DESC, created_at DESC""",
|
||||
(user_id,),
|
||||
)
|
||||
return [_project_row(row) for row in await cursor.fetchall()]
|
||||
|
||||
|
||||
async def list_company_projects(company_id: int) -> list[dict[str, Any]]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT p.id, p.name, p.region, p.status, p.updated_at, p.created_at,
|
||||
u.name AS owner_name, u.email AS owner_email
|
||||
FROM projects p LEFT JOIN users u ON u.id = p.user_id
|
||||
WHERE p.company_id = %s AND p.deleted_at IS NULL
|
||||
ORDER BY p.updated_at DESC, p.created_at DESC""",
|
||||
(company_id,),
|
||||
)
|
||||
return [_project_row(row) for row in await cursor.fetchall()]
|
||||
|
||||
|
||||
async def get_user_company(company_id: int | None) -> dict[str, Any] | None:
|
||||
if company_id is None:
|
||||
return None
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT c.id, c.name, c.business_registration_number, c.business_address,
|
||||
c.business_owner, c.business_status,
|
||||
COUNT(DISTINCT u.id) AS user_count,
|
||||
COUNT(DISTINCT p.id) AS project_count
|
||||
FROM companies c
|
||||
LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL
|
||||
LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL
|
||||
WHERE c.id = %s AND c.deleted_at IS NULL
|
||||
GROUP BY c.id""",
|
||||
(company_id,),
|
||||
)
|
||||
return await cursor.fetchone()
|
||||
|
||||
|
||||
async def search_companies(query: str) -> list[dict[str, Any]]:
|
||||
pool = get_db_pool()
|
||||
pattern = f"%{query}%"
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT id, name, business_registration_number, business_status
|
||||
FROM companies
|
||||
WHERE deleted_at IS NULL AND (name LIKE %s OR business_registration_number LIKE %s)
|
||||
ORDER BY name LIMIT 20""",
|
||||
(pattern, pattern),
|
||||
)
|
||||
return list(await cursor.fetchall())
|
||||
|
||||
|
||||
async def create_company(user_id: int, data: dict[str, Any]) -> dict[str, Any]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
try:
|
||||
await connection.begin()
|
||||
await cursor.execute(
|
||||
"""INSERT INTO companies
|
||||
(name, business_registration_number, business_address, business_owner,
|
||||
business_status, master_user_id, created_by, status)
|
||||
VALUES (%s, %s, %s, %s, '활동중', %s, %s, 'ACTIVE')""",
|
||||
(
|
||||
data["name"],
|
||||
data["business_registration_number"],
|
||||
data.get("business_address"),
|
||||
data.get("business_owner"),
|
||||
user_id,
|
||||
user_id,
|
||||
),
|
||||
)
|
||||
company_id = cursor.lastrowid
|
||||
await cursor.execute(
|
||||
"""UPDATE users
|
||||
SET company_id = %s, role = 'ADMIN', is_master = TRUE,
|
||||
status = 'ACTIVE',
|
||||
auth_expires_at = DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 3 MONTH)
|
||||
WHERE id = %s""",
|
||||
(company_id, user_id),
|
||||
)
|
||||
await cursor.execute(
|
||||
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id)
|
||||
VALUES (%s, 'COMPANY_CREATE', 'company', %s)""",
|
||||
(user_id, company_id),
|
||||
)
|
||||
await connection.commit()
|
||||
return {"company_id": company_id, "status": "ACTIVE"}
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def join_company(user_id: int, company_id: int) -> dict[str, Any]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
try:
|
||||
await connection.begin()
|
||||
await cursor.execute(
|
||||
"""INSERT INTO join_requests (user_id, company_id, status)
|
||||
VALUES (%s, %s, 'PENDING')
|
||||
ON DUPLICATE KEY UPDATE status = 'PENDING', requested_at = CURRENT_TIMESTAMP,
|
||||
reviewed_by = NULL, reviewed_at = NULL""",
|
||||
(user_id, company_id),
|
||||
)
|
||||
request_id = cursor.lastrowid
|
||||
await cursor.execute(
|
||||
"UPDATE users SET status = 'PENDING', company_id = NULL WHERE id = %s", (user_id,)
|
||||
)
|
||||
await connection.commit()
|
||||
return {"join_request_id": request_id, "status": "PENDING"}
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def list_company_members(company_id: int) -> list[dict[str, Any]]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT id, email, name, position, department, role, is_master, status
|
||||
FROM users WHERE company_id = %s AND deleted_at IS NULL ORDER BY name, email""",
|
||||
(company_id,),
|
||||
)
|
||||
rows = list(await cursor.fetchall())
|
||||
for row in rows:
|
||||
row["role"] = _role(row.get("role"))
|
||||
row["is_master"] = bool(row.get("is_master"))
|
||||
return rows
|
||||
|
||||
|
||||
async def add_company_member(company_id: int, email: str) -> dict[str, Any] | None:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await connection.begin()
|
||||
await cursor.execute(
|
||||
"""SELECT id, company_id FROM users
|
||||
WHERE email = %s AND deleted_at IS NULL FOR UPDATE""",
|
||||
(email.lower(),),
|
||||
)
|
||||
user = await cursor.fetchone()
|
||||
if not user or user["company_id"] == company_id:
|
||||
await connection.rollback()
|
||||
return None
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET company_id = %s, status = 'ACTIVE', role = 'USER',
|
||||
is_master = FALSE
|
||||
WHERE id = %s""",
|
||||
(company_id, user["id"]),
|
||||
)
|
||||
await connection.commit()
|
||||
return await get_dashboard_me(user["id"])
|
||||
|
||||
|
||||
async def remove_company_member(company_id: int, user_id: int) -> bool:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET company_id = NULL, status = 'NO_COMPANY', role = 'USER',
|
||||
is_master = FALSE
|
||||
WHERE id = %s AND company_id = %s AND is_master = FALSE""",
|
||||
(user_id, company_id),
|
||||
)
|
||||
changed = cursor.rowcount > 0
|
||||
await connection.commit()
|
||||
return changed
|
||||
|
||||
|
||||
async def list_join_requests(company_id: int | None = None) -> list[dict[str, Any]]:
|
||||
where = "WHERE jr.company_id = %s" if company_id else ""
|
||||
params = (company_id,) if company_id else ()
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
f"""SELECT jr.id, jr.user_id, jr.company_id, jr.requested_at, jr.status,
|
||||
u.email AS user_email, u.name AS user_name, c.name AS company_name
|
||||
FROM join_requests jr
|
||||
JOIN users u ON u.id = jr.user_id
|
||||
JOIN companies c ON c.id = jr.company_id
|
||||
{where}
|
||||
ORDER BY jr.requested_at DESC LIMIT 100""",
|
||||
params,
|
||||
)
|
||||
return list(await cursor.fetchall())
|
||||
|
||||
|
||||
async def process_join_request(
|
||||
request_id: int, reviewer_id: int, approved: bool, company_id: int | None = None
|
||||
) -> bool:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await connection.begin()
|
||||
where = "id = %s AND status = 'PENDING'"
|
||||
params: tuple[Any, ...] = (request_id,)
|
||||
if company_id is not None:
|
||||
where += " AND company_id = %s"
|
||||
params = (request_id, company_id)
|
||||
await cursor.execute(f"SELECT * FROM join_requests WHERE {where} FOR UPDATE", params)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
await connection.rollback()
|
||||
return False
|
||||
status = "APPROVED" if approved else "REJECTED"
|
||||
await cursor.execute(
|
||||
"""UPDATE join_requests SET status = %s, reviewed_by = %s,
|
||||
reviewed_at = CURRENT_TIMESTAMP WHERE id = %s""",
|
||||
(status, reviewer_id, request_id),
|
||||
)
|
||||
if approved:
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET company_id = %s, status = 'ACTIVE'
|
||||
WHERE id = %s""",
|
||||
(row["company_id"], row["user_id"]),
|
||||
)
|
||||
else:
|
||||
await cursor.execute(
|
||||
"UPDATE users SET status = 'REJECTED' WHERE id = %s", (row["user_id"],)
|
||||
)
|
||||
await connection.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def list_all_companies() -> list[dict[str, Any]]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT c.id, c.name, c.business_registration_number, c.business_status,
|
||||
c.created_at, COUNT(DISTINCT u.id) AS user_count,
|
||||
COUNT(DISTINCT p.id) AS project_count
|
||||
FROM companies c
|
||||
LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL
|
||||
LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL
|
||||
WHERE c.deleted_at IS NULL GROUP BY c.id ORDER BY c.created_at DESC"""
|
||||
)
|
||||
return list(await cursor.fetchall())
|
||||
|
||||
|
||||
async def list_all_users() -> list[dict[str, Any]]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT u.id, u.email, u.name, u.role, u.status, u.company_id,
|
||||
c.name AS company_name, u.last_login
|
||||
FROM users u LEFT JOIN companies c ON c.id = u.company_id
|
||||
WHERE u.deleted_at IS NULL ORDER BY u.created_at DESC LIMIT 200"""
|
||||
)
|
||||
rows = list(await cursor.fetchall())
|
||||
for row in rows:
|
||||
row["role"] = _role(row.get("role"))
|
||||
return rows
|
||||
|
||||
|
||||
async def change_user_role(user_id: int, role: str) -> bool:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute("UPDATE users SET role = %s WHERE id = %s", (role, user_id))
|
||||
changed = cursor.rowcount > 0
|
||||
await connection.commit()
|
||||
return changed
|
||||
|
||||
|
||||
async def assign_user_company(user_id: int, company_id: int | None) -> bool:
|
||||
status = "ACTIVE" if company_id else "NO_COMPANY"
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"UPDATE users SET company_id = %s, status = %s WHERE id = %s",
|
||||
(company_id, status, user_id),
|
||||
)
|
||||
changed = cursor.rowcount > 0
|
||||
await connection.commit()
|
||||
return changed
|
||||
|
||||
|
||||
async def list_audit_logs(limit: int, offset: int) -> dict[str, Any]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute("SELECT COUNT(*) AS total FROM system_audit_logs")
|
||||
total = (await cursor.fetchone())["total"]
|
||||
await cursor.execute(
|
||||
"""SELECT l.id, l.user_id, u.email, l.action, l.resource_type,
|
||||
l.resource_id, l.timestamp
|
||||
FROM system_audit_logs l LEFT JOIN users u ON u.id = l.user_id
|
||||
ORDER BY l.timestamp DESC LIMIT %s OFFSET %s""",
|
||||
(limit, offset),
|
||||
)
|
||||
return {"total": total, "items": list(await cursor.fetchall())}
|
||||
|
||||
|
||||
async def get_system_resources() -> dict[str, Any]:
|
||||
total, used, _free = shutil.disk_usage(".")
|
||||
disk_percent = used / total * 100 if total else 0
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT
|
||||
(SELECT COUNT(*) FROM sessions
|
||||
WHERE expires_at > CURRENT_TIMESTAMP) AS active_user_count,
|
||||
(SELECT COUNT(*) FROM projects WHERE deleted_at IS NULL) AS active_project_count"""
|
||||
)
|
||||
stats = await cursor.fetchone()
|
||||
current = {
|
||||
"cpu_usage_percent": None,
|
||||
"memory_usage_percent": None,
|
||||
"disk_usage_percent": round(disk_percent, 2),
|
||||
"active_user_count": stats["active_user_count"],
|
||||
"active_project_count": stats["active_project_count"],
|
||||
"total_storage_mb": round(used / 1024 / 1024, 2),
|
||||
}
|
||||
await cursor.execute(
|
||||
"""SELECT timestamp, cpu_usage_percent, memory_usage_percent, disk_usage_percent,
|
||||
active_user_count, active_project_count, total_storage_mb
|
||||
FROM system_resources
|
||||
WHERE timestamp >= %s ORDER BY timestamp""",
|
||||
(datetime.utcnow() - timedelta(hours=24),),
|
||||
)
|
||||
return {"current": current, "history": list(await cursor.fetchall()), "stats": current}
|
||||
@@ -0,0 +1,239 @@
|
||||
"""B01_Dashboard 역할별 대시보드 API."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from common_util.common_util_auth import require_system_admin, verify_session
|
||||
|
||||
from .B01_Dashboard_Repository import (
|
||||
add_company_member,
|
||||
assign_user_company,
|
||||
change_user_role,
|
||||
create_company,
|
||||
get_dashboard_me,
|
||||
get_system_resources,
|
||||
get_user_company,
|
||||
join_company,
|
||||
list_all_companies,
|
||||
list_all_users,
|
||||
list_audit_logs,
|
||||
list_company_members,
|
||||
list_company_projects,
|
||||
list_join_requests,
|
||||
list_user_projects,
|
||||
process_join_request,
|
||||
remove_company_member,
|
||||
search_companies,
|
||||
update_user_profile,
|
||||
)
|
||||
from .B01_Dashboard_Schema import (
|
||||
AddMemberRequest,
|
||||
AssignCompanyRequest,
|
||||
ChangeUserRoleRequest,
|
||||
CreateCompanyRequest,
|
||||
JoinCompanyRequest,
|
||||
ProcessJoinRequest,
|
||||
UpdateUserRequest,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["B01_Dashboard"])
|
||||
|
||||
|
||||
async def require_company_admin(
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
if session["role"] not in ("ADMIN", "SYSTEM_ADMIN") and not session["is_master"]:
|
||||
raise HTTPException(status_code=403, detail="회사 관리자 권한이 필요합니다.")
|
||||
return session
|
||||
|
||||
|
||||
def _require_company_id(session: dict[str, Any]) -> int:
|
||||
company_id = session.get("company_id")
|
||||
if company_id is None:
|
||||
raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.")
|
||||
return int(company_id)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def dashboard_me(session: dict[str, Any] = Depends(verify_session)):
|
||||
user = await get_dashboard_me(int(session["user_id"]))
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="사용자 정보를 찾을 수 없습니다.")
|
||||
return {"status": "success", "user": user}
|
||||
|
||||
|
||||
@router.patch("/me")
|
||||
async def patch_dashboard_me(
|
||||
payload: UpdateUserRequest,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
user = await update_user_profile(int(session["user_id"]), payload.model_dump())
|
||||
return {"status": "success", "user": user}
|
||||
|
||||
|
||||
@router.get("/user/projects")
|
||||
async def user_projects(session: dict[str, Any] = Depends(verify_session)):
|
||||
return {"status": "success", "projects": await list_user_projects(int(session["user_id"]))}
|
||||
|
||||
|
||||
@router.get("/user/company")
|
||||
async def user_company(session: dict[str, Any] = Depends(verify_session)):
|
||||
return {"status": "success", "company": await get_user_company(session.get("company_id"))}
|
||||
|
||||
|
||||
@router.get("/user/companies")
|
||||
async def user_company_search(
|
||||
q: str = Query(min_length=1, max_length=100),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
_ = session
|
||||
return {"status": "success", "companies": await search_companies(q.strip())}
|
||||
|
||||
|
||||
@router.post("/user/company/create")
|
||||
async def user_company_create(
|
||||
payload: CreateCompanyRequest,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
result = await create_company(int(session["user_id"]), payload.model_dump())
|
||||
return {"status": "success", **result}
|
||||
|
||||
|
||||
@router.post("/user/company/join")
|
||||
async def user_company_join(
|
||||
payload: JoinCompanyRequest,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
result = await join_company(int(session["user_id"]), payload.company_id)
|
||||
return {"status": "success", **result}
|
||||
|
||||
|
||||
@router.get("/admin/members")
|
||||
async def admin_members(session: dict[str, Any] = Depends(require_company_admin)):
|
||||
return {
|
||||
"status": "success",
|
||||
"members": await list_company_members(_require_company_id(session)),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/admin/members")
|
||||
async def admin_add_member(
|
||||
payload: AddMemberRequest,
|
||||
session: dict[str, Any] = Depends(require_company_admin),
|
||||
):
|
||||
member = await add_company_member(_require_company_id(session), payload.email)
|
||||
if not member:
|
||||
raise HTTPException(status_code=409, detail="사용자를 찾을 수 없거나 이미 팀원입니다.")
|
||||
return {"status": "success", "member": member}
|
||||
|
||||
|
||||
@router.delete("/admin/members/{user_id}")
|
||||
async def admin_remove_member(
|
||||
user_id: int, session: dict[str, Any] = Depends(require_company_admin)
|
||||
):
|
||||
if not await remove_company_member(_require_company_id(session), user_id):
|
||||
raise HTTPException(status_code=404, detail="제거할 팀원을 찾을 수 없습니다.")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.get("/admin/join-requests")
|
||||
async def admin_join_requests(session: dict[str, Any] = Depends(require_company_admin)):
|
||||
return {"status": "success", "requests": await list_join_requests(_require_company_id(session))}
|
||||
|
||||
|
||||
@router.patch("/admin/join-requests/{request_id}")
|
||||
async def admin_process_join_request(
|
||||
request_id: int,
|
||||
payload: ProcessJoinRequest,
|
||||
session: dict[str, Any] = Depends(require_company_admin),
|
||||
):
|
||||
changed = await process_join_request(
|
||||
request_id,
|
||||
int(session["user_id"]),
|
||||
payload.action == "APPROVE",
|
||||
_require_company_id(session),
|
||||
)
|
||||
if not changed:
|
||||
raise HTTPException(status_code=404, detail="처리할 가입 신청을 찾을 수 없습니다.")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.get("/admin/projects")
|
||||
async def admin_projects(session: dict[str, Any] = Depends(require_company_admin)):
|
||||
return {
|
||||
"status": "success",
|
||||
"projects": await list_company_projects(_require_company_id(session)),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/admin/companies")
|
||||
async def system_companies(session: dict[str, Any] = Depends(require_system_admin)):
|
||||
_ = session
|
||||
return {"status": "success", "companies": await list_all_companies()}
|
||||
|
||||
|
||||
@router.get("/admin/users")
|
||||
async def system_users(session: dict[str, Any] = Depends(require_system_admin)):
|
||||
_ = session
|
||||
return {"status": "success", "users": await list_all_users()}
|
||||
|
||||
|
||||
@router.patch("/admin/users/{user_id}/role")
|
||||
async def system_change_role(
|
||||
user_id: int,
|
||||
payload: ChangeUserRoleRequest,
|
||||
session: dict[str, Any] = Depends(require_system_admin),
|
||||
):
|
||||
_ = session
|
||||
if not await change_user_role(user_id, payload.role):
|
||||
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.patch("/admin/users/{user_id}/company")
|
||||
async def system_assign_company(
|
||||
user_id: int,
|
||||
payload: AssignCompanyRequest,
|
||||
session: dict[str, Any] = Depends(require_system_admin),
|
||||
):
|
||||
_ = session
|
||||
if not await assign_user_company(user_id, payload.company_id):
|
||||
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.get("/admin/join-requests-all")
|
||||
async def system_join_requests(session: dict[str, Any] = Depends(require_system_admin)):
|
||||
_ = session
|
||||
return {"status": "success", "requests": await list_join_requests()}
|
||||
|
||||
|
||||
@router.patch("/admin/join-requests/{request_id}/approve")
|
||||
async def system_approve_join_request(
|
||||
request_id: int,
|
||||
session: dict[str, Any] = Depends(require_system_admin),
|
||||
):
|
||||
if not await process_join_request(request_id, int(session["user_id"]), True):
|
||||
raise HTTPException(status_code=404, detail="처리할 가입 신청을 찾을 수 없습니다.")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.get("/admin/audit-logs")
|
||||
async def system_audit_logs(
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
session: dict[str, Any] = Depends(require_system_admin),
|
||||
):
|
||||
_ = session
|
||||
return {"status": "success", **await list_audit_logs(limit, (page - 1) * limit)}
|
||||
|
||||
|
||||
@router.get("/admin/resources")
|
||||
async def system_resources(
|
||||
period: str = Query("24h"),
|
||||
session: dict[str, Any] = Depends(require_system_admin),
|
||||
):
|
||||
_ = period
|
||||
_ = session
|
||||
return {"status": "success", **await get_system_resources()}
|
||||
@@ -0,0 +1,38 @@
|
||||
"""B01_Dashboard 요청 스키마."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
position: str | None = Field(default=None, max_length=100)
|
||||
department: str | None = Field(default=None, max_length=100)
|
||||
phone: str | None = Field(default=None, max_length=50)
|
||||
|
||||
|
||||
class CreateCompanyRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
business_registration_number: str = Field(min_length=1, max_length=20)
|
||||
business_address: str | None = Field(default=None, max_length=500)
|
||||
business_owner: str | None = Field(default=None, max_length=100)
|
||||
|
||||
|
||||
class JoinCompanyRequest(BaseModel):
|
||||
company_id: int = Field(gt=0)
|
||||
|
||||
|
||||
class AddMemberRequest(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=255)
|
||||
|
||||
|
||||
class ProcessJoinRequest(BaseModel):
|
||||
action: str = Field(pattern="^(APPROVE|REJECT)$")
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class ChangeUserRoleRequest(BaseModel):
|
||||
role: str = Field(pattern="^(SYSTEM_ADMIN|ADMIN|USER)$")
|
||||
|
||||
|
||||
class AssignCompanyRequest(BaseModel):
|
||||
company_id: int | None = Field(default=None, gt=0)
|
||||
@@ -0,0 +1,612 @@
|
||||
import { ROUTES, type RoutePath } from "@config/config_frontend";
|
||||
import { isBlank } from "@util/common_util_validate";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createCard,
|
||||
createInputField,
|
||||
createTag,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import {
|
||||
addCompanyMember,
|
||||
createCompany,
|
||||
fetchAllCompanies,
|
||||
fetchAllJoinRequests,
|
||||
fetchAllUsers,
|
||||
fetchAuditLogs,
|
||||
fetchCompanyJoinRequests,
|
||||
fetchCompanyMembers,
|
||||
fetchCompanyProjects,
|
||||
fetchDashboardMe,
|
||||
fetchSystemResources,
|
||||
fetchUserCompany,
|
||||
fetchUserProjects,
|
||||
joinCompany,
|
||||
processJoinRequest,
|
||||
removeCompanyMember,
|
||||
searchCompanies,
|
||||
updateUserProfile,
|
||||
type AuditLog,
|
||||
type CompanyInfo,
|
||||
type DashboardUser,
|
||||
type JoinRequest,
|
||||
type Member,
|
||||
type ProjectItem,
|
||||
type ResourceData,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import "./B01_Dashboard_UI_Style.css";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
interface DashboardState {
|
||||
user: DashboardUser;
|
||||
userProjects: ProjectItem[];
|
||||
companyProjects: ProjectItem[];
|
||||
company: CompanyInfo | null;
|
||||
members: Member[];
|
||||
joinRequests: JoinRequest[];
|
||||
allCompanies: CompanyInfo[];
|
||||
allUsers: DashboardUser[];
|
||||
allJoinRequests: JoinRequest[];
|
||||
auditLogs: AuditLog[];
|
||||
resources: ResourceData | null;
|
||||
}
|
||||
|
||||
export async function renderB01Dashboard(root: HTMLElement): Promise<void> {
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const user = await fetchDashboardMe();
|
||||
const state: DashboardState = {
|
||||
user,
|
||||
userProjects: [],
|
||||
companyProjects: [],
|
||||
company: null,
|
||||
members: [],
|
||||
joinRequests: [],
|
||||
allCompanies: [],
|
||||
allUsers: [],
|
||||
allJoinRequests: [],
|
||||
auditLogs: [],
|
||||
resources: null,
|
||||
};
|
||||
await loadRoleData(state);
|
||||
root.append(buildPage(state));
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("B01_Dashboard_LoadFailed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRoleData(state: DashboardState): Promise<void> {
|
||||
if (state.user.role === "SYSTEM_ADMIN") {
|
||||
const [companies, users, requests, logs, resources] = await Promise.all([
|
||||
fetchAllCompanies(),
|
||||
fetchAllUsers(),
|
||||
fetchAllJoinRequests(),
|
||||
fetchAuditLogs(),
|
||||
fetchSystemResources(),
|
||||
]);
|
||||
state.allCompanies = companies;
|
||||
state.allUsers = users;
|
||||
state.allJoinRequests = requests;
|
||||
state.auditLogs = logs;
|
||||
state.resources = resources;
|
||||
return;
|
||||
}
|
||||
const [projects, company] = await Promise.all([fetchUserProjects(), fetchUserCompany()]);
|
||||
state.userProjects = projects;
|
||||
state.company = company;
|
||||
if (state.user.role === "ADMIN") {
|
||||
const [members, requests, companyProjects] = await Promise.all([
|
||||
fetchCompanyMembers(),
|
||||
fetchCompanyJoinRequests(),
|
||||
fetchCompanyProjects(),
|
||||
]);
|
||||
state.members = members;
|
||||
state.joinRequests = requests;
|
||||
state.companyProjects = companyProjects;
|
||||
}
|
||||
}
|
||||
|
||||
function buildPage(state: DashboardState): HTMLElement {
|
||||
const page = document.createElement("div");
|
||||
page.className = "b01-dashboard";
|
||||
page.append(buildHeader(state.user));
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "b01-dashboard__grid";
|
||||
|
||||
if (state.user.role === "SYSTEM_ADMIN") {
|
||||
grid.append(
|
||||
section(L("B01_Dashboard_Resources"), buildResourceMetrics(state.resources), true),
|
||||
section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies)),
|
||||
section(L("B01_Dashboard_Users"), userTable(state.allUsers)),
|
||||
section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.allJoinRequests, true)),
|
||||
section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true),
|
||||
);
|
||||
} else {
|
||||
grid.append(
|
||||
section(
|
||||
L("B01_Dashboard_Projects"),
|
||||
projectTable(state.user.role === "ADMIN" ? state.companyProjects : state.userProjects),
|
||||
true,
|
||||
[
|
||||
createButton({
|
||||
label: L("B01_Dashboard_NewProject"),
|
||||
onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER),
|
||||
}),
|
||||
],
|
||||
),
|
||||
section(L("B01_Dashboard_Company"), buildCompanyPanel(state)),
|
||||
);
|
||||
if (state.user.role === "ADMIN") {
|
||||
grid.append(
|
||||
section(L("B01_Dashboard_Members"), memberTable(state.members), false, [
|
||||
createButton({
|
||||
label: L("B01_Dashboard_AddMember"),
|
||||
variant: "ghost",
|
||||
onClick: () => openAddMemberModal(),
|
||||
}),
|
||||
]),
|
||||
section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.joinRequests, false)),
|
||||
);
|
||||
}
|
||||
}
|
||||
grid.append(section(L("B01_Dashboard_Profile"), buildProfileForm(state.user), true));
|
||||
page.append(grid);
|
||||
return page;
|
||||
}
|
||||
|
||||
function buildHeader(user: DashboardUser): HTMLElement {
|
||||
const header = document.createElement("header");
|
||||
header.className = "b01-dashboard__header";
|
||||
const text = document.createElement("div");
|
||||
const title = document.createElement("h1");
|
||||
title.className = "b01-dashboard__title";
|
||||
title.textContent = L("B01_Dashboard_Title");
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.className = "b01-dashboard__subtitle";
|
||||
subtitle.textContent = L("B01_Dashboard_Subtitle");
|
||||
text.append(title, subtitle);
|
||||
const tag = createTag(roleLabel(user.role), user.role === "SYSTEM_ADMIN" ? "accent" : "neutral");
|
||||
tag.classList.add("b01-dashboard__role");
|
||||
header.append(text, tag);
|
||||
return header;
|
||||
}
|
||||
|
||||
function roleLabel(role: DashboardUser["role"]): string {
|
||||
if (role === "SYSTEM_ADMIN") return L("B01_Dashboard_Role_SystemAdmin");
|
||||
if (role === "ADMIN") return L("B01_Dashboard_Role_Admin");
|
||||
return L("B01_Dashboard_Role_User");
|
||||
}
|
||||
|
||||
function section(
|
||||
title: string,
|
||||
body: HTMLElement,
|
||||
wide = false,
|
||||
actions: HTMLElement[] = [],
|
||||
): HTMLElement {
|
||||
const actionRow = document.createElement("div");
|
||||
actionRow.className = "b01-dashboard__actions";
|
||||
actionRow.append(...actions);
|
||||
const cardBody = actions.length ? [actionRow, body] : [body];
|
||||
const card = createCard({ title, body: cardBody, raised: true });
|
||||
card.classList.add("b01-dashboard__section");
|
||||
if (wide) card.classList.add("b01-dashboard__section--wide");
|
||||
return card;
|
||||
}
|
||||
|
||||
function table(headers: string[], rows: HTMLElement[][]): HTMLElement {
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "b01-dashboard__empty";
|
||||
empty.textContent = L("Common_Status_Empty");
|
||||
return empty;
|
||||
}
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b01-dashboard__table-wrap";
|
||||
const tableEl = document.createElement("table");
|
||||
tableEl.className = "b01-dashboard__table";
|
||||
const thead = document.createElement("thead");
|
||||
const headRow = document.createElement("tr");
|
||||
for (const header of headers) {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = header;
|
||||
headRow.append(th);
|
||||
}
|
||||
thead.append(headRow);
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement("tr");
|
||||
for (const cell of row) {
|
||||
const td = document.createElement("td");
|
||||
td.append(cell);
|
||||
tr.append(td);
|
||||
}
|
||||
tbody.append(tr);
|
||||
}
|
||||
tableEl.append(thead, tbody);
|
||||
wrap.append(tableEl);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function text(value: unknown): HTMLElement {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = value == null || value === "" ? "-" : String(value);
|
||||
return span;
|
||||
}
|
||||
|
||||
function projectTable(projects: ProjectItem[]): HTMLElement {
|
||||
return table(
|
||||
[
|
||||
L("B01_Dashboard_Table_Project"),
|
||||
L("B01_Dashboard_Table_Region"),
|
||||
L("B01_Dashboard_Table_Progress"),
|
||||
L("B01_Dashboard_Table_Workflow"),
|
||||
L("B01_Dashboard_Table_Updated"),
|
||||
],
|
||||
projects.map((project) => [
|
||||
text(project.name),
|
||||
text(project.region),
|
||||
text(`${project.progress_percent}%`),
|
||||
workflow(project.workflow_stage),
|
||||
text(formatDate(project.updated_at)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function workflow(activeStage: number): HTMLElement {
|
||||
const routes: RoutePath[] = [
|
||||
ROUTES.B03_FILE_INPUT,
|
||||
ROUTES.B04_WF1_SURFACE,
|
||||
ROUTES.B05_WF2_ROUTE,
|
||||
ROUTES.B06_WF3_PROFILE_CROSS,
|
||||
ROUTES.B07_WF4_DESIGN_DETAIL,
|
||||
ROUTES.B08_WF5_QUANTITY,
|
||||
ROUTES.B09_WF6_ESTIMATION,
|
||||
];
|
||||
const box = document.createElement("div");
|
||||
box.className = "b01-dashboard__workflow";
|
||||
routes.forEach((route, index) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b01-dashboard__step";
|
||||
button.textContent = `B${String(index + 3).padStart(2, "0")}`;
|
||||
const enabled = index + 1 <= Math.max(activeStage, 1);
|
||||
button.disabled = !enabled;
|
||||
if (enabled) {
|
||||
button.classList.add("is-enabled");
|
||||
button.addEventListener("click", () => navigateTo(route));
|
||||
}
|
||||
box.append(button);
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
function buildCompanyPanel(state: DashboardState): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b01-dashboard__actions";
|
||||
if (!state.company) {
|
||||
wrap.append(
|
||||
createTag(L("B01_Dashboard_NoCompany"), "warning"),
|
||||
createButton({
|
||||
label: L("B01_Dashboard_CreateCompany"),
|
||||
onClick: () => openCreateCompanyModal(),
|
||||
}),
|
||||
createButton({
|
||||
label: L("B01_Dashboard_FindCompany"),
|
||||
variant: "ghost",
|
||||
onClick: () => openFindCompanyModal(),
|
||||
}),
|
||||
);
|
||||
return wrap;
|
||||
}
|
||||
wrap.append(
|
||||
createTag(`${state.company.name} (${state.user.status})`, "success"),
|
||||
text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`),
|
||||
text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`),
|
||||
);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function memberTable(members: Member[]): HTMLElement {
|
||||
return table(
|
||||
[
|
||||
L("B01_Dashboard_Table_Email"),
|
||||
L("B01_Dashboard_Table_Name"),
|
||||
L("B01_Dashboard_Table_Position"),
|
||||
L("B01_Dashboard_Table_Department"),
|
||||
L("B01_Dashboard_Table_Role"),
|
||||
L("B01_Dashboard_Table_Action"),
|
||||
],
|
||||
members.map((member) => [
|
||||
text(member.email),
|
||||
text(member.name),
|
||||
text(member.position),
|
||||
text(member.department),
|
||||
text(member.role),
|
||||
createButton({
|
||||
label: L("B01_Dashboard_RemoveMember"),
|
||||
variant: "ghost",
|
||||
onClick: () => onB01_Member_Remove_Click(member.id),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function joinRequestTable(requests: JoinRequest[], systemMode: boolean): HTMLElement {
|
||||
return table(
|
||||
[
|
||||
L("B01_Dashboard_Table_Email"),
|
||||
...(systemMode ? [L("B01_Dashboard_Table_Company")] : []),
|
||||
L("B01_Dashboard_Table_Requested"),
|
||||
L("B01_Dashboard_Table_Status"),
|
||||
L("B01_Dashboard_Table_Action"),
|
||||
],
|
||||
requests.map((request) => [
|
||||
text(request.user_email),
|
||||
...(systemMode ? [text(request.company_name)] : []),
|
||||
text(formatDate(request.requested_at)),
|
||||
text(request.status),
|
||||
actionPair(
|
||||
() => onB01_JoinRequest_Process_Click(request.id, "APPROVE", systemMode),
|
||||
() => onB01_JoinRequest_Process_Click(request.id, "REJECT", systemMode),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function actionPair(approve: () => void, reject: () => void): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "b01-dashboard__actions";
|
||||
row.append(
|
||||
createButton({ label: L("B01_Dashboard_Approve"), variant: "ghost", onClick: approve }),
|
||||
createButton({ label: L("B01_Dashboard_Reject"), variant: "danger", onClick: reject }),
|
||||
);
|
||||
return row;
|
||||
}
|
||||
|
||||
function buildResourceMetrics(resources: ResourceData | null): HTMLElement {
|
||||
const values = resources?.current;
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "b01-dashboard__metric-grid";
|
||||
const items = [
|
||||
[L("B01_Dashboard_Metric_Cpu"), percent(values?.cpu_usage_percent)],
|
||||
[L("B01_Dashboard_Metric_Memory"), percent(values?.memory_usage_percent)],
|
||||
[L("B01_Dashboard_Metric_Disk"), percent(values?.disk_usage_percent)],
|
||||
[L("B01_Dashboard_Metric_ActiveUsers"), values?.active_user_count ?? 0],
|
||||
[L("B01_Dashboard_Metric_ActiveProjects"), values?.active_project_count ?? 0],
|
||||
];
|
||||
for (const [label, value] of items) {
|
||||
const box = document.createElement("div");
|
||||
box.className = "b01-dashboard__metric";
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.className = "b01-dashboard__metric-label";
|
||||
labelEl.textContent = String(label);
|
||||
const valueEl = document.createElement("span");
|
||||
valueEl.className = "b01-dashboard__metric-value";
|
||||
valueEl.textContent = String(value);
|
||||
box.append(labelEl, valueEl);
|
||||
grid.append(box);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
function companyTable(companies: CompanyInfo[]): HTMLElement {
|
||||
return table(
|
||||
[
|
||||
L("B01_Dashboard_Table_Company"),
|
||||
L("B01_Dashboard_Field_BusinessNumber"),
|
||||
L("B01_Dashboard_Table_Status"),
|
||||
],
|
||||
companies.map((company) => [
|
||||
text(company.name),
|
||||
text(company.business_registration_number),
|
||||
text(company.business_status),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function userTable(users: DashboardUser[]): HTMLElement {
|
||||
return table(
|
||||
[
|
||||
L("B01_Dashboard_Table_Email"),
|
||||
L("B01_Dashboard_Table_Name"),
|
||||
L("B01_Dashboard_Table_Role"),
|
||||
L("B01_Dashboard_Table_Status"),
|
||||
],
|
||||
users.map((user) => [text(user.email), text(user.name), text(user.role), text(user.status)]),
|
||||
);
|
||||
}
|
||||
|
||||
function auditLogTable(logs: AuditLog[]): HTMLElement {
|
||||
return table(
|
||||
[
|
||||
L("B01_Dashboard_Table_Email"),
|
||||
L("B01_Dashboard_Table_Action"),
|
||||
L("B01_Dashboard_Table_Updated"),
|
||||
],
|
||||
logs.map((log) => [text(log.email), text(log.action), text(formatDate(log.timestamp))]),
|
||||
);
|
||||
}
|
||||
|
||||
function buildProfileForm(user: DashboardUser): HTMLElement {
|
||||
const name = createInputField({
|
||||
label: L("B01_Account_Field_Name"),
|
||||
value: user.name,
|
||||
required: true,
|
||||
});
|
||||
const position = createInputField({
|
||||
label: L("B01_Dashboard_Table_Position"),
|
||||
value: user.position ?? "",
|
||||
});
|
||||
const department = createInputField({
|
||||
label: L("B01_Dashboard_Table_Department"),
|
||||
value: user.department ?? "",
|
||||
});
|
||||
const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: user.phone ?? "" });
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "b01-dashboard__form-grid";
|
||||
grid.append(name.root, position.root, department.root, phone.root);
|
||||
const save = createButton({
|
||||
label: L("B01_Dashboard_SaveProfile"),
|
||||
onClick: async function onB01_Profile_Save_Click() {
|
||||
name.setError();
|
||||
if (isBlank(name.input.value)) {
|
||||
name.setError(L("Common_Msg_RequiredField"));
|
||||
return;
|
||||
}
|
||||
await runRequest(() =>
|
||||
updateUserProfile({
|
||||
name: name.input.value.trim(),
|
||||
position: position.input.value.trim() || null,
|
||||
department: department.input.value.trim() || null,
|
||||
phone: phone.input.value.trim() || null,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const wrap = document.createElement("div");
|
||||
wrap.append(grid, save);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function openCreateCompanyModal(): void {
|
||||
const name = createInputField({ label: L("B01_Dashboard_Table_Company"), required: true });
|
||||
const number = createInputField({
|
||||
label: L("B01_Dashboard_Field_BusinessNumber"),
|
||||
required: true,
|
||||
});
|
||||
const address = createInputField({ label: L("B01_Dashboard_Field_Address") });
|
||||
const owner = createInputField({ label: L("B01_Dashboard_Field_Owner") });
|
||||
openModal(
|
||||
L("B01_Dashboard_Modal_CreateCompany"),
|
||||
[name.root, number.root, address.root, owner.root],
|
||||
async () => {
|
||||
if (isBlank(name.input.value) || isBlank(number.input.value)) return;
|
||||
await runRequest(() =>
|
||||
createCompany({
|
||||
name: name.input.value.trim(),
|
||||
business_registration_number: number.input.value.trim(),
|
||||
business_address: address.input.value.trim() || null,
|
||||
business_owner: owner.input.value.trim() || null,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function openFindCompanyModal(): void {
|
||||
const query = createInputField({ label: L("B01_Dashboard_Field_Search"), required: true });
|
||||
const results = document.createElement("div");
|
||||
results.className = "b01-dashboard__actions";
|
||||
const search = createButton({
|
||||
label: L("Common_Btn_Search"),
|
||||
variant: "ghost",
|
||||
onClick: async function onB01_Company_Search_Click() {
|
||||
results.innerHTML = "";
|
||||
const companies = await searchCompanies(query.input.value.trim());
|
||||
for (const company of companies) {
|
||||
results.append(
|
||||
createButton({
|
||||
label: `${company.name} ${L("B01_Dashboard_JoinCompany")}`,
|
||||
variant: "ghost",
|
||||
onClick: () => runRequest(() => joinCompany(company.id)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
openModal(
|
||||
L("B01_Dashboard_Modal_FindCompany"),
|
||||
[query.root, search, results],
|
||||
async () => undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function openAddMemberModal(): void {
|
||||
const email = createInputField({
|
||||
label: L("B01_Dashboard_Field_MemberEmail"),
|
||||
type: "email",
|
||||
required: true,
|
||||
});
|
||||
openModal(L("B01_Dashboard_Modal_AddMember"), [email.root], async () => {
|
||||
if (isBlank(email.input.value)) return;
|
||||
await runRequest(() => addCompanyMember(email.input.value.trim()));
|
||||
});
|
||||
}
|
||||
|
||||
function openModal(title: string, body: HTMLElement[], onConfirm: () => Promise<void>): void {
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "b01-dashboard__modal";
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b01-dashboard__modal-panel";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "b01-dashboard__modal-title";
|
||||
heading.textContent = title;
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b01-dashboard__actions";
|
||||
actions.append(
|
||||
createButton({
|
||||
label: L("Common_Btn_Cancel"),
|
||||
variant: "ghost",
|
||||
onClick: () => modal.remove(),
|
||||
}),
|
||||
createButton({
|
||||
label: L("Common_Btn_Confirm"),
|
||||
onClick: async () => {
|
||||
await onConfirm();
|
||||
modal.remove();
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
},
|
||||
}),
|
||||
);
|
||||
panel.append(heading, ...body, actions);
|
||||
modal.append(panel);
|
||||
document.body.append(modal);
|
||||
}
|
||||
|
||||
async function onB01_Member_Remove_Click(userId: number): Promise<void> {
|
||||
await runRequest(() => removeCompanyMember(userId));
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
}
|
||||
|
||||
async function onB01_JoinRequest_Process_Click(
|
||||
requestId: number,
|
||||
action: "APPROVE" | "REJECT",
|
||||
systemMode: boolean,
|
||||
): Promise<void> {
|
||||
await runRequest(() =>
|
||||
systemMode && action === "APPROVE"
|
||||
? import("./B01_Dashboard_Api_Fetch").then((api) => api.systemApproveJoinRequest(requestId))
|
||||
: processJoinRequest(requestId, action),
|
||||
);
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
}
|
||||
|
||||
async function runRequest(action: () => Promise<unknown>): Promise<void> {
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await action();
|
||||
showToast(L("B01_Dashboard_Saved"), "success");
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("B01_Dashboard_RequestFailed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
return value ? value.slice(0, 10) : "-";
|
||||
}
|
||||
|
||||
function percent(value?: number | null): string {
|
||||
return value == null ? "-" : `${Math.round(value)}%`;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
.b01-dashboard {
|
||||
max-width: var(--page-max-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-40) var(--spacing-24) var(--spacing-64);
|
||||
}
|
||||
|
||||
.b01-dashboard__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-24);
|
||||
align-items: flex-end;
|
||||
margin-bottom: var(--spacing-32);
|
||||
}
|
||||
|
||||
.b01-dashboard__title {
|
||||
font-size: var(--text-heading);
|
||||
}
|
||||
|
||||
.b01-dashboard__subtitle {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.b01-dashboard__role {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-24);
|
||||
}
|
||||
|
||||
.b01-dashboard__section {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__section--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.b01-dashboard__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b01-dashboard__table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
}
|
||||
|
||||
.b01-dashboard__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b01-dashboard__table th,
|
||||
.b01-dashboard__table td {
|
||||
padding: var(--spacing-16);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b01-dashboard__table th {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b01-dashboard__table tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__empty {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b01-dashboard__workflow {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b01-dashboard__step {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-icons);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.b01-dashboard__step.is-enabled {
|
||||
background: var(--color-mist-violet);
|
||||
color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-dashboard__metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b01-dashboard__metric {
|
||||
padding: var(--spacing-16);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b01-dashboard__metric-label {
|
||||
display: block;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b01-dashboard__metric-value {
|
||||
display: block;
|
||||
margin-top: var(--spacing-8);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-subheading);
|
||||
font-family: var(--font-display);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.b01-dashboard__form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b01-dashboard__form-grid .ui-field:first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.b01-dashboard__modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-24);
|
||||
background: rgba(38, 17, 74, 0.24);
|
||||
}
|
||||
|
||||
.b01-dashboard__modal-panel {
|
||||
width: min(560px, 100%);
|
||||
background: var(--color-surface-raised);
|
||||
border-radius: var(--radius-cards);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: var(--spacing-24);
|
||||
}
|
||||
|
||||
.b01-dashboard__modal-title {
|
||||
margin-bottom: var(--spacing-16);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.b01-dashboard__header,
|
||||
.b01-dashboard__grid {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.b01-dashboard__section {
|
||||
margin-top: var(--spacing-24);
|
||||
}
|
||||
|
||||
.b01-dashboard__form-grid,
|
||||
.b01-dashboard__metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ async def verify_session(request: Request) -> dict[str, Any]:
|
||||
expired = now > row[3] or (now - row[4]).total_seconds() > SESSION_IDLE_TIMEOUT_SECONDS
|
||||
# NO_COMPANY(이메일 인증 완료·회사 미연결)도 로그인 유지 대상. 회사 연결 강제는
|
||||
# require_company 의존성에서 별도 처리한다.
|
||||
if expired or row[10] not in ("ACTIVE", "NO_COMPANY"):
|
||||
if expired or row[10] not in ("ACTIVE", "NO_COMPANY", "PENDING"):
|
||||
await cursor.execute("DELETE FROM sessions WHERE id = %s", (session_id,))
|
||||
await connection.commit()
|
||||
raise HTTPException(status_code=401, detail="세션이 만료되었습니다.")
|
||||
|
||||
@@ -35,7 +35,7 @@ async def create_registration(data: dict[str, Any], password_hash: str) -> int:
|
||||
await cursor.execute(
|
||||
"""INSERT INTO users
|
||||
(email, password_hash, name, position, phone, role, is_master, status)
|
||||
VALUES (%s, %s, %s, %s, %s, 'MEMBER', FALSE, 'PENDING_EMAIL')""",
|
||||
VALUES (%s, %s, %s, %s, %s, 'USER', FALSE, 'PENDING_EMAIL')""",
|
||||
(
|
||||
data["email"],
|
||||
password_hash,
|
||||
@@ -152,7 +152,8 @@ async def complete_registration(user_id: int, is_master: bool) -> None:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET status = 'NO_COMPANY', last_email_verified_at = CURRENT_TIMESTAMP
|
||||
"""UPDATE users SET status = 'NO_COMPANY', last_email_verified_at = CURRENT_TIMESTAMP,
|
||||
auth_expires_at = DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 3 MONTH)
|
||||
WHERE id = %s""",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
-- =============================================================================
|
||||
-- DB 마이그레이션: B01_Dashboard 역할별 대시보드
|
||||
-- 파일: 004_dashboard.sql
|
||||
-- =============================================================================
|
||||
|
||||
USE aislo_db;
|
||||
|
||||
ALTER TABLE users
|
||||
MODIFY role ENUM('SYSTEM_ADMIN', 'MASTER', 'MEMBER', 'ADMIN', 'USER') NOT NULL DEFAULT 'USER';
|
||||
|
||||
UPDATE users SET role = 'ADMIN' WHERE role = 'MASTER';
|
||||
UPDATE users SET role = 'USER' WHERE role = 'MEMBER';
|
||||
|
||||
ALTER TABLE users
|
||||
MODIFY role ENUM('SYSTEM_ADMIN', 'ADMIN', 'USER') NOT NULL DEFAULT 'USER',
|
||||
MODIFY status ENUM('PENDING_EMAIL', 'NO_COMPANY', 'PENDING', 'ACTIVE', 'INACTIVE', 'REJECTED')
|
||||
NOT NULL DEFAULT 'PENDING_EMAIL',
|
||||
ADD COLUMN IF NOT EXISTS department VARCHAR(100) NULL AFTER position,
|
||||
ADD COLUMN IF NOT EXISTS last_login DATETIME NULL AFTER status,
|
||||
ADD COLUMN IF NOT EXISTS auth_expires_at DATETIME NULL AFTER last_login;
|
||||
|
||||
ALTER TABLE companies
|
||||
ADD COLUMN IF NOT EXISTS created_by INT NULL AFTER business_status;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_audit_logs (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id INT NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
resource_type VARCHAR(50) NULL,
|
||||
resource_id INT NULL,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
ip_address VARCHAR(50) NULL,
|
||||
user_agent TEXT NULL,
|
||||
INDEX idx_system_audit_logs_timestamp (timestamp),
|
||||
INDEX idx_system_audit_logs_user_id (user_id),
|
||||
INDEX idx_system_audit_logs_action (action),
|
||||
CONSTRAINT fk_system_audit_logs_user_id FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_resources (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
cpu_usage_percent FLOAT NULL,
|
||||
memory_usage_percent FLOAT NULL,
|
||||
disk_usage_percent FLOAT NULL,
|
||||
active_user_count INT NOT NULL DEFAULT 0,
|
||||
active_project_count INT NOT NULL DEFAULT 0,
|
||||
total_storage_mb FLOAT NOT NULL DEFAULT 0,
|
||||
INDEX idx_system_resources_timestamp (timestamp)
|
||||
);
|
||||
|
||||
ALTER TABLE join_requests
|
||||
ADD COLUMN IF NOT EXISTS review_comment TEXT NULL AFTER reviewed_at;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
|
||||
@@ -24,6 +24,7 @@ from A06_Login.A06_Login_Router import router as a06_login_router
|
||||
from A07_Register.A07_Register_Router import router as a07_register_router
|
||||
from A08_Support.A08_Support_Router import router as a08_support_router
|
||||
from A09_Security.A09_Security_Router import router as a09_security_router
|
||||
from B01_Dashboard.B01_Dashboard_Router import router as b01_dashboard_router
|
||||
from B03_FileInput.B03_FileInput_Router import router as b03_file_input_router
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router import router as b04_surface_router
|
||||
from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router
|
||||
@@ -233,6 +234,7 @@ app.include_router(a06_login_router)
|
||||
app.include_router(a07_register_router)
|
||||
app.include_router(a08_support_router)
|
||||
app.include_router(a09_security_router)
|
||||
app.include_router(b01_dashboard_router)
|
||||
protected = [Depends(verify_session)]
|
||||
protected_with_company = [Depends(verify_session), Depends(require_company)]
|
||||
app.include_router(b03_file_input_router, dependencies=protected_with_company)
|
||||
|
||||
@@ -416,6 +416,64 @@ export const ui_locales = {
|
||||
"비밀번호는 8자 이상이어야 합니다.",
|
||||
"Password must be at least 8 characters.",
|
||||
],
|
||||
B01_Dashboard_Title: ["AISLO 대시보드", "AISLO Dashboard"],
|
||||
B01_Dashboard_Subtitle: [
|
||||
"역할과 회사 상태에 맞춰 프로젝트, 조직, 보안 정보를 관리합니다.",
|
||||
"Manage projects, organization, and security by role and company status.",
|
||||
],
|
||||
B01_Dashboard_Role_User: ["일반 사용자", "User"],
|
||||
B01_Dashboard_Role_Admin: ["회사 관리자", "Company Admin"],
|
||||
B01_Dashboard_Role_SystemAdmin: ["시스템 관리자", "System Admin"],
|
||||
B01_Dashboard_Projects: ["프로젝트", "Projects"],
|
||||
B01_Dashboard_NewProject: ["신규 프로젝트", "New project"],
|
||||
B01_Dashboard_Company: ["회사 정보", "Company"],
|
||||
B01_Dashboard_NoCompany: ["회사 미연결", "No company linked"],
|
||||
B01_Dashboard_CreateCompany: ["회사 생성", "Create company"],
|
||||
B01_Dashboard_FindCompany: ["회사 찾기", "Find company"],
|
||||
B01_Dashboard_JoinCompany: ["가입 신청", "Request to join"],
|
||||
B01_Dashboard_Members: ["팀원 관리", "Team members"],
|
||||
B01_Dashboard_AddMember: ["팀원 추가", "Add member"],
|
||||
B01_Dashboard_RemoveMember: ["제거", "Remove"],
|
||||
B01_Dashboard_JoinRequests: ["가입 요청", "Join requests"],
|
||||
B01_Dashboard_Approve: ["승인", "Approve"],
|
||||
B01_Dashboard_Reject: ["거절", "Reject"],
|
||||
B01_Dashboard_Resources: ["리소스 현황", "Resources"],
|
||||
B01_Dashboard_Companies: ["회사 관리", "Companies"],
|
||||
B01_Dashboard_Users: ["사용자 관리", "Users"],
|
||||
B01_Dashboard_AuditLogs: ["시스템 로그", "Audit logs"],
|
||||
B01_Dashboard_Profile: ["기본정보", "Profile"],
|
||||
B01_Dashboard_SaveProfile: ["기본정보 저장", "Save profile"],
|
||||
B01_Dashboard_Table_Project: ["프로젝트명", "Project"],
|
||||
B01_Dashboard_Table_Region: ["지역", "Region"],
|
||||
B01_Dashboard_Table_Progress: ["진행도", "Progress"],
|
||||
B01_Dashboard_Table_Workflow: ["워크플로우", "Workflow"],
|
||||
B01_Dashboard_Table_Updated: ["수정일", "Updated"],
|
||||
B01_Dashboard_Table_Email: ["이메일", "Email"],
|
||||
B01_Dashboard_Table_Name: ["이름", "Name"],
|
||||
B01_Dashboard_Table_Position: ["직급", "Position"],
|
||||
B01_Dashboard_Table_Department: ["부서", "Department"],
|
||||
B01_Dashboard_Table_Role: ["역할", "Role"],
|
||||
B01_Dashboard_Table_Status: ["상태", "Status"],
|
||||
B01_Dashboard_Table_Company: ["회사명", "Company"],
|
||||
B01_Dashboard_Table_Requested: ["신청일", "Requested"],
|
||||
B01_Dashboard_Table_Action: ["관리", "Action"],
|
||||
B01_Dashboard_Table_Owner: ["소유자", "Owner"],
|
||||
B01_Dashboard_Field_BusinessNumber: ["사업자등록번호", "Business number"],
|
||||
B01_Dashboard_Field_Address: ["주소", "Address"],
|
||||
B01_Dashboard_Field_Owner: ["대표자명", "Owner"],
|
||||
B01_Dashboard_Field_Search: ["검색어", "Search"],
|
||||
B01_Dashboard_Field_MemberEmail: ["팀원 이메일", "Member email"],
|
||||
B01_Dashboard_Metric_Cpu: ["CPU", "CPU"],
|
||||
B01_Dashboard_Metric_Memory: ["메모리", "Memory"],
|
||||
B01_Dashboard_Metric_Disk: ["디스크", "Disk"],
|
||||
B01_Dashboard_Metric_ActiveUsers: ["활성 사용자", "Active users"],
|
||||
B01_Dashboard_Metric_ActiveProjects: ["활성 프로젝트", "Active projects"],
|
||||
B01_Dashboard_Modal_CreateCompany: ["회사 생성", "Create company"],
|
||||
B01_Dashboard_Modal_FindCompany: ["회사 검색", "Find company"],
|
||||
B01_Dashboard_Modal_AddMember: ["팀원 추가", "Add member"],
|
||||
B01_Dashboard_Saved: ["저장되었습니다.", "Saved."],
|
||||
B01_Dashboard_LoadFailed: ["대시보드를 불러오지 못했습니다.", "Failed to load dashboard."],
|
||||
B01_Dashboard_RequestFailed: ["요청 처리에 실패했습니다.", "Request failed."],
|
||||
|
||||
/* --- B02_ProjRegister 프로젝트 등록 --- */
|
||||
B02_Proj_Title: ["프로젝트 등록", "Register Project"],
|
||||
|
||||
Reference in New Issue
Block a user