feat: WTM 멀티프로젝트 플랫폼 구축 (BE + FE 전체 구현)

Phase 0: wbx-spring-core 라이브러리 전환
- java-library 플러그인, WbxAutoConfiguration, Admin 조건부 활성화
- 루트 settings.gradle + build.gradle (멀티모듈)

Phase 1: wtm-api 모듈 생성
- 23개 JPA Entity, 14개 Controller, 79개 API 엔드포인트
- Flyway V100~V107 MySQL 마이그레이션
- TimesheetRuleEngine, TimesheetApprovalHandler, P6WbsParser

Phase 2: wtm-frontend (Vue 3 + PrimeVue 4)
- 10개 도메인 모듈, 17개 View, 5개 서브컴포넌트
- 반응형 레이아웃 (AppLayout, AppSidebar, AppTopbar)
- BaseCrudTable, BaseFormDialog, BasePageHeader 표준 컴포넌트
- JWT 인터셉터, 역할 기반 메뉴 필터링

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
이 Commit은 다음에 포함되어 있습니다:
2026-03-25 21:01:43 +09:00
부모 783865266b
커밋 df723f1d59
533개의 변경된 파일15528개의 추가작업 그리고 154개의 파일을 삭제

파일 보기

@@ -0,0 +1,2 @@
kr.co.accura.wbx.spring.config.WbxAutoConfiguration
kr.co.accura.wbx.spring.admin.AdminAutoConfiguration

파일 보기

@@ -0,0 +1,29 @@
# ===== AWS 클라우드 프로필 =====
# 사용법: --spring.profiles.active=prod,postgresql,aws
# AWS Cognito SSO + S3 연동
spring:
security:
oauth2:
client:
registration:
cognito:
client-id: ${AWS_COGNITO_CLIENT_ID}
client-secret: ${AWS_COGNITO_CLIENT_SECRET}
scope: openid,profile,email
provider:
cognito:
issuer-uri: https://cognito-idp.${AWS_REGION:ap-northeast-2}.amazonaws.com/${AWS_USER_POOL_ID}
wbx:
spring:
mfa:
enabled: true
force-for-external: true
file:
storage-type: aws-s3
aws:
bucket: ${AWS_S3_BUCKET}
region: ${AWS_REGION:ap-northeast-2}
access-key: ${AWS_ACCESS_KEY}
secret-key: ${AWS_SECRET_KEY}

파일 보기

@@ -0,0 +1,28 @@
# ===== Azure 클라우드 프로필 =====
# 사용법: --spring.profiles.active=prod,mssql,azure
# Azure Entra SSO + Blob Storage + Key Vault 연동
spring:
security:
oauth2:
client:
registration:
azure:
client-id: ${AZURE_CLIENT_ID}
client-secret: ${AZURE_CLIENT_SECRET}
scope: openid,profile,email
provider:
azure:
issuer-uri: https://login.microsoftonline.com/${AZURE_TENANT_ID}/v2.0
wbx:
spring:
mfa:
enabled: false # Azure Entra Conditional Access가 MFA 처리
force-for-external: true
file:
storage-type: azure-blob
azure:
account-name: ${AZURE_STORAGE_ACCOUNT}
account-key: ${AZURE_STORAGE_KEY}
container-name: ${AZURE_CONTAINER:uploads}

파일 보기

@@ -0,0 +1,69 @@
spring:
application:
name: wbx-spring-core
jpa:
hibernate:
ddl-auto: update
open-in-view: false
database-platform: org.hibernate.dialect.MySQLDialect
properties:
hibernate:
default_batch_fetch_size: 100
format_sql: true
dialect: org.hibernate.dialect.MySQLDialect
flyway:
enabled: false # 개발 시 hibernate ddl-auto 사용, 프로덕션 시 true
datasource:
url: jdbc:mysql://${DB_HOST:ws.ubuilder.co.kr}:${DB_PORT:3306}/${DB_NAME:mos}?useUnicode=true&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul
username: ${DB_USER:jsh}
password: ${DB_PASS:jsh@}
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
data:
redis:
host: localhost
port: 6379
server:
port: 8080
forward-headers-strategy: native
servlet:
context-path: ${SERVER_CONTEXT_PATH:/}
# WBX Spring Framework
wbx:
spring:
api-prefix: /api
jwt:
secret: ${JWT_SECRET:wbx-spring-dev-secret-key-change-in-production-minimum-256-bits-long}
expiration: 28800
cors:
allowed-origins: ${CORS_ORIGINS:http://localhost:5173,http://localhost:3000,http://localhost:8080}
notification:
sse-enabled: true
heartbeat-seconds: 30
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: when-authorized
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui
packages-to-scan: kr.co.accura.wbx.spring
spring.mvc.problemdetail.enabled: false

파일 보기

@@ -0,0 +1,22 @@
# ===== MSSQL 프로필 =====
# 사용법: --spring.profiles.active=prod,mssql
spring:
datasource:
url: jdbc:sqlserver://${DB_HOST:localhost}:${DB_PORT:1433};databaseName=${DB_NAME:wbx_spring};encrypt=true;trustServerCertificate=true
username: ${DB_USER:sa}
password: ${DB_PASS:password}
driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver
hikari:
maximum-pool-size: ${DB_POOL_SIZE:20}
minimum-idle: 5
connection-timeout: 30000
jpa:
database-platform: org.hibernate.dialect.SQLServerDialect
properties:
hibernate:
dialect: org.hibernate.dialect.SQLServerDialect
flyway:
locations: classpath:db/migration/common,classpath:db/migration/mssql

파일 보기

@@ -0,0 +1,22 @@
# ===== MySQL 프로필 =====
# 사용법: --spring.profiles.active=prod,mysql
spring:
datasource:
url: jdbc:mysql://${DB_HOST:ws.ubuilder.co.kr}:${DB_PORT:3306}/${DB_NAME:mos}?useUnicode=true&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul
username: ${DB_USER:jsh}
password: ${DB_PASS:jsh@}
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: ${DB_POOL_SIZE:20}
minimum-idle: 5
connection-timeout: 30000
jpa:
database-platform: org.hibernate.dialect.MySQLDialect
properties:
hibernate:
dialect: org.hibernate.dialect.MySQLDialect
flyway:
locations: classpath:db/migration/common,classpath:db/migration/mysql

파일 보기

@@ -0,0 +1,22 @@
# ===== Oracle 프로필 =====
# 사용법: --spring.profiles.active=prod,oracle
spring:
datasource:
url: jdbc:oracle:thin:@${DB_HOST:localhost}:${DB_PORT:1521}:${DB_SID:ORCL}
username: ${DB_USER:wbxapp}
password: ${DB_PASS:password}
driver-class-name: oracle.jdbc.OracleDriver
hikari:
maximum-pool-size: ${DB_POOL_SIZE:20}
minimum-idle: 5
connection-timeout: 30000
jpa:
database-platform: org.hibernate.dialect.OracleDialect
properties:
hibernate:
dialect: org.hibernate.dialect.OracleDialect
flyway:
locations: classpath:db/migration/common,classpath:db/migration/oracle

파일 보기

@@ -0,0 +1,22 @@
# ===== PostgreSQL 프로필 =====
# 사용법: --spring.profiles.active=prod,postgresql
spring:
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:wbx_spring}
username: ${DB_USER:wbxapp}
password: ${DB_PASS:password}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: ${DB_POOL_SIZE:20}
minimum-idle: 5
connection-timeout: 30000
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
flyway:
locations: classpath:db/migration/common,classpath:db/migration/postgresql

파일 보기

@@ -0,0 +1,52 @@
# ===== WBX Spring Framework — 프로덕션 프로필 =====
# 사용법: java -jar app.jar --spring.profiles.active=prod,mysql
# java -jar app.jar --spring.profiles.active=prod,postgresql
server:
port: 8080
forward-headers-strategy: native
servlet:
context-path: ${SERVER_CONTEXT_PATH:/}
spring:
jpa:
hibernate:
ddl-auto: validate # 프로덕션: Flyway 사용, DDL 검증만
open-in-view: false
properties:
hibernate:
default_batch_fetch_size: 100
flyway:
enabled: true
wbx:
spring:
jwt:
secret: ${JWT_SECRET}
expiration: 28800
cors:
allowed-origins: ${CORS_ORIGINS:https://app.company.com}
notification:
sse-enabled: true
heartbeat-seconds: 30
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when-authorized
springdoc:
swagger-ui:
enabled: false # 프로덕션 Swagger 비활성화
logging:
level:
root: WARN
kr.co.accura.wbx.spring: INFO
file:
name: ${LOG_PATH:/opt/wbx-app/logs/app.log}

파일 보기

@@ -0,0 +1,38 @@
# ===== 테스트 프로필 =====
# 사용법: ./gradlew test (자동 적용)
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
database-platform: org.hibernate.dialect.H2Dialect
flyway:
enabled: false
data:
redis:
host: localhost
port: 6379
wbx:
spring:
jwt:
secret: test-secret-key-minimum-256-bits-for-hmac-sha-algorithm
expiration: 3600
mfa:
enabled: false
file:
storage-type: local
upload-dir: ./build/test-uploads
logging:
level:
root: WARN
kr.co.accura.wbx.spring: DEBUG

파일 보기

@@ -0,0 +1,8 @@
-- WBX Spring Framework — 초기 역할 데이터
INSERT IGNORE INTO wbx_roles (code, name, description, is_system) VALUES
('SA', 'System Administrator', '전체 시스템 관리', true),
('PM', 'Project Manager', '프로젝트 관리, 최종 결재', true),
('PCM', 'Project Control Mgr', 'WBS/TEAL 관리', true),
('PTK', 'Project Timekeeper', '시수 관리', true),
('DL', 'Discipline Lead', '1차 결재, Discipline 관리', true),
('USER', 'General User', '일반 사용자', true);

파일 보기

@@ -0,0 +1,10 @@
-- WBX Spring Framework — 초기 시스템 설정
INSERT IGNORE INTO wbx_system_config (config_key, config_value, value_type, description) VALUES
('auth.max_failed_attempts', '5', 'INT', '최대 로그인 실패 횟수'),
('auth.lockout_minutes', '15', 'INT', '계정 잠금 시간(분)'),
('auth.password_expiry_days', '90', 'INT', '비밀번호 만료 기간(일)'),
('auth.session_timeout_minutes', '480', 'INT', '세션 타임아웃(분)'),
('notification.sse_heartbeat_seconds', '30', 'INT', 'SSE 하트비트 주기(초)'),
('file.max_upload_size_mb', '50', 'INT', '최대 업로드 크기(MB)'),
('app.timezone', 'Asia/Seoul', 'STRING', '시스템 타임존'),
('app.date_format', 'yyyy-MM-dd', 'STRING', '날짜 표시 형식');

파일 보기

@@ -0,0 +1,134 @@
-- WBX Spring Framework — MSSQL 스키마
CREATE TABLE wbx_users (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
email NVARCHAR(255) NOT NULL UNIQUE,
username NVARCHAR(100) NOT NULL UNIQUE,
hashed_password NVARCHAR(500),
full_name NVARCHAR(255),
phone NVARCHAR(50),
department_id BIGINT,
position_title NVARCHAR(100),
employee_number NVARCHAR(50) UNIQUE,
is_active BIT DEFAULT 1,
is_admin BIT DEFAULT 0,
mfa_enabled BIT DEFAULT 0,
azure_oid NVARCHAR(255),
sso_provider NVARCHAR(50),
failed_login_attempts INT DEFAULT 0,
last_failed_login DATETIME2,
locked_until DATETIME2,
password_changed_at DATETIME2,
must_change_password BIT DEFAULT 0,
last_login_at DATETIME2,
created_at DATETIME2 DEFAULT GETDATE(),
updated_at DATETIME2 DEFAULT GETDATE(),
created_by BIGINT,
updated_by BIGINT
);
CREATE TABLE wbx_roles (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
code NVARCHAR(50) NOT NULL UNIQUE,
name NVARCHAR(100) NOT NULL,
description NVARCHAR(500),
is_system BIT DEFAULT 0,
created_at DATETIME2 DEFAULT GETDATE(),
updated_at DATETIME2 DEFAULT GETDATE()
);
CREATE TABLE wbx_user_roles (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
user_id BIGINT NOT NULL FOREIGN KEY REFERENCES wbx_users(id),
role_id BIGINT NOT NULL FOREIGN KEY REFERENCES wbx_roles(id),
CONSTRAINT uk_wbx_user_role UNIQUE (user_id, role_id)
);
CREATE TABLE wbx_role_permissions (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
role_id BIGINT NOT NULL FOREIGN KEY REFERENCES wbx_roles(id),
module NVARCHAR(100) NOT NULL,
action NVARCHAR(50) NOT NULL,
dept_scope NVARCHAR(20) DEFAULT 'OWN',
CONSTRAINT uk_wbx_role_perm UNIQUE (role_id, module, action)
);
CREATE TABLE wbx_refresh_tokens (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
user_id BIGINT NOT NULL FOREIGN KEY REFERENCES wbx_users(id),
token_hash NVARCHAR(500) NOT NULL,
device_info NVARCHAR(500),
ip_address NVARCHAR(50),
expires_at DATETIME2 NOT NULL,
created_at DATETIME2 DEFAULT GETDATE()
);
CREATE TABLE wbx_totp_secrets (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
user_id BIGINT NOT NULL UNIQUE FOREIGN KEY REFERENCES wbx_users(id),
encrypted_secret NVARCHAR(500) NOT NULL,
verified BIT DEFAULT 0,
backup_codes NVARCHAR(2000),
created_at DATETIME2 DEFAULT GETDATE(),
updated_at DATETIME2 DEFAULT GETDATE(),
created_by BIGINT,
updated_by BIGINT
);
CREATE TABLE wbx_login_history (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
user_id BIGINT,
email NVARCHAR(255),
action NVARCHAR(50),
auth_method NVARCHAR(50),
ip_address NVARCHAR(50),
user_agent NVARCHAR(500),
failure_reason NVARCHAR(500),
created_at DATETIME2 DEFAULT GETDATE()
);
CREATE TABLE wbx_notifications (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
user_id BIGINT NOT NULL FOREIGN KEY REFERENCES wbx_users(id),
title NVARCHAR(500),
message NVARCHAR(MAX),
type NVARCHAR(50),
is_read BIT DEFAULT 0,
link NVARCHAR(1000),
created_at DATETIME2 DEFAULT GETDATE()
);
CREATE TABLE wbx_audit_logs (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
user_id BIGINT,
username NVARCHAR(255),
action NVARCHAR(50) NOT NULL,
resource NVARCHAR(100) NOT NULL,
resource_id BIGINT,
detail NVARCHAR(4000),
ip_address NVARCHAR(50),
created_at DATETIME2 DEFAULT GETDATE()
);
CREATE TABLE wbx_file_uploads (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
user_id BIGINT,
original_name NVARCHAR(500),
stored_name NVARCHAR(500),
file_key NVARCHAR(500),
content_type NVARCHAR(200),
file_size BIGINT,
category NVARCHAR(100),
created_at DATETIME2 DEFAULT GETDATE()
);
CREATE TABLE wbx_system_config (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
config_key NVARCHAR(100) NOT NULL UNIQUE,
config_value NVARCHAR(4000),
value_type NVARCHAR(20) DEFAULT 'STRING',
description NVARCHAR(500),
is_editable BIT DEFAULT 1,
updated_at DATETIME2 DEFAULT GETDATE(),
updated_by BIGINT
);

파일 보기

@@ -0,0 +1,141 @@
-- WBX Spring Framework — MySQL 스키마 (utf8mb4)
-- Hibernate ddl-auto와 병행 사용 가능 (Flyway 활성화 시 이 파일 사용)
CREATE TABLE IF NOT EXISTS wbx_users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
username VARCHAR(100) NOT NULL UNIQUE,
hashed_password VARCHAR(500),
full_name VARCHAR(255),
phone VARCHAR(50),
department_id BIGINT,
position_title VARCHAR(100),
employee_number VARCHAR(50) UNIQUE,
is_active TINYINT(1) DEFAULT 1,
is_admin TINYINT(1) DEFAULT 0,
mfa_enabled TINYINT(1) DEFAULT 0,
azure_oid VARCHAR(255),
sso_provider VARCHAR(50),
failed_login_attempts INT DEFAULT 0,
last_failed_login DATETIME,
locked_until DATETIME,
password_changed_at DATETIME,
must_change_password TINYINT(1) DEFAULT 0,
last_login_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by BIGINT,
updated_by BIGINT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS wbx_roles (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
description VARCHAR(500),
is_system TINYINT(1) DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS wbx_user_roles (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
UNIQUE KEY uk_user_role (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES wbx_users(id),
FOREIGN KEY (role_id) REFERENCES wbx_roles(id)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS wbx_role_permissions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
role_id BIGINT NOT NULL,
module VARCHAR(100) NOT NULL,
action VARCHAR(50) NOT NULL,
dept_scope VARCHAR(20) DEFAULT 'OWN',
UNIQUE KEY uk_role_perm (role_id, module, action),
FOREIGN KEY (role_id) REFERENCES wbx_roles(id)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS wbx_refresh_tokens (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
token_hash VARCHAR(500) NOT NULL,
device_info VARCHAR(500),
ip_address VARCHAR(50),
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES wbx_users(id)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS wbx_totp_secrets (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL UNIQUE,
encrypted_secret VARCHAR(500) NOT NULL,
verified TINYINT(1) DEFAULT 0,
backup_codes VARCHAR(2000),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by BIGINT,
updated_by BIGINT,
FOREIGN KEY (user_id) REFERENCES wbx_users(id)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS wbx_login_history (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT,
email VARCHAR(255),
action VARCHAR(50),
auth_method VARCHAR(50),
ip_address VARCHAR(50),
user_agent VARCHAR(500),
failure_reason VARCHAR(500),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS wbx_notifications (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
title VARCHAR(500),
message TEXT,
type VARCHAR(50),
is_read TINYINT(1) DEFAULT 0,
link VARCHAR(1000),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES wbx_users(id)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS wbx_audit_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT,
username VARCHAR(255),
action VARCHAR(50) NOT NULL,
resource VARCHAR(100) NOT NULL,
resource_id BIGINT,
detail VARCHAR(4000),
ip_address VARCHAR(50),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS wbx_file_uploads (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT,
original_name VARCHAR(500),
stored_name VARCHAR(500),
file_key VARCHAR(500),
content_type VARCHAR(200),
file_size BIGINT,
category VARCHAR(100),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS wbx_system_config (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
config_key VARCHAR(100) NOT NULL UNIQUE,
config_value VARCHAR(4000),
value_type VARCHAR(20) DEFAULT 'STRING',
description VARCHAR(500),
is_editable TINYINT(1) DEFAULT 1,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
updated_by BIGINT
) ENGINE=InnoDB;

파일 보기

@@ -0,0 +1,134 @@
-- WBX Spring Framework — Oracle 스키마
CREATE TABLE wbx_users (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email VARCHAR2(255) NOT NULL UNIQUE,
username VARCHAR2(100) NOT NULL UNIQUE,
hashed_password VARCHAR2(500),
full_name VARCHAR2(255),
phone VARCHAR2(50),
department_id NUMBER(19),
position_title VARCHAR2(100),
employee_number VARCHAR2(50) UNIQUE,
is_active NUMBER(1) DEFAULT 1,
is_admin NUMBER(1) DEFAULT 0,
mfa_enabled NUMBER(1) DEFAULT 0,
azure_oid VARCHAR2(255),
sso_provider VARCHAR2(50),
failed_login_attempts NUMBER(10) DEFAULT 0,
last_failed_login TIMESTAMP,
locked_until TIMESTAMP,
password_changed_at TIMESTAMP,
must_change_password NUMBER(1) DEFAULT 0,
last_login_at TIMESTAMP,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP,
created_by NUMBER(19),
updated_by NUMBER(19)
);
CREATE TABLE wbx_roles (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code VARCHAR2(50) NOT NULL UNIQUE,
name VARCHAR2(100) NOT NULL,
description VARCHAR2(500),
is_system NUMBER(1) DEFAULT 0,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP
);
CREATE TABLE wbx_user_roles (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id NUMBER(19) NOT NULL REFERENCES wbx_users(id),
role_id NUMBER(19) NOT NULL REFERENCES wbx_roles(id),
CONSTRAINT uk_wbx_user_role UNIQUE (user_id, role_id)
);
CREATE TABLE wbx_role_permissions (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
role_id NUMBER(19) NOT NULL REFERENCES wbx_roles(id),
module VARCHAR2(100) NOT NULL,
action VARCHAR2(50) NOT NULL,
dept_scope VARCHAR2(20) DEFAULT 'OWN',
CONSTRAINT uk_wbx_role_perm UNIQUE (role_id, module, action)
);
CREATE TABLE wbx_refresh_tokens (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id NUMBER(19) NOT NULL REFERENCES wbx_users(id),
token_hash VARCHAR2(500) NOT NULL,
device_info VARCHAR2(500),
ip_address VARCHAR2(50),
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP
);
CREATE TABLE wbx_totp_secrets (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id NUMBER(19) NOT NULL UNIQUE REFERENCES wbx_users(id),
encrypted_secret VARCHAR2(500) NOT NULL,
verified NUMBER(1) DEFAULT 0,
backup_codes VARCHAR2(2000),
created_at TIMESTAMP DEFAULT SYSTIMESTAMP,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP,
created_by NUMBER(19),
updated_by NUMBER(19)
);
CREATE TABLE wbx_login_history (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id NUMBER(19),
email VARCHAR2(255),
action VARCHAR2(50),
auth_method VARCHAR2(50),
ip_address VARCHAR2(50),
user_agent VARCHAR2(500),
failure_reason VARCHAR2(500),
created_at TIMESTAMP DEFAULT SYSTIMESTAMP
);
CREATE TABLE wbx_notifications (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id NUMBER(19) NOT NULL REFERENCES wbx_users(id),
title VARCHAR2(500),
message CLOB,
type VARCHAR2(50),
is_read NUMBER(1) DEFAULT 0,
link VARCHAR2(1000),
created_at TIMESTAMP DEFAULT SYSTIMESTAMP
);
CREATE TABLE wbx_audit_logs (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id NUMBER(19),
username VARCHAR2(255),
action VARCHAR2(50) NOT NULL,
resource VARCHAR2(100) NOT NULL,
resource_id NUMBER(19),
detail VARCHAR2(4000),
ip_address VARCHAR2(50),
created_at TIMESTAMP DEFAULT SYSTIMESTAMP
);
CREATE TABLE wbx_file_uploads (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id NUMBER(19),
original_name VARCHAR2(500),
stored_name VARCHAR2(500),
file_key VARCHAR2(500),
content_type VARCHAR2(200),
file_size NUMBER(19),
category VARCHAR2(100),
created_at TIMESTAMP DEFAULT SYSTIMESTAMP
);
CREATE TABLE wbx_system_config (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
config_key VARCHAR2(100) NOT NULL UNIQUE,
config_value VARCHAR2(4000),
value_type VARCHAR2(20) DEFAULT 'STRING',
description VARCHAR2(500),
is_editable NUMBER(1) DEFAULT 1,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP,
updated_by NUMBER(19)
);

파일 보기

@@ -0,0 +1,134 @@
-- WBX Spring Framework — PostgreSQL 스키마
CREATE TABLE IF NOT EXISTS wbx_users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
username VARCHAR(100) NOT NULL UNIQUE,
hashed_password VARCHAR(500),
full_name VARCHAR(255),
phone VARCHAR(50),
department_id BIGINT,
position_title VARCHAR(100),
employee_number VARCHAR(50) UNIQUE,
is_active BOOLEAN DEFAULT TRUE,
is_admin BOOLEAN DEFAULT FALSE,
mfa_enabled BOOLEAN DEFAULT FALSE,
azure_oid VARCHAR(255),
sso_provider VARCHAR(50),
failed_login_attempts INT DEFAULT 0,
last_failed_login TIMESTAMP,
locked_until TIMESTAMP,
password_changed_at TIMESTAMP,
must_change_password BOOLEAN DEFAULT FALSE,
last_login_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
created_by BIGINT,
updated_by BIGINT
);
CREATE TABLE IF NOT EXISTS wbx_roles (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
description VARCHAR(500),
is_system BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS wbx_user_roles (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES wbx_users(id),
role_id BIGINT NOT NULL REFERENCES wbx_roles(id),
UNIQUE (user_id, role_id)
);
CREATE TABLE IF NOT EXISTS wbx_role_permissions (
id BIGSERIAL PRIMARY KEY,
role_id BIGINT NOT NULL REFERENCES wbx_roles(id),
module VARCHAR(100) NOT NULL,
action VARCHAR(50) NOT NULL,
dept_scope VARCHAR(20) DEFAULT 'OWN',
UNIQUE (role_id, module, action)
);
CREATE TABLE IF NOT EXISTS wbx_refresh_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES wbx_users(id),
token_hash VARCHAR(500) NOT NULL,
device_info VARCHAR(500),
ip_address VARCHAR(50),
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS wbx_totp_secrets (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL UNIQUE REFERENCES wbx_users(id),
encrypted_secret VARCHAR(500) NOT NULL,
verified BOOLEAN DEFAULT FALSE,
backup_codes VARCHAR(2000),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
created_by BIGINT,
updated_by BIGINT
);
CREATE TABLE IF NOT EXISTS wbx_login_history (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT,
email VARCHAR(255),
action VARCHAR(50),
auth_method VARCHAR(50),
ip_address VARCHAR(50),
user_agent VARCHAR(500),
failure_reason VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS wbx_notifications (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES wbx_users(id),
title VARCHAR(500),
message TEXT,
type VARCHAR(50),
is_read BOOLEAN DEFAULT FALSE,
link VARCHAR(1000),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS wbx_audit_logs (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT,
username VARCHAR(255),
action VARCHAR(50) NOT NULL,
resource VARCHAR(100) NOT NULL,
resource_id BIGINT,
detail VARCHAR(4000),
ip_address VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS wbx_file_uploads (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT,
original_name VARCHAR(500),
stored_name VARCHAR(500),
file_key VARCHAR(500),
content_type VARCHAR(200),
file_size BIGINT,
category VARCHAR(100),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS wbx_system_config (
id BIGSERIAL PRIMARY KEY,
config_key VARCHAR(100) NOT NULL UNIQUE,
config_value VARCHAR(4000),
value_type VARCHAR(20) DEFAULT 'STRING',
description VARCHAR(500),
is_editable BOOLEAN DEFAULT TRUE,
updated_at TIMESTAMP DEFAULT NOW(),
updated_by BIGINT
);

파일 보기

@@ -0,0 +1,64 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Malgun Gothic', -apple-system, sans-serif; background: #f0f2f5; color: #333; }
/* Layout */
.admin-layout { display: flex; min-height: 100vh; }
.admin-sidebar { width: 220px; background: #1e3c78; color: #fff; padding: 0; flex-shrink: 0; }
.admin-sidebar .logo { padding: 20px; font-size: 18px; font-weight: bold; border-bottom: 1px solid rgba(255,255,255,0.1); }
.admin-sidebar nav a { display: block; padding: 12px 20px; color: rgba(255,255,255,0.7); text-decoration: none; font-size: 14px; border-left: 3px solid transparent; }
.admin-sidebar nav a:hover { background: rgba(255,255,255,0.05); color: #fff; }
.admin-sidebar nav a.active { background: rgba(255,255,255,0.1); color: #fff; border-left-color: #4da6ff; }
.admin-content { flex: 1; padding: 24px; }
/* Header */
.page-header { margin-bottom: 24px; }
.page-header h1 { font-size: 22px; color: #1e3c78; }
.page-header p { color: #888; font-size: 13px; margin-top: 4px; }
/* Cards */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
.stat-card { background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.stat-card .label { font-size: 13px; color: #888; }
.stat-card .value { font-size: 28px; font-weight: bold; color: #1e3c78; margin-top: 4px; }
.stat-card .value.green { color: #2e7d32; }
.stat-card .value.orange { color: #e65100; }
/* Table */
.data-table { width: 100%; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.data-table table { width: 100%; border-collapse: collapse; }
.data-table th { background: #1e3c78; color: #fff; padding: 10px 14px; text-align: left; font-size: 13px; font-weight: 500; }
.data-table td { padding: 10px 14px; border-bottom: 1px solid #f0f0f0; font-size: 13px; }
.data-table tr:hover td { background: #f8f9ff; }
/* Badges */
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; }
.badge-success { background: #e8f5e9; color: #2e7d32; }
.badge-danger { background: #ffebee; color: #c62828; }
.badge-warning { background: #fff8e1; color: #e65100; }
.badge-info { background: #e3f2fd; color: #1565c0; }
/* Buttons */
.btn { display: inline-block; padding: 6px 14px; border-radius: 4px; font-size: 13px; text-decoration: none; border: none; cursor: pointer; }
.btn-primary { background: #1e3c78; color: #fff; }
.btn-danger { background: #c62828; color: #fff; }
.btn-outline { background: #fff; border: 1px solid #ddd; color: #555; }
.btn:hover { opacity: 0.85; }
/* Alert */
.alert { padding: 12px 16px; border-radius: 6px; margin-bottom: 16px; font-size: 13px; }
.alert-success { background: #e8f5e9; color: #2e7d32; border: 1px solid #c8e6c9; }
.alert-info { background: #e3f2fd; color: #1565c0; }
/* Detail */
.detail-grid { display: grid; grid-template-columns: 140px 1fr; gap: 8px 16px; background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 16px; }
.detail-grid .label { font-weight: 500; color: #888; font-size: 13px; }
.detail-grid .value { font-size: 14px; }
/* Login */
.login-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #1e3c78; }
.login-box { background: #fff; padding: 40px; border-radius: 12px; width: 380px; box-shadow: 0 4px 20px rgba(0,0,0,0.2); }
.login-box h2 { text-align: center; color: #1e3c78; margin-bottom: 24px; }
.login-box input { width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 6px; margin-bottom: 12px; font-size: 14px; }
.login-box button { width: 100%; padding: 12px; background: #1e3c78; color: #fff; border: none; border-radius: 6px; font-size: 15px; cursor: pointer; }
.login-box button:hover { background: #15306a; }
.login-box .error { color: #c62828; font-size: 13px; text-align: center; margin-bottom: 12px; }

파일 보기

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>WBX Admin - Audit Logs</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
<body>
<div class="admin-layout">
<div th:replace="~{admin/fragments :: sidebar('audit-logs')}"></div>
<div class="admin-content">
<div class="page-header"><h1>감사 로그</h1><p>최근 100건</p></div>
<div class="data-table">
<table>
<thead><tr><th>시간</th><th>사용자ID</th><th>액션</th><th>리소스</th><th>리소스ID</th><th>IP</th><th>상세</th></tr></thead>
<tbody>
<tr th:each="log : ${logs}">
<td th:text="${log.createdAt != null ? #temporals.format(log.createdAt, 'yy-MM-dd HH:mm:ss') : ''}"></td>
<td th:text="${log.userId}"></td>
<td><span class="badge" th:text="${log.action}"></span></td>
<td th:text="${log.resource}"></td>
<td th:text="${log.resourceId}"></td>
<td th:text="${log.ipAddress}"></td>
<td th:text="${log.detail != null ? (#strings.abbreviate(log.detail, 80)) : ''}"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>WBX Admin - System Config</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
<body>
<div class="admin-layout">
<div th:replace="~{admin/fragments :: sidebar('config')}"></div>
<div class="admin-content">
<div class="page-header"><h1>시스템 설정</h1><p>Key-Value 설정 관리</p></div>
<div th:if="${message}" class="alert alert-success" th:text="${message}"></div>
<!-- 설정 추가 폼 -->
<div class="card" style="margin-bottom:20px; padding:16px;">
<h3>설정 추가/수정</h3>
<form th:action="@{/admin/config/save}" method="post" style="display:flex; gap:10px; align-items:end; flex-wrap:wrap;">
<div><label>Key</label><input name="configKey" required style="display:block; padding:6px; border:1px solid #ddd; border-radius:4px;"></div>
<div><label>Value</label><input name="configValue" required style="display:block; padding:6px; border:1px solid #ddd; border-radius:4px; min-width:200px;"></div>
<div><label>설명</label><input name="description" style="display:block; padding:6px; border:1px solid #ddd; border-radius:4px;"></div>
<button type="submit" class="btn btn-primary" style="padding:6px 16px;">저장</button>
</form>
</div>
<!-- 기존 설정 목록 -->
<div class="data-table">
<table>
<thead><tr><th>Key</th><th>Value</th><th>설명</th><th>수정일</th></tr></thead>
<tbody>
<tr th:each="cfg : ${configs}">
<td><code th:text="${cfg.configKey}"></code></td>
<td th:text="${cfg.configValue}"></td>
<td th:text="${cfg.description}"></td>
<td th:text="${cfg.updatedAt != null ? #temporals.format(cfg.updatedAt, 'yy-MM-dd HH:mm') : ''}"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>WBX Admin - Dashboard</title>
<link rel="stylesheet" th:href="@{/admin/css/admin.css}">
</head>
<body>
<div class="admin-layout">
<div th:replace="~{admin/fragments :: sidebar('dashboard')}"></div>
<div class="admin-content">
<div class="page-header">
<h1>대시보드</h1>
<p>WBX Spring Framework 관리 콘솔</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="label">활성 사용자</div>
<div class="value" th:text="${userCount}">0</div>
</div>
<div class="stat-card">
<div class="label">전체 사용자</div>
<div class="value" th:text="${totalUsers}">0</div>
</div>
<div class="stat-card">
<div class="label">로그인 성공</div>
<div class="value green" th:text="${loginCount}">0</div>
</div>
<div class="stat-card">
<div class="label">등록 역할</div>
<div class="value" th:text="${roleCount}">0</div>
</div>
</div>
<div class="page-header"><h1>빠른 링크</h1></div>
<a th:href="@{/admin/users}" class="btn btn-primary" style="margin-right:8px">사용자 관리</a>
<a th:href="@{/admin/roles}" class="btn btn-outline" style="margin-right:8px">역할 관리</a>
<a th:href="@{/admin/login-history}" class="btn btn-outline">로그인 이력</a>
</div>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="sidebar(active)" class="admin-sidebar">
<div class="logo">WBX Admin</div>
<nav>
<a th:href="@{/admin}" th:classappend="${active == 'dashboard'} ? 'active'">📊 대시보드</a>
<a th:href="@{/admin/users}" th:classappend="${active == 'users'} ? 'active'">👥 사용자 관리</a>
<a th:href="@{/admin/roles}" th:classappend="${active == 'roles'} ? 'active'">🔑 역할/권한</a>
<a th:href="@{/admin/login-history}" th:classappend="${active == 'login-history'} ? 'active'">📋 로그인 이력</a>
<a th:href="@{/admin/audit-logs}" th:classappend="${active == 'audit-logs'} ? 'active'">📝 감사 로그</a>
<a th:href="@{/admin/permissions}" th:classappend="${active == 'permissions'} ? 'active'">🛡 권한 매트릭스</a>
<a th:href="@{/admin/config}" th:classappend="${active == 'config'} ? 'active'">⚙ 시스템 설정</a>
<a th:href="@{/admin/system-health}" th:classappend="${active == 'system-health'} ? 'active'">💻 시스템 상태</a>
<a th:href="@{/admin/logout}" style="margin-top:auto; border-top:1px solid rgba(255,255,255,0.1); padding-top:16px;">🚪 로그아웃</a>
</nav>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>WBX Admin - Login History</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
<body>
<div class="admin-layout">
<div th:replace="~{admin/fragments :: sidebar('login-history')}"></div>
<div class="admin-content">
<div class="page-header"><h1>로그인 이력</h1><p>최근 50건</p></div>
<div class="data-table">
<table>
<thead><tr><th>시간</th><th>이메일</th><th>액션</th><th>IP</th><th>인증방법</th><th>사유</th></tr></thead>
<tbody>
<tr th:each="log : ${logs}">
<td th:text="${#temporals.format(log.createdAt, 'yy-MM-dd HH:mm:ss')}"></td>
<td th:text="${log.email}"></td>
<td>
<span th:if="${log.action == 'LOGIN_SUCCESS'}" class="badge badge-success">성공</span>
<span th:if="${log.action == 'LOGIN_FAILURE'}" class="badge badge-danger">실패</span>
<span th:if="${log.action == 'LOGOUT'}" class="badge badge-info">로그아웃</span>
</td>
<td th:text="${log.ipAddress}"></td>
<td th:text="${log.authMethod}"></td>
<td th:text="${log.failureReason}"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>WBX Admin - Login</title>
<link rel="stylesheet" th:href="@{/admin/css/admin.css}">
</head>
<body>
<div class="login-container">
<div class="login-box">
<h2>WBX Admin</h2>
<div th:if="${param.error}" class="error">이메일 또는 비밀번호가 올바르지 않습니다.</div>
<div th:if="${param.logout}" class="alert alert-info">로그아웃되었습니다.</div>
<form th:action="@{/admin/login}" method="post">
<input type="email" name="username" placeholder="이메일" required autofocus>
<input type="password" name="password" placeholder="비밀번호" required>
<button type="submit">로그인</button>
</form>
</div>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>WBX Admin - Permissions</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
<body>
<div class="admin-layout">
<div th:replace="~{admin/fragments :: sidebar('permissions')}"></div>
<div class="admin-content">
<div class="page-header">
<h1>권한 매트릭스</h1>
<p>전체 역할-모듈-액션 권한 현황. 개별 역할 편집은 <a th:href="@{/admin/roles}" style="color:#1e3c78;">역할 관리</a>에서 가능합니다.</p>
</div>
<div class="data-table">
<table>
<thead><tr><th>역할</th><th>모듈</th><th>액션</th><th>범위</th><th>관리</th></tr></thead>
<tbody>
<tr th:each="perm : ${permissions}">
<td>
<span th:each="role : ${roles}" th:if="${role.id == perm.roleId}">
<a th:href="@{/admin/roles/{id}(id=${role.id})}" class="badge badge-info" style="text-decoration:none;" th:text="${role.code}"></a>
</span>
</td>
<td th:text="${perm.module}"></td>
<td th:text="${perm.action}"></td>
<td>
<span class="badge" th:classappend="${perm.deptScope?.name() == 'COMPANY'} ? 'badge-success' : (${perm.deptScope?.name() == 'DEPT'} ? 'badge-warning' : '')"
th:text="${perm.deptScope?.name()}"></span>
</td>
<td>
<form th:action="@{/admin/permissions/{id}/delete(id=${perm.id})}" method="post" style="display:inline;" onsubmit="return confirm('삭제하시겠습니까?')">
<button type="submit" class="btn btn-danger" style="padding:2px 8px;font-size:11px;">삭제</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(permissions)}"><td colspan="5" style="text-align:center;color:#888;padding:20px;">등록된 권한이 없습니다. 역할 관리에서 권한을 추가하세요.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>WBX Admin - Role Detail</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
<body>
<div class="admin-layout">
<div th:replace="~{admin/fragments :: sidebar('roles')}"></div>
<div class="admin-content" th:if="${role}">
<div class="page-header">
<h1>역할 상세: <span th:text="${role.code}"></span></h1>
<p><a th:href="@{/admin/roles}" style="color:#1e3c78;">← 역할 목록으로</a></p>
</div>
<div th:if="${message}" class="alert alert-success" th:text="${message}"></div>
<div th:if="${error}" class="alert" style="background:#ffebee;color:#c62828;border:1px solid #ffcdd2;padding:12px 16px;border-radius:6px;margin-bottom:16px;font-size:13px;" th:text="${error}"></div>
<!-- 역할 정보 수정 -->
<div style="background:#fff;border-radius:8px;padding:20px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">역할 정보</h3>
<form th:action="@{/admin/roles/{id}/update(id=${role.id})}" method="post" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">코드 (변경 불가)</label>
<input th:value="${role.code}" disabled style="padding:6px 10px;border:1px solid #eee;border-radius:4px;font-size:13px;background:#f5f5f5;width:140px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">이름</label>
<input name="name" th:value="${role.name}" required style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:160px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">설명</label>
<input name="description" th:value="${role.description}" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:280px;">
</div>
<button type="submit" class="btn btn-primary">수정</button>
</form>
</div>
<!-- 권한 추가 -->
<div style="background:#fff;border-radius:8px;padding:20px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">권한 추가</h3>
<form th:action="@{/admin/roles/{id}/permissions/add(id=${role.id})}" method="post" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">모듈</label>
<input name="module" required placeholder="예: TIMESHEET" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:160px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">액션</label>
<select name="action" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
<option value="VIEW">VIEW (조회)</option>
<option value="CREATE">CREATE (생성)</option>
<option value="UPDATE">UPDATE (수정)</option>
<option value="DELETE">DELETE (삭제)</option>
<option value="APPROVE">APPROVE (결재)</option>
<option value="EXPORT">EXPORT (내보내기)</option>
</select>
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">데이터 범위</label>
<select name="deptScope" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
<option th:each="s : ${deptScopes}" th:value="${s.name()}" th:text="${s.name() == 'OWN' ? 'OWN (본인)' : (s.name() == 'DEPT' ? 'DEPT (부서)' : 'COMPANY (전사)')}"></option>
</select>
</div>
<button type="submit" class="btn btn-primary">추가</button>
</form>
</div>
<!-- 현재 권한 목록 -->
<div class="data-table">
<table>
<thead><tr><th>모듈</th><th>액션</th><th>범위</th><th>관리</th></tr></thead>
<tbody>
<tr th:each="perm : ${permissions}">
<td th:text="${perm.module}"></td>
<td><span class="badge badge-info" th:text="${perm.action}"></span></td>
<td>
<span class="badge" th:classappend="${perm.deptScope?.name() == 'COMPANY'} ? 'badge-success' : (${perm.deptScope?.name() == 'DEPT'} ? 'badge-warning' : '')"
th:text="${perm.deptScope?.name()}"></span>
</td>
<td>
<form th:action="@{/admin/permissions/{id}/delete(id=${perm.id})}" method="post" style="display:inline;" onsubmit="return confirm('이 권한을 삭제하시겠습니까?')">
<button type="submit" class="btn btn-danger" style="padding:3px 10px;font-size:12px;">삭제</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(permissions)}"><td colspan="4" style="text-align:center;color:#888;padding:20px;">설정된 권한이 없습니다. 위 폼에서 권한을 추가하세요.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>WBX Admin - Roles</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
<body>
<div class="admin-layout">
<div th:replace="~{admin/fragments :: sidebar('roles')}"></div>
<div class="admin-content">
<div class="page-header"><h1>역할 관리</h1><p>역할 추가/수정/삭제 및 권한 설정</p></div>
<div th:if="${message}" class="alert alert-success" th:text="${message}"></div>
<div th:if="${error}" class="alert" style="background:#ffebee;color:#c62828;border:1px solid #ffcdd2;padding:12px 16px;border-radius:6px;margin-bottom:16px;font-size:13px;" th:text="${error}"></div>
<!-- 역할 추가 폼 -->
<div style="background:#fff;border-radius:8px;padding:16px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">새 역할 추가</h3>
<form th:action="@{/admin/roles/add}" method="post" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">코드</label>
<input name="code" required placeholder="예: MANAGER" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:140px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">이름</label>
<input name="name" required placeholder="예: 관리자" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:160px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">설명</label>
<input name="description" placeholder="역할 설명" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:240px;">
</div>
<button type="submit" class="btn btn-primary">추가</button>
</form>
</div>
<!-- 역할 목록 -->
<div class="data-table">
<table>
<thead><tr><th>ID</th><th>코드</th><th>이름</th><th>설명</th><th>시스템</th><th>관리</th></tr></thead>
<tbody>
<tr th:each="role : ${roles}">
<td th:text="${role.id}"></td>
<td><span class="badge badge-info" th:text="${role.code}"></span></td>
<td th:text="${role.name}"></td>
<td th:text="${role.description}"></td>
<td th:text="${role.system} ? 'Y' : ''"></td>
<td>
<a th:href="@{/admin/roles/{id}(id=${role.id})}" class="btn btn-outline" style="padding:3px 10px;font-size:12px;">상세/권한</a>
<form th:unless="${role.system}" th:action="@{/admin/roles/{id}/delete(id=${role.id})}" method="post" style="display:inline;" onsubmit="return confirm('삭제하시겠습니까?')">
<button type="submit" class="btn btn-danger" style="padding:3px 10px;font-size:12px;">삭제</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(roles)}"><td colspan="6" style="text-align:center;color:#888">등록된 역할이 없습니다.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>WBX Admin - System Health</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
<body>
<div class="admin-layout">
<div th:replace="~{admin/fragments :: sidebar('system-health')}"></div>
<div class="admin-content">
<div class="page-header"><h1>시스템 상태</h1><p>서버 런타임 정보</p></div>
<div class="data-table">
<table>
<thead><tr><th>항목</th><th></th></tr></thead>
<tbody>
<tr th:each="entry : ${health}">
<td th:text="${entry.key}"></td>
<td th:text="${entry.value}"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,154 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>WBX Admin - User Detail</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
<body>
<div class="admin-layout">
<div th:replace="~{admin/fragments :: sidebar('users')}"></div>
<div class="admin-content" th:if="${user}">
<div class="page-header">
<h1>사용자 상세: <span th:text="${user.email}"></span></h1>
<p><a th:href="@{/admin/users}" style="color:#1e3c78;">← 사용자 목록으로</a></p>
</div>
<div th:if="${message}" class="alert alert-success" th:text="${message}"></div>
<div th:if="${error}" class="alert" style="background:#ffebee;color:#c62828;border:1px solid #ffcdd2;padding:12px 16px;border-radius:6px;margin-bottom:16px;font-size:13px;" th:text="${error}"></div>
<!-- 사용자 정보 수정 폼 -->
<div style="background:#fff;border-radius:8px;padding:20px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">기본 정보</h3>
<form th:action="@{/admin/users/{id}/update(id=${user.id})}" method="post">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;">
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">이메일 (변경 불가)</label>
<input th:value="${user.email}" disabled style="width:100%;padding:6px 10px;border:1px solid #eee;border-radius:4px;font-size:13px;background:#f5f5f5;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">사용자명 (변경 불가)</label>
<input th:value="${user.username}" disabled style="width:100%;padding:6px 10px;border:1px solid #eee;border-radius:4px;font-size:13px;background:#f5f5f5;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">이름</label>
<input name="fullName" th:value="${user.fullName}" style="width:100%;padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">전화번호</label>
<input name="phone" th:value="${user.phone}" style="width:100%;padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">직위</label>
<input name="positionTitle" th:value="${user.positionTitle}" style="width:100%;padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">사번</label>
<input name="employeeNumber" th:value="${user.employeeNumber}" style="width:100%;padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
</div>
</div>
<div style="display:flex;align-items:center;gap:16px;">
<label style="display:flex;align-items:center;gap:4px;font-size:13px;">
<input name="isAdmin" type="checkbox" value="true" th:checked="${user.admin}"> 관리자 (SA)
</label>
<button type="submit" class="btn btn-primary">정보 수정</button>
</div>
</form>
</div>
<!-- 상태 정보 + 액션 버튼 -->
<div style="background:#fff;border-radius:8px;padding:20px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">계정 상태</h3>
<div class="detail-grid" style="box-shadow:none;padding:0;margin-bottom:12px;">
<div class="label">상태</div>
<div class="value">
<span th:if="${user.active}" class="badge badge-success">활성</span>
<span th:unless="${user.active}" class="badge badge-danger">비활성</span>
</div>
<div class="label">잠금</div>
<div class="value">
<span th:if="${user.locked}" class="badge badge-danger">잠금 (실패 <span th:text="${user.failedLoginAttempts}"></span>회)</span>
<span th:unless="${user.locked}">정상</span>
</div>
<div class="label">MFA</div>
<div class="value" th:text="${user.mfaEnabled} ? '활성화' : '미설정'"></div>
<div class="label">SSO</div>
<div class="value" th:text="${user.ssoProvider != null} ? ${user.ssoProvider} : '미연동'"></div>
<div class="label">최종 로그인</div>
<div class="value" th:text="${user.lastLoginAt != null} ? ${#temporals.format(user.lastLoginAt, 'yyyy-MM-dd HH:mm:ss')} : '없음'"></div>
<div class="label">생성일</div>
<div class="value" th:text="${user.createdAt != null} ? ${#temporals.format(user.createdAt, 'yyyy-MM-dd HH:mm')} : ''"></div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<form th:action="@{/admin/users/{id}/unlock(id=${user.id})}" method="post" style="display:inline">
<button class="btn btn-primary" type="submit">잠금 해제</button>
</form>
<form th:action="@{/admin/users/{id}/reset-password(id=${user.id})}" method="post" style="display:inline">
<button class="btn btn-outline" type="submit">비밀번호 초기화</button>
</form>
<form th:action="@{/admin/users/{id}/toggle-status(id=${user.id})}" method="post" style="display:inline">
<button class="btn" th:classappend="${user.active} ? 'btn-danger' : 'btn-primary'" type="submit" th:text="${user.active} ? '비활성화' : '활성화'"></button>
</form>
<form th:action="@{/admin/users/{id}/delete(id=${user.id})}" method="post" style="display:inline;margin-left:auto;" onsubmit="return confirm('이 사용자를 삭제하시겠습니까? 복구할 수 없습니다.')">
<button class="btn btn-danger" type="submit">사용자 삭제</button>
</form>
</div>
</div>
<!-- 역할 할당 -->
<div style="background:#fff;border-radius:8px;padding:20px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">역할 할당</h3>
<form th:action="@{/admin/users/{id}/roles/add(id=${user.id})}" method="post" style="display:flex;gap:10px;align-items:end;margin-bottom:16px;">
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">역할 선택</label>
<select name="roleId" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
<option th:each="role : ${allRoles}" th:value="${role.id}" th:text="${role.code + ' — ' + role.name}"></option>
</select>
</div>
<button type="submit" class="btn btn-primary">역할 추가</button>
</form>
<div class="data-table" th:if="${!userRoles.isEmpty()}">
<table>
<thead><tr><th>역할 코드</th><th>역할 이름</th><th>할당일</th><th>관리</th></tr></thead>
<tbody>
<tr th:each="ur : ${userRoles}">
<td>
<span th:each="role : ${allRoles}" th:if="${role.id == ur.roleId}" class="badge badge-info" th:text="${role.code}"></span>
</td>
<td>
<span th:each="role : ${allRoles}" th:if="${role.id == ur.roleId}" th:text="${role.name}"></span>
</td>
<td th:text="${ur.grantedAt != null} ? ${#temporals.format(ur.grantedAt, 'yy-MM-dd HH:mm')} : ''"></td>
<td>
<form th:action="@{/admin/users/{uid}/roles/{urid}/delete(uid=${user.id},urid=${ur.id})}" method="post" style="display:inline;" onsubmit="return confirm('이 역할을 해제하시겠습니까?')">
<button type="submit" class="btn btn-danger" style="padding:3px 10px;font-size:12px;">해제</button>
</form>
</td>
</tr>
</tbody>
</table>
</div>
<p th:if="${userRoles.isEmpty()}" style="color:#888;font-size:13px;">할당된 역할이 없습니다.</p>
</div>
<!-- 로그인 이력 -->
<div style="background:#fff;border-radius:8px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">로그인 이력 (최근 10건)</h3>
<div class="data-table">
<table>
<thead><tr><th>시간</th><th>액션</th><th>IP</th><th>인증방법</th></tr></thead>
<tbody>
<tr th:each="log : ${loginHistory}">
<td th:text="${#temporals.format(log.createdAt, 'yy-MM-dd HH:mm')}"></td>
<td>
<span th:if="${log.action == 'LOGIN_SUCCESS'}" class="badge badge-success">성공</span>
<span th:if="${log.action == 'LOGIN_FAILURE'}" class="badge badge-danger">실패</span>
<span th:if="${log.action == 'LOGOUT'}" class="badge badge-info">로그아웃</span>
</td>
<td th:text="${log.ipAddress}"></td>
<td th:text="${log.authMethod}"></td>
</tr>
<tr th:if="${#lists.isEmpty(loginHistory)}"><td colspan="4" style="text-align:center;color:#888;">이력이 없습니다.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>WBX Admin - Users</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
<body>
<div class="admin-layout">
<div th:replace="~{admin/fragments :: sidebar('users')}"></div>
<div class="admin-content">
<div class="page-header"><h1>사용자 관리</h1><p>사용자 추가/수정/삭제 및 역할 할당</p></div>
<div th:if="${message}" class="alert alert-success" th:text="${message}"></div>
<div th:if="${error}" class="alert" style="background:#ffebee;color:#c62828;border:1px solid #ffcdd2;padding:12px 16px;border-radius:6px;margin-bottom:16px;font-size:13px;" th:text="${error}"></div>
<!-- 사용자 추가 폼 -->
<div style="background:#fff;border-radius:8px;padding:16px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">새 사용자 추가</h3>
<form th:action="@{/admin/users/add}" method="post" style="display:flex;gap:8px;align-items:end;flex-wrap:wrap;">
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">이메일 *</label>
<input name="email" type="email" required placeholder="user@company.com" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:180px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">사용자명 *</label>
<input name="username" required placeholder="hong" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:120px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">비밀번호 *</label>
<input name="password" type="password" required placeholder="********" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:130px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">이름</label>
<input name="fullName" placeholder="홍길동" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:100px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">전화</label>
<input name="phone" placeholder="010-0000-0000" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:120px;">
</div>
<div style="display:flex;align-items:center;gap:4px;padding-bottom:2px;">
<input name="isAdmin" type="checkbox" value="true" id="chkAdmin">
<label for="chkAdmin" style="font-size:12px;color:#888;">관리자</label>
</div>
<button type="submit" class="btn btn-primary">추가</button>
</form>
</div>
<!-- 사용자 목록 -->
<div class="data-table">
<table>
<thead>
<tr><th>ID</th><th>이메일</th><th>사용자명</th><th>이름</th><th>관리자</th><th>상태</th><th>잠금</th><th>최종 로그인</th><th>관리</th></tr>
</thead>
<tbody>
<tr th:each="user : ${users}">
<td th:text="${user.id}"></td>
<td th:text="${user.email}"></td>
<td th:text="${user.username}"></td>
<td th:text="${user.fullName}"></td>
<td><span th:if="${user.admin}" class="badge badge-info">SA</span></td>
<td>
<span th:if="${user.active}" class="badge badge-success">활성</span>
<span th:unless="${user.active}" class="badge badge-danger">비활성</span>
</td>
<td><span th:if="${user.locked}" class="badge badge-danger">잠금</span></td>
<td th:text="${user.lastLoginAt != null} ? ${#temporals.format(user.lastLoginAt, 'yy-MM-dd HH:mm')} : '-'"></td>
<td style="white-space:nowrap;">
<a th:href="@{/admin/users/{id}(id=${user.id})}" class="btn btn-outline" style="padding:3px 10px;font-size:12px;">상세</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>