This commit is contained in:
@@ -2,8 +2,12 @@ package com.erp;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableAsync
|
||||||
|
@EnableScheduling
|
||||||
public class ErpApplication {
|
public class ErpApplication {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(ErpApplication.class, args);
|
SpringApplication.run(ErpApplication.class, args);
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.erp.provisioning;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신규 회사 DB 에 {prefix}_admin (COMPANY_ADMIN) 계정을 생성.
|
||||||
|
* BCrypt 해시만 저장되며, 초기 비밀번호는 호출자가 생성/전달.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class AdminAccountCreator {
|
||||||
|
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param dst 신규 회사 DB 커넥션 (autoCommit 무관, 호출자가 트랜잭션 관리)
|
||||||
|
* @param companyCode COMPANY_MNG.company_code (FK 성격)
|
||||||
|
* @param userId 예: qnc_admin
|
||||||
|
* @param rawPassword 평문 비밀번호 (BCrypt 해시 후 저장됨)
|
||||||
|
* @param userName 표시 이름
|
||||||
|
*/
|
||||||
|
public void createCompanyAdmin(Connection dst, String companyCode, String userId,
|
||||||
|
String rawPassword, String userName) throws SQLException {
|
||||||
|
String hash = passwordEncoder.encode(rawPassword);
|
||||||
|
String sql = "INSERT INTO user_info " +
|
||||||
|
"(user_id, user_password, user_name, company_code, user_type, status, created_date) " +
|
||||||
|
"VALUES (?, ?, ?, ?, 'COMPANY_ADMIN', 'active', NOW())";
|
||||||
|
try (PreparedStatement ps = dst.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, userId);
|
||||||
|
ps.setString(2, hash);
|
||||||
|
ps.setString(3, userName);
|
||||||
|
ps.setString(4, companyCode);
|
||||||
|
int affected = ps.executeUpdate();
|
||||||
|
log.info("[Provisioning] CREATE ADMIN: {} (company={}, rows={})", userId, companyCode, affected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package com.erp.provisioning;
|
||||||
|
|
||||||
|
import com.erp.tenant.CompanyResolver;
|
||||||
|
import com.erp.tenant.DbContextHolder;
|
||||||
|
import com.erp.tenant.TenantDbSettings;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.ibatis.session.SqlSession;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회사 프로비저닝 오케스트레이션.
|
||||||
|
*
|
||||||
|
* 6 단계:
|
||||||
|
* 1. REGISTER_META — COMPANY_MNG 에 db_status='provisioning' 으로 row 선반영 (initiate)
|
||||||
|
* 2. CREATE_DATABASE — CREATE DATABASE "{prefix}_vexplor"
|
||||||
|
* 3. COPY_SCHEMA — pg_dump --schema-only | psql
|
||||||
|
* 4. COPY_DATA — 선택 + 필수 그룹의 테이블들 JDBC 복사
|
||||||
|
* 5. CREATE_ADMIN — user_info 에 {prefix}_admin INSERT (BCrypt)
|
||||||
|
* 6. FINALIZE — COMPANY_MNG.db_status='active' + CompanyResolver 캐시 무효화
|
||||||
|
*
|
||||||
|
* 실패 시 보상:
|
||||||
|
* - DROP DATABASE
|
||||||
|
* - COMPANY_MNG.db_status='failed'
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class CompanyProvisioningService {
|
||||||
|
|
||||||
|
private final TenantDbSettings tenantDbSettings;
|
||||||
|
private final ProvisioningRegistry registry;
|
||||||
|
private final DatabaseCreator dbCreator;
|
||||||
|
private final SchemaCopier schemaCopier;
|
||||||
|
private final DataCopier dataCopier;
|
||||||
|
private final AdminAccountCreator adminCreator;
|
||||||
|
private final CompanyResolver companyResolver;
|
||||||
|
private final SqlSession sqlSession;
|
||||||
|
|
||||||
|
@Value("${spring.datasource.url}")
|
||||||
|
private String metaJdbcUrl;
|
||||||
|
|
||||||
|
private String metaDbName() {
|
||||||
|
int slash = metaJdbcUrl.lastIndexOf('/');
|
||||||
|
if (slash < 0) return "vexplor";
|
||||||
|
String tail = metaJdbcUrl.substring(slash + 1);
|
||||||
|
int q = tail.indexOf('?');
|
||||||
|
return q < 0 ? tail : tail.substring(0, q);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step 1: 메타 DB 에 row 선반영 + 레지스트리 생성. 동기 실행. */
|
||||||
|
public ProvisioningStatus initiate(Map<String, Object> req) {
|
||||||
|
String companyCode = (String) req.get("company_code");
|
||||||
|
String dbPrefix = (String) req.get("db_prefix");
|
||||||
|
String dbName = dbPrefix + "_vexplor";
|
||||||
|
String subdomain = (String) req.get("subdomain");
|
||||||
|
|
||||||
|
// 템플릿 그룹 수 집계: 필수(3) + 선택된 optional
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<String> selectedGroupIds = (List<String>) req.getOrDefault("selected_groups", List.of());
|
||||||
|
int templatesCount = (int) java.util.Arrays.stream(TableGroup.values())
|
||||||
|
.filter(g -> g.isRequired() || selectedGroupIds.contains(g.name()))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
Map<String, Object> insertParams = new HashMap<>(req);
|
||||||
|
insertParams.put("db_name", dbName);
|
||||||
|
insertParams.put("db_host", tenantDbSettings.host());
|
||||||
|
insertParams.put("db_status", "provisioning");
|
||||||
|
insertParams.put("templates_count", templatesCount);
|
||||||
|
insertParams.putIfAbsent("status", "active");
|
||||||
|
insertParams.putIfAbsent("writer", "SUPER_ADMIN");
|
||||||
|
sqlSession.insert("provisioning.insertCompanyWithTenant", insertParams);
|
||||||
|
|
||||||
|
ProvisioningStatus s = new ProvisioningStatus();
|
||||||
|
s.setId(UUID.randomUUID().toString());
|
||||||
|
s.setCompanyCode(companyCode);
|
||||||
|
s.setDbName(dbName);
|
||||||
|
s.setSubdomain(subdomain);
|
||||||
|
s.setCurrentStep(ProvisioningStatus.Step.REGISTER_META.name());
|
||||||
|
s.setProgress(1);
|
||||||
|
s.setTotalSteps(6);
|
||||||
|
s.setStatus("in_progress");
|
||||||
|
s.setStartedAt(Instant.now());
|
||||||
|
registry.register(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step 2~6: 비동기 실행. Controller 가 accepted(202) 리턴 후 백그라운드 진행. */
|
||||||
|
@Async
|
||||||
|
public void provisionAsync(String jobId, Map<String, Object> req) {
|
||||||
|
// @Async 스레드는 새로 생성되어 ThreadLocal 을 상속받지 않음.
|
||||||
|
// sqlSession 이 tenant 라우팅을 타므로 메타 DB 를 명시적으로 고정.
|
||||||
|
DbContextHolder.setMeta();
|
||||||
|
try {
|
||||||
|
doProvision(jobId, req);
|
||||||
|
} finally {
|
||||||
|
DbContextHolder.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doProvision(String jobId, Map<String, Object> req) {
|
||||||
|
ProvisioningStatus status = registry.get(jobId);
|
||||||
|
if (status == null) {
|
||||||
|
log.error("[Provisioning] jobId not found: {}", jobId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String dbName = status.getDbName();
|
||||||
|
String subdomain = status.getSubdomain();
|
||||||
|
String companyCode = status.getCompanyCode();
|
||||||
|
String dbPrefix = (String) req.get("db_prefix");
|
||||||
|
String initialPassword = (String) req.get("initial_password");
|
||||||
|
String companyName = (String) req.get("company_name");
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<String> selectedGroupIds = (List<String>) req.getOrDefault("selected_groups", List.of());
|
||||||
|
List<String> tables = gatherTables(selectedGroupIds);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 2: CREATE DATABASE
|
||||||
|
advance(jobId, ProvisioningStatus.Step.CREATE_DATABASE, 2);
|
||||||
|
dbCreator.createDatabase(dbName);
|
||||||
|
|
||||||
|
// Step 3: Copy schema
|
||||||
|
advance(jobId, ProvisioningStatus.Step.COPY_SCHEMA, 3);
|
||||||
|
schemaCopier.copySchema(metaDbName(), dbName);
|
||||||
|
|
||||||
|
// Step 4 & 5: Copy data + admin (같은 dst Connection 재사용)
|
||||||
|
advance(jobId, ProvisioningStatus.Step.COPY_DATA, 4);
|
||||||
|
try (Connection src = DriverManager.getConnection(
|
||||||
|
tenantDbSettings.buildJdbcUrl(metaDbName()),
|
||||||
|
tenantDbSettings.username(), tenantDbSettings.password());
|
||||||
|
Connection dst = DriverManager.getConnection(
|
||||||
|
tenantDbSettings.buildJdbcUrl(dbName),
|
||||||
|
tenantDbSettings.username(), tenantDbSettings.password())) {
|
||||||
|
|
||||||
|
src.setAutoCommit(true);
|
||||||
|
dst.setAutoCommit(false);
|
||||||
|
int totalRows = 0;
|
||||||
|
try {
|
||||||
|
// 테이블 하나라도 실패하면 catch 에서 rollback 후 전체 실패 처리.
|
||||||
|
// (Codex 리뷰 #3 원자성 보장, 2026-04-24).
|
||||||
|
for (String t : tables) {
|
||||||
|
totalRows += dataCopier.copyTable(src, dst, t);
|
||||||
|
}
|
||||||
|
dst.commit();
|
||||||
|
} catch (Exception copyErr) {
|
||||||
|
try { dst.rollback(); } catch (Exception rb) { log.warn("rollback failed: {}", rb.getMessage()); }
|
||||||
|
throw copyErr;
|
||||||
|
}
|
||||||
|
log.info("[Provisioning] DATA total {} rows across {} tables", totalRows, tables.size());
|
||||||
|
|
||||||
|
// 시퀀스 재설정은 반드시 autoCommit=true 로. 개별 setval 실패가 전체 트랜잭션을
|
||||||
|
// abort 시켜 이후 setval 이 "current transaction is aborted" 로 전부 실패하는 것 방지.
|
||||||
|
dst.setAutoCommit(true);
|
||||||
|
dataCopier.resetSequences(dst);
|
||||||
|
|
||||||
|
// Step 5: Admin account
|
||||||
|
advance(jobId, ProvisioningStatus.Step.CREATE_ADMIN, 5);
|
||||||
|
String adminId = dbPrefix + "_admin";
|
||||||
|
String adminName = (companyName == null ? companyCode : companyName) + " 관리자";
|
||||||
|
dst.setAutoCommit(true);
|
||||||
|
adminCreator.createCompanyAdmin(dst, companyCode, adminId, initialPassword, adminName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Finalize
|
||||||
|
advance(jobId, ProvisioningStatus.Step.FINALIZE, 6);
|
||||||
|
Map<String, Object> upd = new HashMap<>();
|
||||||
|
upd.put("company_code", companyCode);
|
||||||
|
upd.put("db_status", "active");
|
||||||
|
sqlSession.update("provisioning.updateDbStatus", upd);
|
||||||
|
companyResolver.invalidate(subdomain);
|
||||||
|
|
||||||
|
registry.update(jobId, s -> {
|
||||||
|
s.setStatus("completed");
|
||||||
|
s.setCompletedAt(Instant.now());
|
||||||
|
});
|
||||||
|
log.info("[Provisioning] ✅ completed: company={} db={} subdomain={}", companyCode, dbName, subdomain);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
String failedStep = registry.get(jobId).getCurrentStep();
|
||||||
|
log.error("[Provisioning] ❌ FAILED at {} for {} ({})", failedStep, dbName, e.getMessage(), e);
|
||||||
|
registry.update(jobId, s -> {
|
||||||
|
s.setStatus("failed");
|
||||||
|
s.setFailedStep(failedStep);
|
||||||
|
s.setErrorMessage(e.getMessage());
|
||||||
|
s.setCompletedAt(Instant.now());
|
||||||
|
});
|
||||||
|
// 보상
|
||||||
|
dbCreator.dropDatabase(dbName);
|
||||||
|
try {
|
||||||
|
Map<String, Object> upd = new HashMap<>();
|
||||||
|
upd.put("company_code", companyCode);
|
||||||
|
upd.put("db_status", "failed");
|
||||||
|
sqlSession.update("provisioning.updateDbStatus", upd);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("[Provisioning] failed marking db_status=failed: {}", ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void advance(String jobId, ProvisioningStatus.Step step, int progress) {
|
||||||
|
registry.update(jobId, s -> {
|
||||||
|
s.setCurrentStep(step.name());
|
||||||
|
s.setProgress(progress);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> gatherTables(List<String> selectedGroupIds) {
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
for (TableGroup g : TableGroup.values()) {
|
||||||
|
if (g.isRequired() || selectedGroupIds.contains(g.name())) {
|
||||||
|
result.addAll(g.getTables());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package com.erp.provisioning;
|
||||||
|
|
||||||
|
import com.erp.tenant.TenantDbSettings;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.ibatis.session.SqlSession;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회사관리 UI (v9 accordion) 렌더용 집계 서비스.
|
||||||
|
*
|
||||||
|
* 정적 필드는 {@code provisioning.listCompaniesForUi} 에서 조회.
|
||||||
|
* Derived 필드는 회사마다 런타임 계산:
|
||||||
|
* - {@code users} — tenant DB: {@code SELECT count(*) FROM user_info}
|
||||||
|
* - {@code db_size_bytes} — 메타 서버: {@code SELECT pg_database_size(?)}
|
||||||
|
* - {@code db_size} — 위 값 포맷팅 ("4.2 GB", "820 MB" 등)
|
||||||
|
* - {@code db_pct} — db_size / DB_QUOTA_GB * 100
|
||||||
|
* - {@code active30} — MVP 0. (last_login_date 컬럼 없음. 생기면 구현)
|
||||||
|
* - {@code spark} — MVP 빈 배열. (일자별 사용자 집계 테이블 생기면 구현)
|
||||||
|
*
|
||||||
|
* ⚠ N+1 주의: 회사 N 개면 tenant DB 에 N 번 접속 + 메타 서버에 N 번 접속.
|
||||||
|
* 회사 50 개 넘어가면 병렬 (CompletableFuture) 또는 집계 캐시 도입. 지금은 MVP.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class CompanyStatsService {
|
||||||
|
|
||||||
|
private final TenantDbSettings settings;
|
||||||
|
private final SqlSession sqlSession;
|
||||||
|
|
||||||
|
public List<Map<String, Object>> listWithStats() {
|
||||||
|
List<Map<String, Object>> rows = sqlSession.selectList("provisioning.listCompaniesForUi");
|
||||||
|
for (Map<String, Object> r : rows) {
|
||||||
|
enrichOne(r);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void enrichOne(Map<String, Object> r) {
|
||||||
|
String dbName = (String) r.get("db_name");
|
||||||
|
String dbStatus = (String) r.get("db_status");
|
||||||
|
int quotaGb = toInt(r.get("db_quota_gb"), 20);
|
||||||
|
|
||||||
|
// Placeholder defaults
|
||||||
|
r.putIfAbsent("users", 0);
|
||||||
|
r.putIfAbsent("active30", 0);
|
||||||
|
r.putIfAbsent("db_size", "—");
|
||||||
|
r.putIfAbsent("db_size_bytes", 0L);
|
||||||
|
r.putIfAbsent("db_pct", 0);
|
||||||
|
r.putIfAbsent("spark", List.of());
|
||||||
|
|
||||||
|
if (!"active".equals(dbStatus) || dbName == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// users — tenant DB
|
||||||
|
try (Connection c = DriverManager.getConnection(
|
||||||
|
settings.buildJdbcUrl(dbName), settings.username(), settings.password());
|
||||||
|
Statement s = c.createStatement();
|
||||||
|
ResultSet rs = s.executeQuery("SELECT count(*) FROM user_info")) {
|
||||||
|
if (rs.next()) r.put("users", rs.getInt(1));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("[Stats] users failed for {}: {}", dbName, e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// db size — 메타 postgres DB 에서 pg_database_size
|
||||||
|
try (Connection c = DriverManager.getConnection(
|
||||||
|
String.format("jdbc:postgresql://%s:%d/postgres", settings.host(), settings.port()),
|
||||||
|
settings.username(), settings.password());
|
||||||
|
PreparedStatement ps = c.prepareStatement("SELECT pg_database_size(?)")) {
|
||||||
|
ps.setString(1, dbName);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
long bytes = rs.getLong(1);
|
||||||
|
r.put("db_size_bytes", bytes);
|
||||||
|
r.put("db_size", formatBytes(bytes));
|
||||||
|
long quotaBytes = (long) quotaGb * 1024L * 1024L * 1024L;
|
||||||
|
r.put("db_pct", quotaBytes > 0 ? (int) ((bytes * 100) / quotaBytes) : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("[Stats] db_size failed for {}: {}", dbName, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String formatBytes(long bytes) {
|
||||||
|
if (bytes < 1024) return bytes + " B";
|
||||||
|
if (bytes < 1024L * 1024) return String.format("%.1f KB", bytes / 1024.0);
|
||||||
|
if (bytes < 1024L * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024));
|
||||||
|
return String.format("%.2f GB", bytes / (1024.0 * 1024 * 1024));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int toInt(Object v, int defaultVal) {
|
||||||
|
if (v == null) return defaultVal;
|
||||||
|
if (v instanceof Number n) return n.intValue();
|
||||||
|
try { return Integer.parseInt(v.toString()); } catch (Exception e) { return defaultVal; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package com.erp.provisioning;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.ResultSetMetaData;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선택된 테이블을 원본 DB → 신규 DB 로 복사.
|
||||||
|
*
|
||||||
|
* 정책:
|
||||||
|
* - company_code 컬럼이 있는 테이블: ('*','TEMPLATE') + NULL 만 복사 (공통 템플릿).
|
||||||
|
* - 없는 테이블: 전체 복사.
|
||||||
|
*
|
||||||
|
* ★ 실패 시: 테이블 하나라도 예외가 터지면 호출자에게 그대로 throw.
|
||||||
|
* CompanyProvisioningService 가 dst.rollback() + DROP DATABASE 로 원자성 보장.
|
||||||
|
*
|
||||||
|
* ★ 2026-04-24 추가: resetSequences() — 데이터 복사 후 모든 시퀀스의 current value 를
|
||||||
|
* max(column)+1 로 재설정. pg_dump --schema-only 는 시퀀스 값을 안 복제하므로 필수.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class DataCopier {
|
||||||
|
|
||||||
|
private static final int BATCH = 500;
|
||||||
|
|
||||||
|
public int copyTable(Connection src, Connection dst, String table) throws SQLException {
|
||||||
|
boolean hasCompanyCode = columnExists(src, table, "company_code");
|
||||||
|
List<String> cols = listColumns(src, table);
|
||||||
|
if (cols.isEmpty()) {
|
||||||
|
log.warn("[Provisioning] {} has no columns in source — skip", table);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
String where = hasCompanyCode ? " WHERE company_code IS NULL OR company_code IN ('*', 'TEMPLATE')" : "";
|
||||||
|
String quotedCols = cols.stream().map(c -> "\"" + c + "\"").collect(Collectors.joining(","));
|
||||||
|
String selectSql = "SELECT " + quotedCols + " FROM \"" + table + "\"" + where;
|
||||||
|
String placeholders = cols.stream().map(c -> "?").collect(Collectors.joining(","));
|
||||||
|
String insertSql = "INSERT INTO \"" + table + "\" (" + quotedCols + ") VALUES (" + placeholders + ")";
|
||||||
|
|
||||||
|
int rows = 0;
|
||||||
|
try (PreparedStatement selectStmt = src.prepareStatement(selectSql);
|
||||||
|
ResultSet rs = selectStmt.executeQuery();
|
||||||
|
PreparedStatement insertStmt = dst.prepareStatement(insertSql)) {
|
||||||
|
ResultSetMetaData meta = rs.getMetaData();
|
||||||
|
int colCount = meta.getColumnCount();
|
||||||
|
while (rs.next()) {
|
||||||
|
for (int i = 1; i <= colCount; i++) {
|
||||||
|
insertStmt.setObject(i, rs.getObject(i));
|
||||||
|
}
|
||||||
|
insertStmt.addBatch();
|
||||||
|
rows++;
|
||||||
|
if (rows % BATCH == 0) insertStmt.executeBatch();
|
||||||
|
}
|
||||||
|
insertStmt.executeBatch();
|
||||||
|
}
|
||||||
|
log.info("[Provisioning] COPY DATA {}: {} rows (filter={})", table, rows, hasCompanyCode);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 시퀀스의 current value 를 연결된 컬럼의 MAX()+1 로 재설정.
|
||||||
|
* pg_depend 조회로 seq↔table↔column 관계를 얻으므로 휴리스틱 없음.
|
||||||
|
*
|
||||||
|
* 이 쿼리의 테이블/컬럼/시퀀스 이름은 pg_class 에서 직접 나온 값이라 신뢰 가능 → SQL injection 위험 없음.
|
||||||
|
* 빈 테이블은 setval(seq, 1) 로 안전하게 초기화.
|
||||||
|
*/
|
||||||
|
public int resetSequences(Connection dst) throws SQLException {
|
||||||
|
// format_type 으로 컬럼 실제 타입까지 조회 → integer 계열만 setval.
|
||||||
|
// 레거시 DB 에선 SERIAL 이었다가 나중에 TEXT 로 타입 변경된 컬럼이 있을 수 있음 (시퀀스 의존성만 남음).
|
||||||
|
// 이런 컬럼에 setval 을 호출하면 "COALESCE types text and integer cannot be matched" 예외 발생.
|
||||||
|
String findSql = """
|
||||||
|
SELECT s.relname AS seq, t.relname AS tbl, a.attname AS col,
|
||||||
|
format_type(a.atttypid, a.atttypmod) AS coltype
|
||||||
|
FROM pg_class s
|
||||||
|
JOIN pg_depend d ON d.objid = s.oid
|
||||||
|
JOIN pg_class t ON t.oid = d.refobjid
|
||||||
|
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid
|
||||||
|
WHERE s.relkind = 'S' AND d.deptype = 'a'
|
||||||
|
""";
|
||||||
|
List<String[]> rows = new ArrayList<>();
|
||||||
|
try (Statement s = dst.createStatement();
|
||||||
|
ResultSet rs = s.executeQuery(findSql)) {
|
||||||
|
while (rs.next()) {
|
||||||
|
rows.add(new String[]{
|
||||||
|
rs.getString("seq"), rs.getString("tbl"),
|
||||||
|
rs.getString("col"), rs.getString("coltype")});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int updated = 0, skippedType = 0, skippedErr = 0;
|
||||||
|
try (Statement us = dst.createStatement()) {
|
||||||
|
for (String[] r : rows) {
|
||||||
|
String seq = r[0], tbl = r[1], col = r[2], coltype = r[3];
|
||||||
|
if (!isIntegerLike(coltype)) {
|
||||||
|
skippedType++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String sql = String.format(
|
||||||
|
"SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\") FROM \"%s\"), 0), 1))",
|
||||||
|
seq.replace("'", "''"), col, tbl);
|
||||||
|
try {
|
||||||
|
us.execute(sql);
|
||||||
|
updated++;
|
||||||
|
} catch (SQLException e) {
|
||||||
|
skippedErr++;
|
||||||
|
log.warn("[Provisioning] setval failed seq={} tbl={} col={} type={}: {}",
|
||||||
|
seq, tbl, col, coltype, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// invyone 은 대다수 PK 가 VARCHAR (문자열 PK). 시퀀스가 연결되어 있어도 실제 INSERT 때
|
||||||
|
// nextval 을 사용하지 않으므로 setval 은 no-op. skipped_non_integer 값이 높아도 정상.
|
||||||
|
if (updated == 0 && skippedErr == 0) {
|
||||||
|
log.info("[Provisioning] RESET SEQUENCES: skipped all {} (string-PK schema, no-op)", rows.size());
|
||||||
|
} else {
|
||||||
|
log.info("[Provisioning] RESET SEQUENCES: updated={} skipped_non_integer={} skipped_error={} total={}",
|
||||||
|
updated, skippedType, skippedErr, rows.size());
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isIntegerLike(String coltype) {
|
||||||
|
if (coltype == null) return false;
|
||||||
|
String t = coltype.toLowerCase();
|
||||||
|
return t.startsWith("integer") || t.startsWith("bigint") || t.startsWith("smallint")
|
||||||
|
|| t.startsWith("int4") || t.startsWith("int8") || t.startsWith("int2");
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> listColumns(Connection conn, String table) throws SQLException {
|
||||||
|
List<String> cols = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = conn.prepareStatement(
|
||||||
|
"SELECT column_name FROM information_schema.columns " +
|
||||||
|
"WHERE table_schema='public' AND lower(table_name)=lower(?) " +
|
||||||
|
"ORDER BY ordinal_position")) {
|
||||||
|
ps.setString(1, table);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) cols.add(rs.getString(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean columnExists(Connection conn, String table, String col) throws SQLException {
|
||||||
|
try (PreparedStatement ps = conn.prepareStatement(
|
||||||
|
"SELECT 1 FROM information_schema.columns " +
|
||||||
|
"WHERE table_schema='public' AND lower(table_name)=lower(?) AND lower(column_name)=lower(?)")) {
|
||||||
|
ps.setString(1, table);
|
||||||
|
ps.setString(2, col);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.erp.provisioning;
|
||||||
|
|
||||||
|
import com.erp.tenant.TenantDbSettings;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CREATE DATABASE / DROP DATABASE 를 superuser 로 실행.
|
||||||
|
*
|
||||||
|
* ★ 보안 강화 (2026-04-24 Codex 리뷰 반영):
|
||||||
|
* - DB 이름은 ^[a-z][a-z0-9_]{{2,40}}$ 화이트리스트 재검증 (Controller 와 2중 방어).
|
||||||
|
* - pg_terminate_backend 는 PreparedStatement 로 파라미터 바인딩.
|
||||||
|
* - CREATE/DROP DATABASE 는 PG 문법상 파라미터 바인딩 불가 → 검증된 이름만 큰따옴표로 quote.
|
||||||
|
*
|
||||||
|
* ★ 복구 강화:
|
||||||
|
* - dropDatabase 는 3회 재시도 + 기하급수 백오프 (300ms, 600ms, 1.2s).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class DatabaseCreator {
|
||||||
|
|
||||||
|
private static final Pattern SAFE_DB_NAME = Pattern.compile("^[a-z][a-z0-9_]{2,40}$");
|
||||||
|
|
||||||
|
private final TenantDbSettings settings;
|
||||||
|
|
||||||
|
public void createDatabase(String dbName) throws SQLException {
|
||||||
|
requireSafeDbName(dbName);
|
||||||
|
try (Connection c = adminConnection()) {
|
||||||
|
c.setAutoCommit(true);
|
||||||
|
try (Statement s = c.createStatement()) {
|
||||||
|
s.execute("CREATE DATABASE \"" + dbName + "\" WITH TEMPLATE template0 ENCODING 'UTF8'");
|
||||||
|
}
|
||||||
|
log.info("[Provisioning] CREATE DATABASE: {}", dbName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 보상 롤백. 재시도 3회 + 백오프. 실패 기록은 남기지만 호출자에게 예외 던지지 않음 (이미 실패 처리 중이므로). */
|
||||||
|
public void dropDatabase(String dbName) {
|
||||||
|
try {
|
||||||
|
requireSafeDbName(dbName);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.error("[Provisioning] drop skipped — unsafe name: {}", dbName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long backoffMs = 300;
|
||||||
|
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
try (Connection c = adminConnection()) {
|
||||||
|
c.setAutoCommit(true);
|
||||||
|
try (PreparedStatement p = c.prepareStatement(
|
||||||
|
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity " +
|
||||||
|
"WHERE datname = ? AND pid <> pg_backend_pid()")) {
|
||||||
|
p.setString(1, dbName);
|
||||||
|
p.execute();
|
||||||
|
}
|
||||||
|
try (Statement s = c.createStatement()) {
|
||||||
|
s.execute("DROP DATABASE IF EXISTS \"" + dbName + "\"");
|
||||||
|
}
|
||||||
|
log.warn("[Provisioning] ROLLBACK — dropped DB: {} (attempt {})", dbName, attempt);
|
||||||
|
return;
|
||||||
|
} catch (SQLException e) {
|
||||||
|
log.warn("[Provisioning] drop attempt {}/3 failed for {}: {}", attempt, dbName, e.getMessage());
|
||||||
|
if (attempt < 3) {
|
||||||
|
try { Thread.sleep(backoffMs); } catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
backoffMs *= 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.error("[Provisioning] drop FAILED after 3 attempts: {} — manual cleanup required", dbName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Connection adminConnection() throws SQLException {
|
||||||
|
String url = String.format("jdbc:postgresql://%s:%d/postgres", settings.host(), settings.port());
|
||||||
|
return DriverManager.getConnection(url, settings.username(), settings.password());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireSafeDbName(String name) {
|
||||||
|
if (name == null || !SAFE_DB_NAME.matcher(name).matches()) {
|
||||||
|
throw new IllegalArgumentException("unsafe db name: " + name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package com.erp.provisioning;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.ibatis.session.SqlSession;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 3-A 프로비저닝 API.
|
||||||
|
*
|
||||||
|
* GET /api/admin/provisioning/table-groups — Step 2 체크박스 렌더용
|
||||||
|
* GET /api/admin/provisioning/check — 실시간 중복/포맷 검증
|
||||||
|
* POST /api/admin/provisioning/companies — 마법사 최종 submit (202 accepted)
|
||||||
|
* GET /api/admin/provisioning/status/{id} — Step 4 폴링
|
||||||
|
*
|
||||||
|
* ⚠ 인증: Phase 3-A 에선 SUPER_ADMIN 권한 체크를 생략하고 SecurityConfig 차원에서 permitAll.
|
||||||
|
* 실운영 배포 전에 Controller 앞단에 권한 검증을 추가해야 함 (JwtAuthenticationFilter 가 세팅한 user_type attribute 검사).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/provisioning")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class ProvisioningController {
|
||||||
|
|
||||||
|
private final CompanyProvisioningService service;
|
||||||
|
private final ProvisioningRegistry registry;
|
||||||
|
private final SqlSession sqlSession;
|
||||||
|
private final CompanyStatsService statsService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로덕션 배포 시엔 반드시 true 로. 개발 중엔 JWT 없는 curl 테스트를 허용하기 위해 false 기본.
|
||||||
|
* 환경변수: TENANT_PROVISIONING_REQUIRE_SUPER_ADMIN=true
|
||||||
|
*/
|
||||||
|
@Value("${tenant.provisioning.require-super-admin:false}")
|
||||||
|
private boolean requireSuperAdmin;
|
||||||
|
|
||||||
|
private static final Set<String> RESERVED_SUBDOMAINS = Set.of(
|
||||||
|
"www", "admin", "api", "app", "static", "assets",
|
||||||
|
"main", "mail", "blog", "dev", "test", "staging", "prod", "console"
|
||||||
|
);
|
||||||
|
|
||||||
|
@GetMapping("/table-groups")
|
||||||
|
public ResponseEntity<List<Map<String, Object>>> tableGroups(HttpServletRequest request) {
|
||||||
|
enforceSuperAdmin(request);
|
||||||
|
return ResponseEntity.ok(Arrays.stream(TableGroup.values()).map(TableGroup::toMap).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회사관리 UI (v9 accordion 메인 화면) 전용 리스트 + derived 집계.
|
||||||
|
* 정적 필드는 COMPANY_MNG 에서, users/db_size 등은 tenant DB/메타 서버 런타임 조회.
|
||||||
|
* N+1 — MVP. 회사 50 개 넘어가면 병렬/캐시 도입 필요.
|
||||||
|
*/
|
||||||
|
@GetMapping("/companies-stats")
|
||||||
|
public ResponseEntity<List<Map<String, Object>>> companiesStats(HttpServletRequest request) {
|
||||||
|
enforceSuperAdmin(request);
|
||||||
|
return ResponseEntity.ok(statsService.listWithStats());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/check")
|
||||||
|
public ResponseEntity<Map<String, Object>> check(
|
||||||
|
HttpServletRequest request,
|
||||||
|
@RequestParam(required = false) String subdomain,
|
||||||
|
@RequestParam(required = false) String dbPrefix,
|
||||||
|
@RequestParam(required = false) String companyCode) {
|
||||||
|
enforceSuperAdmin(request);
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
if (subdomain != null) {
|
||||||
|
boolean formatOk = isValidSubdomain(subdomain);
|
||||||
|
boolean exists = false;
|
||||||
|
if (formatOk) {
|
||||||
|
Map<String, Object> p = new HashMap<>();
|
||||||
|
p.put("subdomain", subdomain);
|
||||||
|
exists = sqlSession.selectOne("provisioning.existsSubdomain", p) != null;
|
||||||
|
}
|
||||||
|
Map<String, Object> sub = new LinkedHashMap<>();
|
||||||
|
sub.put("value", subdomain);
|
||||||
|
sub.put("valid_format", formatOk);
|
||||||
|
sub.put("reserved", RESERVED_SUBDOMAINS.contains(subdomain));
|
||||||
|
sub.put("available", formatOk && !exists);
|
||||||
|
sub.put("url_preview", subdomain + ".invyone.com");
|
||||||
|
result.put("subdomain", sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dbPrefix != null) {
|
||||||
|
boolean formatOk = isValidDbPrefix(dbPrefix);
|
||||||
|
String dbName = dbPrefix + "_vexplor";
|
||||||
|
boolean exists = false;
|
||||||
|
if (formatOk) {
|
||||||
|
Map<String, Object> p = new HashMap<>();
|
||||||
|
p.put("db_name", dbName);
|
||||||
|
exists = sqlSession.selectOne("provisioning.existsDbName", p) != null;
|
||||||
|
}
|
||||||
|
Map<String, Object> pre = new LinkedHashMap<>();
|
||||||
|
pre.put("value", dbPrefix);
|
||||||
|
pre.put("db_name", dbName);
|
||||||
|
pre.put("valid_format", formatOk);
|
||||||
|
pre.put("available", formatOk && !exists);
|
||||||
|
result.put("db_prefix", pre);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (companyCode != null) {
|
||||||
|
boolean formatOk = isValidCompanyCode(companyCode);
|
||||||
|
boolean exists = false;
|
||||||
|
if (formatOk) {
|
||||||
|
Map<String, Object> p = new HashMap<>();
|
||||||
|
p.put("company_code", companyCode);
|
||||||
|
exists = sqlSession.selectOne("provisioning.existsCompanyCode", p) != null;
|
||||||
|
}
|
||||||
|
Map<String, Object> cc = new LinkedHashMap<>();
|
||||||
|
cc.put("value", companyCode);
|
||||||
|
cc.put("valid_format", formatOk);
|
||||||
|
cc.put("available", formatOk && !exists);
|
||||||
|
result.put("company_code", cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/companies")
|
||||||
|
public ResponseEntity<Map<String, Object>> create(HttpServletRequest request,
|
||||||
|
@RequestBody Map<String, Object> req) {
|
||||||
|
enforceSuperAdmin(request);
|
||||||
|
String companyCode = asStr(req.get("company_code"));
|
||||||
|
String companyName = asStr(req.get("company_name"));
|
||||||
|
String subdomain = asStr(req.get("subdomain"));
|
||||||
|
String dbPrefix = asStr(req.getOrDefault("db_prefix", subdomain));
|
||||||
|
|
||||||
|
// 필수 필드
|
||||||
|
if (companyCode == null || companyName == null || subdomain == null || dbPrefix == null) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"error", "missing_required",
|
||||||
|
"message", "company_code, company_name, subdomain, db_prefix 필수"));
|
||||||
|
}
|
||||||
|
// 포맷
|
||||||
|
if (!isValidCompanyCode(companyCode)) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"error", "invalid_format", "field", "company_code"));
|
||||||
|
}
|
||||||
|
if (!isValidSubdomain(subdomain)) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"error", "invalid_format", "field", "subdomain"));
|
||||||
|
}
|
||||||
|
if (!isValidDbPrefix(dbPrefix)) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"error", "invalid_format", "field", "db_prefix"));
|
||||||
|
}
|
||||||
|
// 중복
|
||||||
|
String dbName = dbPrefix + "_vexplor";
|
||||||
|
if (sqlSession.selectOne("provisioning.existsCompanyCode", Map.of("company_code", companyCode)) != null) {
|
||||||
|
return ResponseEntity.status(409).body(Map.of("error", "duplicate", "field", "company_code"));
|
||||||
|
}
|
||||||
|
if (sqlSession.selectOne("provisioning.existsSubdomain", Map.of("subdomain", subdomain)) != null) {
|
||||||
|
return ResponseEntity.status(409).body(Map.of("error", "duplicate", "field", "subdomain"));
|
||||||
|
}
|
||||||
|
if (sqlSession.selectOne("provisioning.existsDbName", Map.of("db_name", dbName)) != null) {
|
||||||
|
return ResponseEntity.status(409).body(Map.of("error", "duplicate", "field", "db_prefix"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> reqMutable = new HashMap<>(req);
|
||||||
|
reqMutable.put("db_prefix", dbPrefix);
|
||||||
|
if (reqMutable.get("initial_password") == null || ((String) reqMutable.get("initial_password")).isBlank()) {
|
||||||
|
reqMutable.put("initial_password", generateRandomPassword());
|
||||||
|
}
|
||||||
|
|
||||||
|
ProvisioningStatus status;
|
||||||
|
try {
|
||||||
|
status = service.initiate(reqMutable);
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
// 사전 체크 후 race condition 으로 끼어든 동일 요청 → DB UNIQUE 제약이 잡아줌
|
||||||
|
return ResponseEntity.status(409).body(Map.of(
|
||||||
|
"error", "duplicate",
|
||||||
|
"message", "동시 요청에서 회사 코드/서브도메인/DB 이름이 이미 사용됨"));
|
||||||
|
}
|
||||||
|
service.provisionAsync(status.getId(), reqMutable);
|
||||||
|
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("provisioning_id", status.getId());
|
||||||
|
body.put("company_code", companyCode);
|
||||||
|
body.put("db_name", status.getDbName());
|
||||||
|
body.put("subdomain", subdomain);
|
||||||
|
body.put("admin_user_id", dbPrefix + "_admin");
|
||||||
|
body.put("initial_password", reqMutable.get("initial_password"));
|
||||||
|
body.put("status_url", "/api/admin/provisioning/status/" + status.getId());
|
||||||
|
// 초기 비밀번호가 본문에 포함되므로 캐시·프록시·브라우저 히스토리에 저장 금지
|
||||||
|
return ResponseEntity.accepted()
|
||||||
|
.header("Cache-Control", "no-store")
|
||||||
|
.header("Pragma", "no-cache")
|
||||||
|
.body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/status/{id}")
|
||||||
|
public ResponseEntity<Map<String, Object>> status(HttpServletRequest request,
|
||||||
|
@PathVariable("id") String id) {
|
||||||
|
enforceSuperAdmin(request);
|
||||||
|
ProvisioningStatus s = registry.get(id);
|
||||||
|
if (s == null) return ResponseEntity.notFound().build();
|
||||||
|
return ResponseEntity.ok(s.toMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 권한 체크
|
||||||
|
//
|
||||||
|
// 현재 `/api/**` 가 permitAll 이라 Controller 레벨에서 수동 검증.
|
||||||
|
// JWT 가 있으면 JwtAuthenticationFilter 가 request.getAttribute("user_type") 세팅.
|
||||||
|
// 개발 모드(requireSuperAdmin=false): JWT 없이도 통과 (curl 테스트용). 단 다른 role 은 차단.
|
||||||
|
// 프로덕션 모드(requireSuperAdmin=true): SUPER_ADMIN 아니면 모두 403.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
private void enforceSuperAdmin(HttpServletRequest request) {
|
||||||
|
String userType = (String) request.getAttribute("user_type");
|
||||||
|
if ("SUPER_ADMIN".equals(userType)) return;
|
||||||
|
|
||||||
|
if (!requireSuperAdmin && userType == null) {
|
||||||
|
log.warn("[Provisioning] anonymous access allowed in dev mode (set " +
|
||||||
|
"tenant.provisioning.require-super-admin=true in production)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "SUPER_ADMIN only");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Validation helpers ---
|
||||||
|
|
||||||
|
private static boolean isValidCompanyCode(String s) {
|
||||||
|
return s != null && s.matches("^[A-Z][A-Z0-9_]{2,30}$");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isValidSubdomain(String s) {
|
||||||
|
return s != null
|
||||||
|
&& s.matches("^[a-z][a-z0-9-]{2,30}$")
|
||||||
|
&& !RESERVED_SUBDOMAINS.contains(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isValidDbPrefix(String s) {
|
||||||
|
return s != null && s.matches("^[a-z][a-z0-9_]{2,30}$");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String asStr(Object v) {
|
||||||
|
return (v == null) ? null : v.toString().trim().isEmpty() ? null : v.toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String generateRandomPassword() {
|
||||||
|
String chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789@#$";
|
||||||
|
SecureRandom r = new SecureRandom();
|
||||||
|
StringBuilder sb = new StringBuilder(12);
|
||||||
|
for (int i = 0; i < 12; i++) sb.append(chars.charAt(r.nextInt(chars.length())));
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.erp.provisioning;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 진행 중인 프로비저닝 작업의 인메모리 레지스트리.
|
||||||
|
* 완료/실패한 최종 상태는 COMPANY_MNG.db_status 에 영속화되므로, 이 레지스트리는
|
||||||
|
* "지금 진행 중이거나 방금 끝난" 단기 상태 전용.
|
||||||
|
*
|
||||||
|
* ★ 2026-04-24 Codex 리뷰 반영:
|
||||||
|
* - 완료/실패 후 TTL 24h 초과하면 자동 제거 (매시간 cleanup).
|
||||||
|
* - 이전 버전: 서버 무중단 운영 시 Map 이 영구 누적.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class ProvisioningRegistry {
|
||||||
|
|
||||||
|
private static final int CLEANUP_TTL_HOURS = 24;
|
||||||
|
|
||||||
|
private final ConcurrentHashMap<String, ProvisioningStatus> jobs = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public void register(ProvisioningStatus status) {
|
||||||
|
jobs.put(status.getId(), status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProvisioningStatus get(String id) {
|
||||||
|
return jobs.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void update(String id, Consumer<ProvisioningStatus> updater) {
|
||||||
|
jobs.computeIfPresent(id, (k, v) -> {
|
||||||
|
updater.accept(v);
|
||||||
|
return v;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Collection<ProvisioningStatus> list() {
|
||||||
|
return Collections.unmodifiableCollection(jobs.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 매시간 실행. 완료/실패 상태로 24h 지난 job 을 메모리에서 해제. */
|
||||||
|
@Scheduled(fixedDelayString = "${tenant.provisioning.cleanup-ms:3600000}", initialDelay = 600_000)
|
||||||
|
public void cleanupExpiredJobs() {
|
||||||
|
Instant threshold = Instant.now().minus(CLEANUP_TTL_HOURS, ChronoUnit.HOURS);
|
||||||
|
int before = jobs.size();
|
||||||
|
jobs.entrySet().removeIf(e -> {
|
||||||
|
ProvisioningStatus s = e.getValue();
|
||||||
|
String state = s.getStatus();
|
||||||
|
boolean done = "completed".equals(state) || "failed".equals(state);
|
||||||
|
return done && s.getCompletedAt() != null && s.getCompletedAt().isBefore(threshold);
|
||||||
|
});
|
||||||
|
int after = jobs.size();
|
||||||
|
if (before != after) {
|
||||||
|
log.info("[Provisioning] cleanup — removed {} expired jobs, {} remain", before - after, after);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.erp.provisioning;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로비저닝 작업 진행 상태. 프론트가 /status/{id} 폴링으로 받아감.
|
||||||
|
* 인메모리 ProvisioningRegistry 가 보관. 서버 재시작 시 유실되며, 그 경우 failed 로 간주.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ProvisioningStatus {
|
||||||
|
|
||||||
|
/** 프로비저닝 작업 고유 ID (UUID) */
|
||||||
|
private String id;
|
||||||
|
/** 생성 요청한 회사 코드 */
|
||||||
|
private String companyCode;
|
||||||
|
/** 생성 대상 DB 이름 (예: qnc_vexplor) */
|
||||||
|
private String dbName;
|
||||||
|
/** 서브도메인 */
|
||||||
|
private String subdomain;
|
||||||
|
|
||||||
|
/** Step.name() — 프론트에서 로컬라이즈 */
|
||||||
|
private String currentStep;
|
||||||
|
/** 1부터. 완료하면 progress == totalSteps */
|
||||||
|
private int progress;
|
||||||
|
private int totalSteps;
|
||||||
|
|
||||||
|
/** in_progress | completed | failed */
|
||||||
|
private String status;
|
||||||
|
/** 실패 시 어느 Step 에서 터졌는지 */
|
||||||
|
private String failedStep;
|
||||||
|
private String errorMessage;
|
||||||
|
|
||||||
|
private Instant startedAt;
|
||||||
|
private Instant completedAt;
|
||||||
|
|
||||||
|
public enum Step {
|
||||||
|
/** 메타 DB 에 COMPANY_MNG row 선반영 (status=pending) */
|
||||||
|
REGISTER_META,
|
||||||
|
/** CREATE DATABASE */
|
||||||
|
CREATE_DATABASE,
|
||||||
|
/** pg_dump --schema-only | psql */
|
||||||
|
COPY_SCHEMA,
|
||||||
|
/** 선택된 테이블 데이터 복사 (company_code 필터) */
|
||||||
|
COPY_DATA,
|
||||||
|
/** {prefix}_admin 계정 생성 */
|
||||||
|
CREATE_ADMIN,
|
||||||
|
/** COMPANY_MNG.db_status=active */
|
||||||
|
FINALIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> toMap() {
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("id", id);
|
||||||
|
m.put("companyCode", companyCode);
|
||||||
|
m.put("dbName", dbName);
|
||||||
|
m.put("subdomain", subdomain);
|
||||||
|
m.put("currentStep", currentStep);
|
||||||
|
m.put("progress", progress);
|
||||||
|
m.put("totalSteps", totalSteps);
|
||||||
|
m.put("status", status);
|
||||||
|
m.put("failedStep", failedStep);
|
||||||
|
m.put("errorMessage", errorMessage);
|
||||||
|
m.put("startedAt", startedAt == null ? null : startedAt.toString());
|
||||||
|
m.put("completedAt", completedAt == null ? null : completedAt.toString());
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package com.erp.provisioning;
|
||||||
|
|
||||||
|
import com.erp.tenant.TenantDbSettings;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pg_dump → psql 파이프로 스키마 복제.
|
||||||
|
*
|
||||||
|
* ★ 보안 강화 (2026-04-24 Codex 리뷰 반영):
|
||||||
|
* - 이전 버전: "sh -c 'pg_dump ... | psql ...'" 로 쉘 문자열 구성 → command injection 표면.
|
||||||
|
* - 현재: ProcessBuilder 에 각 인자를 배열로 전달 + JVM 내부에서 stdout→stdin 파이프.
|
||||||
|
* - DB 이름은 ^[a-z][a-z0-9_]{{2,40}}$ 화이트리스트 재검증.
|
||||||
|
* - PGPASSWORD 는 여전히 env 로 전달. (.pgpass 전환은 별도 작업)
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class SchemaCopier {
|
||||||
|
|
||||||
|
private static final Pattern SAFE_DB_NAME = Pattern.compile("^[a-z][a-z0-9_]{2,40}$");
|
||||||
|
private static final int BUFFER_SIZE = 64 * 1024;
|
||||||
|
private static final long TIMEOUT_MILLIS = 5 * 60 * 1000; // 5분
|
||||||
|
|
||||||
|
private final TenantDbSettings settings;
|
||||||
|
|
||||||
|
public void copySchema(String srcDb, String dstDb) throws IOException, InterruptedException {
|
||||||
|
requireSafeDbName(srcDb);
|
||||||
|
requireSafeDbName(dstDb);
|
||||||
|
|
||||||
|
ProcessBuilder dumpPb = new ProcessBuilder(
|
||||||
|
"pg_dump",
|
||||||
|
"-h", settings.host(),
|
||||||
|
"-p", String.valueOf(settings.port()),
|
||||||
|
"-U", settings.username(),
|
||||||
|
"-s", // schema only
|
||||||
|
"--no-owner",
|
||||||
|
"--no-privileges",
|
||||||
|
srcDb
|
||||||
|
);
|
||||||
|
dumpPb.environment().put("PGPASSWORD", settings.password());
|
||||||
|
dumpPb.redirectErrorStream(false);
|
||||||
|
|
||||||
|
ProcessBuilder restorePb = new ProcessBuilder(
|
||||||
|
"psql",
|
||||||
|
"-h", settings.host(),
|
||||||
|
"-p", String.valueOf(settings.port()),
|
||||||
|
"-U", settings.username(),
|
||||||
|
"-d", dstDb,
|
||||||
|
"-v", "ON_ERROR_STOP=1"
|
||||||
|
);
|
||||||
|
restorePb.environment().put("PGPASSWORD", settings.password());
|
||||||
|
restorePb.redirectErrorStream(false);
|
||||||
|
|
||||||
|
Process dump = dumpPb.start();
|
||||||
|
Process restore = restorePb.start();
|
||||||
|
|
||||||
|
// dump stdout -> restore stdin
|
||||||
|
Thread pipe = new Thread(() -> {
|
||||||
|
try (InputStream in = dump.getInputStream();
|
||||||
|
OutputStream out = restore.getOutputStream()) {
|
||||||
|
byte[] buf = new byte[BUFFER_SIZE];
|
||||||
|
int n;
|
||||||
|
while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("[Provisioning] pipe error (dump→psql): {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}, "schema-copy-pipe");
|
||||||
|
pipe.setDaemon(true);
|
||||||
|
|
||||||
|
// stderr 캡처 (비동기)
|
||||||
|
StringBuilder dumpErr = new StringBuilder();
|
||||||
|
StringBuilder restoreErr = new StringBuilder();
|
||||||
|
Thread dumpErrT = asyncCollect(dump.getErrorStream(), dumpErr, "pg_dump-stderr");
|
||||||
|
Thread restoreErrT = asyncCollect(restore.getErrorStream(), restoreErr, "psql-stderr");
|
||||||
|
|
||||||
|
pipe.start();
|
||||||
|
dumpErrT.start();
|
||||||
|
restoreErrT.start();
|
||||||
|
|
||||||
|
long deadline = System.currentTimeMillis() + TIMEOUT_MILLIS;
|
||||||
|
boolean dumpDone = dump.waitFor(TIMEOUT_MILLIS, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||||
|
if (!dumpDone) { dump.destroyForcibly(); restore.destroyForcibly(); throw new IOException("pg_dump timed out"); }
|
||||||
|
long remaining = Math.max(1, deadline - System.currentTimeMillis());
|
||||||
|
boolean restoreDone = restore.waitFor(remaining, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||||
|
if (!restoreDone) { restore.destroyForcibly(); throw new IOException("psql timed out"); }
|
||||||
|
|
||||||
|
pipe.join(5_000);
|
||||||
|
dumpErrT.join(5_000);
|
||||||
|
restoreErrT.join(5_000);
|
||||||
|
|
||||||
|
int dumpExit = dump.exitValue();
|
||||||
|
int restoreExit = restore.exitValue();
|
||||||
|
|
||||||
|
if (dumpExit != 0 || restoreExit != 0) {
|
||||||
|
String dumpLog = dumpErr.length() > 2000 ? dumpErr.substring(0, 2000) + "…" : dumpErr.toString();
|
||||||
|
String restoreLog = restoreErr.length() > 2000 ? restoreErr.substring(0, 2000) + "…" : restoreErr.toString();
|
||||||
|
throw new IOException(String.format(
|
||||||
|
"schema copy failed (dumpExit=%d, restoreExit=%d)\n--pg_dump stderr--\n%s\n--psql stderr--\n%s",
|
||||||
|
dumpExit, restoreExit, dumpLog, restoreLog));
|
||||||
|
}
|
||||||
|
log.info("[Provisioning] COPY SCHEMA: {} -> {}", srcDb, dstDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireSafeDbName(String name) {
|
||||||
|
if (name == null || !SAFE_DB_NAME.matcher(name).matches()) {
|
||||||
|
throw new IllegalArgumentException("unsafe db name: " + name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Thread asyncCollect(InputStream in, StringBuilder sink, String threadName) {
|
||||||
|
Thread t = new Thread(() -> {
|
||||||
|
try (InputStream is = in) {
|
||||||
|
byte[] buf = new byte[4096];
|
||||||
|
int n;
|
||||||
|
while ((n = is.read(buf)) != -1) {
|
||||||
|
sink.append(new String(buf, 0, n, StandardCharsets.UTF_8));
|
||||||
|
if (sink.length() > 10_000) break; // runaway 방어
|
||||||
|
}
|
||||||
|
} catch (IOException ignored) { }
|
||||||
|
}, threadName);
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.erp.provisioning;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회사 프로비저닝 시 복사 가능한 테이블 그룹.
|
||||||
|
* 그룹 id = enum 이름 (SCREEN 등). UI 체크박스와 1:1 매핑.
|
||||||
|
* required=true 그룹은 UI 에서 해제 불가 (disabled checkbox).
|
||||||
|
*/
|
||||||
|
public enum TableGroup {
|
||||||
|
|
||||||
|
SCREEN("화면관리", true, List.of(
|
||||||
|
"screen_definitions", "screen_groups", "screen_group_screens",
|
||||||
|
"screen_layouts_v1", "screen_layouts_v2", "screen_layouts_v3",
|
||||||
|
"screen_layouts_pop", "screen_menu_assignments",
|
||||||
|
"screen_conditional_zones", "screen_data_flows", "screen_data_transfer",
|
||||||
|
"screen_embedding", "screen_field_joins", "screen_split_panel",
|
||||||
|
"screen_table_relations", "screen_templates"
|
||||||
|
)),
|
||||||
|
CONTROL("제어관리", true, List.of("dtg_management")),
|
||||||
|
BATCH("배치관리", true, List.of("batch_configs", "batch_mappings", "batch_execution_logs")),
|
||||||
|
DASHBOARD("대시보드", false, List.of("dashboard_cards", "dashboard_elements")),
|
||||||
|
DATAFLOW("데이터플로우", false, List.of("dataflow_diagrams", "node_flows")),
|
||||||
|
MENU_AUTH("권한/메뉴 기본값", false, List.of(
|
||||||
|
"menu_info", "authority_master", "authority_master_history",
|
||||||
|
"authority_sub_menu", "authority_sub_user"
|
||||||
|
));
|
||||||
|
|
||||||
|
private final String label;
|
||||||
|
private final boolean required;
|
||||||
|
private final List<String> tables;
|
||||||
|
|
||||||
|
TableGroup(String label, boolean required, List<String> tables) {
|
||||||
|
this.label = label;
|
||||||
|
this.required = required;
|
||||||
|
this.tables = tables;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLabel() { return label; }
|
||||||
|
public boolean isRequired() { return required; }
|
||||||
|
public List<String> getTables() { return tables; }
|
||||||
|
|
||||||
|
/** API 응답용 Map 변환. UI 에서 체크박스 렌더에 필요한 모든 정보. */
|
||||||
|
public Map<String, Object> toMap() {
|
||||||
|
return Map.of(
|
||||||
|
"id", name(),
|
||||||
|
"label", label,
|
||||||
|
"required", required,
|
||||||
|
"tables", tables
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TableGroup parse(String id) {
|
||||||
|
try {
|
||||||
|
return TableGroup.valueOf(id);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<TableGroup> requiredGroups() {
|
||||||
|
return Arrays.stream(values()).filter(TableGroup::isRequired).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
package com.erp.security;
|
package com.erp.security;
|
||||||
|
|
||||||
|
import com.erp.tenant.CompanyResolver;
|
||||||
|
import com.erp.tenant.SubdomainResolverFilter;
|
||||||
|
import com.erp.tenant.TenantDbSettings;
|
||||||
|
import com.erp.tenant.TenantRoutingDataSource;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -22,9 +27,13 @@ import java.util.List;
|
|||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
private final JwtTokenProvider jwtTokenProvider;
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
private final CompanyResolver companyResolver;
|
||||||
|
private final TenantRoutingDataSource tenantRoutingDataSource;
|
||||||
|
private final TenantDbSettings tenantDbSettings;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CORS 화이트리스트. 콤마 구분 문자열로 주입 (예: "http://localhost:3000,https://v1.invion.com").
|
* CORS 화이트리스트. 콤마 구분 문자열로 주입 (예: "http://localhost:3000,https://v1.invion.com").
|
||||||
@@ -48,7 +57,12 @@ public class SecurityConfig {
|
|||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
|
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
|
||||||
UsernamePasswordAuthenticationFilter.class);
|
UsernamePasswordAuthenticationFilter.class)
|
||||||
|
// Phase 2 (2026-04-24): 서브도메인 → CompanyResolver → TenantRoutingDataSource 라우팅.
|
||||||
|
// JwtAuthenticationFilter 보다 앞에서 실행되어야 tenant 컨텍스트가 먼저 결정됨.
|
||||||
|
.addFilterBefore(
|
||||||
|
new SubdomainResolverFilter(companyResolver, tenantRoutingDataSource, tenantDbSettings),
|
||||||
|
JwtAuthenticationFilter.class);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
@@ -61,12 +75,16 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
CorsConfiguration config = new CorsConfiguration();
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
// 와일드카드 금지 — 환경변수에서 받은 화이트리스트만 허용
|
// 테넌트 서브도메인 지원을 위해 setAllowedOriginPatterns 사용
|
||||||
List<String> origins = Arrays.stream(corsAllowedOrigins.split(","))
|
// (setAllowedOrigins 는 정확한 매칭만 허용해서 *.invyone.com 같은 패턴이 안 됨)
|
||||||
|
// 전체 와일드카드 '*' 는 금지 — 반드시 명시된 prefix 만 허용.
|
||||||
|
List<String> patterns = Arrays.stream(corsAllowedOrigins.split(","))
|
||||||
.map(String::trim)
|
.map(String::trim)
|
||||||
.filter(s -> !s.isEmpty())
|
.filter(s -> !s.isEmpty())
|
||||||
.toList();
|
.toList();
|
||||||
config.setAllowedOrigins(origins);
|
log.info("[CORS] raw value from @Value = '{}'", corsAllowedOrigins);
|
||||||
|
log.info("[CORS] parsed patterns ({}): {}", patterns.size(), patterns);
|
||||||
|
config.setAllowedOriginPatterns(patterns);
|
||||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||||
config.setAllowedHeaders(List.of("*"));
|
config.setAllowedHeaders(List.of("*"));
|
||||||
config.setAllowCredentials(true);
|
config.setAllowCredentials(true);
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.erp.tenant;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.ibatis.session.SqlSession;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서브도메인 → tenant DB 이름 매핑.
|
||||||
|
* 메타 DB (COMPANY_MNG) 에서 조회, ConcurrentHashMap 으로 캐시.
|
||||||
|
* null 결과(= 매핑 없음)는 캐시하지 않음 — 신규 회사 프로비저닝 직후 바로 반영되게.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class CompanyResolver {
|
||||||
|
|
||||||
|
private final SqlSession sqlSession;
|
||||||
|
|
||||||
|
private final Map<String, String> cache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return tenant DB 이름, 매핑 없으면 null.
|
||||||
|
*/
|
||||||
|
public String resolveDbName(String subdomain) {
|
||||||
|
if (subdomain == null || subdomain.isBlank()) return null;
|
||||||
|
|
||||||
|
String cached = cache.get(subdomain);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
params.put("subdomain", subdomain);
|
||||||
|
String dbName = sqlSession.selectOne("tenant.resolveDbNameBySubdomain", params);
|
||||||
|
|
||||||
|
if (dbName != null) {
|
||||||
|
cache.put(subdomain, dbName);
|
||||||
|
log.debug("[CompanyResolver] resolved: {} -> {}", subdomain, dbName);
|
||||||
|
}
|
||||||
|
return dbName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 특정 서브도메인 캐시 무효화 (회사 수정/삭제 시) */
|
||||||
|
public void invalidate(String subdomain) {
|
||||||
|
if (subdomain != null) cache.remove(subdomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전체 캐시 무효화 (운영 도구/디버그용) */
|
||||||
|
public void invalidateAll() {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.erp.tenant;
|
||||||
|
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 DataSource 를 {@link TenantRoutingDataSource} 로 교체.
|
||||||
|
* - @Bean DataSource 를 제공하므로 Spring Boot DataSourceAutoConfiguration 의 자동 DataSource 는 스킵됨.
|
||||||
|
* - MyBatis / TransactionManager 등 하위 컴포넌트는 이 @Primary bean 을 자동으로 사용.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@Slf4j
|
||||||
|
public class DataSourceConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public TenantDbSettings tenantDbSettings(DataSourceProperties props) {
|
||||||
|
TenantDbSettings s = TenantDbSettings.fromJdbcUrl(props.getUrl(), props.getUsername(), props.getPassword());
|
||||||
|
log.info("[DataSourceConfig] tenant default endpoint: {}:{}", s.host(), s.port());
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@Primary
|
||||||
|
public TenantRoutingDataSource dataSource(DataSourceProperties props) {
|
||||||
|
HikariDataSource meta = TenantDataSourceFactory.createMeta(
|
||||||
|
props.getUrl(), props.getUsername(), props.getPassword());
|
||||||
|
TenantRoutingDataSource routing = new TenantRoutingDataSource();
|
||||||
|
routing.registerMeta(meta);
|
||||||
|
return routing;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.erp.tenant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청 단위 tenant DB 컨텍스트.
|
||||||
|
* SubdomainResolverFilter 가 세팅하고, (Phase 2 이후) TenantRoutingDataSource 가 읽는다.
|
||||||
|
* Phase 1 단계에선 세팅/클리어만 수행. 실제 라우팅은 Phase 2에서 붙음.
|
||||||
|
*/
|
||||||
|
public final class DbContextHolder {
|
||||||
|
|
||||||
|
/** 메타 DB (invyone.com / admin.invyone.com / 서브도메인 없음) 를 가리키는 센티넬 */
|
||||||
|
public static final String META = "__META__";
|
||||||
|
|
||||||
|
private static final ThreadLocal<String> CTX = new ThreadLocal<>();
|
||||||
|
|
||||||
|
private DbContextHolder() {}
|
||||||
|
|
||||||
|
public static void set(String dbName) {
|
||||||
|
CTX.set(dbName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void setMeta() {
|
||||||
|
CTX.set(META);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String get() {
|
||||||
|
return CTX.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isMeta() {
|
||||||
|
return META.equals(CTX.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clear() {
|
||||||
|
CTX.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.erp.tenant;
|
||||||
|
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host 헤더에서 서브도메인을 파싱 → CompanyResolver 로 DB 이름 조회 →
|
||||||
|
* TenantRoutingDataSource 에 tenant 풀이 없으면 lazy 생성 → DbContextHolder 세팅.
|
||||||
|
*
|
||||||
|
* Phase 2 (실제 라우팅):
|
||||||
|
* - localhost / IP / invyone.com / www / admin → META
|
||||||
|
* - 알 수 없는 서브도메인 → META (log.debug 만)
|
||||||
|
* - 등록된 서브도메인 → tenant DB 로 라우팅
|
||||||
|
*
|
||||||
|
* SecurityConfig 에서 JwtAuthenticationFilter 앞에 등록됨.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SubdomainResolverFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private static final Pattern IPV4 = Pattern.compile("^\\d{1,3}(\\.\\d{1,3}){3}$");
|
||||||
|
|
||||||
|
private final CompanyResolver companyResolver;
|
||||||
|
private final TenantRoutingDataSource routingDataSource;
|
||||||
|
private final TenantDbSettings tenantDbSettings;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
FilterChain chain) throws ServletException, IOException {
|
||||||
|
try {
|
||||||
|
String host = request.getHeader("Host");
|
||||||
|
String subdomain = extractSubdomain(host);
|
||||||
|
|
||||||
|
if (subdomain == null) {
|
||||||
|
DbContextHolder.setMeta();
|
||||||
|
} else {
|
||||||
|
// resolve 쿼리 자체는 메타 DB 를 타야 하므로 먼저 META 로 세팅.
|
||||||
|
DbContextHolder.setMeta();
|
||||||
|
String dbName = companyResolver.resolveDbName(subdomain);
|
||||||
|
|
||||||
|
if (dbName == null) {
|
||||||
|
log.debug("[Tenant] unknown subdomain '{}' → META fallback (host={})", subdomain, host);
|
||||||
|
// META 유지
|
||||||
|
} else {
|
||||||
|
ensureTenantPool(dbName);
|
||||||
|
DbContextHolder.set(dbName);
|
||||||
|
log.info("[Tenant] routed: subdomain={} → dbName={} (path={})",
|
||||||
|
subdomain, dbName, request.getRequestURI());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chain.doFilter(request, response);
|
||||||
|
} finally {
|
||||||
|
DbContextHolder.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 회사 DB 풀이 없으면 최초 1회 생성. minIdle=0 정책은 Factory 가 책임. */
|
||||||
|
private void ensureTenantPool(String dbName) {
|
||||||
|
if (routingDataSource.hasTenant(dbName)) return;
|
||||||
|
synchronized (routingDataSource) {
|
||||||
|
if (routingDataSource.hasTenant(dbName)) return;
|
||||||
|
HikariDataSource ds = TenantDataSourceFactory.createTenant(
|
||||||
|
tenantDbSettings.buildJdbcUrl(dbName),
|
||||||
|
tenantDbSettings.username(),
|
||||||
|
tenantDbSettings.password(),
|
||||||
|
dbName);
|
||||||
|
routingDataSource.addTenant(dbName, ds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host 헤더에서 서브도메인 추출. 포트 제거 + IP/localhost/www/admin 제외.
|
||||||
|
*/
|
||||||
|
static String extractSubdomain(String host) {
|
||||||
|
if (host == null || host.isBlank()) return null;
|
||||||
|
|
||||||
|
int colon = host.indexOf(':');
|
||||||
|
if (colon != -1) host = host.substring(0, colon);
|
||||||
|
|
||||||
|
host = host.toLowerCase();
|
||||||
|
|
||||||
|
if ("localhost".equals(host)) return null;
|
||||||
|
if (IPV4.matcher(host).matches()) return null;
|
||||||
|
|
||||||
|
String[] parts = host.split("\\.");
|
||||||
|
if (parts.length < 3) return null; // invyone.com (2파트) → null
|
||||||
|
|
||||||
|
String first = parts[0];
|
||||||
|
if ("www".equals(first) || "admin".equals(first)) return null;
|
||||||
|
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.erp.tenant;
|
||||||
|
|
||||||
|
import com.zaxxer.hikari.HikariConfig;
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HikariDataSource 빌더.
|
||||||
|
*
|
||||||
|
* - META 용: 앱 상시 접근. 기존 application.yml 의 설정값과 호환 (minIdle=2, max=10).
|
||||||
|
* - Tenant 용: 회사 수만큼 풀이 복제되므로 ★ minIdle=0 ★ + idleTimeout=1분.
|
||||||
|
* → 안 쓰는 회사 풀은 커넥션 0으로 수축, Postgres max_connections 압박 방지.
|
||||||
|
* (실행계획 문서 2.1 참조)
|
||||||
|
*/
|
||||||
|
public final class TenantDataSourceFactory {
|
||||||
|
|
||||||
|
private TenantDataSourceFactory() {}
|
||||||
|
|
||||||
|
public static HikariDataSource createMeta(String jdbcUrl, String username, String password) {
|
||||||
|
HikariConfig cfg = base(jdbcUrl, username, password);
|
||||||
|
cfg.setPoolName("meta-hikari");
|
||||||
|
cfg.setMaximumPoolSize(10);
|
||||||
|
cfg.setMinimumIdle(2);
|
||||||
|
cfg.setIdleTimeout(600_000);
|
||||||
|
return new HikariDataSource(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HikariDataSource createTenant(String jdbcUrl, String username, String password, String dbName) {
|
||||||
|
HikariConfig cfg = base(jdbcUrl, username, password);
|
||||||
|
cfg.setPoolName("tenant-" + dbName);
|
||||||
|
cfg.setMaximumPoolSize(5);
|
||||||
|
cfg.setMinimumIdle(0); // ★ 회사 풀 폭증 방지의 핵심
|
||||||
|
cfg.setIdleTimeout(60_000); // 1분 idle → 반납
|
||||||
|
return new HikariDataSource(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HikariConfig base(String jdbcUrl, String username, String password) {
|
||||||
|
HikariConfig cfg = new HikariConfig();
|
||||||
|
cfg.setJdbcUrl(jdbcUrl);
|
||||||
|
cfg.setUsername(username);
|
||||||
|
cfg.setPassword(password);
|
||||||
|
cfg.setDriverClassName("org.postgresql.Driver");
|
||||||
|
cfg.setConnectionTimeout(30_000);
|
||||||
|
cfg.setMaxLifetime(1_800_000);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.erp.tenant;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tenant DB 연결 공통 설정. 기존 {@code spring.datasource.url} 을 파싱해서
|
||||||
|
* host/port/credential 을 회사별 pool 생성 시 재사용한다.
|
||||||
|
* application.yml 에 별도 {@code tenant.*} prefix 를 추가하지 않는 이유:
|
||||||
|
* - 중복 저장 금지
|
||||||
|
* - 회사별 DB 는 기본적으로 같은 Postgres 인스턴스에 생성되므로 credential 1셋으로 충분
|
||||||
|
* - 다중 서버로 분산할 때는 COMPANY_MNG.DB_HOST 컬럼을 사용 (Phase 6)
|
||||||
|
*/
|
||||||
|
public record TenantDbSettings(String host, int port, String username, String password) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* jdbc:postgresql://host:port/dbname 에서 host/port 추출.
|
||||||
|
* port 생략 시 5432 기본값.
|
||||||
|
*/
|
||||||
|
public static TenantDbSettings fromJdbcUrl(String jdbcUrl, String username, String password) {
|
||||||
|
if (jdbcUrl == null || !jdbcUrl.startsWith("jdbc:")) {
|
||||||
|
throw new IllegalArgumentException("Unsupported jdbcUrl: " + jdbcUrl);
|
||||||
|
}
|
||||||
|
// "jdbc:postgresql://host:port/db?..." → URI 파싱 가능하게 "jdbc:" 제거
|
||||||
|
URI uri = URI.create(jdbcUrl.substring("jdbc:".length()));
|
||||||
|
String host = uri.getHost();
|
||||||
|
int port = uri.getPort() == -1 ? 5432 : uri.getPort();
|
||||||
|
if (host == null) {
|
||||||
|
throw new IllegalArgumentException("Cannot parse host from jdbcUrl: " + jdbcUrl);
|
||||||
|
}
|
||||||
|
return new TenantDbSettings(host, port, username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String buildJdbcUrl(String dbName) {
|
||||||
|
return String.format("jdbc:postgresql://%s:%d/%s", host, port, dbName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.erp.tenant;
|
||||||
|
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link DbContextHolder#get()} 값을 lookup key 로 사용해 요청마다 tenant DataSource 로 라우팅.
|
||||||
|
*
|
||||||
|
* ★ 2026-04-24 Codex 리뷰 반영:
|
||||||
|
* - 이전 버전은 {@code setTargetDataSources() + afterPropertiesSet()} 를 add/remove 때마다 재호출해
|
||||||
|
* 내부 {@code resolvedDataSources} 를 rebuild 했음. 그 사이 lookup 이 들어오면 기존 체크아웃
|
||||||
|
* 커넥션과 새 풀이 충돌할 수 있음.
|
||||||
|
* - 지금은 {@link #determineTargetDataSource()} 자체를 override 하고 {@link ConcurrentHashMap} 을
|
||||||
|
* 직접 조회. {@code afterPropertiesSet()} 은 no-op 로 override 해서 super 의 rebuild 로직을
|
||||||
|
* 사용하지 않음.
|
||||||
|
* - add/remove 는 put/remove 단일 연산이라 락 없이 안전.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
|
||||||
|
|
||||||
|
private final ConcurrentHashMap<String, DataSource> sources = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public void registerMeta(DataSource meta) {
|
||||||
|
sources.put(DbContextHolder.META, meta);
|
||||||
|
log.info("[TenantRouting] META DataSource registered");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addTenant(String dbName, DataSource ds) {
|
||||||
|
DataSource prev = sources.putIfAbsent(dbName, ds);
|
||||||
|
if (prev != null) {
|
||||||
|
log.warn("[TenantRouting] tenant '{}' already registered — new instance ignored", dbName);
|
||||||
|
closeIfHikari(ds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.info("[TenantRouting] tenant DataSource added: {}", dbName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ★ LRU/TTL eviction 전 단계 — 수동으로 풀을 닫고 싶을 때 사용. Phase 6 에서 자동화 예정. */
|
||||||
|
public boolean evictTenant(String dbName) {
|
||||||
|
DataSource removed = sources.remove(dbName);
|
||||||
|
if (removed == null) return false;
|
||||||
|
closeIfHikari(removed);
|
||||||
|
log.warn("[TenantRouting] tenant DataSource evicted: {}", dbName);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasTenant(String dbName) {
|
||||||
|
return sources.containsKey(dbName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DataSource getMetaDataSource() {
|
||||||
|
return sources.get(DbContextHolder.META);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// AbstractRoutingDataSource override
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** super 의 resolvedDataSources rebuild 를 타지 않도록 no-op. */
|
||||||
|
@Override
|
||||||
|
public void afterPropertiesSet() {
|
||||||
|
// intentionally empty
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected DataSource determineTargetDataSource() {
|
||||||
|
String key = (String) determineCurrentLookupKey();
|
||||||
|
if (key == null) key = DbContextHolder.META;
|
||||||
|
DataSource ds = sources.get(key);
|
||||||
|
if (ds != null) return ds;
|
||||||
|
|
||||||
|
// 알 수 없는 tenant key → META 로 안전 fallback
|
||||||
|
DataSource meta = sources.get(DbContextHolder.META);
|
||||||
|
if (meta == null) {
|
||||||
|
throw new IllegalStateException("META DataSource not registered yet");
|
||||||
|
}
|
||||||
|
log.debug("[TenantRouting] key '{}' not found, falling back to META", key);
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Object determineCurrentLookupKey() {
|
||||||
|
String key = DbContextHolder.get();
|
||||||
|
return key != null ? key : DbContextHolder.META;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void closeIfHikari(DataSource ds) {
|
||||||
|
if (ds instanceof HikariDataSource h) {
|
||||||
|
try { h.close(); } catch (Exception ignored) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,8 +39,11 @@ jwt:
|
|||||||
expiration: ${JWT_EXPIRATION:86400000}
|
expiration: ${JWT_EXPIRATION:86400000}
|
||||||
|
|
||||||
cors:
|
cors:
|
||||||
# 콤마 구분 문자열. dev 디폴트는 localhost 와 사무실 Tailscale IP
|
# 콤마 구분 문자열. setAllowedOriginPatterns 로 매칭됨.
|
||||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:9772,http://localhost:9771,http://100.126.230.80:9772}
|
# Spring CORS 문법: 포트 와일드카드는 `[*]` 로 표기. YAML 이 `[...]` 를 sequence 로 해석하지
|
||||||
|
# 않도록 반드시 따옴표로 감싸기.
|
||||||
|
# dev 디폴트: localhost + 사무실 Tailscale IP + 테넌트 서브도메인 (모든 포트) 패턴.
|
||||||
|
allowed-origins: "${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:9772,http://localhost:9771,http://100.126.230.80:9772,http://*.invyone.com:[*],https://*.invyone.com:[*],http://*.invyone.com,https://*.invyone.com}"
|
||||||
|
|
||||||
file:
|
file:
|
||||||
upload-dir: ./uploads
|
upload-dir: ./uploads
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
|
||||||
|
<mapper namespace="provisioning">
|
||||||
|
|
||||||
|
<select id="existsCompanyCode" parameterType="map" resultType="int">
|
||||||
|
SELECT 1 FROM COMPANY_MNG WHERE COMPANY_CODE = #{company_code} LIMIT 1
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="existsSubdomain" parameterType="map" resultType="int">
|
||||||
|
SELECT 1 FROM COMPANY_MNG WHERE SUBDOMAIN = #{subdomain} LIMIT 1
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="existsDbName" parameterType="map" resultType="int">
|
||||||
|
SELECT 1 FROM COMPANY_MNG WHERE DB_NAME = #{db_name} LIMIT 1
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<insert id="insertCompanyWithTenant" parameterType="map">
|
||||||
|
INSERT INTO COMPANY_MNG (
|
||||||
|
COMPANY_CODE
|
||||||
|
, COMPANY_NAME
|
||||||
|
, BUSINESS_REGISTRATION_NUMBER
|
||||||
|
, REPRESENTATIVE_NAME
|
||||||
|
, REPRESENTATIVE_PHONE
|
||||||
|
, EMAIL
|
||||||
|
, WEBSITE
|
||||||
|
, ADDRESS
|
||||||
|
, STATUS
|
||||||
|
, DB_NAME
|
||||||
|
, SUBDOMAIN
|
||||||
|
, DB_HOST
|
||||||
|
, DB_STATUS
|
||||||
|
, PLAN
|
||||||
|
, INDUSTRY
|
||||||
|
, TEMPLATES_COUNT
|
||||||
|
, WRITER
|
||||||
|
, CREATED_DATE
|
||||||
|
) VALUES (
|
||||||
|
#{company_code}
|
||||||
|
, #{company_name}
|
||||||
|
, #{business_registration_number}
|
||||||
|
, #{representative_name}
|
||||||
|
, #{representative_phone}
|
||||||
|
, #{email}
|
||||||
|
, #{website}
|
||||||
|
, #{address}
|
||||||
|
, COALESCE(#{status}, 'active')
|
||||||
|
, #{db_name}
|
||||||
|
, #{subdomain}
|
||||||
|
, #{db_host}
|
||||||
|
, COALESCE(#{db_status}, 'provisioning')
|
||||||
|
, COALESCE(#{plan}, 'Starter')
|
||||||
|
, #{industry}
|
||||||
|
, COALESCE(#{templates_count}, 0)
|
||||||
|
, #{writer}
|
||||||
|
, NOW()
|
||||||
|
)
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
회사관리 UI (v9 accordion) 렌더용 전체 목록.
|
||||||
|
정적 필드만 반환. users / active30 / db_size / spark 등 derived 는 CompanyStatsService 가 덧붙임.
|
||||||
|
-->
|
||||||
|
<select id="listCompaniesForUi" resultType="map">
|
||||||
|
SELECT
|
||||||
|
COMPANY_CODE as company_code
|
||||||
|
, COMPANY_NAME as company_name
|
||||||
|
, SUBDOMAIN as subdomain
|
||||||
|
, DB_NAME as db_name
|
||||||
|
, DB_HOST as db_host
|
||||||
|
, DB_STATUS as db_status
|
||||||
|
, STATUS as status
|
||||||
|
, COALESCE(PLAN, 'Starter') as plan
|
||||||
|
, INDUSTRY as industry
|
||||||
|
, REPRESENTATIVE_NAME as owner
|
||||||
|
, BUSINESS_REGISTRATION_NUMBER as brn
|
||||||
|
, EMAIL as email
|
||||||
|
, COALESCE(TEMPLATES_COUNT, 0) as templates
|
||||||
|
, COALESCE(DB_QUOTA_GB, 20) as db_quota_gb
|
||||||
|
, CREATED_DATE as created
|
||||||
|
, WRITER as writer
|
||||||
|
FROM COMPANY_MNG
|
||||||
|
ORDER BY CREATED_DATE DESC NULLS LAST, COMPANY_CODE
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="updateDbStatus" parameterType="map">
|
||||||
|
UPDATE COMPANY_MNG
|
||||||
|
SET DB_STATUS = #{db_status}
|
||||||
|
WHERE COMPANY_CODE = #{company_code}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
|
||||||
|
<mapper namespace="tenant">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
서브도메인으로 회사 DB 이름 조회.
|
||||||
|
- DB_STATUS='active' 인 회사만 접근 허용.
|
||||||
|
- SUBDOMAIN NULL 이거나 DB_NAME NULL 이면 null 리턴 → META fallback.
|
||||||
|
-->
|
||||||
|
<select id="resolveDbNameBySubdomain" parameterType="map" resultType="string">
|
||||||
|
SELECT DB_NAME
|
||||||
|
FROM COMPANY_MNG
|
||||||
|
WHERE SUBDOMAIN = #{subdomain}
|
||||||
|
AND DB_NAME IS NOT NULL
|
||||||
|
AND DB_STATUS = 'active'
|
||||||
|
LIMIT 1
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# 079 마이그레이션 실행 가이드 — 멀티테넌시 Phase 1
|
||||||
|
|
||||||
|
작성일: 2026-04-24
|
||||||
|
작성자: gbpark
|
||||||
|
관련 문서: `notes/gbpark/2026-04-24-company-db-provisioning-execution-plan.md`
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
회사별 DB 자동 프로비저닝 & 서브도메인 라우팅을 위한 메타 컬럼을 `COMPANY_MNG` 테이블에 추가.
|
||||||
|
|
||||||
|
Phase 1 은 **라우팅 no-op** 단계 — 컬럼만 추가, 실제 라우팅은 Phase 2에서.
|
||||||
|
|
||||||
|
## 추가되는 컬럼
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| `DB_NAME` | VARCHAR(64) | 실제 DB 이름 (예: `qnc_vexplor`). Phase 3 이전엔 NULL 허용 |
|
||||||
|
| `SUBDOMAIN` | VARCHAR(64) | 접속 서브도메인 (예: `qnc`). UNIQUE |
|
||||||
|
| `DB_HOST` | VARCHAR(128) | DB 서버 호스트 (향후 분산 대비). 기본 NULL |
|
||||||
|
| `DB_STATUS` | VARCHAR(20) | `provisioning` / `schema_copied` / `admin_created` / `active` / `failed` / `suspended`. 기본 `active` |
|
||||||
|
|
||||||
|
> 기존 `STATUS` 컬럼('active'/'inactive')과 혼동 주의. `DB_STATUS` 는 **DB 프로비저닝 상태**로 별개 역할.
|
||||||
|
|
||||||
|
## SQL
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 079: COMPANY_MNG 멀티테넌시 메타 컬럼 추가
|
||||||
|
ALTER TABLE COMPANY_MNG
|
||||||
|
ADD COLUMN IF NOT EXISTS DB_NAME VARCHAR(64),
|
||||||
|
ADD COLUMN IF NOT EXISTS SUBDOMAIN VARCHAR(64),
|
||||||
|
ADD COLUMN IF NOT EXISTS DB_HOST VARCHAR(128),
|
||||||
|
ADD COLUMN IF NOT EXISTS DB_STATUS VARCHAR(20) DEFAULT 'active';
|
||||||
|
|
||||||
|
-- 서브도메인 UNIQUE 인덱스 (NULL 다수 허용)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS UX_COMPANY_MNG_SUBDOMAIN
|
||||||
|
ON COMPANY_MNG (SUBDOMAIN)
|
||||||
|
WHERE SUBDOMAIN IS NOT NULL;
|
||||||
|
|
||||||
|
-- 기존 회사들은 DB_STATUS 기본값 'active' 로 채워짐 (DEFAULT 덕분).
|
||||||
|
-- 확인용 조회:
|
||||||
|
-- SELECT COMPANY_CODE, COMPANY_NAME, DB_NAME, SUBDOMAIN, DB_HOST, DB_STATUS FROM COMPANY_MNG;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 실행 방법
|
||||||
|
|
||||||
|
### 방법 1: DBeaver / pgAdmin
|
||||||
|
1. `vexplor` DB 연결
|
||||||
|
2. 위 SQL 블록 복사 & 실행
|
||||||
|
3. 확인 쿼리 돌려서 컬럼 생성 여부 확인
|
||||||
|
|
||||||
|
### 방법 2: psql
|
||||||
|
```bash
|
||||||
|
psql -h 183.99.177.40 -U postgres -d vexplor <<'SQL'
|
||||||
|
ALTER TABLE COMPANY_MNG
|
||||||
|
ADD COLUMN IF NOT EXISTS DB_NAME VARCHAR(64),
|
||||||
|
ADD COLUMN IF NOT EXISTS SUBDOMAIN VARCHAR(64),
|
||||||
|
ADD COLUMN IF NOT EXISTS DB_HOST VARCHAR(128),
|
||||||
|
ADD COLUMN IF NOT EXISTS DB_STATUS VARCHAR(20) DEFAULT 'active';
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS UX_COMPANY_MNG_SUBDOMAIN
|
||||||
|
ON COMPANY_MNG (SUBDOMAIN)
|
||||||
|
WHERE SUBDOMAIN IS NOT NULL;
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
## 확인 쿼리
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1. 컬럼 생성 확인
|
||||||
|
SELECT column_name, data_type, column_default, is_nullable
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'company_mng'
|
||||||
|
AND column_name IN ('db_name', 'subdomain', 'db_host', 'db_status')
|
||||||
|
ORDER BY column_name;
|
||||||
|
|
||||||
|
-- 2. 기존 회사 DB_STATUS 자동 채워짐 확인
|
||||||
|
SELECT COMPANY_CODE, COMPANY_NAME, STATUS, DB_STATUS FROM COMPANY_MNG;
|
||||||
|
|
||||||
|
-- 3. UNIQUE 인덱스 확인
|
||||||
|
SELECT indexname, indexdef FROM pg_indexes
|
||||||
|
WHERE tablename = 'company_mng' AND indexname = 'ux_company_mng_subdomain';
|
||||||
|
```
|
||||||
|
|
||||||
|
## 롤백
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DROP INDEX IF EXISTS UX_COMPANY_MNG_SUBDOMAIN;
|
||||||
|
ALTER TABLE COMPANY_MNG
|
||||||
|
DROP COLUMN IF EXISTS DB_NAME,
|
||||||
|
DROP COLUMN IF EXISTS SUBDOMAIN,
|
||||||
|
DROP COLUMN IF EXISTS DB_HOST,
|
||||||
|
DROP COLUMN IF EXISTS DB_STATUS;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 기존 API/화면 영향
|
||||||
|
|
||||||
|
- `admin.xml` 의 `insertCompany` / `updateCompany` 는 이 컬럼들을 참조 안 함. 신규 컬럼은 기본값/NULL 로 채워짐 → **회귀 없음**.
|
||||||
|
- 기존 회사관리 UI (`frontend/app/(main)/admin/userMng/companyList`) 변경 불필요. Phase 3 에서 마법사 UI 신설 시 함께 수정.
|
||||||
|
|
||||||
|
## 체크리스트
|
||||||
|
|
||||||
|
- [ ] `vexplor` DB 에 위 SQL 실행
|
||||||
|
- [ ] 확인 쿼리 1, 2, 3 모두 통과
|
||||||
|
- [ ] `SELECT * FROM COMPANY_MNG LIMIT 1;` 로 기존 데이터 정상 조회 확인
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# 080 마이그레이션 실행 가이드 — DB_NAME UNIQUE + race condition 방어
|
||||||
|
|
||||||
|
작성일: 2026-04-24
|
||||||
|
작성자: gbpark
|
||||||
|
관련: Codex 리뷰 #4 — ProvisioningController 중복 체크 race condition 방어
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
회사 프로비저닝 중복 체크와 INSERT 사이 **race condition** 을 DB 레벨에서 차단.
|
||||||
|
|
||||||
|
- 현재 Controller: `exists → insert` 두 단계 사이 동시에 같은 subdomain 요청 들어오면 둘 다 통과 → DB 두 건 INSERT.
|
||||||
|
- 해결: `SUBDOMAIN`, `DB_NAME` 에 UNIQUE 제약. INSERT 실패 시 `DuplicateKeyException` → Controller 에서 409 응답.
|
||||||
|
- `SUBDOMAIN` UNIQUE 는 079 마이그레이션에 이미 포함됨. 이번엔 `DB_NAME` 만 추가.
|
||||||
|
|
||||||
|
## SQL
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 080: DB_NAME UNIQUE 제약
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS UX_COMPANY_MNG_DB_NAME
|
||||||
|
ON COMPANY_MNG (DB_NAME)
|
||||||
|
WHERE DB_NAME IS NOT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
> `COMPANY_CODE` 는 이미 테이블 생성 시 PK 이므로 별도 제약 불필요.
|
||||||
|
|
||||||
|
## 실행 방법
|
||||||
|
|
||||||
|
### DBeaver / pgAdmin
|
||||||
|
위 SQL 블록을 `vexplor` DB 에 실행.
|
||||||
|
|
||||||
|
### psql
|
||||||
|
```bash
|
||||||
|
psql -h 183.99.177.40 -U postgres -d vexplor <<'SQL'
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS UX_COMPANY_MNG_DB_NAME
|
||||||
|
ON COMPANY_MNG (DB_NAME)
|
||||||
|
WHERE DB_NAME IS NOT NULL;
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
## 확인 쿼리
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT indexname, indexdef FROM pg_indexes
|
||||||
|
WHERE tablename = 'company_mng'
|
||||||
|
AND indexname IN ('ux_company_mng_subdomain', 'ux_company_mng_db_name');
|
||||||
|
```
|
||||||
|
|
||||||
|
## 롤백
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DROP INDEX IF EXISTS UX_COMPANY_MNG_DB_NAME;
|
||||||
|
```
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 081 마이그레이션 — 회사관리 UI 메타 컬럼
|
||||||
|
|
||||||
|
작성일: 2026-04-24
|
||||||
|
작성자: gbpark
|
||||||
|
관련: Phase 3-B 회사관리 메인 화면(v9 목업) 렌더용 필드 확보
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
목업 accordion row 에서 표시하는 필드 중 `COMPANY_MNG` 에 **상주해야 하는 정적 필드**만 컬럼으로 추가.
|
||||||
|
런타임 계산되는 필드(users / db_size / spark)는 별도 `CompanyStatsService` 가 집계.
|
||||||
|
|
||||||
|
## 추가 컬럼
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 기본값 | 용도 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `PLAN` | VARCHAR(20) | `'Starter'` | 과금 플랜 (`Starter` / `Standard` / `Enterprise`). 목업의 PLAN badge |
|
||||||
|
| `INDUSTRY` | VARCHAR(50) | NULL | 업종 (예: `반도체`, `제조`). 목업 accordion 의 `industry` |
|
||||||
|
| `TEMPLATES_COUNT` | INT | `0` | 프로비저닝 시 복제한 그룹 수(필수 3 + 선택 n). 정적 집계용 |
|
||||||
|
| `DB_QUOTA_GB` | INT | `20` | 회사별 DB 할당량. `db_pct` 계산 기준값 |
|
||||||
|
|
||||||
|
> 목업에 있었지만 **컬럼 안 만든 것** (기존 필드 재사용 또는 런타임):
|
||||||
|
> - `owner` → 기존 `REPRESENTATIVE_NAME` 사용
|
||||||
|
> - `brn` → 기존 `BUSINESS_REGISTRATION_NUMBER` 사용
|
||||||
|
> - `created` → 기존 `CREATED_DATE` 사용
|
||||||
|
> - `writer` → 기존 `WRITER` 사용
|
||||||
|
> - `users` / `active30` / `db_size` / `db_pct` / `spark` → 런타임 집계 (아래 문서 참조)
|
||||||
|
|
||||||
|
## SQL
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 081: 회사관리 UI 메타 컬럼 추가
|
||||||
|
ALTER TABLE COMPANY_MNG
|
||||||
|
ADD COLUMN IF NOT EXISTS PLAN VARCHAR(20) DEFAULT 'Starter',
|
||||||
|
ADD COLUMN IF NOT EXISTS INDUSTRY VARCHAR(50),
|
||||||
|
ADD COLUMN IF NOT EXISTS TEMPLATES_COUNT INT DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS DB_QUOTA_GB INT DEFAULT 20;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -h 183.99.177.40 -U postgres -d vexplor <<'SQL'
|
||||||
|
ALTER TABLE COMPANY_MNG
|
||||||
|
ADD COLUMN IF NOT EXISTS PLAN VARCHAR(20) DEFAULT 'Starter',
|
||||||
|
ADD COLUMN IF NOT EXISTS INDUSTRY VARCHAR(50),
|
||||||
|
ADD COLUMN IF NOT EXISTS TEMPLATES_COUNT INT DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS DB_QUOTA_GB INT DEFAULT 20;
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
## 확인 쿼리
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT column_name, data_type, column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'company_mng'
|
||||||
|
AND column_name IN ('plan', 'industry', 'templates_count', 'db_quota_gb')
|
||||||
|
ORDER BY column_name;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 롤백
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE COMPANY_MNG
|
||||||
|
DROP COLUMN IF EXISTS PLAN,
|
||||||
|
DROP COLUMN IF EXISTS INDUSTRY,
|
||||||
|
DROP COLUMN IF EXISTS TEMPLATES_COUNT,
|
||||||
|
DROP COLUMN IF EXISTS DB_QUOTA_GB;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 기존 API 영향
|
||||||
|
|
||||||
|
- `AdminController.listCompanies` (기존 회사관리 CRUD) — 새 컬럼 무시. 회귀 없음.
|
||||||
|
- `ProvisioningController.create` → `insertCompanyWithTenant` 에 `templates_count` 파라미터 추가됨 (`CompanyProvisioningService.initiate()` 에서 계산).
|
||||||
|
- 신규: `GET /api/admin/provisioning/companies-stats` — 목업 메인 화면 렌더용. 전 필드 + derived 집계 반환.
|
||||||
@@ -3,8 +3,10 @@ FROM eclipse-temurin:21-jdk-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# curl 설치 (헬스체크용)
|
# curl 설치 (헬스체크용) + postgresql16-client (회사 DB 프로비저닝 pg_dump/psql 용).
|
||||||
RUN apk add --no-cache curl
|
# ★ 서버 PG 16.13 과 버전 맞춰야 함 — alpine 기본 postgresql-client 는 18 이라 pg_dump 18 이
|
||||||
|
# "SET transaction_timeout" (17+ 신규) 을 dump 에 포함 → 서버가 거부. 버전 고정 필수.
|
||||||
|
RUN apk add --no-cache curl postgresql16-client
|
||||||
|
|
||||||
# Gradle Wrapper 복사 및 의존성 캐싱
|
# Gradle Wrapper 복사 및 의존성 캐싱
|
||||||
COPY gradlew ./
|
COPY gradlew ./
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ services:
|
|||||||
# JWT_SECRET 은 docker/dev/.env 에서 주입 (이 파일은 git 추적, .env 는 gitignored + syncthing 동기화)
|
# JWT_SECRET 은 docker/dev/.env 에서 주입 (이 파일은 git 추적, .env 는 gitignored + syncthing 동기화)
|
||||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET 환경변수 필요. docker/dev/.env 파일 확인}
|
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET 환경변수 필요. docker/dev/.env 파일 확인}
|
||||||
JWT_EXPIRATION: ${JWT_EXPIRATION:-86400000}
|
JWT_EXPIRATION: ${JWT_EXPIRATION:-86400000}
|
||||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:9772,http://100.126.230.80:9772}
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:9772,http://100.126.230.80:9772,http://*.invyone.com:[*],https://*.invyone.com:[*],http://*.invyone.com,https://*.invyone.com}
|
||||||
FILE_UPLOAD_DIR: ./uploads
|
FILE_UPLOAD_DIR: ./uploads
|
||||||
volumes:
|
volumes:
|
||||||
- ../../backend-spring:/app
|
- ../../backend-spring:/app
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
# INVYONE 멀티테넌시 · 서브도메인 라우팅 아키텍처
|
||||||
|
|
||||||
|
> **DB-per-tenant + 서브도메인 기반 자동 라우팅.**
|
||||||
|
> 회사 하나 = 전용 PostgreSQL DB 하나 = 전용 서브도메인 하나.
|
||||||
|
> 회사 간 데이터 물리적 격리 + 코드 변경 없이 무제한 확장.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
INVYONE 은 **회사(=tenant)별로 독립된 PostgreSQL 데이터베이스**를 가진다. 접속한 서브도메인에 따라 요청이 자동으로 해당 회사 DB 로 라우팅된다.
|
||||||
|
|
||||||
|
```
|
||||||
|
qnc.invyone.com → qnc_vexplor DB
|
||||||
|
kookje.invyone.com → kookje_vexplor DB
|
||||||
|
vexplor (메타 DB) → 회사 정보·라우팅 테이블·SUPER_ADMIN 데이터
|
||||||
|
```
|
||||||
|
|
||||||
|
### 설계 원칙
|
||||||
|
|
||||||
|
- **데이터 격리**: 회사 간 테이블/쿼리 섞일 가능성 원천 차단 (다른 DB).
|
||||||
|
- **운영 격리**: 회사별 백업·복구 독립. 한 회사 DB 장애가 다른 회사에 영향 없음.
|
||||||
|
- **스키마 불변 전제**: 최고 관리자 기준 베이스 템플릿은 DDL 변경 없음. 각 회사 DB 내부에서 컬럼 추가해도 해당 회사 DB 안에만 영향.
|
||||||
|
- **제로 코드 확장**: 회사 추가 시 API 호출 한 번으로 전체 세팅 (DB 생성 + 스키마 복제 + 템플릿 데이터 + 관리자 계정).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 핵심 구성 요소
|
||||||
|
|
||||||
|
### 2.1 백엔드 (Spring)
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|---|---|
|
||||||
|
| `com.erp.tenant.DbContextHolder` | 요청 스레드의 현재 tenant DB 이름 보관 (ThreadLocal) |
|
||||||
|
| `com.erp.tenant.SubdomainResolverFilter` | Host 헤더에서 서브도메인 파싱 → `DbContextHolder.set(dbName)` |
|
||||||
|
| `com.erp.tenant.CompanyResolver` | 서브도메인 → `COMPANY_MNG.DB_NAME` 조회 (ConcurrentHashMap 캐시) |
|
||||||
|
| `com.erp.tenant.TenantRoutingDataSource` | `AbstractRoutingDataSource` 확장. `DbContextHolder` 값으로 실제 DataSource 라우팅 |
|
||||||
|
| `com.erp.tenant.TenantDataSourceFactory` | Hikari 풀 빌더. META=`minIdle:2/max:10`, Tenant=**`minIdle:0`**/max:5 |
|
||||||
|
| `com.erp.tenant.DataSourceConfig` | `@Primary DataSource` 로 Spring Boot auto-config 덮음 |
|
||||||
|
| `com.erp.provisioning.*` | 회사 생성 오케스트레이션 (6단계) + REST API |
|
||||||
|
|
||||||
|
### 2.2 데이터 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
[브라우저: qnc.invyone.com]
|
||||||
|
↓ (HTTP Host 헤더)
|
||||||
|
[SubdomainResolverFilter]
|
||||||
|
↓ subdomain=qnc → CompanyResolver.resolveDbName("qnc")
|
||||||
|
↓ DbContextHolder.set("qnc_vexplor")
|
||||||
|
[JwtAuthenticationFilter]
|
||||||
|
↓
|
||||||
|
[Controller → Service → sqlSession.selectList(...)]
|
||||||
|
↓
|
||||||
|
[TenantRoutingDataSource.determineTargetDataSource()]
|
||||||
|
↓ DbContextHolder.get() = "qnc_vexplor"
|
||||||
|
[HikariDataSource for qnc_vexplor] → 실제 SQL 실행
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 메타 DB 의 역할
|
||||||
|
|
||||||
|
`vexplor` DB (COMPANY_MNG 테이블) 는 **라우팅 룩업 테이블**만 담당:
|
||||||
|
|
||||||
|
| 컬럼 | 용도 |
|
||||||
|
|---|---|
|
||||||
|
| `COMPANY_CODE` | 회사 식별자 (PK) |
|
||||||
|
| `SUBDOMAIN` | 서브도메인 prefix (UNIQUE) |
|
||||||
|
| `DB_NAME` | 실제 tenant DB 이름 (예: `qnc_vexplor`) (UNIQUE) |
|
||||||
|
| `DB_HOST` | tenant DB 호스트 (분산 대비, 현재 단일) |
|
||||||
|
| `DB_STATUS` | `provisioning` / `active` / `failed` / `suspended` |
|
||||||
|
| `PLAN`, `INDUSTRY`, `TEMPLATES_COUNT`, `DB_QUOTA_GB` | UI 메타 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 회사 생성 플로우 (프로비저닝)
|
||||||
|
|
||||||
|
### 3.1 API
|
||||||
|
|
||||||
|
| Method | URL | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/admin/provisioning/table-groups` | 복사 가능한 템플릿 그룹 (필수 3 + 선택 3) |
|
||||||
|
| GET | `/api/admin/provisioning/check` | subdomain / db_prefix / company_code 실시간 검증 |
|
||||||
|
| POST | `/api/admin/provisioning/companies` | 회사 생성 시작 (202 Accepted + provisioning_id) |
|
||||||
|
| GET | `/api/admin/provisioning/status/{id}` | 진행 상태 폴링 (2초 간격 권장) |
|
||||||
|
| GET | `/api/admin/provisioning/companies-stats` | 메인 화면용 회사 목록 + derived 집계 |
|
||||||
|
|
||||||
|
### 3.2 6단계 상태 머신
|
||||||
|
|
||||||
|
```
|
||||||
|
1. REGISTER_META COMPANY_MNG 에 status='provisioning' 선반영
|
||||||
|
2. CREATE_DATABASE CREATE DATABASE "{prefix}_vexplor" (postgres 기본 DB 경유)
|
||||||
|
3. COPY_SCHEMA pg_dump --schema-only | psql (ProcessBuilder 배열 인자)
|
||||||
|
4. COPY_DATA 선택된 그룹 테이블 JDBC 복사 (company_code 필터)
|
||||||
|
5. CREATE_ADMIN {prefix}_admin BCrypt 계정 생성
|
||||||
|
6. FINALIZE db_status='active' + CompanyResolver 캐시 무효화
|
||||||
|
```
|
||||||
|
|
||||||
|
**실패 시 보상**: `DROP DATABASE IF EXISTS` (3회 백오프) + `db_status='failed'` 표시.
|
||||||
|
모든 단계 idempotent. UNIQUE 제약으로 race condition 방어.
|
||||||
|
|
||||||
|
### 3.3 필수 필드 (강빈 스펙)
|
||||||
|
|
||||||
|
최소 **4개**만 필수:
|
||||||
|
|
||||||
|
- `company_code` — `^[A-Z][A-Z0-9_]{2,30}$`
|
||||||
|
- `company_name`
|
||||||
|
- `subdomain` — `^[a-z][a-z0-9-]{2,30}$` (예약어 `www|admin|api|app|static|assets|...` 금지)
|
||||||
|
- `db_prefix` — `^[a-z][a-z0-9_]{2,30}$`
|
||||||
|
|
||||||
|
사업자번호·대표자·이메일 등 선택. 나중에 수정 가능.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 환경별 도메인 전략 ★★★
|
||||||
|
|
||||||
|
서브도메인 기반 멀티테넌시는 **환경마다 DNS 해결 방식이 달라야** 한다. 이 절이 이 문서 전체에서 가장 중요.
|
||||||
|
|
||||||
|
### 4.1 운영 (프로덕션)
|
||||||
|
|
||||||
|
**와일드카드 DNS + Nginx 리버스 프록시.** 한 번 세팅하면 회사 무한히 추가해도 추가 작업 0.
|
||||||
|
|
||||||
|
```
|
||||||
|
[DNS Provider]
|
||||||
|
A *.invyone.com → 운영서버공인IP
|
||||||
|
|
||||||
|
[Nginx conf]
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name *.invyone.com;
|
||||||
|
ssl_certificate .../fullchain.pem; # Let's Encrypt wildcard
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend:3000;
|
||||||
|
proxy_set_header Host $host; # ★ Host 헤더 보존 필수
|
||||||
|
}
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8081;
|
||||||
|
proxy_set_header Host $host; # ★
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Let's Encrypt 와일드카드 인증서**: `certbot certonly --manual --preferred-challenges=dns -d invyone.com -d '*.invyone.com'`
|
||||||
|
- 이후 사용자 `http://qnc.invyone.com` 접속 → Nginx 가 Host 헤더 보존해 backend 에 프록시 → SubdomainResolverFilter → 자동 라우팅.
|
||||||
|
|
||||||
|
### 4.2 로컬 개발 (본인 머신에서 docker up)
|
||||||
|
|
||||||
|
**`*.localhost` 자동 매핑** 사용. RFC 6761 + 최신 브라우저가 자동 해석.
|
||||||
|
|
||||||
|
```
|
||||||
|
브라우저가 test07.localhost:9772 요청
|
||||||
|
→ Chrome/Firefox/Edge 가 자동으로 127.0.0.1:9772 해석
|
||||||
|
→ hosts 편집 0, 설정 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**조건**: 본인 PC 에서 `docker compose up` 으로 프론트/백엔드를 직접 띄워야 함.
|
||||||
|
|
||||||
|
### 4.3 공유 개발 서버 (원격 서버 한 대로 여러 명이 접속)
|
||||||
|
|
||||||
|
로컬 머신이 아닌 원격 IP(예: Tailscale `100.x.x.x`, 사무실 고정 IP 등) 에 도커가 떠 있을 때는 `*.localhost` 안 통함.
|
||||||
|
|
||||||
|
선택지:
|
||||||
|
|
||||||
|
- **옵션 A — `nip.io` / `sslip.io`** (설치 0)
|
||||||
|
```
|
||||||
|
test07.100-126-230-80.nip.io → 100.126.230.80 (자동)
|
||||||
|
```
|
||||||
|
CORS 패턴에 `http://*.<서버IP hyphens>.nip.io:[*]` 추가만 하면 됨.
|
||||||
|
|
||||||
|
- **옵션 B — Windows hosts 수동 편집** (hosts 와일드카드 미지원이라 회사마다 한 줄)
|
||||||
|
```
|
||||||
|
<서버IP> test07.invyone.com
|
||||||
|
<서버IP> qnc.invyone.com
|
||||||
|
```
|
||||||
|
회사 늘어날 때마다 한 줄씩. 테스트 3~4개 회사면 감당 가능.
|
||||||
|
|
||||||
|
- **옵션 C — 개발 서버에 Nginx + DuckDNS/공개 와일드카드 도메인** (운영과 동일 경험)
|
||||||
|
|
||||||
|
### 4.4 요약 표
|
||||||
|
|
||||||
|
| 환경 | 접속 방식 | 설정 필요도 |
|
||||||
|
|---|---|---|
|
||||||
|
| **운영** | 와일드카드 DNS + Nginx + Let's Encrypt wildcard TLS | 최초 1회, 이후 0 |
|
||||||
|
| **로컬 (직접 up)** | `*.localhost` 자동 | 0 |
|
||||||
|
| **원격 공유 개발 서버** | `nip.io` (추천) / hosts 편집 / 개발서버 Nginx | 경우 따라 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. CORS 허용 패턴
|
||||||
|
|
||||||
|
모든 환경에서 브라우저 CORS 가 문제되지 않도록 `.env` 또는 `application.yml` 의 `CORS_ALLOWED_ORIGINS` 에 아래 패턴 **전부** 포함 권장:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:3000,
|
||||||
|
http://localhost:9772,
|
||||||
|
http://localhost:9771,
|
||||||
|
http://*.localhost:[*], # 로컬 직접 up
|
||||||
|
http://*.invyone.com:[*], # 개발 테스트 (hosts 편집 or 실 와일드카드)
|
||||||
|
https://*.invyone.com:[*],
|
||||||
|
http://*.invyone.com,
|
||||||
|
https://*.invyone.com,
|
||||||
|
http://*.[개발서버IP].nip.io:[*] # nip.io 사용 시 환경에 맞게 치환
|
||||||
|
```
|
||||||
|
|
||||||
|
> ★ `setAllowedOriginPatterns` 사용 (단순 `setAllowedOrigins` 아님).
|
||||||
|
> ★ YAML 에서 `[*]` 는 flow sequence 로 해석되므로 **반드시 따옴표로 감싸기**.
|
||||||
|
|
||||||
|
```java
|
||||||
|
// SecurityConfig.java (발췌)
|
||||||
|
config.setAllowedOriginPatterns(patterns); // setAllowedOrigins 아님
|
||||||
|
config.setAllowCredentials(true);
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# application.yml
|
||||||
|
cors:
|
||||||
|
allowed-origins: "${CORS_ALLOWED_ORIGINS:...}" # 반드시 quote
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 프론트엔드 API URL 규칙
|
||||||
|
|
||||||
|
`frontend/lib/api/client.ts` 의 분기 **순서가 중요**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1) 테넌트 서브도메인 → 직접 백엔드 포트 (Next rewrite 우회)
|
||||||
|
if (currentHost.endsWith(".invyone.com")) {
|
||||||
|
return `http://${currentHost}:8083/api`;
|
||||||
|
}
|
||||||
|
// 2) 프로덕션 메인 도메인
|
||||||
|
if (currentHost === "v1.invion.com") return "https://api.invion.com/api";
|
||||||
|
// 3) NEXT_PUBLIC_API_URL (docker-compose 주입)
|
||||||
|
// 4) localhost 기본값
|
||||||
|
```
|
||||||
|
|
||||||
|
> ★ NEXT_PUBLIC_API_URL=/api 같은 rewrite 방식은 **Host 헤더를 변조**해 서브도메인 파싱이 실패한다. 테넌트 도메인은 반드시 직접 포트로.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 운영 배포 체크리스트
|
||||||
|
|
||||||
|
Git push 한 번으로 자동 배포되는 구조라도, 아래 3가지는 **Git 바깥**이라 수동으로 확인해야 한다:
|
||||||
|
|
||||||
|
- [ ] `.env` 의 `CORS_ALLOWED_ORIGINS` 에 **`*.invyone.com:[*]` 패턴 포함**
|
||||||
|
- [ ] DB 마이그레이션 `RUN_079`, `RUN_080`, `RUN_081` 운영 DB 에서 **1회 실행**
|
||||||
|
- [ ] Docker 이미지 재빌드 (Dockerfile 의 `postgresql16-client` 반영) — `docker compose build --no-cache backend-spring`
|
||||||
|
- [ ] `tenant.provisioning.require-super-admin=true` (프로덕션에선 필수, 개발은 false)
|
||||||
|
- [ ] (최초 1회) 와일드카드 DNS A 레코드 + Nginx wildcard server_name + Let's Encrypt wildcard 인증서
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 관련 마이그레이션
|
||||||
|
|
||||||
|
| 마이그레이션 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| `db/migrations/RUN_079_MIGRATION.md` | `COMPANY_MNG` 에 `DB_NAME`, `SUBDOMAIN`, `DB_HOST`, `DB_STATUS` 컬럼 + SUBDOMAIN UNIQUE 인덱스 |
|
||||||
|
| `db/migrations/RUN_080_MIGRATION.md` | `DB_NAME` UNIQUE 인덱스 (race condition 방어) |
|
||||||
|
| `db/migrations/RUN_081_MIGRATION.md` | `PLAN`, `INDUSTRY`, `TEMPLATES_COUNT`, `DB_QUOTA_GB` (회사관리 UI 메타) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 설계/실행 노트 (원본)
|
||||||
|
|
||||||
|
상세 설계 문서는 아래 참조:
|
||||||
|
|
||||||
|
- `notes/gbpark/2026-04-24-company-db-provisioning-execution-plan.md` — 초기 설계 + Codex 리뷰 반영 + 실행 체크리스트
|
||||||
|
- `notes/gbpark/2026-04-24-company-mgmt-ui-schema.md` — 회사관리 UI 필드 ↔ COMPANY_MNG 컬럼 ↔ derived 집계 매핑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 금지사항 (요약)
|
||||||
|
|
||||||
|
- ❌ 테넌트 도메인에서 `NEXT_PUBLIC_API_URL=/api` rewrite 쓰기 (Host 헤더 소실)
|
||||||
|
- ❌ `setAllowedOrigins` 로만 CORS 설정 (와일드카드 매칭 안 됨 → `setAllowedOriginPatterns` 사용)
|
||||||
|
- ❌ `application.yml` 에 `[*]` 를 따옴표 없이 노출 (YAML sequence 로 해석됨)
|
||||||
|
- ❌ `pg_dump` 버전과 서버 버전 불일치 (`postgresql16-client` 등 서버 버전에 맞춰 고정)
|
||||||
|
- ❌ DDL 직접 문자열 concat (회사명·컬럼명은 반드시 화이트리스트 정규식 검증 후 큰따옴표 quote)
|
||||||
|
- ❌ 회사 DB 수만큼 Hikari `minIdle` 증가 (★ 회사 DB 풀은 반드시 `minIdle=0`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 무한 확장 체크
|
||||||
|
|
||||||
|
현재 아키텍처는 회사 N 개일 때:
|
||||||
|
|
||||||
|
| N | 영향 | 조치 |
|
||||||
|
|---|---|---|
|
||||||
|
| ~20 | 없음 | 0 |
|
||||||
|
| ~100 | Postgres `max_connections` 기본값(100) 근접 | `max_connections=500` 증가 |
|
||||||
|
| ~500 | 단일 Postgres 인스턴스 부담 | `COMPANY_MNG.DB_HOST` 로 여러 서버 분산 (이미 컬럼 준비됨) |
|
||||||
|
| ∞ | 풀 메모리 누적 | tenant pool LRU eviction (Phase 6 에서 자동화) |
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { FileText, Download, Plus, Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { getCompaniesStats } from "@/lib/api/provisioning";
|
||||||
|
import CompanyStatsStrip from "@/components/admin/provisioning/CompanyStatsStrip";
|
||||||
|
import CompanyAccordionRow from "@/components/admin/provisioning/CompanyAccordionRow";
|
||||||
|
import Wizard from "@/components/admin/provisioning/wizard/Wizard";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SUPER_ADMIN — 회사 프로비저닝 / 서브도메인 관리.
|
||||||
|
* 경로: /admin/sysMng/subdomainList
|
||||||
|
*
|
||||||
|
* 기존 회사 관리(/admin/userMng/companyList) 는 일반 CRUD 그대로 유지.
|
||||||
|
* 이 페이지는 "테넌트 DB 생성 + 서브도메인 라우팅" 전용.
|
||||||
|
*/
|
||||||
|
export default function SubdomainListPage() {
|
||||||
|
const [openKey, setOpenKey] = useState<string | null>(null);
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [filter, setFilter] = useState<"all" | "active" | "provisioning" | "inactive" | "failed">("all");
|
||||||
|
const [planFilter, setPlanFilter] = useState("all");
|
||||||
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const { data: rows = [], isLoading, refetch, dataUpdatedAt } = useQuery({
|
||||||
|
queryKey: ["companies-stats"],
|
||||||
|
queryFn: getCompaniesStats,
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
// provisioning 중인 회사 있으면 3초 폴링, 없으면 30초
|
||||||
|
const hasProvisioning = Array.isArray(query.state.data)
|
||||||
|
? query.state.data.some((r: any) => r.db_status === "provisioning")
|
||||||
|
: false;
|
||||||
|
return hasProvisioning ? 3_000 : 30_000;
|
||||||
|
},
|
||||||
|
staleTime: 2_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() =>
|
||||||
|
rows
|
||||||
|
.filter((r) => filter === "all" || (r.db_status || r.status) === filter)
|
||||||
|
.filter((r) => planFilter === "all" || (r.plan || "Starter") === planFilter)
|
||||||
|
.filter((r) => {
|
||||||
|
if (!q) return true;
|
||||||
|
const needle = q.toLowerCase();
|
||||||
|
return (
|
||||||
|
(r.company_name || "").toLowerCase().includes(needle) ||
|
||||||
|
(r.subdomain || "").toLowerCase().includes(needle) ||
|
||||||
|
(r.company_code || "").toLowerCase().includes(needle)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[rows, filter, planFilter, q],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
|
const safePage = Math.min(page, totalPages);
|
||||||
|
const pageStart = (safePage - 1) * PAGE_SIZE;
|
||||||
|
const paged = filtered.slice(pageStart, pageStart + PAGE_SIZE);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [q, filter, planFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (page > totalPages) setPage(totalPages);
|
||||||
|
}, [page, totalPages]);
|
||||||
|
|
||||||
|
const activeCount = rows.filter((r) => r.db_status === "active").length;
|
||||||
|
const provisCount = rows.filter((r) => r.db_status === "provisioning").length;
|
||||||
|
const inactCount = rows.filter((r) => r.db_status === "inactive" || r.status === "inactive").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1.1rem",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
background: "var(--v5-bg)",
|
||||||
|
color: "var(--v5-text)",
|
||||||
|
fontFamily: "var(--v5-font-sans)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
@keyframes pulsedot { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||||
|
[data-accrow]:last-child { border-bottom: 0 !important; }
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* 페이지 헤더 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: "0.85rem",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "0.68rem", color: "var(--v5-text-sec)", fontWeight: 500, marginBottom: 3 }}>
|
||||||
|
<b style={{ color: "var(--v5-text)" }}>홈</b> · 관리자 · 시스템관리 · 회사 프로비저닝
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "1.15rem", fontWeight: 800, letterSpacing: "-0.02em", color: "var(--v5-text)" }}>
|
||||||
|
회사 프로비저닝
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
color: "var(--v5-text-sec)",
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Invy.one 플랫폼에 등록된 테넌트 회사 · 서브도메인 라우팅
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "0.4rem" }}>
|
||||||
|
<HeaderBtn onClick={() => refetch()} icon={<RefreshCw size={11} strokeWidth={1.75} />}>
|
||||||
|
새로고침
|
||||||
|
</HeaderBtn>
|
||||||
|
<HeaderBtn icon={<FileText size={11} strokeWidth={1.75} />}>감사 로그</HeaderBtn>
|
||||||
|
<HeaderBtn icon={<Download size={11} strokeWidth={1.75} />}>CSV 내보내기</HeaderBtn>
|
||||||
|
<HeaderBtn
|
||||||
|
variant="primary"
|
||||||
|
icon={<Plus size={11} strokeWidth={1.75} />}
|
||||||
|
onClick={() => setWizardOpen(true)}
|
||||||
|
>
|
||||||
|
회사 생성
|
||||||
|
</HeaderBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* stats strip */}
|
||||||
|
<CompanyStatsStrip rows={rows} />
|
||||||
|
|
||||||
|
{/* toolbar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginLeft: "-1.1rem",
|
||||||
|
marginRight: "-1.1rem",
|
||||||
|
padding: "0.55rem 2rem",
|
||||||
|
borderTop: "1px solid var(--v5-border)",
|
||||||
|
borderBottom: "1px solid var(--v5-border)",
|
||||||
|
background: "var(--v5-surface-solid)",
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.45rem",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ position: "relative", flex: "0 0 260px" }}>
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="회사명 · subdomain · company_code 검색"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.4rem 0.45rem 0.4rem 1.7rem",
|
||||||
|
background: "var(--v5-surface-hover)",
|
||||||
|
border: "1px solid var(--v5-border)",
|
||||||
|
borderRadius: 0,
|
||||||
|
color: "var(--v5-text)",
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 8,
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Search size={11} strokeWidth={1.75} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value as any)}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
|
<option value="all">전체 상태</option>
|
||||||
|
<option value="active">활성</option>
|
||||||
|
<option value="provisioning">생성 중</option>
|
||||||
|
<option value="failed">실패</option>
|
||||||
|
<option value="inactive">비활성</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select value={planFilter} onChange={(e) => setPlanFilter(e.target.value)} style={selectStyle}>
|
||||||
|
<option value="all">전체 플랜</option>
|
||||||
|
<option value="Enterprise">Enterprise</option>
|
||||||
|
<option value="Standard">Standard</option>
|
||||||
|
<option value="Starter">Starter</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
<div style={{ fontSize: "0.66rem", color: "var(--v5-text-sec)", fontFamily: "var(--v5-font-mono)", fontWeight: 500 }}>
|
||||||
|
활성 <b style={{ color: "rgb(var(--v5-green-rgb))" }}>{activeCount}</b>
|
||||||
|
<span style={{ margin: "0 6px", opacity: 0.4 }}>·</span>
|
||||||
|
생성중 <b style={{ color: "var(--v5-primary)" }}>{provisCount}</b>
|
||||||
|
<span style={{ margin: "0 6px", opacity: 0.4 }}>·</span>
|
||||||
|
비활성 <b style={{ color: "var(--v5-text)" }}>{inactCount}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* list header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginLeft: "-1.1rem",
|
||||||
|
marginRight: "-1.1rem",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "14px 1fr 110px 100px 80px 18px",
|
||||||
|
gap: "0.85rem",
|
||||||
|
padding: "0.65rem 2rem",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
color: "var(--v5-text)",
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
fontWeight: 700,
|
||||||
|
background: "var(--v5-surface-hover)",
|
||||||
|
borderBottom: "1px solid var(--v5-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
<span>회사 / 서브도메인</span>
|
||||||
|
<span>사용자</span>
|
||||||
|
<span>DB 용량</span>
|
||||||
|
<span>상태</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* rows (scrollable when long) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginLeft: "-1.1rem",
|
||||||
|
marginRight: "-1.1rem",
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflowY: "auto",
|
||||||
|
borderBottom: "1px solid var(--v5-border)",
|
||||||
|
background: "var(--v5-surface-solid)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "2.2rem",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "var(--v5-text-sec)",
|
||||||
|
fontSize: "0.76rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
로딩 중...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading &&
|
||||||
|
paged.map((r) => (
|
||||||
|
<CompanyAccordionRow
|
||||||
|
key={r.company_code}
|
||||||
|
r={r}
|
||||||
|
open={r.company_code === openKey}
|
||||||
|
onToggle={() => setOpenKey(r.company_code === openKey ? null : r.company_code)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!isLoading && filtered.length === 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "2.2rem",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "var(--v5-text-sec)",
|
||||||
|
fontSize: "0.76rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
조건에 맞는 회사가 없습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* footer + pagination */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "0.65rem",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
fontSize: "0.66rem",
|
||||||
|
color: "var(--v5-text-sec)",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
flexShrink: 0,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{filtered.length === 0 ? 0 : pageStart + 1}-{pageStart + paged.length} / {filtered.length} rows
|
||||||
|
{filtered.length !== rows.length && (
|
||||||
|
<span style={{ opacity: 0.6 }}> · 전체 {rows.length}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Pagination page={safePage} totalPages={totalPages} onChange={setPage} />
|
||||||
|
|
||||||
|
<span>
|
||||||
|
last sync ·{" "}
|
||||||
|
{new Date(dataUpdatedAt).toLocaleString("ko-KR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{wizardOpen && <Wizard onClose={() => setWizardOpen(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeaderBtn({
|
||||||
|
children,
|
||||||
|
icon,
|
||||||
|
variant = "secondary",
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
variant?: "primary" | "secondary";
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
const isPrimary = variant === "primary";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.4rem",
|
||||||
|
height: 30,
|
||||||
|
padding: "0 0.75rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
border: `1px solid ${isPrimary ? "var(--v5-primary)" : "var(--v5-border)"}`,
|
||||||
|
background: isPrimary ? "var(--v5-primary)" : "var(--v5-surface-solid)",
|
||||||
|
color: isPrimary ? "#fff" : "var(--v5-text)",
|
||||||
|
boxShadow: isPrimary ? "0 0 18px rgba(var(--v5-primary-rgb), 0.22)" : "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
transition: "all 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pagination({
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
onChange: (p: number) => void;
|
||||||
|
}) {
|
||||||
|
if (totalPages <= 1) return <span />;
|
||||||
|
|
||||||
|
const windowSize = 5;
|
||||||
|
let start = Math.max(1, page - Math.floor(windowSize / 2));
|
||||||
|
let end = Math.min(totalPages, start + windowSize - 1);
|
||||||
|
start = Math.max(1, end - windowSize + 1);
|
||||||
|
const pages: number[] = [];
|
||||||
|
for (let i = start; i <= end; i++) pages.push(i);
|
||||||
|
|
||||||
|
const btn = (key: string, label: React.ReactNode, p: number, disabled: boolean, active = false): React.ReactNode => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => !disabled && onChange(p)}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{
|
||||||
|
minWidth: 24,
|
||||||
|
height: 24,
|
||||||
|
padding: "0 7px",
|
||||||
|
border: `1px solid ${active ? "var(--v5-primary)" : "var(--v5-border)"}`,
|
||||||
|
background: active ? "var(--v5-primary)" : "var(--v5-surface-solid)",
|
||||||
|
color: active ? "#fff" : disabled ? "var(--v5-text-muted)" : "var(--v5-text)",
|
||||||
|
fontSize: "0.68rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: "inherit",
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
opacity: disabled ? 0.45 : 1,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "inline-flex", alignItems: "center", gap: 3 }}>
|
||||||
|
{btn("prev", <ChevronLeft size={11} strokeWidth={1.75} />, page - 1, page <= 1)}
|
||||||
|
{start > 1 && btn("p-1", 1, 1, false, page === 1)}
|
||||||
|
{start > 2 && <span key="lead-dots" style={{ padding: "0 2px", color: "var(--v5-text-muted)" }}>…</span>}
|
||||||
|
{pages.map((p) => btn(`p-${p}`, p, p, false, p === page))}
|
||||||
|
{end < totalPages - 1 && <span key="tail-dots" style={{ padding: "0 2px", color: "var(--v5-text-muted)" }}>…</span>}
|
||||||
|
{end < totalPages && btn(`p-${totalPages}`, totalPages, totalPages, false, page === totalPages)}
|
||||||
|
{btn("next", <ChevronRight size={11} strokeWidth={1.75} />, page + 1, page >= totalPages)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectStyle: React.CSSProperties = {
|
||||||
|
padding: "0.4rem 0.5rem",
|
||||||
|
background: "var(--v5-surface-hover)",
|
||||||
|
border: "1px solid var(--v5-border)",
|
||||||
|
borderRadius: 0,
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
color: "var(--v5-text)",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
fontWeight: 500,
|
||||||
|
};
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
MoreHorizontal,
|
||||||
|
Info,
|
||||||
|
Users,
|
||||||
|
Layers,
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowUpRight,
|
||||||
|
KeyRound,
|
||||||
|
Copy,
|
||||||
|
PauseCircle,
|
||||||
|
Trash2,
|
||||||
|
UserPlus,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
import StatusDot from "./StatusDot";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 회사 row. 클릭 시 accordion 펼쳐지며 탭 4개 (개요/구성원/템플릿/위험영역) 표시.
|
||||||
|
* 목업 v9 AccRow 포팅. API 데이터 스키마는 /companies-stats 응답.
|
||||||
|
*/
|
||||||
|
export default function CompanyAccordionRow({
|
||||||
|
r,
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
r: Record<string, any>;
|
||||||
|
open: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const [tab, setTab] = useState<"overview" | "members" | "templates" | "danger">("overview");
|
||||||
|
|
||||||
|
const sub = r.subdomain || "";
|
||||||
|
const name = r.company_name || r.name || r.company_code;
|
||||||
|
const plan = (r.plan || "Starter").toString();
|
||||||
|
const dbName = r.db_name || `${sub}_vexplor`;
|
||||||
|
const dbPct = Number(r.db_pct) || 0;
|
||||||
|
const users = Number(r.users) || 0;
|
||||||
|
const active30 = Number(r.active30) || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-accrow
|
||||||
|
style={{
|
||||||
|
background: open ? "var(--v5-surface-hover)" : "var(--v5-surface-solid)",
|
||||||
|
borderBottom: "1px solid var(--v5-border)",
|
||||||
|
position: "relative",
|
||||||
|
transition: "background 0.12s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 2,
|
||||||
|
background: "var(--v5-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
background: "transparent",
|
||||||
|
border: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "0.6rem 2rem 0.6rem 2rem",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "14px 1fr 110px 100px 80px 18px",
|
||||||
|
gap: "0.85rem",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{open ? (
|
||||||
|
<ChevronDown size={12} color="var(--v5-text-muted)" strokeWidth={1.75} />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={12} color="var(--v5-text-muted)" strokeWidth={1.75} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
|
||||||
|
<span style={{ fontSize: "0.82rem", fontWeight: 700, letterSpacing: "-0.01em", color: "var(--v5-text)" }}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.58rem",
|
||||||
|
padding: "1px 6px",
|
||||||
|
borderRadius: 3,
|
||||||
|
background: "var(--v5-border)",
|
||||||
|
color: "var(--v5-text)",
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{plan.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.64rem",
|
||||||
|
color: "var(--v5-text-sec)",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
marginTop: 2,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--v5-primary)" }}>{sub ? `${sub}.invyone.com` : "—"}</span>
|
||||||
|
<span style={{ opacity: 0.4 }}>·</span>
|
||||||
|
<span>{r.company_code}</span>
|
||||||
|
{r.industry && (
|
||||||
|
<>
|
||||||
|
<span style={{ opacity: 0.4 }}>·</span>
|
||||||
|
<span>{r.industry}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={labelSm}>사용자</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
color: "var(--v5-text)",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{users}
|
||||||
|
<span style={{ fontSize: "0.62rem", color: "var(--v5-text-sec)", fontWeight: 500, marginLeft: 2 }}>명</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.6rem", color: "var(--v5-text-sec)", fontFamily: "var(--v5-font-mono)", marginTop: 1 }}>
|
||||||
|
30일 활성 {active30}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={labelSm}>DB</div>
|
||||||
|
<div style={{ fontSize: "0.66rem", fontFamily: "var(--v5-font-mono)", color: "var(--v5-text)", fontWeight: 600, marginBottom: 3 }}>
|
||||||
|
{r.db_size || "—"}
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 2, background: "var(--v5-border)", borderRadius: 1, overflow: "hidden" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(dbPct, 100)}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: dbPct > 70 ? "var(--v5-amber)" : "var(--v5-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatusDot status={r.db_status || r.status} />
|
||||||
|
|
||||||
|
<MoreHorizontal size={12} color="var(--v5-text-muted)" strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div style={{ padding: "0 2rem 0.9rem 3rem" }}>
|
||||||
|
{/* tabs */}
|
||||||
|
<div style={{ display: "flex", gap: 0, borderBottom: "1px solid var(--v5-border)", marginBottom: "0.7rem" }}>
|
||||||
|
{([
|
||||||
|
["overview", "개요", Info],
|
||||||
|
["members", "구성원", Users],
|
||||||
|
["templates", "템플릿", Layers],
|
||||||
|
["danger", "위험 영역", AlertTriangle],
|
||||||
|
] as const).map(([k, l, IconC]) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setTab(k);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "0.45rem 0.7rem 0.5rem",
|
||||||
|
background: "transparent",
|
||||||
|
border: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: tab === k ? "var(--v5-primary)" : "var(--v5-text-sec)",
|
||||||
|
borderBottom: `2px solid ${tab === k ? "var(--v5-primary)" : "transparent"}`,
|
||||||
|
marginBottom: -1,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconC size={11} strokeWidth={1.75} /> {l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontSize: "0.6rem",
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
padding: "0.4rem 0",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.created && <>생성 {formatDate(r.created)}</>}
|
||||||
|
{r.writer && <> · writer {r.writer}</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "overview" && (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1.3fr 1fr", gap: "1.2rem" }}>
|
||||||
|
{/* 기본정보 */}
|
||||||
|
<div>
|
||||||
|
<div style={sectionTitle}>기본 정보</div>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "120px 1fr", rowGap: "0.5rem", fontSize: "0.72rem" }}>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["회사 코드", r.company_code, true],
|
||||||
|
["회사명", name, false],
|
||||||
|
["서브도메인", sub ? <SubdomainLine sub={sub} /> : "—", true],
|
||||||
|
["DB명", dbName, true],
|
||||||
|
["사업자번호", r.brn || "—", true],
|
||||||
|
["플랜", plan, false],
|
||||||
|
["업종", r.industry || "—", false],
|
||||||
|
["대표자", r.owner || "—", false],
|
||||||
|
] as const
|
||||||
|
).map(([l, v, mono], i) => (
|
||||||
|
<div key={i} style={{ display: "contents" }}>
|
||||||
|
<span style={{ color: "var(--v5-text-sec)", fontSize: "0.68rem", fontWeight: 500 }}>{l}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "var(--v5-text)",
|
||||||
|
fontFamily: mono ? "var(--v5-font-mono)" : "inherit",
|
||||||
|
fontSize: mono ? "0.7rem" : "0.72rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v as any}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 운영지표 + 액션 */}
|
||||||
|
<div>
|
||||||
|
<div style={sectionTitle}>운영 지표</div>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.4rem" }}>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["총 사용자", users],
|
||||||
|
["30일 활성", active30],
|
||||||
|
["DB", r.db_size || "—"],
|
||||||
|
["설치 템플릿", r.templates || 0],
|
||||||
|
] as const
|
||||||
|
).map(([l, v], i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
padding: "0.45rem 0.55rem",
|
||||||
|
background: "var(--v5-surface-solid)",
|
||||||
|
border: "1px solid var(--v5-border)",
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.6rem",
|
||||||
|
color: "var(--v5-text-sec)",
|
||||||
|
marginBottom: 3,
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{l}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.92rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "var(--v5-text)",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
letterSpacing: "-0.01em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 5, marginTop: "0.55rem", flexWrap: "wrap" }}>
|
||||||
|
<ABtn
|
||||||
|
icon={<ArrowUpRight size={11} strokeWidth={1.75} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (sub) window.open(`http://${sub}.invyone.com`, "_blank");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
테넌트 접속
|
||||||
|
</ABtn>
|
||||||
|
<ABtn icon={<KeyRound size={11} strokeWidth={1.75} />}>관리자 계정</ABtn>
|
||||||
|
<ABtn icon={<Copy size={11} strokeWidth={1.75} />}>템플릿 재복제</ABtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "members" && (
|
||||||
|
<EmptyNote>
|
||||||
|
<UserPlus size={14} strokeWidth={1.75} /> 구성원 목록은 회사별 tenant DB 에서 실시간 조회로 표시 예정 (Phase 4). 현재
|
||||||
|
총 {users}명.
|
||||||
|
</EmptyNote>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "templates" && (
|
||||||
|
<EmptyNote>
|
||||||
|
<Layers size={14} strokeWidth={1.75} /> 이 회사에 설치된 템플릿 그룹 {r.templates || 0}개. 상세 보기는 Phase 4 에서.
|
||||||
|
</EmptyNote>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "danger" && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
t: "회사 비활성화",
|
||||||
|
d: "사용자 로그인 차단 · 데이터 보존 · 언제든 재활성 가능",
|
||||||
|
b: "비활성화",
|
||||||
|
c: "var(--v5-amber)",
|
||||||
|
Icon: PauseCircle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
t: "관리자 비밀번호 재설정",
|
||||||
|
d: "무작위 비밀번호 재설정 · 1회 표시",
|
||||||
|
b: "재설정",
|
||||||
|
c: "var(--v5-primary)",
|
||||||
|
Icon: KeyRound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
t: "회사 영구 삭제",
|
||||||
|
d: "회사 + 테넌트 DB 영구 삭제 · 복구 불가",
|
||||||
|
b: "삭제 예약",
|
||||||
|
c: "var(--v5-red)",
|
||||||
|
Icon: Trash2,
|
||||||
|
},
|
||||||
|
].map((row, i) => {
|
||||||
|
const IconC = row.Icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "20px 1fr 100px",
|
||||||
|
gap: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "0.55rem 0.7rem",
|
||||||
|
background: "var(--v5-surface-solid)",
|
||||||
|
border: "1px solid var(--v5-border)",
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: row.c }}>
|
||||||
|
<IconC size={14} strokeWidth={1.75} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "var(--v5-text)" }}>{row.t}</div>
|
||||||
|
<div style={{ fontSize: "0.6rem", color: "var(--v5-text-muted)", marginTop: 2 }}>{row.d}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
title="Phase 4 에서 구현"
|
||||||
|
style={{
|
||||||
|
height: 26,
|
||||||
|
padding: "0 0.6rem",
|
||||||
|
borderRadius: 5,
|
||||||
|
border: `1px solid ${row.c}`,
|
||||||
|
background: "transparent",
|
||||||
|
color: row.c,
|
||||||
|
fontSize: "0.62rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: "not-allowed",
|
||||||
|
opacity: 0.5,
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.b}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelSm: React.CSSProperties = {
|
||||||
|
fontSize: "0.58rem",
|
||||||
|
color: "var(--v5-text-sec)",
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontWeight: 700,
|
||||||
|
marginBottom: 2,
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionTitle: React.CSSProperties = {
|
||||||
|
fontSize: "0.62rem",
|
||||||
|
color: "var(--v5-text-sec)",
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontWeight: 700,
|
||||||
|
marginBottom: "0.55rem",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
};
|
||||||
|
|
||||||
|
function SubdomainLine({ sub }: { sub: string }) {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
<span style={{ color: "var(--v5-primary)" }}>{sub}</span>
|
||||||
|
<span style={{ color: "var(--v5-text-muted)" }}>.invyone.com</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ABtn({
|
||||||
|
children,
|
||||||
|
icon,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.(e);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
|
height: 26,
|
||||||
|
padding: "0 0.6rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: "0.64rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
border: "1px solid var(--v5-border)",
|
||||||
|
background: "var(--v5-surface-solid)",
|
||||||
|
color: "var(--v5-text)",
|
||||||
|
cursor: "pointer",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
transition: "all 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyNote({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
border: "1px dashed var(--v5-border)",
|
||||||
|
borderRadius: 6,
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
fontSize: "0.66rem",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 6,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(v: any): string {
|
||||||
|
if (!v) return "—";
|
||||||
|
try {
|
||||||
|
const d = typeof v === "number" ? new Date(v) : new Date(String(v));
|
||||||
|
if (isNaN(d.getTime())) return String(v);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
} catch {
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { TrendingUp } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상단 KPI strip — 4 카드 (전체 회사 / 활성률 / 총 사용자 / DB 사용량).
|
||||||
|
* 모든 카드가 같은 grid row 구조 공유해서 시각적 일관성 유지.
|
||||||
|
*/
|
||||||
|
export default function CompanyStatsStrip({ rows }: { rows: Record<string, any>[] }) {
|
||||||
|
const total = rows.length;
|
||||||
|
const active = rows.filter((r) => r.db_status === "active").length;
|
||||||
|
const inact = rows.filter((r) => r.db_status === "inactive" || r.status === "inactive").length;
|
||||||
|
const provis = rows.filter((r) => r.db_status === "provisioning").length;
|
||||||
|
|
||||||
|
const users = rows.reduce((s, r) => s + (Number(r.users) || 0), 0);
|
||||||
|
const active30 = rows.reduce((s, r) => s + (Number(r.active30) || 0), 0);
|
||||||
|
const pctActive = total > 0 ? Math.round((active / total) * 100) : 0;
|
||||||
|
const pctEngaged = users > 0 ? Math.round((active30 / users) * 100) : 0;
|
||||||
|
|
||||||
|
const dbBytes = rows.reduce((s, r) => s + (Number(r.db_size_bytes) || 0), 0);
|
||||||
|
const dbGB = dbBytes / (1024 * 1024 * 1024);
|
||||||
|
const dbQuotaGB = 20; // 기본 quota (추후 합계로 변경 가능)
|
||||||
|
const pctDB = Math.round((dbGB / dbQuotaGB) * 100);
|
||||||
|
|
||||||
|
const cardStyle: React.CSSProperties = {
|
||||||
|
padding: "0.75rem 0.85rem",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateRows: "16px 32px 6px 16px",
|
||||||
|
rowGap: 8,
|
||||||
|
background: "var(--v5-surface-solid)",
|
||||||
|
border: "1px solid var(--v5-border)",
|
||||||
|
borderRadius: 10,
|
||||||
|
};
|
||||||
|
const label: React.CSSProperties = {
|
||||||
|
fontSize: "0.68rem",
|
||||||
|
color: "var(--v5-text-sec)",
|
||||||
|
fontWeight: 700,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
letterSpacing: "-0.005em",
|
||||||
|
};
|
||||||
|
const bigRow: React.CSSProperties = { display: "flex", alignItems: "baseline", gap: 6 };
|
||||||
|
const bigNum: React.CSSProperties = {
|
||||||
|
fontSize: "1.75rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "var(--v5-text)",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
letterSpacing: "-0.03em",
|
||||||
|
lineHeight: 1,
|
||||||
|
};
|
||||||
|
const unit: React.CSSProperties = { fontSize: "0.68rem", color: "var(--v5-text-sec)", fontWeight: 500 };
|
||||||
|
const bar: React.CSSProperties = {
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
background: "var(--v5-border)",
|
||||||
|
alignSelf: "center",
|
||||||
|
};
|
||||||
|
const sub: React.CSSProperties = {
|
||||||
|
fontSize: "0.64rem",
|
||||||
|
color: "var(--v5-text-sec)",
|
||||||
|
fontWeight: 500,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(4, 1fr)",
|
||||||
|
gap: "0.55rem",
|
||||||
|
marginBottom: "0.8rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 1 · 전체 회사 */}
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<div style={label}>전체 테넌트 회사</div>
|
||||||
|
<div style={bigRow}>
|
||||||
|
<span style={bigNum}>{total}</span>
|
||||||
|
<span style={unit}>개 회사</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...bar, display: "flex" }}>
|
||||||
|
<div style={{ flex: active || 0.0001, background: "rgb(var(--v5-green-rgb))" }} />
|
||||||
|
<div style={{ flex: provis || 0.0001, background: "var(--v5-primary)" }} />
|
||||||
|
<div style={{ flex: inact || 0.0001, background: "var(--v5-text-muted)", opacity: 0.35 }} />
|
||||||
|
</div>
|
||||||
|
<div style={sub}>
|
||||||
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
|
||||||
|
<span style={{ width: 5, height: 5, borderRadius: "50%", background: "rgb(var(--v5-green-rgb))" }} />
|
||||||
|
활성 <b style={{ color: "var(--v5-text)", fontFamily: "var(--v5-font-mono)" }}>{active}</b>
|
||||||
|
</span>
|
||||||
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
|
||||||
|
<span style={{ width: 5, height: 5, borderRadius: "50%", background: "var(--v5-text-muted)", opacity: 0.5 }} />
|
||||||
|
비활성 <b style={{ color: "var(--v5-text)", fontFamily: "var(--v5-font-mono)" }}>{inact}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2 · 활성률 */}
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<div style={label}>활성률</div>
|
||||||
|
<div style={bigRow}>
|
||||||
|
<span style={bigNum}>{pctActive}</span>
|
||||||
|
<span style={{ ...unit, fontSize: "0.85rem", fontWeight: 600 }}>%</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.56rem",
|
||||||
|
color: "rgb(var(--v5-green-rgb))",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
marginLeft: "auto",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrendingUp size={9} strokeWidth={1.75} />
|
||||||
|
기준 30일
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={bar}>
|
||||||
|
<div style={{ width: `${pctActive}%`, height: "100%", background: "rgb(var(--v5-green-rgb))" }} />
|
||||||
|
</div>
|
||||||
|
<div style={sub}>
|
||||||
|
활성 <b style={{ color: "var(--v5-text)", fontFamily: "var(--v5-font-mono)" }}>{active}</b> / 전체 {total}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3 · 총 사용자 */}
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<div style={label}>총 사용자</div>
|
||||||
|
<div style={bigRow}>
|
||||||
|
<span style={bigNum}>{users}</span>
|
||||||
|
<span style={unit}>명</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.56rem",
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
marginLeft: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
30일 {active30}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={bar}>
|
||||||
|
<div style={{ width: `${pctEngaged}%`, height: "100%", background: "var(--v5-primary)" }} />
|
||||||
|
</div>
|
||||||
|
<div style={sub}>
|
||||||
|
참여율 <b style={{ color: "var(--v5-text)", fontFamily: "var(--v5-font-mono)" }}>{pctEngaged}%</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 4 · DB 사용량 */}
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<div style={label}>총 DB 사용량</div>
|
||||||
|
<div style={bigRow}>
|
||||||
|
<span style={bigNum}>{dbGB.toFixed(1)}</span>
|
||||||
|
<span style={unit}>GB</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.56rem",
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
marginLeft: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
/ {dbQuotaGB} GB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={bar}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(pctDB, 100)}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: pctDB > 70 ? "var(--v5-amber)" : "var(--v5-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={sub}>
|
||||||
|
사용률 <b style={{ color: "var(--v5-text)", fontFamily: "var(--v5-font-mono)" }}>{pctDB}%</b> · {total}개 회사 합계
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미니 스파크라인. 데이터 없으면 "—" 표시 (목업 로직 그대로).
|
||||||
|
*/
|
||||||
|
export default function Sparkline({
|
||||||
|
data,
|
||||||
|
color = "var(--v5-primary)",
|
||||||
|
width = 96,
|
||||||
|
height = 18,
|
||||||
|
}: {
|
||||||
|
data?: number[];
|
||||||
|
color?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}) {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.58rem",
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const max = Math.max(...data, 1);
|
||||||
|
const min = Math.min(...data, 0);
|
||||||
|
const span = Math.max(max - min, 1);
|
||||||
|
const pts = data
|
||||||
|
.map((v, i) => {
|
||||||
|
const x = (i / (data.length - 1)) * width;
|
||||||
|
const y = height - ((v - min) / span) * (height - 3) - 1.5;
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
return (
|
||||||
|
<svg width={width} height={height} style={{ display: "block" }}>
|
||||||
|
<polyline
|
||||||
|
points={pts}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="1.25"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회사 상태 표시용 작은 컬러 dot + 라벨.
|
||||||
|
* provisioning 상태일 때만 pulse 애니메이션.
|
||||||
|
*/
|
||||||
|
const MAP: Record<string, { color: string; label: string }> = {
|
||||||
|
active: { color: "rgb(var(--v5-green-rgb))", label: "활성" },
|
||||||
|
provisioning: { color: "var(--v5-primary)", label: "생성중" },
|
||||||
|
failed: { color: "var(--v5-red)", label: "실패" },
|
||||||
|
suspended: { color: "var(--v5-text-muted)", label: "정지" },
|
||||||
|
inactive: { color: "var(--v5-text-muted)", label: "비활성" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StatusDot({ status }: { status?: string }) {
|
||||||
|
const m = MAP[status || ""] || { color: "var(--v5-text-muted)", label: status || "—" };
|
||||||
|
const isProvisioning = status === "provisioning";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--v5-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: m.color,
|
||||||
|
boxShadow: `0 0 6px ${m.color}`,
|
||||||
|
animation: isProvisioning ? "pulsedot 1.4s ease-in-out infinite" : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{m.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Link as LinkIcon, Briefcase, AlertTriangle, Database } from "lucide-react";
|
||||||
|
import { checkAvailability } from "@/lib/api/provisioning";
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
TextInput,
|
||||||
|
CheckAvailBadge,
|
||||||
|
AvailStatus,
|
||||||
|
availToInputStatus,
|
||||||
|
} from "./fields";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 1 · 회사 기본 정보 (시안 v2 포팅).
|
||||||
|
* 좌측: 식별자 섹션 + 법인정보 섹션 (둘 다 기본 노출)
|
||||||
|
* 우측: LIVE PREVIEW 카드 (접속 URL / DB 이름 / 회사 코드 / 주의)
|
||||||
|
*
|
||||||
|
* 로직 (유지):
|
||||||
|
* - subdomain 입력 시 db_prefix / company_code 자동 제안 (사용자 수정 전까지)
|
||||||
|
* - debounce 350ms 로 백엔드 /check 호출
|
||||||
|
* - valid: 3개 식별자 available + company_name 존재
|
||||||
|
*/
|
||||||
|
export default function Step1Basic({
|
||||||
|
state,
|
||||||
|
setState,
|
||||||
|
onValidChange,
|
||||||
|
}: {
|
||||||
|
state: Record<string, any>;
|
||||||
|
setState: (patch: Record<string, any>) => void;
|
||||||
|
onValidChange: (valid: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [subStatus, setSubStatus] = useState<AvailStatus>("idle");
|
||||||
|
const [prefStatus, setPrefStatus] = useState<AvailStatus>("idle");
|
||||||
|
const [codeStatus, setCodeStatus] = useState<AvailStatus>("idle");
|
||||||
|
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
function onSubChange(v: string) {
|
||||||
|
const sub = v.toLowerCase().replace(/[^a-z0-9-]/g, "");
|
||||||
|
const patch: Record<string, any> = { subdomain: sub };
|
||||||
|
if (!state.db_prefix_manual) {
|
||||||
|
patch.db_prefix = sub.replace(/-/g, "_");
|
||||||
|
}
|
||||||
|
if (!state.company_code_manual) {
|
||||||
|
patch.company_code = sub.toUpperCase().replace(/-/g, "_");
|
||||||
|
}
|
||||||
|
setState(patch);
|
||||||
|
}
|
||||||
|
function onDbPrefixChange(v: string) {
|
||||||
|
setState({ db_prefix: v.toLowerCase().replace(/[^a-z0-9_]/g, ""), db_prefix_manual: true });
|
||||||
|
}
|
||||||
|
function onCompanyCodeChange(v: string) {
|
||||||
|
setState({ company_code: v.toUpperCase().replace(/[^A-Z0-9_]/g, ""), company_code_manual: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
setSubStatus(state.subdomain ? "checking" : "idle");
|
||||||
|
setPrefStatus(state.db_prefix ? "checking" : "idle");
|
||||||
|
setCodeStatus(state.company_code ? "checking" : "idle");
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
subdomain: state.subdomain,
|
||||||
|
dbPrefix: state.db_prefix,
|
||||||
|
companyCode: state.company_code,
|
||||||
|
};
|
||||||
|
if (!payload.subdomain && !payload.dbPrefix && !payload.companyCode) return;
|
||||||
|
|
||||||
|
timerRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const r: any = await checkAvailability(payload);
|
||||||
|
if (payload.subdomain === state.subdomain) {
|
||||||
|
const sub = r?.subdomain;
|
||||||
|
setSubStatus(
|
||||||
|
!sub ? "idle" : !sub.valid_format || sub.reserved ? "invalid" : sub.available ? "available" : "taken",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (payload.dbPrefix === state.db_prefix) {
|
||||||
|
const pref = r?.db_prefix;
|
||||||
|
setPrefStatus(!pref ? "idle" : !pref.valid_format ? "invalid" : pref.available ? "available" : "taken");
|
||||||
|
}
|
||||||
|
if (payload.companyCode === state.company_code) {
|
||||||
|
const cc = r?.company_code;
|
||||||
|
setCodeStatus(!cc ? "idle" : !cc.valid_format ? "invalid" : cc.available ? "available" : "taken");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSubStatus("idle");
|
||||||
|
setPrefStatus("idle");
|
||||||
|
setCodeStatus("idle");
|
||||||
|
}
|
||||||
|
}, 350);
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, [state.subdomain, state.db_prefix, state.company_code]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const valid =
|
||||||
|
subStatus === "available" &&
|
||||||
|
prefStatus === "available" &&
|
||||||
|
codeStatus === "available" &&
|
||||||
|
!!state.company_name;
|
||||||
|
onValidChange(valid);
|
||||||
|
}, [subStatus, prefStatus, codeStatus, state.company_name, onValidChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1.4rem 1.6rem",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 320px",
|
||||||
|
gap: "1.6rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 왼쪽: 폼 */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.55rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.15em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--v5-cyan)",
|
||||||
|
marginBottom: 6,
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
01 · BASIC
|
||||||
|
</div>
|
||||||
|
<h2 style={{ fontSize: "1.15rem", fontWeight: 800, letterSpacing: "-0.02em", margin: 0, color: "var(--v5-text)" }}>
|
||||||
|
회사 기본 정보
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: "0.7rem", color: "var(--v5-text-sec)", marginTop: 4 }}>
|
||||||
|
subdomain 을 입력하면 접속 URL 과 DB 이름이 자동 결정됩니다. 생성 후에는 변경 불가합니다.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 식별자 ── */}
|
||||||
|
<div style={{ marginTop: "1.2rem" }}>
|
||||||
|
<SectionHead
|
||||||
|
icon={<LinkIcon size={11} strokeWidth={1.75} />}
|
||||||
|
label="식별자"
|
||||||
|
hint="생성 후 변경 불가"
|
||||||
|
/>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.7rem 0.85rem" }}>
|
||||||
|
<Field
|
||||||
|
label="SUBDOMAIN"
|
||||||
|
required
|
||||||
|
hint={<CheckAvailBadge status={subStatus} value={state.subdomain} />}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
value={state.subdomain || ""}
|
||||||
|
onChange={onSubChange}
|
||||||
|
placeholder="qnc"
|
||||||
|
suffix=".invyone.com"
|
||||||
|
mono
|
||||||
|
status={availToInputStatus(subStatus)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="DB_PREFIX"
|
||||||
|
required
|
||||||
|
hint={<CheckAvailBadge status={prefStatus} value={state.db_prefix} />}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
value={state.db_prefix || ""}
|
||||||
|
onChange={onDbPrefixChange}
|
||||||
|
placeholder="qnc"
|
||||||
|
suffix="_vexplor"
|
||||||
|
mono
|
||||||
|
status={availToInputStatus(prefStatus)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="COMPANY_CODE"
|
||||||
|
required
|
||||||
|
hint={<CheckAvailBadge status={codeStatus} value={state.company_code} />}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
value={state.company_code || ""}
|
||||||
|
onChange={onCompanyCodeChange}
|
||||||
|
placeholder="QNC"
|
||||||
|
mono
|
||||||
|
status={availToInputStatus(codeStatus)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="회사명 (표시)" required>
|
||||||
|
<TextInput
|
||||||
|
value={state.company_name || ""}
|
||||||
|
onChange={(v) => setState({ company_name: v })}
|
||||||
|
placeholder="큐엔씨 주식회사"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 법인 정보 ── */}
|
||||||
|
<div style={{ marginTop: "1.2rem" }}>
|
||||||
|
<SectionHead icon={<Briefcase size={11} strokeWidth={1.75} />} label="법인 정보" />
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.7rem 0.85rem" }}>
|
||||||
|
<Field label="사업자번호">
|
||||||
|
<TextInput
|
||||||
|
value={state.business_registration_number || ""}
|
||||||
|
onChange={(v) => setState({ business_registration_number: v })}
|
||||||
|
placeholder="123-45-67890"
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="대표자명">
|
||||||
|
<TextInput
|
||||||
|
value={state.representative_name || ""}
|
||||||
|
onChange={(v) => setState({ representative_name: v })}
|
||||||
|
placeholder="홍길동"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="대표 연락처">
|
||||||
|
<TextInput
|
||||||
|
value={state.representative_phone || ""}
|
||||||
|
onChange={(v) => setState({ representative_phone: v })}
|
||||||
|
placeholder="02-1234-5678"
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="담당자 이메일">
|
||||||
|
<TextInput
|
||||||
|
value={state.email || ""}
|
||||||
|
onChange={(v) => setState({ email: v })}
|
||||||
|
placeholder="admin@company.kr"
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="회사 주소" full>
|
||||||
|
<TextInput
|
||||||
|
value={state.address || ""}
|
||||||
|
onChange={(v) => setState({ address: v })}
|
||||||
|
placeholder="서울특별시 강남구 테헤란로 123"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="웹사이트" full>
|
||||||
|
<TextInput
|
||||||
|
value={state.website || ""}
|
||||||
|
onChange={(v) => setState({ website: v })}
|
||||||
|
placeholder="https://company.kr"
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽: LIVE PREVIEW */}
|
||||||
|
<div style={{ position: "sticky", top: 0, alignSelf: "start" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0.95rem 1rem",
|
||||||
|
background: "var(--v5-surface-solid)",
|
||||||
|
border: "1px solid var(--v5-border)",
|
||||||
|
borderRadius: 10,
|
||||||
|
boxShadow: "0 0 24px rgba(var(--v5-cyan-rgb), 0.08)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.52rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.18em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--v5-cyan)",
|
||||||
|
marginBottom: "0.65rem",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
LIVE PREVIEW
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PreviewField label="접속 URL">
|
||||||
|
<span style={{ color: "var(--v5-text-muted)" }}>https://</span>
|
||||||
|
<span style={{ color: "var(--v5-cyan)", fontWeight: 700 }}>
|
||||||
|
{state.subdomain || "___"}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "var(--v5-text-sec)" }}>.invyone.com</span>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<CheckAvailBadge status={subStatus} value={state.subdomain} />
|
||||||
|
</PreviewField>
|
||||||
|
|
||||||
|
<PreviewField label="DB 이름">
|
||||||
|
<Database size={11} color="var(--v5-text-muted)" />
|
||||||
|
<span style={{ color: "var(--v5-primary)", fontWeight: 700 }}>
|
||||||
|
{state.db_prefix || "___"}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "var(--v5-text-sec)" }}>_vexplor</span>
|
||||||
|
</PreviewField>
|
||||||
|
|
||||||
|
<PreviewField label="회사 코드">
|
||||||
|
{state.company_code ? (
|
||||||
|
<span style={{ color: "var(--v5-text)" }}>{state.company_code}</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "var(--v5-text-muted)" }}>___</span>
|
||||||
|
)}
|
||||||
|
</PreviewField>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0.6rem 0.7rem",
|
||||||
|
background: "rgba(var(--v5-amber-rgb), 0.08)",
|
||||||
|
border: "1px solid rgba(var(--v5-amber-rgb), 0.3)",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: "0.62rem",
|
||||||
|
color: "var(--v5-amber)",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: 6,
|
||||||
|
marginTop: "0.4rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={11} strokeWidth={1.75} style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
|
<div>
|
||||||
|
<b>주의</b> — subdomain 과 db_prefix 는 생성 후 변경할 수 없습니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHead({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.58rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--v5-text-sec)",
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
marginBottom: "0.55rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span>{label}</span>
|
||||||
|
{hint && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
fontWeight: 400,
|
||||||
|
textTransform: "none",
|
||||||
|
letterSpacing: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
— {hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewField({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: "0.85rem" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.52rem",
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
marginBottom: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 0.6rem",
|
||||||
|
background: "var(--v5-bg)",
|
||||||
|
border: "1px solid var(--v5-border)",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
Lock,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Monitor,
|
||||||
|
Sliders,
|
||||||
|
CalendarClock,
|
||||||
|
Workflow,
|
||||||
|
Shield,
|
||||||
|
Database,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { getTableGroups } from "@/lib/api/provisioning";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 2 · 템플릿 그룹 선택 (시안 v2 포팅).
|
||||||
|
* 왼쪽: 카드 리스트 (필수 / 선택 subheader 로 분리, 각 카드에 아이콘·체크·확장)
|
||||||
|
* 오른쪽: SUMMARY 패널 (선택된 그룹 · 복제될 테이블 · 예상 레코드 · 예상 소요 시간)
|
||||||
|
*
|
||||||
|
* 로직 (유지):
|
||||||
|
* - /table-groups 로드
|
||||||
|
* - g.required 는 서버가 강제하므로 UI 도 disabled + 항상 체크
|
||||||
|
* - selected_groups 에는 선택 그룹(required=false) 만 저장
|
||||||
|
* - valid = true 항상
|
||||||
|
*/
|
||||||
|
export default function Step2Template({
|
||||||
|
state,
|
||||||
|
setState,
|
||||||
|
onValidChange,
|
||||||
|
}: {
|
||||||
|
state: Record<string, any>;
|
||||||
|
setState: (patch: Record<string, any>) => void;
|
||||||
|
onValidChange: (valid: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { data: groups = [], isLoading } = useQuery({
|
||||||
|
queryKey: ["provisioning-table-groups"],
|
||||||
|
queryFn: getTableGroups,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
const selected: string[] = state.selected_groups || [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state.selected_groups) setState({ selected_groups: [] });
|
||||||
|
}, []); // eslint-disable-line
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onValidChange(true);
|
||||||
|
}, [onValidChange]);
|
||||||
|
|
||||||
|
function toggle(id: string, required: boolean) {
|
||||||
|
if (required) return;
|
||||||
|
const next = selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id];
|
||||||
|
setState({ selected_groups: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
const required = groups.filter((g: any) => g.required);
|
||||||
|
const optional = groups.filter((g: any) => !g.required);
|
||||||
|
const selectedGroups = groups.filter((g: any) => g.required || selected.includes(g.id));
|
||||||
|
const totalTables = selectedGroups.reduce(
|
||||||
|
(a: number, g: any) => a + (Array.isArray(g.tables) ? g.tables.length : 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalRows = selectedGroups.reduce((a: number, g: any) => a + (Number(g.count) || 0), 0);
|
||||||
|
const estimatedSec = 10 + selectedGroups.length * 3;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1.4rem 1.6rem",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 280px",
|
||||||
|
gap: "1.6rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.55rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.15em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--v5-cyan)",
|
||||||
|
marginBottom: 6,
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
02 · TEMPLATE
|
||||||
|
</div>
|
||||||
|
<h2 style={{ fontSize: "1.15rem", fontWeight: 800, letterSpacing: "-0.02em", margin: 0, color: "var(--v5-text)" }}>
|
||||||
|
복사할 기준 데이터
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: "0.7rem", color: "var(--v5-text-sec)", marginTop: 4, maxWidth: 560 }}>
|
||||||
|
기본 회사(INVION_DEFAULT) 의 기준 데이터 중 새 회사에 복제할 항목을 선택합니다.
|
||||||
|
<b style={{ color: "var(--v5-text)" }}> 필수 그룹</b>은 해제할 수 없습니다.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div style={{ padding: "1rem", color: "var(--v5-text-muted)", fontSize: "0.68rem" }}>로딩 중...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: "1.1rem", display: "flex", flexDirection: "column", gap: "0.55rem" }}>
|
||||||
|
{required.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SubHead>필수 (해제 불가)</SubHead>
|
||||||
|
{required.map((g: any) => (
|
||||||
|
<TemplateCard
|
||||||
|
key={g.id}
|
||||||
|
g={g}
|
||||||
|
checked
|
||||||
|
onToggle={() => {}}
|
||||||
|
expanded={expanded === g.id}
|
||||||
|
onExpand={() => setExpanded(expanded === g.id ? null : g.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{optional.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SubHead style={{ paddingTop: 10 }}>선택</SubHead>
|
||||||
|
{optional.map((g: any) => (
|
||||||
|
<TemplateCard
|
||||||
|
key={g.id}
|
||||||
|
g={g}
|
||||||
|
checked={selected.includes(g.id)}
|
||||||
|
onToggle={() => toggle(g.id, false)}
|
||||||
|
expanded={expanded === g.id}
|
||||||
|
onExpand={() => setExpanded(expanded === g.id ? null : g.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 요약 패널 */}
|
||||||
|
<div style={{ position: "sticky", top: 0, alignSelf: "start" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1rem",
|
||||||
|
background: "var(--v5-surface-solid)",
|
||||||
|
border: "1px solid var(--v5-border)",
|
||||||
|
borderRadius: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.52rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.18em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--v5-cyan)",
|
||||||
|
marginBottom: "0.7rem",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
SUMMARY
|
||||||
|
</div>
|
||||||
|
<SummaryRow
|
||||||
|
label="선택된 그룹"
|
||||||
|
value={`${selectedGroups.length} / ${groups.length}`}
|
||||||
|
accent="cyan"
|
||||||
|
isLast={false}
|
||||||
|
/>
|
||||||
|
<SummaryRow label="복제될 테이블" value={totalTables} mono isLast={false} />
|
||||||
|
<SummaryRow label="예상 레코드" value={`${totalRows.toLocaleString()}건`} mono isLast={false} />
|
||||||
|
<SummaryRow label="예상 소요 시간" value={`약 ${estimatedSec}초`} mono isLast />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 카드 ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const ICON_MAP: Record<string, React.ComponentType<{ size?: number; strokeWidth?: number }>> = {
|
||||||
|
screen: Monitor,
|
||||||
|
control: Sliders,
|
||||||
|
batch: CalendarClock,
|
||||||
|
dataflow: Workflow,
|
||||||
|
authmenu: Shield,
|
||||||
|
menu: Shield,
|
||||||
|
auth: Shield,
|
||||||
|
};
|
||||||
|
|
||||||
|
function TemplateCard({
|
||||||
|
g,
|
||||||
|
checked,
|
||||||
|
onToggle,
|
||||||
|
expanded,
|
||||||
|
onExpand,
|
||||||
|
}: {
|
||||||
|
g: Record<string, any>;
|
||||||
|
checked: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
expanded: boolean;
|
||||||
|
onExpand: () => void;
|
||||||
|
}) {
|
||||||
|
const locked = !!g.required;
|
||||||
|
const Ic = ICON_MAP[g.id] || ICON_MAP[g.key] || Database;
|
||||||
|
const tables: string[] = Array.isArray(g.tables) ? g.tables : [];
|
||||||
|
const meta: string[] = [];
|
||||||
|
meta.push(`${tables.length}개 테이블`);
|
||||||
|
if (g.count != null) meta.push(`${Number(g.count).toLocaleString()}건`);
|
||||||
|
if (g.size) meta.push(String(g.size));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${checked ? "rgba(var(--v5-cyan-rgb), 0.35)" : "var(--v5-border)"}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: checked ? "rgba(var(--v5-cyan-rgb), 0.04)" : "var(--v5-surface-solid)",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
overflow: "hidden",
|
||||||
|
boxShadow: checked ? "0 0 18px rgba(var(--v5-cyan-rgb), 0.08)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => !locked && onToggle()}
|
||||||
|
style={{
|
||||||
|
padding: "0.85rem 1rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.7rem",
|
||||||
|
cursor: locked ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* checkbox */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 4,
|
||||||
|
border: `1.5px solid ${checked ? "var(--v5-cyan)" : "var(--v5-border)"}`,
|
||||||
|
background: checked ? "var(--v5-cyan)" : "transparent",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "#fff",
|
||||||
|
flexShrink: 0,
|
||||||
|
boxShadow: checked ? "0 0 10px rgba(var(--v5-cyan-rgb), 0.4)" : "none",
|
||||||
|
opacity: locked ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{checked && <Check size={12} strokeWidth={3} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* icon tile */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 8,
|
||||||
|
background: checked ? "rgba(var(--v5-cyan-rgb), 0.12)" : "var(--v5-bg-subtle)",
|
||||||
|
color: checked ? "var(--v5-cyan)" : "var(--v5-text-muted)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ic size={15} strokeWidth={1.75} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* label */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<span style={{ fontSize: "0.82rem", fontWeight: 700, color: "var(--v5-text)" }}>
|
||||||
|
{g.label || g.id}
|
||||||
|
</span>
|
||||||
|
{locked && (
|
||||||
|
<span
|
||||||
|
title="필수 템플릿입니다"
|
||||||
|
style={{
|
||||||
|
fontSize: "0.5rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
padding: "0.1rem 0.4rem",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "rgba(var(--v5-red-rgb), 0.1)",
|
||||||
|
color: "var(--v5-red)",
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Lock size={8} /> 필수
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.6rem",
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{meta.join(" · ")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tables.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onExpand();
|
||||||
|
}}
|
||||||
|
aria-label={expanded ? "접기" : "펼치기"}
|
||||||
|
style={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
border: "1px solid var(--v5-border)",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && tables.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0.7rem 1rem 0.85rem 3.4rem",
|
||||||
|
borderTop: "1px solid var(--v5-border)",
|
||||||
|
background: "var(--v5-bg)",
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tables.map((t) => (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
style={{
|
||||||
|
padding: "0.15rem 0.45rem",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
fontSize: "0.58rem",
|
||||||
|
background: "var(--v5-surface-solid)",
|
||||||
|
border: "1px solid var(--v5-border)",
|
||||||
|
borderRadius: 4,
|
||||||
|
color: "var(--v5-text-sec)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubHead({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.52rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.15em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
padding: "4px 0",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mono,
|
||||||
|
accent,
|
||||||
|
isLast,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
mono?: boolean;
|
||||||
|
accent?: "cyan";
|
||||||
|
isLast: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "baseline",
|
||||||
|
padding: "0.45rem 0",
|
||||||
|
borderBottom: isLast ? "none" : "1px solid var(--v5-border-subtle, rgba(0,0,0,0.05))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "0.6rem", color: "var(--v5-text-muted)" }}>{label}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: mono ? "var(--v5-font-mono)" : "inherit",
|
||||||
|
color: accent === "cyan" ? "var(--v5-cyan)" : "var(--v5-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
RefreshCw,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
AlertTriangle,
|
||||||
|
Lock,
|
||||||
|
Shield,
|
||||||
|
KeyRound,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { genPassword } from "./fields";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 3 · 초기 관리자 계정 (시안 v2 포팅).
|
||||||
|
* 단일 테두리 카드 안에 4개 row: USER_ID / INITIAL_PASSWORD / USER_TYPE / FORCE_PW_CHANGE
|
||||||
|
*
|
||||||
|
* 로직 (유지):
|
||||||
|
* - user_id = {db_prefix}_admin (자동)
|
||||||
|
* - initial_password 자동 생성, 재생성, 복사, 보기 토글
|
||||||
|
* - force_password_change 기본 ON
|
||||||
|
* - valid = password 길이 8+
|
||||||
|
*/
|
||||||
|
export default function Step3Admin({
|
||||||
|
state,
|
||||||
|
setState,
|
||||||
|
onValidChange,
|
||||||
|
}: {
|
||||||
|
state: Record<string, any>;
|
||||||
|
setState: (patch: Record<string, any>) => void;
|
||||||
|
onValidChange: (valid: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [visible, setVisible] = useState(true);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const userId = state.db_prefix ? `${state.db_prefix}_admin` : "—";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state.initial_password) {
|
||||||
|
setState({ initial_password: genPassword(12) });
|
||||||
|
}
|
||||||
|
if (state.force_password_change === undefined) {
|
||||||
|
setState({ force_password_change: true });
|
||||||
|
}
|
||||||
|
}, []); // eslint-disable-line
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onValidChange(!!state.initial_password && state.initial_password.length >= 8);
|
||||||
|
}, [state.initial_password, onValidChange]);
|
||||||
|
|
||||||
|
const pw: string = state.initial_password || "";
|
||||||
|
|
||||||
|
function regen() {
|
||||||
|
setState({ initial_password: genPassword(12) });
|
||||||
|
setCopied(false);
|
||||||
|
}
|
||||||
|
async function copy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(pw);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1400);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordActions = (
|
||||||
|
<div style={{ display: "flex", gap: 4 }}>
|
||||||
|
<IconBtn title={visible ? "숨기기" : "보기"} onClick={() => setVisible((v) => !v)}>
|
||||||
|
{visible ? <EyeOff size={12} /> : <Eye size={12} />}
|
||||||
|
</IconBtn>
|
||||||
|
<IconBtn title="재생성" onClick={regen}>
|
||||||
|
<RefreshCw size={12} />
|
||||||
|
</IconBtn>
|
||||||
|
<button
|
||||||
|
onClick={copy}
|
||||||
|
title="복사"
|
||||||
|
style={{
|
||||||
|
height: 30,
|
||||||
|
padding: "0 0.6rem",
|
||||||
|
borderRadius: 7,
|
||||||
|
border: `1px solid ${copied ? "rgb(var(--v5-green-rgb))" : "var(--v5-cyan)"}`,
|
||||||
|
background: copied ? "rgb(var(--v5-green-rgb))" : "var(--v5-cyan)",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
fontSize: "0.62rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
transition: "all 0.2s",
|
||||||
|
boxShadow: copied
|
||||||
|
? "0 0 14px rgba(var(--v5-green-rgb), 0.4)"
|
||||||
|
: "0 0 10px rgba(var(--v5-cyan-rgb), 0.3)",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? <Check size={11} /> : <Copy size={11} />}
|
||||||
|
{copied ? "복사됨" : "복사"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "1.4rem 1.6rem", maxWidth: 820 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.55rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.15em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--v5-cyan)",
|
||||||
|
marginBottom: 6,
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
03 · ADMIN ACCOUNT
|
||||||
|
</div>
|
||||||
|
<h2 style={{ fontSize: "1.15rem", fontWeight: 800, letterSpacing: "-0.02em", margin: 0, color: "var(--v5-text)" }}>
|
||||||
|
초기 관리자 계정
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: "0.7rem", color: "var(--v5-text-sec)", marginTop: 4, maxWidth: 560 }}>
|
||||||
|
시스템이 자동으로{" "}
|
||||||
|
<code style={{ fontFamily: "var(--v5-font-mono)", color: "var(--v5-primary)" }}>COMPANY_ADMIN</code> 권한의 관리자 계정을
|
||||||
|
생성합니다. 생성된 비밀번호는{" "}
|
||||||
|
<b style={{ color: "var(--v5-text)" }}>이 화면을 벗어나면 다시 볼 수 없으며</b>, DB 에는 BCrypt 해시만 저장됩니다.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "1.2rem",
|
||||||
|
border: "1px solid var(--v5-border)",
|
||||||
|
borderRadius: 10,
|
||||||
|
background: "var(--v5-surface-solid)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row
|
||||||
|
label="USER_ID"
|
||||||
|
sub="자동 · 읽기전용"
|
||||||
|
trailing={<Lock size={10} color="var(--v5-text-muted)" />}
|
||||||
|
>
|
||||||
|
<ReadBox mono>{userId}</ReadBox>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row label="INITIAL_PASSWORD" sub="무작위 12자" trailing={passwordActions}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0.45rem 0.6rem",
|
||||||
|
background: "rgba(var(--v5-cyan-rgb), 0.05)",
|
||||||
|
border: "1px solid rgba(var(--v5-cyan-rgb), 0.25)",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--v5-cyan)",
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KeyRound size={13} />
|
||||||
|
<span style={{ flex: 1 }}>{visible ? pw : "•".repeat(pw.length || 12)}</span>
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row label="USER_TYPE" sub="고정 · 수정 불가">
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "0.25rem 0.55rem",
|
||||||
|
background: "rgba(var(--v5-primary-rgb), 0.1)",
|
||||||
|
color: "var(--v5-primary)",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
fontSize: "0.62rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
borderRadius: 999,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Shield size={10} />
|
||||||
|
COMPANY_ADMIN
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row label="FORCE_PW_CHANGE" sub="권장" last>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}>
|
||||||
|
<Switch
|
||||||
|
on={!!state.force_password_change}
|
||||||
|
onChange={() => setState({ force_password_change: !state.force_password_change })}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: "0.7rem", color: "var(--v5-text)" }}>
|
||||||
|
첫 로그인 시 비밀번호 변경 강제
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 경고 배너 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "1rem",
|
||||||
|
padding: "0.75rem 0.9rem",
|
||||||
|
background: "rgba(var(--v5-red-rgb), 0.04)",
|
||||||
|
border: "1px solid rgba(var(--v5-red-rgb), 0.25)",
|
||||||
|
borderRadius: 8,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={14} color="var(--v5-red)" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
|
<div style={{ fontSize: "0.68rem", color: "var(--v5-text)", lineHeight: 1.5 }}>
|
||||||
|
<b>이 비밀번호는 다시 볼 수 없습니다.</b>
|
||||||
|
<span style={{ color: "var(--v5-text-sec)" }}>
|
||||||
|
{" "}
|
||||||
|
생성 후 DB 에는 BCrypt 해시만 저장됩니다. 지금 복사하여 안전한 채널 (1Password, Keeper 등) 로 회사에 전달하세요.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
label,
|
||||||
|
sub,
|
||||||
|
children,
|
||||||
|
trailing,
|
||||||
|
last,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
sub?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
trailing?: React.ReactNode;
|
||||||
|
last?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0.9rem 1.1rem",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "130px 1fr auto",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "1rem",
|
||||||
|
borderBottom: last ? "none" : "1px solid var(--v5-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.52rem",
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{sub && <div style={{ fontSize: "0.55rem", color: "var(--v5-text-muted)", marginTop: 2 }}>{sub}</div>}
|
||||||
|
</div>
|
||||||
|
<div>{children}</div>
|
||||||
|
<div>{trailing}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconBtn({
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
style={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 7,
|
||||||
|
border: "1px solid var(--v5-border)",
|
||||||
|
background: "var(--v5-surface-solid)",
|
||||||
|
color: "var(--v5-text-sec)",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReadBox({ children, mono }: { children: React.ReactNode; mono?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0.45rem 0.6rem",
|
||||||
|
background: "var(--v5-bg-subtle)",
|
||||||
|
border: "1px solid var(--v5-border)",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontFamily: mono ? "var(--v5-font-mono)" : "inherit",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "var(--v5-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Switch({ on, onChange }: { on: boolean; onChange: () => void }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onChange}
|
||||||
|
style={{
|
||||||
|
width: 34,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: on ? "var(--v5-cyan)" : "var(--v5-bg-subtle)",
|
||||||
|
border: `1px solid ${on ? "var(--v5-cyan)" : "var(--v5-border)"}`,
|
||||||
|
position: "relative",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
boxShadow: on ? "0 0 10px rgba(var(--v5-cyan-rgb), 0.3)" : "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 1,
|
||||||
|
left: on ? 17 : 1,
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#fff",
|
||||||
|
transition: "left 0.2s",
|
||||||
|
boxShadow: "0 1px 2px rgba(0,0,0,0.2)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,666 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Check, X, AlertOctagon } from "lucide-react";
|
||||||
|
import { createCompany, getProvisioningStatus, CreateCompanyRequest } from "@/lib/api/provisioning";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 4 · 생성 진행 (시안 v2 포팅).
|
||||||
|
* 레이아웃: 좌측 (제목 + 큰 진행바 + 단계 리스트 + 결과 배너) + 우측 (다크 로그 콘솔)
|
||||||
|
*
|
||||||
|
* 로직 (유지):
|
||||||
|
* - 마운트 즉시 POST /companies → jobId 취득 → 폴링 시작
|
||||||
|
* - 폴링 결과 status.currentStep 변화에 맞춰 단계 state 갱신
|
||||||
|
* - onDone 으로 최종 결과 상위에 통보
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DISPLAY_STEPS: { key: string; label: string; sub: string; sec: number }[] = [
|
||||||
|
{ key: "CREATE_DATABASE", label: "DB 생성", sub: "CREATE DATABASE '%db%'", sec: 2 },
|
||||||
|
{ key: "COPY_SCHEMA", label: "스키마 복제", sub: "pg_dump --schema-only | psql", sec: 5 },
|
||||||
|
{ key: "COPY_DATA", label: "템플릿 데이터 복사", sub: "선택된 그룹의 공통 데이터", sec: 12 },
|
||||||
|
{ key: "CREATE_ADMIN", label: "관리자 계정 생성", sub: "BCrypt · user_info", sec: 3 },
|
||||||
|
{ key: "FINALIZE", label: "마무리", sub: "db_status=active · 캐시 무효화", sec: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
type RowStatus = "pending" | "running" | "done" | "failed";
|
||||||
|
|
||||||
|
export default function Step4Run({
|
||||||
|
state,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
state: Record<string, any>;
|
||||||
|
onDone: (result: { success: boolean; subdomain?: string; companyCode?: string; error?: string }) => void;
|
||||||
|
}) {
|
||||||
|
const [jobId, setJobId] = useState<string | null>(null);
|
||||||
|
const [status, setStatus] = useState<Record<string, any>>({
|
||||||
|
status: "in_progress",
|
||||||
|
currentStep: "",
|
||||||
|
progress: 0,
|
||||||
|
});
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
const [log, setLog] = useState<string[]>([]);
|
||||||
|
const [startedAt] = useState<number>(() => Date.now());
|
||||||
|
const pollRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const startedRef = useRef(false);
|
||||||
|
const prevStepRef = useRef<string>("");
|
||||||
|
|
||||||
|
// 로그 append 헬퍼
|
||||||
|
function appendLog(line: string) {
|
||||||
|
const hhmmss = new Date().toTimeString().slice(0, 8);
|
||||||
|
setLog((lg) => [...lg, `[${hhmmss}] ${line}`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (startedRef.current) return;
|
||||||
|
startedRef.current = true;
|
||||||
|
appendLog(`▸ START provisioning · ${state.company_code || "—"}`);
|
||||||
|
appendLog(`· subdomain=${state.subdomain}`);
|
||||||
|
appendLog(`· db_prefix=${state.db_prefix}`);
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const payload: CreateCompanyRequest = {
|
||||||
|
company_code: state.company_code,
|
||||||
|
company_name: state.company_name,
|
||||||
|
subdomain: state.subdomain,
|
||||||
|
db_prefix: state.db_prefix,
|
||||||
|
business_registration_number: state.business_registration_number,
|
||||||
|
representative_name: state.representative_name,
|
||||||
|
representative_phone: state.representative_phone,
|
||||||
|
email: state.email,
|
||||||
|
website: state.website,
|
||||||
|
address: state.address,
|
||||||
|
selected_groups: state.selected_groups || [],
|
||||||
|
initial_password: state.initial_password,
|
||||||
|
};
|
||||||
|
const resp = await createCompany(payload);
|
||||||
|
setJobId(resp.provisioning_id);
|
||||||
|
appendLog(`✓ accepted · job_id=${resp.provisioning_id}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.response?.data?.message || e?.response?.data?.error || e?.message || "요청 실패";
|
||||||
|
setCreateError(msg);
|
||||||
|
appendLog(`✗ FAILED · ${msg}`);
|
||||||
|
onDone({ success: false, error: msg });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
if (pollRef.current) clearTimeout(pollRef.current);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!jobId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
try {
|
||||||
|
const s = await getProvisioningStatus(jobId!);
|
||||||
|
if (cancelled) return;
|
||||||
|
setStatus(s);
|
||||||
|
|
||||||
|
// step 변화 감지 → 로그
|
||||||
|
if (s.currentStep && s.currentStep !== prevStepRef.current) {
|
||||||
|
const disp = DISPLAY_STEPS.find((d) => d.key === s.currentStep);
|
||||||
|
appendLog(`▸ ${s.currentStep}${disp ? ` · ${disp.label}` : ""}`);
|
||||||
|
prevStepRef.current = s.currentStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.status === "completed") {
|
||||||
|
appendLog(`━ READY · https://${s.subdomain || state.subdomain}.invyone.com`);
|
||||||
|
onDone({ success: true, subdomain: s.subdomain || state.subdomain, companyCode: s.companyCode });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (s.status === "failed") {
|
||||||
|
appendLog(`✗ FAILED · ${s.failedStep || "—"} · ${s.errorMessage || "—"}`);
|
||||||
|
onDone({ success: false, error: s.errorMessage || "프로비저닝 실패" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 네트워크 일시 장애 → 다음 틱에서 재시도
|
||||||
|
}
|
||||||
|
pollRef.current = setTimeout(tick, 2000);
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (pollRef.current) clearTimeout(pollRef.current);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line
|
||||||
|
}, [jobId]);
|
||||||
|
|
||||||
|
const current = status?.currentStep as string | undefined;
|
||||||
|
const failed = status?.status === "failed";
|
||||||
|
const done = status?.status === "completed";
|
||||||
|
|
||||||
|
function rowStatus(key: string): RowStatus {
|
||||||
|
if (!current && !done && !failed) return "pending";
|
||||||
|
if (done) return "done";
|
||||||
|
const idx = DISPLAY_STEPS.findIndex((s) => s.key === key);
|
||||||
|
const curIdx = DISPLAY_STEPS.findIndex((s) => s.key === current);
|
||||||
|
if (failed && status.failedStep === key) return "failed";
|
||||||
|
if (idx < curIdx) return "done";
|
||||||
|
if (idx === curIdx) return failed ? "failed" : "running";
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress: number = useMemo(() => {
|
||||||
|
if (done) return 100;
|
||||||
|
if (typeof status?.progress === "number") return Math.max(0, Math.min(100, Math.round(status.progress)));
|
||||||
|
if (!current) return 0;
|
||||||
|
const curIdx = DISPLAY_STEPS.findIndex((s) => s.key === current);
|
||||||
|
if (curIdx < 0) return 0;
|
||||||
|
return Math.round(((curIdx + 0.5) / DISPLAY_STEPS.length) * 100);
|
||||||
|
}, [done, current, status?.progress]);
|
||||||
|
|
||||||
|
const elapsedSec = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
|
||||||
|
|
||||||
|
const variant: "running" | "success" | "failed" = failed ? "failed" : done ? "success" : "running";
|
||||||
|
const barLabel = variant === "success" ? "COMPLETED" : variant === "failed" ? "HALTED" : "IN PROGRESS";
|
||||||
|
const accent =
|
||||||
|
variant === "success" ? "rgb(var(--v5-green-rgb))" : variant === "failed" ? "var(--v5-red)" : "var(--v5-cyan)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1.4rem 1.6rem",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 320px",
|
||||||
|
gap: "1.6rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.55rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.15em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--v5-cyan)",
|
||||||
|
marginBottom: 6,
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
04 · PROVISIONING
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: "1.15rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
margin: 0,
|
||||||
|
color: "var(--v5-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{variant === "success" ? "회사 생성 완료" : variant === "failed" ? "생성 실패" : "회사 생성 중"}
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: "0.7rem", color: "var(--v5-text-sec)", marginTop: 4 }}>
|
||||||
|
{variant === "success" && (
|
||||||
|
<>
|
||||||
|
<b style={{ color: "rgb(var(--v5-green-rgb))" }}>
|
||||||
|
{state.subdomain}.invyone.com
|
||||||
|
</b>{" "}
|
||||||
|
에서 접속 가능합니다. 회사에 URL 과 초기 비밀번호를 전달하세요.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{variant === "failed" && <>오류가 발생했습니다. 로그를 확인 후 롤백/재시도를 진행하세요.</>}
|
||||||
|
{variant === "running" && <>브라우저를 닫아도 서버에서 계속 진행됩니다.</>}
|
||||||
|
</div>
|
||||||
|
{createError && (
|
||||||
|
<div style={{ marginTop: 6, fontSize: "0.66rem", color: "var(--v5-red)", fontFamily: "var(--v5-font-mono)" }}>
|
||||||
|
{createError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 큰 진행 바 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "1.2rem",
|
||||||
|
padding: "1.05rem 1.1rem",
|
||||||
|
border: "1px solid var(--v5-border)",
|
||||||
|
borderRadius: 10,
|
||||||
|
background: "var(--v5-surface-solid)",
|
||||||
|
boxShadow: variant === "running" ? "0 0 26px rgba(var(--v5-cyan-rgb), 0.1)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: "0.55rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.56rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.12em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{barLabel}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "1.3rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: "-0.03em",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
color: accent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{progress}
|
||||||
|
<span style={{ fontSize: "0.75rem", color: "var(--v5-text-muted)" }}>%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
background: "var(--v5-bg-subtle)",
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
width: `${progress}%`,
|
||||||
|
background:
|
||||||
|
variant === "success"
|
||||||
|
? "linear-gradient(90deg, rgb(var(--v5-green-rgb)), rgb(var(--v5-green-rgb)))"
|
||||||
|
: variant === "failed"
|
||||||
|
? "linear-gradient(90deg, var(--v5-red), #ff6b6b)"
|
||||||
|
: "linear-gradient(90deg, var(--v5-cyan), var(--v5-primary))",
|
||||||
|
boxShadow: variant === "running" ? "0 0 12px rgba(var(--v5-cyan-rgb), 0.5)" : "none",
|
||||||
|
transition: "width 0.4s",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{variant === "running" && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 40,
|
||||||
|
background: "linear-gradient(90deg, transparent, rgba(255,255,255,0.6), transparent)",
|
||||||
|
left: `${Math.max(0, progress - 8)}%`,
|
||||||
|
animation: "shimmer 1.5s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "0.55rem",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
fontSize: "0.55rem",
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>PROV_ID · {jobId || "—"}</span>
|
||||||
|
<span>
|
||||||
|
경과 {elapsedSec}s
|
||||||
|
{variant === "running" && " · 진행 중"}
|
||||||
|
{variant === "success" && " · 완료"}
|
||||||
|
{variant === "failed" && " · 실패"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 단계 리스트 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "1rem",
|
||||||
|
padding: "0.4rem 1.1rem",
|
||||||
|
border: "1px solid var(--v5-border)",
|
||||||
|
borderRadius: 10,
|
||||||
|
background: "var(--v5-surface-solid)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DISPLAY_STEPS.map((s, i) => (
|
||||||
|
<RunRow
|
||||||
|
key={s.key}
|
||||||
|
step={s}
|
||||||
|
idx={i}
|
||||||
|
status={rowStatus(s.key)}
|
||||||
|
isLast={i === DISPLAY_STEPS.length - 1}
|
||||||
|
dbName={`${state.db_prefix || "___"}_vexplor`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 실패 에러 메시지 */}
|
||||||
|
{failed && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "1rem",
|
||||||
|
padding: "0.85rem 1rem",
|
||||||
|
border: "1px solid rgba(var(--v5-red-rgb), 0.3)",
|
||||||
|
background: "rgba(var(--v5-red-rgb), 0.04)",
|
||||||
|
borderRadius: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.6rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--v5-red)",
|
||||||
|
letterSpacing: "0.12em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
marginBottom: 6,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertOctagon size={12} /> ERROR · {status?.failedStep || "—"}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
padding: "0.55rem 0.65rem",
|
||||||
|
background: "var(--v5-bg)",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid var(--v5-border)",
|
||||||
|
color: "var(--v5-text)",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
overflowX: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status?.errorMessage || createError || "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 성공 접속 카드 */}
|
||||||
|
{done && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "1rem",
|
||||||
|
padding: "1rem 1.1rem",
|
||||||
|
border: "1px solid rgba(var(--v5-green-rgb), 0.3)",
|
||||||
|
background: "rgba(var(--v5-green-rgb), 0.04)",
|
||||||
|
borderRadius: 10,
|
||||||
|
boxShadow: "0 0 24px rgba(var(--v5-green-rgb), 0.1)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.85rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: "linear-gradient(135deg, rgb(var(--v5-green-rgb)), rgb(var(--v5-green-rgb)))",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "#fff",
|
||||||
|
boxShadow: "0 0 14px rgba(var(--v5-green-rgb), 0.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check size={20} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.56rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "rgb(var(--v5-green-rgb))",
|
||||||
|
letterSpacing: "0.15em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
READY
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.85rem", fontWeight: 700, marginTop: 2, color: "var(--v5-text)" }}>
|
||||||
|
<span style={{ fontFamily: "var(--v5-font-mono)", color: "var(--v5-cyan)" }}>
|
||||||
|
{state.subdomain}.invyone.com
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "var(--v5-text-sec)", fontWeight: 400 }}> 접속 가능</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.6rem",
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
marginTop: 2,
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
로그인: {state.db_prefix}_admin / [Step 3 에서 복사한 비밀번호]
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 로그 콘솔 */}
|
||||||
|
<div style={{ alignSelf: "start" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#0a0a0c",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "1px solid var(--v5-border)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0.55rem 0.7rem",
|
||||||
|
borderBottom: "1px solid rgba(255,255,255,0.08)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
fontSize: "0.56rem",
|
||||||
|
letterSpacing: "0.12em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "#a8a8b0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: accent,
|
||||||
|
boxShadow: `0 0 6px ${accent}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
PROVISIONING LOG
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0.55rem 0.7rem",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
fontSize: "0.58rem",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
color: "#d4d4dc",
|
||||||
|
maxHeight: 420,
|
||||||
|
overflow: "auto",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{log.length === 0 ? <span style={{ color: "#70707a" }}>(로그 없음)</span> : log.join("\n")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RunRow({
|
||||||
|
step,
|
||||||
|
idx,
|
||||||
|
status,
|
||||||
|
isLast,
|
||||||
|
dbName,
|
||||||
|
}: {
|
||||||
|
step: { key: string; label: string; sub: string; sec: number };
|
||||||
|
idx: number;
|
||||||
|
status: RowStatus;
|
||||||
|
isLast: boolean;
|
||||||
|
dbName: string;
|
||||||
|
}) {
|
||||||
|
const isDone = status === "done";
|
||||||
|
const isActive = status === "running";
|
||||||
|
const isFailed = status === "failed";
|
||||||
|
const isPending = status === "pending";
|
||||||
|
const barColor = isFailed
|
||||||
|
? "var(--v5-red)"
|
||||||
|
: isDone
|
||||||
|
? "rgb(var(--v5-green-rgb))"
|
||||||
|
: isActive
|
||||||
|
? "var(--v5-cyan)"
|
||||||
|
: "var(--v5-border)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "32px 1fr 90px",
|
||||||
|
gap: "0.85rem",
|
||||||
|
padding: "0.7rem 0",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* circle + connecting line */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
height: 32,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isLast && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 26,
|
||||||
|
bottom: -18,
|
||||||
|
width: 2,
|
||||||
|
background: isDone ? "rgb(var(--v5-green-rgb))" : "var(--v5-border)",
|
||||||
|
left: 15,
|
||||||
|
transition: "background 0.3s",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: isFailed
|
||||||
|
? "var(--v5-red)"
|
||||||
|
: isDone
|
||||||
|
? "rgb(var(--v5-green-rgb))"
|
||||||
|
: isActive
|
||||||
|
? "var(--v5-cyan)"
|
||||||
|
: "var(--v5-bg)",
|
||||||
|
border: `2px solid ${barColor}`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "#fff",
|
||||||
|
boxShadow: isActive
|
||||||
|
? "0 0 14px rgba(var(--v5-cyan-rgb), 0.5)"
|
||||||
|
: isFailed
|
||||||
|
? "0 0 14px rgba(var(--v5-red-rgb), 0.4)"
|
||||||
|
: "none",
|
||||||
|
transition: "all 0.3s",
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDone && <Check size={13} />}
|
||||||
|
{isFailed && <X size={13} />}
|
||||||
|
{isActive && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
border: "2px solid rgba(255,255,255,0.35)",
|
||||||
|
borderTopColor: "#fff",
|
||||||
|
borderRadius: "50%",
|
||||||
|
}}
|
||||||
|
className="spin"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isPending && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.6rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{idx + 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
fontWeight: isActive ? 700 : 600,
|
||||||
|
color: isFailed
|
||||||
|
? "var(--v5-red)"
|
||||||
|
: isActive
|
||||||
|
? "var(--v5-cyan)"
|
||||||
|
: isDone
|
||||||
|
? "var(--v5-text)"
|
||||||
|
: "var(--v5-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.6rem",
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.sub.replace("%db%", dbName)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: "right",
|
||||||
|
fontSize: "0.62rem",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
color: isFailed
|
||||||
|
? "var(--v5-red)"
|
||||||
|
: isDone
|
||||||
|
? "rgb(var(--v5-green-rgb))"
|
||||||
|
: isActive
|
||||||
|
? "var(--v5-cyan)"
|
||||||
|
: "var(--v5-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDone && "완료"}
|
||||||
|
{isActive && "진행 중…"}
|
||||||
|
{isPending && "대기"}
|
||||||
|
{isFailed && "실패"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Building2, Layers, UserCog, Rocket, Check } from "lucide-react";
|
||||||
|
|
||||||
|
export const WIZARD_STEPS = [
|
||||||
|
{ n: 1, key: "basic", label: "기본 정보", icon: Building2, desc: "회사 · 접속 주소 · DB" },
|
||||||
|
{ n: 2, key: "template", label: "템플릿 선택", icon: Layers, desc: "복사할 기준 데이터" },
|
||||||
|
{ n: 3, key: "admin", label: "관리자 계정", icon: UserCog, desc: "초기 관리자 생성" },
|
||||||
|
{ n: 4, key: "run", label: "생성 진행", icon: Rocket, desc: "DB · 스키마 · 복사" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function StepIndicator({ current }: { current: number }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "stretch",
|
||||||
|
background: "var(--v5-surface-solid)",
|
||||||
|
borderBottom: "1px solid var(--v5-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{WIZARD_STEPS.map((s, i) => {
|
||||||
|
const done = current > s.n;
|
||||||
|
const active = current === s.n;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.key}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.65rem",
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
position: "relative",
|
||||||
|
background: active ? "rgba(var(--v5-cyan-rgb), 0.06)" : "transparent",
|
||||||
|
borderRight: i < WIZARD_STEPS.length - 1 ? "1px solid var(--v5-border)" : "none",
|
||||||
|
transition: "background 0.25s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
borderRadius: 7,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: done
|
||||||
|
? "rgb(var(--v5-green-rgb))"
|
||||||
|
: active
|
||||||
|
? "var(--v5-cyan)"
|
||||||
|
: "var(--v5-bg-subtle)",
|
||||||
|
color: done || active ? "#fff" : "var(--v5-text-muted)",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
fontSize: "0.62rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
boxShadow: active ? "0 0 14px rgba(var(--v5-cyan-rgb), 0.5)" : "none",
|
||||||
|
transition: "all 0.25s",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{done ? <Check size={13} strokeWidth={2.5} /> : s.n}
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.52rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.15em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: done || active ? "var(--v5-text-sec)" : "var(--v5-text-muted)",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
STEP {s.n} / {WIZARD_STEPS.length}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: active || done ? 700 : 500,
|
||||||
|
color: active
|
||||||
|
? "var(--v5-cyan)"
|
||||||
|
: done
|
||||||
|
? "var(--v5-text)"
|
||||||
|
: "var(--v5-text-sec)",
|
||||||
|
marginTop: 1,
|
||||||
|
letterSpacing: "-0.01em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{active && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: -1,
|
||||||
|
height: 2,
|
||||||
|
background:
|
||||||
|
"linear-gradient(90deg, transparent, var(--v5-cyan), transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Rocket,
|
||||||
|
Building2,
|
||||||
|
AlertTriangle,
|
||||||
|
FileText,
|
||||||
|
ArrowUpRight,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
Info,
|
||||||
|
XOctagon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import StepIndicator from "./StepIndicator";
|
||||||
|
import Step1Basic from "./Step1Basic";
|
||||||
|
import Step2Template from "./Step2Template";
|
||||||
|
import Step3Admin from "./Step3Admin";
|
||||||
|
import Step4Run from "./Step4Run";
|
||||||
|
|
||||||
|
type RunDone = {
|
||||||
|
success: boolean;
|
||||||
|
subdomain?: string;
|
||||||
|
companyCode?: string;
|
||||||
|
error?: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회사 생성 마법사 메인 (시안 v2 포팅).
|
||||||
|
* 모달로 열림. ESC · 배경 클릭으로 닫기 (진행 중 생성은 경고).
|
||||||
|
* Step 4 진입하면 Step4Run 이 POST /companies 를 즉시 호출 → 폴링.
|
||||||
|
* 성공/실패 후 메인 목록 invalidate.
|
||||||
|
*/
|
||||||
|
export default function Wizard({ onClose }: { onClose: () => void }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [state, setStateRaw] = useState<Record<string, any>>({ selected_groups: [] });
|
||||||
|
const [validByStep, setValidByStep] = useState<Record<number, boolean>>({
|
||||||
|
1: false,
|
||||||
|
2: true,
|
||||||
|
3: false,
|
||||||
|
4: false,
|
||||||
|
});
|
||||||
|
const [runDone, setRunDone] = useState<RunDone>(null);
|
||||||
|
|
||||||
|
function setState(patch: Record<string, any>) {
|
||||||
|
setStateRaw((s) => ({ ...s, ...patch }));
|
||||||
|
}
|
||||||
|
function mark(n: number, v: boolean) {
|
||||||
|
setValidByStep((x) => (x[n] === v ? x : { ...x, [n]: v }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const canNext = step < 4 && validByStep[step];
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
if (step < 4 && canNext) setStep((s) => s + 1);
|
||||||
|
}
|
||||||
|
function prev() {
|
||||||
|
if (step > 1 && step < 4) setStep((s) => s - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryClose() {
|
||||||
|
if (step === 4 && !runDone) {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
"생성 작업이 진행 중입니다. 창을 닫아도 서버에서는 계속 진행되지만, 이 화면은 다시 볼 수 없습니다. 닫을까요?",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (runDone?.success) qc.invalidateQueries({ queryKey: ["companies-stats"] });
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) tryClose();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
background: "rgba(6, 5, 14, 0.5)",
|
||||||
|
backdropFilter: "blur(2px)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
.spin { animation: cspin 0.8s linear infinite; }
|
||||||
|
@keyframes cspin { to { transform: rotate(360deg); } }
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { transform: translateX(-40px); }
|
||||||
|
100% { transform: translateX(calc(100% + 40px)); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "min(1180px, 100%)",
|
||||||
|
height: "min(820px, 100%)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
background: "var(--v5-bg)",
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: "1px solid var(--v5-border)",
|
||||||
|
boxShadow: "0 0 60px rgba(var(--v5-cyan-rgb), 0.14)",
|
||||||
|
fontFamily: "var(--v5-font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 브랜디드 헤더 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.8rem",
|
||||||
|
padding: "0.85rem 1.1rem",
|
||||||
|
background: "var(--v5-surface-solid)",
|
||||||
|
borderBottom: "1px solid var(--v5-border)",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 9,
|
||||||
|
background: "linear-gradient(135deg, var(--v5-cyan), var(--v5-primary))",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "#fff",
|
||||||
|
boxShadow: "0 0 18px rgba(var(--v5-cyan-rgb), 0.4)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Building2 size={16} strokeWidth={1.75} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.55rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.18em",
|
||||||
|
color: "var(--v5-cyan)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
INVION · PROVISIONING
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.92rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
marginTop: 1,
|
||||||
|
color: "var(--v5-text)",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
회사 생성
|
||||||
|
{(state.company_name || state.subdomain) && (
|
||||||
|
<span style={{ color: "var(--v5-text-muted)", fontWeight: 400 }}>
|
||||||
|
{" · "}
|
||||||
|
{state.company_name || state.subdomain}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.58rem",
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
letterSpacing: "0.12em",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
SUPER_ADMIN
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={tryClose}
|
||||||
|
aria-label="닫기"
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 7,
|
||||||
|
border: "1px solid var(--v5-border)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--v5-text-sec)",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
{/* glow line */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: -1,
|
||||||
|
height: 1,
|
||||||
|
background:
|
||||||
|
"linear-gradient(90deg, transparent, var(--v5-cyan), var(--v5-primary), transparent)",
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StepIndicator current={step} />
|
||||||
|
|
||||||
|
{/* 본문 (스크롤) */}
|
||||||
|
<div style={{ flex: 1, overflow: "auto", background: "var(--v5-bg)", minHeight: 0 }}>
|
||||||
|
{step === 1 && <Step1Basic state={state} setState={setState} onValidChange={(v) => mark(1, v)} />}
|
||||||
|
{step === 2 && <Step2Template state={state} setState={setState} onValidChange={(v) => mark(2, v)} />}
|
||||||
|
{step === 3 && <Step3Admin state={state} setState={setState} onValidChange={(v) => mark(3, v)} />}
|
||||||
|
{step === 4 && (
|
||||||
|
<Step4Run
|
||||||
|
state={state}
|
||||||
|
onDone={(r) => {
|
||||||
|
setRunDone(r);
|
||||||
|
if (r.success) qc.invalidateQueries({ queryKey: ["companies-stats"] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer (상황별) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem 1.1rem",
|
||||||
|
borderTop: "1px solid var(--v5-border)",
|
||||||
|
background: "var(--v5-surface-solid)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step < 4 && (
|
||||||
|
<>
|
||||||
|
<Btn variant="ghost" onClick={tryClose}>
|
||||||
|
취소
|
||||||
|
</Btn>
|
||||||
|
<div style={{ flex: 1, fontSize: "0.6rem", color: "var(--v5-text-muted)", textAlign: "center" }}>
|
||||||
|
{validByStep[step] ? "다음 단계로 이동 가능" : "필수 항목을 확인하세요"}
|
||||||
|
</div>
|
||||||
|
<Btn variant="secondary" icon={<ArrowLeft size={11} strokeWidth={1.75} />} onClick={prev} disabled={step <= 1}>
|
||||||
|
이전
|
||||||
|
</Btn>
|
||||||
|
{step < 3 ? (
|
||||||
|
<Btn
|
||||||
|
variant="cyan"
|
||||||
|
onClick={next}
|
||||||
|
disabled={!canNext}
|
||||||
|
icon={<ArrowRight size={11} strokeWidth={1.75} />}
|
||||||
|
iconRight
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</Btn>
|
||||||
|
) : (
|
||||||
|
<Btn variant="cyan" onClick={next} disabled={!canNext} icon={<Rocket size={11} strokeWidth={1.75} />}>
|
||||||
|
생성 시작
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 4 && runDone?.success && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontSize: "0.62rem",
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
생성 완료 · AUDIT event 기록됨
|
||||||
|
</div>
|
||||||
|
<Btn icon={<FileText size={11} strokeWidth={1.75} />}>감사 로그</Btn>
|
||||||
|
<Btn
|
||||||
|
variant="cyan"
|
||||||
|
icon={<ArrowUpRight size={11} strokeWidth={1.75} />}
|
||||||
|
onClick={() => {
|
||||||
|
if (runDone.subdomain) {
|
||||||
|
window.open(`http://${runDone.subdomain}.invyone.com`, "_blank");
|
||||||
|
}
|
||||||
|
tryClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
바로 이동
|
||||||
|
</Btn>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 4 && runDone && !runDone.success && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontSize: "0.62rem",
|
||||||
|
color: "var(--v5-red)",
|
||||||
|
fontWeight: 600,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={11} strokeWidth={1.75} />
|
||||||
|
실패 · DB 가 미완성 상태일 수 있습니다
|
||||||
|
</div>
|
||||||
|
<Btn variant="danger" icon={<Trash2 size={11} strokeWidth={1.75} />}>
|
||||||
|
롤백 (DB 삭제)
|
||||||
|
</Btn>
|
||||||
|
<Btn variant="cyan" icon={<RefreshCw size={11} strokeWidth={1.75} />} onClick={tryClose}>
|
||||||
|
닫기
|
||||||
|
</Btn>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 4 && !runDone && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontSize: "0.62rem",
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Info size={11} strokeWidth={1.75} />
|
||||||
|
생성 중에는 닫아도 서버에서 계속 진행됩니다.
|
||||||
|
</div>
|
||||||
|
<Btn variant="ghost" icon={<XOctagon size={11} strokeWidth={1.75} />} onClick={tryClose}>
|
||||||
|
창 닫기
|
||||||
|
</Btn>
|
||||||
|
<Btn variant="secondary" disabled>
|
||||||
|
진행 중…
|
||||||
|
</Btn>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Btn({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
variant = "secondary",
|
||||||
|
icon,
|
||||||
|
iconRight,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
variant?: "primary" | "secondary" | "ghost" | "cyan" | "danger";
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
iconRight?: boolean;
|
||||||
|
}) {
|
||||||
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
|
primary: {
|
||||||
|
background: "var(--v5-primary)",
|
||||||
|
color: "#fff",
|
||||||
|
borderColor: "var(--v5-primary)",
|
||||||
|
},
|
||||||
|
cyan: {
|
||||||
|
background: "var(--v5-cyan)",
|
||||||
|
color: "#fff",
|
||||||
|
borderColor: "var(--v5-cyan)",
|
||||||
|
boxShadow: disabled ? "none" : "0 0 18px rgba(var(--v5-cyan-rgb), 0.25)",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
background: "var(--v5-surface-solid)",
|
||||||
|
color: "var(--v5-text)",
|
||||||
|
borderColor: "var(--v5-border)",
|
||||||
|
},
|
||||||
|
ghost: {
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--v5-text-sec)",
|
||||||
|
borderColor: "transparent",
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
background: "var(--v5-red)",
|
||||||
|
color: "#fff",
|
||||||
|
borderColor: "var(--v5-red)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={disabled ? undefined : onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
|
height: 30,
|
||||||
|
padding: "0 0.75rem",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
border: "1px solid transparent",
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
transition: "all 0.2s cubic-bezier(0.4,0,0.2,1)",
|
||||||
|
opacity: disabled ? 0.45 : 1,
|
||||||
|
fontFamily: "inherit",
|
||||||
|
...styles[variant],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!iconRight && icon}
|
||||||
|
{children}
|
||||||
|
{iconRight && icon}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마법사 전용 공용 입력 컴포넌트 (시안 v2 포팅).
|
||||||
|
* - Field: label + hint + child input wrapper
|
||||||
|
* - TextInput: 텍스트 입력 (mono, prefix/suffix, status 테두리 글로우)
|
||||||
|
* - CheckAvailBadge: subdomain/db_prefix 실시간 검증 인디케이터
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AvailStatus = "idle" | "checking" | "available" | "taken" | "invalid";
|
||||||
|
|
||||||
|
export function Field({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
required,
|
||||||
|
full,
|
||||||
|
error,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint?: React.ReactNode;
|
||||||
|
required?: boolean;
|
||||||
|
full?: boolean;
|
||||||
|
error?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ gridColumn: full ? "1 / -1" : "auto", display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.55rem",
|
||||||
|
color: error ? "var(--v5-red)" : "var(--v5-text-muted)",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
marginBottom: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
{required && (
|
||||||
|
<span style={{ color: "var(--v5-red)", fontFamily: "sans-serif" }}>•</span>
|
||||||
|
)}
|
||||||
|
{hint && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
textTransform: "none",
|
||||||
|
letterSpacing: 0,
|
||||||
|
marginLeft: "auto",
|
||||||
|
fontSize: "0.58rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
{error && (
|
||||||
|
<div style={{ fontSize: "0.58rem", color: "var(--v5-red)", fontFamily: "var(--v5-font-mono)" }}>{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextInputStatus = "ok" | "err" | "warn";
|
||||||
|
|
||||||
|
export function TextInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
mono,
|
||||||
|
prefix,
|
||||||
|
suffix,
|
||||||
|
readOnly,
|
||||||
|
type = "text",
|
||||||
|
status,
|
||||||
|
size = "md",
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange?: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
mono?: boolean;
|
||||||
|
prefix?: React.ReactNode;
|
||||||
|
suffix?: React.ReactNode;
|
||||||
|
readOnly?: boolean;
|
||||||
|
type?: string;
|
||||||
|
status?: TextInputStatus;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
}) {
|
||||||
|
const border =
|
||||||
|
status === "ok"
|
||||||
|
? "rgb(var(--v5-green-rgb))"
|
||||||
|
: status === "err"
|
||||||
|
? "var(--v5-red)"
|
||||||
|
: status === "warn"
|
||||||
|
? "var(--v5-amber)"
|
||||||
|
: "var(--v5-border)";
|
||||||
|
const glow =
|
||||||
|
status === "ok"
|
||||||
|
? "0 0 0 3px rgba(var(--v5-green-rgb), 0.12)"
|
||||||
|
: status === "err"
|
||||||
|
? "0 0 0 3px rgba(var(--v5-red-rgb), 0.15)"
|
||||||
|
: status === "warn"
|
||||||
|
? "0 0 0 3px rgba(var(--v5-amber-rgb), 0.12)"
|
||||||
|
: "none";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "stretch",
|
||||||
|
border: `1px solid ${border}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: readOnly ? "var(--v5-bg-subtle)" : "var(--v5-surface-solid)",
|
||||||
|
overflow: "hidden",
|
||||||
|
boxShadow: glow,
|
||||||
|
transition: "all 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{prefix && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "0 0.5rem",
|
||||||
|
background: "var(--v5-bg-subtle)",
|
||||||
|
borderRight: "1px solid var(--v5-border)",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
fontSize: "0.62rem",
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{prefix}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
readOnly={readOnly}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: size === "sm" ? "0.3rem 0.45rem" : "0.42rem 0.55rem",
|
||||||
|
background: "transparent",
|
||||||
|
border: 0,
|
||||||
|
outline: "none",
|
||||||
|
fontSize: size === "sm" ? "0.65rem" : "0.7rem",
|
||||||
|
color: "var(--v5-text)",
|
||||||
|
fontFamily: mono ? "var(--v5-font-mono)" : "inherit",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{suffix && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "0 0.5rem",
|
||||||
|
background: "var(--v5-bg-subtle)",
|
||||||
|
borderLeft: "1px solid var(--v5-border)",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
fontSize: "0.62rem",
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{suffix}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckAvailBadge({ status, value }: { status: AvailStatus; value?: string }) {
|
||||||
|
if (!value || status === "idle")
|
||||||
|
return <span style={{ fontSize: "0.58rem", color: "var(--v5-text-muted)" }}>입력 후 자동 확인</span>;
|
||||||
|
if (status === "checking")
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
fontSize: "0.58rem",
|
||||||
|
color: "var(--v5-text-muted)",
|
||||||
|
fontFamily: "var(--v5-font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loader2 size={10} className="spin" /> 중복 확인 중…
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
if (status === "available")
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
fontSize: "0.58rem",
|
||||||
|
color: "rgb(var(--v5-green-rgb))",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={11} /> 사용 가능
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
if (status === "taken")
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
fontSize: "0.58rem",
|
||||||
|
color: "var(--v5-red)",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XCircle size={11} /> 이미 사용 중
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
fontSize: "0.58rem",
|
||||||
|
color: "var(--v5-amber)",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XCircle size={11} /> 형식 오류
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TextInput 의 status prop 과 매핑 */
|
||||||
|
export function availToInputStatus(a: AvailStatus): TextInputStatus | undefined {
|
||||||
|
if (a === "available") return "ok";
|
||||||
|
if (a === "taken" || a === "invalid") return "err";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function genPassword(length = 12): string {
|
||||||
|
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789@#$";
|
||||||
|
const arr = new Uint32Array(length);
|
||||||
|
if (typeof window !== "undefined" && window.crypto) {
|
||||||
|
window.crypto.getRandomValues(arr);
|
||||||
|
}
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
out += chars[arr[i] % chars.length];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
+20
-17
@@ -11,28 +11,31 @@ const authLog = (event: string, detail: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// API URL 동적 설정 - 환경변수 우선 사용
|
// API URL 동적 설정
|
||||||
|
// 우선순위: 1) 테넌트 서브도메인 → 직접 백엔드 2) 프로덕션 도메인 3) NEXT_PUBLIC_API_URL 4) default
|
||||||
const getApiBaseUrl = (): string => {
|
const getApiBaseUrl = (): string => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const currentHost = window.location.hostname;
|
||||||
|
|
||||||
|
// 1. 테넌트 서브도메인 (*.invyone.com) 은 Next.js rewrite 우회 필수.
|
||||||
|
// NEXT_PUBLIC_API_URL=/api 쓰면 rewrite 가 Host 헤더를 invyone-backend-spring:8081 로
|
||||||
|
// 변조해서 서브도메인 파싱이 실패함. 직접 8083 으로 보내서 Host 헤더 보존.
|
||||||
|
if (currentHost.endsWith(".invyone.com")) {
|
||||||
|
return `http://${currentHost}:8083/api`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 프로덕션 메인 도메인
|
||||||
|
if (currentHost === "v1.invion.com") {
|
||||||
|
return "https://api.invion.com/api";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 환경변수 (docker-compose 에서 /api 로 주입되면 Next rewrite 사용)
|
||||||
if (process.env.NEXT_PUBLIC_API_URL) {
|
if (process.env.NEXT_PUBLIC_API_URL) {
|
||||||
return process.env.NEXT_PUBLIC_API_URL;
|
return process.env.NEXT_PUBLIC_API_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
// 4. 로컬 기본
|
||||||
const currentHost = window.location.hostname;
|
|
||||||
const currentPort = window.location.port;
|
|
||||||
|
|
||||||
if (currentHost === "v1.invion.com") {
|
|
||||||
return "https://api.invion.com/api";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(currentHost === "localhost" || currentHost === "127.0.0.1") &&
|
|
||||||
(currentPort === "9771" || currentPort === "3000")
|
|
||||||
) {
|
|
||||||
return "http://localhost:8081/api";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "http://localhost:8081/api";
|
return "http://localhost:8081/api";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Phase 3-A 프로비저닝 API 클라이언트.
|
||||||
|
* 백엔드: /api/admin/provisioning/*
|
||||||
|
* - GET /table-groups - 마법사 Step 2 체크박스 렌더
|
||||||
|
* - GET /check - subdomain/db_prefix/company_code 실시간 검증
|
||||||
|
* - POST /companies - 회사 생성 (202 accepted)
|
||||||
|
* - GET /status/{id} - 진행 상태 폴링
|
||||||
|
* - GET /companies-stats - 메인 화면 accordion 렌더용 (derived 필드 포함)
|
||||||
|
*/
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export type CompanyStats = Record<string, any>;
|
||||||
|
|
||||||
|
export async function getCompaniesStats(): Promise<CompanyStats[]> {
|
||||||
|
const { data } = await apiClient.get("/admin/provisioning/companies-stats");
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTableGroups(): Promise<Record<string, any>[]> {
|
||||||
|
const { data } = await apiClient.get("/admin/provisioning/table-groups");
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckParams {
|
||||||
|
subdomain?: string;
|
||||||
|
dbPrefix?: string;
|
||||||
|
companyCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkAvailability(params: CheckParams): Promise<Record<string, any>> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params.subdomain) q.set("subdomain", params.subdomain);
|
||||||
|
if (params.dbPrefix) q.set("dbPrefix", params.dbPrefix);
|
||||||
|
if (params.companyCode) q.set("companyCode", params.companyCode);
|
||||||
|
const { data } = await apiClient.get(`/admin/provisioning/check?${q.toString()}`);
|
||||||
|
return data || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCompanyRequest {
|
||||||
|
company_code: string;
|
||||||
|
company_name: string;
|
||||||
|
subdomain: string;
|
||||||
|
db_prefix: string;
|
||||||
|
business_registration_number?: string;
|
||||||
|
representative_name?: string;
|
||||||
|
representative_phone?: string;
|
||||||
|
email?: string;
|
||||||
|
website?: string;
|
||||||
|
address?: string;
|
||||||
|
selected_groups?: string[];
|
||||||
|
initial_password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCompanyResponse {
|
||||||
|
provisioning_id: string;
|
||||||
|
company_code: string;
|
||||||
|
db_name: string;
|
||||||
|
subdomain: string;
|
||||||
|
admin_user_id: string;
|
||||||
|
initial_password: string;
|
||||||
|
status_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCompany(req: CreateCompanyRequest): Promise<CreateCompanyResponse> {
|
||||||
|
const { data } = await apiClient.post("/admin/provisioning/companies", req);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProvisioningStatus(jobId: string): Promise<Record<string, any>> {
|
||||||
|
const { data } = await apiClient.get(`/admin/provisioning/status/${jobId}`);
|
||||||
|
return data || {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# 회사관리 UI 스키마 & 데이터 소스 레퍼런스
|
||||||
|
|
||||||
|
작성일: 2026-04-24
|
||||||
|
작성자: gbpark
|
||||||
|
대상 UI: `admin-companyList-v9-systemized.jsx` (v9 accordion 메인 화면)
|
||||||
|
관련 마이그레이션: `db/migrations/RUN_081_MIGRATION.md`
|
||||||
|
관련 실행계획: `notes/gbpark/2026-04-24-company-db-provisioning-execution-plan.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 요약
|
||||||
|
|
||||||
|
목업 v9 accordion row 가 요구하는 **16개 필드** 중:
|
||||||
|
- **8개**는 `COMPANY_MNG` 정적 컬럼 (기존 + 081 에서 추가)
|
||||||
|
- **4개**는 런타임 집계 (`CompanyStatsService` 가 계산)
|
||||||
|
- **2개**는 MVP placeholder (추후 데이터 파이프라인 생기면 채움)
|
||||||
|
- **2개**는 화면에서 derive (표시 로직)
|
||||||
|
|
||||||
|
신규 API: `GET /api/admin/provisioning/companies-stats`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목업 ↔ 데이터 소스 매핑
|
||||||
|
|
||||||
|
### 1.1 정적 필드 (COMPANY_MNG 컬럼)
|
||||||
|
|
||||||
|
| 목업 필드 | COMPANY_MNG 컬럼 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| `code` | `COMPANY_CODE` | 기존 |
|
||||||
|
| `name` | `COMPANY_NAME` | 기존 |
|
||||||
|
| `sub` | `SUBDOMAIN` | 079 추가 |
|
||||||
|
| `brn` | `BUSINESS_REGISTRATION_NUMBER` | 기존 |
|
||||||
|
| `owner` | `REPRESENTATIVE_NAME` | 기존. 목업이 `owner` 이지만 그대로 매핑 |
|
||||||
|
| `created` | `CREATED_DATE` | 기존 |
|
||||||
|
| `writer` | `WRITER` | 기존 |
|
||||||
|
| **`plan`** | `PLAN` | ★ 081 추가. 기본값 `'Starter'` |
|
||||||
|
| **`industry`** | `INDUSTRY` | ★ 081 추가. 기본 NULL |
|
||||||
|
| **`templates`** | `TEMPLATES_COUNT` | ★ 081 추가. 프로비저닝 시 `CompanyProvisioningService.initiate()` 가 "필수(3) + 선택 n" 합산해서 저장 |
|
||||||
|
|
||||||
|
추가로 DB 라우팅 관련 (079):
|
||||||
|
| 목업에 없지만 UI 에서 파생 | 소스 |
|
||||||
|
|---|---|
|
||||||
|
| `{prefix}.invyone.com` 프리뷰 | `SUBDOMAIN` + 하드코딩 도메인 |
|
||||||
|
| `{prefix}_vexplor` DB 명 | `DB_NAME` |
|
||||||
|
| 상태 dot | `DB_STATUS` |
|
||||||
|
|
||||||
|
### 1.2 런타임 집계 (CompanyStatsService)
|
||||||
|
|
||||||
|
| 목업 필드 | 계산 방법 | 구현 파일:함수 |
|
||||||
|
|---|---|---|
|
||||||
|
| `users` | tenant DB `SELECT count(*) FROM user_info` | `CompanyStatsService.enrichOne()` |
|
||||||
|
| `db_size_bytes` (숨김) | 메타 서버 `SELECT pg_database_size(?)` | 같음 |
|
||||||
|
| `db_size` ("4.2 GB" 포맷) | `formatBytes(bytes)` | 같음 |
|
||||||
|
| `db_pct` (0~100) | `bytes / (DB_QUOTA_GB * 1GB) * 100` | 같음 |
|
||||||
|
|
||||||
|
`DB_QUOTA_GB` 는 081 에서 추가된 컬럼. 회사별 할당량. 기본 20 GB. 추후 플랜별 자동 결정 로직 추가 가능.
|
||||||
|
|
||||||
|
### 1.3 MVP Placeholder (나중에 구현)
|
||||||
|
|
||||||
|
| 목업 필드 | 현재 값 | 언제 채움 |
|
||||||
|
|---|---|---|
|
||||||
|
| `active30` | `0` | `user_info` 또는 별도 `login_history` 테이블에 `last_login_date` 컬럼 생기면 ` WHERE last_login_date > NOW() - INTERVAL '30 days'` 로 구현 |
|
||||||
|
| `spark` | `[]` (빈 배열) | 일자별 사용자 집계 테이블 (`daily_active_users` 같은) 생기면 최근 12일 데이터 반환 |
|
||||||
|
|
||||||
|
### 1.4 화면에서 derive (백엔드 추가 작업 없음)
|
||||||
|
|
||||||
|
| 목업 필드 | 계산 |
|
||||||
|
|---|---|
|
||||||
|
| `url_preview` (`qnc.invyone.com`) | `${subdomain}.invyone.com` |
|
||||||
|
| `db_name_display` (`qnc_vexplor`) | `db_name` (이미 있음) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 신규 API
|
||||||
|
|
||||||
|
### `GET /api/admin/provisioning/companies-stats`
|
||||||
|
|
||||||
|
회사관리 v9 accordion 메인 화면 전용.
|
||||||
|
|
||||||
|
**권한**: SUPER_ADMIN (개발 모드 익명 허용)
|
||||||
|
|
||||||
|
**응답 예시**:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"company_code": "COMPANY_10",
|
||||||
|
"company_name": "큐엔씨",
|
||||||
|
"subdomain": "qnc",
|
||||||
|
"db_name": "qnc_vexplor",
|
||||||
|
"db_host": "183.99.177.40",
|
||||||
|
"db_status": "active",
|
||||||
|
"status": "active",
|
||||||
|
"plan": "Starter",
|
||||||
|
"industry": null,
|
||||||
|
"owner": "정대표",
|
||||||
|
"brn": "456-78-90123",
|
||||||
|
"email": "admin@qnc.kr",
|
||||||
|
"templates": 3,
|
||||||
|
"db_quota_gb": 20,
|
||||||
|
"created": "2026-04-24T...",
|
||||||
|
"writer": "SUPER_ADMIN",
|
||||||
|
|
||||||
|
"users": 31,
|
||||||
|
"active30": 0,
|
||||||
|
"db_size": "820 MB",
|
||||||
|
"db_size_bytes": 859963392,
|
||||||
|
"db_pct": 4,
|
||||||
|
"spark": []
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
정렬: `CREATED_DATE DESC`.
|
||||||
|
|
||||||
|
**N+1 주의**: 회사 N 개 → tenant DB N 번 + 메타 서버 N 번 접속. 현재 MVP 직렬 실행. 회사 50 개 넘어가면 병렬(`CompletableFuture`) 또는 5분 캐시 도입.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 081 마이그레이션 요약
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE COMPANY_MNG
|
||||||
|
ADD COLUMN IF NOT EXISTS PLAN VARCHAR(20) DEFAULT 'Starter',
|
||||||
|
ADD COLUMN IF NOT EXISTS INDUSTRY VARCHAR(50),
|
||||||
|
ADD COLUMN IF NOT EXISTS TEMPLATES_COUNT INT DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS DB_QUOTA_GB INT DEFAULT 20;
|
||||||
|
```
|
||||||
|
|
||||||
|
- `PLAN` 기본 'Starter' → 기존 회사들도 자동 'Starter'.
|
||||||
|
- `TEMPLATES_COUNT` 는 신규 프로비저닝부터만 채워짐. 기존 회사들은 0.
|
||||||
|
- `INDUSTRY` NULL → UI 에서 "—" 로 표시.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 관련 코드 변경
|
||||||
|
|
||||||
|
### 신규
|
||||||
|
- `com/erp/provisioning/CompanyStatsService.java` — derived 집계
|
||||||
|
- `mapper/provisioning.xml` → `listCompaniesForUi` select 추가
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
- `com/erp/provisioning/CompanyProvisioningService.java` → `initiate()` 에 templates_count 계산
|
||||||
|
- `com/erp/provisioning/ProvisioningController.java` → `/companies-stats` 엔드포인트 + `CompanyStatsService` 주입
|
||||||
|
- `mapper/provisioning.xml` → `insertCompanyWithTenant` 에 `PLAN`, `INDUSTRY`, `TEMPLATES_COUNT` 컬럼 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Phase 3-B 프론트가 할 일 정리
|
||||||
|
|
||||||
|
1. `GET /api/admin/provisioning/companies-stats` 호출해 회사 목록 획득
|
||||||
|
2. 응답 스키마 그대로 `R9` mock 자리에 주입
|
||||||
|
3. 필요 시 AccRow 가 `sub` 변수 대신 `subdomain` 사용하도록 네이밍 일관화
|
||||||
|
|
||||||
|
### 미구현 필드 표시 정책
|
||||||
|
|
||||||
|
- `active30 = 0` 이면 표시하되 "—" 로 회색 처리 추천
|
||||||
|
- `spark.length === 0` 이면 기존 Spark 컴포넌트가 "—" 반환 (이미 목업에 로직 있음)
|
||||||
|
- `industry === null` 이면 "—" 표시
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 추후 작업 항목
|
||||||
|
|
||||||
|
| 항목 | 우선순위 | 트리거 |
|
||||||
|
|---|---|---|
|
||||||
|
| `active30` 구현 | 중 | `user_info.last_login_date` 컬럼 또는 `login_history` 테이블 생성 |
|
||||||
|
| `spark` 일자별 집계 | 낮 | 일 단위 배치로 `daily_active_users` 테이블 생성 |
|
||||||
|
| `companies-stats` 병렬 집계 | 중 | 회사 수 50 개 넘으면 |
|
||||||
|
| `industry` 선택 드롭다운 | 낮 | 마법사 Step1 에 "업종" 필드 추가 시 |
|
||||||
|
| `plan` 선택 UI | 낮 | 과금 시스템 도입 시점 |
|
||||||
|
| `DB_QUOTA_GB` 플랜 연동 | 낮 | 플랜별 자동 할당 (Starter=5, Standard=20, Enterprise=100) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
이 문서는 나중에 위 필드들을 실제 데이터로 채울 때 **체크리스트**로 쓰면 됨. 컬럼은 다 깔려있으니 추가 마이그레이션 없이 값만 채우면 화면이 바로 살아남.
|
||||||
Reference in New Issue
Block a user