중간 세이브

This commit is contained in:
2026-04-10 13:33:37 +09:00
parent c6e81c4520
commit 9c36191ebf
97 changed files with 13844 additions and 482 deletions
@@ -0,0 +1,108 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.BusinessRuleService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequiredArgsConstructor
@Slf4j
public class BusinessRuleController {
private final BusinessRuleService businessRuleService;
/**
* GET /api/dashboards/{dashboardId}/rules — 해당 대시보드의 룰 목록
*/
@GetMapping("/api/dashboards/{dashboardId}/rules")
public ResponseEntity<ApiResponse<Map<String, Object>>> getBusinessRuleList(
@PathVariable String dashboardId,
@RequestAttribute("company_code") String companyCode,
@RequestParam(required = false, defaultValue = "1") int page,
@RequestParam(required = false, defaultValue = "100") int limit) {
Map<String, Object> params = new HashMap<>();
params.put("dashboard_id", dashboardId);
params.put("company_code", companyCode);
params.put("page", page);
params.put("limit", limit);
return ResponseEntity.ok(ApiResponse.success(businessRuleService.getBusinessRuleList(params)));
}
/**
* GET /api/rules/{ruleId} — 룰 상세 (노드 + 연결)
*/
@GetMapping("/api/rules/{ruleId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getBusinessRuleInfo(
@PathVariable String ruleId) {
Map<String, Object> params = new HashMap<>();
params.put("rule_id", ruleId);
Map<String, Object> result = businessRuleService.getBusinessRuleInfo(params);
if (result == null) {
return ResponseEntity.ok(ApiResponse.error("Rule not found"));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
/**
* POST /api/dashboards/{dashboardId}/rules — 룰 생성
*/
@PostMapping("/api/dashboards/{dashboardId}/rules")
public ResponseEntity<ApiResponse<Map<String, Object>>> insertBusinessRule(
@PathVariable String dashboardId,
@RequestBody Map<String, Object> body,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId) {
body.put("dashboard_id", dashboardId);
body.put("company_code", companyCode);
body.put("user_id", userId);
return ResponseEntity.ok(ApiResponse.success(businessRuleService.insertBusinessRule(body)));
}
/**
* PUT /api/rules/{ruleId} — 룰 수정
*/
@PutMapping("/api/rules/{ruleId}")
public ResponseEntity<ApiResponse<Void>> updateBusinessRule(
@PathVariable String ruleId,
@RequestBody Map<String, Object> body,
@RequestAttribute("user_id") String userId) {
body.put("rule_id", ruleId);
body.put("user_id", userId);
businessRuleService.updateBusinessRule(body);
return ResponseEntity.ok(ApiResponse.success(null));
}
/**
* DELETE /api/rules/{ruleId} — 룰 삭제
*/
@DeleteMapping("/api/rules/{ruleId}")
public ResponseEntity<ApiResponse<Void>> deleteBusinessRule(
@PathVariable String ruleId,
@RequestAttribute("user_id") String userId) {
Map<String, Object> params = new HashMap<>();
params.put("rule_id", ruleId);
params.put("user_id", userId);
businessRuleService.deleteBusinessRule(params);
return ResponseEntity.ok(ApiResponse.success(null));
}
/**
* PUT /api/rules/{ruleId}/toggle — 활성/비활성 토글
*/
@PutMapping("/api/rules/{ruleId}/toggle")
public ResponseEntity<ApiResponse<Void>> toggleBusinessRule(
@PathVariable String ruleId,
@RequestAttribute("user_id") String userId) {
Map<String, Object> params = new HashMap<>();
params.put("rule_id", ruleId);
params.put("user_id", userId);
businessRuleService.toggleBusinessRule(params);
return ResponseEntity.ok(ApiResponse.success(null));
}
}
@@ -2,165 +2,137 @@ package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.DashboardService;
import com.erp.service.ExternalRestApiConnectionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/dashboard")
@RequestMapping("/api/dashboards")
@RequiredArgsConstructor
@Slf4j
public class DashboardController {
private final DashboardService dashboardService;
private final ExternalRestApiConnectionService externalRestApiConnectionService;
// ═══ 대시보드 CRUD ═══
@GetMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> getDashboards(
public ResponseEntity<ApiResponse<Map<String, Object>>> getDashboardList(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
@RequestAttribute("user_id") String userId,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "50") int limit) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("user_id", userId);
params.put("keyword", keyword);
params.put("page", page);
params.put("limit", limit);
return ResponseEntity.ok(ApiResponse.success(dashboardService.getDashboardList(params)));
}
@GetMapping("/list")
public ResponseEntity<ApiResponse<Map<String, Object>>> getDashboardsLegacy(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(dashboardService.getDashboardList(params)));
}
@GetMapping("/public")
public ResponseEntity<ApiResponse<Map<String, Object>>> getPublicDashboards(
@RequestParam Map<String, Object> params) {
params.put("company_code", "*");
params.put("is_public", "true");
return ResponseEntity.ok(ApiResponse.success(dashboardService.getDashboardList(params)));
}
@GetMapping("/my")
public ResponseEntity<ApiResponse<Map<String, Object>>> getMyDashboards(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(dashboardService.getDashboardList(params)));
}
@GetMapping("/public/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getPublicDashboard(
@PathVariable String id) {
Map<String, Object> result = dashboardService.getDashboardById(id, null);
if (result == null) return ResponseEntity.status(404).body(ApiResponse.error("대시보드를 찾을 수 없습니다."));
return ResponseEntity.ok(ApiResponse.success(result));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getDashboard(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "user_id", required = false) String userId,
@PathVariable String id) {
Map<String, Object> result = dashboardService.getDashboardById(id, companyCode);
if (result == null) return ResponseEntity.status(404).body(ApiResponse.error("대시보드를 찾을 수 없거나 접근 권한이 없습니다."));
// 다른 사람 것 조회 시 조회수 증가
if (userId != null && !userId.equals(result.get("created_by"))) {
dashboardService.incrementViewCount(id);
@GetMapping("/{dashboardId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getDashboardInfo(
@PathVariable String dashboardId) {
Map<String, Object> params = new HashMap<>();
params.put("dashboard_id", dashboardId);
Map<String, Object> result = dashboardService.getDashboardInfo(params);
if (result == null) {
return ResponseEntity.ok(ApiResponse.error("대시보드를 찾을 수 없습니다"));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
@PostMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> createDashboard(
public ResponseEntity<ApiResponse<Map<String, Object>>> insertDashboard(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "user_id", required = false) String userId,
@RequestAttribute("user_id") String userId,
@RequestBody Map<String, Object> body) {
String title = (String) body.get("title");
if (title == null || title.isBlank()) return ResponseEntity.status(400).body(ApiResponse.error("대시보드 제목이 필요합니다."));
Map<String, Object> result = dashboardService.createDashboard(body, userId, companyCode);
return ResponseEntity.status(201).body(ApiResponse.success(result, "대시보드가 생성되었습니다."));
body.put("company_code", companyCode);
body.put("user_id", userId);
return ResponseEntity.ok(ApiResponse.success(dashboardService.insertDashboard(body)));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateDashboard(
@PathVariable String id,
@RequestAttribute(value = "user_id", required = false) String userId,
@PutMapping("/{dashboardId}")
public ResponseEntity<ApiResponse<Void>> updateDashboard(
@PathVariable String dashboardId,
@RequestAttribute("user_id") String userId,
@RequestBody Map<String, Object> body) {
Map<String, Object> result = dashboardService.updateDashboard(id, body, userId);
if (result == null) return ResponseEntity.status(404).body(ApiResponse.error("대시보드를 찾을 수 없거나 수정 권한이 없습니다."));
return ResponseEntity.ok(ApiResponse.success(result, "대시보드가 수정되었습니다."));
body.put("dashboard_id", dashboardId);
body.put("user_id", userId);
dashboardService.updateDashboard(body);
return ResponseEntity.ok(ApiResponse.success(null, "수정 완료"));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteDashboard(
@PathVariable String id,
@RequestAttribute(value = "user_id", required = false) String userId) {
boolean deleted = dashboardService.deleteDashboard(id, userId);
if (!deleted) return ResponseEntity.status(404).body(ApiResponse.error("대시보드를 찾을 수 없거나 삭제 권한이 없습니다."));
return ResponseEntity.ok(ApiResponse.success(null, "대시보드가 삭제되었습니다."));
@DeleteMapping("/{dashboardId}")
public ResponseEntity<ApiResponse<Void>> deleteDashboard(
@PathVariable String dashboardId,
@RequestAttribute("user_id") String userId) {
Map<String, Object> params = new HashMap<>();
params.put("dashboard_id", dashboardId);
params.put("user_id", userId);
dashboardService.deleteDashboard(params);
return ResponseEntity.ok(ApiResponse.success(null, "삭제 완료"));
}
@PostMapping("/execute-query")
public ResponseEntity<ApiResponse<Map<String, Object>>> executeQuery(
// ═══ 카드 CRUD ═══
@GetMapping("/{dashboardId}/cards")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getDashboardCards(
@PathVariable String dashboardId) {
Map<String, Object> params = new HashMap<>();
params.put("dashboard_id", dashboardId);
return ResponseEntity.ok(ApiResponse.success(dashboardService.getDashboardCardList(params)));
}
@PostMapping("/{dashboardId}/cards")
public ResponseEntity<ApiResponse<Map<String, Object>>> insertDashboardCard(
@PathVariable String dashboardId,
@RequestBody Map<String, Object> body) {
String query = (String) body.get("query");
if (query == null || query.isBlank()) return ResponseEntity.status(400).body(ApiResponse.error("쿼리가 필요합니다."));
try {
Map<String, Object> result = dashboardService.executeQuery(query);
return ResponseEntity.ok(ApiResponse.success(result, "쿼리가 실행되었습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
body.put("dashboard_id", dashboardId);
return ResponseEntity.ok(ApiResponse.success(dashboardService.insertDashboardCard(body)));
}
@PostMapping("/execute-dml")
public ResponseEntity<ApiResponse<Map<String, Object>>> executeDml(
@PutMapping("/{dashboardId}/cards/{cardId}")
public ResponseEntity<ApiResponse<Void>> updateDashboardCard(
@PathVariable String dashboardId,
@PathVariable String cardId,
@RequestBody Map<String, Object> body) {
String query = (String) body.get("query");
if (query == null || query.isBlank()) return ResponseEntity.status(400).body(ApiResponse.error("쿼리가 필요합니다."));
try {
Map<String, Object> result = dashboardService.executeDml(query);
return ResponseEntity.ok(ApiResponse.success(result, "쿼리가 실행되었습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
body.put("dashboard_id", dashboardId);
body.put("card_id", cardId);
dashboardService.updateDashboardCard(body);
return ResponseEntity.ok(ApiResponse.success(null));
}
@PostMapping("/fetch-external-api")
public ResponseEntity<ApiResponse<Object>> fetchExternalApi(
@DeleteMapping("/{dashboardId}/cards/{cardId}")
public ResponseEntity<ApiResponse<Void>> deleteDashboardCard(
@PathVariable String dashboardId,
@PathVariable String cardId) {
Map<String, Object> params = new HashMap<>();
params.put("card_id", cardId);
dashboardService.deleteDashboardCard(params);
return ResponseEntity.ok(ApiResponse.success(null));
}
@PutMapping("/{dashboardId}/cards/batch")
public ResponseEntity<ApiResponse<Void>> updateCardPositions(
@PathVariable String dashboardId,
@RequestBody Map<String, Object> body) {
dashboardService.updateCardPositions(body);
return ResponseEntity.ok(ApiResponse.success(null, "일괄 업데이트 완료"));
}
// ═══ 사이드바 메뉴 ═══
@GetMapping("/sidebar/menu")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getSidebarMenu(
@RequestAttribute("company_code") String companyCode,
@RequestBody Map<String, Object> body) {
String url = (String) body.get("url");
if (url == null || url.isBlank()) return ResponseEntity.status(400).body(ApiResponse.error("URL이 필요합니다."));
Object externalConnectionId = body.get("external_connection_id");
Map<String, Object> requestParams = new HashMap<>(body);
requestParams.put("company_code", companyCode);
if (externalConnectionId != null) {
int connId = Integer.parseInt(String.valueOf(externalConnectionId));
Map<String, Object> result = externalRestApiConnectionService.fetchData(connId, url, null, requestParams);
return ResponseEntity.ok(ApiResponse.success(result));
}
// 커넥션 없이 직접 호출 - 기본 응답
Map<String, Object> result = new LinkedHashMap<>();
result.put("url", url);
result.put("message", "externalConnectionId가 필요합니다.");
return ResponseEntity.ok(ApiResponse.success(result));
}
@PostMapping("/table-schema")
public ResponseEntity<ApiResponse<Map<String, Object>>> getTableSchema(
@RequestBody Map<String, Object> body) {
String tableName = (String) body.get("table_name");
if (tableName == null || tableName.isBlank()) return ResponseEntity.status(400).body(ApiResponse.error("테이블명이 필요합니다."));
try {
Map<String, Object> result = dashboardService.getTableSchema(tableName);
return ResponseEntity.ok(ApiResponse.success(result));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
@RequestAttribute("user_id") String userId) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("user_id", userId);
return ResponseEntity.ok(ApiResponse.success(dashboardService.getSidebarMenu(params)));
}
}
@@ -0,0 +1,58 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.MetaService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/meta")
@RequiredArgsConstructor
@Slf4j
public class MetaController {
private final MetaService metaService;
/**
* GET /api/meta/tables — 접근 가능한 테이블 목록
*/
@GetMapping("/tables")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getMetaTableList(
@RequestAttribute("company_code") String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(metaService.getMetaTableList(params)));
}
/**
* GET /api/meta/tables/{tableName}/fields — 특정 테이블의 FieldConfig[] 반환
*/
@GetMapping("/tables/{tableName}/fields")
public ResponseEntity<ApiResponse<Map<String, Object>>> getMetaFields(
@PathVariable String tableName,
@RequestAttribute("company_code") String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(metaService.getMetaFields(params)));
}
/**
* GET /api/meta/tables/{tableName}/relations — 테이블 간 업무 관계 (Phase 5 제어 모드)
*/
@GetMapping("/tables/{tableName}/relations")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getMetaRelations(
@PathVariable String tableName,
@RequestAttribute("company_code") String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(metaService.getMetaRelations(params)));
}
}
@@ -0,0 +1,104 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.TemplateService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/templates")
@RequiredArgsConstructor
@Slf4j
public class TemplateController {
private final TemplateService templateService;
/**
* GET /api/templates — 템플릿 목록
*/
@GetMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> getTemplateList(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.putIfAbsent("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(templateService.getTemplateList(params)));
}
/**
* GET /api/templates/{templateId} — 템플릿 상세
*/
@GetMapping("/{templateId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getTemplateInfo(
@PathVariable String templateId,
@RequestAttribute("company_code") String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("template_id", templateId);
params.put("company_code", companyCode);
Map<String, Object> result = templateService.getTemplateInfo(params);
if (result == null) {
return ResponseEntity.ok(ApiResponse.error("템플릿을 찾을 수 없습니다"));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
/**
* POST /api/templates — 템플릿 생성
*/
@PostMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> insertTemplate(
@RequestBody Map<String, Object> params,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId) {
params.put("company_code", companyCode);
params.put("user_id", userId);
Map<String, Object> result = templateService.insertTemplate(params);
return ResponseEntity.ok(ApiResponse.success(result));
}
/**
* PUT /api/templates/{templateId} — 템플릿 수정
*/
@PutMapping("/{templateId}")
public ResponseEntity<ApiResponse<Void>> updateTemplate(
@PathVariable String templateId,
@RequestBody Map<String, Object> params,
@RequestAttribute("user_id") String userId) {
params.put("template_id", templateId);
params.put("user_id", userId);
templateService.updateTemplate(params);
return ResponseEntity.ok(ApiResponse.success(null));
}
/**
* PUT /api/templates/{templateId}/publish — 템플릿 게시
*/
@PutMapping("/{templateId}/publish")
public ResponseEntity<ApiResponse<Void>> publishTemplate(
@PathVariable String templateId,
@RequestAttribute("user_id") String userId) {
Map<String, Object> params = new HashMap<>();
params.put("template_id", templateId);
params.put("user_id", userId);
templateService.publishTemplate(params);
return ResponseEntity.ok(ApiResponse.success(null));
}
/**
* DELETE /api/templates/{templateId} — 템플릿 삭제 (소프트)
*/
@DeleteMapping("/{templateId}")
public ResponseEntity<ApiResponse<Void>> deleteTemplate(
@PathVariable String templateId,
@RequestAttribute("user_id") String userId) {
Map<String, Object> params = new HashMap<>();
params.put("template_id", templateId);
params.put("user_id", userId);
templateService.deleteTemplate(params);
return ResponseEntity.ok(ApiResponse.success(null));
}
}
@@ -0,0 +1,48 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.UserOverrideService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/overrides")
@RequiredArgsConstructor
public class UserOverrideController {
private final UserOverrideService userOverrideService;
@GetMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> getUserOverride(
@RequestAttribute("user_id") String userId,
@RequestParam String card_id) {
Map<String, Object> params = new HashMap<>();
params.put("user_id", userId);
params.put("card_id", card_id);
Map<String, Object> result = userOverrideService.getUserOverride(params);
return ResponseEntity.ok(ApiResponse.success(result));
}
@PutMapping
public ResponseEntity<ApiResponse<Void>> upsertUserOverride(
@RequestAttribute("user_id") String userId,
@RequestBody Map<String, Object> body) {
body.put("user_id", userId);
userOverrideService.upsertUserOverride(body);
return ResponseEntity.ok(ApiResponse.success(null, "저장 완료"));
}
@DeleteMapping
public ResponseEntity<ApiResponse<Void>> deleteUserOverride(
@RequestAttribute("user_id") String userId,
@RequestParam String card_id) {
Map<String, Object> params = new HashMap<>();
params.put("user_id", userId);
params.put("card_id", card_id);
userOverrideService.deleteUserOverride(params);
return ResponseEntity.ok(ApiResponse.success(null, "삭제 완료"));
}
}
@@ -0,0 +1,94 @@
package com.erp.service;
import com.erp.common.BaseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Service
@Slf4j
public class BusinessRuleService extends BaseService {
@Autowired
private CommonService commonService;
@Autowired
private ObjectMapper objectMapper;
private static final String NS = "businessRule.";
public Map<String, Object> getBusinessRuleList(Map<String, Object> params) {
commonService.applyPagination(params);
int totalCount = sqlSession.selectOne(NS + "getBusinessRuleListCnt", params);
List<Map<String, Object>> list = sqlSession.selectList(NS + "getBusinessRuleList", params);
return commonService.buildListResponse(list, totalCount, params);
}
public Map<String, Object> getBusinessRuleInfo(Map<String, Object> params) {
Map<String, Object> row = sqlSession.selectOne(NS + "getBusinessRuleInfo", params);
if (row != null) {
parseJsonField(row, "nodes");
parseJsonField(row, "connections");
}
return row;
}
@Transactional
public Map<String, Object> insertBusinessRule(Map<String, Object> params) {
String ruleId = "rule_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
params.put("rule_id", ruleId);
stringifyJsonField(params, "nodes");
stringifyJsonField(params, "connections");
sqlSession.insert(NS + "insertBusinessRule", params);
return Map.of("rule_id", ruleId);
}
@Transactional
public void updateBusinessRule(Map<String, Object> params) {
stringifyJsonField(params, "nodes");
stringifyJsonField(params, "connections");
sqlSession.update(NS + "updateBusinessRule", params);
}
@Transactional
public int deleteBusinessRule(Map<String, Object> params) {
return sqlSession.update(NS + "deleteBusinessRule", params);
}
@Transactional
public void toggleBusinessRule(Map<String, Object> params) {
sqlSession.update(NS + "toggleBusinessRule", params);
}
// ── JSONB 유틸 ──
private void parseJsonField(Map<String, Object> row, String key) {
Object val = row.get(key);
if (val instanceof String) {
try {
row.put(key, objectMapper.readValue((String) val, Object.class));
} catch (Exception e) {
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
}
}
}
private void stringifyJsonField(Map<String, Object> params, String key) {
Object val = params.get(key);
if (val != null && !(val instanceof String)) {
try {
params.put(key, objectMapper.writeValueAsString(val));
} catch (Exception e) {
log.warn("Failed to stringify field '{}': {}", key, e.getMessage());
params.put(key, "[]");
}
}
if (val == null) {
params.put(key, "[]");
}
}
}
@@ -1,347 +1,106 @@
package com.erp.service;
import com.erp.common.BaseService;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Service
@Slf4j
public class DashboardService extends BaseService {
@Autowired private JdbcTemplate jdbcTemplate;
@Autowired private ObjectMapper objectMapper;
@Autowired
private CommonService commonService;
private static final String NS = "dashboard.";
// ═══ 대시보드 CRUD ═══
// ── 목록 조회 ────────────────────────────────────────────────────────────────
public Map<String, Object> getDashboardList(Map<String, Object> params) {
String companyCode = (String) params.getOrDefault("company_code", "*");
String search = (String) params.get("search");
String category = (String) params.get("category");
int page = toInt(params.get("page"), 1);
int limit = Math.min(toInt(params.get("limit"), 20), 100);
int offset = (page - 1) * limit;
List<Object> args = new ArrayList<>();
StringBuilder where = new StringBuilder("d.deleted_date IS NULL");
if (!"*".equals(companyCode)) {
where.append(" AND d.company_code = ?");
args.add(companyCode);
}
if (search != null && !search.isBlank()) {
where.append(" AND (d.title ILIKE ? OR d.description ILIKE ?)");
args.add("%" + search + "%");
args.add("%" + search + "%");
}
if (category != null && !category.isBlank()) {
where.append(" AND d.category = ?");
args.add(category);
commonService.applyPagination(params);
int totalCount = sqlSession.selectOne(NS + "getDashboardListCnt", params);
List<Map<String, Object>> list = sqlSession.selectList(NS + "getDashboardList", params);
return commonService.buildListResponse(list, totalCount, params);
}
String countSql = "SELECT COUNT(DISTINCT d.id) FROM dashboards d WHERE " + where;
int total = jdbcTemplate.queryForObject(countSql, Integer.class, args.toArray());
String listSql = "SELECT d.id, d.title, d.description, d.thumbnail_url, d.is_public," +
" d.created_by, d.created_date, d.updated_date, d.tags, d.category, d.view_count, d.company_code," +
" u.user_name as created_by_name," +
" COUNT(de.id) as elements_count" +
" FROM dashboards d" +
" LEFT JOIN dashboard_elements de ON d.id = de.dashboard_id" +
" LEFT JOIN user_info u ON d.created_by = u.user_id" +
" WHERE " + where +
" GROUP BY d.id, d.title, d.description, d.thumbnail_url, d.is_public," +
" d.created_by, d.created_date, d.updated_date, d.tags, d.category, d.view_count, d.company_code, u.user_name" +
" ORDER BY d.updated_date DESC LIMIT ? OFFSET ?";
List<Object> listArgs = new ArrayList<>(args);
listArgs.add(limit);
listArgs.add(offset);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(listSql, listArgs.toArray());
List<Map<String, Object>> dashboards = new ArrayList<>();
for (Map<String, Object> row : rows) {
dashboards.add(formatDashboardRow(row, false));
public Map<String, Object> getDashboardInfo(Map<String, Object> params) {
return sqlSession.selectOne(NS + "getDashboardInfo", params);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("dashboards", dashboards);
Map<String, Object> pagination = new LinkedHashMap<>();
pagination.put("page", page);
pagination.put("limit", limit);
pagination.put("total", total);
pagination.put("total_pages", (int) Math.ceil((double) total / limit));
result.put("pagination", pagination);
return result;
}
// ── 단건 조회 (요소 포함) ─────────────────────────────────────────────────────
public Map<String, Object> getDashboardById(String dashboardId, String companyCode) {
List<Object> args = new ArrayList<>();
args.add(dashboardId);
String where = "id = ? AND deleted_date IS NULL";
if (companyCode != null && !"*".equals(companyCode)) {
where += " AND company_code = ?";
args.add(companyCode);
}
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT * FROM dashboards WHERE " + where, args.toArray());
if (rows.isEmpty()) return null;
Map<String, Object> dashboard = formatDashboardRow(rows.get(0), true);
List<Map<String, Object>> elements = jdbcTemplate.queryForList(
"SELECT * FROM dashboard_elements WHERE dashboard_id = ? ORDER BY display_order ASC",
dashboardId);
dashboard.put("elements", elements.stream().map(this::formatElement).collect(java.util.stream.Collectors.toList()));
return dashboard;
}
// ── 생성 ─────────────────────────────────────────────────────────────────────
@Transactional
public Map<String, Object> createDashboard(Map<String, Object> params, String userId, String companyCode) {
String dashboardId = UUID.randomUUID().toString();
String title = String.valueOf(params.get("title"));
String description = (String) params.get("description");
boolean isPublic = Boolean.TRUE.equals(params.get("is_public")) || "true".equals(String.valueOf(params.get("is_public")));
String tagsJson = toJson(params.getOrDefault("tags", new ArrayList<>()));
String category = (String) params.get("category");
String settingsJson = toJson(params.getOrDefault("settings", new HashMap<>()));
jdbcTemplate.update(
"INSERT INTO dashboards (id, title, description, is_public, created_by, created_date, updated_date, tags, category, view_count, settings, company_code)" +
" VALUES (?, ?, ?, ?, ?, NOW(), NOW(), ?::jsonb, ?, 0, ?::jsonb, ?)",
dashboardId, title, description, isPublic, userId, tagsJson, category, settingsJson, companyCode != null ? companyCode : "DEFAULT");
insertElements(dashboardId, params.get("elements"));
return getDashboardById(dashboardId, "*");
public Map<String, Object> insertDashboard(Map<String, Object> params) {
String dashboardId = "dash_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
params.put("dashboard_id", dashboardId);
if (params.get("icon") == null) {
params.put("icon", "\uD83D\uDCCB");
}
if (params.get("display_order") == null) {
params.put("display_order", 0);
}
sqlSession.insert(NS + "insertDashboard", params);
Map<String, Object> result = new HashMap<>();
result.put("dashboard_id", dashboardId);
return result;
}
// ── 수정 ─────────────────────────────────────────────────────────────────────
@Transactional
public Map<String, Object> updateDashboard(String dashboardId, Map<String, Object> params, String userId) {
List<String> sets = new ArrayList<>();
List<Object> args = new ArrayList<>();
if (params.containsKey("title")) { sets.add("title = ?"); args.add(params.get("title")); }
if (params.containsKey("description")) { sets.add("description = ?"); args.add(params.get("description")); }
if (params.containsKey("is_public")) {
sets.add("is_public = ?");
args.add(Boolean.TRUE.equals(params.get("is_public")) || "true".equals(String.valueOf(params.get("is_public"))));
}
if (params.containsKey("tags")) { sets.add("tags = ?::jsonb"); args.add(toJson(params.get("tags"))); }
if (params.containsKey("category")) { sets.add("category = ?"); args.add(params.get("category")); }
if (params.containsKey("settings")) { sets.add("settings = ?::jsonb"); args.add(toJson(params.get("settings"))); }
sets.add("updated_date = NOW()");
args.add(dashboardId);
args.add(userId);
int updated = jdbcTemplate.update(
"UPDATE dashboards SET " + String.join(", ", sets) +
" WHERE id = ? AND created_by = ? AND deleted_date IS NULL",
args.toArray());
if (updated == 0) return null;
if (params.containsKey("elements")) {
jdbcTemplate.update("DELETE FROM dashboard_elements WHERE dashboard_id = ?", dashboardId);
insertElements(dashboardId, params.get("elements"));
}
return getDashboardById(dashboardId, "*");
public void updateDashboard(Map<String, Object> params) {
sqlSession.update(NS + "updateDashboard", params);
}
// ── 삭제 (소프트) ────────────────────────────────────────────────────────────
@Transactional
public boolean deleteDashboard(String dashboardId, String userId) {
int deleted = jdbcTemplate.update(
"UPDATE dashboards SET deleted_date = NOW(), updated_date = NOW() WHERE id = ? AND created_by = ? AND deleted_date IS NULL",
dashboardId, userId);
return deleted > 0;
public int deleteDashboard(Map<String, Object> params) {
return sqlSession.update(NS + "deleteDashboard", params);
}
// ── 조회수 증가 ───────────────────────────────────────────────────────────────
public void incrementViewCount(String dashboardId) {
jdbcTemplate.update("UPDATE dashboards SET view_count = view_count + 1 WHERE id = ? AND deleted_date IS NULL", dashboardId);
// ═══ 카드 CRUD ═══
public List<Map<String, Object>> getDashboardCardList(Map<String, Object> params) {
return sqlSession.selectList(NS + "getDashboardCardList", params);
}
// ── execute-query (SELECT만) ──────────────────────────────────────────────────
public Map<String, Object> executeQuery(String sql) {
String trimmed = sql.trim();
String lower = trimmed.toLowerCase();
if (!lower.startsWith("select") && !lower.startsWith("with")) {
throw new IllegalArgumentException("SELECT 또는 WITH 쿼리만 허용됩니다.");
}
List<Map<String, Object>> rows = jdbcTemplate.queryForList(trimmed);
List<String> columns = rows.isEmpty() ? new ArrayList<>() : new ArrayList<>(rows.get(0).keySet());
Map<String, Object> result = new LinkedHashMap<>();
result.put("columns", columns);
result.put("rows", rows);
result.put("row_count", rows.size());
@Transactional
public Map<String, Object> insertDashboardCard(Map<String, Object> params) {
String cardId = "card_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
params.put("card_id", cardId);
if (params.get("position_x") == null) params.put("position_x", 50);
if (params.get("position_y") == null) params.put("position_y", 50);
if (params.get("width") == null) params.put("width", 600);
if (params.get("height") == null) params.put("height", 400);
if (params.get("display_order") == null) params.put("display_order", 0);
sqlSession.insert(NS + "insertDashboardCard", params);
Map<String, Object> result = new HashMap<>();
result.put("card_id", cardId);
return result;
}
// ── execute-dml (INSERT/UPDATE/DELETE) ────────────────────────────────────────
public Map<String, Object> executeDml(String sql) {
String trimmed = sql.trim();
String lower = trimmed.toLowerCase();
boolean isAllowed = lower.startsWith("insert") || lower.startsWith("update") || lower.startsWith("delete");
if (!isAllowed) throw new IllegalArgumentException("INSERT, UPDATE, DELETE 쿼리만 허용됩니다.");
String[] dangerous = {"drop table", "drop database", "truncate", "alter table", "create table"};
for (String d : dangerous) {
if (lower.contains(d)) throw new IllegalArgumentException("허용되지 않는 쿼리입니다.");
}
String command = trimmed.toUpperCase().split("\\s+")[0];
int rowCount = jdbcTemplate.update(trimmed);
Map<String, Object> result = new LinkedHashMap<>();
result.put("row_count", rowCount);
result.put("command", command);
return result;
@Transactional
public void updateDashboardCard(Map<String, Object> params) {
sqlSession.update(NS + "updateDashboardCard", params);
}
// ── table-schema ──────────────────────────────────────────────────────────────
public Map<String, Object> getTableSchema(String tableName) {
if (!tableName.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) {
throw new IllegalArgumentException("유효하지 않은 테이블명입니다.");
}
List<Map<String, Object>> cols = jdbcTemplate.queryForList(
"SELECT column_name, data_type, udt_name FROM information_schema.columns" +
" WHERE table_name = ? ORDER BY ordinal_position",
tableName.toLowerCase());
Set<String> dateTypes = new HashSet<>(Arrays.asList(
"timestamp", "timestamp without time zone", "timestamp with time zone",
"date", "time", "time without time zone", "time with time zone"));
Set<String> dateUdt = new HashSet<>(Arrays.asList("timestamp", "timestamptz", "date", "time", "timetz"));
List<String> dateColumns = new ArrayList<>();
List<Map<String, Object>> columns = new ArrayList<>();
for (Map<String, Object> col : cols) {
String dt = String.valueOf(col.get("data_type")).toLowerCase();
String udt = String.valueOf(col.get("udt_name")).toLowerCase();
if (dateTypes.contains(dt) || dateUdt.contains(udt)) {
dateColumns.add(String.valueOf(col.get("column_name")));
}
Map<String, Object> c = new LinkedHashMap<>();
c.put("name", col.get("column_name"));
c.put("type", col.get("data_type"));
c.put("udt_name", col.get("udt_name"));
columns.add(c);
@Transactional
public void deleteDashboardCard(Map<String, Object> params) {
sqlSession.update(NS + "deleteDashboardCard", params);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("table_name", tableName);
result.put("columns", columns);
result.put("date_columns", dateColumns);
return result;
}
// ── helpers ───────────────────────────────────────────────────────────────────
@Transactional
@SuppressWarnings("unchecked")
private void insertElements(String dashboardId, Object elementsObj) {
if (!(elementsObj instanceof List)) return;
List<?> elements = (List<?>) elementsObj;
for (int i = 0; i < elements.size(); i++) {
if (!(elements.get(i) instanceof Map)) continue;
Map<String, Object> el = (Map<String, Object>) elements.get(i);
String elementId = UUID.randomUUID().toString();
Map<String, Object> position = el.get("position") instanceof Map ? (Map<String, Object>) el.get("position") : new HashMap<>();
Map<String, Object> size = el.get("size") instanceof Map ? (Map<String, Object>) el.get("size") : new HashMap<>();
jdbcTemplate.update(
"INSERT INTO dashboard_elements (id, dashboard_id, element_type, element_subtype," +
" position_x, position_y, width, height, title, custom_title, show_header, content," +
" data_source_config, chart_config, list_config, yard_config, custom_metric_config," +
" display_order, created_date, updated_date)" +
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?::jsonb,?::jsonb,?::jsonb,?::jsonb,?::jsonb,?,NOW(),NOW())",
elementId, dashboardId,
el.get("type"), el.get("subtype"),
toInt(position.get("x"), 0), toInt(position.get("y"), 0),
toInt(size.get("width"), 4), toInt(size.get("height"), 3),
el.get("title"), el.get("custom_title"),
!Boolean.FALSE.equals(el.get("show_header")),
el.get("content"),
toJson(el.getOrDefault("data_source", new HashMap<>())),
toJson(el.getOrDefault("chart_config", new HashMap<>())),
el.get("list_config") != null ? toJson(el.get("list_config")) : null,
el.get("yard_config") != null ? toJson(el.get("yard_config")) : null,
el.get("custom_metric_config") != null ? toJson(el.get("custom_metric_config")) : null,
i);
public void updateCardPositions(Map<String, Object> params) {
List<Map<String, Object>> cards = (List<Map<String, Object>>) params.get("cards");
if (cards != null) {
for (Map<String, Object> card : cards) {
sqlSession.update(NS + "updateCardPosition", card);
}
}
}
private Map<String, Object> formatDashboardRow(Map<String, Object> row, boolean includeSettings) {
Map<String, Object> d = new LinkedHashMap<>();
d.put("id", row.get("id"));
d.put("title", row.get("title"));
d.put("description", row.get("description"));
d.put("thumbnail_url", row.get("thumbnail_url"));
d.put("is_public", row.get("is_public"));
d.put("created_by", row.get("created_by"));
d.put("created_by_name", row.get("created_by_name") != null ? row.get("created_by_name") : row.get("created_by"));
d.put("created_date", row.get("created_date"));
d.put("updated_date", row.get("updated_date"));
d.put("tags", parseJson(row.get("tags"), new ArrayList<>()));
d.put("category", row.get("category"));
d.put("view_count", toInt(row.get("view_count"), 0));
d.put("company_code", row.get("company_code"));
if (row.containsKey("elements_count")) d.put("elements_count", toInt(row.get("elements_count"), 0));
if (includeSettings) d.put("settings", parseJson(row.get("settings"), null));
return d;
}
// ═══ 사이드바 메뉴 ═══
private Map<String, Object> formatElement(Map<String, Object> row) {
Map<String, Object> el = new LinkedHashMap<>();
el.put("id", row.get("id"));
el.put("type", row.get("element_type"));
el.put("subtype", row.get("element_subtype"));
Map<String, Object> pos = new LinkedHashMap<>();
pos.put("x", row.get("position_x"));
pos.put("y", row.get("position_y"));
el.put("position", pos);
Map<String, Object> size = new LinkedHashMap<>();
size.put("width", row.get("width"));
size.put("height", row.get("height"));
el.put("size", size);
el.put("title", row.get("title"));
el.put("custom_title", row.get("custom_title"));
el.put("show_header", !Boolean.FALSE.equals(row.get("show_header")));
el.put("content", row.get("content"));
el.put("data_source", parseJson(row.get("data_source_config"), new HashMap<>()));
el.put("chart_config", parseJson(row.get("chart_config"), new HashMap<>()));
if (row.get("list_config") != null) el.put("list_config", parseJson(row.get("list_config"), null));
if (row.get("yard_config") != null) el.put("yard_config", parseJson(row.get("yard_config"), null));
if (row.get("custom_metric_config") != null) el.put("custom_metric_config", parseJson(row.get("custom_metric_config"), null));
return el;
}
private String toJson(Object obj) {
if (obj == null) return "{}";
if (obj instanceof String) return (String) obj;
try { return objectMapper.writeValueAsString(obj); }
catch (Exception e) { return "{}"; }
}
private Object parseJson(Object value, Object defaultValue) {
if (value == null) return defaultValue;
String jsonStr = null;
if (value instanceof String) {
jsonStr = (String) value;
} else if (value instanceof org.postgresql.util.PGobject pgObj) {
jsonStr = pgObj.getValue();
}
if (jsonStr != null) {
try { return objectMapper.readValue(jsonStr, new TypeReference<Object>() {}); }
catch (Exception e) { return defaultValue; }
}
return value;
}
private int toInt(Object v, int def) {
if (v == null) return def;
if (v instanceof Number) return ((Number) v).intValue();
try { return Integer.parseInt(v.toString()); } catch (Exception e) { return def; }
public List<Map<String, Object>> getSidebarMenu(Map<String, Object> params) {
return sqlSession.selectList(NS + "getSidebarMenu", params);
}
}
@@ -0,0 +1,389 @@
package com.erp.service;
import com.erp.common.BaseService;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.*;
@Service
@Slf4j
public class MetaService extends BaseService {
@Autowired
private CommonService commonService;
@Autowired
private ObjectMapper objectMapper;
private static final String NS = "meta.";
private static final Set<String> SYSTEM_FIELDS = Set.of(
"company_code", "created_by", "created_date", "updated_by", "updated_date",
"is_active", "deleted_date", "deleted_by", "writer", "write_date"
);
private static final Set<String> SEARCHABLE_TYPES = Set.of(
"text", "select", "entity", "date", "code"
);
// ──────────────────────────────────────────────────
// 테이블 목록
// ──────────────────────────────────────────────────
public List<Map<String, Object>> getMetaTableList(Map<String, Object> params) {
return sqlSession.selectList(NS + "getMetaTableList", params);
}
// ──────────────────────────────────────────────────
// FieldConfig[] 반환
// ──────────────────────────────────────────────────
public Map<String, Object> getMetaFields(Map<String, Object> params) {
String tableName = (String) params.get("table_name");
List<Map<String, Object>> schemaCols = sqlSession.selectList(NS + "getSchemaColumns", params);
List<String> pks = sqlSession.selectList(NS + "getPrimaryKeys", params);
List<Map<String, Object>> customMeta = sqlSession.selectList(NS + "getCustomMeta", params);
String tableLabel = sqlSession.selectOne(NS + "getTableLabel", params);
if (tableLabel == null || tableLabel.isBlank()) {
tableLabel = tableName;
}
String primaryKey = pks.isEmpty() ? null : String.join(",", pks);
List<Map<String, Object>> fields = buildFieldConfigs(schemaCols, pks, customMeta);
Map<String, Object> result = new LinkedHashMap<>();
result.put("table_name", tableName);
result.put("table_label", tableLabel);
result.put("primary_key", primaryKey);
result.put("fields", fields);
return result;
}
// ──────────────────────────────────────────────────
// FieldConfig 빌드 (핵심)
// ──────────────────────────────────────────────────
private List<Map<String, Object>> buildFieldConfigs(
List<Map<String, Object>> schemaCols,
List<String> pks,
List<Map<String, Object>> customMeta
) {
Set<String> pkSet = new HashSet<>(pks);
Map<String, Map<String, Object>> metaMap = new LinkedHashMap<>();
for (Map<String, Object> meta : customMeta) {
String colName = str(meta, "column_name");
if (colName != null) {
metaMap.put(colName, meta);
}
}
List<Map<String, Object>> fields = new ArrayList<>();
for (Map<String, Object> schemaCol : schemaCols) {
String columnName = str(schemaCol, "column_name");
String dataType = str(schemaCol, "data_type");
String isNullable = str(schemaCol, "is_nullable");
String columnDefault = str(schemaCol, "column_default");
int ordinalPosition = num(schemaCol, "ordinal_position");
Map<String, Object> meta = metaMap.get(columnName);
Map<String, Object> detailSettings = meta != null ? parseDetailSettings(meta) : null;
boolean isPk = pkSet.contains(columnName);
boolean isSystem = SYSTEM_FIELDS.contains(columnName);
// ── type ──
String fieldType;
if (meta != null && str(meta, "input_type") != null && !str(meta, "input_type").isBlank()) {
fieldType = mapInputTypeToFieldType(str(meta, "input_type"));
} else {
fieldType = mapDataTypeToFieldType(dataType);
}
// ── label ──
String label = columnName;
if (meta != null && str(meta, "column_label") != null && !str(meta, "column_label").isBlank()) {
label = str(meta, "column_label");
}
// ── order ──
int order = ordinalPosition;
if (meta != null && meta.get("display_order") != null) {
int displayOrder = num(meta, "display_order");
if (displayOrder > 0) order = displayOrder;
}
// ── visible ──
boolean visible = !isSystem;
if (!isSystem && meta != null && meta.get("is_visible") != null) {
String isVisible = meta.get("is_visible").toString();
visible = "Y".equalsIgnoreCase(isVisible) || "true".equalsIgnoreCase(isVisible);
}
// ── required (★ 앱 레벨 메타 우선, DB 스키마 폴백) ──
boolean required;
if (!isSystem && meta != null && meta.get("is_nullable") != null) {
// table_type_columns.IS_NULLABLE가 있으면 앱 레벨 메타가 진실
String metaNullable = meta.get("is_nullable").toString();
required = "NO".equalsIgnoreCase(metaNullable);
} else {
// 없으면 information_schema 폴백
required = "NO".equalsIgnoreCase(isNullable) && columnDefault == null && !isSystem;
}
// ── editable ──
boolean editable = !isPk && !isSystem;
if ("code".equals(fieldType)) editable = false;
// ── searchable ──
boolean searchable = !isSystem && SEARCHABLE_TYPES.contains(fieldType);
// ── build field map ──
Map<String, Object> field = new LinkedHashMap<>();
field.put("column", columnName);
field.put("label", label);
field.put("type", fieldType);
field.put("visible", visible);
field.put("order", order);
field.put("width", getDefaultWidth(fieldType));
field.put("align", "number".equals(fieldType) ? "right" : "left");
field.put("required", required);
field.put("editable", editable);
field.put("pk", isPk);
field.put("system", isSystem);
field.put("searchable", searchable);
field.put("sortable", !isSystem);
field.put("format", getDefaultFormat(fieldType));
field.put("options", null);
field.put("ref", null);
field.put("computed", null);
// ── entity → ref ──
if ("entity".equals(fieldType) && meta != null) {
Map<String, Object> ref = buildFieldRef(meta, detailSettings);
if (ref != null) field.put("ref", ref);
}
// ── select → options ──
if ("select".equals(fieldType) && detailSettings != null) {
List<Object> options = extractOptions(detailSettings);
if (options != null && !options.isEmpty()) field.put("options", options);
}
// ── computed ──
if (detailSettings != null && detailSettings.containsKey("computed")) {
String computed = detailSettings.get("computed").toString();
if (!computed.isBlank()) {
field.put("computed", computed);
field.put("editable", false);
}
}
fields.add(field);
}
fields.sort(Comparator.comparingInt(f -> num(f, "order")));
return fields;
}
// ──────────────────────────────────────────────────
// 타입 매핑
// ──────────────────────────────────────────────────
private String mapDataTypeToFieldType(String dataType) {
if (dataType == null) return "text";
return switch (dataType.toLowerCase()) {
case "character varying", "varchar" -> "text";
case "text" -> "textarea";
case "integer", "bigint", "smallint" -> "number";
case "numeric", "decimal", "real", "double precision" -> "number";
case "boolean" -> "checkbox";
case "date" -> "date";
case "timestamp without time zone", "timestamp with time zone" -> "datetime";
case "jsonb", "json" -> "textarea";
case "bytea" -> "file";
default -> "text";
};
}
private String mapInputTypeToFieldType(String inputType) {
if (inputType == null) return "text";
return switch (inputType.toLowerCase()) {
case "text" -> "text";
case "number", "decimal" -> "number";
case "date" -> "date";
case "datetime" -> "datetime";
case "select", "category" -> "select";
case "entity" -> "entity";
case "checkbox", "boolean" -> "checkbox";
case "textarea", "text_area" -> "textarea";
case "file" -> "file";
case "code", "numbering" -> "code";
case "email", "password", "tel" -> "text";
default -> "text";
};
}
// ──────────────────────────────────────────────────
// entity ref 빌드
// ──────────────────────────────────────────────────
private Map<String, Object> buildFieldRef(Map<String, Object> meta, Map<String, Object> detailSettings) {
// 1차: top-level 컬럼
String refTable = str(meta, "reference_table");
String refColumn = str(meta, "reference_column");
String displayCol = str(meta, "display_column");
// 2차: detail_settings JSON 폴백
if ((refTable == null || refTable.isBlank()) && detailSettings != null) {
refTable = strFromMap(detailSettings, "referenceTable");
}
if ((refColumn == null || refColumn.isBlank()) && detailSettings != null) {
refColumn = strFromMap(detailSettings, "referenceColumn");
}
if ((displayCol == null || displayCol.isBlank()) && detailSettings != null) {
displayCol = strFromMap(detailSettings, "displayColumn");
}
if (refTable == null || refTable.isBlank()) return null;
Map<String, Object> ref = new LinkedHashMap<>();
ref.put("table", refTable);
ref.put("value_column", refColumn != null && !refColumn.isBlank() ? refColumn : "id");
ref.put("display_column", displayCol != null && !displayCol.isBlank() ? displayCol : refColumn);
// search_columns
if (detailSettings != null && detailSettings.containsKey("searchColumns")) {
Object sc = detailSettings.get("searchColumns");
if (sc instanceof List) {
ref.put("search_columns", sc);
}
}
return ref;
}
// ──────────────────────────────────────────────────
// select options 추출
// ──────────────────────────────────────────────────
/**
* select options 추출.
* ★ FieldOption 규격: string이면 value=label, 객체면 {value, label} 그대로 보존
* - string[]: ["임시저장", "확정"] → 그대로 반환
* - [{value,label}]: [{value:"DIRECT",label:"직접배송"}] → 그대로 반환
* - 프론트에서 select 렌더러는 항상 value를 저장/전송, label을 표시
*/
@SuppressWarnings("unchecked")
private List<Object> extractOptions(Map<String, Object> detailSettings) {
if (detailSettings == null || !detailSettings.containsKey("options")) return null;
Object optionsObj = detailSettings.get("options");
if (!(optionsObj instanceof List)) return null;
List<?> optionsList = (List<?>) optionsObj;
if (optionsList.isEmpty()) return null;
List<Object> result = new ArrayList<>();
for (Object item : optionsList) {
if (item instanceof String) {
// 단순 문자열: value=label로 해석
result.add(item);
} else if (item instanceof Map) {
// {value, label} 객체: 그대로 보존
Map<String, Object> optMap = (Map<String, Object>) item;
Map<String, Object> opt = new LinkedHashMap<>();
Object value = optMap.get("value");
Object label = optMap.get("label");
opt.put("value", value != null ? value.toString() : "");
opt.put("label", label != null ? label.toString() : (value != null ? value.toString() : ""));
result.add(opt);
}
}
return result.isEmpty() ? null : result;
}
// ──────────────────────────────────────────────────
// detail_settings 파싱
// ──────────────────────────────────────────────────
private Map<String, Object> parseDetailSettings(Map<String, Object> meta) {
Object detailObj = meta.get("detail_settings");
if (detailObj == null) return null;
String detailStr = detailObj.toString().trim();
if (detailStr.isEmpty() || "{}".equals(detailStr)) return null;
try {
return objectMapper.readValue(detailStr, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
log.warn("detail_settings 파싱 실패: {}", detailStr);
return null;
}
}
// ──────────────────────────────────────────────────
// 기본값
// ──────────────────────────────────────────────────
private int getDefaultWidth(String fieldType) {
return switch (fieldType) {
case "number" -> 100;
case "date" -> 120;
case "datetime" -> 160;
case "select" -> 130;
case "entity" -> 180;
case "checkbox" -> 80;
case "code" -> 120;
case "textarea" -> 200;
default -> 150;
};
}
private String getDefaultFormat(String fieldType) {
return switch (fieldType) {
case "number" -> "#,##0";
case "date" -> "YYYY-MM-DD";
case "datetime" -> "YYYY-MM-DD HH:mm";
default -> null;
};
}
// ──────────────────────────────────────────────────
// 유틸
// ──────────────────────────────────────────────────
private String str(Map<String, Object> map, String key) {
Object val = map.get(key);
return val != null ? val.toString() : null;
}
private String strFromMap(Map<String, Object> map, String key) {
Object val = map.get(key);
return val != null ? val.toString() : null;
}
private int num(Map<String, Object> map, String key) {
Object val = map.get(key);
if (val instanceof Number) return ((Number) val).intValue();
if (val != null) {
try { return Integer.parseInt(val.toString()); }
catch (NumberFormatException e) { return 0; }
}
return 0;
}
// ──────────────────────────────────────────────────
// 테이블 간 업무 관계 (Phase 5 — 제어 모드)
// ──────────────────────────────────────────────────
public List<Map<String, Object>> getMetaRelations(Map<String, Object> params) {
return sqlSession.selectList(NS + "getMetaRelations", params);
}
}
@@ -0,0 +1,129 @@
package com.erp.service;
import com.erp.common.BaseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Service
@Slf4j
public class TemplateService extends BaseService {
@Autowired
private CommonService commonService;
@Autowired
private ObjectMapper objectMapper;
private static final String NS = "template.";
// ──────────────────────────────────────────────────
// 목록
// ──────────────────────────────────────────────────
public Map<String, Object> getTemplateList(Map<String, Object> params) {
commonService.applyPagination(params);
int totalCount = sqlSession.selectOne(NS + "getTemplateListCnt", params);
List<Map<String, Object>> list = sqlSession.selectList(NS + "getTemplateList", params);
return commonService.buildListResponse(list, totalCount, params);
}
// ──────────────────────────────────────────────────
// 단건
// ──────────────────────────────────────────────────
public Map<String, Object> getTemplateInfo(Map<String, Object> params) {
Map<String, Object> row = sqlSession.selectOne(NS + "getTemplateInfo", params);
if (row == null) return null;
// JSONB 문자열 → 객체 변환
parseJsonField(row, "fields");
parseJsonField(row, "views");
parseJsonField(row, "connections");
return row;
}
// ──────────────────────────────────────────────────
// 등록
// ──────────────────────────────────────────────────
@Transactional
public Map<String, Object> insertTemplate(Map<String, Object> params) {
String templateId = "tpl_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
params.put("template_id", templateId);
// 객체 → JSON 문자열 변환
stringifyJsonField(params, "fields");
stringifyJsonField(params, "views");
stringifyJsonField(params, "connections");
sqlSession.insert(NS + "insertTemplate", params);
Map<String, Object> result = new HashMap<>();
result.put("template_id", templateId);
return result;
}
// ──────────────────────────────────────────────────
// 수정
// ──────────────────────────────────────────────────
@Transactional
public void updateTemplate(Map<String, Object> params) {
stringifyJsonField(params, "fields");
stringifyJsonField(params, "views");
stringifyJsonField(params, "connections");
sqlSession.update(NS + "updateTemplate", params);
}
// ──────────────────────────────────────────────────
// 게시
// ──────────────────────────────────────────────────
@Transactional
public void publishTemplate(Map<String, Object> params) {
sqlSession.update(NS + "publishTemplate", params);
}
// ──────────────────────────────────────────────────
// 삭제 (소프트)
// ──────────────────────────────────────────────────
@Transactional
public int deleteTemplate(Map<String, Object> params) {
return sqlSession.update(NS + "deleteTemplate", params);
}
// ──────────────────────────────────────────────────
// JSON 유틸
// ──────────────────────────────────────────────────
private void parseJsonField(Map<String, Object> row, String key) {
Object val = row.get(key);
if (val instanceof String) {
try {
row.put(key, objectMapper.readValue((String) val, Object.class));
} catch (Exception e) {
log.warn("JSON 파싱 실패: key={}, value={}", key, val, e);
}
}
}
private void stringifyJsonField(Map<String, Object> params, String key) {
Object val = params.get(key);
if (val != null && !(val instanceof String)) {
try {
params.put(key, objectMapper.writeValueAsString(val));
} catch (Exception e) {
log.warn("JSON 직렬화 실패: key={}", key, e);
params.put(key, "[]");
}
}
}
}
@@ -0,0 +1,68 @@
package com.erp.service;
import com.erp.common.BaseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Service
@Slf4j
public class UserOverrideService extends BaseService {
@Autowired
private CommonService commonService;
@Autowired
private ObjectMapper objectMapper;
private static final String NS = "userOverride.";
public Map<String, Object> getUserOverride(Map<String, Object> params) {
Map<String, Object> row = sqlSession.selectOne(NS + "getUserOverride", params);
if (row != null) {
parseJsonField(row, "overrides");
}
return row;
}
@Transactional
public void upsertUserOverride(Map<String, Object> params) {
if (params.get("override_id") == null) {
String overrideId = "ovr_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
params.put("override_id", overrideId);
}
stringifyJsonField(params, "overrides");
sqlSession.insert(NS + "upsertUserOverride", params);
}
@Transactional
public void deleteUserOverride(Map<String, Object> params) {
sqlSession.delete(NS + "deleteUserOverride", params);
}
private void parseJsonField(Map<String, Object> row, String key) {
Object val = row.get(key);
if (val instanceof String) {
try {
row.put(key, objectMapper.readValue((String) val, Object.class));
} catch (Exception e) {
log.warn("JSON parse failed: key={}", key);
}
}
}
private void stringifyJsonField(Map<String, Object> params, String key) {
Object val = params.get(key);
if (val != null && !(val instanceof String)) {
try {
params.put(key, objectMapper.writeValueAsString(val));
} catch (Exception e) {
log.warn("JSON stringify failed: key={}", key);
}
}
}
}
@@ -14,9 +14,9 @@ spring:
jackson:
default-property-inclusion: always
datasource:
url: jdbc:postgresql://39.117.244.52:11132/testvex
url: jdbc:postgresql://211.115.91.141:11134/test_dev
username: postgres
password: "ph0909!!"
password: "vexplor0909!!"
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 10
@@ -0,0 +1,120 @@
<?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="businessRule">
<!-- ═══ 대시보드별 비즈니스 룰 목록 ═══ -->
<select id="getBusinessRuleList" parameterType="map" resultType="map">
SELECT
RULE_ID
, DASHBOARD_ID
, NAME
, DESCRIPTION
, IS_ENABLED
, COMPANY_CODE
, CREATED_BY
, CREATED_DATE
, UPDATED_BY
, UPDATED_DATE
FROM BUSINESS_RULES
WHERE IS_ACTIVE = 'Y'
<if test='dashboard_id != null and dashboard_id != ""'>
AND DASHBOARD_ID = #{dashboard_id}
</if>
<include refid="common.companyCodeFilter"/>
<include refid="common.dynamicOrderBy"/>
<include refid="common.pagination"/>
</select>
<!-- ═══ 목록 카운트 ═══ -->
<select id="getBusinessRuleListCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM BUSINESS_RULES
WHERE IS_ACTIVE = 'Y'
<if test='dashboard_id != null and dashboard_id != ""'>
AND DASHBOARD_ID = #{dashboard_id}
</if>
<include refid="common.companyCodeFilter"/>
</select>
<!-- ═══ 룰 상세 (노드 + 연결 JSONB 포함) ═══ -->
<select id="getBusinessRuleInfo" parameterType="map" resultType="map">
SELECT
RULE_ID
, DASHBOARD_ID
, NAME
, DESCRIPTION
, NODES
, CONNECTIONS
, IS_ENABLED
, COMPANY_CODE
, CREATED_BY
, CREATED_DATE
, UPDATED_BY
, UPDATED_DATE
FROM BUSINESS_RULES
WHERE RULE_ID = #{rule_id}
AND IS_ACTIVE = 'Y'
</select>
<!-- ═══ 룰 등록 ═══ -->
<insert id="insertBusinessRule" parameterType="map">
INSERT INTO BUSINESS_RULES (
RULE_ID
, DASHBOARD_ID
, NAME
, DESCRIPTION
, NODES
, CONNECTIONS
, IS_ENABLED
, COMPANY_CODE
, CREATED_BY
, CREATED_DATE
) VALUES (
#{rule_id}
, #{dashboard_id}
, #{name}
, #{description}
, #{nodes}::jsonb
, #{connections}::jsonb
, COALESCE(#{is_enabled}, true)
, #{company_code}
, #{user_id}
, CURRENT_TIMESTAMP
)
</insert>
<!-- ═══ 룰 수정 ═══ -->
<update id="updateBusinessRule" parameterType="map">
UPDATE BUSINESS_RULES
SET
NAME = #{name}
, DESCRIPTION = #{description}
, NODES = #{nodes}::jsonb
, CONNECTIONS = #{connections}::jsonb
, UPDATED_BY = #{user_id}
, UPDATED_DATE = CURRENT_TIMESTAMP
WHERE RULE_ID = #{rule_id}
AND IS_ACTIVE = 'Y'
</update>
<!-- ═══ 룰 삭제 (소프트) ═══ -->
<update id="deleteBusinessRule" parameterType="map">
UPDATE BUSINESS_RULES
SET IS_ACTIVE = 'D'
, UPDATED_BY = #{user_id}
, UPDATED_DATE = CURRENT_TIMESTAMP
WHERE RULE_ID = #{rule_id}
</update>
<!-- ═══ 활성/비활성 토글 ═══ -->
<update id="toggleBusinessRule" parameterType="map">
UPDATE BUSINESS_RULES
SET IS_ENABLED = NOT IS_ENABLED
, UPDATED_BY = #{user_id}
, UPDATED_DATE = CURRENT_TIMESTAMP
WHERE RULE_ID = #{rule_id}
AND IS_ACTIVE = 'Y'
</update>
</mapper>
@@ -1,63 +1,226 @@
<?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">
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="dashboard">
<sql id="dashboardSearchCondition">
<if test="keyword != null and keyword != ''">
AND (NAME ILIKE '%' || #{keyword} || '%')
</if>
</sql>
<!-- ═══ 대시보드 CRUD ═══ -->
<select id="getDashboardList" parameterType="map" resultType="map">
SELECT *
FROM DASHBOARD
WHERE 1=1
SELECT DASHBOARD_ID
, NAME
, ICON
, DISPLAY_ORDER
, COMPANY_CODE
, USER_ID
, IS_ACTIVE
, CREATED_BY
, CREATED_DATE
, UPDATED_BY
, UPDATED_DATE
FROM DASHBOARDS
WHERE IS_ACTIVE = 'Y'
<include refid="common.companyCodeFilter"/>
<include refid="dashboardSearchCondition"/>
<include refid="common.dynamicOrderBy"/>
AND (USER_ID = #{user_id} OR USER_ID IS NULL)
<if test='keyword != null and keyword != ""'>
AND NAME LIKE CONCAT('%', #{keyword}, '%')
</if>
ORDER BY DISPLAY_ORDER ASC, CREATED_DATE ASC
<include refid="common.pagination"/>
</select>
<select id="getDashboardListCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM DASHBOARD
WHERE 1=1
FROM DASHBOARDS
WHERE IS_ACTIVE = 'Y'
<include refid="common.companyCodeFilter"/>
<include refid="dashboardSearchCondition"/>
AND (USER_ID = #{user_id} OR USER_ID IS NULL)
<if test='keyword != null and keyword != ""'>
AND NAME LIKE CONCAT('%', #{keyword}, '%')
</if>
</select>
<select id="getDashboardInfo" parameterType="map" resultType="map">
SELECT *
FROM DASHBOARD
WHERE ID = #{id}
<include refid="common.companyCodeFilter"/>
SELECT DASHBOARD_ID
, NAME
, ICON
, DISPLAY_ORDER
, COMPANY_CODE
, USER_ID
, IS_ACTIVE
, CREATED_BY
, CREATED_DATE
, UPDATED_BY
, UPDATED_DATE
FROM DASHBOARDS
WHERE DASHBOARD_ID = #{dashboard_id}
AND IS_ACTIVE = 'Y'
</select>
<insert id="insertDashboard" parameterType="map" useGeneratedKeys="true" keyProperty="id">
INSERT INTO DASHBOARD (
COMPANY_CODE
<insert id="insertDashboard" parameterType="map">
INSERT INTO DASHBOARDS (
DASHBOARD_ID
, NAME
, ICON
, DISPLAY_ORDER
, COMPANY_CODE
, USER_ID
, IS_ACTIVE
, CREATED_BY
, CREATED_DATE
, UPDATED_BY
, UPDATED_DATE
) VALUES (
#{company_code}
, NOW()
, NOW()
#{dashboard_id}
, #{name}
, #{icon}
, #{display_order}
, #{company_code}
, #{user_id}
, 'Y'
, #{user_id}
, CURRENT_TIMESTAMP
, #{user_id}
, CURRENT_TIMESTAMP
)
</insert>
<update id="updateDashboard" parameterType="map">
UPDATE DASHBOARD
SET
UPDATED_DATE = NOW()
WHERE ID = #{id}
<include refid="common.companyCodeFilter"/>
UPDATE DASHBOARDS
SET UPDATED_DATE = CURRENT_TIMESTAMP
, UPDATED_BY = #{user_id}
<if test='name != null'>
, NAME = #{name}
</if>
<if test='icon != null'>
, ICON = #{icon}
</if>
<if test='display_order != null'>
, DISPLAY_ORDER = #{display_order}
</if>
WHERE DASHBOARD_ID = #{dashboard_id}
AND IS_ACTIVE = 'Y'
</update>
<delete id="deleteDashboard" parameterType="map">
DELETE FROM DASHBOARD
WHERE ID = #{id}
<update id="deleteDashboard" parameterType="map">
UPDATE DASHBOARDS
SET IS_ACTIVE = 'D'
, UPDATED_DATE = CURRENT_TIMESTAMP
, UPDATED_BY = #{user_id}
WHERE DASHBOARD_ID = #{dashboard_id}
</update>
<!-- ═══ 대시보드 카드 ═══ -->
<select id="getDashboardCardList" parameterType="map" resultType="map">
SELECT DC.CARD_ID
, DC.DASHBOARD_ID
, DC.TEMPLATE_ID
, DC.POSITION_X
, DC.POSITION_Y
, DC.WIDTH
, DC.HEIGHT
, DC.IS_COLLAPSED
, DC.DISPLAY_ORDER
, DC.IS_ACTIVE
, DC.CREATED_DATE
, DC.UPDATED_DATE
, T.NAME AS TEMPLATE_NAME
, T.CATEGORY AS TEMPLATE_CATEGORY
, T.DESCRIPTION AS TEMPLATE_DESCRIPTION
, T.PRIMARY_TABLE
, T.STATUS AS TEMPLATE_STATUS
FROM DASHBOARD_CARDS DC
LEFT JOIN TEMPLATES T ON DC.TEMPLATE_ID = T.TEMPLATE_ID AND T.IS_ACTIVE = 'Y'
WHERE DC.DASHBOARD_ID = #{dashboard_id}
AND DC.IS_ACTIVE = 'Y'
ORDER BY DC.DISPLAY_ORDER ASC, DC.CREATED_DATE ASC
</select>
<insert id="insertDashboardCard" parameterType="map">
INSERT INTO DASHBOARD_CARDS (
CARD_ID
, DASHBOARD_ID
, TEMPLATE_ID
, POSITION_X
, POSITION_Y
, WIDTH
, HEIGHT
, IS_COLLAPSED
, DISPLAY_ORDER
, IS_ACTIVE
, CREATED_DATE
, UPDATED_DATE
) VALUES (
#{card_id}
, #{dashboard_id}
, #{template_id}
, #{position_x}
, #{position_y}
, #{width}
, #{height}
, FALSE
, #{display_order}
, 'Y'
, CURRENT_TIMESTAMP
, CURRENT_TIMESTAMP
)
</insert>
<update id="updateDashboardCard" parameterType="map">
UPDATE DASHBOARD_CARDS
SET UPDATED_DATE = CURRENT_TIMESTAMP
<if test='position_x != null'>
, POSITION_X = #{position_x}
</if>
<if test='position_y != null'>
, POSITION_Y = #{position_y}
</if>
<if test='width != null'>
, WIDTH = #{width}
</if>
<if test='height != null'>
, HEIGHT = #{height}
</if>
<if test='is_collapsed != null'>
, IS_COLLAPSED = #{is_collapsed}
</if>
<if test='display_order != null'>
, DISPLAY_ORDER = #{display_order}
</if>
WHERE CARD_ID = #{card_id}
AND IS_ACTIVE = 'Y'
</update>
<update id="updateCardPosition" parameterType="map">
UPDATE DASHBOARD_CARDS
SET POSITION_X = #{position_x}
, POSITION_Y = #{position_y}
, WIDTH = #{width}
, HEIGHT = #{height}
, IS_COLLAPSED = #{is_collapsed}
, UPDATED_DATE = CURRENT_TIMESTAMP
WHERE CARD_ID = #{card_id}
AND IS_ACTIVE = 'Y'
</update>
<update id="deleteDashboardCard" parameterType="map">
UPDATE DASHBOARD_CARDS
SET IS_ACTIVE = 'D'
, UPDATED_DATE = CURRENT_TIMESTAMP
WHERE CARD_ID = #{card_id}
</update>
<!-- ═══ 사이드바 메뉴 ═══ -->
<select id="getSidebarMenu" parameterType="map" resultType="map">
SELECT DASHBOARD_ID
, NAME
, ICON
, DISPLAY_ORDER
FROM DASHBOARDS
WHERE IS_ACTIVE = 'Y'
<include refid="common.companyCodeFilter"/>
</delete>
AND (USER_ID = #{user_id} OR USER_ID IS NULL)
ORDER BY DISPLAY_ORDER ASC, CREATED_DATE ASC
</select>
</mapper>
@@ -0,0 +1,108 @@
<?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="meta">
<!-- ═══ 테이블 목록 (public 스키마, 시스템 테이블 제외) ═══ -->
<select id="getMetaTableList" parameterType="map" resultType="map">
SELECT
T.TABLE_NAME
, COALESCE(TL.TABLE_LABEL, T.TABLE_NAME) AS TABLE_LABEL
, (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS C
WHERE C.TABLE_SCHEMA = 'public' AND C.TABLE_NAME = T.TABLE_NAME) AS COLUMN_COUNT
, CASE WHEN EXISTS(
SELECT 1 FROM TABLE_TYPE_COLUMNS TTC
WHERE TTC.TABLE_NAME = T.TABLE_NAME
<if test='company_code != null and company_code != "*"'>
AND (TTC.COMPANY_CODE = #{company_code} OR TTC.COMPANY_CODE = '*')
</if>
) THEN true ELSE false END AS HAS_CUSTOM_META
FROM INFORMATION_SCHEMA.TABLES T
LEFT JOIN TABLE_LABELS TL
ON T.TABLE_NAME = TL.TABLE_NAME
WHERE T.TABLE_SCHEMA = 'public'
AND T.TABLE_TYPE = 'BASE TABLE'
AND T.TABLE_NAME NOT LIKE 'pg_%'
AND T.TABLE_NAME NOT IN ('spatial_ref_sys')
ORDER BY T.TABLE_NAME
</select>
<!-- ═══ 특정 테이블의 컬럼 정보 (information_schema) ═══ -->
<select id="getSchemaColumns" parameterType="map" resultType="map">
SELECT
COLUMN_NAME
, DATA_TYPE
, IS_NULLABLE
, COLUMN_DEFAULT
, CHARACTER_MAXIMUM_LENGTH
, ORDINAL_POSITION
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'public'
AND TABLE_NAME = #{table_name}
ORDER BY ORDINAL_POSITION
</select>
<!-- ═══ PK 컬럼 목록 ═══ -->
<select id="getPrimaryKeys" parameterType="map" resultType="string">
SELECT KCU.COLUMN_NAME
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS TC
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE KCU
ON TC.CONSTRAINT_NAME = KCU.CONSTRAINT_NAME
AND TC.TABLE_SCHEMA = KCU.TABLE_SCHEMA
WHERE TC.CONSTRAINT_TYPE = 'PRIMARY KEY'
AND TC.TABLE_SCHEMA = 'public'
AND TC.TABLE_NAME = #{table_name}
</select>
<!-- ═══ TABLE_TYPE_COLUMNS 커스텀 메타 (회사 우선, '*' 폴백) ═══ -->
<select id="getCustomMeta" parameterType="map" resultType="map">
SELECT DISTINCT ON (COLUMN_NAME)
COLUMN_NAME
, COLUMN_LABEL
, INPUT_TYPE
, COALESCE(DETAIL_SETTINGS::TEXT, '') AS DETAIL_SETTINGS
, DISPLAY_ORDER
, IS_NULLABLE
, IS_VISIBLE
, REFERENCE_TABLE
, REFERENCE_COLUMN
, DISPLAY_COLUMN
, CODE_CATEGORY
, CODE_VALUE
, COMPANY_CODE
FROM TABLE_TYPE_COLUMNS
WHERE TABLE_NAME = #{table_name}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
ORDER BY COLUMN_NAME
, CASE WHEN COMPANY_CODE = #{company_code} THEN 0 ELSE 1 END
</select>
<!-- ═══ 테이블 라벨 단건 조회 ═══ -->
<select id="getTableLabel" parameterType="map" resultType="string">
SELECT TABLE_LABEL
FROM TABLE_LABELS
WHERE TABLE_NAME = #{table_name}
</select>
<!-- ═══ 테이블 간 업무 관계 (table_relationships 기반) ═══ -->
<select id="getMetaRelations" parameterType="map" resultType="map">
SELECT
SOURCE_TABLE
, TARGET_TABLE
, RELATION_TYPE
, COALESCE(LABEL, '') AS LABEL
, COALESCE(DESCRIPTION, '') AS DESCRIPTION
, SOURCE_COLUMN
, TARGET_COLUMN
FROM TABLE_RELATIONSHIPS
WHERE (SOURCE_TABLE = #{table_name} OR TARGET_TABLE = #{table_name})
AND IS_ACTIVE = 'Y'
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
ORDER BY SOURCE_TABLE, TARGET_TABLE
</select>
</mapper>
@@ -0,0 +1,149 @@
<?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="template">
<select id="getTemplateList" parameterType="map" resultType="map">
SELECT
TEMPLATE_ID
, NAME
, CATEGORY
, DESCRIPTION
, PRIMARY_TABLE
, STATUS
, VERSION
, CREATED_BY
, CREATED_DATE
, UPDATED_BY
, UPDATED_DATE
FROM TEMPLATES
WHERE 1=1
AND IS_ACTIVE != 'D'
<include refid="common.companyCodeFilter"/>
<if test='keyword != null and keyword != ""'>
AND (UPPER(NAME) LIKE '%' || UPPER(#{keyword}) || '%'
OR UPPER(PRIMARY_TABLE) LIKE '%' || UPPER(#{keyword}) || '%')
</if>
<if test='status != null and status != ""'>
AND STATUS = #{status}
</if>
<if test='category != null and category != ""'>
AND CATEGORY = #{category}
</if>
<include refid="common.dynamicOrderBy"/>
<include refid="common.pagination"/>
</select>
<select id="getTemplateListCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM TEMPLATES
WHERE 1=1
AND IS_ACTIVE != 'D'
<include refid="common.companyCodeFilter"/>
<if test='keyword != null and keyword != ""'>
AND (UPPER(NAME) LIKE '%' || UPPER(#{keyword}) || '%'
OR UPPER(PRIMARY_TABLE) LIKE '%' || UPPER(#{keyword}) || '%')
</if>
<if test='status != null and status != ""'>
AND STATUS = #{status}
</if>
<if test='category != null and category != ""'>
AND CATEGORY = #{category}
</if>
</select>
<select id="getTemplateInfo" parameterType="map" resultType="map">
SELECT
TEMPLATE_ID
, NAME
, CATEGORY
, DESCRIPTION
, PRIMARY_TABLE
, FIELDS
, VIEWS
, CONNECTIONS
, COMPANY_CODE
, VERSION
, STATUS
, CREATED_BY
, CREATED_DATE
, UPDATED_BY
, UPDATED_DATE
FROM TEMPLATES
WHERE TEMPLATE_ID = #{template_id}
AND IS_ACTIVE != 'D'
</select>
<insert id="insertTemplate" parameterType="map">
INSERT INTO TEMPLATES (
TEMPLATE_ID
, NAME
, CATEGORY
, DESCRIPTION
, PRIMARY_TABLE
, FIELDS
, VIEWS
, CONNECTIONS
, COMPANY_CODE
, VERSION
, STATUS
, CREATED_BY
, CREATED_DATE
, UPDATED_BY
, UPDATED_DATE
) VALUES (
#{template_id}
, #{name}
, #{category}
, #{description}
, #{primary_table}
, #{fields}::jsonb
, #{views}::jsonb
, #{connections}::jsonb
, #{company_code}
, 1
, 'draft'
, #{user_id}
, NOW()
, #{user_id}
, NOW()
)
</insert>
<update id="updateTemplate" parameterType="map">
UPDATE TEMPLATES
SET
NAME = #{name}
, CATEGORY = #{category}
, DESCRIPTION = #{description}
, PRIMARY_TABLE = #{primary_table}
, FIELDS = #{fields}::jsonb
, VIEWS = #{views}::jsonb
, CONNECTIONS = #{connections}::jsonb
, VERSION = VERSION + 1
, UPDATED_BY = #{user_id}
, UPDATED_DATE = NOW()
WHERE TEMPLATE_ID = #{template_id}
AND IS_ACTIVE != 'D'
</update>
<update id="publishTemplate" parameterType="map">
UPDATE TEMPLATES
SET
STATUS = 'published'
, UPDATED_BY = #{user_id}
, UPDATED_DATE = NOW()
WHERE TEMPLATE_ID = #{template_id}
AND IS_ACTIVE != 'D'
</update>
<update id="deleteTemplate" parameterType="map">
UPDATE TEMPLATES
SET
IS_ACTIVE = 'D'
, UPDATED_BY = #{user_id}
, UPDATED_DATE = NOW()
WHERE TEMPLATE_ID = #{template_id}
</update>
</mapper>
@@ -0,0 +1,45 @@
<?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="userOverride">
<select id="getUserOverride" parameterType="map" resultType="map">
SELECT OVERRIDE_ID
, USER_ID
, CARD_ID
, OVERRIDES
, CREATED_DATE
, UPDATED_DATE
FROM USER_OVERRIDES
WHERE USER_ID = #{user_id}
AND CARD_ID = #{card_id}
</select>
<insert id="upsertUserOverride" parameterType="map">
INSERT INTO USER_OVERRIDES (
OVERRIDE_ID
, USER_ID
, CARD_ID
, OVERRIDES
, CREATED_DATE
, UPDATED_DATE
) VALUES (
#{override_id}
, #{user_id}
, #{card_id}
, #{overrides}::jsonb
, CURRENT_TIMESTAMP
, CURRENT_TIMESTAMP
)
ON CONFLICT (USER_ID, CARD_ID)
DO UPDATE SET
OVERRIDES = #{overrides}::jsonb
, UPDATED_DATE = CURRENT_TIMESTAMP
</insert>
<delete id="deleteUserOverride" parameterType="map">
DELETE FROM USER_OVERRIDES
WHERE USER_ID = #{user_id}
AND CARD_ID = #{card_id}
</delete>
</mapper>
+4
View File
@@ -1,3 +1,5 @@
name: invyone
# invyone (= 옛 TEST-VEX, React + Java 풀스택) 도커 컴포즈
# 사무실 우분투 호스팅용. restart unless-stopped 로 항상 떠있게.
#
@@ -19,6 +21,7 @@ services:
# Spring Boot 백엔드
# ========================
backend-spring:
image: invyone-backend-spring
build:
context: ../../backend-spring
dockerfile: ../docker/dev/backend-spring.Dockerfile
@@ -55,6 +58,7 @@ services:
# Next.js 프론트엔드
# ========================
frontend:
image: invyone-frontend
build:
context: ../../frontend
dockerfile: ../docker/dev/frontend.Dockerfile
@@ -0,0 +1,7 @@
"use client";
import BuilderLayout from "@/components/builder/BuilderLayout";
export default function BuilderPage() {
return <BuilderLayout />;
}
+7
View File
@@ -0,0 +1,7 @@
'use client';
import { DashboardLayout } from '@/components/dash/DashboardLayout';
export default function DashPage() {
return <DashboardLayout />;
}
+227
View File
@@ -0,0 +1,227 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { getMetaTableList, getMetaFields } from '@/lib/api/meta';
import { fcList, fcInsert, fcUpdate } from '@/lib/api/fcData';
import { FcSearch, FcTable, FcForm, FcPagination } from '@/components/fc';
import type { FieldConfig } from '@/types/invyone-component';
export default function TestFcPage() {
// 테이블 목록
const [tables, setTables] = useState<Record<string, any>[]>([]);
const [selectedTable, setSelectedTable] = useState('');
// FieldConfig
const [fields, setFields] = useState<FieldConfig[]>([]);
const [tableLabel, setTableLabel] = useState('');
const [primaryKey, setPrimaryKey] = useState<string | null>(null);
// 데이터
const [data, setData] = useState<Record<string, any>[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [loading, setLoading] = useState(false);
// 검색 + 행 선택
const [searchParams, setSearchParams] = useState<Record<string, any>>({});
const [selectedRow, setSelectedRow] = useState<Record<string, any> | null>(null);
const [selectedRowIndex, setSelectedRowIndex] = useState<number>(-1);
// 알림
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// 1. 테이블 목록 로드
useEffect(() => {
getMetaTableList()
.then(setTables)
.catch((err) => console.error('테이블 목록 로드 실패:', err));
}, []);
// 2. 테이블 선택 시 FieldConfig 로드
useEffect(() => {
if (!selectedTable) {
setFields([]);
setData([]);
setSelectedRow(null);
return;
}
getMetaFields(selectedTable)
.then((meta) => {
setFields(meta.fields);
setTableLabel(meta.table_label);
setPrimaryKey(meta.primary_key);
setSearchParams({});
setSelectedRow(null);
setSelectedRowIndex(-1);
setPage(1);
})
.catch((err) => console.error('FieldConfig 로드 실패:', err));
}, [selectedTable]);
// 3. 데이터 조회 (테이블 선택, 검색, 페이지 변경 시)
const fetchData = useCallback(async () => {
if (!selectedTable || fields.length === 0) return;
setLoading(true);
try {
const result = await fcList({
tableName: selectedTable,
page,
size: pageSize,
...searchParams,
});
setData(result.data);
setTotal(result.total);
} catch (err) {
console.error('데이터 조회 실패:', err);
setData([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [selectedTable, fields.length, page, pageSize, searchParams]);
useEffect(() => {
fetchData();
}, [fetchData]);
// 4. 검색 연동: FcSearch → FcTable
const handleSearch = useCallback((params: Record<string, any>) => {
setSearchParams(params);
setPage(1);
setSelectedRow(null);
setSelectedRowIndex(-1);
}, []);
// 5. 행 선택 연동: FcTable → FcForm
const handleRowSelect = useCallback((row: Record<string, any>) => {
setSelectedRow(row);
const idx = data.findIndex((d) => d === row);
setSelectedRowIndex(idx);
}, [data]);
// 6. 폼 제출: FcForm → DB 저장 → FcTable 새로고침
const handleFormSubmit = useCallback(async (formData: Record<string, any>) => {
try {
if (selectedRow && primaryKey && selectedRow[primaryKey]) {
// 수정
await fcUpdate(selectedTable, selectedRow[primaryKey], formData);
showMessage('success', '수정되었습니다');
} else {
// 등록
await fcInsert(selectedTable, formData);
showMessage('success', '등록되었습니다');
}
fetchData();
} catch (err: any) {
showMessage('error', err?.message ?? '저장 실패');
}
}, [selectedTable, selectedRow, primaryKey, fetchData]);
// 페이지 변경
const handlePageChange = useCallback((params: { page: number; size: number }) => {
setPage(params.page);
setPageSize(params.size);
}, []);
function showMessage(type: 'success' | 'error', text: string) {
setMessage({ type, text });
setTimeout(() => setMessage(null), 3000);
}
return (
<div className="p-4 space-y-3 max-w-[1400px]">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h1 className="text-sm font-bold text-[var(--v5-text)]">
FieldConfig
</h1>
{message && (
<div className={`px-3 py-1 rounded text-xs font-medium
${message.type === 'success'
? 'bg-[var(--v5-green)]/20 text-[var(--v5-green)]'
: 'bg-[var(--v5-red)]/20 text-[var(--v5-red)]'}`}
>
{message.text}
</div>
)}
</div>
{/* 테이블 선택 드롭다운 */}
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-[var(--v5-text-sec)]">:</label>
<select
value={selectedTable}
onChange={(e) => setSelectedTable(e.target.value)}
className="h-7 rounded-md border border-[var(--v5-border)] bg-[var(--v5-surface)]
px-2 text-xs text-[var(--v5-text)] outline-none min-w-[250px]"
>
<option value=""> ...</option>
{tables.map((t) => (
<option key={t.table_name} value={t.table_name}>
{t.table_label !== t.table_name ? `${t.table_label} (${t.table_name})` : t.table_name}
{t.has_custom_meta ? ' ★' : ''}
</option>
))}
</select>
{selectedTable && (
<span className="text-xs text-[var(--v5-text-muted)]">
{tableLabel} {fields.length} , PK: {primaryKey ?? 'N/A'}
</span>
)}
</div>
{/* 메인 콘텐츠: 선택된 테이블이 있을 때만 */}
{selectedTable && fields.length > 0 && (
<div className="grid grid-cols-[1fr_350px] gap-3">
{/* 왼쪽: 검색 + 테이블 + 페이지네이션 */}
<div className="space-y-2">
<FcSearch
fields={fields}
onSearch={handleSearch}
config={{ layout: 'inline', autoSearch: false }}
/>
<FcTable
fields={fields}
data={data}
loading={loading}
onRowSelect={handleRowSelect}
selectedRowIndex={selectedRowIndex}
config={{ pageSize, selectionMode: 'single', style: 'compact' }}
/>
<FcPagination
total={total}
page={page}
pageSize={pageSize}
onPageChange={handlePageChange}
/>
</div>
{/* 오른쪽: 폼 */}
<div className="space-y-2">
<div className="text-xs font-medium text-[var(--v5-text-sec)]">
{selectedRow ? '수정' : '신규 등록'}
</div>
<FcForm
fields={fields}
loadRow={selectedRow ?? undefined}
onSubmit={handleFormSubmit}
config={{ columns: 1, saveAction: { method: selectedRow ? 'UPDATE' : 'INSERT', refreshAfterSave: true } }}
/>
</div>
</div>
)}
{/* 선택 안 됐을 때 */}
{!selectedTable && (
<div className="flex items-center justify-center h-60 rounded-md border border-dashed border-[var(--v5-border)]
text-[var(--v5-text-muted)] text-xs">
FcSearch + FcTable + FcForm이
</div>
)}
</div>
);
}
@@ -0,0 +1,168 @@
"use client";
import React, { useMemo } from "react";
import { useBuilderState } from "./hooks/useBuilderState";
import { useBlockDrag } from "./hooks/useBlockDrag";
import type { Component, TableConfig, FormConfig, SearchConfig, TitleConfig, ButtonConfig, ButtonBarConfig } from "@/types/invyone-component";
import type { FieldConfig } from "@/types/invyone-component";
interface BuilderBlockProps {
block: Component;
}
/** 캔버스 위의 개별 블록 — 드래그 이동 + 리사이즈 + 프리뷰 */
export default function BuilderBlock({ block }: BuilderBlockProps) {
const selectedBlockId = useBuilderState((s) => s.selectedBlockId);
const fields = useBuilderState((s) => s.fields);
const selectBlock = useBuilderState((s) => s.selectBlock);
const { startDrag, startResize } = useBlockDrag();
const isSelected = selectedBlockId === block.id;
const { x, y, w, h } = block.position;
return (
<div
className={`dev-block${isSelected ? " selected" : ""}`}
style={{ left: x, top: y, width: w, height: h }}
onMouseDown={(e) => {
if ((e.target as HTMLElement).classList.contains("dev-resize-handle")) return;
startDrag(e, block.id, x, y, w, h);
}}
onClick={(e) => { e.stopPropagation(); selectBlock(block.id); }}
>
<div className="dev-block-label">{block.label}</div>
<div className="dev-block-content">
<BlockPreview block={block} fields={fields} />
</div>
<div
className="dev-resize-handle"
onMouseDown={(e) => startResize(e, block.id, x, y, w, h)}
/>
</div>
);
}
/** 블록 내부 프리뷰 렌더 (타입별 분기) */
function BlockPreview({ block, fields }: { block: Component; fields: FieldConfig[] }) {
const visibleFields = useMemo(
() => fields.filter((f) => f.visible && !f.system).sort((a, b) => a.order - b.order),
[fields]
);
switch (block.type) {
case "table":
return <TablePreview fields={visibleFields} />;
case "form":
return <FormPreview fields={visibleFields} config={block.config as FormConfig} />;
case "search":
return <SearchPreview fields={fields.filter((f) => f.searchable && !f.system)} />;
case "title":
return <TitlePreview config={block.config as TitleConfig} />;
case "button":
return <ButtonPreview config={block.config as ButtonConfig} />;
case "button-bar":
return <ButtonBarPreview config={block.config as ButtonBarConfig} />;
case "pagination":
return <PaginationPreview />;
case "divider":
return <div style={{ borderTop: "1px solid var(--d-border)", margin: "0.3rem 0" }} />;
case "stats":
return <div style={{ fontSize: "0.44rem", color: "var(--d-text3)" }}> </div>;
default:
return <div style={{ fontSize: "0.44rem", color: "var(--d-text3)" }}>{block.type}</div>;
}
}
function TablePreview({ fields }: { fields: FieldConfig[] }) {
const cols = fields.slice(0, 8);
if (!cols.length) return <div style={{ fontSize: "0.44rem", color: "var(--d-text3)" }}> </div>;
return (
<table className="dev-pv-table">
<thead>
<tr>{cols.map((f) => <th key={f.column}>{f.label}</th>)}</tr>
</thead>
<tbody>
{[0, 1, 2].map((r) => (
<tr key={r}>{cols.map((f) => <td key={f.column}></td>)}</tr>
))}
</tbody>
</table>
);
}
function FormPreview({ fields, config }: { fields: FieldConfig[]; config: FormConfig }) {
const cols = config?.columns || 2;
const formFields = fields.filter((f) => !f.pk || f.type !== "code");
return (
<div style={{ display: "grid", gridTemplateColumns: `repeat(${cols}, 1fr)`, gap: "0.2rem 0.4rem" }}>
{formFields.slice(0, 10).map((f) => (
<div className="dev-pv-field" key={f.column}>
<div className="dev-pv-field-label">
{f.label}{f.required && <span style={{ color: "var(--d-red)" }}> *</span>}
</div>
<div className="dev-pv-field-input">
{f.type === "select"
? (typeof f.options?.[0] === "string" ? f.options[0] : typeof f.options?.[0] === "object" ? f.options[0].label : "—")
: "—"}
</div>
</div>
))}
</div>
);
}
function SearchPreview({ fields }: { fields: FieldConfig[] }) {
if (!fields.length) return <div style={{ fontSize: "0.44rem", color: "var(--d-text3)" }}> </div>;
return (
<div className="dev-pv-search">
{fields.slice(0, 5).map((f) => (
<div className="dev-pv-search-item" key={f.column}>
<div className="dev-pv-search-label">{f.label}</div>
<div className="dev-pv-search-input"></div>
</div>
))}
<div className="dev-pv-search-item" style={{ justifyContent: "flex-end" }}>
<button className="dev-pv-btn primary" style={{ marginTop: "auto" }}></button>
</div>
</div>
);
}
function TitlePreview({ config }: { config: TitleConfig }) {
return (
<div style={{ fontSize: config.fontSize, fontWeight: config.fontWeight, textAlign: config.align, color: "var(--d-text)" }}>
{config.text || "제목"}
</div>
);
}
function ButtonPreview({ config }: { config: ButtonConfig }) {
const cls = config.variant === "primary" ? "dev-pv-btn primary" : "dev-pv-btn";
return <div className={cls}>{config.text || "버튼"}</div>;
}
function ButtonBarPreview({ config }: { config: ButtonBarConfig }) {
return (
<div style={{ display: "flex", gap: "0.2rem", padding: "0.2rem" }}>
{(config.buttons || []).map((btn, i) => (
<div key={i} className={btn.variant === "primary" ? "dev-pv-btn primary" : "dev-pv-btn"}>
{btn.text}
</div>
))}
</div>
);
}
function PaginationPreview() {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", fontSize: "0.42rem", color: "var(--d-text3)", padding: "0.1rem" }}>
<span> 0</span>
<div style={{ display: "flex", gap: "0.15rem" }}>
<span style={{ padding: "0.1rem 0.25rem", borderRadius: 3, background: "var(--d-accent)", color: "#fff" }}>1</span>
<span style={{ padding: "0.1rem 0.25rem" }}>2</span>
<span style={{ padding: "0.1rem 0.25rem" }}>3</span>
</div>
<span>20/</span>
</div>
);
}
@@ -0,0 +1,91 @@
"use client";
import React, { useCallback } from "react";
import { useBuilderState, useCurrentViewBlocks } from "./hooks/useBuilderState";
import BuilderBlock from "./BuilderBlock";
import type { ComponentType } from "@/types/invyone-component";
export default function BuilderCanvas() {
const blocks = useCurrentViewBlocks();
const addBlock = useBuilderState((s) => s.addBlock);
const selectBlock = useBuilderState((s) => s.selectBlock);
const currentView = useBuilderState((s) => s.currentView);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const type = e.dataTransfer.getData("component-type") as ComponentType;
if (!type) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = Math.round(e.clientX - rect.left);
const y = Math.round(e.clientY - rect.top);
addBlock(type, { x, y, w: 0, h: 0 });
},
[addBlock]
);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}, []);
const handleCanvasClick = useCallback(
(e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest(".dev-block")) return;
selectBlock(null);
},
[selectBlock]
);
// 팝업 뷰 (등록/수정)
if (currentView !== "list") {
return (
<div className="dev-canvas" onClick={handleCanvasClick}>
<div className="dev-popup-overlay">
<div className="dev-popup-frame">
<div
className="dev-canvas-inner"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{blocks.length === 0 && (
<div className="dev-empty">
<div className="dev-empty-icon">📝</div>
<div className="dev-empty-text">
{currentView === "create" ? "등록" : "수정"}
</div>
</div>
)}
{blocks.map((block) => (
<BuilderBlock key={block.id} block={block} />
))}
</div>
</div>
</div>
</div>
);
}
return (
<div
className="dev-canvas"
onClick={handleCanvasClick}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
<div className="dev-canvas-inner">
{blocks.length === 0 && (
<div className="dev-empty">
<div className="dev-empty-icon">🎨</div>
<div className="dev-empty-text">
</div>
</div>
)}
{blocks.map((block) => (
<BuilderBlock key={block.id} block={block} />
))}
</div>
</div>
);
}
@@ -0,0 +1,61 @@
"use client";
import React, { useEffect, useCallback } from "react";
import { useBuilderState } from "./hooks/useBuilderState";
import BuilderToolbar from "./BuilderToolbar";
import BuilderPalette from "./BuilderPalette";
import BuilderCanvas from "./BuilderCanvas";
import BuilderProps from "./BuilderProps";
import "@/styles/developer.css";
export default function BuilderLayout() {
const blocks = useBuilderState((s) => s.blocks);
const currentView = useBuilderState((s) => s.currentView);
const tableName = useBuilderState((s) => s.tableName);
const connections = useBuilderState((s) => s.connections);
const isDirty = useBuilderState((s) => s.isDirty);
const selectedBlockId = useBuilderState((s) => s.selectedBlockId);
const removeBlock = useBuilderState((s) => s.removeBlock);
const selectBlock = useBuilderState((s) => s.selectBlock);
const viewBlocks = blocks[currentView];
const blockCount = viewBlocks.length;
// 키보드 단축키: Delete/Backspace → 블록 삭제, 화살표 → 블록 이동
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (!selectedBlockId) return;
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return;
if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
removeBlock(selectedBlockId);
}
if (e.key === "Escape") {
selectBlock(null);
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [selectedBlockId, removeBlock, selectBlock]);
return (
<div className="dev-shell">
<BuilderToolbar />
<div className="dev-body">
<BuilderPalette />
<BuilderCanvas />
<BuilderProps />
</div>
{/* 상태바 */}
<div className="dev-status">
<span> {blockCount} · {tableName || "테이블 미선택"} · {connections.length}</span>
<span>{isDirty ? "수정됨" : "저장됨"}</span>
</div>
</div>
);
}
@@ -0,0 +1,90 @@
"use client";
import React, { useCallback } from "react";
import { useBuilderState } from "./hooks/useBuilderState";
import type { ComponentType } from "@/types/invyone-component";
interface PaletteItem {
type: ComponentType;
label: string;
icon: string;
cat: "data" | "input" | "action" | "display";
}
const PALETTE_ITEMS: { section: string; items: PaletteItem[] }[] = [
{
section: "데이터",
items: [
{ type: "table", label: "데이터 테이블", icon: "📊", cat: "data" },
{ type: "search", label: "검색 필터", icon: "🔍", cat: "data" },
],
},
{
section: "입력",
items: [
{ type: "form", label: "입력 폼", icon: "📝", cat: "input" },
],
},
{
section: "액션",
items: [
{ type: "button", label: "버튼", icon: "🔘", cat: "action" },
{ type: "button-bar", label: "버튼 바", icon: "⬜", cat: "action" },
],
},
{
section: "표시",
items: [
{ type: "title", label: "제목/텍스트", icon: "📌", cat: "display" },
{ type: "stats", label: "통계 카드", icon: "📈", cat: "display" },
{ type: "divider", label: "구분선", icon: "——", cat: "display" },
{ type: "pagination", label: "페이지네이션", icon: "📄", cat: "display" },
],
},
];
export default function BuilderPalette() {
const addBlock = useBuilderState((s) => s.addBlock);
const tableName = useBuilderState((s) => s.tableName);
const handleDragStart = useCallback(
(e: React.DragEvent, type: ComponentType) => {
e.dataTransfer.setData("component-type", type);
e.dataTransfer.effectAllowed = "copy";
},
[]
);
const handleClick = useCallback(
(type: ComponentType) => {
// 클릭으로도 추가 가능 (캔버스 중앙에 배치)
addBlock(type, { x: 16, y: 16, w: 0, h: 0 });
},
[addBlock]
);
return (
<div className="dev-palette">
<div className="dev-pal-header"></div>
{PALETTE_ITEMS.map((sec) => (
<React.Fragment key={sec.section}>
<div className="dev-pal-sec">{sec.section}</div>
{sec.items.map((item) => (
<div
key={item.type}
className="dev-pal-item"
data-cat={item.cat}
draggable
onDragStart={(e) => handleDragStart(e, item.type)}
onClick={() => handleClick(item.type)}
style={{ opacity: !tableName && ["table", "form", "search"].includes(item.type) ? 0.4 : 1 }}
>
<span className="dev-pal-icon">{item.icon}</span>
<span>{item.label}</span>
</div>
))}
</React.Fragment>
))}
</div>
);
}
@@ -0,0 +1,98 @@
"use client";
import React from "react";
import { useBuilderState, useSelectedBlock } from "./hooks/useBuilderState";
import TableProps from "./props/TableProps";
import FormProps from "./props/FormProps";
import SearchProps from "./props/SearchProps";
import { SingleButtonProps, ButtonBarProps } from "./props/ButtonProps";
import TitleProps from "./props/TitleProps";
const TYPE_LABELS: Record<string, string> = {
table: "📊 데이터 테이블",
form: "📝 입력 폼",
search: "🔍 검색 필터",
button: "🔘 버튼",
"button-bar": "⬜ 버튼 바",
title: "📌 제목/텍스트",
stats: "📈 통계 카드",
divider: "── 구분선",
pagination: "📄 페이지네이션",
};
/** 우측 속성 패널 */
export default function BuilderProps() {
const block = useSelectedBlock();
const updateBlock = useBuilderState((s) => s.updateBlock);
const removeBlock = useBuilderState((s) => s.removeBlock);
const moveBlock = useBuilderState((s) => s.moveBlock);
const resizeBlock = useBuilderState((s) => s.resizeBlock);
if (!block) {
return (
<div className="dev-props">
<div className="dev-prop-header"></div>
<div style={{ padding: "1rem 0.6rem", textAlign: "center", color: "var(--d-text3)", fontSize: "0.5rem" }}>
<br />
</div>
</div>
);
}
return (
<div className="dev-props">
<div className="dev-prop-header">{TYPE_LABELS[block.type] || block.type}</div>
{/* 공통: 이름 */}
<div className="dev-prop-sec"> </div>
<div className="dev-prop-row">
<span className="dev-prop-label"></span>
<input
className="dev-input"
value={block.label}
onChange={(e) => updateBlock(block.id, { label: e.target.value })}
/>
</div>
{/* 공통: 위치/크기 */}
<div className="dev-prop-sec"> · </div>
<div className="dev-pos-grid">
<div className="dev-pos-item">
<label>X</label>
<input type="number" value={Math.round(block.position.x)}
onChange={(e) => moveBlock(block.id, Number(e.target.value), block.position.y)} />
</div>
<div className="dev-pos-item">
<label>Y</label>
<input type="number" value={Math.round(block.position.y)}
onChange={(e) => moveBlock(block.id, block.position.x, Number(e.target.value))} />
</div>
<div className="dev-pos-item">
<label>W</label>
<input type="number" value={Math.round(block.position.w)}
onChange={(e) => resizeBlock(block.id, Number(e.target.value), block.position.h)} />
</div>
<div className="dev-pos-item">
<label>H</label>
<input type="number" value={Math.round(block.position.h)}
onChange={(e) => resizeBlock(block.id, block.position.w, Number(e.target.value))} />
</div>
</div>
{/* 타입별 속성 패널 */}
{block.type === "table" && <TableProps block={block} />}
{block.type === "form" && <FormProps block={block} />}
{block.type === "search" && <SearchProps block={block} />}
{block.type === "button" && <SingleButtonProps block={block} />}
{block.type === "button-bar" && <ButtonBarProps block={block} />}
{block.type === "title" && <TitleProps block={block} />}
{/* 삭제 버튼 */}
<div style={{ padding: "0.6rem" }}>
<button className="dev-delete-btn" onClick={() => removeBlock(block.id)}>
🗑
</button>
</div>
</div>
);
}
@@ -0,0 +1,151 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { useBuilderState } from "./hooks/useBuilderState";
import type { BuilderView } from "./hooks/useBuilderState";
import { getMetaTableList, getMetaFields } from "@/lib/api/meta";
import { insertTemplate, updateTemplate } from "@/lib/api/template";
const VIEW_TABS: { key: BuilderView; label: string }[] = [
{ key: "list", label: "목록" },
{ key: "create", label: "등록" },
{ key: "edit", label: "수정" },
];
export default function BuilderToolbar() {
const [tables, setTables] = useState<Record<string, any>[]>([]);
const [saving, setSaving] = useState(false);
const tableName = useBuilderState((s) => s.tableName);
const templateName = useBuilderState((s) => s.templateName);
const currentView = useBuilderState((s) => s.currentView);
const templateId = useBuilderState((s) => s.templateId);
const isDirty = useBuilderState((s) => s.isDirty);
const setTable = useBuilderState((s) => s.setTable);
const switchView = useBuilderState((s) => s.switchView);
const setTemplateMeta = useBuilderState((s) => s.setTemplateMeta);
const toTemplate = useBuilderState((s) => s.toTemplate);
const markClean = useBuilderState((s) => s.markClean);
// 테이블 목록 로드
useEffect(() => {
getMetaTableList()
.then(setTables)
.catch(() => {});
}, []);
// 테이블 선택
const handleTableChange = useCallback(
async (e: React.ChangeEvent<HTMLSelectElement>) => {
const name = e.target.value;
if (!name) return;
try {
const meta = await getMetaFields(name);
setTable(name, meta.fields);
if (!templateName) {
setTemplateMeta({ templateName: meta.table_label || name });
}
} catch {
// ignore
}
},
[setTable, setTemplateMeta, templateName]
);
// 저장
const handleSave = useCallback(async () => {
setSaving(true);
try {
const tpl = toTemplate();
const payload: Record<string, any> = {
name: tpl.name,
category: tpl.category,
description: tpl.description,
primary_table: tpl.primaryTable,
fields: tpl.fields,
views: tpl.views,
connections: tpl.connections,
};
if (templateId) {
await updateTemplate(templateId, payload);
} else {
const result = await insertTemplate(payload);
useBuilderState.setState({ templateId: result.template_id });
}
markClean();
} catch {
// ignore
} finally {
setSaving(false);
}
}, [toTemplate, templateId, markClean]);
return (
<>
{/* 헤더 */}
<div className="dev-hdr">
<div className="dev-hdr-l">
<span className="dev-logo">INVYONE</span>
<span className="dev-badge">DEV</span>
<input
className="dev-input"
style={{ minWidth: 160, fontWeight: 600, fontSize: "0.62rem" }}
value={templateName}
onChange={(e) => setTemplateMeta({ templateName: e.target.value })}
placeholder="템플릿 이름"
/>
</div>
<div className="dev-hdr-r">
<button
className={`dev-btn primary`}
onClick={handleSave}
disabled={saving}
>
{saving ? "저장 중..." : "💾 저장"}
</button>
</div>
</div>
{/* 도구모음 */}
<div className="dev-toolbar">
<div className="dev-tb-group">
<span className="dev-tb-label"></span>
<select
className="dev-select"
value={tableName || ""}
onChange={handleTableChange}
>
<option value=""> ...</option>
{tables.map((t) => (
<option key={t.table_name} value={t.table_name}>
{t.table_label || t.table_name}
{t.has_custom_meta ? " ★" : ""}
</option>
))}
</select>
</div>
<div className="dev-tb-group">
<span className="dev-tb-label"></span>
{VIEW_TABS.map((tab) => (
<button
key={tab.key}
className={`dev-view-tab${currentView === tab.key ? " active" : ""}`}
onClick={() => switchView(tab.key)}
>
{tab.label}
</button>
))}
</div>
<div style={{ flex: 1 }} />
{isDirty && (
<span style={{ fontSize: "0.42rem", color: "var(--d-orange)", fontWeight: 600 }}>
</span>
)}
</div>
</>
);
}
@@ -0,0 +1,88 @@
"use client";
import { useCallback, useRef } from "react";
import { useBuilderState } from "./useBuilderState";
interface DragState {
id: string;
startX: number;
startY: number;
origX: number;
origY: number;
origW: number;
origH: number;
mode: "move" | "resize";
}
/**
* 블록 드래그(이동)/리사이즈 훅.
* mousedown → mousemove → mouseup 패턴.
* Shift 키: 8px 스냅.
*/
export function useBlockDrag() {
const dragRef = useRef<DragState | null>(null);
const moveBlock = useBuilderState((s) => s.moveBlock);
const resizeBlock = useBuilderState((s) => s.resizeBlock);
const selectBlock = useBuilderState((s) => s.selectBlock);
const startDrag = useCallback(
(e: React.MouseEvent, id: string, origX: number, origY: number, origW: number, origH: number) => {
e.preventDefault();
e.stopPropagation();
selectBlock(id);
dragRef.current = { id, startX: e.clientX, startY: e.clientY, origX, origY, origW, origH, mode: "move" };
document.body.style.cursor = "grabbing";
document.body.style.userSelect = "none";
const onMove = (ev: MouseEvent) => {
const d = dragRef.current;
if (!d || d.mode !== "move") return;
let nx = d.origX + (ev.clientX - d.startX);
let ny = d.origY + (ev.clientY - d.startY);
if (ev.shiftKey) { nx = Math.round(nx / 8) * 8; ny = Math.round(ny / 8) * 8; }
moveBlock(d.id, Math.round(nx), Math.round(ny));
};
const onUp = () => {
dragRef.current = null;
document.body.style.cursor = "";
document.body.style.userSelect = "";
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[moveBlock, selectBlock]
);
const startResize = useCallback(
(e: React.MouseEvent, id: string, origX: number, origY: number, origW: number, origH: number) => {
e.preventDefault();
e.stopPropagation();
dragRef.current = { id, startX: e.clientX, startY: e.clientY, origX, origY, origW, origH, mode: "resize" };
document.body.style.cursor = "nwse-resize";
document.body.style.userSelect = "none";
const onMove = (ev: MouseEvent) => {
const d = dragRef.current;
if (!d || d.mode !== "resize") return;
let nw = d.origW + (ev.clientX - d.startX);
let nh = d.origH + (ev.clientY - d.startY);
if (ev.shiftKey) { nw = Math.round(nw / 8) * 8; nh = Math.round(nh / 8) * 8; }
resizeBlock(d.id, Math.round(nw), Math.round(nh));
};
const onUp = () => {
dragRef.current = null;
document.body.style.cursor = "";
document.body.style.userSelect = "";
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[resizeBlock]
);
return { startDrag, startResize };
}
@@ -0,0 +1,362 @@
"use client";
import { create } from "zustand";
import { devtools } from "zustand/middleware";
import type {
FieldConfig,
Component,
ComponentType,
Position,
ViewConfig,
Template,
Connection,
TableConfig,
FormConfig,
SearchConfig,
ButtonConfig,
ButtonBarConfig,
TitleConfig,
StatsConfig,
DividerConfig,
PaginationConfig,
ComponentTypeConfig,
} from "@/types/invyone-component";
// ─── 뷰 타입 ───
export type BuilderView = "list" | "create" | "edit";
// ─── 상태 인터페이스 ───
interface BuilderState {
// 테이블/필드
tableName: string | null;
fields: FieldConfig[];
// 현재 뷰
currentView: BuilderView;
// 블록 목록 (뷰별)
blocks: Record<BuilderView, Component[]>;
// 선택된 블록
selectedBlockId: string | null;
// 연결
connections: Connection[];
// 템플릿 메타
templateId: string | null;
templateName: string;
category: string;
description: string;
// 변경 상태
isDirty: boolean;
// 액션
setTable: (tableName: string, fields: FieldConfig[]) => void;
switchView: (view: BuilderView) => void;
addBlock: (type: ComponentType, position: Position) => void;
removeBlock: (id: string) => void;
updateBlock: (id: string, updates: Partial<Component>) => void;
selectBlock: (id: string | null) => void;
moveBlock: (id: string, x: number, y: number) => void;
resizeBlock: (id: string, w: number, h: number) => void;
updateBlockConfig: (id: string, config: Partial<ComponentTypeConfig>) => void;
updateField: (column: string, updates: Partial<FieldConfig>) => void;
setTemplateMeta: (meta: { templateName?: string; category?: string; description?: string }) => void;
addConnection: (conn: Connection) => void;
removeConnection: (connId: string) => void;
toTemplate: () => Template;
fromTemplate: (tpl: Record<string, any>) => void;
resetBuilder: () => void;
markClean: () => void;
}
// ─── ID 생성 ───
let _blockIdCounter = 0;
function genBlockId(): string {
return `blk_${Date.now().toString(36)}_${(++_blockIdCounter).toString(36)}`;
}
function genConnId(): string {
return `conn_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
}
// ─── 컴포넌트 기본 설정 ───
function defaultConfig(type: ComponentType): ComponentTypeConfig {
switch (type) {
case "table":
return {
pageSize: 20,
selectionMode: "single",
showCheckbox: false,
inlineEdit: false,
autoLoad: true,
toolbar: { showExcel: false, showRefresh: true, showFilter: false },
style: "default",
} satisfies TableConfig;
case "form":
return {
columns: 2,
saveAction: { method: "UPSERT", refreshAfterSave: true },
} satisfies FormConfig;
case "search":
return {
dateRangeEnabled: true,
showResetButton: true,
autoSearch: false,
layout: "inline",
} satisfies SearchConfig;
case "button":
return {
text: "버튼",
actionType: "save",
variant: "default",
} satisfies ButtonConfig;
case "button-bar":
return {
buttons: [
{ text: "등록", actionType: "add", variant: "primary" },
{ text: "삭제", actionType: "delete", variant: "destructive" },
],
} satisfies ButtonBarConfig;
case "title":
return {
text: "제목",
fontSize: "0.75rem",
fontWeight: "700",
align: "left",
} satisfies TitleConfig;
case "stats":
return { items: [] } satisfies StatsConfig;
case "divider":
return { style: "solid" } satisfies DividerConfig;
case "pagination":
return {
pageSize: 20,
showSizeSelector: true,
sizeOptions: [10, 20, 50, 100],
} satisfies PaginationConfig;
default:
return { text: "", fontSize: "0.75rem", fontWeight: "400", align: "left" } satisfies TitleConfig;
}
}
// ─── 컴포넌트 기본 크기 ───
function defaultSize(type: ComponentType): { w: number; h: number } {
switch (type) {
case "table": return { w: 854, h: 380 };
case "form": return { w: 440, h: 300 };
case "search": return { w: 854, h: 42 };
case "button": return { w: 100, h: 36 };
case "button-bar": return { w: 370, h: 36 };
case "title": return { w: 300, h: 36 };
case "stats": return { w: 400, h: 80 };
case "divider": return { w: 854, h: 8 };
case "pagination": return { w: 854, h: 24 };
default: return { w: 200, h: 100 };
}
}
// ─── 컴포넌트 기본 라벨 ───
function defaultLabel(type: ComponentType): string {
const map: Record<string, string> = {
table: "데이터 테이블",
form: "입력 폼",
search: "검색 필터",
button: "버튼",
"button-bar": "버튼 바",
title: "제목",
stats: "통계 카드",
divider: "구분선",
pagination: "페이지네이션",
chart: "차트",
tabs: "탭",
"split-panel": "분할 패널",
};
return map[type] || type;
}
// ─── 초기 상태 ───
const initialState = {
tableName: null as string | null,
fields: [] as FieldConfig[],
currentView: "list" as BuilderView,
blocks: { list: [], create: [], edit: [] } as Record<BuilderView, Component[]>,
selectedBlockId: null as string | null,
connections: [] as Connection[],
templateId: null as string | null,
templateName: "",
category: "",
description: "",
isDirty: false,
};
// ─── 스토어 ───
export const useBuilderState = create<BuilderState>()(
devtools(
(set, get) => ({
...initialState,
setTable: (tableName, fields) =>
set({ tableName, fields, isDirty: true }),
switchView: (view) =>
set({ currentView: view, selectedBlockId: null }),
addBlock: (type, position) => {
const state = get();
const size = defaultSize(type);
const block: Component = {
id: genBlockId(),
type,
label: defaultLabel(type),
position: { x: position.x, y: position.y, w: size.w, h: size.h },
config: defaultConfig(type),
};
const viewBlocks = [...state.blocks[state.currentView], block];
set({
blocks: { ...state.blocks, [state.currentView]: viewBlocks },
selectedBlockId: block.id,
isDirty: true,
});
},
removeBlock: (id) => {
const state = get();
const viewBlocks = state.blocks[state.currentView].filter((b) => b.id !== id);
const connections = state.connections.filter(
(c) => c.from.componentId !== id && c.to.componentId !== id
);
set({
blocks: { ...state.blocks, [state.currentView]: viewBlocks },
connections,
selectedBlockId: state.selectedBlockId === id ? null : state.selectedBlockId,
isDirty: true,
});
},
updateBlock: (id, updates) => {
const state = get();
const viewBlocks = state.blocks[state.currentView].map((b) =>
b.id === id ? { ...b, ...updates } : b
);
set({ blocks: { ...state.blocks, [state.currentView]: viewBlocks }, isDirty: true });
},
selectBlock: (id) => set({ selectedBlockId: id }),
moveBlock: (id, x, y) => {
const state = get();
const viewBlocks = state.blocks[state.currentView].map((b) =>
b.id === id ? { ...b, position: { ...b.position, x: Math.max(0, x), y: Math.max(0, y) } } : b
);
set({ blocks: { ...state.blocks, [state.currentView]: viewBlocks }, isDirty: true });
},
resizeBlock: (id, w, h) => {
const state = get();
const viewBlocks = state.blocks[state.currentView].map((b) =>
b.id === id
? { ...b, position: { ...b.position, w: Math.max(40, w), h: Math.max(20, h) } }
: b
);
set({ blocks: { ...state.blocks, [state.currentView]: viewBlocks }, isDirty: true });
},
updateBlockConfig: (id, configUpdates) => {
const state = get();
const viewBlocks = state.blocks[state.currentView].map((b) =>
b.id === id ? { ...b, config: { ...b.config, ...configUpdates } as ComponentTypeConfig } : b
);
set({ blocks: { ...state.blocks, [state.currentView]: viewBlocks }, isDirty: true });
},
updateField: (column, updates) => {
const state = get();
const fields = state.fields.map((f) =>
f.column === column ? { ...f, ...updates } : f
);
set({ fields, isDirty: true });
},
setTemplateMeta: (meta) =>
set({
...(meta.templateName !== undefined ? { templateName: meta.templateName } : {}),
...(meta.category !== undefined ? { category: meta.category } : {}),
...(meta.description !== undefined ? { description: meta.description } : {}),
isDirty: true,
}),
addConnection: (conn) => {
const state = get();
if (!conn.id) conn.id = genConnId();
set({ connections: [...state.connections, conn], isDirty: true });
},
removeConnection: (connId) => {
const state = get();
set({ connections: state.connections.filter((c) => c.id !== connId), isDirty: true });
},
toTemplate: (): Template => {
const s = get();
return {
templateId: s.templateId || "",
name: s.templateName,
category: s.category,
description: s.description || undefined,
primaryTable: s.tableName || "",
fields: s.fields,
views: {
list: { components: s.blocks.list },
create: { components: s.blocks.create },
edit: { components: s.blocks.edit },
},
connections: s.connections,
companyCode: "*",
version: 1,
status: "draft",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
},
fromTemplate: (tpl) => {
set({
templateId: tpl.template_id ?? tpl.templateId ?? null,
templateName: tpl.name ?? "",
category: tpl.category ?? "",
description: tpl.description ?? "",
tableName: tpl.primary_table ?? tpl.primaryTable ?? null,
fields: tpl.fields ?? [],
blocks: {
list: tpl.views?.list?.components ?? [],
create: tpl.views?.create?.components ?? [],
edit: tpl.views?.edit?.components ?? [],
},
connections: tpl.connections ?? [],
currentView: "list",
selectedBlockId: null,
isDirty: false,
});
},
resetBuilder: () => set({ ...initialState }),
markClean: () => set({ isDirty: false }),
}),
{ name: "builder-state" }
)
);
// ─── 셀렉터 훅 ───
export function useCurrentViewBlocks() {
return useBuilderState((s) => s.blocks[s.currentView]);
}
export function useSelectedBlock(): Component | null {
return useBuilderState((s) => {
if (!s.selectedBlockId) return null;
return s.blocks[s.currentView].find((b) => b.id === s.selectedBlockId) ?? null;
});
}
@@ -0,0 +1,119 @@
"use client";
import React from "react";
import { useBuilderState } from "../hooks/useBuilderState";
import type { Component, ButtonConfig, ButtonBarConfig } from "@/types/invyone-component";
const ACTION_OPTIONS: { value: string; label: string }[] = [
{ value: "save", label: "저장" },
{ value: "edit", label: "수정" },
{ value: "delete", label: "삭제" },
{ value: "add", label: "신규" },
{ value: "cancel", label: "취소" },
{ value: "close", label: "닫기" },
{ value: "navigate", label: "화면 이동" },
{ value: "popup", label: "팝업 열기" },
{ value: "search", label: "검색" },
{ value: "reset", label: "초기화" },
{ value: "submit", label: "제출" },
{ value: "approval", label: "승인" },
];
const VARIANT_OPTIONS: { value: string; label: string }[] = [
{ value: "primary", label: "강조 (파란색)" },
{ value: "default", label: "기본 (테두리)" },
{ value: "destructive", label: "위험 (빨간색)" },
{ value: "outline", label: "아웃라인" },
{ value: "ghost", label: "투명" },
];
export function SingleButtonProps({ block }: { block: Component }) {
const updateBlockConfig = useBuilderState((s) => s.updateBlockConfig);
const config = block.config as ButtonConfig;
const update = (key: string, val: any) => updateBlockConfig(block.id, { [key]: val });
return (
<>
<div className="dev-prop-sec"> </div>
<div className="dev-prop-row">
<span className="dev-prop-label"></span>
<input className="dev-input" value={config.text}
onChange={(e) => update("text", e.target.value)} />
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"></span>
<select className="dev-select" value={config.actionType}
onChange={(e) => update("actionType", e.target.value)}>
{ACTION_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"></span>
<select className="dev-select" value={config.variant}
onChange={(e) => update("variant", e.target.value)}>
{VARIANT_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div className="dev-prop-row">
<span className="dev-prop-label"> </span>
<input className="dev-input" value={config.confirm || ""}
placeholder="비워두면 확인 없이 실행"
onChange={(e) => update("confirm", e.target.value || undefined)} />
</div>
</>
);
}
export function ButtonBarProps({ block }: { block: Component }) {
const updateBlockConfig = useBuilderState((s) => s.updateBlockConfig);
const config = block.config as ButtonBarConfig;
const updateButton = (idx: number, key: string, val: any) => {
const buttons = [...config.buttons];
buttons[idx] = { ...buttons[idx], [key]: val };
updateBlockConfig(block.id, { buttons } as any);
};
const addButton = () => {
const buttons = [...config.buttons, { text: "버튼", actionType: "save" as const, variant: "default" as const }];
updateBlockConfig(block.id, { buttons } as any);
};
const removeButton = (idx: number) => {
const buttons = config.buttons.filter((_, i) => i !== idx);
updateBlockConfig(block.id, { buttons } as any);
};
return (
<>
<div className="dev-prop-sec"> </div>
{config.buttons.map((btn, i) => (
<div key={i} style={{ padding: "0.15rem 0.6rem", borderBottom: "1px dashed var(--d-border)" }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.2rem", marginBottom: "0.1rem" }}>
<input className="dev-input" style={{ flex: 1 }} value={btn.text}
onChange={(e) => updateButton(i, "text", e.target.value)} />
<button className="dev-delete-btn" style={{ width: "auto", padding: "0.15rem 0.3rem", fontSize: "0.4rem" }}
onClick={() => removeButton(i)}></button>
</div>
<div style={{ display: "flex", gap: "0.2rem" }}>
<select className="dev-select" style={{ flex: 1, fontSize: "0.42rem" }} value={btn.actionType}
onChange={(e) => updateButton(i, "actionType", e.target.value)}>
{ACTION_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<select className="dev-select" style={{ flex: 1, fontSize: "0.42rem" }} value={btn.variant}
onChange={(e) => updateButton(i, "variant", e.target.value)}>
{VARIANT_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
</div>
))}
<div style={{ padding: "0.2rem 0.6rem" }}>
<button className="dev-btn" style={{ width: "100%", justifyContent: "center" }}
onClick={addButton}>
+
</button>
</div>
</>
);
}
@@ -0,0 +1,97 @@
"use client";
import React, { useState } from "react";
import { useBuilderState } from "../hooks/useBuilderState";
import type { FieldConfig } from "@/types/invyone-component";
interface FieldListEditorProps {
/** 필터 함수: 어떤 필드를 목록에 표시할지 */
filter?: (f: FieldConfig) => boolean;
/** 체크박스 토글 대상 속성 */
toggleKey?: "visible" | "searchable";
}
/** 필드 체크리스트 (table/form/search 속성 패널 공통) */
export default function FieldListEditor({ filter, toggleKey = "visible" }: FieldListEditorProps) {
const fields = useBuilderState((s) => s.fields);
const updateField = useBuilderState((s) => s.updateField);
const [expandedCol, setExpandedCol] = useState<string | null>(null);
const filteredFields = filter ? fields.filter(filter) : fields;
const sorted = [...filteredFields].sort((a, b) => a.order - b.order);
return (
<div className="dev-field-list">
{sorted.map((f) => (
<React.Fragment key={f.column}>
<div
className="dev-field-item"
onClick={() => setExpandedCol(expandedCol === f.column ? null : f.column)}
>
<div
className={`dev-field-check${f[toggleKey] ? " on" : ""}`}
onClick={(e) => {
e.stopPropagation();
updateField(f.column, { [toggleKey]: !f[toggleKey] });
}}
>
</div>
<span className="dev-field-name">{f.label}</span>
<div style={{ display: "flex", gap: 2 }}>
{f.pk && <span className="dev-fc-badge pk">PK</span>}
{f.required && <span className="dev-fc-badge req"></span>}
{f.searchable && <span className="dev-fc-badge sch"></span>}
{f.system && <span className="dev-fc-badge sys">SYS</span>}
{f.computed && <span className="dev-fc-badge cmp"></span>}
</div>
<span className="dev-field-type">{f.type}</span>
<span className="dev-field-drag"></span>
</div>
{expandedCol === f.column && <FieldDetail field={f} />}
</React.Fragment>
))}
</div>
);
}
/** 필드 상세 편집 패널 */
function FieldDetail({ field }: { field: FieldConfig }) {
const updateField = useBuilderState((s) => s.updateField);
const col = field.column;
return (
<div style={{ padding: "0.3rem 0.4rem", background: "var(--d-bg3)", borderRadius: 4, margin: "0.1rem 0", fontSize: "0.46rem" }}
onClick={(e) => e.stopPropagation()}>
<div style={{ display: "flex", gap: "0.3rem", marginBottom: "0.2rem" }}>
<label style={{ flex: 1, display: "flex", flexDirection: "column", gap: 2 }}>
<span style={{ fontSize: "0.4rem", fontWeight: 700, color: "var(--d-text3)" }}> </span>
<input className="dev-input" value={field.label}
onChange={(e) => updateField(col, { label: e.target.value })} />
</label>
<label style={{ width: 80, display: "flex", flexDirection: "column", gap: 2 }}>
<span style={{ fontSize: "0.4rem", fontWeight: 700, color: "var(--d-text3)" }}></span>
<input className="dev-input" type="number" value={field.width ?? ""}
placeholder="자동"
onChange={(e) => updateField(col, { width: parseInt(e.target.value) || undefined })} />
</label>
</div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<Toggle label="필수" checked={field.required} onToggle={(v) => updateField(col, { required: v })} />
<Toggle label="편집" checked={field.editable} onToggle={(v) => updateField(col, { editable: v })} />
<Toggle label="검색" checked={!!field.searchable} onToggle={(v) => updateField(col, { searchable: v })} />
<Toggle label="정렬" checked={field.sortable !== false} onToggle={(v) => updateField(col, { sortable: v })} />
</div>
</div>
);
}
function Toggle({ label, checked, onToggle }: { label: string; checked: boolean; onToggle: (v: boolean) => void }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: "0.2rem", cursor: "pointer" }}
onClick={() => onToggle(!checked)}>
<div className={`dev-toggle${checked ? " on" : ""}`} />
<span style={{ fontSize: "0.42rem", color: "var(--d-text3)" }}>{label}</span>
</div>
);
}
@@ -0,0 +1,41 @@
"use client";
import React from "react";
import { useBuilderState } from "../hooks/useBuilderState";
import FieldListEditor from "./FieldListEditor";
import type { Component, FormConfig } from "@/types/invyone-component";
export default function FormProps({ block }: { block: Component }) {
const updateBlockConfig = useBuilderState((s) => s.updateBlockConfig);
const config = block.config as FormConfig;
const update = (key: string, val: any) => updateBlockConfig(block.id, { [key]: val });
return (
<>
<div className="dev-prop-sec"> </div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"> </span>
<select className="dev-select" value={config.columns}
onChange={(e) => update("columns", Number(e.target.value))}>
<option value={1}>1</option>
<option value={2}>2</option>
<option value={3}>3</option>
</select>
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"> </span>
<select className="dev-select" value={config.saveAction?.method || "UPSERT"}
onChange={(e) => update("saveAction", { ...config.saveAction, method: e.target.value })}>
<option value="INSERT"></option>
<option value="UPDATE"></option>
<option value="UPSERT">/</option>
</select>
</div>
<div className="dev-prop-sec"> </div>
<div className="dev-hint">체크: 폼에 · 클릭: 상세 </div>
<FieldListEditor filter={(f) => !f.system} toggleKey="visible" />
</>
);
}
@@ -0,0 +1,46 @@
"use client";
import React from "react";
import { useBuilderState } from "../hooks/useBuilderState";
import FieldListEditor from "./FieldListEditor";
import type { Component, SearchConfig } from "@/types/invyone-component";
export default function SearchProps({ block }: { block: Component }) {
const updateBlockConfig = useBuilderState((s) => s.updateBlockConfig);
const config = block.config as SearchConfig;
const update = (key: string, val: any) => updateBlockConfig(block.id, { [key]: val });
return (
<>
<div className="dev-prop-sec"> </div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"> </span>
<div className={`dev-toggle${config.dateRangeEnabled ? " on" : ""}`}
onClick={() => update("dateRangeEnabled", !config.dateRangeEnabled)} />
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"> </span>
<div className={`dev-toggle${config.showResetButton ? " on" : ""}`}
onClick={() => update("showResetButton", !config.showResetButton)} />
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"> </span>
<div className={`dev-toggle${config.autoSearch ? " on" : ""}`}
onClick={() => update("autoSearch", !config.autoSearch)} />
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"></span>
<select className="dev-select" value={config.layout}
onChange={(e) => update("layout", e.target.value)}>
<option value="inline"></option>
<option value="stacked"></option>
</select>
</div>
<div className="dev-prop-sec"> </div>
<div className="dev-hint">체크: 검색에 · 클릭: 상세 </div>
<FieldListEditor filter={(f) => !f.system} toggleKey="searchable" />
</>
);
}
@@ -0,0 +1,64 @@
"use client";
import React from "react";
import { useBuilderState } from "../hooks/useBuilderState";
import FieldListEditor from "./FieldListEditor";
import type { Component, TableConfig } from "@/types/invyone-component";
export default function TableProps({ block }: { block: Component }) {
const updateBlockConfig = useBuilderState((s) => s.updateBlockConfig);
const config = block.config as TableConfig;
const update = (key: string, val: any) => updateBlockConfig(block.id, { [key]: val });
return (
<>
<div className="dev-prop-sec"> </div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"> </span>
<select className="dev-select" value={config.pageSize}
onChange={(e) => update("pageSize", Number(e.target.value))}>
{[10, 20, 50, 100].map((n) => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"> </span>
<select className="dev-select" value={config.selectionMode}
onChange={(e) => update("selectionMode", e.target.value)}>
<option value="none"></option>
<option value="single"></option>
<option value="multiple"></option>
</select>
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"> </span>
<div className={`dev-toggle${config.autoLoad ? " on" : ""}`}
onClick={() => update("autoLoad", !config.autoLoad)} />
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"> </span>
<div className={`dev-toggle${config.inlineEdit ? " on" : ""}`}
onClick={() => update("inlineEdit", !config.inlineEdit)} />
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"></span>
<div className={`dev-toggle${config.showCheckbox ? " on" : ""}`}
onClick={() => update("showCheckbox", !config.showCheckbox)} />
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"></span>
<select className="dev-select" value={config.style}
onChange={(e) => update("style", e.target.value)}>
<option value="default"></option>
<option value="striped"></option>
<option value="bordered"></option>
<option value="compact"></option>
</select>
</div>
<div className="dev-prop-sec"> </div>
<div className="dev-hint">체크: 보이기 · 클릭: 상세 </div>
<FieldListEditor filter={(f) => !f.system} toggleKey="visible" />
</>
);
}
@@ -0,0 +1,52 @@
"use client";
import React from "react";
import { useBuilderState } from "../hooks/useBuilderState";
import type { Component, TitleConfig } from "@/types/invyone-component";
export default function TitleProps({ block }: { block: Component }) {
const updateBlockConfig = useBuilderState((s) => s.updateBlockConfig);
const config = block.config as TitleConfig;
const update = (key: string, val: any) => updateBlockConfig(block.id, { [key]: val });
return (
<>
<div className="dev-prop-sec"> </div>
<div className="dev-prop-row">
<span className="dev-prop-label"></span>
<input className="dev-input" value={config.text}
onChange={(e) => update("text", e.target.value)} />
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"></span>
<select className="dev-select" value={config.fontSize}
onChange={(e) => update("fontSize", e.target.value)}>
<option value="0.5rem"></option>
<option value="0.75rem"></option>
<option value="1rem"></option>
<option value="1.2rem"> </option>
</select>
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"></span>
<select className="dev-select" value={config.fontWeight}
onChange={(e) => update("fontWeight", e.target.value)}>
<option value="400"></option>
<option value="500"> </option>
<option value="700"></option>
<option value="800"> </option>
</select>
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"></span>
<select className="dev-select" value={config.align}
onChange={(e) => update("align", e.target.value)}>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
</select>
</div>
</>
);
}
@@ -0,0 +1,161 @@
'use client';
/**
* SVG 연결선 + 화살표 마커 4종 + 뱃지
* mockup drawTreeLine/addEdgeBadge 포팅
*/
interface ConnectionSvgProps {
children?: React.ReactNode;
}
/** SVG 컨테이너 (캔버스 위 오버레이, defs 포함) */
export function ConnectionSvg({ children }: ConnectionSvgProps) {
return (
<svg className="ctrl-svg" width="100%" height="100%" style={{ overflow: 'visible' }}>
<defs>
<marker id="arr-fk" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-cyan)" opacity=".7" />
</marker>
<marker id="arr-auto" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-primary)" opacity=".8" />
</marker>
<marker id="arr-cond" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-amber)" opacity=".8" />
</marker>
<marker id="arr-src" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-pink)" opacity=".8" />
</marker>
<marker id="arr-rule" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-cyan)" opacity=".8" />
</marker>
<marker id="arr-yes" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-green)" opacity=".8" />
</marker>
<marker id="arr-no" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--v5-text-muted, #888)" opacity=".5" />
</marker>
</defs>
{children}
</svg>
);
}
/** bezier 경로 계산: from(x1,y1) → to(x2,y2) */
export function bezierPath(x1: number, y1: number, x2: number, y2: number): string {
const dx = x2 - x1;
return `M${x1},${y1} C${x1 + dx * 0.5},${y1} ${x1 + dx * 0.5},${y2} ${x2},${y2}`;
}
/** 타입별 CSS 클래스 + 마커 */
export function lineStyle(type: string): { cls: string; marker: string } {
switch (type) {
case 'source': return { cls: 'ctrl-line-tpl', marker: 'url(#arr-src)' };
case 'auto': return { cls: 'ctrl-line-auto', marker: 'url(#arr-auto)' };
case 'cond': return { cls: 'ctrl-line-cond', marker: 'url(#arr-cond)' };
default: return { cls: 'ctrl-line', marker: 'url(#arr-fk)' };
}
}
interface FlowLineProps {
x1: number; y1: number;
x2: number; y2: number;
type: string;
animate?: boolean;
delay?: number;
}
/** 단일 연결선 (SVG path) */
export function FlowLine({ x1, y1, x2, y2, type, animate, delay }: FlowLineProps) {
const { cls, marker } = lineStyle(type);
const d = bezierPath(x1, y1, x2, y2);
if (animate) {
return (
<path
d={d}
className={cls}
markerEnd={marker}
style={{
strokeDasharray: '1000',
strokeDashoffset: '1000',
animation: 'none',
transition: `stroke-dashoffset 0.4s ease-out ${(delay ?? 0)}ms`,
}}
ref={(el) => {
if (!el) return;
const len = el.getTotalLength();
el.style.strokeDasharray = String(len);
el.style.strokeDashoffset = String(len);
requestAnimationFrame(() => {
el.style.strokeDashoffset = '0';
});
setTimeout(() => {
el.style.transition = 'none';
el.style.strokeDasharray = '';
el.style.strokeDashoffset = '';
el.style.animation = '';
}, 500 + (delay ?? 0));
}}
/>
);
}
return <path d={d} className={cls} markerEnd={marker} />;
}
interface FlowBadgeProps {
x: number; y: number;
label: string;
type: string;
animate?: boolean;
delay?: number;
}
/** 연결선 위 뱃지 (HTML) */
export function FlowBadge({ x, y, label, type, animate, delay }: FlowBadgeProps) {
const cls = type === 'source' ? 'tpl-link' : type === 'auto' ? 'auto' : type === 'cond' ? 'cond' : '';
if (type === 'cond') {
const parts = label.split('→');
const condText = (parts[0] || '조건').trim();
const actionText = (parts[1] || '실행').trim();
return (
<div
className={`ctrl-badge cond`}
style={{
left: x, top: y,
opacity: animate ? 0 : 1,
transition: animate ? `opacity .3s ${(delay ?? 0)}ms` : undefined,
}}
ref={(el) => {
if (el && animate) requestAnimationFrame(() => { el.style.opacity = '1'; });
}}
>
<div className="cb-head"><div className="cb-icon"></div> </div>
<div className="cb-cond">{condText}</div>
<div className="cb-paths">
<span className="cb-yes">Yes {actionText}</span>
<span className="cb-no">No </span>
</div>
</div>
);
}
return (
<div
className={`ctrl-badge ${cls}`}
style={{
left: x, top: y,
opacity: animate ? 0 : 1,
transition: animate ? `opacity .3s ${(delay ?? 0)}ms` : undefined,
}}
ref={(el) => {
if (el && animate) requestAnimationFrame(() => { el.style.opacity = '1'; });
}}
>
{label}
</div>
);
}
@@ -0,0 +1,57 @@
'use client';
import { useRef } from 'react';
import { useControlMode } from './hooks/useControlMode';
import { ControlToolbar } from './ControlToolbar';
import { ControlPalette } from './ControlPalette';
import { FlowViewer } from './FlowViewer';
import { RuleBuilder } from './RuleBuilder';
import '@/styles/control-mode.css';
interface ControlModeProps {
dashboardId: string;
cards: Record<string, any>[];
canvasRef: React.RefObject<HTMLDivElement | null>;
}
/**
* 제어 모드 오버레이 — 캔버스 위에 렌더
* ⚡ 버튼으로 토글, 읽기/편집 모드 전환
*/
export function ControlMode({ dashboardId, cards, canvasRef }: ControlModeProps) {
const { active, mode } = useControlMode();
if (!active) return null;
return (
<>
{/* 제어 모드 툴바 */}
<ControlToolbar dashboardId={dashboardId} />
{/* 읽기 모드: 카드 클릭 → 흐름 시각화 */}
{mode === 'view' && (
<FlowViewer cards={cards} canvasRef={canvasRef} dashboardId={dashboardId} />
)}
{/* 편집 모드: 규칙 빌더 */}
{mode === 'edit' && (
<RuleBuilder canvasRef={canvasRef} />
)}
</>
);
}
/**
* 제어 모드 팔레트 wrapper — 사이드바에 삽입
*/
export function ControlPaletteWrapper() {
const { active, mode, addRuleNode } = useControlMode();
if (!active || mode !== 'edit') return null;
return (
<ControlPalette
onDropTable={() => {}}
onDropControl={() => {}}
/>
);
}
+103
View File
@@ -0,0 +1,103 @@
'use client';
import { useRef, useCallback } from 'react';
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
import { PortHandle } from './PortHandle';
interface ControlNodeProps {
node: Record<string, any>;
onDragStart?: (nodeId: string, port: string, e: React.MouseEvent) => void;
onDragEnd?: (nodeId: string, port: string) => void;
}
/**
* 제어 노드 (16종) — mockup buildCtrlNode 포팅
*/
export function ControlNode({ node, onDragStart, onDragEnd }: ControlNodeProps) {
const { removeRuleNode, moveRuleNode, setConfigNodeId } = useControlMode();
const nodeRef = useRef<HTMLDivElement>(null);
const def = CTRL_NODE_TYPES[node.type];
if (!def) return null;
const outPorts = def.out || [{ port: 'out', label: '→', cls: '' }];
const handleHeadMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const sx = e.clientX, sy = e.clientY;
const sl = node.x, st = node.y;
const el = nodeRef.current;
if (el) el.style.zIndex = '30';
const mv = (ev: MouseEvent) => {
moveRuleNode(node.id, sl + ev.clientX - sx, st + ev.clientY - sy);
};
const up = () => {
if (el) el.style.zIndex = '20';
document.removeEventListener('mousemove', mv);
document.removeEventListener('mouseup', up);
};
document.addEventListener('mousemove', mv);
document.addEventListener('mouseup', up);
}, [node.id, node.x, node.y, moveRuleNode]);
return (
<div
ref={nodeRef}
className="ctrl-action-node"
data-node-id={node.id}
data-node-type={node.type}
style={{
left: node.x,
top: node.y,
['--na-rgb' as string]: def.rgb,
}}
>
{/* Input 포트 */}
<PortHandle
nodeId={node.id}
port="in"
type="in"
onDragEnd={onDragEnd}
/>
{/* 헤더 */}
<div className="ctrl-an-head" onMouseDown={handleHeadMouseDown}>
<div className="ctrl-an-icon">{def.icon}</div>
<span className="ctrl-an-name">{def.label}</span>
<button
className="ctrl-an-del"
onClick={(e) => { e.stopPropagation(); removeRuleNode(node.id); }}
>
</button>
</div>
{/* 본문 */}
<div
className="ctrl-an-body"
onClick={() => setConfigNodeId(node.id)}
>
<div className="ctrl-an-summary">
{node.config?.summary || '클릭하여 설정'}
</div>
</div>
{/* Output 포트 */}
<div className="ctrl-an-ports-out">
{outPorts.map((p) => (
<PortHandle
key={p.port}
nodeId={node.id}
port={p.port}
type="out"
cls={p.cls}
label={p.label}
onDragStart={onDragStart}
/>
))}
</div>
</div>
);
}
@@ -0,0 +1,83 @@
'use client';
import { useEffect, useState } from 'react';
import { CTRL_NODE_TYPES } from './hooks/useControlMode';
import { getMetaTableList } from '@/lib/api/meta';
interface ControlPaletteProps {
onDropTable: (tableName: string, x: number, y: number) => void;
onDropControl: (type: string, x: number, y: number) => void;
}
/**
* 제어 모드 팔레트 — 사이드바 교체
* mockup renderCtrlPalette 포팅
*/
export function ControlPalette({ onDropTable, onDropControl }: ControlPaletteProps) {
const [tables, setTables] = useState<Record<string, any>[]>([]);
useEffect(() => {
getMetaTableList().then(setTables).catch(() => {});
}, []);
const handleDragStart = (e: React.DragEvent, data: Record<string, any>) => {
e.dataTransfer.setData('text/plain', JSON.stringify(data));
e.dataTransfer.effectAllowed = 'copy';
};
const catLabels: Record<string, string> = {
'트리거': '트리거',
'조건': '조건 / 분기',
'액션': '액션',
'흐름': '흐름 제어',
'연동': '외부 연동',
'기록': '기록',
};
const cats = ['트리거', '조건', '액션', '흐름', '연동', '기록'];
return (
<div style={{ overflowY: 'auto', flex: 1 }}>
{/* DB 테이블 섹션 */}
<div className="ctrl-palette-section">DB </div>
{tables.map((t) => {
const name = t.table_name ?? t.TABLE_NAME;
const label = t.table_label ?? t.TABLE_LABEL ?? name;
return (
<div
key={name}
className="ctrl-palette-item"
draggable
title={`${label} — 캔버스로 드래그`}
onDragStart={(e) => handleDragStart(e, { kind: 'table', name })}
>
<span className="cp-icon">🏢</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</span>
</div>
);
})}
{/* 제어 노드 — 카테고리별 그룹 */}
{cats.map((cat) => {
const items = Object.entries(CTRL_NODE_TYPES).filter(([, d]) => d.cat === cat);
if (!items.length) return null;
return (
<div key={cat}>
<div className="ctrl-palette-section">{catLabels[cat] ?? cat}</div>
{items.map(([type, def]) => (
<div
key={type}
className="ctrl-palette-item"
draggable
title={`${def.label} — 캔버스로 드래그`}
onDragStart={(e) => handleDragStart(e, { kind: 'control', type })}
>
<span className="cp-icon">{def.icon}</span>
<span>{def.label}</span>
</div>
))}
</div>
);
})}
</div>
);
}
@@ -0,0 +1,128 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Eye, Wrench, Save, FolderOpen } from 'lucide-react';
import { useControlMode } from './hooks/useControlMode';
import { getBusinessRuleList, getBusinessRuleInfo, insertBusinessRule, updateBusinessRule } from '@/lib/api/businessRule';
import { toast } from 'sonner';
interface ControlToolbarProps {
dashboardId: string;
}
export function ControlToolbar({ dashboardId }: ControlToolbarProps) {
const { mode, setMode, ruleNodes, ruleConnections, activeRuleId, setActiveRuleId, setRuleNodes, setRuleConnections } = useControlMode();
const [ruleList, setRuleList] = useState<Record<string, any>[]>([]);
const [showRuleList, setShowRuleList] = useState(false);
// ★ 편집 모드 진입 시 기존 규칙 목록 로드
useEffect(() => {
if (mode !== 'edit') return;
getBusinessRuleList(dashboardId)
.then((res) => setRuleList(res?.list ?? res?.data ?? []))
.catch(() => setRuleList([]));
}, [mode, dashboardId]);
// ★ 기존 규칙 로드 → 편집 상태 복원
const handleLoadRule = useCallback(async (ruleId: string) => {
try {
const detail = await getBusinessRuleInfo(ruleId);
if (!detail) { toast.error('규칙을 찾을 수 없습니다'); return; }
setRuleNodes(detail.nodes ?? []);
setRuleConnections(detail.connections ?? []);
setActiveRuleId(ruleId);
setShowRuleList(false);
toast.success(`"${detail.name ?? ruleId}" 로드됨`);
} catch {
toast.error('규칙 로드 실패');
}
}, [setRuleNodes, setRuleConnections, setActiveRuleId]);
const handleSave = async () => {
try {
const data = {
name: `규칙 ${new Date().toLocaleString('ko-KR')}`,
nodes: ruleNodes,
connections: ruleConnections,
};
if (activeRuleId) {
await updateBusinessRule(activeRuleId, data);
toast.success('규칙 저장됨');
} else {
const result = await insertBusinessRule(dashboardId, data);
if (result?.rule_id) setActiveRuleId(result.rule_id);
toast.success('규칙 생성됨');
}
} catch {
toast.error('저장 실패');
}
};
return (
<div className="ctrl-toolbar">
<span style={{ fontWeight: 700, color: 'var(--ctrl-cyan)', marginRight: '.5rem' }}> </span>
<div className="ctrl-toolbar-mode">
<button
className={`ctrl-mode-btn${mode === 'view' ? ' on' : ''}`}
onClick={() => setMode('view')}
>
<Eye size={12} style={{ marginRight: 3 }} />
</button>
<button
className={`ctrl-mode-btn${mode === 'edit' ? ' on' : ''}`}
onClick={() => setMode('edit')}
>
<Wrench size={12} style={{ marginRight: 3 }} />
</button>
</div>
{mode === 'edit' && (
<div style={{ marginLeft: 'auto', display: 'flex', gap: '.3rem', position: 'relative' }}>
{/* ★ 기존 규칙 로드 버튼 */}
<button className="ctrl-mode-btn" onClick={() => setShowRuleList(!showRuleList)}>
<FolderOpen size={12} style={{ marginRight: 3 }} />
{ruleList.length > 0 ? ` (${ruleList.length})` : ''}
</button>
{/* ★ 규칙 목록 드롭다운 */}
{showRuleList && ruleList.length > 0 && (
<div style={{
position: 'absolute', top: '100%', right: 0, marginTop: 4,
background: 'var(--ctrl-glass-strong, rgba(17,16,42,.65))',
border: '1px solid var(--ctrl-glass-border, rgba(162,155,254,.12))',
borderRadius: 8, padding: '.3rem', minWidth: 200, zIndex: 100,
backdropFilter: 'blur(20px) saturate(1.4)',
}}>
{ruleList.map((rule) => {
const id = rule.rule_id ?? rule.RULE_ID;
const name = rule.name ?? rule.NAME ?? id;
const isActive = id === activeRuleId;
return (
<button
key={id}
onClick={() => handleLoadRule(id)}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '.3rem .5rem', borderRadius: 6, border: 'none',
background: isActive ? 'rgba(0,206,201,.12)' : 'transparent',
color: isActive ? 'var(--ctrl-cyan)' : 'var(--v5-text-sec)',
fontSize: '.55rem', cursor: 'pointer',
}}
>
{name}
</button>
);
})}
</div>
)}
{ruleNodes.length > 0 && (
<button className="ctrl-mode-btn" onClick={handleSave}>
<Save size={12} style={{ marginRight: 3 }} />
</button>
)}
</div>
)}
</div>
);
}
+415
View File
@@ -0,0 +1,415 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useControlMode, CTRL_NODE_TYPES } from './hooks/useControlMode';
import { useFlowAnimation } from './hooks/useFlowAnimation';
import { getMetaFields, getMetaRelations } from '@/lib/api/meta';
import { getBusinessRuleList, getBusinessRuleInfo } from '@/lib/api/businessRule';
import { TableNode } from './TableNode';
import { ControlNode } from './ControlNode';
import { ConnectionSvg, FlowLine, FlowBadge, bezierPath } from './ConnectionLine';
import type { FieldConfig } from '@/types/invyone-component';
interface FlowViewerProps {
cards: Record<string, any>[];
canvasRef: React.RefObject<HTMLDivElement | null>;
dashboardId: string;
}
/** 저장된 룰 그래프 (노드+연결선, 별도 오버레이) */
interface RuleOverlay {
ruleName: string;
nodes: Record<string, any>[];
connections: Record<string, any>[];
}
/**
* 룰 노드의 포트 위치 계산 (RuleBuilder.portPos와 동일 로직)
* - table 노드: width 200, 포트 in=좌측, out=우측 (y+18)
* - control 노드: width 160, in=좌측 (y+40), out=우측 (다중 포트 분배)
*/
function computePortPos(node: Record<string, any>, port: string): { x: number; y: number } | null {
if (!node) return null;
const nx = node.x ?? 0;
const ny = node.y ?? 0;
if (node.type === 'table') {
if (port === 'in') return { x: nx, y: ny + 18 };
return { x: nx + 200, y: ny + 18 };
}
// 제어 노드
if (port === 'in') return { x: nx, y: ny + 40 };
// output 포트 — 타입별 위치
const def = (CTRL_NODE_TYPES as Record<string, any>)[node.type];
const outPorts = def?.out || [{ port: 'out', label: '→', cls: '' }];
const idx = outPorts.findIndex((p: Record<string, any>) => p.port === port);
const total = outPorts.length;
const nodeH = 80;
const centerY = ny + nodeH / 2;
const gap = 8;
const startY = centerY - ((total - 1) * gap) / 2;
return { x: nx + 160, y: startY + (idx >= 0 ? idx : 0) * gap };
}
/** 테이블 메타 캐시 */
const metaCache: Record<string, { label: string; icon: string; columns: FieldConfig[] }> = {};
async function loadTableMeta(tableName: string) {
if (metaCache[tableName]) return metaCache[tableName];
try {
const meta = await getMetaFields(tableName);
const result = {
label: meta.table_label ?? tableName,
icon: '🏢',
columns: (meta.fields ?? []).filter((f: FieldConfig) => !f.system).slice(0, 8),
};
metaCache[tableName] = result;
return result;
} catch {
return { label: tableName, icon: '🏢', columns: [] };
}
}
export function FlowViewer({ cards, canvasRef, dashboardId }: FlowViewerProps) {
const {
activeFlowCardId,
flowEdges,
tablePositions,
setActiveFlowCard,
setFlowEdges,
setTablePositions,
} = useControlMode();
const { showFlow } = useFlowAnimation();
const [tableMetas, setTableMetas] = useState<Record<string, { label: string; icon: string; columns: FieldConfig[] }>>({});
const [animTimings, setAnimTimings] = useState<{ edge: Record<string, any>; lineDelay: number; nodeDelay: number }[]>([]);
const [revealedNodes, setRevealedNodes] = useState<Set<string>>(new Set());
const [ruleOverlays, setRuleOverlays] = useState<RuleOverlay[]>([]);
const animRef = useRef<ReturnType<typeof setTimeout>[]>([]);
// 카드 클릭 → 흐름 표시
const handleCardClick = useCallback(async (cardId: string) => {
// 같은 카드 클릭 → 닫기
if (activeFlowCardId === cardId) {
clearFlow();
return;
}
const card = cards.find((c) => (c.card_id ?? c.CARD_ID) === cardId);
if (!card) return;
const sourceTable = card.primary_table ?? card.PRIMARY_TABLE;
if (!sourceTable) return;
// 카드 위치 계산
const cv = canvasRef.current;
if (!cv) return;
const cardEl = cv.querySelector(`[data-card-id="${cardId}"]`) as HTMLElement;
if (!cardEl) return;
const cardRight = cardEl.offsetLeft + cardEl.offsetWidth;
const cardCenterY = cardEl.offsetTop + cardEl.offsetHeight / 2;
const canvasHeight = cv.clientHeight;
// ★ 2소스 분리 조회: table_relationships (구조) + business_rules (자동화)
let relations: Record<string, any>[] = [];
try { relations = await getMetaRelations(sourceTable); } catch { /* 빈 배열 사용 */ }
// ★ 현재 대시보드의 활성 비즈니스 룰 조회 → 별도 오버레이 (BFS에 합치지 않음)
// ★ 단, sourceTable과 관련된 룰만 표시 (해당 테이블 노드를 포함하는 룰)
const overlays: RuleOverlay[] = [];
try {
const rulesRes = await getBusinessRuleList(dashboardId);
const ruleList = (rulesRes?.list ?? rulesRes?.data ?? [])
.filter((r: Record<string, any>) => r.is_enabled === true || r.IS_ENABLED === true);
for (const rule of ruleList) {
const ruleId = rule.rule_id ?? rule.RULE_ID;
if (!ruleId) continue;
const ruleDetail = await getBusinessRuleInfo(ruleId);
if (!ruleDetail) continue;
const nodes: Record<string, any>[] = ruleDetail.nodes ?? [];
// ★ 룰이 sourceTable을 포함하는지 확인 (table 타입 노드의 table_name 매칭)
const involvesSourceTable = nodes.some((n) =>
n.type === 'table' && (n.table_name === sourceTable || n.tableName === sourceTable)
);
if (!involvesSourceTable) continue;
overlays.push({
ruleName: rule.name ?? rule.NAME ?? ruleId,
nodes,
connections: ruleDetail.connections ?? [],
});
}
} catch { /* 룰 조회 실패 시 관계만 표시 */ }
setRuleOverlays(overlays);
// 흐름 계산 (★ table_relationships만 — 룰은 별도 오버레이)
const result = showFlow(cardId, sourceTable, relations, { right: cardRight, centerY: cardCenterY }, canvasHeight);
// 테이블 메타 로드
const tableNames = new Set<string>();
result.edges.forEach((e) => {
if (!e.to.startsWith('CARD:')) tableNames.add(e.to);
if (!e.from.startsWith('CARD:')) tableNames.add(e.from);
});
const metas: Record<string, { label: string; icon: string; columns: FieldConfig[] }> = {};
await Promise.all(
Array.from(tableNames).map(async (name) => {
metas[name] = await loadTableMeta(name);
})
);
setTableMetas(metas);
// 상태 업데이트
setActiveFlowCard(cardId);
setFlowEdges(result.edges);
setTablePositions(result.positions);
setAnimTimings(result.timings);
// 애니메이션: 순차 reveal
const revealed = new Set<string>();
setRevealedNodes(new Set());
animRef.current.forEach(clearTimeout);
animRef.current = [];
result.timings.forEach(({ edge, nodeDelay }) => {
const t = setTimeout(() => {
if (!edge.to.startsWith('CARD:') && !revealed.has(edge.to)) {
revealed.add(edge.to);
setRevealedNodes(new Set(revealed));
}
}, nodeDelay);
animRef.current.push(t);
});
}, [activeFlowCardId, cards, canvasRef, setActiveFlowCard, setFlowEdges, setTablePositions, showFlow]);
// 클릭 이벤트 위임
useEffect(() => {
const cv = canvasRef.current;
if (!cv) return;
const handler = (e: MouseEvent) => {
const cardEl = (e.target as HTMLElement).closest('[data-card-id]') as HTMLElement;
if (cardEl) {
const id = cardEl.dataset.cardId;
if (id) handleCardClick(id);
return;
}
// 빈 영역 클릭 → 흐름 닫기
if ((e.target as HTMLElement).closest('.tbl-node') ||
(e.target as HTMLElement).closest('.ctrl-badge')) return;
if (activeFlowCardId) clearFlow();
};
cv.addEventListener('click', handler);
return () => cv.removeEventListener('click', handler);
}, [canvasRef, handleCardClick, activeFlowCardId]);
const clearFlow = useCallback(() => {
animRef.current.forEach(clearTimeout);
animRef.current = [];
setActiveFlowCard(null);
setFlowEdges([]);
setTablePositions({});
setAnimTimings([]);
setRevealedNodes(new Set());
setTableMetas({});
setRuleOverlays([]);
}, [setActiveFlowCard, setFlowEdges, setTablePositions]);
// 테이블 노드 드래그 → 위치 업데이트 + 선 재그리기
const handleNodeMove = useCallback((name: string, x: number, y: number) => {
setTablePositions({ ...tablePositions, [name]: { x, y } });
}, [tablePositions, setTablePositions]);
if (!activeFlowCardId || flowEdges.length === 0) return null;
// 카드 위치 가져오기
const getCardPos = (cardId: string) => {
const cv = canvasRef.current;
if (!cv) return { right: 0, centerY: 0 };
const el = cv.querySelector(`[data-card-id="${cardId}"]`) as HTMLElement;
if (!el) return { right: 0, centerY: 0 };
return { right: el.offsetLeft + el.offsetWidth, centerY: el.offsetTop + el.offsetHeight / 2 };
};
// 좌표 계산
const getFromPos = (from: string) => {
if (from.startsWith('CARD:')) {
const cardId = from.split(':')[1];
const pos = getCardPos(cardId);
return { x: pos.right, y: pos.centerY };
}
const p = tablePositions[from];
if (!p) return null;
return { x: p.x + 200, y: p.y + 80 }; // 노드 우측 중앙
};
const getToPos = (to: string) => {
const p = tablePositions[to];
if (!p) return null;
return { x: p.x, y: p.y + 80 }; // 노드 좌측 중앙
};
return (
<>
{/* SVG 연결선 */}
<ConnectionSvg>
{animTimings.map(({ edge, lineDelay }, idx) => {
const from = getFromPos(edge.from);
const to = getToPos(edge.to);
if (!from || !to) return null;
return (
<FlowLine
key={`line-${idx}`}
x1={from.x} y1={from.y}
x2={to.x} y2={to.y}
type={edge.type}
animate
delay={lineDelay}
/>
);
})}
</ConnectionSvg>
{/* 연결선 뱃지 */}
{animTimings.map(({ edge, nodeDelay }, idx) => {
const from = getFromPos(edge.from);
const to = getToPos(edge.to);
if (!from || !to) return null;
const mx = (from.x + to.x) / 2;
const my = (from.y + to.y) / 2;
return (
<FlowBadge
key={`badge-${idx}`}
x={mx} y={my}
label={edge.label}
type={edge.type}
animate
delay={nodeDelay}
/>
);
})}
{/* 테이블 노드 (table_relationships 레이어) */}
{Object.entries(tablePositions).map(([name, pos]) => {
const meta = tableMetas[name];
if (!meta) return null;
const revealed = revealedNodes.has(name);
return (
<TableNode
key={name}
tableName={name}
label={meta.label}
icon={meta.icon}
columns={meta.columns}
x={pos.x}
y={pos.y}
onMove={handleNodeMove}
style={{
opacity: revealed ? 1 : 0,
transform: revealed ? 'scale(1)' : 'scale(0.3)',
transition: 'opacity .35s ease-out, transform .35s cubic-bezier(.16,1,.3,1)',
pointerEvents: revealed ? 'auto' : 'none',
}}
/>
);
})}
{/* ★ 비즈니스 룰 오버레이 (별도 레이어 — 저장된 노드 type별로 렌더) */}
{ruleOverlays.map((overlay, oi) => (
<div key={`rule-overlay-${oi}`}>
{/* 룰 노드 → type별로 TableNode 또는 ControlNode (read-only) */}
{overlay.nodes.map((node) => {
// ★ type === 'table'이면 TableNode로 렌더
if (node.type === 'table') {
const tableName = node.table_name ?? node.tableName ?? node.label ?? node.id;
const columns: FieldConfig[] = (node.columns ?? []).slice(0, 8);
return (
<div
key={node.id}
style={{
position: 'absolute',
left: node.x ?? 0,
top: node.y ?? 0,
opacity: 0.85,
pointerEvents: 'none',
}}
>
<TableNode
tableName={tableName}
label={node.label ?? tableName}
icon="🏢"
columns={columns}
x={0}
y={0}
onMove={() => {}}
/>
</div>
);
}
// ★ 그 외는 제어 노드 (CTRL_NODE_TYPES)
const nodeType = node.type ?? 'auto-insert';
const typeDef = (CTRL_NODE_TYPES as Record<string, any>)[nodeType];
return (
<div
key={node.id}
className="ctrl-action-node"
style={{
position: 'absolute',
left: node.x ?? 0,
top: node.y ?? 0,
width: 160,
opacity: 0.85,
pointerEvents: 'none',
// @ts-ignore
'--na-rgb': typeDef?.rgb ?? '108,92,231',
} as React.CSSProperties}
>
<div className="ctrl-an-head" style={{ cursor: 'default' }}>
<div className="ctrl-an-icon">{typeDef?.icon ?? '⚡'}</div>
<span className="ctrl-an-name">{typeDef?.label ?? nodeType}</span>
</div>
<div className="ctrl-an-body">
<div className="ctrl-an-summary" style={{ fontSize: '.45rem', color: 'var(--v5-text-muted)' }}>
[{overlay.ruleName}]
</div>
</div>
</div>
);
})}
{/* 룰 연결선 (SVG) — RuleBuilder.portPos와 동일한 앵커 계산 */}
<ConnectionSvg>
{overlay.connections.map((conn, ci) => {
const fromNode = overlay.nodes.find((n) => n.id === (conn.from_node_id ?? conn.fromNodeId));
const toNode = overlay.nodes.find((n) => n.id === (conn.to_node_id ?? conn.toNodeId));
if (!fromNode || !toNode) return null;
const fromPort = conn.from_port ?? conn.fromPort ?? 'out';
const toPort = conn.to_port ?? conn.toPort ?? 'in';
const f = computePortPos(fromNode, fromPort);
const t = computePortPos(toNode, toPort);
if (!f || !t) return null;
const lineType = fromPort === 'yes' ? 'auto' : fromPort === 'no' ? 'cond' : 'auto';
return (
<FlowLine
key={`rule-line-${oi}-${ci}`}
x1={f.x} y1={f.y} x2={t.x} y2={t.y}
type={lineType}
animate={false}
delay={0}
/>
);
})}
</ConnectionSvg>
</div>
))}
</>
);
}
@@ -0,0 +1,269 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
/**
* 노드 설정 팝오버 (mockup showNodeConfig/_buildCfgForm 포팅)
* 노드 타입별 설정 폼
*/
export function NodeConfigPopover() {
const { configNodeId, ruleNodes, setConfigNodeId, updateRuleNode } = useControlMode();
const popRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const node = configNodeId ? ruleNodes.find((n) => n.id === configNodeId) : null;
const def = node ? CTRL_NODE_TYPES[node.type] : null;
useEffect(() => {
if (configNodeId && node) {
requestAnimationFrame(() => setOpen(true));
} else {
setOpen(false);
}
}, [configNodeId, node]);
// 외부 클릭 닫기
useEffect(() => {
const handler = (e: MouseEvent) => {
if (!configNodeId) return;
if ((e.target as HTMLElement).closest('.ctrl-cfg-pop')) return;
if ((e.target as HTMLElement).closest('.ctrl-an-body')) return;
setConfigNodeId(null);
};
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}, [configNodeId, setConfigNodeId]);
if (!node || !def) return null;
const handleSave = (summary: string, config: Record<string, any>) => {
updateRuleNode(node.id, { config: { ...node.config, ...config, summary } });
setConfigNodeId(null);
};
return (
<div
ref={popRef}
className={`ctrl-cfg-pop${open ? ' open' : ''}`}
style={{ left: node.x + 172, top: node.y }}
>
<div className="cfg-hd">{def.icon} {def.label} </div>
<ConfigForm type={node.type} config={node.config ?? {}} onSave={handleSave} onClose={() => setConfigNodeId(null)} />
</div>
);
}
function ConfigForm({ type, config, onSave, onClose }: {
type: string; config: Record<string, any>;
onSave: (summary: string, config: Record<string, any>) => void;
onClose: () => void;
}) {
const [vals, setVals] = useState<Record<string, any>>(config);
const set = (k: string, v: any) => setVals((p) => ({ ...p, [k]: v }));
const handleSave = () => {
let summary = '';
switch (type) {
case 'condition':
summary = `${vals.field || '?'} ${vals.op || '='} "${vals.value || '?'}"`;
break;
case 'status-change':
summary = `${vals.table || '?'}.${vals.field || 'STATUS'} → "${vals.value || '?'}"`;
break;
case 'auto-insert':
summary = `${vals.table || '?'} INSERT`;
break;
case 'timer':
summary = `${vals.field || '?'} +${vals.amount || 0}${vals.unit || '일'} 경과`;
break;
case 'notification':
summary = `${vals.channel || '이메일'}${vals.target || '담당자'}`;
break;
case 'approval':
summary = `${vals.approver || '팀장'} 승인 (${vals.condition || ''})`;
break;
case 'calculation':
summary = `${vals.table || '?'}.${vals.field || '?'} = ${vals.formula || '?'}`;
break;
case 'webhook':
summary = `${vals.method || 'POST'} ${(vals.url || '').slice(0, 25)}...`;
break;
case 'validation':
summary = `${vals.field || '?'} ${vals.rule || '필수값'}`;
break;
case 'log':
summary = `로그: ${vals.content || '?'}`;
break;
default:
summary = vals.summary || '설정됨';
}
onSave(summary, vals);
};
return (
<>
{renderFields(type, vals, set)}
<div className="cfg-ft">
<button className="cfg-btn save" onClick={handleSave}></button>
<button className="cfg-btn" onClick={onClose}></button>
</div>
</>
);
}
function renderFields(
type: string,
vals: Record<string, any>,
set: (k: string, v: any) => void
) {
switch (type) {
case 'condition':
return (
<>
<CfgSec label="필드">
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="STATUS" />
</CfgSec>
<CfgSec label="연산자">
<CfgSelect value={vals.op ?? '='} onChange={(v) => set('op', v)}
options={['=', '≠', '>', '<', '기한 경과', '포함']} />
</CfgSec>
<CfgSec label="값">
<CfgInput value={vals.value ?? ''} onChange={(v) => set('value', v)} placeholder="비교값" />
</CfgSec>
</>
);
case 'status-change':
return (
<>
<CfgSec label="대상 테이블">
<CfgInput value={vals.table ?? ''} onChange={(v) => set('table', v)} placeholder="테이블명" />
</CfgSec>
<CfgSec label="변경 필드">
<CfgInput value={vals.field ?? 'STATUS'} onChange={(v) => set('field', v)} />
</CfgSec>
<CfgSec label="변경값">
<CfgInput value={vals.value ?? ''} onChange={(v) => set('value', v)} placeholder="새 값" />
</CfgSec>
</>
);
case 'auto-insert':
return (
<CfgSec label="대상 테이블">
<CfgInput value={vals.table ?? ''} onChange={(v) => set('table', v)} placeholder="테이블명" />
</CfgSec>
);
case 'timer':
return (
<>
<CfgSec label="기준 필드">
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="ORDER_DATE" />
</CfgSec>
<CfgSec label="경과 기준">
<div style={{ display: 'flex', gap: '.3rem' }}>
<CfgInput value={vals.amount ?? '0'} onChange={(v) => set('amount', v)} placeholder="0" />
<CfgSelect value={vals.unit ?? '일'} onChange={(v) => set('unit', v)} options={['일', '시간', '주']} />
</div>
</CfgSec>
</>
);
case 'notification':
return (
<>
<CfgSec label="채널">
<CfgSelect value={vals.channel ?? '이메일'} onChange={(v) => set('channel', v)}
options={['이메일', 'SMS', '푸시', 'Slack']} />
</CfgSec>
<CfgSec label="수신자">
<CfgSelect value={vals.target ?? '담당자'} onChange={(v) => set('target', v)}
options={['담당자', '관리자', '전체']} />
</CfgSec>
<CfgSec label="메시지">
<textarea className="cfg-ta" rows={2} value={vals.message ?? ''}
onChange={(e) => set('message', e.target.value)} />
</CfgSec>
</>
);
case 'approval':
return (
<>
<CfgSec label="승인자">
<CfgSelect value={vals.approver ?? '팀장'} onChange={(v) => set('approver', v)}
options={['팀장', '부서장', '관리자', '지정 사용자']} />
</CfgSec>
<CfgSec label="승인 조건">
<CfgInput value={vals.condition ?? ''} onChange={(v) => set('condition', v)} placeholder="조건식" />
</CfgSec>
</>
);
case 'calculation':
return (
<>
<CfgSec label="대상 테이블">
<CfgInput value={vals.table ?? ''} onChange={(v) => set('table', v)} placeholder="테이블명" />
</CfgSec>
<CfgSec label="결과 필드">
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="필드명" />
</CfgSec>
<CfgSec label="수식">
<CfgInput value={vals.formula ?? ''} onChange={(v) => set('formula', v)} placeholder="QTY * UNIT_PRICE" />
</CfgSec>
</>
);
case 'webhook':
return (
<>
<CfgSec label="URL">
<CfgInput value={vals.url ?? ''} onChange={(v) => set('url', v)} placeholder="https://..." />
</CfgSec>
<CfgSec label="메서드">
<CfgSelect value={vals.method ?? 'POST'} onChange={(v) => set('method', v)}
options={['POST', 'GET', 'PUT', 'DELETE']} />
</CfgSec>
</>
);
case 'validation':
return (
<>
<CfgSec label="대상 필드">
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="필드명" />
</CfgSec>
<CfgSec label="검증 규칙">
<CfgSelect value={vals.rule ?? '필수값 (NOT NULL)'} onChange={(v) => set('rule', v)}
options={['필수값 (NOT NULL)', '범위 체크', '정규식 매칭', '참조 무결성', '커스텀 조건']} />
</CfgSec>
</>
);
case 'log':
return (
<CfgSec label="내용">
<CfgInput value={vals.content ?? ''} onChange={(v) => set('content', v)} placeholder="액션 설명" />
</CfgSec>
);
default:
return <div className="cfg-sec" style={{ color: 'var(--v5-text-muted)', fontSize: '.55rem' }}> </div>;
}
}
function CfgSec({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="cfg-sec">
<label className="cfg-lb">{label}</label>
{children}
</div>
);
}
function CfgInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) {
return (
<input className="cfg-inp" value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} />
);
}
function CfgSelect({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: string[] }) {
return (
<select className="cfg-sel" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
);
}
@@ -0,0 +1,51 @@
'use client';
/**
* I/O 포트 핸들 — 노드 양쪽 원형 (드래그 연결 시작/끝점)
* mockup initPortEvents 포팅
*/
interface PortHandleProps {
nodeId: string;
port: string;
type: 'in' | 'out';
cls?: string;
label?: string;
isTable?: boolean;
onDragStart?: (nodeId: string, port: string, e: React.MouseEvent) => void;
onDragEnd?: (nodeId: string, port: string) => void;
}
export function PortHandle({ nodeId, port, type, cls, label, isTable, onDragStart, onDragEnd }: PortHandleProps) {
const handleMouseDown = (e: React.MouseEvent) => {
if (type !== 'out' || !onDragStart) return;
e.preventDefault();
e.stopPropagation();
onDragStart(nodeId, port, e);
};
const handleMouseUp = (e: React.MouseEvent) => {
if (type !== 'in' || !onDragEnd) return;
e.stopPropagation();
onDragEnd(nodeId, port);
};
const className = [
'ctrl-io-port',
`port-${type}`,
cls ?? '',
isTable ? 'tbl-io' : '',
].filter(Boolean).join(' ');
return (
<div
className={className}
data-node={nodeId}
data-port={port}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
>
{label && <span className="port-label">{label}</span>}
</div>
);
}
+226
View File
@@ -0,0 +1,226 @@
'use client';
import { useCallback, useRef } from 'react';
import { useControlMode, genNodeId, CTRL_NODE_TYPES } from './hooks/useControlMode';
import { usePortDrag } from './hooks/usePortDrag';
import { ControlNode } from './ControlNode';
import { TableNode } from './TableNode';
import { PortHandle } from './PortHandle';
import { NodeConfigPopover } from './NodeConfigPopover';
import { bezierPath } from './ConnectionLine';
import { getMetaFields } from '@/lib/api/meta';
import type { FieldConfig } from '@/types/invyone-component';
interface RuleBuilderProps {
canvasRef: React.RefObject<HTMLDivElement | null>;
}
/** 테이블 필드 캐시 */
const fieldCache: Record<string, FieldConfig[]> = {};
/**
* 규칙 빌더 — 편집 모드
* mockup initRuleBuilder/dropTable/dropControl 포팅
*/
export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
const {
ruleNodes,
ruleConnections,
addRuleNode,
moveRuleNode,
} = useControlMode();
const { startDrag, finishDrag } = usePortDrag(canvasRef);
// 캔버스 드롭 처리
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
let d: Record<string, any>;
try { d = JSON.parse(e.dataTransfer.getData('text/plain')); } catch { return; }
if (!d?.kind) return;
const cv = canvasRef.current;
if (!cv) return;
const r = cv.getBoundingClientRect();
const x = e.clientX - r.left + cv.scrollLeft;
const y = e.clientY - r.top + cv.scrollTop;
if (d.kind === 'table') {
// 중복 방지
if (ruleNodes.find((n) => n.type === 'table' && n.table_name === d.name)) return;
// 필드 로드
let cols: FieldConfig[] = [];
if (fieldCache[d.name]) {
cols = fieldCache[d.name];
} else {
try {
const meta = await getMetaFields(d.name);
cols = (meta.fields ?? []).filter((f: FieldConfig) => !f.system).slice(0, 8);
fieldCache[d.name] = cols;
} catch { /* 빈 필드 */ }
}
addRuleNode({
id: genNodeId('tbl'),
type: 'table',
table_name: d.name,
label: d.name,
x: x - 100,
y: y - 40,
columns: cols,
});
} else if (d.kind === 'control' && CTRL_NODE_TYPES[d.type]) {
addRuleNode({
id: genNodeId('ctrl'),
type: d.type,
label: CTRL_NODE_TYPES[d.type].label,
x: x - 80,
y: y - 30,
config: {},
});
}
}, [canvasRef, ruleNodes, addRuleNode]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}, []);
// 노드 좌표에서 포트 위치 계산
const portPos = useCallback((nodeId: string, port: string) => {
const node = ruleNodes.find((n) => n.id === nodeId);
if (!node) return null;
if (node.type === 'table') {
if (port === 'in') return { x: node.x, y: node.y + 18 };
return { x: node.x + 200, y: node.y + 18 };
}
// 제어 노드
if (port === 'in') return { x: node.x, y: node.y + 40 };
// output 포트 — 타입별 위치
const def = CTRL_NODE_TYPES[node.type];
const outPorts = def?.out || [{ port: 'out', label: '→', cls: '' }];
const idx = outPorts.findIndex((p) => p.port === port);
const total = outPorts.length;
const nodeH = 80;
const centerY = node.y + nodeH / 2;
const gap = 8;
const startY = centerY - ((total - 1) * gap) / 2;
return { x: node.x + 160, y: startY + idx * gap };
}, [ruleNodes]);
return (
<>
{/* 드롭존 (캔버스 전체에 이벤트 걸기 위한 투명 레이어) */}
<div
style={{ position: 'absolute', inset: 0, zIndex: 5 }}
onDrop={handleDrop}
onDragOver={handleDragOver}
/>
{/* 연결선 SVG */}
<svg className="ctrl-svg" id="rule-svg" width="100%" height="100%" style={{ overflow: 'visible' }}>
<defs>
<marker id="arr-rule" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-cyan)" opacity=".8" />
</marker>
<marker id="arr-yes" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-green)" opacity=".8" />
</marker>
<marker id="arr-no" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--v5-text-muted, #888)" opacity=".5" />
</marker>
</defs>
{ruleConnections.map((c) => {
const f = portPos(c.from_node_id, c.from_port);
const t = portPos(c.to_node_id, c.to_port);
if (!f || !t) return null;
const cls = c.from_port === 'yes' ? 'rule-conn-path conn-yes'
: c.from_port === 'no' ? 'rule-conn-path conn-no'
: 'rule-conn-path';
const marker = c.from_port === 'yes' ? 'url(#arr-yes)'
: c.from_port === 'no' ? 'url(#arr-no)'
: 'url(#arr-rule)';
return (
<path
key={c.id}
d={bezierPath(f.x, f.y, t.x, t.y)}
className={cls}
markerEnd={marker}
/>
);
})}
</svg>
{/* 연결 삭제 뱃지 */}
{ruleConnections.map((c) => {
const f = portPos(c.from_node_id, c.from_port);
const t = portPos(c.to_node_id, c.to_port);
if (!f || !t) return null;
const mx = (f.x + t.x) / 2, my = (f.y + t.y) / 2;
return (
<div
key={`badge-${c.id}`}
className="rule-conn-badge"
style={{ left: mx, top: my }}
>
<span
className="conn-x"
onClick={(e) => {
e.stopPropagation();
useControlMode.getState().removeRuleConnection(c.id);
}}
>
</span>
</div>
);
})}
{/* 노드 렌더 */}
{ruleNodes.map((node) => {
if (node.type === 'table') {
return (
<div key={node.id} style={{ position: 'absolute', left: 0, top: 0 }}>
<TableNode
tableName={node.table_name}
label={node.label}
icon="🏢"
columns={node.columns ?? []}
x={node.x}
y={node.y}
onMove={(_, x, y) => moveRuleNode(node.id, x, y)}
style={{ overflow: 'visible' }}
/>
{/* I/O 포트 */}
<PortHandle nodeId={node.id} port="in" type="in" isTable onDragEnd={finishDrag} />
<div style={{ position: 'absolute', left: node.x + 194, top: node.y + 12 }}>
<PortHandle nodeId={node.id} port="out" type="out" isTable label="→" onDragStart={startDrag} />
</div>
</div>
);
}
return (
<ControlNode
key={node.id}
node={node}
onDragStart={startDrag}
onDragEnd={finishDrag}
/>
);
})}
{/* 설정 팝오버 */}
<NodeConfigPopover />
</>
);
}
+77
View File
@@ -0,0 +1,77 @@
'use client';
import { useRef, useCallback } from 'react';
interface TableNodeProps {
tableName: string;
label: string;
icon: string;
columns: Record<string, any>[];
x: number;
y: number;
style?: React.CSSProperties;
onMove?: (name: string, x: number, y: number) => void;
}
export function TableNode({ tableName, label, icon, columns, x, y, style, onMove }: TableNodeProps) {
const nodeRef = useRef<HTMLDivElement>(null);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (!onMove) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const sl = x, st = y;
const el = nodeRef.current;
if (el) el.style.zIndex = '30';
const move = (ev: MouseEvent) => {
onMove(tableName, sl + ev.clientX - sx, st + ev.clientY - sy);
};
const up = () => {
if (el) el.style.zIndex = '20';
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
};
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
}, [onMove, tableName, x, y]);
const dtypeIcons: Record<string, string> = {
text: 'Aa', number: '#', date: '📅', select: '▼', checkbox: '☑', file: '📎', code: '⚡',
textarea: 'Aa', datetime: '📅', entity: '🔗',
};
return (
<div
ref={nodeRef}
className="tbl-node"
data-table={tableName}
style={{ left: x, top: y, ...style }}
>
<div className="tbl-node-head" onMouseDown={handleMouseDown}>
<div className="tbl-icon">{icon}</div>
<span className="tbl-name">{tableName}</span>
<span className="tbl-badge">{label}</span>
</div>
<div className="tbl-node-cols">
{columns.map((col) => {
const name = col.column ?? col.name ?? col.COLUMN_NAME ?? '';
const type = col.type ?? col.dtype ?? 'text';
const mark = col.pk ? 'PK' : col.mark === 'FK' ? 'FK' : '';
const portCls = mark === 'PK' ? 'pk' : mark === 'FK' ? 'fk' : '';
const displayName = col.label ?? col.dname ?? name;
const dtIcon = dtypeIcons[type] || 'Aa';
return (
<div key={name} className="tbl-col" data-col={name}>
<div className={`tbl-port ${portCls}`} />
<span className="tbl-col-name">{displayName}</span>
<span className="tbl-col-type">{dtIcon} {type}</span>
{mark && <span className={`tbl-col-mark ${mark.toLowerCase()}`}>{mark}</span>}
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,166 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
/**
* 제어 노드 16종 정의 (mockup CTRL_NODE_TYPES)
* out: 커스텀 출력 포트. 없으면 기본 [{port:'out', label:'→'}]
*/
export const CTRL_NODE_TYPES: Record<string, {
icon: string; label: string; rgb: string; cat: string;
out?: { port: string; label: string; cls: string }[];
}> = {
'timer': { icon: '⏱', label: '타이머', rgb: '0,206,201', cat: '트리거' },
'condition': { icon: '◇', label: '조건분기', rgb: '253,203,110', cat: '조건',
out: [{ port: 'yes', label: 'Y', cls: 'port-yes' }, { port: 'no', label: 'N', cls: 'port-no' }] },
'validation': { icon: '✔', label: '데이터 검증', rgb: '255,107,129', cat: '조건',
out: [{ port: 'pass', label: '✓', cls: 'port-yes' }, { port: 'fail', label: '✗', cls: 'port-no' }] },
'status-change': { icon: '🔄', label: '상태 변경', rgb: '108,92,231', cat: '액션' },
'auto-insert': { icon: '📝', label: '자동 등록', rgb: '85,239,196', cat: '액션' },
'calculation': { icon: '🧮', label: '계산/수식', rgb: '45,152,218', cat: '액션' },
'delete': { icon: '🗑', label: '삭제/보관', rgb: '255,71,87', cat: '액션' },
'document': { icon: '📄', label: '문서 생성', rgb: '162,155,254', cat: '액션' },
'approval': { icon: '✋', label: '승인/결재', rgb: '255,165,2', cat: '흐름',
out: [{ port: 'approved', label: '✓', cls: 'port-yes' }, { port: 'rejected', label: '✗', cls: 'port-no' }] },
'delay': { icon: '⏳', label: '대기/지연', rgb: '72,219,251', cat: '흐름' },
'loop': { icon: '🔁', label: '반복', rgb: '223,142,254', cat: '흐름',
out: [{ port: 'each', label: '→', cls: '' }, { port: 'done', label: '✓', cls: 'port-yes' }] },
'parallel': { icon: '🔀', label: '병렬 실행', rgb: '0,206,201', cat: '흐름' },
'merge': { icon: '⤵', label: '병합/합류', rgb: '149,175,192', cat: '흐름' },
'webhook': { icon: '🌐', label: '외부 호출', rgb: '116,185,255', cat: '연동' },
'notification': { icon: '📨', label: '알림 발송', rgb: '253,121,168', cat: '연동' },
'log': { icon: '📜', label: '로그 기록', rgb: '150,150,160', cat: '기록' },
};
interface ControlModeState {
/** 제어 모드 활성 여부 */
active: boolean;
/** 읽기 / 편집 모드 */
mode: 'view' | 'edit';
/** 활성 흐름 — 클릭된 카드 ID */
activeFlowCardId: string | null;
/** 흐름 엣지 배열 (BFS 결과) */
flowEdges: Record<string, any>[];
/** 테이블 노드 위치 */
tablePositions: Record<string, { x: number; y: number }>;
/** 규칙 빌더 — 노드 */
ruleNodes: Record<string, any>[];
/** 규칙 빌더 — 연결 */
ruleConnections: Record<string, any>[];
/** 현재 편집 중인 룰 ID */
activeRuleId: string | null;
/** 설정 팝오버 대상 노드 ID */
configNodeId: string | null;
// 액션
toggleControlMode: () => void;
setMode: (mode: 'view' | 'edit') => void;
setActiveFlowCard: (cardId: string | null) => void;
setFlowEdges: (edges: Record<string, any>[]) => void;
setTablePositions: (pos: Record<string, { x: number; y: number }>) => void;
setRuleNodes: (nodes: Record<string, any>[]) => void;
addRuleNode: (node: Record<string, any>) => void;
updateRuleNode: (nodeId: string, updates: Record<string, any>) => void;
removeRuleNode: (nodeId: string) => void;
moveRuleNode: (nodeId: string, x: number, y: number) => void;
setRuleConnections: (conns: Record<string, any>[]) => void;
addRuleConnection: (conn: Record<string, any>) => void;
removeRuleConnection: (connId: string) => void;
setActiveRuleId: (ruleId: string | null) => void;
setConfigNodeId: (nodeId: string | null) => void;
resetControlMode: () => void;
}
let _nodeSeq = 0;
export function genNodeId(prefix: string) { return `${prefix}-${++_nodeSeq}`; }
let _connSeq = 0;
export function genConnId() { return `conn-${++_connSeq}`; }
export const useControlMode = create<ControlModeState>()(
devtools(
(set) => ({
active: false,
mode: 'view',
activeFlowCardId: null,
flowEdges: [],
tablePositions: {},
ruleNodes: [],
ruleConnections: [],
activeRuleId: null,
configNodeId: null,
toggleControlMode: () =>
set((s) => ({
active: !s.active,
mode: 'view',
activeFlowCardId: null,
flowEdges: [],
tablePositions: {},
configNodeId: null,
})),
setMode: (mode) => set({ mode, configNodeId: null }),
setActiveFlowCard: (cardId) => set({ activeFlowCardId: cardId }),
setFlowEdges: (edges) => set({ flowEdges: edges }),
setTablePositions: (pos) => set({ tablePositions: pos }),
setRuleNodes: (nodes) => set({ ruleNodes: nodes }),
addRuleNode: (node) => set((s) => ({ ruleNodes: [...s.ruleNodes, node] })),
updateRuleNode: (nodeId, updates) =>
set((s) => ({
ruleNodes: s.ruleNodes.map((n) =>
n.id === nodeId ? { ...n, ...updates } : n
),
})),
removeRuleNode: (nodeId) =>
set((s) => ({
ruleNodes: s.ruleNodes.filter((n) => n.id !== nodeId),
ruleConnections: s.ruleConnections.filter(
(c) => c.from_node_id !== nodeId && c.to_node_id !== nodeId
),
})),
moveRuleNode: (nodeId, x, y) =>
set((s) => ({
ruleNodes: s.ruleNodes.map((n) =>
n.id === nodeId ? { ...n, x, y } : n
),
})),
setRuleConnections: (conns) => set({ ruleConnections: conns }),
addRuleConnection: (conn) =>
set((s) => ({ ruleConnections: [...s.ruleConnections, conn] })),
removeRuleConnection: (connId) =>
set((s) => ({
ruleConnections: s.ruleConnections.filter((c) => c.id !== connId),
})),
setActiveRuleId: (ruleId) => set({ activeRuleId: ruleId }),
setConfigNodeId: (nodeId) => set({ configNodeId: nodeId }),
resetControlMode: () =>
set({
active: false,
mode: 'view',
activeFlowCardId: null,
flowEdges: [],
tablePositions: {},
ruleNodes: [],
ruleConnections: [],
activeRuleId: null,
configNodeId: null,
}),
}),
{ name: 'control-mode-store' }
)
);
@@ -0,0 +1,206 @@
import { useCallback } from 'react';
/**
* 트리 확산 애니메이션용 위치 계산 + 타이밍 (mockup calcFlowPositions 포팅)
*
* 카드 우측에서 depth별로 트리 형태 배치
* depth별 시간 지연으로 선 → 노드 순차 등장
*/
interface FlowEdge {
from: string;
to: string;
type: string;
label: string;
}
/**
* BFS로 카드에서 도달 가능한 전체 체인 계산
* ★ 양방향 탐색: outgoing (from===cur) + incoming (to===cur)
* → inbound 관계도 놓치지 않음
*/
export function buildFlowChain(
rootKey: string,
allEdges: FlowEdge[]
): { edges: FlowEdge[]; depths: Record<string, number> } {
const reachable = new Set([rootKey]);
const queue = [rootKey];
while (queue.length) {
const cur = queue.shift()!;
// ★ outgoing: from === cur
allEdges
.filter((e) => e.from === cur)
.forEach((e) => {
if (!reachable.has(e.to)) {
reachable.add(e.to);
queue.push(e.to);
}
});
// ★ incoming: to === cur (양방향 탐색)
allEdges
.filter((e) => e.to === cur)
.forEach((e) => {
if (!reachable.has(e.from)) {
reachable.add(e.from);
queue.push(e.from);
}
});
}
const edges = allEdges.filter((e) => reachable.has(e.from) && reachable.has(e.to));
// depth 계산 (양방향)
const depths: Record<string, number> = { [rootKey]: 0 };
const q2 = [rootKey];
while (q2.length) {
const cur = q2.shift()!;
// outgoing
edges
.filter((e) => e.from === cur)
.forEach((e) => {
if (depths[e.to] === undefined) {
depths[e.to] = depths[cur] + 1;
q2.push(e.to);
}
});
// incoming
edges
.filter((e) => e.to === cur)
.forEach((e) => {
if (depths[e.from] === undefined) {
depths[e.from] = depths[cur] + 1;
q2.push(e.from);
}
});
}
return { edges, depths };
}
/**
* 카드 우측에 테이블 노드를 트리 형태로 배치
*/
export function calcFlowPositions(
cardRight: number,
cardCenterY: number,
canvasHeight: number,
depths: Record<string, number>
): Record<string, { x: number; y: number }> {
const startX = cardRight + 80;
// depth별 노드 그룹핑 (CARD: 제외)
const depthNodes: Record<number, string[]> = {};
Object.entries(depths).forEach(([name, d]) => {
if (name.startsWith('CARD:')) return;
if (!depthNodes[d]) depthNodes[d] = [];
depthNodes[d].push(name);
});
const maxD = Math.max(1, ...Object.keys(depthNodes).map(Number));
const colGap = Math.max(270, Math.min(350, (1200 - startX - 230) / maxD));
const rowGap = 240;
const pos: Record<string, { x: number; y: number }> = {};
Object.entries(depthNodes).forEach(([dStr, nodes]) => {
const di = parseInt(dStr);
const totalH = nodes.length * rowGap;
const startY = Math.max(20, (canvasHeight - totalH) / 2);
nodes.forEach((name, i) => {
pos[name] = {
x: startX + (di - 1) * colGap,
y: startY + i * rowGap,
};
});
});
return pos;
}
/**
* depth별 애니메이션 타이밍 계산
* 선 → 노드 순서로 연쇄 등장
*/
export function calcAnimationTimings(
edges: FlowEdge[],
depths: Record<string, number>
): { edge: FlowEdge; lineDelay: number; nodeDelay: number }[] {
const STEP = 500;
const NODE_D = 350;
// 엣지를 depth별 그룹핑
const depthEdges: Record<number, FlowEdge[]> = {};
edges.forEach((edge) => {
const fd = depths[edge.from] ?? 0;
const d = fd + 1;
if (!depthEdges[d]) depthEdges[d] = [];
depthEdges[d].push(edge);
});
const result: { edge: FlowEdge; lineDelay: number; nodeDelay: number }[] = [];
const maxDepth = Math.max(0, ...Object.keys(depthEdges).map(Number));
for (let d = 1; d <= maxDepth; d++) {
const edgesAtDepth = depthEdges[d] || [];
const base = 300 + (d - 1) * STEP;
edgesAtDepth.forEach((edge, i) => {
result.push({
edge,
lineDelay: base + i * 120,
nodeDelay: base + i * 120 + NODE_D,
});
});
}
return result;
}
/**
* useFlowAnimation — 흐름 표시 관리 훅
*/
export function useFlowAnimation() {
const showFlow = useCallback(
(
cardId: string,
sourceTable: string,
relations: Record<string, any>[],
cardRect: { right: number; centerY: number },
canvasHeight: number
) => {
// 1. 엣지 구성: 카드 → 소스 테이블 + relations
const rootKey = `CARD:${cardId}`;
const allEdges: FlowEdge[] = [
{ from: rootKey, to: sourceTable, type: 'source', label: '데이터 소스' },
];
relations.forEach((rel) => {
const type = rel.relation_type ?? rel.RELATION_TYPE ?? 'auto';
const label = rel.label ?? rel.LABEL ?? `${rel.source_table ?? rel.SOURCE_TABLE}${rel.target_table ?? rel.TARGET_TABLE}`;
const from = rel.source_table ?? rel.SOURCE_TABLE;
const to = rel.target_table ?? rel.TARGET_TABLE;
if (from && to) {
allEdges.push({ from, to, type, label });
}
});
// 2. BFS 체인 + depth 계산
const { edges, depths } = buildFlowChain(rootKey, allEdges);
// 3. 위치 계산
const positions = calcFlowPositions(
cardRect.right,
cardRect.centerY,
canvasHeight,
depths
);
// 4. 애니메이션 타이밍
const timings = calcAnimationTimings(edges, depths);
return { edges, depths, positions, timings };
},
[]
);
return { showFlow };
}
@@ -0,0 +1,117 @@
import { useRef, useCallback, useEffect } from 'react';
import { useControlMode, genConnId } from './useControlMode';
import { bezierPath } from '../ConnectionLine';
/**
* 포트 연결 드래그 로직 (mockup startPortDrag/finishPortDrag 포팅)
* output 포트 mousedown → 임시 선 → input 포트 mouseup → 연결 생성
*/
export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
const { addRuleConnection, ruleConnections } = useControlMode();
const dragRef = useRef<{
fromNodeId: string;
fromPort: string;
line: SVGPathElement;
x1: number;
y1: number;
} | null>(null);
const startDrag = useCallback((nodeId: string, port: string, e: React.MouseEvent) => {
const cv = canvasRef.current;
if (!cv) return;
// SVG 확보
let svg = cv.querySelector('#rule-svg') as SVGSVGElement | null;
if (!svg) {
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.id = 'rule-svg';
svg.classList.add('ctrl-svg');
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.style.overflow = 'visible';
cv.appendChild(svg);
}
const cr = cv.getBoundingClientRect();
const portEl = (e.target as HTMLElement).closest('.ctrl-io-port') as HTMLElement;
if (!portEl) return;
const pr = portEl.getBoundingClientRect();
const x1 = pr.left + pr.width / 2 - cr.left + cv.scrollLeft;
const y1 = pr.top + pr.height / 2 - cr.top + cv.scrollTop;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.classList.add('rule-temp-line');
line.setAttribute('d', `M${x1},${y1} L${x1},${y1}`);
svg.appendChild(line);
dragRef.current = { fromNodeId: nodeId, fromPort: port, line, x1, y1 };
cv.classList.add('port-dragging');
portEl.classList.add('port-active');
}, [canvasRef]);
const finishDrag = useCallback((toNodeId: string, toPort: string) => {
const d = dragRef.current;
if (!d) return;
// 같은 노드 연결 방지
if (d.fromNodeId === toNodeId) {
cleanup();
return;
}
// 중복 방지
if (ruleConnections.find((c) =>
c.from_node_id === d.fromNodeId && c.from_port === d.fromPort && c.to_node_id === toNodeId
)) {
cleanup();
return;
}
addRuleConnection({
id: genConnId(),
from_node_id: d.fromNodeId,
from_port: d.fromPort,
to_node_id: toNodeId,
to_port: toPort,
});
cleanup();
}, [addRuleConnection, ruleConnections]);
const cleanup = useCallback(() => {
const d = dragRef.current;
if (!d) return;
d.line.remove();
canvasRef.current?.classList.remove('port-dragging');
document.querySelectorAll('.port-active').forEach((el) => el.classList.remove('port-active'));
document.querySelectorAll('.port-hover').forEach((el) => el.classList.remove('port-hover'));
dragRef.current = null;
}, [canvasRef]);
// 마우스 이동/종료 전역 핸들러
useEffect(() => {
const onMove = (e: MouseEvent) => {
const d = dragRef.current;
if (!d) return;
const cv = canvasRef.current;
if (!cv) return;
const cr = cv.getBoundingClientRect();
const x2 = e.clientX - cr.left + cv.scrollLeft;
const y2 = e.clientY - cr.top + cv.scrollTop;
d.line.setAttribute('d', bezierPath(d.x1, d.y1, x2, y2));
};
const onUp = () => {
if (dragRef.current) cleanup();
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
return () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
}, [canvasRef, cleanup]);
return { startDrag, finishDrag };
}
+32
View File
@@ -0,0 +1,32 @@
'use client';
interface CardMiniViewProps {
templateName: string;
category?: string;
tableName?: string;
}
export function CardMiniView({ templateName, category, tableName }: CardMiniViewProps) {
return (
<div className="dash-mini-body">
<div className="dash-mini-stats">
<div className="dash-mini-stat">
<div className="ms-label">릿</div>
<div className="ms-value" style={{ fontSize: '.85rem' }}>{templateName}</div>
</div>
{category && (
<div className="dash-mini-stat">
<div className="ms-label"></div>
<div className="ms-value" style={{ fontSize: '.85rem' }}>{category}</div>
</div>
)}
{tableName && (
<div className="dash-mini-stat">
<div className="ms-label"></div>
<div className="ms-value" style={{ fontSize: '.75rem' }}>{tableName}</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,111 @@
'use client';
import { useState, useEffect } from 'react';
import { X, Settings } from 'lucide-react';
import { getMetaFields } from '@/lib/api/meta';
import { getUserOverride, upsertUserOverride } from '@/lib/api/override';
import { FieldConfig } from '@/types/invyone-component';
interface CardSettingsPanelProps {
cardId: string;
tableName: string;
onClose: () => void;
}
export function CardSettingsPanel({ cardId, tableName, onClose }: CardSettingsPanelProps) {
const [fields, setFields] = useState<FieldConfig[]>([]);
const [overrides, setOverrides] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
const load = async () => {
try {
const [meta, ovr] = await Promise.all([
getMetaFields(tableName),
getUserOverride(cardId),
]);
if (meta?.fields) setFields(meta.fields.filter((f: FieldConfig) => !f.system));
if (ovr?.overrides) setOverrides(ovr.overrides);
} catch (err) {
console.error('[CardSettings] Load failed:', err);
}
};
load();
}, [cardId, tableName]);
const isFieldHidden = (column: string) => {
return overrides?.fields?.[column]?.visible === false;
};
const toggleField = async (column: string) => {
const currentlyHidden = isFieldHidden(column);
const newOverrides = {
...overrides,
fields: {
...(overrides.fields ?? {}),
[column]: { ...(overrides.fields?.[column] ?? {}), visible: currentlyHidden },
},
};
// visible: true 이면 삭제 (기본값이니까)
if (currentlyHidden) {
delete newOverrides.fields[column].visible;
if (Object.keys(newOverrides.fields[column]).length === 0) {
delete newOverrides.fields[column];
}
}
setOverrides(newOverrides);
setSaving(true);
try {
await upsertUserOverride({ card_id: cardId, overrides: newOverrides });
} catch (err) {
console.error('[CardSettings] Save failed:', err);
} finally {
setSaving(false);
}
};
return (
<div className="dash-settings">
<div className="dash-settings-head">
<div className="dash-settings-title">
<Settings size={13} />
<span> </span>
</div>
<button
className="dash-card-btn"
onClick={onClose}
style={{ width: 22, height: 22, borderRadius: 6 }}
>
<X size={14} />
</button>
</div>
<div className="dash-settings-body">
<div style={{
fontSize: '.55rem', fontWeight: 700, color: 'var(--v5-primary)',
textTransform: 'uppercase', letterSpacing: '.08em',
padding: '.3rem 0', borderBottom: '1px solid var(--v5-border-subtle)',
marginBottom: '.4rem',
}}>
/
</div>
{fields.map((f) => (
<div key={f.column} className="dash-settings-row">
<span className="dash-settings-label">{f.label}</span>
<div
className={`dash-toggle${!isFieldHidden(f.column) ? ' on' : ''}`}
onClick={() => toggleField(f.column)}
/>
</div>
))}
{saving && (
<div style={{ textAlign: 'center', fontSize: '.6rem', color: 'var(--v5-text-muted)', marginTop: '.5rem' }}>
...
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,185 @@
'use client';
import { useRef, useCallback, useEffect, forwardRef } from 'react';
import { useDashboardStore } from '@/stores/dashboardStore';
import { DashboardCard } from './DashboardCard';
import { DashboardEmpty } from './DashboardEmpty';
interface DashboardCanvasProps {
dashboardName: string;
onOpenLibrary: () => void;
onOpenSettings?: (cardId: string) => void;
controlMode?: boolean;
}
export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(function DashboardCanvas({
dashboardName,
onOpenLibrary,
onOpenSettings,
controlMode: controlActive,
}, externalRef) {
const cards = useDashboardStore((s) => s.cards);
const editMode = useDashboardStore((s) => s.editMode);
const updateCard = useDashboardStore((s) => s.updateCard);
const removeCard = useDashboardStore((s) => s.removeCard);
const internalRef = useRef<HTMLDivElement>(null);
const canvasRef = (externalRef as React.RefObject<HTMLDivElement | null>) ?? internalRef;
const dragRef = useRef<{
cardId: string;
startX: number; startY: number;
origLeft: number; origTop: number;
origW: number; origH: number;
mode: 'drag' | 'resize';
el: HTMLElement;
} | null>(null);
// 캔버스 경계 clamp
const clamp = useCallback((l: number, t: number, w: number, h: number) => {
const cv = canvasRef.current;
if (!cv) return { l, t, w, h };
const cw = cv.clientWidth;
const ch = cv.clientHeight;
w = Math.min(w, cw);
h = Math.min(h, ch);
l = Math.max(0, Math.min(l, cw - w));
t = Math.max(0, Math.min(t, ch - h));
return { l, t, w, h };
}, []);
// 마우스 다운 → 드래그/리사이즈 시작
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (!editMode) return;
const target = e.target as HTMLElement;
// 버튼/입력 클릭은 무시
if (target.closest('button') || target.closest('input') || target.closest('select')) return;
const cardEl = target.closest('.dash-card') as HTMLElement;
if (!cardEl) return;
const cardId = cardEl.dataset.cardId;
if (!cardId) return;
const isResize = target.closest('[data-resize]') !== null;
e.preventDefault();
dragRef.current = {
cardId,
startX: e.clientX,
startY: e.clientY,
origLeft: cardEl.offsetLeft,
origTop: cardEl.offsetTop,
origW: cardEl.offsetWidth,
origH: cardEl.offsetHeight,
mode: isResize ? 'resize' : 'drag',
el: cardEl,
};
cardEl.classList.add(isResize ? 'resizing' : 'dragging');
document.body.style.cursor = isResize ? 'nwse-resize' : 'grabbing';
document.body.style.userSelect = 'none';
}, [editMode]);
// 마우스 이동
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
const d = dragRef.current;
if (!d) return;
const dx = e.clientX - d.startX;
const dy = e.clientY - d.startY;
if (d.mode === 'drag') {
const c = clamp(d.origLeft + dx, d.origTop + dy, d.origW, d.origH);
d.el.style.left = c.l + 'px';
d.el.style.top = c.t + 'px';
} else {
const nw = Math.max(220, d.origW + dx);
const nh = Math.max(140, d.origH + dy);
const c = clamp(d.origLeft, d.origTop, nw, nh);
d.el.style.width = c.w + 'px';
d.el.style.height = c.h + 'px';
}
};
const handleMouseUp = () => {
const d = dragRef.current;
if (!d) return;
d.el.classList.remove('dragging', 'resizing');
document.body.style.cursor = '';
document.body.style.userSelect = '';
// 최종 위치를 store에 반영
updateCard(d.cardId, {
position_x: d.el.offsetLeft,
position_y: d.el.offsetTop,
width: d.el.offsetWidth,
height: d.el.offsetHeight,
});
dragRef.current = null;
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [clamp, updateCard]);
const handleToggleCollapse = useCallback((cardId: string) => {
const card = cards.find((c) => (c.card_id ?? c.CARD_ID) === cardId);
if (!card) return;
const wasCollapsed = card.is_collapsed ?? card.IS_COLLAPSED ?? false;
updateCard(cardId, { is_collapsed: !wasCollapsed });
}, [cards, updateCard]);
const handleRemove = useCallback((cardId: string) => {
if (!confirm('이 카드를 삭제하시겠습니까?')) return;
removeCard(cardId);
}, [removeCard]);
return (
<div
ref={canvasRef}
className={`dash-canvas${editMode ? ' edit-mode' : ''}${controlActive ? ' control-mode' : ''}`}
onMouseDown={controlActive ? undefined : handleMouseDown}
>
{cards.length === 0 ? (
<DashboardEmpty dashboardName={dashboardName} onOpenLibrary={onOpenLibrary} />
) : (
cards.map((card) => {
const id = card.card_id ?? card.CARD_ID;
const x = Number(card.position_x ?? card.POSITION_X ?? 50);
const y = Number(card.position_y ?? card.POSITION_Y ?? 50);
const w = Number(card.width ?? card.WIDTH ?? 600);
const h = Number(card.height ?? card.HEIGHT ?? 400);
return (
<div
key={id}
data-card-id={id}
style={{
position: 'absolute',
left: x + 'px',
top: y + 'px',
width: w + 'px',
height: h + 'px',
}}
>
<DashboardCard
card={card}
editMode={editMode}
onRemove={handleRemove}
onToggleCollapse={handleToggleCollapse}
onOpenSettings={onOpenSettings}
/>
</div>
);
})
)}
</div>
);
});
+201
View File
@@ -0,0 +1,201 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { RefreshCw, ChevronDown, X, Settings } from 'lucide-react';
import { getTemplateInfo } from '@/lib/api/template';
import { getMetaFields } from '@/lib/api/meta';
import { fcList } from '@/lib/api/fcData';
import { FcTable, FcSearch, FcPagination } from '@/components/fc';
import { FieldConfig } from '@/types/invyone-component';
import { CardMiniView } from './CardMiniView';
interface DashboardCardProps {
card: Record<string, any>;
editMode: boolean;
onRemove: (cardId: string) => void;
onToggleCollapse: (cardId: string) => void;
onOpenSettings?: (cardId: string) => void;
}
export function DashboardCard({
card,
editMode,
onRemove,
onToggleCollapse,
onOpenSettings,
}: DashboardCardProps) {
const cardId = card.card_id ?? card.CARD_ID;
const templateId = card.template_id ?? card.TEMPLATE_ID;
const templateName = card.template_name ?? card.TEMPLATE_NAME ?? '템플릿';
const templateCategory = card.template_category ?? card.TEMPLATE_CATEGORY ?? '';
const primaryTable = card.primary_table ?? card.PRIMARY_TABLE ?? '';
const isCollapsed = card.is_collapsed ?? card.IS_COLLAPSED ?? false;
const [fields, setFields] = useState<FieldConfig[]>([]);
const [data, setData] = useState<Record<string, any>[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [searchParams, setSearchParams] = useState<Record<string, any>>({});
const [loading, setLoading] = useState(false);
const [templateLoaded, setTemplateLoaded] = useState(false);
const mountedRef = useRef(true);
// Template + FieldConfig 로드
useEffect(() => {
mountedRef.current = true;
if (!primaryTable) return;
const loadTemplate = async () => {
try {
const meta = await getMetaFields(primaryTable);
if (mountedRef.current && meta?.fields) {
setFields(meta.fields);
setTemplateLoaded(true);
}
} catch (err) {
console.error(`[DashboardCard] Failed to load fields for ${primaryTable}:`, err);
}
};
loadTemplate();
return () => { mountedRef.current = false; };
}, [primaryTable]);
// 데이터 로드
const loadData = useCallback(async () => {
if (!primaryTable || !templateLoaded) return;
setLoading(true);
try {
const result = await fcList({
tableName: primaryTable,
page,
size: pageSize,
...searchParams,
});
if (mountedRef.current) {
setData(result?.data ?? result?.list ?? []);
setTotalCount(result?.total ?? result?.total_count ?? 0);
}
} catch (err) {
console.error(`[DashboardCard] Failed to load data:`, err);
} finally {
if (mountedRef.current) setLoading(false);
}
}, [primaryTable, templateLoaded, page, pageSize, searchParams]);
useEffect(() => { loadData(); }, [loadData]);
const handleSearch = (params: Record<string, any>) => {
setSearchParams(params);
setPage(1);
};
const handlePageChange = ({ page: newPage, size }: { page: number; size: number }) => {
setPage(newPage);
setPageSize(size);
};
const visibleFields = fields.filter((f) => f.visible && !f.system);
const searchableFields = fields.filter((f) => f.searchable && !f.system);
return (
<div className={`dash-card${isCollapsed ? ' collapsed' : ''}`}>
{/* 카드 헤더 */}
<div className="dash-card-head">
<div className="dash-card-head-l">
<div className="dash-card-icon">📋</div>
<div className="dash-card-title">{templateName}</div>
{templateCategory && (
<div className="dash-card-bdg">{templateCategory}</div>
)}
</div>
<div className="dash-card-head-r">
<button
className="dash-card-btn"
title="새로고침"
onClick={(e) => { e.stopPropagation(); loadData(); }}
>
<RefreshCw size={13} />
</button>
{onOpenSettings && (
<button
className="dash-card-btn"
title="설정"
onClick={(e) => { e.stopPropagation(); onOpenSettings(cardId); }}
>
<Settings size={13} />
</button>
)}
<button
className="dash-card-btn"
title="접기/펴기"
onClick={(e) => { e.stopPropagation(); onToggleCollapse(cardId); }}
>
<ChevronDown size={13} style={{
transform: isCollapsed ? 'rotate(180deg)' : 'none',
transition: 'transform .25s',
}} />
</button>
{editMode && (
<button
className="dash-card-btn danger"
title="카드 삭제"
onClick={(e) => { e.stopPropagation(); onRemove(cardId); }}
>
<X size={13} />
</button>
)}
</div>
</div>
{/* 본문 — Template 컴포넌트 렌더 */}
<div className="dash-card-body">
{!primaryTable ? (
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--v5-text-muted)', fontSize: '.7rem' }}>
릿.
</div>
) : !templateLoaded ? (
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--v5-text-muted)', fontSize: '.7rem' }}>
...
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '.35rem', height: '100%' }}>
{searchableFields.length > 0 && (
<FcSearch
fields={fields}
onSearch={handleSearch}
config={{ layout: 'inline', autoSearch: false, dateRangeEnabled: true, showResetButton: true }}
/>
)}
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
<FcTable
fields={visibleFields}
data={data}
loading={loading}
/>
</div>
{totalCount > 0 && (
<FcPagination
total={totalCount}
page={page}
pageSize={pageSize}
onPageChange={handlePageChange}
/>
)}
</div>
)}
</div>
{/* 접힌 상태: 미니 뷰 */}
<CardMiniView
templateName={templateName}
category={templateCategory}
tableName={primaryTable}
/>
{/* 리사이즈 핸들 */}
<div className="dash-resize-handle" data-resize="true" />
</div>
);
}
@@ -0,0 +1,21 @@
'use client';
interface DashboardEmptyProps {
dashboardName: string;
onOpenLibrary: () => void;
}
export function DashboardEmpty({ dashboardName, onOpenLibrary }: DashboardEmptyProps) {
return (
<div className="dash-empty">
<div className="dash-empty-icon">📋</div>
<div className="dash-empty-title">{dashboardName}</div>
<div className="dash-empty-desc">
릿 . <b>+ 릿 </b> .
</div>
<button className="dash-empty-btn" onClick={onOpenLibrary}>
+ 릿
</button>
</div>
);
}
@@ -0,0 +1,279 @@
'use client';
import { useEffect, useState, useCallback, useRef } from 'react';
import { useDashboardStore } from '@/stores/dashboardStore';
import {
getDashboardList,
getDashboardCards,
insertDashboard,
updateDashboard,
deleteDashboard,
insertDashboardCard,
updateCardPositionsBatch,
deleteDashboardCard,
} from '@/lib/api/dashMenu';
import { DashboardSidebar } from './DashboardSidebar';
import { DashboardToolbar } from './DashboardToolbar';
import { DashboardCanvas } from './DashboardCanvas';
import { TemplateLibraryModal } from './TemplateLibraryModal';
import { CardSettingsPanel } from './CardSettingsPanel';
import { ControlMode } from '@/components/control/ControlMode';
import { ControlPalette } from '@/components/control/ControlPalette';
import { useControlMode } from '@/components/control/hooks/useControlMode';
import { toast } from 'sonner';
import '@/styles/dashboard.css';
export function DashboardLayout() {
const {
dashboards,
activeDashboardId,
cards,
editMode,
setDashboards,
setActiveDashboard,
setCards,
addCard,
setEditMode,
} = useDashboardStore();
const controlActive = useControlMode((s) => s.active);
const controlMode = useControlMode((s) => s.mode);
const [libOpen, setLibOpen] = useState(false);
const [settingsCardId, setSettingsCardId] = useState<string | null>(null);
const [initialized, setInitialized] = useState(false);
const canvasRef = useRef<HTMLDivElement>(null);
// 대시보드 목록 로드
const loadDashboards = useCallback(async () => {
try {
const result = await getDashboardList({ limit: 100 });
const list: Record<string, any>[] = result?.list ?? [];
setDashboards(list);
// 첫 번째 대시보드 자동 선택
if (list.length > 0 && !activeDashboardId) {
const firstId = list[0].dashboard_id ?? list[0].DASHBOARD_ID;
setActiveDashboard(firstId);
}
setInitialized(true);
} catch (err) {
console.error('[Dashboard] Load failed:', err);
setInitialized(true);
}
}, [setDashboards, setActiveDashboard, activeDashboardId]);
useEffect(() => { loadDashboards(); }, []);
// 대시보드 전환 시 카드 로드
const loadCards = useCallback(async (dashId: string) => {
try {
const cardList = await getDashboardCards(dashId);
setCards(cardList ?? []);
} catch (err) {
console.error('[Dashboard] Load cards failed:', err);
setCards([]);
}
}, [setCards]);
useEffect(() => {
if (activeDashboardId) {
loadCards(activeDashboardId);
setEditMode(false);
}
}, [activeDashboardId, loadCards, setEditMode]);
// 활성 대시보드 정보
const activeDash = dashboards.find(
(d) => (d.dashboard_id ?? d.DASHBOARD_ID) === activeDashboardId
);
const dashName = activeDash?.name ?? activeDash?.NAME ?? '대시보드';
// 대시보드 CRUD
const handleAddDashboard = async () => {
const name = prompt('새 대시보드 이름:', '새 대시보드');
if (!name?.trim()) return;
try {
const result = await insertDashboard({ name: name.trim() });
await loadDashboards();
if (result?.dashboard_id) {
setActiveDashboard(result.dashboard_id);
}
toast.success(`"${name.trim()}" 대시보드를 만들었습니다`);
} catch (err) {
toast.error('대시보드 생성 실패');
}
};
const handleRenameDashboard = async (id: string) => {
const dash = dashboards.find((d) => (d.dashboard_id ?? d.DASHBOARD_ID) === id);
if (!dash) return;
const newName = prompt('새 이름:', dash.name ?? dash.NAME ?? '');
if (!newName?.trim()) return;
try {
await updateDashboard(id, { name: newName.trim() });
await loadDashboards();
toast.success('이름 변경됨');
} catch (err) {
toast.error('이름 변경 실패');
}
};
const handleDeleteDashboard = async (id: string) => {
if (dashboards.length <= 1) {
toast.warning('마지막 대시보드는 삭제할 수 없습니다');
return;
}
const dash = dashboards.find((d) => (d.dashboard_id ?? d.DASHBOARD_ID) === id);
if (!confirm(`"${dash?.name ?? dash?.NAME}" 을 삭제합니다.`)) return;
try {
await deleteDashboard(id);
await loadDashboards();
toast.info('대시보드 삭제됨');
} catch (err) {
toast.error('삭제 실패');
}
};
const handleSwitchDashboard = (id: string) => {
if (id === activeDashboardId) return;
setActiveDashboard(id);
};
// 템플릿 추가 (라이브러리 → 카드)
const handleSelectTemplate = async (template: Record<string, any>) => {
if (!activeDashboardId) return;
const templateId = template.template_id ?? template.TEMPLATE_ID;
try {
const result = await insertDashboardCard(activeDashboardId, {
template_id: templateId,
position_x: 50 + Math.floor(Math.random() * 200),
position_y: 30 + Math.floor(Math.random() * 100),
width: 700,
height: 450,
});
// 새 카드를 store에 추가 (서버 응답 + 원본 template 정보 결합)
addCard({
...result,
template_id: templateId,
template_name: template.name ?? template.NAME,
template_category: template.category ?? template.CATEGORY,
primary_table: template.primary_table ?? template.PRIMARY_TABLE,
position_x: 50 + Math.floor(Math.random() * 200),
position_y: 30 + Math.floor(Math.random() * 100),
width: 700,
height: 450,
is_collapsed: false,
});
setLibOpen(false);
if (!editMode) setEditMode(true);
toast.success(`${template.name ?? template.NAME} 카드를 추가했습니다`);
} catch (err) {
toast.error('카드 추가 실패');
}
};
// 레이아웃 저장
const handleSaveLayout = async () => {
if (!activeDashboardId) return;
try {
const cardPositions = cards.map((c) => ({
card_id: c.card_id ?? c.CARD_ID,
position_x: c.position_x ?? c.POSITION_X ?? 0,
position_y: c.position_y ?? c.POSITION_Y ?? 0,
width: c.width ?? c.WIDTH ?? 600,
height: c.height ?? c.HEIGHT ?? 400,
is_collapsed: c.is_collapsed ?? c.IS_COLLAPSED ?? false,
}));
await updateCardPositionsBatch(activeDashboardId, cardPositions);
toast.success(`${cards.length}개 카드 레이아웃 저장됨`);
} catch (err) {
toast.error('저장 실패');
}
};
// 설정 카드 정보
const settingsCard = settingsCardId
? cards.find((c) => (c.card_id ?? c.CARD_ID) === settingsCardId)
: null;
if (!initialized) {
return (
<div className="dash-shell" style={{ alignItems: 'center', justifyContent: 'center' }}>
<div style={{ color: 'var(--v5-text-muted)', fontSize: '.8rem' }}> ...</div>
</div>
);
}
return (
<div className="dash-shell">
{/* 사이드바 — 제어 편집 모드에서는 팔레트로 교체 */}
{controlActive && controlMode === 'edit' ? (
<div className="dash-side">
<div className="dash-side-sec" style={{ color: 'var(--ctrl-cyan)' }}> </div>
<ControlPalette onDropTable={() => {}} onDropControl={() => {}} />
</div>
) : (
<DashboardSidebar
onAddDashboard={handleAddDashboard}
onRenameDashboard={handleRenameDashboard}
onDeleteDashboard={handleDeleteDashboard}
onSwitchDashboard={handleSwitchDashboard}
/>
)}
<div className="dash-content">
{activeDashboardId ? (
<>
<DashboardToolbar
dashboardName={dashName}
cardCount={cards.length}
onOpenLibrary={() => setLibOpen(true)}
onSaveLayout={handleSaveLayout}
/>
{/* 제어 모드 툴바 + 오버레이 */}
<div style={{ position: 'relative', flex: 1 }}>
<DashboardCanvas
ref={canvasRef}
dashboardName={dashName}
onOpenLibrary={() => setLibOpen(true)}
onOpenSettings={(id) => setSettingsCardId(id)}
controlMode={controlActive}
/>
<ControlMode
dashboardId={activeDashboardId}
cards={cards}
canvasRef={canvasRef}
/>
{settingsCard && !controlActive && (
<CardSettingsPanel
cardId={settingsCardId!}
tableName={settingsCard.primary_table ?? settingsCard.PRIMARY_TABLE ?? ''}
onClose={() => setSettingsCardId(null)}
/>
)}
</div>
</>
) : (
<div style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
flexDirection: 'column', gap: '.8rem',
}}>
<div style={{ fontSize: '3rem', opacity: .25 }}>📋</div>
<div style={{ fontSize: '.9rem', fontWeight: 700, color: 'var(--v5-text-sec)' }}>
</div>
<button className="dash-empty-btn" onClick={handleAddDashboard}>
+
</button>
</div>
)}
</div>
<TemplateLibraryModal
open={libOpen}
onClose={() => setLibOpen(false)}
onSelectTemplate={handleSelectTemplate}
/>
</div>
);
}
@@ -0,0 +1,67 @@
'use client';
import { useState } from 'react';
import { Plus, Pencil, X } from 'lucide-react';
import { useDashboardStore } from '@/stores/dashboardStore';
interface DashboardSidebarProps {
onAddDashboard: () => void;
onRenameDashboard: (id: string) => void;
onDeleteDashboard: (id: string) => void;
onSwitchDashboard: (id: string) => void;
}
export function DashboardSidebar({
onAddDashboard,
onRenameDashboard,
onDeleteDashboard,
onSwitchDashboard,
}: DashboardSidebarProps) {
const dashboards = useDashboardStore((s) => s.dashboards);
const activeDashboardId = useDashboardStore((s) => s.activeDashboardId);
return (
<div className="dash-side">
<div className="dash-side-sec"> </div>
{dashboards.map((d) => {
const id = d.dashboard_id ?? d.DASHBOARD_ID;
const name = d.name ?? d.NAME ?? '대시보드';
const icon = d.icon ?? d.ICON ?? '📋';
const isActive = id === activeDashboardId;
return (
<div
key={id}
className={`dash-si${isActive ? ' on' : ''}`}
onClick={() => onSwitchDashboard(id)}
>
<span className="ic">{icon}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{name}
</span>
<div className="dash-si-actions">
<button
className="dash-si-act"
title="이름 변경"
onClick={(e) => { e.stopPropagation(); onRenameDashboard(id); }}
>
<Pencil size={11} />
</button>
<button
className="dash-si-act danger"
title="삭제"
onClick={(e) => { e.stopPropagation(); onDeleteDashboard(id); }}
>
<X size={11} />
</button>
</div>
</div>
);
})}
<button className="dash-add-btn" onClick={onAddDashboard}>
<Plus size={14} />
<span> </span>
</button>
</div>
);
}
@@ -0,0 +1,76 @@
'use client';
import { Edit3, Save, Plus, Zap } from 'lucide-react';
import { useDashboardStore } from '@/stores/dashboardStore';
import { useControlMode } from '@/components/control/hooks/useControlMode';
interface DashboardToolbarProps {
dashboardName: string;
cardCount: number;
onOpenLibrary: () => void;
onSaveLayout: () => void;
}
export function DashboardToolbar({
dashboardName,
cardCount,
onOpenLibrary,
onSaveLayout,
}: DashboardToolbarProps) {
const editMode = useDashboardStore((s) => s.editMode);
const toggleEditMode = useDashboardStore((s) => s.toggleEditMode);
const setEditMode = useDashboardStore((s) => s.setEditMode);
const controlActive = useControlMode((s) => s.active);
const toggleControlMode = useControlMode((s) => s.toggleControlMode);
const handleToggleControl = () => {
if (!controlActive) {
// 제어 모드 진입 시 편집 모드 끄기
setEditMode(false);
}
toggleControlMode();
};
return (
<div className="dash-toolbar">
<div className="dash-toolbar-l">
<span className="dash-cv-title">{dashboardName}</span>
<span className="dash-cv-meta">{`템플릿 ${cardCount}`}</span>
</div>
<div className="dash-toolbar-r">
{/* ⚡ 제어 모드 토글 */}
<button
className={`dash-btn${controlActive ? ' control-on' : ''}`}
onClick={handleToggleControl}
title={controlActive ? '제어 모드 끄기' : '제어 모드 — 데이터 흐름 시각화'}
>
<Zap size={13} />
<span>{controlActive ? '제어 ✓' : '제어'}</span>
</button>
{!controlActive && (
<>
<button
className={`dash-btn${editMode ? ' on' : ''}`}
onClick={toggleEditMode}
title={editMode ? '편집 모드 끄기' : '편집 모드 켜기'}
>
<Edit3 size={13} />
<span>{editMode ? '편집 중' : '편집'}</span>
</button>
<button className="dash-btn primary" onClick={onOpenLibrary}>
<Plus size={13} />
<span>릿 </span>
</button>
{editMode && (
<button className="dash-btn" onClick={onSaveLayout}>
<Save size={13} />
<span></span>
</button>
)}
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,162 @@
'use client';
import { useState, useEffect } from 'react';
import { Search, X } from 'lucide-react';
import { getTemplateList } from '@/lib/api/template';
interface TemplateLibraryModalProps {
open: boolean;
onClose: () => void;
onSelectTemplate: (template: Record<string, any>) => void;
}
const CATEGORIES = [
{ id: '', label: '전체', icon: '📋' },
{ id: 'sales', label: '영업/CRM', icon: '💰' },
{ id: 'production', label: '생산/공정', icon: '🏭' },
{ id: 'hr', label: '인사/급여', icon: '👥' },
{ id: 'inventory', label: '재고/물류', icon: '📦' },
{ id: 'finance', label: '재무/회계', icon: '💳' },
{ id: 'admin', label: '관리자', icon: '⚙' },
];
export function TemplateLibraryModal({ open, onClose, onSelectTemplate }: TemplateLibraryModalProps) {
const [templates, setTemplates] = useState<Record<string, any>[]>([]);
const [activeCategory, setActiveCategory] = useState('');
const [keyword, setKeyword] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open) return;
const load = async () => {
setLoading(true);
try {
const result = await getTemplateList({ status: 'published', limit: 100 });
setTemplates(result?.list ?? []);
} catch (err) {
console.error('[TemplateLibrary] Load failed:', err);
} finally {
setLoading(false);
}
};
load();
}, [open]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (open) document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
const filtered = templates.filter((t) => {
const name = (t.name ?? t.NAME ?? '').toLowerCase();
const cat = (t.category ?? t.CATEGORY ?? '').toLowerCase();
const desc = (t.description ?? t.DESCRIPTION ?? '').toLowerCase();
const matchKeyword = !keyword || name.includes(keyword.toLowerCase()) || desc.includes(keyword.toLowerCase());
const matchCategory = !activeCategory || cat === activeCategory;
return matchKeyword && matchCategory;
});
return (
<>
<div
className={`dash-lib-backdrop${open ? ' open' : ''}`}
onClick={onClose}
/>
<div className={`dash-lib-modal${open ? ' open' : ''}`}>
{/* 헤더 */}
<div className="dash-lib-head">
<div style={{ display: 'flex', alignItems: 'center', gap: '.7rem' }}>
<span className="dash-lib-title">릿 </span>
<span style={{ fontSize: '.6rem', color: 'var(--v5-text-muted)' }}>
{filtered.length}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '.5rem' }}>
<div style={{
display: 'flex', alignItems: 'center', gap: '.5rem',
padding: '.45rem .75rem', borderRadius: '10px',
background: 'var(--v5-surface)', border: '1px solid var(--v5-glass-border)',
width: '220px',
}}>
<Search size={13} style={{ color: 'var(--v5-text-muted)', flexShrink: 0 }} />
<input
type="text"
placeholder="템플릿 검색..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
style={{
flex: 1, border: 'none', background: 'transparent',
color: 'var(--v5-text)', fontSize: '.7rem', fontFamily: 'inherit', outline: 'none',
}}
/>
</div>
<button className="dash-lib-close" onClick={onClose}>
<X size={14} />
</button>
</div>
</div>
{/* 본문 */}
<div className="dash-lib-body">
{/* 카테고리 */}
<div className="dash-lib-cats">
{CATEGORIES.map((cat) => (
<div
key={cat.id}
className={`dash-lib-cat${activeCategory === cat.id ? ' on' : ''}`}
onClick={() => setActiveCategory(cat.id)}
>
<span className="ic">{cat.icon}</span>
<span>{cat.label}</span>
</div>
))}
</div>
{/* 카드 그리드 */}
<div className="dash-lib-grid">
{loading ? (
<div style={{ padding: '3rem', textAlign: 'center', color: 'var(--v5-text-muted)', fontSize: '.7rem' }}>
릿 ...
</div>
) : filtered.length === 0 ? (
<div style={{ padding: '3rem', textAlign: 'center', color: 'var(--v5-text-muted)', fontSize: '.7rem' }}>
{templates.length === 0
? '게시된 템플릿이 없습니다. 개발자 빌더에서 템플릿을 만들고 게시하세요.'
: '검색 결과가 없습니다.'}
</div>
) : (
<div className="dash-lib-cards">
{filtered.map((t) => {
const tid = t.template_id ?? t.TEMPLATE_ID;
const name = t.name ?? t.NAME ?? '템플릿';
const cat = t.category ?? t.CATEGORY ?? '';
const desc = t.description ?? t.DESCRIPTION ?? '';
const table = t.primary_table ?? t.PRIMARY_TABLE ?? '';
return (
<div
key={tid}
className="dash-lib-card"
onClick={() => onSelectTemplate(t)}
>
<div className="dash-lib-card-icon">📋</div>
<div className="dash-lib-card-name">{name}</div>
{desc && <div className="dash-lib-card-desc">{desc}</div>}
<div style={{ display: 'flex', gap: '.2rem', marginTop: 'auto', flexWrap: 'wrap' }}>
{cat && <span className="dash-lib-card-tag">{cat}</span>}
{table && <span className="dash-lib-card-tag">{table}</span>}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
</>
);
}
+69
View File
@@ -0,0 +1,69 @@
'use client';
import { useState } from 'react';
import type { ButtonConfig } from '@/types/invyone-component';
interface FcButtonProps {
config: ButtonConfig;
onClick?: () => void;
disabled?: boolean;
}
const VARIANT_CLASSES: Record<string, string> = {
primary: 'bg-[var(--v5-primary)] text-white hover:opacity-90 shadow-[var(--v5-glow-sm)]',
default: 'bg-[var(--v5-surface)] text-[var(--v5-text)] border border-[var(--v5-border)] hover:border-[var(--v5-primary)]',
destructive: 'bg-[var(--v5-red)] text-white hover:opacity-90',
outline: 'bg-transparent text-[var(--v5-text)] border border-[var(--v5-border)] hover:bg-[var(--v5-surface-hover)]',
ghost: 'bg-transparent text-[var(--v5-text-sec)] hover:bg-[var(--v5-surface-hover)]',
};
export function FcButton({ config, onClick, disabled }: FcButtonProps) {
const [confirming, setConfirming] = useState(false);
const handleClick = () => {
if (config.confirm && !confirming) {
setConfirming(true);
return;
}
setConfirming(false);
onClick?.();
};
const handleCancel = () => setConfirming(false);
const variantClass = VARIANT_CLASSES[config.variant] ?? VARIANT_CLASSES.default;
if (confirming) {
return (
<div className="inline-flex items-center gap-1">
<span className="text-[0.65rem] text-[var(--v5-red)]">{config.confirm}</span>
<button
type="button"
onClick={handleClick}
className="px-2 py-0.5 rounded text-[0.65rem] bg-[var(--v5-red)] text-white"
>
</button>
<button
type="button"
onClick={handleCancel}
className="px-2 py-0.5 rounded text-[0.65rem] border border-[var(--v5-border)] text-[var(--v5-text-sec)]"
>
</button>
</div>
);
}
return (
<button
type="button"
onClick={handleClick}
disabled={disabled}
className={`px-3 py-1 rounded text-xs font-medium transition-all
disabled:opacity-40 disabled:cursor-not-allowed ${variantClass}`}
>
{config.text}
</button>
);
}
+23
View File
@@ -0,0 +1,23 @@
'use client';
import { FcButton } from './FcButton';
import type { ActionType, ButtonBarConfig } from '@/types/invyone-component';
interface FcButtonBarProps {
config: ButtonBarConfig;
onAction?: (actionType: ActionType) => void;
}
export function FcButtonBar({ config, onAction }: FcButtonBarProps) {
return (
<div className="flex items-center gap-1.5 flex-wrap">
{config.buttons.map((btn, idx) => (
<FcButton
key={`${btn.actionType}-${idx}`}
config={btn}
onClick={() => onAction?.(btn.actionType)}
/>
))}
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
'use client';
import { useMemo, useEffect, useState, useCallback } from 'react';
import { FieldRenderer } from './fields/FieldRenderer';
import type { FieldConfig, FormConfig } from '@/types/invyone-component';
const DEFAULT_CONFIG: FormConfig = {
columns: 2,
saveAction: {
method: 'UPSERT',
refreshAfterSave: true,
},
};
interface FcFormProps {
fields: FieldConfig[];
config?: Partial<FormConfig>;
initialData?: Record<string, any>;
onSubmit?: (data: Record<string, any>) => void;
onSaved?: (data: Record<string, any>) => void;
loadRow?: Record<string, any>;
}
/** required 검증: null, undefined, '' 만 empty. 0, false는 유효. */
function isFieldEmpty(value: any): boolean {
return value === null || value === undefined || value === '';
}
export function FcForm({
fields,
config: configOverride,
initialData,
onSubmit,
loadRow,
}: FcFormProps) {
const config = useMemo(() => ({ ...DEFAULT_CONFIG, ...configOverride }), [configOverride]);
// 표시할 필드: system이 아니고 visible인 것, order 순
const formFields = useMemo(
() => fields
.filter((f) => !f.system && f.visible)
.sort((a, b) => a.order - b.order),
[fields],
);
// 폼 데이터 상태
const [formData, setFormData] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isModified, setIsModified] = useState(false);
// initialData 또는 loadRow가 변경되면 폼 데이터 갱신
useEffect(() => {
const source = loadRow ?? initialData ?? {};
setFormData({ ...source });
setErrors({});
setIsModified(false);
}, [loadRow, initialData]);
// 필드 값 변경
const handleChange = useCallback((column: string, value: any) => {
setFormData((prev) => ({ ...prev, [column]: value }));
setErrors((prev) => {
const next = { ...prev };
delete next[column];
return next;
});
setIsModified(true);
}, []);
// 제출
const handleSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault();
// required 검증
const newErrors: Record<string, string> = {};
for (const field of formFields) {
if (field.required && isFieldEmpty(formData[field.column])) {
newErrors[field.column] = `${field.label}은(는) 필수입니다`;
}
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
onSubmit?.(formData);
}, [formData, formFields, onSubmit]);
// 초기화
const handleReset = useCallback(() => {
const source = loadRow ?? initialData ?? {};
setFormData({ ...source });
setErrors({});
setIsModified(false);
}, [loadRow, initialData]);
const gridCols = config.columns === 3 ? 'grid-cols-3' : config.columns === 1 ? 'grid-cols-1' : 'grid-cols-2';
// 섹션이 있으면 섹션별로 그룹핑
const sections = config.sections;
const renderField = (field: FieldConfig) => {
const isDisabled = !field.editable || (field.pk && field.type === 'code');
return (
<div key={field.column} className="space-y-0.5">
<label className="flex items-center gap-0.5 text-xs font-medium text-[var(--v5-text-sec)]">
{field.label}
{field.required && <span className="text-[var(--v5-red)]">*</span>}
</label>
<FieldRenderer
field={field}
value={formData[field.column]}
onChange={(v) => handleChange(field.column, v)}
mode="form"
disabled={isDisabled}
error={errors[field.column]}
/>
{errors[field.column] && (
<p className="text-[0.65rem] text-[var(--v5-red)]">{errors[field.column]}</p>
)}
</div>
);
};
return (
<form
onSubmit={handleSubmit}
className="fc-form rounded-md border border-[var(--v5-glass-border)] p-3
bg-[var(--v5-glass)] backdrop-blur-[20px]"
>
{sections && sections.length > 0 ? (
// 섹션별 렌더링
sections.map((section) => {
const sectionFields = formFields.filter((f) => section.fields.includes(f.column));
if (sectionFields.length === 0) return null;
return (
<div key={section.label} className="mb-3">
<h4 className="text-xs font-semibold text-[var(--v5-primary)] mb-2 pb-1
border-b border-[var(--v5-border-subtle)]">
{section.label}
</h4>
<div className={`grid ${gridCols} gap-x-3 gap-y-2`}>
{sectionFields.map(renderField)}
</div>
</div>
);
})
) : (
// 단일 섹션
<div className={`grid ${gridCols} gap-x-3 gap-y-2`}>
{formFields.map(renderField)}
</div>
)}
{/* 버튼 영역 */}
<div className="flex items-center justify-end gap-2 mt-3 pt-2 border-t border-[var(--v5-border-subtle)]">
<button
type="button"
onClick={handleReset}
disabled={!isModified}
className="px-3 py-1 rounded text-xs border border-[var(--v5-border)]
text-[var(--v5-text-sec)] bg-[var(--v5-surface)]
hover:border-[var(--v5-primary)] hover:text-[var(--v5-primary)]
disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
</button>
<button
type="submit"
className="px-3 py-1 rounded text-xs font-medium text-white
bg-[var(--v5-primary)] hover:opacity-90
shadow-[var(--v5-glow-sm)] transition-all"
>
{config.saveAction.method === 'INSERT' ? '등록' : '저장'}
</button>
</div>
</form>
);
}
+101
View File
@@ -0,0 +1,101 @@
'use client';
import { useMemo } from 'react';
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import type { PaginationConfig } from '@/types/invyone-component';
const DEFAULT_CONFIG: PaginationConfig = {
pageSize: 20,
showSizeSelector: true,
sizeOptions: [10, 20, 50, 100],
};
interface FcPaginationProps {
config?: Partial<PaginationConfig>;
total: number;
page: number;
pageSize?: number;
onPageChange?: (params: { page: number; size: number }) => void;
}
export function FcPagination({
config: configOverride,
total,
page,
pageSize: pageSizeOverride,
onPageChange,
}: FcPaginationProps) {
const config = useMemo(() => ({ ...DEFAULT_CONFIG, ...configOverride }), [configOverride]);
const size = pageSizeOverride ?? config.pageSize;
const totalPages = Math.max(1, Math.ceil(total / size));
const goTo = (p: number) => {
const clamped = Math.max(1, Math.min(p, totalPages));
if (clamped !== page) onPageChange?.({ page: clamped, size });
};
// 보여줄 페이지 번호 계산 (최대 5개)
const pages = useMemo(() => {
const maxVisible = 5;
let start = Math.max(1, page - Math.floor(maxVisible / 2));
const end = Math.min(totalPages, start + maxVisible - 1);
start = Math.max(1, end - maxVisible + 1);
const arr: number[] = [];
for (let i = start; i <= end; i++) arr.push(i);
return arr;
}, [page, totalPages]);
return (
<div className="flex items-center justify-between gap-4 text-xs text-[var(--v5-text-sec)]">
<div className="flex items-center gap-1.5">
<span> {total.toLocaleString()}</span>
{config.showSizeSelector && (
<select
value={size}
onChange={(e) => onPageChange?.({ page: 1, size: Number(e.target.value) })}
className="h-6 rounded border border-[var(--v5-border)] bg-[var(--v5-surface)]
px-1 text-xs text-[var(--v5-text)] outline-none"
>
{config.sizeOptions.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
)}
</div>
<div className="flex items-center gap-0.5">
<PgBtn onClick={() => goTo(1)} disabled={page <= 1}><ChevronsLeft className="h-3 w-3" /></PgBtn>
<PgBtn onClick={() => goTo(page - 1)} disabled={page <= 1}><ChevronLeft className="h-3 w-3" /></PgBtn>
{pages.map((p) => (
<button
key={p}
onClick={() => goTo(p)}
className={`h-6 min-w-[24px] rounded text-xs transition-colors
${p === page
? 'bg-[var(--v5-primary)] text-white shadow-[var(--v5-glow-sm)]'
: 'hover:bg-[var(--v5-surface-hover)] text-[var(--v5-text-sec)]'}`}
>
{p}
</button>
))}
<PgBtn onClick={() => goTo(page + 1)} disabled={page >= totalPages}><ChevronRight className="h-3 w-3" /></PgBtn>
<PgBtn onClick={() => goTo(totalPages)} disabled={page >= totalPages}><ChevronsRight className="h-3 w-3" /></PgBtn>
</div>
</div>
);
}
function PgBtn({ children, onClick, disabled }: { children: React.ReactNode; onClick: () => void; disabled: boolean }) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="flex h-6 w-6 items-center justify-center rounded
text-[var(--v5-text-muted)] hover:text-[var(--v5-primary)] hover:bg-[var(--v5-surface-hover)]
disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
{children}
</button>
);
}
+156
View File
@@ -0,0 +1,156 @@
'use client';
import { useMemo, useState, useCallback, useEffect, useRef } from 'react';
import { Search, RotateCcw } from 'lucide-react';
import { FieldRenderer } from './fields/FieldRenderer';
import type { FieldConfig, SearchConfig } from '@/types/invyone-component';
const DEFAULT_CONFIG: SearchConfig = {
dateRangeEnabled: true,
showResetButton: true,
autoSearch: false,
layout: 'inline',
};
interface FcSearchProps {
fields: FieldConfig[];
config?: Partial<SearchConfig>;
onSearch?: (params: Record<string, any>) => void;
}
export function FcSearch({ fields, config: configOverride, onSearch }: FcSearchProps) {
const config = useMemo(() => ({ ...DEFAULT_CONFIG, ...configOverride }), [configOverride]);
// searchable 필드만 추출, order 순
const searchFields = useMemo(
() => fields
.filter((f) => f.searchable && !f.system)
.sort((a, b) => a.order - b.order),
[fields],
);
const [values, setValues] = useState<Record<string, any>>({});
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleChange = useCallback((column: string, value: any) => {
setValues((prev) => {
const next = { ...prev };
if (value === undefined || value === '' || value === null) {
delete next[column];
} else {
next[column] = value;
}
return next;
});
}, []);
// autoSearch: 값 변경 시 300ms 디바운스 후 자동 검색
useEffect(() => {
if (!config.autoSearch) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onSearch?.(buildSearchParams(values, searchFields));
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [values, config.autoSearch, onSearch, searchFields]);
const handleSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault();
onSearch?.(buildSearchParams(values, searchFields));
}, [values, searchFields, onSearch]);
const handleReset = useCallback(() => {
setValues({});
onSearch?.({});
}, [onSearch]);
if (searchFields.length === 0) return null;
const isInline = config.layout === 'inline';
return (
<form
onSubmit={handleSubmit}
className="fc-search rounded-md border border-[var(--v5-glass-border)] p-2
bg-[var(--v5-glass)] backdrop-blur-[20px]"
>
<div className={isInline
? 'flex flex-wrap items-end gap-2'
: 'grid grid-cols-1 gap-2'
}>
{searchFields.map((field) => (
<div
key={field.column}
className={isInline ? 'flex flex-col gap-0.5 min-w-[140px] max-w-[220px]' : 'space-y-0.5'}
>
<label className="text-[0.65rem] font-medium text-[var(--v5-text-muted)] whitespace-nowrap">
{field.label}
</label>
<FieldRenderer
field={field}
value={values[field.column]}
onChange={(v) => handleChange(field.column, v)}
mode="search"
/>
</div>
))}
{/* 버튼 */}
<div className="flex items-end gap-1 ml-auto">
<button
type="submit"
className="flex items-center gap-1 h-7 px-2.5 rounded text-xs font-medium text-white
bg-[var(--v5-primary)] hover:opacity-90 shadow-[var(--v5-glow-sm)] transition-all"
>
<Search className="h-3 w-3" />
</button>
{config.showResetButton && (
<button
type="button"
onClick={handleReset}
className="flex items-center gap-1 h-7 px-2 rounded text-xs border border-[var(--v5-border)]
text-[var(--v5-text-sec)] bg-[var(--v5-surface)]
hover:border-[var(--v5-primary)] hover:text-[var(--v5-primary)] transition-colors"
>
<RotateCcw className="h-3 w-3" />
</button>
)}
</div>
</div>
</form>
);
}
/**
* 검색 값을 API 파라미터 형식으로 변환.
* date 범위: { order_date_from, order_date_to }
* number 범위: { amount_min, amount_max }
* select 다중: { status: ['확정','완료'] }
* text 부분 일치: { customer_name: '삼성' }
*/
function buildSearchParams(values: Record<string, any>, fields: FieldConfig[]): Record<string, any> {
const params: Record<string, any> = {};
for (const field of fields) {
const val = values[field.column];
if (val === undefined || val === null || val === '') continue;
if ((field.type === 'date' || field.type === 'datetime') && typeof val === 'object' && !Array.isArray(val)) {
// 범위: {from, to}
if (val.from) params[`${field.column}_from`] = val.from;
if (val.to) params[`${field.column}_to`] = val.to;
} else if (field.type === 'number' && typeof val === 'object' && !Array.isArray(val)) {
// 범위: {min, max}
if (val.min !== undefined) params[`${field.column}_min`] = val.min;
if (val.max !== undefined) params[`${field.column}_max`] = val.max;
} else {
params[field.column] = val;
}
}
return params;
}
+236
View File
@@ -0,0 +1,236 @@
'use client';
import { useMemo, useState, useCallback, useEffect } from 'react';
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
flexRender,
type ColumnDef,
type SortingState,
type RowSelectionState,
} from '@tanstack/react-table';
import { ArrowUpDown, ArrowUp, ArrowDown, Loader2 } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { CellRenderer } from './table/CellRenderer';
import type { FieldConfig, TableConfig } from '@/types/invyone-component';
const DEFAULT_CONFIG: TableConfig = {
pageSize: 20,
selectionMode: 'single',
showCheckbox: false,
inlineEdit: false,
autoLoad: true,
toolbar: { showExcel: false, showRefresh: true, showFilter: false },
style: 'compact',
};
interface FcTableProps {
fields: FieldConfig[];
data: Record<string, any>[];
config?: Partial<TableConfig>;
loading?: boolean;
onRowSelect?: (row: Record<string, any>) => void;
onRowsSelect?: (rows: Record<string, any>[]) => void;
selectedRowIndex?: number;
}
export function FcTable({
fields,
data,
config: configOverride,
loading,
onRowSelect,
onRowsSelect,
selectedRowIndex,
}: FcTableProps) {
const config = useMemo(() => ({ ...DEFAULT_CONFIG, ...configOverride }), [configOverride]);
const [sorting, setSorting] = useState<SortingState>(
config.defaultSort
? [{ id: config.defaultSort.column, desc: config.defaultSort.direction === 'desc' }]
: [],
);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
// visible + order 기준으로 필드 정렬
const visibleFields = useMemo(
() => fields.filter((f) => f.visible).sort((a, b) => a.order - b.order),
[fields],
);
// TanStack 컬럼 정의 생성
const columns = useMemo<ColumnDef<Record<string, any>>[]>(() => {
const cols: ColumnDef<Record<string, any>>[] = [];
// 체크박스 컬럼
if (config.showCheckbox && config.selectionMode === 'multiple') {
cols.push({
id: '_select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
/>
),
size: 36,
enableSorting: false,
});
}
// 데이터 컬럼
for (const field of visibleFields) {
cols.push({
id: field.column,
accessorKey: field.column,
header: ({ column }) => {
if (!field.sortable) {
return <span>{field.label}</span>;
}
return (
<button
type="button"
className="flex items-center gap-0.5 hover:text-[var(--v5-primary)] transition-colors"
onClick={() => column.toggleSorting()}
>
<span>{field.label}</span>
{column.getIsSorted() === 'asc' ? (
<ArrowUp className="h-3 w-3" />
) : column.getIsSorted() === 'desc' ? (
<ArrowDown className="h-3 w-3" />
) : (
<ArrowUpDown className="h-2.5 w-2.5 opacity-40" />
)}
</button>
);
},
cell: ({ getValue }) => <CellRenderer field={field} value={getValue()} />,
size: field.width ?? getDefaultWidth(field.type),
enableSorting: field.sortable ?? true,
meta: { align: field.align ?? (field.type === 'number' ? 'right' : 'left') },
});
}
return cols;
}, [visibleFields, config.showCheckbox, config.selectionMode]);
// 행 선택 변경 콜백
const handleRowSelectionChange = useCallback(
(updater: RowSelectionState | ((old: RowSelectionState) => RowSelectionState)) => {
setRowSelection((prev) => {
const next = typeof updater === 'function' ? updater(prev) : updater;
if (onRowsSelect) {
const selectedRows = Object.keys(next)
.filter((k) => next[k])
.map((k) => data[Number(k)])
.filter(Boolean);
onRowsSelect(selectedRows);
}
return next;
});
},
[data, onRowsSelect],
);
const table = useReactTable({
data,
columns,
state: { sorting, rowSelection },
onSortingChange: setSorting,
onRowSelectionChange: handleRowSelectionChange,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
enableMultiRowSelection: config.selectionMode === 'multiple',
});
// 데이터 변경 시 선택 초기화
useEffect(() => {
setRowSelection({});
}, [data]);
return (
<div className="fc-table-wrap rounded-md border border-[var(--v5-glass-border)] overflow-hidden
bg-[var(--v5-glass)] backdrop-blur-[20px]">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-xs">
<thead>
{table.getHeaderGroups().map((hg) => (
<tr key={hg.id} className="border-b border-[var(--v5-border)]">
{hg.headers.map((header) => (
<th
key={header.id}
className="px-2 py-1.5 text-left font-semibold text-[var(--v5-text-sec)]
bg-[var(--v5-surface)] whitespace-nowrap select-none"
style={{ width: header.getSize() }}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={columns.length} className="text-center py-8">
<Loader2 className="h-5 w-5 animate-spin mx-auto text-[var(--v5-primary)]" />
</td>
</tr>
) : data.length === 0 ? (
<tr>
<td colSpan={columns.length} className="text-center py-8 text-[var(--v5-text-muted)]">
</td>
</tr>
) : (
table.getRowModel().rows.map((row, idx) => (
<tr
key={row.id}
onClick={() => onRowSelect?.(row.original)}
className={`border-b border-[var(--v5-border-subtle)] cursor-pointer transition-colors
hover:bg-[var(--v5-surface-hover)]
${selectedRowIndex === idx ? 'bg-[var(--v5-primary-glow)]' : ''}`}
>
{row.getVisibleCells().map((cell) => {
const align = (cell.column.columnDef.meta as any)?.align ?? 'left';
return (
<td
key={cell.id}
className={`px-2 py-1 whitespace-nowrap text-[var(--v5-text)]
${align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left'}`}
style={{ maxWidth: cell.column.getSize() }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
function getDefaultWidth(type: string): number {
switch (type) {
case 'number': return 100;
case 'date': return 120;
case 'datetime': return 160;
case 'checkbox': return 60;
case 'entity': return 180;
case 'textarea': return 200;
case 'code': return 120;
default: return 150;
}
}
@@ -0,0 +1,55 @@
'use client';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import type { FieldConfig } from '@/types/invyone-component';
interface CheckboxFieldProps {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
mode: 'form' | 'search';
disabled?: boolean;
error?: string;
}
export function CheckboxField({ field, value, onChange, mode, disabled, error }: CheckboxFieldProps) {
if (mode === 'search') {
// 검색: Select (전체/✓/✗)
return (
<Select
value={value === true ? 'true' : value === false ? 'false' : ''}
onValueChange={(v) => {
if (v === 'true') onChange(true);
else if (v === 'false') onChange(false);
else onChange(undefined);
}}
disabled={disabled}
>
<SelectTrigger className={`h-7 text-xs ${error ? 'border-destructive' : ''}`}>
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-xs"></SelectItem>
<SelectItem value="true" className="text-xs"></SelectItem>
<SelectItem value="false" className="text-xs"></SelectItem>
</SelectContent>
</Select>
);
}
// 폼: 체크박스
const checked = value === true || value === 'true' || value === 't';
return (
<div className="flex items-center gap-2 h-7">
<Checkbox
checked={checked}
onCheckedChange={(c) => onChange(c === true)}
disabled={disabled}
/>
<span className="text-xs text-[var(--v5-text-sec)]">{field.label}</span>
</div>
);
}
@@ -0,0 +1,45 @@
'use client';
import { Input } from '@/components/ui/input';
import { Lock } from 'lucide-react';
import type { FieldConfig } from '@/types/invyone-component';
interface CodeFieldProps {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
mode: 'form' | 'search';
disabled?: boolean;
error?: string;
}
export function CodeField({ field, value, onChange, mode, disabled, error }: CodeFieldProps) {
if (mode === 'search') {
// 검색: 완전 일치 텍스트 입력
return (
<Input
type="text"
value={value ?? ''}
onChange={(e) => onChange(e.target.value || undefined)}
placeholder={field.placeholder ?? field.label}
disabled={disabled}
className={`h-7 text-xs ${error ? 'border-destructive' : ''}`}
/>
);
}
// 폼: readonly 표시 (자동채번)
return (
<div className="flex items-center gap-1">
<Input
type="text"
value={value ?? ''}
readOnly
disabled
placeholder="자동채번"
className="h-7 text-xs flex-1 bg-[var(--v5-bg-subtle)] text-[var(--v5-text-muted)]"
/>
<Lock className="h-3 w-3 shrink-0 text-[var(--v5-text-muted)]" />
</div>
);
}
@@ -0,0 +1,50 @@
'use client';
import { Input } from '@/components/ui/input';
import type { FieldConfig } from '@/types/invyone-component';
interface DateFieldProps {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
mode: 'form' | 'search';
disabled?: boolean;
error?: string;
}
export function DateField({ field, value, onChange, mode, disabled, error }: DateFieldProps) {
if (mode === 'search') {
// 검색: DateRangePicker (시작~종료)
const from = value?.from ?? '';
const to = value?.to ?? '';
return (
<div className="flex items-center gap-1">
<Input
type="date"
value={from}
onChange={(e) => onChange({ ...value, from: e.target.value || undefined })}
disabled={disabled}
className={`h-7 text-xs flex-1 ${error ? 'border-destructive' : ''}`}
/>
<span className="text-xs text-[var(--v5-text-muted)]">~</span>
<Input
type="date"
value={to}
onChange={(e) => onChange({ ...value, to: e.target.value || undefined })}
disabled={disabled}
className={`h-7 text-xs flex-1 ${error ? 'border-destructive' : ''}`}
/>
</div>
);
}
return (
<Input
type="date"
value={value ?? ''}
onChange={(e) => onChange(e.target.value || null)}
disabled={disabled}
className={`h-7 text-xs ${error ? 'border-destructive' : ''}`}
/>
);
}
@@ -0,0 +1,49 @@
'use client';
import { Input } from '@/components/ui/input';
import type { FieldConfig } from '@/types/invyone-component';
interface DateTimeFieldProps {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
mode: 'form' | 'search';
disabled?: boolean;
error?: string;
}
export function DateTimeField({ field, value, onChange, mode, disabled, error }: DateTimeFieldProps) {
if (mode === 'search') {
const from = value?.from ?? '';
const to = value?.to ?? '';
return (
<div className="flex items-center gap-1">
<Input
type="datetime-local"
value={from}
onChange={(e) => onChange({ ...value, from: e.target.value || undefined })}
disabled={disabled}
className={`h-7 text-xs flex-1 ${error ? 'border-destructive' : ''}`}
/>
<span className="text-xs text-[var(--v5-text-muted)]">~</span>
<Input
type="datetime-local"
value={to}
onChange={(e) => onChange({ ...value, to: e.target.value || undefined })}
disabled={disabled}
className={`h-7 text-xs flex-1 ${error ? 'border-destructive' : ''}`}
/>
</div>
);
}
return (
<Input
type="datetime-local"
value={value ?? ''}
onChange={(e) => onChange(e.target.value || null)}
disabled={disabled}
className={`h-7 text-xs ${error ? 'border-destructive' : ''}`}
/>
);
}
@@ -0,0 +1,49 @@
'use client';
import { Input } from '@/components/ui/input';
import { Search } from 'lucide-react';
import type { FieldConfig } from '@/types/invyone-component';
interface EntityFieldProps {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
mode: 'form' | 'search';
disabled?: boolean;
error?: string;
}
/**
* entity 타입 필드 — 팝업 검색 + 입력
* Phase 2에서는 텍스트 입력 + 검색 버튼 UI만 구현.
* 실제 팝업 검색은 Phase 3+ 에서 ref 테이블 조회와 함께 구현.
*/
export function EntityField({ field, value, onChange, mode, disabled, error }: EntityFieldProps) {
const displayLabel = field.ref
? `${field.label} (${field.ref.table})`
: field.label;
return (
<div className="flex items-center gap-1">
<Input
type="text"
value={value ?? ''}
onChange={(e) => onChange(e.target.value || null)}
placeholder={field.placeholder ?? displayLabel}
disabled={disabled}
className={`h-7 text-xs flex-1 ${error ? 'border-destructive' : ''}`}
/>
<button
type="button"
disabled={disabled}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-input
bg-[var(--v5-surface)] text-[var(--v5-text-muted)] transition-colors
hover:border-[var(--v5-primary)] hover:text-[var(--v5-primary)]
disabled:opacity-50 disabled:cursor-not-allowed"
title={`${field.label} 검색`}
>
<Search className="h-3 w-3" />
</button>
</div>
);
}
@@ -0,0 +1,55 @@
'use client';
import type { FieldConfig } from '@/types/invyone-component';
import { TextField } from './TextField';
import { NumberField } from './NumberField';
import { DateField } from './DateField';
import { DateTimeField } from './DateTimeField';
import { SelectField } from './SelectField';
import { EntityField } from './EntityField';
import { CheckboxField } from './CheckboxField';
import { TextareaField } from './TextareaField';
import { FileField } from './FileField';
import { CodeField } from './CodeField';
export interface FieldRendererProps {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
mode: 'form' | 'search';
disabled?: boolean;
error?: string;
}
/**
* FieldConfig.type을 보고 적절한 입력 위젯을 렌더한다.
*
* mode='form': 폼 렌더링 규칙 (date→DatePicker, select→Select)
* mode='search': 검색 렌더링 규칙 (date→DateRangePicker, select→MultiSelect)
*/
export function FieldRenderer(props: FieldRendererProps) {
switch (props.field.type) {
case 'text':
return <TextField {...props} />;
case 'number':
return <NumberField {...props} />;
case 'date':
return <DateField {...props} />;
case 'datetime':
return <DateTimeField {...props} />;
case 'select':
return <SelectField {...props} />;
case 'entity':
return <EntityField {...props} />;
case 'checkbox':
return <CheckboxField {...props} />;
case 'textarea':
return <TextareaField {...props} />;
case 'file':
return <FileField {...props} />;
case 'code':
return <CodeField {...props} />;
default:
return <TextField {...props} />;
}
}
@@ -0,0 +1,52 @@
'use client';
import { Upload } from 'lucide-react';
import type { FieldConfig } from '@/types/invyone-component';
interface FileFieldProps {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
mode: 'form' | 'search';
disabled?: boolean;
error?: string;
}
/**
* file 타입: 검색에서는 렌더하지 않음.
* Phase 2에서는 파일 선택 UI만 제공, 실제 업로드는 Phase 3+.
*/
export function FileField({ field, value, onChange, mode, disabled, error }: FileFieldProps) {
if (mode === 'search') {
return null; // 검색 불가
}
const fileName = typeof value === 'string' ? value : value?.name ?? '';
return (
<div className="flex items-center gap-2">
<label
className={`flex h-7 cursor-pointer items-center gap-1.5 rounded-md border px-2 text-xs
transition-colors
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-[var(--v5-primary)] hover:text-[var(--v5-primary)]'}
${error ? 'border-destructive' : 'border-input'}
bg-[var(--v5-surface)] text-[var(--v5-text-sec)]`}
>
<Upload className="h-3 w-3" />
<span> </span>
<input
type="file"
className="hidden"
disabled={disabled}
onChange={(e) => {
const file = e.target.files?.[0];
onChange(file ?? null);
}}
/>
</label>
{fileName && (
<span className="truncate text-xs text-[var(--v5-text-sec)]">{fileName}</span>
)}
</div>
);
}
@@ -0,0 +1,53 @@
'use client';
import { Input } from '@/components/ui/input';
import type { FieldConfig } from '@/types/invyone-component';
interface NumberFieldProps {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
mode: 'form' | 'search';
disabled?: boolean;
error?: string;
}
export function NumberField({ field, value, onChange, mode, disabled, error }: NumberFieldProps) {
if (mode === 'search') {
// 검색: min~max 범위 입력 2개
const min = value?.min ?? '';
const max = value?.max ?? '';
return (
<div className="flex items-center gap-1">
<Input
type="number"
value={min}
onChange={(e) => onChange({ ...value, min: e.target.value === '' ? undefined : Number(e.target.value) })}
placeholder="최소"
disabled={disabled}
className={`h-7 text-xs flex-1 ${error ? 'border-destructive' : ''}`}
/>
<span className="text-xs text-[var(--v5-text-muted)]">~</span>
<Input
type="number"
value={max}
onChange={(e) => onChange({ ...value, max: e.target.value === '' ? undefined : Number(e.target.value) })}
placeholder="최대"
disabled={disabled}
className={`h-7 text-xs flex-1 ${error ? 'border-destructive' : ''}`}
/>
</div>
);
}
return (
<Input
type="number"
value={value ?? ''}
onChange={(e) => onChange(e.target.value === '' ? null : Number(e.target.value))}
placeholder={field.placeholder ?? field.label}
disabled={disabled}
className={`h-7 text-xs text-right ${error ? 'border-destructive' : ''}`}
/>
);
}
@@ -0,0 +1,111 @@
'use client';
import { useState } from 'react';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import type { FieldConfig, FieldOption } from '@/types/invyone-component';
interface SelectFieldProps {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
mode: 'form' | 'search';
disabled?: boolean;
error?: string;
}
/** FieldOption → { value, label } 정규화. string이면 value=label */
function normalizeOption(opt: FieldOption): { value: string; label: string } {
if (typeof opt === 'string') return { value: opt, label: opt };
return { value: opt.value, label: opt.label };
}
/** value로 label 찾기 */
function findLabel(options: FieldOption[], val: string): string {
for (const opt of options) {
const norm = normalizeOption(opt);
if (norm.value === val) return norm.label;
}
return val;
}
export function SelectField({ field, value, onChange, mode, disabled, error }: SelectFieldProps) {
const rawOptions = field.options ?? [];
const options = rawOptions.map(normalizeOption);
if (mode === 'search') {
// 검색: MultiSelect (다중, 체크박스) — ★ value를 저장/전송
const selected: string[] = Array.isArray(value) ? value : [];
const [open, setOpen] = useState(false);
const toggle = (optValue: string) => {
const next = selected.includes(optValue)
? selected.filter((v) => v !== optValue)
: [...selected, optValue];
onChange(next.length > 0 ? next : undefined);
};
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
disabled={disabled}
className={`flex h-7 w-full items-center justify-between rounded-md border bg-transparent px-2 text-xs
${error ? 'border-destructive' : 'border-input'}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
>
<span className="truncate text-left">
{selected.length > 0
? selected.map((v) => findLabel(rawOptions, v)).join(', ')
: field.label}
</span>
<svg className="h-3 w-3 opacity-50 shrink-0" viewBox="0 0 12 12" fill="none">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
{open && (
<div className="absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-md border bg-popover p-1 shadow-md">
{options.map((opt) => (
<label
key={opt.value}
className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-xs hover:bg-accent"
>
<Checkbox
checked={selected.includes(opt.value)}
onCheckedChange={() => toggle(opt.value)}
/>
<span>{opt.label}</span>
</label>
))}
{options.length === 0 && (
<div className="px-2 py-1 text-xs text-muted-foreground"> </div>
)}
</div>
)}
</div>
);
}
// 폼: 단일 선택 — ★ value를 저장, label을 표시
return (
<Select
value={value ?? ''}
onValueChange={(v) => onChange(v || null)}
disabled={disabled}
>
<SelectTrigger className={`h-7 text-xs ${error ? 'border-destructive' : ''}`}>
<SelectValue placeholder={field.placeholder ?? field.label} />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -0,0 +1,26 @@
'use client';
import { Input } from '@/components/ui/input';
import type { FieldConfig } from '@/types/invyone-component';
interface TextFieldProps {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
mode: 'form' | 'search';
disabled?: boolean;
error?: string;
}
export function TextField({ field, value, onChange, mode, disabled, error }: TextFieldProps) {
return (
<Input
type="text"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder ?? field.label}
disabled={disabled}
className={`h-7 text-xs ${error ? 'border-destructive' : ''}`}
/>
);
}
@@ -0,0 +1,44 @@
'use client';
import { Input } from '@/components/ui/input';
import type { FieldConfig } from '@/types/invyone-component';
interface TextareaFieldProps {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
mode: 'form' | 'search';
disabled?: boolean;
error?: string;
}
export function TextareaField({ field, value, onChange, mode, disabled, error }: TextareaFieldProps) {
if (mode === 'search') {
// 검색: 일반 텍스트 입력 (부분 일치)
return (
<Input
type="text"
value={value ?? ''}
onChange={(e) => onChange(e.target.value || undefined)}
placeholder={field.placeholder ?? field.label}
disabled={disabled}
className={`h-7 text-xs ${error ? 'border-destructive' : ''}`}
/>
);
}
return (
<textarea
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder ?? field.label}
disabled={disabled}
rows={3}
className={`flex w-full rounded-md border bg-transparent px-3 py-2 text-xs
placeholder:text-muted-foreground
focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]
disabled:cursor-not-allowed disabled:opacity-50 outline-none
${error ? 'border-destructive' : 'border-input'}`}
/>
);
}
+9
View File
@@ -0,0 +1,9 @@
// FieldConfig 기반 컴포넌트 공개 exports
export { FcTable } from './FcTable';
export { FcForm } from './FcForm';
export { FcSearch } from './FcSearch';
export { FcButton } from './FcButton';
export { FcButtonBar } from './FcButtonBar';
export { FcPagination } from './FcPagination';
export { FieldRenderer } from './fields/FieldRenderer';
export { CellRenderer } from './table/CellRenderer';
@@ -0,0 +1,119 @@
'use client';
import { Check, X, FileText } from 'lucide-react';
import type { FieldConfig, FieldOption } from '@/types/invyone-component';
interface CellRendererProps {
field: FieldConfig;
value: any;
}
/**
* FieldType에 따라 셀 내용을 포맷팅한다.
* 렌더링 계약(spec Section 2.2) 테이블 렌더 컬럼 기준.
*/
export function CellRenderer({ field, value }: CellRendererProps) {
if (value === null || value === undefined) {
return <span className="text-[var(--v5-text-muted)]">-</span>;
}
switch (field.type) {
case 'number':
return <span>{formatNumber(value, field.format)}</span>;
case 'date':
return <span>{formatDate(value)}</span>;
case 'datetime':
return <span>{formatDateTime(value)}</span>;
case 'checkbox':
return value === true || value === 'true' || value === 't'
? <Check className="h-3.5 w-3.5 text-[var(--v5-green)]" />
: <X className="h-3.5 w-3.5 text-[var(--v5-text-muted)]" />;
case 'textarea':
// 40자 말줄임
return <span title={String(value)}>
{String(value).length > 40 ? String(value).slice(0, 40) + '...' : String(value)}
</span>;
case 'file':
return (
<span className="inline-flex items-center gap-1 text-[var(--v5-primary)] cursor-pointer">
<FileText className="h-3 w-3" />
<span className="truncate max-w-[120px]">{String(value)}</span>
</span>
);
case 'entity':
// ref.displayColumn 값은 조인된 데이터에서 오므로 그대로 표시
return <span>{String(value)}</span>;
case 'select':
// ★ select: value→label 변환하여 표시
return <span>{resolveOptionLabel(field.options, value)}</span>;
default:
// text, code: 텍스트 그대로
return <span>{String(value)}</span>;
}
}
/** select value → label 변환. 못 찾으면 value 그대로 표시 */
function resolveOptionLabel(options: FieldOption[] | undefined, value: any): string {
if (!options || !value) return String(value ?? '');
const strVal = String(value);
for (const opt of options) {
if (typeof opt === 'string') {
if (opt === strVal) return opt;
} else {
if (opt.value === strVal) return opt.label;
}
}
return strVal;
}
function formatNumber(value: any, format?: string): string {
const num = Number(value);
if (isNaN(num)) return String(value);
// #,##0 포맷 — 천 단위 콤마
if (format === '#,##0' || !format) {
return num.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
}
// #,##0.00 등 소수점
const match = format.match(/\.(\d+)/);
if (match) {
const decimals = match[1].length;
return num.toLocaleString('ko-KR', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
}
return num.toLocaleString('ko-KR');
}
function formatDate(value: any): string {
if (!value) return '';
try {
const d = new Date(value);
if (isNaN(d.getTime())) return String(value);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
} catch {
return String(value);
}
}
function formatDateTime(value: any): string {
if (!value) return '';
try {
const d = new Date(value);
if (isNaN(d.getTime())) return String(value);
const date = formatDate(value);
const h = String(d.getHours()).padStart(2, '0');
const min = String(d.getMinutes()).padStart(2, '0');
return `${date} ${h}:${min}`;
} catch {
return String(value);
}
}
+9
View File
@@ -935,8 +935,17 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</aside>
{/* Content area */}
{/* ★ INVYONE 신규 페이지는 children 직접 렌더, 기존 VEX 페이지는 탭 시스템 */}
<main className="v5-content flex min-w-0 flex-1 flex-col overflow-hidden">
{pathname && (
pathname.startsWith('/dash') ||
pathname.startsWith('/admin/builder') ||
pathname.startsWith('/test-fc')
) ? (
<div className="relative min-h-0 flex-1 overflow-auto">{children}</div>
) : (
<TabContent />
)}
</main>
</div>
</div>
+39
View File
@@ -0,0 +1,39 @@
/**
* INVYONE 비즈니스 룰 API 클라이언트 (Phase 5 — 제어 모드)
* ★ 별도 인터페이스 정의 안 함 — 전부 Record<string, any>
*/
import { apiClient } from './client';
export async function getBusinessRuleList(dashboardId: string): Promise<Record<string, any>> {
const res = await apiClient.get(`/dashboards/${dashboardId}/rules`);
return res.data.data ?? { list: [], total_count: 0 };
}
export async function getBusinessRuleInfo(ruleId: string): Promise<Record<string, any> | null> {
const res = await apiClient.get(`/rules/${ruleId}`);
return res.data.data ?? null;
}
export async function insertBusinessRule(
dashboardId: string,
data: Record<string, any>
): Promise<Record<string, any>> {
const res = await apiClient.post(`/dashboards/${dashboardId}/rules`, data);
return res.data.data;
}
export async function updateBusinessRule(
ruleId: string,
data: Record<string, any>
): Promise<void> {
await apiClient.put(`/rules/${ruleId}`, data);
}
export async function deleteBusinessRule(ruleId: string): Promise<void> {
await apiClient.delete(`/rules/${ruleId}`);
}
export async function toggleBusinessRule(ruleId: string): Promise<void> {
await apiClient.put(`/rules/${ruleId}/toggle`);
}
+57
View File
@@ -0,0 +1,57 @@
import { apiClient } from './client';
// ═══ 대시보드 CRUD (Phase 4 — /api/dashboards) ═══
export async function getDashboardList(params?: Record<string, any>) {
const res = await apiClient.get('/dashboards', { params });
return res.data.data;
}
export async function getDashboardInfo(dashboardId: string) {
const res = await apiClient.get(`/dashboards/${dashboardId}`);
return res.data.success ? res.data.data : null;
}
export async function insertDashboard(data: Record<string, any>) {
const res = await apiClient.post('/dashboards', data);
return res.data.data;
}
export async function updateDashboard(dashboardId: string, data: Record<string, any>) {
await apiClient.put(`/dashboards/${dashboardId}`, data);
}
export async function deleteDashboard(dashboardId: string) {
await apiClient.delete(`/dashboards/${dashboardId}`);
}
// ═══ 카드 CRUD ═══
export async function getDashboardCards(dashboardId: string) {
const res = await apiClient.get(`/dashboards/${dashboardId}/cards`);
return res.data.data ?? [];
}
export async function insertDashboardCard(dashboardId: string, data: Record<string, any>) {
const res = await apiClient.post(`/dashboards/${dashboardId}/cards`, data);
return res.data.data;
}
export async function updateDashboardCard(dashboardId: string, cardId: string, data: Record<string, any>) {
await apiClient.put(`/dashboards/${dashboardId}/cards/${cardId}`, data);
}
export async function deleteDashboardCard(dashboardId: string, cardId: string) {
await apiClient.delete(`/dashboards/${dashboardId}/cards/${cardId}`);
}
export async function updateCardPositionsBatch(dashboardId: string, cards: Record<string, any>[]) {
await apiClient.put(`/dashboards/${dashboardId}/cards/batch`, { cards });
}
// ═══ 사이드바 메뉴 ═══
export async function getSidebarMenu() {
const res = await apiClient.get('/dashboards/sidebar/menu');
return res.data.data ?? [];
}
+43
View File
@@ -0,0 +1,43 @@
/**
* FieldConfig 기반 데이터 CRUD API 래퍼
* 기존 dataApi를 활용하면서 FieldConfig 컴포넌트에서 쓰기 편한 형태로 감쌈
*
* ★ 별도 인터페이스 정의 안 함 — Record<string, any>
*/
import { dataApi } from './data';
/** FieldConfig 기반 목록 조회 */
export async function fcList(params: Record<string, any>): Promise<Record<string, any>> {
const { tableName, page = 1, size = 20, sortBy, sortOrder, ...filters } = params;
return dataApi.getTableData(tableName, {
page,
size,
sortBy,
sortOrder,
filters,
});
}
/** FieldConfig 기반 단건 조회 */
export async function fcGet(tableName: string, id: string): Promise<Record<string, any>> {
const result = await dataApi.getRecordDetail(tableName, id);
return result.data ?? {};
}
/** FieldConfig 기반 등록 */
export async function fcInsert(tableName: string, data: Record<string, any>): Promise<any> {
return dataApi.createRecord(tableName, data);
}
/** FieldConfig 기반 수정 */
export async function fcUpdate(tableName: string, id: string, data: Record<string, any>): Promise<any> {
return dataApi.updateRecord(tableName, id, data);
}
/** FieldConfig 기반 삭제 */
export async function fcDelete(tableName: string, ids: string[]): Promise<any> {
// 단건씩 삭제 (bulk delete API가 있으면 교체)
const results = await Promise.all(ids.map((id) => dataApi.deleteRecord(tableName, id)));
return results;
}
+95
View File
@@ -0,0 +1,95 @@
/**
* INVYONE 메타 API 클라이언트
* DB 테이블 메타 → FieldConfig 변환 API
*/
import { apiClient } from './client';
import type { FieldConfig, FieldRef } from '@/types/invyone-component';
/**
* 접근 가능한 테이블 목록 조회
* ★ 일반 API는 Record<string, any> — 별도 인터페이스 정의 안 함
*/
export async function getMetaTableList(): Promise<Record<string, any>[]> {
const res = await apiClient.get('/meta/tables');
return res.data.data;
}
/**
* 특정 테이블의 FieldConfig[] 반환
* ★ fields만 FieldConfig[] 타입 (invyone-component.ts 규격 예외)
*/
export async function getMetaFields(tableName: string): Promise<{
table_name: string;
table_label: string;
primary_key: string | null;
fields: FieldConfig[];
[key: string]: any;
}> {
const res = await apiClient.get(`/meta/tables/${tableName}/fields`);
const data = res.data.data;
// 백엔드 snake_case → 프론트 camelCase 변환 (FieldConfig 규격 맞춤)
if (data?.fields) {
data.fields = data.fields.map(toFieldConfig);
}
return data;
}
/**
* 백엔드 응답 → FieldConfig 변환
* 대부분 키가 단일 단어라 변환 불필요, ref 내부만 camelCase 변환
*/
function toFieldConfig(raw: Record<string, any>): FieldConfig {
const field: FieldConfig = {
column: raw.column,
label: raw.label,
type: raw.type,
visible: raw.visible,
order: raw.order,
required: raw.required,
editable: raw.editable,
};
if (raw.width != null) field.width = raw.width;
if (raw.align != null) field.align = raw.align;
if (raw.defaultValue != null || raw.default_value != null) {
field.defaultValue = raw.defaultValue ?? raw.default_value;
}
if (raw.placeholder != null) field.placeholder = raw.placeholder;
if (raw.options != null) field.options = raw.options;
if (raw.format != null) field.format = raw.format;
if (raw.computed != null) field.computed = raw.computed;
if (raw.pk != null) field.pk = raw.pk;
if (raw.system != null) field.system = raw.system;
if (raw.searchable != null) field.searchable = raw.searchable;
if (raw.sortable != null) field.sortable = raw.sortable;
// ref: snake_case → camelCase 변환
if (raw.ref != null) {
field.ref = toFieldRef(raw.ref);
}
return field;
}
function toFieldRef(raw: Record<string, any>): FieldRef {
return {
table: raw.table,
valueColumn: raw.value_column ?? raw.valueColumn,
displayColumn: raw.display_column ?? raw.displayColumn,
...(raw.search_columns || raw.searchColumns
? { searchColumns: raw.search_columns ?? raw.searchColumns }
: {}),
};
}
/**
* 테이블 간 업무 관계 조회 (Phase 5 — 제어 모드)
* table_relationships 기반, 필드 참조(table_type_columns)와 별도
*/
export async function getMetaRelations(tableName: string): Promise<Record<string, any>[]> {
const res = await apiClient.get(`/meta/tables/${tableName}/relations`);
return res.data.data ?? [];
}
+14
View File
@@ -0,0 +1,14 @@
import { apiClient } from './client';
export async function getUserOverride(cardId: string) {
const res = await apiClient.get('/overrides', { params: { card_id: cardId } });
return res.data.data;
}
export async function upsertUserOverride(data: Record<string, any>) {
await apiClient.put('/overrides', data);
}
export async function deleteUserOverride(cardId: string) {
await apiClient.delete('/overrides', { params: { card_id: cardId } });
}
+39
View File
@@ -0,0 +1,39 @@
/**
* INVYONE Template CRUD API 클라이언트
*/
import { apiClient } from './client';
/** 템플릿 목록 조회 */
export async function getTemplateList(params?: Record<string, any>): Promise<Record<string, any>> {
const res = await apiClient.get('/templates', { params });
return res.data.data;
}
/** 템플릿 상세 조회 */
export async function getTemplateInfo(templateId: string): Promise<Record<string, any> | null> {
const res = await apiClient.get(`/templates/${templateId}`);
if (!res.data.success) return null;
return res.data.data;
}
/** 템플릿 생성 — template_id 반환 */
export async function insertTemplate(data: Record<string, any>): Promise<Record<string, any>> {
const res = await apiClient.post('/templates', data);
return res.data.data;
}
/** 템플릿 수정 */
export async function updateTemplate(templateId: string, data: Record<string, any>): Promise<void> {
await apiClient.put(`/templates/${templateId}`, data);
}
/** 템플릿 게시 (draft → published) */
export async function publishTemplate(templateId: string): Promise<void> {
await apiClient.put(`/templates/${templateId}/publish`);
}
/** 템플릿 삭제 (소프트) */
export async function deleteTemplate(templateId: string): Promise<void> {
await apiClient.delete(`/templates/${templateId}`);
}
+86
View File
@@ -0,0 +1,86 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface DashboardState {
dashboards: Record<string, any>[];
activeDashboardId: string | null;
cards: Record<string, any>[];
editMode: boolean;
loading: boolean;
setDashboards: (dashboards: Record<string, any>[]) => void;
setActiveDashboard: (id: string | null) => void;
setCards: (cards: Record<string, any>[]) => void;
addCard: (card: Record<string, any>) => void;
updateCard: (cardId: string, updates: Record<string, any>) => void;
removeCard: (cardId: string) => void;
toggleEditMode: () => void;
setEditMode: (on: boolean) => void;
setLoading: (loading: boolean) => void;
addDashboard: (dashboard: Record<string, any>) => void;
updateDashboardInList: (id: string, updates: Record<string, any>) => void;
removeDashboard: (id: string) => void;
}
export const useDashboardStore = create<DashboardState>()(
devtools(
(set) => ({
dashboards: [],
activeDashboardId: null,
cards: [],
editMode: false,
loading: false,
setDashboards: (dashboards) => set({ dashboards }),
setActiveDashboard: (id) => set({ activeDashboardId: id, editMode: false }),
setCards: (cards) => set({ cards }),
addCard: (card) => set((s) => ({ cards: [...s.cards, card] })),
updateCard: (cardId, updates) =>
set((s) => ({
cards: s.cards.map((c) =>
(c.card_id ?? c.CARD_ID) === cardId ? { ...c, ...updates } : c
),
})),
removeCard: (cardId) =>
set((s) => ({
cards: s.cards.filter((c) => (c.card_id ?? c.CARD_ID) !== cardId),
})),
toggleEditMode: () => set((s) => ({ editMode: !s.editMode })),
setEditMode: (on) => set({ editMode: on }),
setLoading: (loading) => set({ loading }),
addDashboard: (dashboard) =>
set((s) => ({ dashboards: [...s.dashboards, dashboard] })),
updateDashboardInList: (id, updates) =>
set((s) => ({
dashboards: s.dashboards.map((d) =>
(d.dashboard_id ?? d.DASHBOARD_ID) === id ? { ...d, ...updates } : d
),
})),
removeDashboard: (id) =>
set((s) => {
const filtered = s.dashboards.filter(
(d) => (d.dashboard_id ?? d.DASHBOARD_ID) !== id
);
const isActive = s.activeDashboardId === id;
return {
dashboards: filtered,
activeDashboardId: isActive
? (filtered[0]?.dashboard_id ?? filtered[0]?.DASHBOARD_ID ?? null)
: s.activeDashboardId,
};
}),
}),
{ name: 'dashboard-store' }
)
);
+390
View File
@@ -0,0 +1,390 @@
/* ═══════════════════════════════════════════════════════════════════════════
Phase 5 — 제어 모드 + 규칙 빌더
mockup css/07-control-mode.css + css/08-rule-builder.css → React 포팅
★ --v5-* / --ctrl-* 변수 사용, 즉흥 hex 금지
═══════════════════════════════════════════════════════════════════════════ */
/* ── 제어 모드 변수 ── */
:root {
--ctrl-cyan: #00cec9;
--ctrl-cyan-glow: rgba(0, 206, 201, .3);
--ctrl-primary: #6c5ce7;
--ctrl-amber: #fdcb6e;
--ctrl-pink: #fd79a8;
--ctrl-green: #55efc4;
--ctrl-red: #ff4757;
--ctrl-glass: rgba(255, 255, 255, .06);
--ctrl-glass-strong: rgba(255, 255, 255, .08);
--ctrl-glass-border: rgba(0, 206, 201, .25);
}
.dark {
--ctrl-glass: rgba(255, 255, 255, .04);
--ctrl-glass-strong: rgba(255, 255, 255, .06);
}
/* ═══ 제어 모드 캔버스 배경 ═══ */
.dash-canvas.control-mode {
background-image: radial-gradient(circle at 0.5px 0.5px, rgba(0,206,201,.22) 0.5px, transparent 0);
background-size: 24px 24px;
overflow: auto;
}
.dark .dash-canvas.control-mode {
background-image: radial-gradient(circle at 0.5px 0.5px, rgba(85,239,196,.18) 0.5px, transparent 0);
}
/* 카드 축소 + 클릭 가능 */
.dash-canvas.control-mode .dash-card {
transition: all .5s cubic-bezier(.16,1,.3,1);
opacity: .5; z-index: 25; cursor: pointer;
}
.dash-canvas.control-mode .dash-card:hover { opacity: .8; box-shadow: 0 0 20px var(--ctrl-cyan-glow); }
.dash-canvas.control-mode .dash-card.flow-active {
opacity: 1; border-color: var(--ctrl-cyan);
box-shadow: 0 0 30px rgba(0,206,201,.3);
}
/* ── 제어 모드 토글 버튼 활성 ── */
.dash-btn.control-on {
background: linear-gradient(135deg, var(--ctrl-cyan), #55efc4) !important;
color: #06050e !important; border-color: transparent !important;
box-shadow: 0 0 20px rgba(0,206,201,.3) !important; font-weight: 700;
}
.dash-btn.control-on:hover { box-shadow: 0 0 30px rgba(0,206,201,.45) !important; }
/* ═══ SVG 오버레이 ═══ */
.ctrl-svg {
position: absolute; inset: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 10; overflow: visible;
}
/* ── 연결선 4종 ── */
.ctrl-line { fill: none; stroke: var(--ctrl-cyan); stroke-width: 1.5; opacity: .55;
stroke-dasharray: 6 3; animation: ctrlPulse 1.5s linear infinite; }
.ctrl-line-auto { fill: none; stroke: var(--ctrl-primary); stroke-width: 2.5; opacity: .6;
stroke-dasharray: 6 4; animation: ctrlPulse 1.2s linear infinite; }
.ctrl-line-cond { fill: none; stroke: var(--ctrl-amber); stroke-width: 2; opacity: .55;
stroke-dasharray: 4 4; animation: ctrlPulse 1.8s linear infinite; }
.ctrl-line-tpl { fill: none; stroke: var(--ctrl-pink); stroke-width: 2.5; opacity: .65;
stroke-dasharray: 5 5; animation: ctrlPulse 1.4s linear infinite; }
@keyframes ctrlPulse { to { stroke-dashoffset: -18; } }
/* ★ 라이트 모드 보정 */
html:not(.dark) .ctrl-line { stroke: #00a89e; stroke-width: 2; opacity: .5; }
html:not(.dark) .ctrl-line-auto { stroke: #5b4acf; stroke-width: 3; opacity: .6; }
html:not(.dark) .ctrl-line-cond { stroke: #d4a017; stroke-width: 2.5; opacity: .55; }
html:not(.dark) .ctrl-line-tpl { stroke: #e0559e; stroke-width: 3; opacity: .6; }
/* ═══ 연결선 위 뱃지 ═══ */
.ctrl-badge {
position: absolute; padding: .2rem .6rem; border-radius: 9px;
background: var(--ctrl-glass-strong);
backdrop-filter: blur(16px) saturate(1.4);
-webkit-backdrop-filter: blur(16px) saturate(1.4);
border: 1px solid rgba(0,206,201,.3);
font-size: .55rem; font-weight: 700; color: var(--ctrl-cyan);
white-space: nowrap; z-index: 15; cursor: pointer;
transition: all .25s; box-shadow: 0 4px 16px rgba(0,206,201,.12);
pointer-events: auto; transform: translate(-50%, -50%);
}
.ctrl-badge:hover {
border-color: var(--ctrl-cyan);
box-shadow: 0 8px 24px rgba(0,206,201,.3); transform: translate(-50%,-50%) scale(1.05);
}
.ctrl-badge.auto { border-color: rgba(108,92,231,.35); color: var(--ctrl-primary);
box-shadow: 0 4px 16px rgba(108,92,231,.12); }
.ctrl-badge.tpl-link { border-color: rgba(253,121,168,.35); color: var(--ctrl-pink);
box-shadow: 0 4px 16px rgba(253,121,168,.12); font-size: .5rem; }
/* 조건분기 뱃지 */
.ctrl-badge.cond {
border-color: rgba(253,203,110,.4); color: var(--v5-text, #e8e8ee);
box-shadow: 0 6px 20px rgba(253,203,110,.15); padding: .45rem .7rem;
min-width: 120px; text-align: left; white-space: normal; line-height: 1.4;
border-radius: 10px; border-width: 2px;
}
.ctrl-badge.cond .cb-head {
display: flex; align-items: center; gap: .3rem;
font-size: .5rem; font-weight: 700; color: var(--ctrl-amber); margin-bottom: .25rem;
}
.ctrl-badge.cond .cb-icon {
width: 16px; height: 16px; border-radius: 4px;
display: flex; align-items: center; justify-content: center; font-size: .6rem;
background: rgba(253,203,110,.15); border: 1px solid rgba(253,203,110,.3);
}
.ctrl-badge.cond .cb-cond {
font-size: .6rem; font-weight: 600; padding: .2rem .4rem;
border-radius: 6px; background: rgba(253,203,110,.08);
border: 1px dashed rgba(253,203,110,.25); margin-bottom: .25rem;
}
.ctrl-badge.cond .cb-paths { display: flex; gap: .5rem; font-size: .48rem; font-weight: 700; }
.ctrl-badge.cond .cb-yes { color: var(--ctrl-green); }
.ctrl-badge.cond .cb-yes::before { content: '●'; margin-right: .15rem; font-size: .4rem; }
.ctrl-badge.cond .cb-no { color: var(--v5-text-muted, #888); }
.ctrl-badge.cond .cb-no::before { content: '○'; margin-right: .15rem; font-size: .4rem; }
html:not(.dark) .ctrl-badge { background: rgba(255,255,255,.92);
border-color: rgba(0,0,0,.15); box-shadow: 0 2px 12px rgba(0,0,0,.1); }
/* ═══ 테이블 노드 ═══ */
.tbl-node {
position: absolute; width: 200px;
background: var(--ctrl-glass-strong);
backdrop-filter: blur(20px) saturate(1.4);
-webkit-backdrop-filter: blur(20px) saturate(1.4);
border: 1px solid var(--ctrl-glass-border); border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,.08), 0 0 20px rgba(0,206,201,.08);
z-index: 20; overflow: hidden; transition: border-color .2s, box-shadow .2s;
}
.dark .tbl-node { box-shadow: 0 8px 24px rgba(0,0,0,.5), 0 0 20px rgba(85,239,196,.06); }
.tbl-node:hover {
border-color: var(--ctrl-cyan);
box-shadow: 0 12px 32px rgba(0,0,0,.12), 0 0 30px rgba(0,206,201,.18);
}
.tbl-node-head {
display: flex; align-items: center; gap: .4rem; padding: .55rem .7rem;
background: linear-gradient(135deg, rgba(0,206,201,.12), rgba(0,206,201,.04));
border-bottom: 1px solid rgba(0,206,201,.15); cursor: grab;
}
.tbl-node-head:active { cursor: grabbing; }
.tbl-icon {
width: 20px; height: 20px; border-radius: 5px;
display: flex; align-items: center; justify-content: center;
font-size: .7rem; background: rgba(0,206,201,.12); flex-shrink: 0;
}
.tbl-name { flex: 1; font-size: .65rem; font-weight: 700; color: var(--v5-text, #e8e8ee); letter-spacing: -.01em; }
.tbl-badge {
font-size: .45rem; padding: .1rem .35rem; border-radius: 999px;
background: rgba(0,206,201,.1); color: var(--ctrl-cyan); font-weight: 700;
}
.tbl-node-cols { padding: .35rem 0; max-height: 160px; overflow-y: auto; }
.tbl-col {
display: flex; align-items: center; gap: .35rem; padding: .22rem .65rem;
font-size: .58rem; color: var(--v5-text-sec, #aaa); transition: background .1s;
}
.tbl-col:hover { background: var(--v5-surface-hover, rgba(255,255,255,.05)); color: var(--v5-text, #eee); }
.tbl-port {
width: 8px; height: 8px; border-radius: 50%; background: var(--v5-surface, #2a2a36);
border: 2px solid rgba(0,206,201,.35); flex-shrink: 0; cursor: crosshair; transition: all .2s;
}
.tbl-port:hover {
background: var(--ctrl-cyan); border-color: var(--ctrl-cyan);
box-shadow: 0 0 8px rgba(0,206,201,.5); transform: scale(1.3);
}
.tbl-port.pk { border-color: var(--ctrl-primary); background: rgba(108,92,231,.15); }
.tbl-port.fk { border-color: var(--ctrl-amber); background: rgba(253,203,110,.15); }
.tbl-col-name { flex: 1; font-weight: 500; }
.tbl-col-type { font-size: .45rem; color: var(--v5-text-muted, #777); font-weight: 600; margin-left: auto; }
.tbl-col-mark { font-size: .42rem; font-weight: 700; padding: .05rem .25rem; border-radius: 4px; margin-left: .2rem; }
.tbl-col-mark.pk { color: var(--ctrl-primary); background: rgba(108,92,231,.1); }
.tbl-col-mark.fk { color: var(--ctrl-amber); background: rgba(253,203,110,.1); }
/* ═══ 제어 노드 (액션/조건/타이머) ═══ */
.ctrl-action-node {
position: absolute; width: 160px;
background: var(--ctrl-glass-strong);
backdrop-filter: blur(20px) saturate(1.4);
-webkit-backdrop-filter: blur(20px) saturate(1.4);
border: 1px solid rgba(var(--na-rgb, 0,206,201), .25); border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,.08), 0 0 16px rgba(var(--na-rgb, 0,206,201), .08);
z-index: 20; overflow: visible; transition: border-color .2s, box-shadow .2s;
}
.ctrl-action-node:hover {
border-color: rgba(var(--na-rgb, 0,206,201), .5);
box-shadow: 0 12px 32px rgba(0,0,0,.12), 0 0 24px rgba(var(--na-rgb, 0,206,201), .18);
}
.ctrl-an-head {
display: flex; align-items: center; gap: .4rem; padding: .5rem .65rem;
background: linear-gradient(135deg, rgba(var(--na-rgb, 0,206,201), .1), rgba(var(--na-rgb, 0,206,201), .03));
border-bottom: 1px solid rgba(var(--na-rgb, 0,206,201), .12);
border-radius: 12px 12px 0 0; cursor: grab;
}
.ctrl-an-head:active { cursor: grabbing; }
.ctrl-an-icon {
width: 22px; height: 22px; border-radius: 6px;
display: flex; align-items: center; justify-content: center; font-size: .8rem;
background: linear-gradient(135deg, rgba(var(--na-rgb, 0,206,201), .18), rgba(var(--na-rgb, 0,206,201), .06));
border: 1px solid rgba(var(--na-rgb, 0,206,201), .25);
}
.ctrl-an-name { flex: 1; font-size: .62rem; font-weight: 700; color: var(--v5-text, #e8e8ee); }
.ctrl-an-del {
width: 18px; height: 18px; border-radius: 5px;
border: 1px solid transparent; background: transparent;
color: var(--v5-text-muted, #888); font-size: .55rem; cursor: pointer;
display: flex; align-items: center; justify-content: center; transition: all .15s;
}
.ctrl-an-del:hover {
background: rgba(255,71,87,.1); border-color: rgba(255,71,87,.3); color: var(--ctrl-red);
}
.ctrl-an-body {
padding: .5rem .65rem; cursor: pointer;
border-radius: 0 0 12px 12px; transition: background .15s;
}
.ctrl-an-body:hover { background: rgba(var(--na-rgb, 0,206,201), .06); }
.ctrl-an-summary { font-size: .55rem; color: var(--v5-text-sec, #aaa); line-height: 1.4; }
/* ═══ I/O 포트 ═══ */
.ctrl-io-port {
position: absolute; width: 10px; height: 10px; border-radius: 50%;
border: 2px solid; cursor: crosshair; transition: all .2s; z-index: 25;
}
.ctrl-io-port.port-in {
left: -6px; top: 50%; transform: translateY(-50%);
border-color: var(--ctrl-cyan); background: var(--v5-surface, #2a2a36);
}
.ctrl-io-port.port-in.tbl-io { top: 18px; transform: none; }
.ctrl-io-port.port-out { border-color: var(--ctrl-cyan); background: var(--ctrl-cyan); }
.ctrl-io-port.port-out.tbl-io { position: absolute; right: -6px; top: 18px; }
.ctrl-io-port.port-yes { border-color: var(--ctrl-green); background: var(--ctrl-green); }
.ctrl-io-port.port-no { border-color: var(--v5-text-muted, #888); background: var(--v5-text-muted, #888); opacity: .6; }
.ctrl-an-ports-out {
position: absolute; right: -6px; top: 50%; transform: translateY(-50%);
display: flex; flex-direction: column; gap: 8px;
}
.ctrl-an-ports-out .ctrl-io-port { position: relative; right: auto; top: auto; transform: none; }
.port-label {
position: absolute; right: 14px; top: 50%; transform: translateY(-50%);
font-size: .42rem; font-weight: 700; color: var(--v5-text-muted, #888);
pointer-events: none; white-space: nowrap;
}
.ctrl-io-port:hover { box-shadow: 0 0 10px rgba(0,206,201,.5); transform: scale(1.3); }
.ctrl-io-port.port-in:hover { background: var(--ctrl-cyan); transform: translateY(-50%) scale(1.3); }
.ctrl-io-port.port-in.tbl-io:hover { transform: scale(1.3); }
.ctrl-io-port.port-hover {
background: var(--ctrl-cyan) !important;
box-shadow: 0 0 14px rgba(0,206,201,.6) !important;
transform: translateY(-50%) scale(1.5) !important;
}
.ctrl-io-port.port-hover.tbl-io { transform: scale(1.5) !important; }
/* 드래그 중 모든 input 포트 pulse */
.dash-canvas.port-dragging .ctrl-io-port.port-in { animation: portPulse 1.2s ease infinite; }
@keyframes portPulse {
0%, 100% { box-shadow: 0 0 4px rgba(0,206,201,.3); }
50% { box-shadow: 0 0 14px rgba(0,206,201,.6); }
}
/* ═══ 규칙 연결선 ═══ */
.rule-temp-line { fill: none; stroke: var(--ctrl-cyan); stroke-width: 2;
stroke-dasharray: 6 3; opacity: .7; pointer-events: none; }
.rule-conn-path { fill: none; stroke: var(--ctrl-cyan); stroke-width: 2; opacity: .6;
stroke-dasharray: 6 3; animation: ctrlPulse 1.5s linear infinite; }
.rule-conn-path.conn-yes { stroke: var(--ctrl-green); }
.rule-conn-path.conn-no { stroke: var(--v5-text-muted, #888); opacity: .35; }
/* 연결 삭제 버튼 */
.rule-conn-badge {
position: absolute; transform: translate(-50%, -50%);
z-index: 15; pointer-events: auto; padding: 6px;
}
.conn-x {
display: flex; align-items: center; justify-content: center;
width: 20px; height: 20px; border-radius: 50%;
background: var(--ctrl-glass-strong);
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border: 1.5px solid rgba(255,71,87,.15);
color: var(--v5-text-muted, #888); font-size: .55rem; font-weight: 700; cursor: pointer;
opacity: 0; transition: all .2s; transform: scale(.6);
}
.rule-conn-badge:hover .conn-x {
opacity: 1; transform: scale(1);
background: rgba(255,71,87,.15); border-color: rgba(255,71,87,.5); color: var(--ctrl-red);
box-shadow: 0 2px 12px rgba(255,71,87,.2);
}
/* ═══ 설정 팝오버 ═══ */
.ctrl-cfg-pop {
position: absolute; width: 220px;
background: var(--ctrl-glass-strong);
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
border: 1px solid rgba(108,92,231,.3); border-radius: 12px;
box-shadow: 0 12px 40px rgba(0,0,0,.15), 0 0 24px rgba(108,92,231,.1);
z-index: 50; padding: .7rem;
opacity: 0; transform: translateX(-8px); transition: opacity .2s, transform .2s;
}
.ctrl-cfg-pop.open { opacity: 1; transform: translateX(0); }
.cfg-hd { font-size: .65rem; font-weight: 700; color: var(--v5-text, #eee);
padding-bottom: .5rem; border-bottom: 1px solid var(--v5-border, #3a3a48); margin-bottom: .5rem; }
.cfg-sec { margin-bottom: .5rem; }
.cfg-lb { display: block; font-size: .48rem; font-weight: 700; color: var(--v5-text-muted, #888);
text-transform: uppercase; letter-spacing: .05em; margin-bottom: .15rem; }
.cfg-sel, .cfg-inp, .cfg-ta {
width: 100%; padding: .3rem .45rem; border-radius: 7px;
border: 1px solid var(--v5-border, #3a3a48); background: var(--v5-surface, #2a2a36);
color: var(--v5-text, #eee); font-size: .55rem; font-family: inherit;
outline: none; transition: border-color .15s; box-sizing: border-box;
}
.cfg-sel:focus, .cfg-inp:focus, .cfg-ta:focus { border-color: var(--ctrl-primary); }
.cfg-add-btn {
width: 100%; padding: .25rem; border-radius: 6px;
border: 1px dashed var(--v5-border, #3a3a48); background: transparent;
color: var(--v5-text-muted, #888); font-size: .5rem; cursor: pointer; margin-top: .3rem;
}
.cfg-add-btn:hover { border-color: var(--ctrl-cyan); color: var(--ctrl-cyan); }
.cfg-ft {
display: flex; gap: .3rem; padding-top: .5rem;
border-top: 1px solid var(--v5-border, #3a3a48); margin-top: .5rem;
}
.cfg-btn {
flex: 1; padding: .3rem; border-radius: 7px;
border: 1px solid var(--v5-border, #3a3a48); background: var(--v5-surface, #2a2a36);
color: var(--v5-text, #eee); font-size: .55rem; font-weight: 600;
cursor: pointer; transition: all .15s;
}
.cfg-btn.save { background: var(--ctrl-primary); border-color: var(--ctrl-primary); color: #fff; }
.cfg-btn:hover { opacity: .85; }
/* ═══ 팔레트 ═══ */
.ctrl-palette-section {
font-size: .52rem; font-weight: 700; color: var(--ctrl-cyan);
text-transform: uppercase; letter-spacing: .08em; padding: .7rem .65rem .3rem;
}
.ctrl-palette-item {
display: flex; align-items: center; gap: .5rem; padding: .45rem .65rem;
border-radius: 8px; font-size: .68rem; font-weight: 500;
color: var(--v5-text-sec, #aaa); cursor: grab; transition: all .2s;
}
.ctrl-palette-item:hover {
background: rgba(0,206,201,.08); color: var(--v5-text, #eee); transform: translateX(2px);
}
.ctrl-palette-item .cp-icon { font-size: .8rem; width: 20px; text-align: center; }
.ctrl-palette-item[draggable="true"] { cursor: grab; }
.ctrl-palette-item[draggable="true"]:active { cursor: grabbing; }
/* ═══ 제어 모드 툴바 ═══ */
.ctrl-toolbar {
display: flex; align-items: center; gap: .5rem; padding: .3rem .5rem;
background: var(--ctrl-glass); border-bottom: 1px solid var(--ctrl-glass-border);
font-size: .6rem;
}
.ctrl-toolbar-mode {
display: flex; gap: .25rem;
}
.ctrl-mode-btn {
padding: .25rem .6rem; border-radius: 6px; border: 1px solid var(--v5-border, #3a3a48);
background: transparent; color: var(--v5-text-sec, #aaa); font-size: .55rem;
font-weight: 600; cursor: pointer; transition: all .2s;
}
.ctrl-mode-btn.on {
background: rgba(0,206,201,.12); border-color: var(--ctrl-cyan);
color: var(--ctrl-cyan); font-weight: 700;
}
.ctrl-mode-btn:hover:not(.on) { background: var(--v5-surface-hover, rgba(255,255,255,.04)); }
html:not(.dark) .ctrl-action-node {
box-shadow: 0 4px 16px rgba(0,0,0,.06), 0 0 12px rgba(var(--na-rgb, 0,206,201), .06);
}
html:not(.dark) .rule-conn-path { stroke-width: 2.5; opacity: .5; }
html:not(.dark) .ctrl-cfg-pop {
box-shadow: 0 8px 32px rgba(0,0,0,.1); border-color: rgba(108,92,231,.2);
}
+377
View File
@@ -0,0 +1,377 @@
/* ═══════════════════════════════════════════════════════════════════════════
Phase 4 — Dashboard (= Menu) v5 Cosmic Glassmorphism
mockup css/01~06 기반, --v5-* 변수 사용
═══════════════════════════════════════════════════════════════════════════ */
/* ── 대시보드 셸 ── */
.dash-shell {
display: flex;
flex: 1;
overflow: hidden;
height: 100%;
}
/* ── 사이드바 ── */
.dash-side {
width: 220px;
background: var(--v5-glass);
backdrop-filter: blur(20px) saturate(1.3);
-webkit-backdrop-filter: blur(20px) saturate(1.3);
border-right: 1px solid var(--v5-glass-border);
padding: .85rem .6rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1px;
flex-shrink: 0;
transition: width .35s cubic-bezier(.4,0,.2,1), padding .35s;
}
.dash-side-sec {
font-size: .55rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .12em; color: var(--v5-text-muted);
padding: .5rem .65rem .35rem;
}
.dash-si {
padding: .5rem .7rem; border-radius: 10px; font-size: .77rem;
color: var(--v5-text-sec); cursor: pointer;
transition: all .25s cubic-bezier(.4,0,.2,1);
font-weight: 450; display: flex; align-items: center; gap: .6rem;
position: relative; overflow: hidden;
}
.dash-si .ic { width: 16px; height: 16px; display: flex; align-items: center;
justify-content: center; opacity: .65; flex-shrink: 0; font-size: .85rem; }
.dash-si:hover { background: var(--v5-surface-hover); color: var(--v5-text);
transform: translateX(2px); }
.dash-si.on {
background: linear-gradient(135deg, rgba(108,92,231,.12), rgba(108,92,231,.05));
color: var(--v5-primary); font-weight: 600;
border: 1px solid rgba(108,92,231,.15); box-shadow: var(--v5-glow-sm);
}
.dark .dash-si.on {
background: linear-gradient(135deg, rgba(162,155,254,.14), rgba(162,155,254,.05));
border-color: rgba(162,155,254,.15);
}
.dash-si::before { content: ''; position: absolute; left: 0; top: 0; width: 3px;
height: 100%; background: var(--v5-primary); border-radius: 0 2px 2px 0;
transform: scaleY(0); transition: transform .2s cubic-bezier(.4,0,.2,1); }
.dash-si.on::before { transform: scaleY(1); }
/* 사이드바 호버 액션 (rename/delete) */
.dash-si-actions { margin-left: auto; display: flex; gap: .15rem;
opacity: 0; transition: opacity .15s; }
.dash-si:hover .dash-si-actions { opacity: 1; }
.dash-si-act { width: 18px; height: 18px; border-radius: 5px; border: none;
background: transparent; color: var(--v5-text-muted); cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all .15s; font-size: .55rem; }
.dash-si-act:hover { background: var(--v5-surface-hover); color: var(--v5-primary); }
.dash-si-act.danger:hover { background: rgba(255,71,87,.12); color: var(--v5-red); }
/* 새 대시보드 추가 버튼 */
.dash-add-btn {
display: flex; align-items: center; gap: .5rem; padding: .45rem .7rem;
border-radius: 10px; border: 1px dashed var(--v5-glass-border);
background: transparent; color: var(--v5-text-muted); cursor: pointer;
font-size: .7rem; font-weight: 600; font-family: inherit;
transition: all .2s; margin: .3rem 0;
}
.dash-add-btn:hover { border-color: var(--v5-primary); color: var(--v5-primary);
background: rgba(108,92,231,.04); }
/* ── 콘텐츠 영역 ── */
.dash-content { flex: 1; overflow: auto; display: flex; flex-direction: column; }
/* ── 캔버스 툴바 ── */
.dash-toolbar {
display: flex; align-items: center; justify-content: space-between;
padding: .75rem 1.25rem; background: var(--v5-glass);
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--v5-glass-border); flex-shrink: 0;
}
.dash-toolbar-l { display: flex; align-items: center; gap: .75rem; }
.dash-cv-title { font-size: .95rem; font-weight: 700; color: var(--v5-text);
letter-spacing: -.01em; }
.dash-cv-meta {
font-size: .6rem; color: var(--v5-text-muted); padding: .2rem .55rem;
border-radius: 999px; background: var(--v5-surface);
border: 1px solid var(--v5-glass-border);
}
.dash-toolbar-r { display: flex; align-items: center; gap: .5rem; }
.dash-btn {
display: flex; align-items: center; gap: .4rem; padding: .42rem .8rem;
border-radius: 10px; border: 1px solid var(--v5-glass-border);
background: var(--v5-surface); color: var(--v5-text-sec);
font-size: .68rem; font-weight: 600; cursor: pointer; font-family: inherit;
transition: all .2s;
}
.dash-btn:hover { border-color: var(--v5-primary); color: var(--v5-primary);
box-shadow: var(--v5-glow-sm); }
.dash-btn.primary {
background: linear-gradient(135deg, var(--v5-primary), var(--v5-primary-light));
color: white; border-color: transparent; box-shadow: var(--v5-glow-sm);
}
.dash-btn.primary:hover { transform: translateY(-1px); box-shadow: var(--v5-glow-md); }
.dash-btn.on {
background: linear-gradient(135deg, var(--v5-primary), var(--v5-primary-light));
color: white; border-color: transparent; box-shadow: var(--v5-glow-sm);
}
/* ── 캔버스 ── */
.dash-canvas {
flex: 1; position: relative; padding: 0; min-height: 600px; overflow: hidden;
background-image: radial-gradient(circle at 0.5px 0.5px, var(--v5-glass-border) 0.5px, transparent 0);
background-size: 20px 20px;
}
.dash-canvas.edit-mode {
background-image: radial-gradient(circle at 0.5px 0.5px, rgba(108,92,231,.25) 0.5px, transparent 0);
outline: 1px dashed rgba(108,92,231,.18); outline-offset: -8px;
}
.dark .dash-canvas.edit-mode {
background-image: radial-gradient(circle at 0.5px 0.5px, rgba(162,155,254,.3) 0.5px, transparent 0);
}
/* ── 카드 ── */
.dash-card {
position: absolute; background: var(--v5-glass-strong, rgba(255,255,255,.65));
backdrop-filter: blur(20px) saturate(1.4);
-webkit-backdrop-filter: blur(20px) saturate(1.4);
border: 1px solid var(--v5-glass-border); border-radius: 16px;
box-shadow: 0 8px 32px rgba(0,0,0,0.06), var(--v5-glow-sm);
display: flex; flex-direction: column; overflow: hidden;
transition: box-shadow .25s, border-color .25s;
}
.dark .dash-card { box-shadow: 0 8px 32px rgba(0,0,0,0.4), var(--v5-glow-sm); }
.dash-card:hover { border-color: rgba(108,92,231,.25);
box-shadow: 0 12px 40px rgba(0,0,0,0.08), var(--v5-glow-md); }
.dash-canvas.edit-mode .dash-card { cursor: move;
border-style: solid; border-color: rgba(108,92,231,.3); }
.dash-card.dragging {
box-shadow: 0 24px 60px rgba(108,92,231,.35), var(--v5-glow-lg, var(--v5-glow-md));
border-color: var(--v5-primary); z-index: 50;
}
.dash-card.resizing {
box-shadow: 0 24px 60px rgba(0,206,201,.3), var(--v5-glow-lg, var(--v5-glow-md));
border-color: var(--v5-cyan); z-index: 50;
}
/* 카드 헤더 */
.dash-card-head {
display: flex; align-items: center; justify-content: space-between;
padding: .65rem .9rem;
border-bottom: 1px solid var(--v5-border-subtle); flex-shrink: 0;
background: var(--v5-glass);
}
.dash-card-head-l { display: flex; align-items: center; gap: .55rem; }
.dash-card-icon {
width: 24px; height: 24px; border-radius: 7px;
display: flex; align-items: center; justify-content: center; font-size: .85rem;
background: linear-gradient(135deg, rgba(108,92,231,.15), rgba(0,206,201,.1));
border: 1px solid rgba(108,92,231,.18);
}
.dash-card-title { font-size: .78rem; font-weight: 700; color: var(--v5-text);
letter-spacing: -.01em; }
.dash-card-bdg {
font-size: .5rem; font-weight: 700; color: var(--v5-primary);
padding: .1rem .4rem; border-radius: 999px;
background: rgba(108,92,231,.08); border: 1px solid rgba(108,92,231,.18);
text-transform: uppercase; letter-spacing: .05em;
}
.dash-card-head-r { display: flex; align-items: center; gap: .3rem; }
.dash-card-btn {
width: 22px; height: 22px; border-radius: 6px; border: none;
background: transparent; color: var(--v5-text-muted); cursor: pointer;
display: flex; align-items: center; justify-content: center; transition: all .15s;
}
.dash-card-btn:hover { background: var(--v5-surface-hover); color: var(--v5-primary); }
.dash-card-btn.danger:hover { background: rgba(255,71,87,.12); color: var(--v5-red); }
/* 카드 본문 */
.dash-card-body { flex: 1; overflow: auto; padding: .5rem; }
/* 리사이즈 핸들 */
.dash-resize-handle {
position: absolute; right: 0; bottom: 0; width: 18px; height: 18px;
cursor: nwse-resize; display: none; align-items: center; justify-content: center;
color: var(--v5-text-muted); opacity: .6; transition: opacity .2s;
}
.dash-resize-handle:hover { opacity: 1; color: var(--v5-primary); }
.dash-canvas.edit-mode .dash-resize-handle { display: flex; }
.dash-resize-handle::before {
content: ''; position: absolute; right: 3px; bottom: 3px; width: 10px; height: 10px;
background: linear-gradient(135deg, transparent 50%, currentColor 50%, currentColor 60%,
transparent 60%, transparent 70%, currentColor 70%, currentColor 80%, transparent 80%);
}
/* 접힌 카드 */
.dash-card.collapsed .dash-card-body { display: none; }
.dash-card.collapsed .dash-mini-body { display: flex; flex-direction: column;
flex: 1; overflow: hidden; padding: .65rem .8rem; gap: .5rem; }
.dash-card:not(.collapsed) .dash-mini-body { display: none; }
/* 미니 본문 통계 */
.dash-mini-stats {
display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: .45rem; flex: 1; align-content: start;
}
.dash-mini-stat {
padding: .55rem .65rem; border-radius: 9px; background: var(--v5-glass);
border: 1px solid var(--v5-glass-border); display: flex; flex-direction: column;
justify-content: center; min-height: 54px;
}
.dash-mini-stat .ms-label { font-size: .5rem; font-weight: 600;
color: var(--v5-text-muted); text-transform: uppercase; letter-spacing: .06em; }
.dash-mini-stat .ms-value { font-size: 1.15rem; font-weight: 800;
color: var(--v5-text); margin-top: .15rem; letter-spacing: -.02em; line-height: 1; }
/* ── 빈 대시보드 ── */
.dash-empty {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
display: flex; flex-direction: column; align-items: center; gap: .6rem;
color: var(--v5-text-muted); text-align: center;
}
.dash-empty-icon { font-size: 3rem; opacity: .25; }
.dash-empty-title { font-size: .95rem; font-weight: 700; color: var(--v5-text-sec); }
.dash-empty-desc { font-size: .65rem; color: var(--v5-text-muted);
max-width: 280px; line-height: 1.5; }
.dash-empty-btn {
margin-top: .4rem; padding: .5rem 1.2rem; border-radius: 10px; border: none;
background: linear-gradient(135deg, var(--v5-primary), var(--v5-primary-light));
color: white; font-size: .7rem; font-weight: 700; cursor: pointer;
font-family: inherit; box-shadow: var(--v5-glow-sm); transition: all .2s;
}
.dash-empty-btn:hover { transform: translateY(-1px); box-shadow: var(--v5-glow-md); }
/* ── 라이브러리 모달 ── */
.dash-lib-backdrop {
position: fixed; inset: 0; background: rgba(6,5,14,0.5);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
z-index: 200; opacity: 0; pointer-events: none;
transition: opacity .3s cubic-bezier(.4,0,.2,1);
}
.dash-lib-backdrop.open { opacity: 1; pointer-events: auto; }
.dash-lib-modal {
position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%) scale(.96);
width: min(920px, 90vw); height: min(620px, 85vh);
background: var(--v5-glass-strong, rgba(255,255,255,.65));
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
border: 1px solid var(--v5-glass-border); border-radius: 20px;
box-shadow: 0 24px 80px rgba(0,0,0,0.2), var(--v5-glow-lg, var(--v5-glow-md));
z-index: 201; display: flex; flex-direction: column; overflow: hidden;
opacity: 0; pointer-events: none; transition: all .3s cubic-bezier(.16,1,.3,1);
}
.dash-lib-modal.open { opacity: 1; transform: translate(-50%,-50%) scale(1);
pointer-events: auto; }
.dash-lib-head {
display: flex; align-items: center; justify-content: space-between;
padding: 1rem 1.25rem; border-bottom: 1px solid var(--v5-glass-border);
flex-shrink: 0;
}
.dash-lib-title { font-size: .95rem; font-weight: 800; color: var(--v5-text); }
.dash-lib-close {
width: 30px; height: 30px; border-radius: 9px;
border: 1px solid var(--v5-glass-border); background: var(--v5-surface);
color: var(--v5-text-muted); cursor: pointer;
display: flex; align-items: center; justify-content: center; transition: all .2s;
}
.dash-lib-close:hover { border-color: var(--v5-red); color: var(--v5-red); }
.dash-lib-body { display: flex; flex: 1; overflow: hidden; }
.dash-lib-cats {
width: 160px; flex-shrink: 0; background: var(--v5-glass);
border-right: 1px solid var(--v5-glass-border);
padding: .7rem .5rem; overflow-y: auto; display: flex; flex-direction: column; gap: 1px;
}
.dash-lib-cat {
display: flex; align-items: center; gap: .5rem; padding: .45rem .55rem;
border-radius: 9px; font-size: .68rem; font-weight: 500;
color: var(--v5-text-sec); cursor: pointer; transition: all .2s;
}
.dash-lib-cat:hover { background: var(--v5-surface-hover); color: var(--v5-text); }
.dash-lib-cat.on {
background: linear-gradient(135deg, rgba(108,92,231,.12), rgba(108,92,231,.04));
color: var(--v5-primary); font-weight: 600;
}
.dash-lib-grid { flex: 1; overflow-y: auto; padding: 1rem 1.25rem; }
.dash-lib-cards {
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: .7rem;
}
.dash-lib-card {
padding: .85rem; border-radius: 13px; background: var(--v5-glass);
border: 1px solid var(--v5-glass-border); cursor: pointer;
transition: all .25s; display: flex; flex-direction: column; gap: .4rem;
}
.dash-lib-card:hover { border-color: var(--v5-primary);
transform: translateY(-2px); box-shadow: var(--v5-glow-md); }
.dash-lib-card-icon {
width: 34px; height: 34px; border-radius: 10px;
display: flex; align-items: center; justify-content: center; font-size: 1.15rem;
background: linear-gradient(135deg, rgba(108,92,231,.15), rgba(0,206,201,.08));
border: 1px solid rgba(108,92,231,.15);
}
.dash-lib-card-name { font-size: .78rem; font-weight: 700; color: var(--v5-text); }
.dash-lib-card-desc { font-size: .55rem; color: var(--v5-text-muted); line-height: 1.4; }
.dash-lib-card-tag {
font-size: .48rem; padding: .1rem .35rem; border-radius: 5px;
background: rgba(108,92,231,.08); color: var(--v5-primary); font-weight: 600;
display: inline-block;
}
/* ── 카드 설정 패널 ── */
.dash-settings {
position: absolute; top: 46px; right: 10px; width: 280px;
max-height: calc(100% - 60px);
background: var(--v5-glass-strong, rgba(255,255,255,.65));
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
border: 1px solid var(--v5-glass-border); border-radius: 14px;
box-shadow: 0 16px 48px rgba(0,0,0,.15), var(--v5-glow-md);
z-index: 60; display: flex; flex-direction: column; overflow: hidden;
}
.dash-settings-head {
display: flex; align-items: center; justify-content: space-between;
padding: .6rem .85rem; border-bottom: 1px solid var(--v5-glass-border);
}
.dash-settings-title { font-size: .75rem; font-weight: 700; color: var(--v5-text); }
.dash-settings-body { flex: 1; overflow-y: auto; padding: .55rem .85rem .85rem; }
.dash-settings-row {
display: flex; align-items: center; justify-content: space-between;
padding: .4rem .1rem;
}
.dash-settings-row + .dash-settings-row { border-top: 1px dashed var(--v5-border-subtle); }
.dash-settings-label { font-size: .7rem; font-weight: 500; color: var(--v5-text-sec); }
/* 토글 스위치 */
.dash-toggle {
position: relative; width: 32px; height: 18px; border-radius: 999px;
background: var(--v5-surface); border: 1px solid var(--v5-glass-border);
cursor: pointer; transition: all .25s; flex-shrink: 0;
}
.dash-toggle::after {
content: ''; position: absolute; top: 1px; left: 1px; width: 14px; height: 14px;
border-radius: 50%; background: var(--v5-text-muted);
transition: all .25s; box-shadow: 0 1px 3px rgba(0,0,0,.15);
}
.dash-toggle.on {
background: linear-gradient(135deg, var(--v5-primary), var(--v5-primary-light));
border-color: transparent; box-shadow: var(--v5-glow-sm);
}
.dash-toggle.on::after { left: 15px; background: white; }
/* ── Toast ── */
.dash-toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px);
background: var(--v5-glass-strong, rgba(255,255,255,.65));
backdrop-filter: blur(20px) saturate(1.4); border: 1px solid var(--v5-glass-border);
border-radius: 12px; padding: .65rem 1.1rem;
font-size: .7rem; font-weight: 600; color: var(--v5-text);
box-shadow: 0 12px 40px rgba(0,0,0,.15), var(--v5-glow-md);
z-index: 300; opacity: 0; pointer-events: none;
transition: all .3s cubic-bezier(.16,1,.3,1);
display: flex; align-items: center; gap: .5rem;
}
.dash-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
+381
View File
@@ -0,0 +1,381 @@
/* ═══════════════════════════════════════════════════════════════════════════
★ 개발자 빌더 — IDE 스타일 프로페셔널 테마
코스믹 글래스모피즘 X → 깔끔한 IDE/Figma 스타일
mockup 09-developer.css 기반 React 포팅
═══════════════════════════════════════════════════════════════════════════ */
/* ─── 개발자 전용 색상 (다크) ─── */
.dark .dev-shell {
--d-bg: #121218;
--d-bg2: #1a1a22;
--d-bg3: #22222c;
--d-surface: #2a2a36;
--d-surface2: #32323f;
--d-border: #3a3a48;
--d-border2: #4a4a58;
--d-text: #e8e8ee;
--d-text2: #b0b0be;
--d-text3: #78788a;
--d-accent: #5b9ef5;
--d-accent2: #4a8de6;
--d-green: #4ade80;
--d-cyan: #22d3ee;
--d-orange: #fb923c;
--d-pink: #f472b6;
--d-red: #f87171;
}
/* ─── 개발자 전용 색상 (라이트) ─── */
:root:not(.dark) .dev-shell,
html:not(.dark) .dev-shell {
--d-bg: #f5f5f8;
--d-bg2: #ededf2;
--d-bg3: #e4e4ec;
--d-surface: #fff;
--d-surface2: #f8f8fb;
--d-border: #d8d8e2;
--d-border2: #c4c4d0;
--d-text: #1a1a24;
--d-text2: #5a5a6e;
--d-text3: #8a8a9e;
--d-accent: #3b7dd8;
--d-accent2: #2d6bc4;
--d-green: #16a34a;
--d-cyan: #0891b2;
--d-orange: #ea580c;
--d-pink: #db2777;
--d-red: #dc2626;
}
/* ═══ 셸 ═══ */
.dev-shell {
display: flex;
flex-direction: column;
height: 100vh;
position: relative;
z-index: 1;
background: var(--d-bg);
color: var(--d-text);
font-family: inherit;
}
/* ─── 헤더 ─── */
.dev-hdr {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0.8rem;
height: 42px;
background: var(--d-bg2);
border-bottom: 1px solid var(--d-border);
z-index: 10;
flex-shrink: 0;
}
.dev-hdr-l { display: flex; align-items: center; gap: 0.6rem; }
.dev-logo { font-size: 0.72rem; font-weight: 800; letter-spacing: -0.02em; color: var(--d-accent); }
.dev-badge {
font-size: 0.48rem; font-weight: 700; padding: 0.12rem 0.4rem; border-radius: 4px;
background: var(--d-accent); color: #fff;
}
.dev-hdr-r { display: flex; align-items: center; gap: 0.35rem; }
/* ─── 도구모음 ─── */
.dev-toolbar {
display: flex; align-items: center; gap: 0.4rem; padding: 0 0.8rem;
background: var(--d-bg2); border-bottom: 1px solid var(--d-border); height: 34px;
flex-shrink: 0;
}
.dev-tb-group {
display: flex; align-items: center; gap: 0.25rem;
padding-right: 0.5rem; border-right: 1px solid var(--d-border); margin-right: 0.15rem;
}
.dev-tb-group:last-child { border-right: none; }
.dev-tb-label {
font-size: 0.44rem; font-weight: 700; color: var(--d-text3);
text-transform: uppercase; letter-spacing: 0.04em; margin-right: 0.15rem;
}
/* ─── 공통 버튼/셀렉트/인풋 ─── */
.dev-btn {
padding: 0.22rem 0.55rem; border-radius: 5px; border: 1px solid var(--d-border);
background: var(--d-bg3); color: var(--d-text2); font-size: 0.52rem; font-weight: 600;
cursor: pointer; transition: all 0.12s; display: flex; align-items: center; gap: 0.25rem;
}
.dev-btn:hover { border-color: var(--d-accent); color: var(--d-text); background: var(--d-surface); }
.dev-btn.primary { background: var(--d-accent); border-color: var(--d-accent); color: #fff; }
.dev-btn.primary:hover { background: var(--d-accent2); }
.dev-select {
padding: 0.12rem 0.3rem; border-radius: 4px; border: 1px solid var(--d-border);
background: var(--d-bg3); color: var(--d-text); font-size: 0.52rem; outline: none;
}
.dev-select:focus { border-color: var(--d-accent); }
.dev-input {
padding: 0.18rem 0.35rem; border-radius: 4px; border: 1px solid var(--d-border);
background: var(--d-bg3); color: var(--d-text); font-size: 0.5rem; outline: none;
width: 100%; box-sizing: border-box;
}
.dev-input:focus { border-color: var(--d-accent); }
/* ─── 뷰 탭 ─── */
.dev-view-tab {
padding: 0.12rem 0.4rem; border-radius: 4px; border: 1px solid transparent;
background: transparent; color: var(--d-text3); font-size: 0.52rem; cursor: pointer;
transition: all 0.1s; font-weight: 600;
}
.dev-view-tab:hover { background: var(--d-surface); color: var(--d-text); }
.dev-view-tab.active { background: var(--d-accent); color: #fff; border-color: var(--d-accent); }
/* ═══ 3패널 ═══ */
.dev-body { display: flex; flex: 1; overflow: hidden; }
/* ─── 좌: 팔레트 ─── */
.dev-palette {
width: 180px; min-width: 180px; border-right: 1px solid var(--d-border);
background: var(--d-bg2); overflow-y: auto; flex-shrink: 0;
}
.dev-pal-header {
padding: 0.4rem 0.55rem; font-size: 0.48rem; font-weight: 700;
color: var(--d-text3); text-transform: uppercase; letter-spacing: 0.06em;
border-bottom: 1px solid var(--d-border);
}
.dev-pal-sec {
padding: 0.4rem 0.55rem 0.15rem; font-size: 0.42rem; font-weight: 700;
color: var(--d-accent); text-transform: uppercase; letter-spacing: 0.05em;
margin-top: 0.2rem;
}
.dev-pal-item {
display: flex; align-items: center; gap: 0.4rem; padding: 0.28rem 0.55rem;
font-size: 0.56rem; font-weight: 500; color: var(--d-text2); cursor: grab;
transition: all 0.1s; border-radius: 4px; margin: 1px 3px;
}
.dev-pal-item:hover { background: var(--d-surface); color: var(--d-text); }
.dev-pal-item:active { cursor: grabbing; background: var(--d-surface2); }
.dev-pal-icon {
width: 18px; height: 18px; border-radius: 4px; display: flex;
align-items: center; justify-content: center; font-size: 0.65rem; flex-shrink: 0;
}
/* 카테고리별 아이콘 색상 */
.dev-pal-item[data-cat="data"] .dev-pal-icon { color: var(--d-accent); }
.dev-pal-item[data-cat="input"] .dev-pal-icon { color: var(--d-green); }
.dev-pal-item[data-cat="action"] .dev-pal-icon { color: var(--d-pink); }
.dev-pal-item[data-cat="display"] .dev-pal-icon { color: var(--d-orange); }
/* ─── 중: 캔버스 ─── */
.dev-canvas {
flex: 1; overflow: auto; position: relative; background: var(--d-bg);
}
.dark .dev-canvas {
background-image: radial-gradient(circle, rgba(255,255,255,0.03) 0.5px, transparent 0.5px);
background-size: 20px 20px;
}
:root:not(.dark) .dev-canvas,
html:not(.dark) .dev-canvas {
background-image: radial-gradient(circle, rgba(0,0,0,0.06) 0.5px, transparent 0.5px);
background-size: 20px 20px;
}
.dev-canvas-inner {
position: relative; min-width: 1200px; min-height: 800px; padding: 16px;
}
/* 블록 */
.dev-block {
position: absolute; border: 1.5px dashed var(--d-border2); border-radius: 6px;
background: var(--d-bg2); cursor: pointer; transition: border-color 0.1s, box-shadow 0.1s;
overflow: hidden;
}
.dev-block:hover { border-color: var(--d-accent); box-shadow: 0 0 0 1px var(--d-accent); }
.dev-block.selected {
border-color: var(--d-accent); border-style: solid; border-width: 2px;
box-shadow: 0 0 0 3px rgba(91,158,245,0.15);
}
.dev-block-label {
position: absolute; top: -1px; left: 6px; padding: 0 0.3rem;
font-size: 0.4rem; font-weight: 700; color: var(--d-accent); background: var(--d-bg);
letter-spacing: 0.02em; z-index: 1;
}
.dev-block.selected .dev-block-label {
background: var(--d-accent); color: #fff;
border-radius: 0 0 3px 3px; padding: 0.02rem 0.3rem;
}
.dev-block-content {
padding: 0.4rem; font-size: 0.5rem; color: var(--d-text2);
pointer-events: none; height: 100%; display: flex; flex-direction: column;
padding-top: 0.7rem;
}
/* 리사이즈 핸들 */
.dev-resize-handle {
position: absolute; bottom: 0; right: 0; width: 12px; height: 12px;
cursor: nwse-resize; z-index: 2;
}
.dev-resize-handle::after {
content: ''; position: absolute; bottom: 2px; right: 2px;
width: 6px; height: 6px; border-right: 2px solid var(--d-border2);
border-bottom: 2px solid var(--d-border2);
}
.dev-block.selected .dev-resize-handle::after {
border-color: var(--d-accent);
}
/* ─── 우: 속성 패널 ─── */
.dev-props {
width: 260px; min-width: 260px; border-left: 1px solid var(--d-border);
background: var(--d-bg2); overflow-y: auto; flex-shrink: 0;
}
.dev-prop-header {
padding: 0.4rem 0.6rem; font-size: 0.55rem; font-weight: 700; color: var(--d-text);
border-bottom: 1px solid var(--d-border); display: flex; align-items: center; gap: 0.25rem;
background: var(--d-bg3);
}
.dev-prop-sec {
padding: 0.4rem 0.6rem 0.15rem; font-size: 0.42rem; font-weight: 700;
color: var(--d-accent); text-transform: uppercase; letter-spacing: 0.04em;
border-top: 1px solid var(--d-border); margin-top: 0.15rem;
}
.dev-prop-sec:first-of-type { border-top: none; margin-top: 0; }
.dev-prop-row { padding: 0.2rem 0.6rem; display: flex; flex-direction: column; gap: 0.08rem; }
.dev-prop-row.inline { flex-direction: row; align-items: center; justify-content: space-between; }
.dev-prop-label { font-size: 0.46rem; font-weight: 600; color: var(--d-text3); }
/* 위치 그리드 (X/Y/W/H 4칸) */
.dev-pos-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.2rem; padding: 0.2rem 0.6rem;
}
.dev-pos-item { display: flex; align-items: center; gap: 0.2rem; }
.dev-pos-item label {
font-size: 0.42rem; font-weight: 700; color: var(--d-text3); width: 14px;
}
.dev-pos-item input {
flex: 1; padding: 0.12rem 0.2rem; border-radius: 3px; border: 1px solid var(--d-border);
background: var(--d-bg3); color: var(--d-text); font-size: 0.46rem; text-align: center;
outline: none; width: 0;
}
.dev-pos-item input:focus { border-color: var(--d-accent); }
/* 토글 */
.dev-toggle {
width: 26px; height: 14px; border-radius: 7px; background: var(--d-border);
position: relative; cursor: pointer; transition: background 0.12s; flex-shrink: 0;
}
.dev-toggle.on { background: var(--d-accent); }
.dev-toggle::after {
content: ''; position: absolute; width: 10px; height: 10px; border-radius: 50%;
background: #fff; top: 2px; left: 2px; transition: left 0.12s;
}
.dev-toggle.on::after { left: 14px; }
/* 필드 목록 */
.dev-field-list { padding: 0 0.6rem 0.2rem; max-height: 280px; overflow-y: auto; }
.dev-field-item {
display: flex; align-items: center; gap: 0.3rem; padding: 0.18rem 0;
font-size: 0.48rem; color: var(--d-text2); border-bottom: 1px dashed var(--d-border);
cursor: pointer; transition: background 0.08s; border-radius: 2px;
}
.dev-field-item:last-child { border-bottom: none; }
.dev-field-item:hover { background: var(--d-surface); }
.dev-field-check {
width: 14px; height: 14px; border-radius: 3px; border: 1.5px solid var(--d-border2);
display: flex; align-items: center; justify-content: center; font-size: 0.42rem;
cursor: pointer; transition: all 0.1s; flex-shrink: 0; color: transparent;
}
.dev-field-check.on { background: var(--d-accent); border-color: var(--d-accent); color: #fff; }
.dev-field-name { flex: 1; font-weight: 500; color: var(--d-text); font-size: 0.48rem; }
.dev-field-type {
font-size: 0.4rem; color: var(--d-text3); font-weight: 600;
padding: 0.08rem 0.25rem; border-radius: 3px; background: var(--d-surface);
}
.dev-field-drag { color: var(--d-text3); cursor: grab; font-size: 0.5rem; }
/* 필드 배지 */
.dev-fc-badge {
font-size: 0.36rem; font-weight: 700; padding: 0.04rem 0.18rem; border-radius: 2px;
letter-spacing: 0.02em;
}
.dev-fc-badge.pk { background: var(--d-accent); color: #fff; }
.dev-fc-badge.req { background: var(--d-red); color: #fff; }
.dev-fc-badge.sch { background: var(--d-green); color: #fff; }
.dev-fc-badge.sys { background: var(--d-text3); color: #fff; }
.dev-fc-badge.cmp { background: var(--d-orange); color: #fff; }
/* ═══ 상태바 ═══ */
.dev-status {
display: flex; align-items: center; justify-content: space-between;
padding: 0 0.8rem; height: 22px; font-size: 0.42rem; color: var(--d-text3);
background: var(--d-bg2); border-top: 1px solid var(--d-border);
flex-shrink: 0;
}
/* ═══ 빈 캔버스 ═══ */
.dev-empty {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
text-align: center; color: var(--d-text3);
}
.dev-empty-icon { font-size: 2rem; margin-bottom: 0.4rem; opacity: 0.3; }
.dev-empty-text { font-size: 0.6rem; font-weight: 500; }
/* ═══ 프리뷰 테이블 ═══ */
.dev-pv-table { width: 100%; border-collapse: collapse; font-size: 0.48rem; }
.dev-pv-table th {
text-align: left; padding: 0.22rem 0.35rem; font-weight: 700; color: var(--d-text3);
border-bottom: 1px solid var(--d-border); font-size: 0.42rem; text-transform: uppercase;
}
.dev-pv-table td {
padding: 0.22rem 0.35rem; border-bottom: 1px dashed var(--d-border); color: var(--d-text2);
}
/* ═══ 프리뷰 폼 ═══ */
.dev-pv-field { display: flex; flex-direction: column; gap: 0.1rem; margin-bottom: 0.35rem; }
.dev-pv-field-label { font-size: 0.42rem; font-weight: 700; color: var(--d-text3); }
.dev-pv-field-input {
padding: 0.22rem 0.35rem; border-radius: 4px; border: 1px solid var(--d-border);
background: var(--d-bg3); font-size: 0.48rem; color: var(--d-text2);
}
/* ═══ 프리뷰 검색 ═══ */
.dev-pv-search { display: flex; gap: 0.25rem; flex-wrap: wrap; }
.dev-pv-search-item { display: flex; flex-direction: column; gap: 0.06rem; }
.dev-pv-search-label { font-size: 0.38rem; font-weight: 600; color: var(--d-text3); }
.dev-pv-search-input {
padding: 0.18rem 0.3rem; border-radius: 3px; border: 1px solid var(--d-border);
background: var(--d-bg3); font-size: 0.44rem; color: var(--d-text2); min-width: 80px;
}
/* ═══ 프리뷰 버튼 ═══ */
.dev-pv-btn {
display: inline-flex; align-items: center; gap: 0.2rem; padding: 0.18rem 0.45rem;
border-radius: 4px; font-size: 0.46rem; font-weight: 600; border: 1px solid var(--d-border);
color: var(--d-text2);
}
.dev-pv-btn.primary { background: var(--d-accent); border-color: var(--d-accent); color: #fff; }
/* ═══ 팝업 오버레이 ═══ */
.dev-popup-overlay {
position: absolute; inset: 0; background: rgba(0,0,0,0.3);
display: flex; align-items: center; justify-content: center; z-index: 20;
}
.dev-popup-frame {
width: 500px; min-height: 300px; border-radius: 8px;
border: 1px solid var(--d-border); background: var(--d-bg2);
position: relative; overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.dev-popup-frame .dev-canvas-inner {
min-width: unset; min-height: 300px; padding: 12px;
}
/* ═══ 힌트 텍스트 ═══ */
.dev-hint {
font-size: 0.42rem; color: var(--d-text3); padding: 0.15rem 0.6rem; font-style: italic;
}
/* ═══ 삭제 버튼 ═══ */
.dev-delete-btn {
width: 100%; padding: 0.25rem; border-radius: 4px; border: 1px solid var(--d-red);
background: transparent; color: var(--d-red); font-size: 0.46rem; font-weight: 600;
cursor: pointer; transition: all 0.12s;
}
.dev-delete-btn:hover { background: var(--d-red); color: #fff; }
+14 -2
View File
@@ -47,6 +47,13 @@ export interface FieldRef {
searchColumns?: string[];
}
/**
* select 타입의 선택지.
* - string이면 value=label로 해석 (단순 목록)
* - { value, label } 객체면 value를 저장, label을 표시 (코드값/표시값 분리)
*/
export type FieldOption = string | { value: string; label: string };
/**
* 모든 컴포넌트가 공유하는 유일한 필드 정의.
*
@@ -92,8 +99,13 @@ export interface FieldConfig {
// ─── 타입별 확장 ───
/** select 타입: 선택지 목록 */
options?: string[];
/** select 타입: 선택지 목록
* - string이면 value=label로 해석
* - { value, label } 객체면 value를 저장, label을 표시
* - select 렌더러/검색 파라미터는 항상 value를 저장/전송
* - 테이블 셀은 label을 표시
*/
options?: FieldOption[];
/** entity 타입: FK 참조 정보 */
ref?: FieldRef;
/** 포맷 문자열 (number: '#,##0', date: 'YYYY-MM-DD' 등) */
@@ -0,0 +1,634 @@
# Phase 1: DB 메타 읽기 → FieldConfig 변환
> **목적**: PostgreSQL 테이블의 스키마(컬럼명, 타입, PK/FK, nullable 등)를 읽어서 INVYONE의 FieldConfig[] 배열로 변환하는 파이프라인 구축
> **전제 조건**: 없음 (최초 단계)
> **산출물**: 테이블명을 주면 FieldConfig[]을 반환하는 백엔드 API + 프론트엔드 타입/API 클라이언트
> **다음 단계**: Phase 2가 이 FieldConfig[]을 받아서 테이블/폼/검색 컴포넌트로 렌더링
---
## 1. 핵심 개념
INVYONE의 모든 UI는 **FieldConfig**라는 단일 규격으로 동작한다. FieldConfig는 DB 테이블의 컬럼 메타데이터를 UI가 소비할 수 있는 형태로 변환한 것이다.
```
PostgreSQL information_schema + table_type_columns (기존)
→ Java Service에서 변환
→ FieldConfig[] JSON 반환
→ 프론트엔드가 이걸 받아서 렌더링
```
---
## 2. 현재 존재하는 것
### 2.1 백엔드 (backend-spring)
| 파일 | 역할 | 활용 |
|---|---|---|
| `TableManagementController.java` | 테이블/컬럼 CRUD API | **확장 대상** — FieldConfig 변환 API 추가 |
| `TableManagementService.java` | 테이블 메타 조회 서비스 | **확장 대상** — 변환 로직 추가 |
| `mapper/TableManagementMapper.xml` | MyBatis SQL | **확장 대상** — 조회 쿼리 추가 |
| `DataController.java` | 범용 데이터 CRUD | 참고용 — 나중에 Phase 2에서 활용 |
### 2.2 DB 테이블 (이미 존재)
**`table_type_columns`** — VEX에서 가져온 컬럼 메타데이터 테이블:
- `table_name`, `column_name`, `input_type`, `detail_settings` (JSONB)
- `is_nullable`, `display_order`, `company_code`
- 회사별 오버라이드 지원 (`company_code = '*'`이면 글로벌)
**PostgreSQL `information_schema.columns`** — DB 원본 스키마:
- `table_name`, `column_name`, `data_type`, `is_nullable`, `column_default`
- `character_maximum_length`, `ordinal_position`
**PostgreSQL `information_schema.table_constraints` + `key_column_usage`** — PK/FK 정보
### 2.3 프론트엔드
| 파일 | 역할 |
|---|---|
| `frontend/types/invyone-component.ts` | **FieldConfig 타입 정의 (이미 완성)** — 이 파일의 `FieldConfig` 인터페이스가 진실의 원천 |
| `frontend/lib/api/tableSchema.ts` | 테이블 스키마 API 클라이언트 (기존 VEX용, 참고) |
---
## 3. 상세 설계
### 3.1 FieldConfig 타입 (이미 정의됨 — frontend/types/invyone-component.ts)
```typescript
interface FieldConfig {
// 식별
column: string; // DB 컬럼명
label: string; // 화면 표시 라벨
// 타입
type: FieldType; // 'text' | 'number' | 'date' | 'datetime' | 'select' | 'entity' | 'checkbox' | 'textarea' | 'file' | 'code'
// 표시
visible: boolean;
order: number;
width?: number;
align?: 'left' | 'center' | 'right';
// 입력
required: boolean;
editable: boolean;
defaultValue?: unknown;
placeholder?: string;
// 타입별 확장
options?: string[]; // select용
ref?: FieldRef; // entity FK 참조
format?: string; // 포맷 문자열
computed?: string; // 자동 계산 수식
// 메타
pk?: boolean;
system?: boolean; // company_code 같은 시스템 필드
searchable?: boolean;
sortable?: boolean;
}
```
### 3.2 PostgreSQL → FieldType 매핑 규칙
DB의 `data_type`을 FieldType으로 변환하는 규칙:
```
PostgreSQL data_type → FieldType
─────────────────────────────────────────
character varying, varchar → 'text'
text → 'textarea'
integer, bigint, smallint → 'number'
numeric, decimal, real, double→ 'number'
boolean → 'checkbox'
date → 'date'
timestamp, timestamptz → 'datetime'
jsonb, json → 'textarea'
bytea → 'file'
(그 외) → 'text' (기본값)
```
**단, `table_type_columns.input_type`이 있으면 그것이 우선한다.**
`table_type_columns.input_type` → FieldType 매핑:
```
input_type → FieldType
────────────────────────────
text → 'text'
number → 'number'
date → 'date'
datetime → 'datetime'
select → 'select'
entity → 'entity'
checkbox → 'checkbox'
boolean → 'checkbox'
textarea → 'textarea'
text_area → 'textarea'
file → 'file'
code → 'code'
numbering → 'code'
category → 'select'
decimal → 'number'
email → 'text'
password → 'text'
tel → 'text'
(그 외) → 'text'
```
### 3.3 변환 로직 상세
입력: `table_name` (String), `company_code` (String)
```
Step 1: information_schema.columns에서 해당 테이블의 모든 컬럼 조회
Step 2: information_schema.table_constraints + key_column_usage에서 PK 컬럼 목록 조회
Step 3: table_type_columns에서 해당 테이블의 커스텀 메타 조회 (company_code 우선순위: 해당 회사 > '*')
→ input_type='entity'인 컬럼의 detail_settings JSON에서 FK 참조 정보 추출
→ referenceTable, referenceColumn, displayColumn
Step 4: Step 1~3 병합하여 FieldConfig[] 생성
```
**★ FK는 DB 제약조건(FOREIGN KEY)이 아닌 `table_type_columns.detail_settings` JSON으로 관리.**
VEX는 비즈니스 테이블에 DB 레벨 FK 제약조건을 걸지 않는다. 관계 정보는 전부 메타데이터 테이블에서 관리한다.
이유: 유연성 (마이그레이션 없이 관계 변경), 멀티테넌시 (회사별 관계 설정), 데이터 이관 시 제약조건 우회 가능.
#### Step 5 병합 규칙:
```
FieldConfig.column = information_schema.column_name
FieldConfig.label = table_type_columns.column_label (있으면) || column_name을 한글화 시도 (없으면 그대로)
FieldConfig.type = table_type_columns.input_type 매핑 (있으면) || data_type 매핑
FieldConfig.visible = true (기본값, table_type_columns에서 오버라이드 가능)
FieldConfig.order = table_type_columns.display_order (있으면) || information_schema.ordinal_position
FieldConfig.required = (is_nullable = 'NO') 이고 (column_default가 없으면) true
FieldConfig.editable = PK가 아니고 system이 아니면 true
FieldConfig.pk = PK 컬럼이면 true
FieldConfig.system = column_name이 'company_code', 'created_by', 'created_date', 'updated_by', 'updated_date', 'is_active' 중 하나면 true
FieldConfig.searchable = table_type_columns에서 지정 (없으면 text/select/entity/date만 true)
FieldConfig.sortable = true (기본값)
FieldConfig.ref = table_type_columns.input_type='entity'이면 detail_settings에서 { table, value_column, display_column } 추출
FieldConfig.options = table_type_columns.detail_settings에서 options 추출 (select 타입)
FieldConfig.format = number이면 '#,##0', date이면 'YYYY-MM-DD', datetime이면 'YYYY-MM-DD HH:mm'
FieldConfig.width = type에 따라 기본값: text=150, number=100, date=120, select=130, entity=180
FieldConfig.align = number이면 'right', 나머지 'left'
```
### 3.4 백엔드 API 설계
#### 3.4.1 신규 API 엔드포인트
**`GET /api/meta/tables`** — 접근 가능한 테이블 목록
```
Request: (없음, JWT에서 company_code 추출)
Response:
{
"success": true,
"data": [
{
"table_name": "order_management_test",
"table_label": "수주관리",
"column_count": 13,
"has_custom_meta": true
},
...
]
}
```
**`GET /api/meta/tables/{tableName}/fields`** — 특정 테이블의 FieldConfig[] 반환
```
Request: GET /api/meta/tables/order_management_test/fields
Headers: Authorization: Bearer {jwt}
Response (★ 백엔드 응답은 전부 snake_case, 프론트에서 camelCase 변환):
{
"success": true,
"data": {
"table_name": "order_management_test",
"table_label": "수주관리",
"primary_key": "order_no",
"fields": [
{
"column": "order_no",
"label": "수주번호",
"type": "code",
"visible": true,
"order": 1,
"width": 120,
"align": "left",
"required": true,
"editable": false,
"pk": true,
"system": false,
"searchable": true,
"sortable": true,
"format": null,
"options": null,
"ref": null,
"computed": null
},
{
"column": "order_date",
"label": "수주일",
"type": "date",
"visible": true,
"order": 2,
"width": 120,
"align": "left",
"required": true,
"editable": true,
"pk": false,
"system": false,
"searchable": true,
"sortable": true,
"format": "YYYY-MM-DD",
"options": null,
"ref": null,
"computed": null
},
{
"column": "customer",
"label": "거래처",
"type": "entity",
"visible": true,
"order": 3,
"width": 180,
"align": "left",
"required": true,
"editable": true,
"pk": false,
"system": false,
"searchable": true,
"sortable": true,
"format": null,
"options": null,
"ref": {
"table": "customer_mng",
"value_column": "customer_code",
"display_column": "customer_name",
"search_columns": ["customer_name", "biz_number"]
},
"computed": null
},
{
"column": "amount",
"label": "금액",
"type": "number",
"visible": true,
"order": 7,
"width": 100,
"align": "right",
"required": false,
"editable": false,
"pk": false,
"system": false,
"searchable": false,
"sortable": true,
"format": "#,##0",
"options": null,
"ref": null,
"computed": "quantity * unit_price"
},
{
"column": "status",
"label": "상태",
"type": "select",
"visible": true,
"order": 9,
"width": 100,
"align": "center",
"required": false,
"editable": true,
"pk": false,
"system": false,
"searchable": true,
"sortable": true,
"format": null,
"options": ["임시저장", "확정", "완료", "취소"],
"ref": null,
"computed": null
},
{
"column": "company_code",
"label": "회사코드",
"type": "text",
"visible": false,
"order": 99,
"required": false,
"editable": false,
"pk": false,
"system": true,
"searchable": false,
"sortable": false
}
]
}
}
```
**★ 관계 정보 2소스 책임 분리 (B안 확정):**
| 소스 | 용도 | 사용 Phase |
|---|---|---|
| `table_type_columns.detail_settings` | **필드 레벨 참조** — FieldConfig.ref, entity picker, 폼/검색/테이블 렌더링 | Phase 1 (`getTableFields`) |
| `table_relationships` | **테이블 간 관계** — 제어 모드, 데이터플로우, 업무적 연결 (one-to-many 등) | Phase 5 (`getMetaRelations`) |
- Phase 1의 `getTableFields()``table_type_columns`만 읽는다 (필드 참조 원천)
- Phase 5의 관계 그래프는 `table_relationships`를 읽는다 (업무 관계 원천)
- 제어 모드에서 두 소스가 필요하면 **서비스 레이어에서 읽기 시점에만 합친다** (저장 구조 통합 안 함)
따라서 **이 Phase 1에서는 relations API를 만들지 않는다.** `getTableFields()``table_type_columns`에서 entity 참조(ref)를 추출하는 것까지만 책임진다. `table_relationships` 기반 관계 API는 Phase 5에서 구현한다.
#### 3.4.2 구현할 Java 파일 (★ 파이프라인 규칙 준수)
**★ 덕일 스타일 3레이어. Mapper Interface 금지. 파일명 1:1 매칭.**
| 파일 | 경로 | 비고 |
|---|---|---|
| `MetaController.java` | `controller/` | `/api/meta/*` |
| `MetaService.java` | `service/` | `extends BaseService` |
| `meta.xml` | `resources/mapper/` | `namespace="meta"` (소문자, Mapper 안 붙임) |
**MetaController.java:**
```java
@RestController
@RequestMapping("/api/meta")
@Slf4j
public class MetaController {
@Autowired
private MetaService metaService;
@GetMapping("/tables")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getMetaTableList(
@RequestAttribute("companyCode") String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(metaService.getMetaTableList(params)));
}
@GetMapping("/tables/{tableName}/fields")
public ResponseEntity<ApiResponse<Map<String, Object>>> getMetaFields(
@PathVariable String tableName,
@RequestAttribute("companyCode") String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(metaService.getMetaFields(params)));
}
// ★ relations API는 Phase 1에서 안 만듦
// table_relationships 기반 관계 조회는 Phase 5 (제어 모드)에서 구현
// Phase 1은 getTableFields()의 FieldConfig.ref (entity 참조)까지만 책임
}
```
**MetaService.java:**
```java
@Service
@Slf4j
public class MetaService extends BaseService {
@Autowired
private CommonService commonService;
/** 테이블 목록 */
public List<Map<String, Object>> getMetaTableList(Map<String, Object> params) {
return sqlSession.selectList("meta.getMetaTableList", params);
}
/** 특정 테이블의 FieldConfig[] 반환 */
public Map<String, Object> getMetaFields(Map<String, Object> params) {
List<Map<String, Object>> schemaCols = sqlSession.selectList("meta.getSchemaColumns", params);
List<String> pks = sqlSession.selectList("meta.getPrimaryKeys", params);
List<Map<String, Object>> customMeta = sqlSession.selectList("meta.getCustomMeta", params);
// ★ FK는 DB 제약조건이 아닌 customMeta(table_type_columns)의 detail_settings에서 추출
// input_type='entity'인 컬럼의 detail_settings JSON → referenceTable/referenceColumn/displayColumn
List<Map<String, Object>> fields = buildFieldConfigs(schemaCols, pks, customMeta);
Map<String, Object> result = new HashMap<>();
result.put("table_name", params.get("table_name"));
result.put("fields", fields);
return result;
}
// ★ relations 조회는 Phase 5에서 table_relationships 기반으로 구현
// Phase 1은 getMetaFields()의 FieldConfig.ref (entity 참조)까지만 책임
/** 병합하여 FieldConfig Map 리스트 생성 (상세 로직은 Section 3.3 참고) */
private List<Map<String, Object>> buildFieldConfigs(
List<Map<String, Object>> schemaCols,
List<String> pks,
List<Map<String, Object>> customMeta
) {
// column, label, type, visible, order, required, editable, pk, system 등
// customMeta가 있으면 우선 적용
// ★ input_type='entity'이면 detail_settings JSON 파싱 → ref Map 생성
// ObjectMapper로 detail_settings 파싱 → referenceTable, referenceColumn, displayColumn 추출
// system 컬럼은 visible=false, system=true
}
}
```
#### 3.4.3 MyBatis SQL — `meta.xml` (★ 덕일 스타일)
**★ 파일명: `meta.xml` (소문자, Mapper 안 붙임)**
**★ namespace: `meta`**
**★ SQL 키워드/테이블/컬럼: UPPER_SNAKE, #{파라미터}: snake_case**
**★ SELECT 쉼표 앞에, OGNL test 바깥 작은따옴표**
```xml
<?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="meta">
<!-- ═══ 테이블 목록 (public 스키마, 시스템 테이블 제외) ═══ -->
<select id="getMetaTableList" parameterType="map" resultType="map">
SELECT
T.TABLE_NAME
, (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS C
WHERE C.TABLE_SCHEMA = 'public' AND C.TABLE_NAME = T.TABLE_NAME) AS COLUMN_COUNT
, CASE WHEN EXISTS(
SELECT 1 FROM TABLE_TYPE_COLUMNS TTC
WHERE TTC.TABLE_NAME = T.TABLE_NAME
<if test='company_code != null and company_code != "*"'>
AND (TTC.COMPANY_CODE = #{company_code} OR TTC.COMPANY_CODE = '*')
</if>
) THEN true ELSE false END AS HAS_CUSTOM_META
FROM INFORMATION_SCHEMA.TABLES T
WHERE T.TABLE_SCHEMA = 'public'
AND T.TABLE_TYPE = 'BASE TABLE'
AND T.TABLE_NAME NOT LIKE 'pg_%'
AND T.TABLE_NAME NOT IN ('spatial_ref_sys')
ORDER BY T.TABLE_NAME
</select>
<!-- ═══ 특정 테이블의 컬럼 정보 ═══ -->
<select id="getSchemaColumns" parameterType="map" resultType="map">
SELECT
COLUMN_NAME
, DATA_TYPE
, IS_NULLABLE
, COLUMN_DEFAULT
, CHARACTER_MAXIMUM_LENGTH
, ORDINAL_POSITION
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'public'
AND TABLE_NAME = #{table_name}
ORDER BY ORDINAL_POSITION
</select>
<!-- ═══ PK 컬럼 목록 ═══ -->
<select id="getPrimaryKeys" parameterType="map" resultType="string">
SELECT KCU.COLUMN_NAME
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS TC
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE KCU
ON TC.CONSTRAINT_NAME = KCU.CONSTRAINT_NAME
AND TC.TABLE_SCHEMA = KCU.TABLE_SCHEMA
WHERE TC.CONSTRAINT_TYPE = 'PRIMARY KEY'
AND TC.TABLE_SCHEMA = 'public'
AND TC.TABLE_NAME = #{table_name}
</select>
<!-- ═══ 관계 조회 (★ DB FK 제약조건이 아닌 table_type_columns 메타 기반) ═══ -->
<select id="getMetaRelations" parameterType="map" resultType="map">
SELECT
TABLE_NAME AS FROM_TABLE
, COLUMN_NAME AS FROM_COLUMN
, INPUT_TYPE
, DETAIL_SETTINGS
FROM TABLE_TYPE_COLUMNS
WHERE TABLE_NAME = #{table_name}
AND INPUT_TYPE IN ('entity', 'category')
AND DETAIL_SETTINGS IS NOT NULL
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</select>
<!--
★ detail_settings JSON에서 참조 정보 추출 (Java Service에서 파싱):
{
"referenceTable": "partner_info",
"referenceColumn": "id",
"displayColumn": "partner_name"
}
→ FieldConfig.ref = { table, value_column, display_column } 로 변환
-->
<!-- ═══ TABLE_TYPE_COLUMNS 커스텀 메타 (회사 우선, '*' 폴백) ═══ -->
<select id="getCustomMeta" parameterType="map" resultType="map">
SELECT DISTINCT ON (COLUMN_NAME)
COLUMN_NAME
, INPUT_TYPE
, DETAIL_SETTINGS
, DISPLAY_ORDER
, IS_NULLABLE
, COMPANY_CODE
FROM TABLE_TYPE_COLUMNS
WHERE TABLE_NAME = #{table_name}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
ORDER BY COLUMN_NAME
, CASE WHEN COMPANY_CODE = #{company_code} THEN 0 ELSE 1 END
</select>
</mapper>
```
### 3.5 프론트엔드 API 클라이언트
**신규 파일: `frontend/lib/api/meta.ts`**
```typescript
import { client } from './client';
import type { FieldConfig } from '@/types/invyone-component';
/**
* ★ 일반 API는 Record<string, any> — 별도 인터페이스 정의 안 함
* ★ 단, FieldConfig만은 invyone-component.ts에 규격 확정된 예외 타입
* → getTableFields()의 fields 배열은 FieldConfig[]로 캐스팅
*/
/** 접근 가능한 테이블 목록 */
export async function getTableList(): Promise<Record<string, any>[]> {
const res = await client.get('/api/meta/tables');
return res.data.data;
}
/** 특정 테이블의 FieldConfig[] 반환 (★ fields만 FieldConfig[] 타입) */
export async function getTableFields(tableName: string): Promise<{
table_name: string;
fields: FieldConfig[];
[key: string]: any;
}> {
const res = await client.get(`/api/meta/tables/${tableName}/fields`);
return res.data.data;
}
// ★ relations API는 Phase 5에서 구현 (table_relationships 기반)
// Phase 1은 getTableFields()의 FieldConfig.ref까지만 책임
```
---
## 4. 시스템 필드 판별 규칙
다음 컬럼명은 자동으로 `system: true`로 설정하고, `visible: false`, `editable: false`로 만든다:
```
company_code, created_by, created_date, updated_by, updated_date,
is_active, deleted_date, deleted_by, writer, write_date
```
---
## 5. 참고 파일
| 파일 | 용도 |
|---|---|
| `frontend/types/invyone-component.ts` | **FieldConfig 타입 정의 (진실의 원천)** — 이 파일의 인터페이스와 정확히 일치하는 JSON을 반환해야 함 |
| `notes/gbpark/2026-04-09-invyone-architecture.md` | 아키텍처 결정 문서 — 변환 규칙의 근거 |
| `notes/gbpark/2026-04-08-invyone-component-spec.md` | 컴포넌트 규격 — FieldType별 렌더링 계약 |
| `backend-spring/src/main/java/com/erp/service/TableManagementService.java` | 기존 테이블 관리 서비스 — 참고용 (패턴) |
| `backend-spring/src/main/resources/mapper/TableManagementMapper.xml` | 기존 MyBatis 매퍼 — SQL 패턴 참고 |
| `notes/gbpark/2026-04-08-invyone-mockup/js/08-admin-builder.js` | mockup의 테이블 메타 데이터 — `AB_TABLE_FIELDS` 객체가 FieldConfig의 mockup 버전 |
---
## 6. 완료 기준
1. **`GET /api/meta/tables`** 호출 시 DB의 public 스키마 테이블 목록이 반환된다
2. **`GET /api/meta/tables/{tableName}/fields`** 호출 시:
- `FieldConfig[]` 형태의 JSON이 반환된다
- `frontend/types/invyone-component.ts``FieldConfig` 인터페이스와 필드명이 정확히 일치한다
- PK 컬럼에 `pk: true`가 설정된다
- system 컬럼(company_code 등)에 `system: true`, `visible: false`가 설정된다
- `table_type_columns`에 커스텀 메타가 있으면 그것이 우선 적용된다
- select 타입 필드에 `options[]`가 포함된다
- entity 타입 필드에 `ref` 객체가 포함된다
3. ~~relations API는 Phase 1에서 안 만듦~~ (Phase 5에서 `table_relationships` 기반으로 구현)
4. 프론트엔드 `lib/api/meta.ts`에서 위 API들을 호출하는 함수가 있다
5. 실제 DB 테이블 (예: `order_management_test`, `user_info`)로 테스트하여 합리적인 FieldConfig가 생성됨을 확인
---
## 7. 다음 단계 연결
Phase 2는 이 API가 반환하는 `FieldConfig[]`을 받아서:
- **테이블 컴포넌트**: FieldConfig[]을 컬럼으로 렌더링
- **폼 컴포넌트**: FieldConfig[]을 입력 필드로 렌더링
- **검색 컴포넌트**: FieldConfig[]을 검색 조건으로 렌더링
Phase 2의 컴포넌트들은 `getTableFields(tableName)`을 호출하여 FieldConfig[]을 가져온 뒤 렌더링한다.
@@ -0,0 +1,298 @@
# Phase 1 구현 작업기록 — DB 메타 → FieldConfig 변환
> **작업일**: 2026-04-10
> **설계서**: `notes/gbpark/2026-04-10-phase1-db-meta-to-fieldconfig.md`
> **상태**: 구현 완료 + DB 실테스트 통과
---
## 1. 생성된 파일 (4개)
### 1.1 `backend-spring/src/main/resources/mapper/meta.xml`
MyBatis XML 매퍼. namespace=`meta`, 쿼리 5개:
| 쿼리 ID | 용도 | 반환 |
|---|---|---|
| `getMetaTableList` | public 스키마 테이블 목록 | table_name, table_label, column_count, has_custom_meta |
| `getSchemaColumns` | information_schema.columns 전체 컬럼 | column_name, data_type, is_nullable, column_default, ordinal_position |
| `getPrimaryKeys` | PK 컬럼명 목록 | string (column_name) |
| `getCustomMeta` | table_type_columns 커스텀 메타 (회사 우선순위) | column_name, column_label, input_type, detail_settings, reference_table 등 |
| `getTableLabel` | TABLE_LABELS에서 라벨 단건 | string (table_label) |
**회사 우선순위 처리**: `getCustomMeta`에서 `DISTINCT ON (COLUMN_NAME)` + `CASE WHEN COMPANY_CODE = #{company_code} THEN 0 ELSE 1 END` 정렬로 회사별 메타가 '*'(글로벌)보다 우선.
---
### 1.2 `backend-spring/src/main/java/com/erp/service/MetaService.java`
핵심 변환 로직. 덕일 스타일 준수 — extends BaseService, @Autowired CommonService, sqlSession 직접 호출.
#### 주요 메서드
| 메서드 | 역할 |
|---|---|
| `getMetaTableList(params)` | 테이블 목록 pass-through |
| `getMetaFields(params)` | 4개 쿼리 오케스트레이션 → buildFieldConfigs 호출 → 결과 조립 |
| `buildFieldConfigs(schemaCols, pks, customMeta)` | **핵심** — 3소스 병합하여 FieldConfig Map 리스트 생성 |
| `mapDataTypeToFieldType(dataType)` | PostgreSQL data_type → FieldType 변환 (12개 매핑) |
| `mapInputTypeToFieldType(inputType)` | table_type_columns.input_type → FieldType 변환 (15개 매핑) |
| `buildFieldRef(meta, detailSettings)` | entity 타입의 ref 객체 빌드 (top-level 컬럼 우선 → detail_settings 폴백) |
| `extractOptions(detailSettings)` | select 타입의 options 추출 (`string[]``[{value,label}]` 둘 다 처리) |
| `parseDetailSettings(meta)` | detail_settings JSONB → Map 파싱 (ObjectMapper) |
#### buildFieldConfigs 병합 규칙 (필드별)
```
column = information_schema.column_name
label = table_type_columns.column_label (있으면) || column_name
type = table_type_columns.input_type 매핑 (있으면) || data_type 매핑
visible = system이면 무조건 false, 아니면 is_visible
order = display_order > 0이면 우선 || ordinal_position
required = ★ 앱 레벨 메타(table_type_columns.is_nullable) 우선, 없으면 DB 스키마 폴백
editable = !PK && !system && type!='code'
pk = information_schema PK 제약조건
system = SYSTEM_FIELDS 목록에 포함 여부
searchable = !system && (text|select|entity|date|code)
sortable = !system
format = number→'#,##0', date→'YYYY-MM-DD', datetime→'YYYY-MM-DD HH:mm'
width = type별 기본값 (number:100, date:120, entity:180 등)
align = number→'right', 나머지→'left'
options = detail_settings.options (string[] 또는 [{value,label}] → label 추출)
ref = entity일 때 reference_table/column/display_column (top-level 우선 → detail_settings 폴백)
computed = detail_settings.computed (있으면 editable=false 강제)
```
#### required 판정 로직 (★ 수동 수정 반영)
```java
// ★ 앱 레벨 메타 우선, DB 스키마 폴백
if (!isSystem && meta != null && meta.get("is_nullable") != null) {
// table_type_columns.IS_NULLABLE가 있으면 앱 레벨 메타가 진실
required = "NO".equalsIgnoreCase(metaNullable);
} else {
// 없으면 information_schema 폴백
required = "NO".equalsIgnoreCase(isNullable) && columnDefault == null && !isSystem;
}
```
원래 구현: information_schema.is_nullable + column_default 기반.
수정 이유: VEX에서 앱 레벨 메타(table_type_columns.IS_NULLABLE)가 DB 스키마보다 우선하는 패턴. 관리자가 설정한 필수 여부가 DB 제약조건보다 비즈니스적으로 정확함.
#### system 필드 목록
```
company_code, created_by, created_date, updated_by, updated_date,
is_active, deleted_date, deleted_by, writer, write_date
```
→ 자동으로 `visible: false`, `editable: false`, `required: false`, `searchable: false`, `sortable: false`
#### 타입 매핑 테이블
**PostgreSQL data_type → FieldType:**
| data_type | FieldType |
|---|---|
| character varying, varchar | text |
| text | textarea |
| integer, bigint, smallint | number |
| numeric, decimal, real, double precision | number |
| boolean | checkbox |
| date | date |
| timestamp (with/without tz) | datetime |
| jsonb, json | textarea |
| bytea | file |
| 기타 | text |
**input_type → FieldType (custom meta 우선):**
| input_type | FieldType |
|---|---|
| text, email, password, tel | text |
| number, decimal | number |
| date | date |
| datetime | datetime |
| select, category | select |
| entity | entity |
| checkbox, boolean | checkbox |
| textarea, text_area | textarea |
| file | file |
| code, numbering | code |
| 기타 | text |
#### entity ref 빌드 로직
```
1차: top-level 컬럼 (REFERENCE_TABLE, REFERENCE_COLUMN, DISPLAY_COLUMN)
2차: detail_settings JSON 폴백 (referenceTable, referenceColumn, displayColumn)
→ table이 없으면 ref=null
→ value_column 기본값 "id", display_column 기본값 = value_column
→ search_columns는 detail_settings에서만 추출
```
#### select options 추출 로직
```
detail_settings.options 필드에서 추출
- string[] 형식: ["대기", "확정", ...] → 그대로 반환
- [{value, label}] 형식: [{value:"PENDING", label:"대기"}, ...] → label만 추출
- options 없으면 null (category 타입은 common code 연동 필요 — Phase 1 범위 밖)
```
---
### 1.3 `backend-spring/src/main/java/com/erp/controller/MetaController.java`
REST 컨트롤러. 2개 엔드포인트:
| 엔드포인트 | 메서드 | 설명 |
|---|---|---|
| `GET /api/meta/tables` | `getMetaTableList` | 접근 가능한 테이블 목록 |
| `GET /api/meta/tables/{tableName}/fields` | `getMetaFields` | 특정 테이블의 FieldConfig[] 반환 |
- `@RequestAttribute("company_code")` — JWT 필터에서 주입 (snake_case, 기존 패턴 따름)
- `@RequiredArgsConstructor` + `private final MetaService` — 기존 컨트롤러 패턴 따름
- relations API는 Phase 1에서 안 만듦 (Phase 5 담당)
---
### 1.4 `frontend/lib/api/meta.ts`
프론트엔드 API 클라이언트. 2개 함수:
| 함수 | 반환 타입 | 설명 |
|---|---|---|
| `getMetaTableList()` | `Record<string, any>[]` | 테이블 목록 (별도 인터페이스 안 만듦) |
| `getMetaFields(tableName)` | `{table_name, table_label, primary_key, fields: FieldConfig[]}` | FieldConfig 규격 타입 사용 (유일한 예외) |
**snake_case → camelCase 변환 처리:**
- `toFieldConfig(raw)` — 대부분 단일 단어라 변환 불필요, `default_value``defaultValue`만 처리
- `toFieldRef(raw)``value_column``valueColumn`, `display_column``displayColumn`, `search_columns``searchColumns`
- `apiClient` 사용 (baseURL에 `/api` 이미 포함 → `/meta/tables`로 호출)
---
## 2. 덕일 스타일 준수 체크리스트
| 규칙 | 준수 |
|---|---|
| 3레이어 (Controller → Service → XML) | O |
| Mapper Interface 금지 | O — sqlSession 직접 호출 |
| Map<String, Object> 사용, DTO 금지 | O — ApiResponse만 예외 |
| BaseService 상속 | O |
| @Autowired CommonService | O |
| XML 파일명: 소문자, Mapper 안 붙임 | O — `meta.xml` |
| XML namespace: 파일명과 동일 | O — `namespace="meta"` |
| SQL: UPPER_SNAKE | O |
| SELECT 쉼표: 앞에 | O |
| #{파라미터}: snake_case | O |
| OGNL test: 바깥 작은따옴표 | O |
| 프론트 타입: Record<string, any> (FieldConfig만 예외) | O |
---
## 3. 실제 DB 테스트 결과
**테스트 환경**: `test_dev` DB (211.115.91.141:11134)
**테스트 계정**: shkim (SUPER_ADMIN)
**테스트 테이블**: `sales_order_mng` (entity/select/category/number/date 타입 다수 보유)
### 3.1 GET /api/meta/tables
```json
{
"success": true,
"data": [
{"table_name": "approval_definitions", "table_label": "approval_definitions", "column_count": 15, "has_custom_meta": false},
{"table_name": "sales_order_mng", "table_label": "수주관리", "column_count": 44, "has_custom_meta": true},
...
]
}
```
### 3.2 GET /api/meta/tables/sales_order_mng/fields
**검증 결과:**
| 검증 항목 | 결과 | 상세 |
|---|---|---|
| table_label | OK | "수주관리" (TABLE_LABELS에서) |
| primary_key | OK | "id" |
| PK 감지 | OK | id: `pk:true, editable:false` |
| system 필드 | OK | company_code, created_by/date, updated_by/date, writer → `visible:false, system:true` |
| entity + ref | OK | partner_id: `ref:{table:"customer_mng", value_column:"customer_code", display_column:"customer_name"}` |
| entity (top-level 컬럼) | OK | delivery_partner_id: `ref:{table:"delivery_destination", ...}` |
| select + options ({value,label}) | OK | shipping_method: `options:["직접배송","택배","화물","퀵서비스"]` |
| select + options (문자열) | OK | status: `options:["대기","확정","출하","완료","취소"]` |
| custom meta 우선 | OK | id(integer) → table_type_columns에 input_type='text' 설정 → type:"text" |
| date 포맷 | OK | `format:"YYYY-MM-DD"` |
| number 포맷/정렬 | OK | `format:"#,##0", align:"right"` |
| 컴파일 | OK | `./gradlew compileJava` + `./gradlew bootJar` 성공 |
| 프론트 타입체크 | OK | `npx tsc --noEmit` — meta.ts 에러 0개 |
---
## 4. Phase 1 범위 밖 (나중에 해야 할 것)
| 항목 | 담당 Phase | 비고 |
|---|---|---|
| relations API (table_relationships) | Phase 5 | 제어 모드에서 사용 |
| category 타입의 common code 옵션 로딩 | Phase 2+ | CODE_CATEGORY → 공통코드 테이블 조회 |
| entity ref가 없는 entity 필드 처리 | Phase 2+ | part_code, manager_id 등 — reference_table 미설정 |
| FieldConfig.defaultValue, placeholder 세팅 | Phase 2+ | detail_settings에서 추출 가능하나 현재 미구현 |
| select options의 value/label 분리 저장 | Phase 2+ | 현재 label만 string[]로 반환, value 매핑 필요 시 타입 확장 |
---
## 5. 파일별 코드 요약
### meta.xml (89줄)
```
5개 쿼리:
- getMetaTableList: INFORMATION_SCHEMA.TABLES + TABLE_LABELS + TABLE_TYPE_COLUMNS EXISTS
- getSchemaColumns: INFORMATION_SCHEMA.COLUMNS
- getPrimaryKeys: TABLE_CONSTRAINTS + KEY_COLUMN_USAGE
- getCustomMeta: TABLE_TYPE_COLUMNS (DISTINCT ON + 회사 우선순위)
- getTableLabel: TABLE_LABELS 단건
```
### MetaService.java (375줄)
```
퍼블릭 메서드 2개:
- getMetaTableList(params) → List<Map>
- getMetaFields(params) → Map (table_name, table_label, primary_key, fields[])
프라이빗 메서드 10개:
- buildFieldConfigs: 3소스 병합 (핵심, ~120줄)
- mapDataTypeToFieldType: PG타입→FieldType (12매핑)
- mapInputTypeToFieldType: input_type→FieldType (15매핑)
- buildFieldRef: entity ref 조립 (top-level 우선 → detail_settings 폴백)
- extractOptions: select options 추출 (string[] + {value,label} 대응)
- parseDetailSettings: JSONB→Map (ObjectMapper)
- getDefaultWidth: 타입별 기본 너비
- getDefaultFormat: 타입별 기본 포맷
- str, strFromMap, num: Map 유틸
```
### MetaController.java (45줄)
```
2개 엔드포인트:
- GET /api/meta/tables → getMetaTableList
- GET /api/meta/tables/{tableName}/fields → getMetaFields
```
### meta.ts (86줄)
```
2개 API 함수:
- getMetaTableList() → Record<string, any>[]
- getMetaFields(tableName) → {fields: FieldConfig[], ...}
2개 변환 헬퍼:
- toFieldConfig(raw) → FieldConfig
- toFieldRef(raw) → FieldRef (snake→camel)
```
@@ -0,0 +1,386 @@
# Phase 2: 규격 기반 컴포넌트 — FieldConfig로 테이블/폼/검색 렌더링
> **목적**: FieldConfig[]을 입력으로 받아 테이블/폼/검색 UI를 렌더링하는 React 컴포넌트 구현
> **전제 조건**: Phase 1 완료 (GET /api/meta/tables/{tableName}/fields API가 FieldConfig[]을 반환)
> **산출물**: FieldConfig 기반 3대 핵심 컴포넌트 (FcTable, FcForm, FcSearch) + 보조 컴포넌트 (FcButton, FcButtonBar, FcPagination)
> **다음 단계**: Phase 3에서 이 컴포넌트들을 개발자 빌더 캔버스에 배치
---
## 1. 핵심 원칙
**"같은 FieldConfig가 컴포넌트에 따라 다르게 렌더된다"**
```
FieldConfig { column: 'order_date', label: '수주일', type: 'date' }
→ FcTable에서: <td>2026-04-08</td> (format 적용된 텍스트)
→ FcForm에서: <DatePicker label="수주일" /> (입력 위젯)
→ FcSearch에서: <DateRangePicker /> (범위 검색)
```
이 렌더링 규칙은 `notes/gbpark/2026-04-08-invyone-component-spec.md` Section 2.2에 명시되어 있다. **반드시 이 매핑 테이블을 따를 것.**
---
## 2. 현재 존재하는 것
### 2.1 타입 정의 (이미 완성)
`frontend/types/invyone-component.ts` — FieldConfig, Component, DataPort, Template, 모든 Config 타입이 정의됨. **이 파일의 타입을 그대로 사용한다. 새로 만들지 말 것.**
### 2.2 기존 컴포넌트 (참고용, 직접 사용 금지)
기존 `frontend/lib/registry/components/`에 85개+ 컴포넌트가 있지만, 이것들은 VEX 규격(ColumnConfig 354줄 등)에 맞춰 만들어진 것이므로 **FieldConfig 기반 신규 컴포넌트를 만든다.** 기존 컴포넌트는 코드 패턴 참고용으로만 사용.
### 2.3 UI 라이브러리 (활용)
| 라이브러리 | 용도 |
|---|---|
| Radix UI | 기본 UI 프리미티브 (Dialog, Select, Checkbox 등) |
| TanStack Table v8 | 테이블 렌더링 |
| React Hook Form v7 | 폼 상태 관리 |
| Zod v4 | 폼 유효성 검증 |
| date-fns v4 | 날짜 포맷팅 |
| Lucide React | 아이콘 |
### 2.4 디자인 시스템
v5 Cosmic Glassmorphism (`frontend/styles/v5-layout.css`, `frontend/app/globals.css`). 모든 컴포넌트는 v5 토큰을 따른다. 즉흥 hex/rgb 금지.
---
## 3. 파일 구조
```
frontend/components/fc/ ← FieldConfig 기반 컴포넌트 (fc = FieldConfig)
├── FcTable.tsx ← 데이터 테이블
├── FcForm.tsx ← 입력 폼
├── FcSearch.tsx ← 검색 필터
├── FcButton.tsx ← 단일 버튼
├── FcButtonBar.tsx ← 버튼 그룹
├── FcPagination.tsx ← 페이지네이션
├── fields/ ← FieldType별 렌더러
│ ├── FieldRenderer.tsx ← FieldType→위젯 디스패처
│ ├── TextField.tsx
│ ├── NumberField.tsx
│ ├── DateField.tsx
│ ├── DateTimeField.tsx
│ ├── SelectField.tsx
│ ├── EntityField.tsx
│ ├── CheckboxField.tsx
│ ├── TextareaField.tsx
│ ├── FileField.tsx
│ └── CodeField.tsx
├── table/ ← 테이블 전용 셀 렌더러
│ └── CellRenderer.tsx ← FieldType→셀 포맷 디스패처
└── index.ts ← 공개 exports
```
---
## 4. 상세 설계
### 4.1 FieldRenderer — FieldType→위젯 디스패처
모든 폼/검색 입력 필드를 렌더링하는 허브 컴포넌트.
```typescript
// frontend/components/fc/fields/FieldRenderer.tsx
interface FieldRendererProps {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
mode: 'form' | 'search'; // 폼인지 검색인지에 따라 위젯이 달라짐
disabled?: boolean;
error?: string;
}
/**
* FieldConfig.type을 보고 적절한 입력 위젯을 렌더한다.
*
* mode='form' 이면 폼 렌더링 규칙 적용:
* date → DatePicker(단일), select → Select(단일)
*
* mode='search' 이면 검색 렌더링 규칙 적용:
* date → DateRangePicker(범위), select → MultiSelect(다중)
*/
function FieldRenderer({ field, value, onChange, mode, disabled, error }: FieldRendererProps) {
// field.type에 따라 switch/case로 분기
// 각 케이스에서 fields/ 폴더의 개별 컴포넌트를 렌더
}
```
### 4.2 렌더링 계약 (★ 반드시 이 테이블대로 구현)
| FieldType | FcTable (셀) | FcForm (입력) | FcSearch (검색) |
|---|---|---|---|
| `text` | 텍스트 그대로 | `<input type="text">` | `<input>` (부분 일치) |
| `number` | format 적용 (#,##0) | `<input type="number">` | min~max 범위 입력 2개 |
| `date` | format 적용 (YYYY-MM-DD) | DatePicker (단일) | **DateRangePicker (시작~종료)** |
| `datetime` | format 적용 | DateTimePicker | DateTimeRangePicker |
| `select` | 텍스트 그대로 | `<Select>` (단일) | **MultiSelect (다중, 체크박스)** |
| `entity` | ref.displayColumn 값 표시 | 팝업 검색 버튼 + 입력 | 팝업 검색 (단일) |
| `checkbox` | ✓/✗ 아이콘 | `<Checkbox>` | `<Select>` (전체/✓/✗) |
| `textarea` | 말줄임 (...) 40자 | `<textarea>` rows=3 | `<input>` (부분 일치) |
| `file` | 파일명 링크 | 파일 업로드 | — (검색 불가, 렌더 안 함) |
| `code` | 텍스트 그대로 | readonly 표시 | `<input>` (완전 일치) |
### 4.3 FcTable — 데이터 테이블
```typescript
// frontend/components/fc/FcTable.tsx
interface FcTableProps {
fields: FieldConfig[]; // 컬럼 정의
data: Record<string, any>[]; // 행 데이터
config?: TableConfig; // 테이블 설정 (invyone-component.ts)
loading?: boolean;
// ─── DataPort 출력 (콜백) ───
onRowSelect?: (row: Record<string, any>) => void; // selectedRow(row)
onRowsSelect?: (rows: Record<string, any>[]) => void; // selectedRows(rows)
// ─── DataPort 입력 ───
searchParams?: Record<string, any>; // 검색 조건 (적용 시 필터링/API 재호출)
}
```
**렌더링 규칙:**
1. `fields`에서 `visible: true`인 것만 컬럼으로 표시
2. `order` 순으로 정렬
3. `width` 적용 (없으면 타입별 기본값)
4. `align` 적용
5. `sortable: true`인 컬럼은 헤더 클릭 시 정렬
6. 셀 값은 `CellRenderer`가 FieldType에 따라 포맷팅
7. 행 클릭 시 `onRowSelect` 호출
8. 체크박스 선택 시 `onRowsSelect` 호출
**데이터 조회:**
- `config.autoLoad: true`이면 마운트 시 자동으로 `GET /api/data/{tableName}` 호출
- `searchParams`가 변경되면 재조회
- 페이지네이션은 FcPagination과 연동
### 4.4 FcForm — 입력 폼
```typescript
// frontend/components/fc/FcForm.tsx
interface FcFormProps {
fields: FieldConfig[]; // 필드 정의
config?: FormConfig; // 폼 설정
initialData?: Record<string, any>; // 수정 모드 시 초기값
// ─── DataPort 출력 ───
onSubmit?: (data: Record<string, any>) => void; // formData(row)
onSaved?: (data: Record<string, any>) => void; // savedRow(row)
// ─── DataPort 입력 ───
loadRow?: Record<string, any>; // 외부에서 행 데이터 로드
}
```
**렌더링 규칙:**
1. `fields`에서 `system: true`인 것은 숨김
2. `visible: true`인 것만 표시
3. `order` 순으로 배치
4. `config.columns` (1/2/3)에 따라 그리드 레이아웃
5. 각 필드는 `FieldRenderer(mode='form')`로 렌더
6. `required: true`인 필드에 * 표시
7. `editable: false`인 필드는 disabled
8. `pk: true`이고 `type: 'code'`이면 자동채번 표시 (readonly)
9. `config.sections`가 있으면 섹션별로 그룹핑
**유효성 검증:**
- 제출 시 `required: true` + 값이 비어있으면 에러 표시
- empty 판단: `value === null || value === undefined || value === ''` (0, false는 유효)
### 4.5 FcSearch — 검색 필터
```typescript
// frontend/components/fc/FcSearch.tsx
interface FcSearchProps {
fields: FieldConfig[]; // 전체 필드 (searchable: true인 것만 렌더)
config?: SearchConfig;
// ─── DataPort 출력 ───
onSearch?: (params: Record<string, any>) => void; // searchParams(params)
}
```
**렌더링 규칙:**
1. `fields`에서 `searchable: true`인 것만 표시
2. 각 필드는 `FieldRenderer(mode='search')`로 렌더
3. `config.layout: 'inline'`이면 한 줄에 나열, `'stacked'`이면 세로
4. "검색" 버튼 + "초기화" 버튼 (showResetButton)
5. `config.autoSearch: true`이면 입력 시 300ms 디바운스 후 자동 검색
6. 검색 버튼 클릭 또는 자동 검색 시 `onSearch(params)` 호출
**검색 파라미터 형식:**
```typescript
// date 범위: { order_date_from: '2026-01-01', order_date_to: '2026-12-31' }
// select 다중: { status: ['확정', '완료'] }
// text 부분 일치: { customer_name: '삼성' }
// number 범위: { amount_min: 1000, amount_max: 9999 }
```
### 4.6 FcButton / FcButtonBar
```typescript
// frontend/components/fc/FcButton.tsx
interface FcButtonProps {
config: ButtonConfig;
onClick?: () => void; // clicked(value)
disabled?: boolean;
}
// frontend/components/fc/FcButtonBar.tsx
interface FcButtonBarProps {
config: ButtonBarConfig;
onAction?: (actionType: ActionType) => void;
}
```
**ActionType 12종 (invyone-component.ts에 정의됨):**
save, edit, delete, add, cancel, close, navigate, popup, search, reset, submit, approval
### 4.7 FcPagination
```typescript
// frontend/components/fc/FcPagination.tsx
interface FcPaginationProps {
config?: PaginationConfig;
total: number;
page: number;
onPageChange?: (params: { page: number; size: number }) => void; // pageChange(params)
}
```
### 4.8 CellRenderer — 테이블 셀 포맷터
```typescript
// frontend/components/fc/table/CellRenderer.tsx
interface CellRendererProps {
field: FieldConfig;
value: any;
}
/**
* FieldType에 따라 셀 내용을 포맷팅한다:
* - number: format 적용 (#,##0 → 1,234,567)
* - date: format 적용 (YYYY-MM-DD)
* - checkbox: ✓/✗ 아이콘
* - entity: ref.displayColumn 값 표시 (별도 조회 필요 시 캐시)
* - textarea: 40자 말줄임 (...)
* - file: 파일명 링크
* - 나머지: 텍스트 그대로
*/
```
---
## 5. 데이터 연동 (CRUD API)
FcTable이 데이터를 조회하고, FcForm이 데이터를 저장할 때 사용하는 API.
기존 `DataController.java`가 범용 CRUD를 제공하지만, FieldConfig 기반으로 파라미터를 맞춰주는 래퍼가 필요.
**프론트엔드 API: `frontend/lib/api/fcData.ts`**
```typescript
/**
* ★ 별도 인터페이스 정의 안 함 — 백엔드가 Map<String, Object>이므로 프론트도 Record<string, any>
* ★ 유일하게 타입이 있는 건 FieldConfig (invyone-component.ts에 규격 확정된 것)
*/
/** FieldConfig 기반 목록 조회 */
export async function fcList(params: Record<string, any>): Promise<Record<string, any>>;
/** FieldConfig 기반 단건 조회 */
export async function fcGet(tableName: string, id: string): Promise<Record<string, any>>;
/** FieldConfig 기반 등록 */
export async function fcInsert(tableName: string, data: Record<string, any>): Promise<any>;
/** FieldConfig 기반 수정 */
export async function fcUpdate(tableName: string, id: string, data: Record<string, any>): Promise<any>;
/** FieldConfig 기반 삭제 (soft delete) */
export async function fcDelete(tableName: string, ids: string[]): Promise<any>;
```
**백엔드**: 기존 `DataController` 또는 `DataAdvancedController`의 엔드포인트를 활용. 필요하면 FieldConfig의 searchable/computed 등을 반영하는 동적 쿼리 빌더 추가.
---
## 6. 테스트 페이지
구현 확인용 테스트 페이지를 하나 만든다:
**`frontend/app/(main)/test-fc/page.tsx`**
```typescript
/**
* FieldConfig 컴포넌트 테스트 페이지
*
* 1. 드롭다운에서 테이블 선택
* 2. getTableFields(tableName) 호출 → FieldConfig[] 수신
* 3. FcSearch + FcTable + FcForm을 동시에 렌더
* 4. FcSearch → searchParams → FcTable (검색 연동)
* 5. FcTable 행 클릭 → selectedRow → FcForm (데이터 로드)
* 6. FcForm 저장 → DB에 실제 저장 → FcTable 새로고침
*/
```
이 페이지가 동작하면 Phase 2 완료.
---
## 7. 스타일링 규칙
1. v5 CSS 토큰 사용 (`var(--v5-primary)`, `var(--glass)`, `var(--glow-sm)` 등)
2. 컴팩트 폰트 (0.55~0.85rem)
3. 글래스모피즘: `backdrop-filter:blur(20px) saturate(1.4)` + `var(--glass)` + `var(--glass-border)`
4. 다크/라이트 모드 둘 다 지원
5. CSS 파일: `frontend/styles/fc-components.css` (필요 시) 또는 Tailwind 클래스 활용
6. 기존 `v5-layout.css``globals.css`의 토큰 먼저 확인 후 재사용
---
## 8. 참고 파일
| 파일 | 용도 |
|---|---|
| `notes/gbpark/2026-04-10-phase1-implementation-log.md` | **Phase 1 구현 결과** — API 응답 형태, 병합 규칙, 실 DB 테스트 결과, 범위 밖 항목 |
| `frontend/types/invyone-component.ts` | **모든 타입 정의 (진실의 원천)** |
| `notes/gbpark/2026-04-08-invyone-component-spec.md` (Section 2.2) | **렌더링 계약 테이블** — 반드시 이대로 구현 |
| `notes/gbpark/2026-04-09-invyone-architecture.md` (Section 4) | 렌더링 계약 요약 |
| `notes/gbpark/2026-04-08-invyone-mockup/js/08-admin-builder.js` | mockup의 프리뷰 렌더 함수 (pvTable, pvForm, pvSearch) — UI 감각 참고 |
| `notes/gbpark/2026-04-08-invyone-mockup/css/05-widgets.css` | HR 테이블 스타일 — 테이블 CSS 참고 |
| `frontend/styles/v5-layout.css` | v5 디자인 토큰 |
| `frontend/app/globals.css` | Tailwind/shadcn 토큰 |
---
## 9. 완료 기준
1. **FcTable**: FieldConfig[]과 데이터를 받아 테이블을 렌더링한다. 정렬, 행 선택, 페이지네이션이 동작한다.
2. **FcForm**: FieldConfig[]을 받아 입력 폼을 렌더링한다. 10종 FieldType 전부 동작한다. required 검증이 동작한다.
3. **FcSearch**: FieldConfig[]에서 searchable 필드만 추출하여 검색 UI를 렌더한다. 검색 시 params 객체를 반환한다.
4. **FcSearch → FcTable 연동**: 검색 실행 시 테이블이 필터링된 데이터를 표시한다.
5. **FcTable → FcForm 연동**: 행 클릭 시 폼에 해당 행 데이터가 로드된다.
6. **FcForm 저장**: 폼 제출 시 DB에 INSERT/UPDATE가 실행되고 테이블이 새로고침된다.
7. **테스트 페이지** (`/test-fc`)에서 위 전체 흐름이 실제 DB 데이터로 동작한다.
---
## 10. 다음 단계 연결
Phase 3 (개발자 빌더)는 이 컴포넌트들을:
- 팔레트에서 선택하여 캔버스에 배치
- 속성 패널에서 config(TableConfig, FormConfig 등) 조정
- Template JSON으로 저장/로드
Phase 3은 FcTable/FcForm/FcSearch를 "배치하고 설정하는 UI"이고, Phase 2는 그 컴포넌트 자체다.
@@ -0,0 +1,305 @@
# Phase 2 구현 작업기록 — FieldConfig 기반 컴포넌트 (FcTable/FcForm/FcSearch)
> **작업일**: 2026-04-10
> **설계서**: `notes/gbpark/2026-04-10-phase2-fieldconfig-components.md`
> **상태**: 구현 완료 + TypeScript 타입체크 통과 (tsc --noEmit 에러 0개)
---
## 1. 생성된 파일 (17개)
### 1.1 파일 구조
```
frontend/
├── components/fc/ ← FieldConfig 기반 컴포넌트 (신규)
│ ├── index.ts ← 공개 exports
│ ├── FcTable.tsx ← 데이터 테이블 (TanStack Table v8)
│ ├── FcForm.tsx ← 입력 폼 (자체 상태 관리)
│ ├── FcSearch.tsx ← 검색 필터
│ ├── FcButton.tsx ← 단일 버튼 (confirm 팝업 포함)
│ ├── FcButtonBar.tsx ← 버튼 그룹
│ ├── FcPagination.tsx ← 페이지네이션
│ ├── fields/ ← FieldType별 렌더러 (10종)
│ │ ├── FieldRenderer.tsx ← FieldType→위젯 디스패처
│ │ ├── TextField.tsx
│ │ ├── NumberField.tsx
│ │ ├── DateField.tsx
│ │ ├── DateTimeField.tsx
│ │ ├── SelectField.tsx
│ │ ├── EntityField.tsx
│ │ ├── CheckboxField.tsx
│ │ ├── TextareaField.tsx
│ │ ├── FileField.tsx
│ │ └── CodeField.tsx
│ └── table/ ← 테이블 전용
│ └── CellRenderer.tsx ← 셀 포맷 디스패처
├── lib/api/
│ └── fcData.ts ← FieldConfig 기반 CRUD API 래퍼 (신규)
└── app/(main)/test-fc/
└── page.tsx ← 테스트 페이지 (신규)
```
---
## 2. 핵심 컴포넌트 상세
### 2.1 FieldRenderer — FieldType→위젯 디스패처
`frontend/components/fc/fields/FieldRenderer.tsx`
- `mode='form'` / `mode='search'` 분기로 같은 FieldConfig가 다른 위젯으로 렌더
- switch/case로 10종 FieldType을 개별 컴포넌트에 위임
- 모든 필드 공통 인터페이스: `{ field, value, onChange, mode, disabled?, error? }`
### 2.2 렌더링 계약 구현 (spec Section 2.2 전부 준수)
| FieldType | FcTable 셀 | FcForm 입력 | FcSearch 검색 |
|---|---|---|---|
| `text` | 텍스트 그대로 | `<Input type="text">` | `<Input>` (부분 일치) |
| `number` | `#,##0` 포맷 (toLocaleString) | `<Input type="number">` | min~max 범위 2개 |
| `date` | YYYY-MM-DD 포맷 | `<Input type="date">` | from~to 범위 2개 |
| `datetime` | YYYY-MM-DD HH:mm 포맷 | `<Input type="datetime-local">` | from~to 범위 2개 |
| `select` | 텍스트 그대로 | Radix `<Select>` 단일 | MultiSelect (Checkbox 드롭다운) |
| `entity` | ref.displayColumn 그대로 | `<Input>` + 검색 버튼 | `<Input>` + 검색 버튼 |
| `checkbox` | ✓(green) / ✗(muted) 아이콘 | Radix `<Checkbox>` | `<Select>` 전체/✓/✗ |
| `textarea` | 40자 말줄임 (...) | `<textarea rows={3}>` | `<Input>` (부분 일치) |
| `file` | 파일명 + FileText 아이콘 | 파일 업로드 버튼 | — (렌더 안 함) |
| `code` | 텍스트 그대로 | readonly + Lock 아이콘 | `<Input>` (완전 일치) |
### 2.3 FcTable — 데이터 테이블
`frontend/components/fc/FcTable.tsx` (~190줄)
**기술 스택**: TanStack Table v8 (`@tanstack/react-table`)
**기능:**
- `fields`에서 `visible: true`인 것만 컬럼으로 표시, `order` 순 정렬
- `width`, `align` 적용 (없으면 타입별 기본값)
- `sortable: true`인 컬럼은 헤더 클릭 시 정렬 토글 (asc→desc→none)
- 행 클릭 시 `onRowSelect(row)` 호출 + 선택 행 하이라이트
- `showCheckbox: true` + `selectionMode: 'multiple'`일 때 체크박스 전체선택/개별선택
- 로딩 스피너, 빈 데이터 표시
- `CellRenderer`가 FieldType에 따라 셀 포맷팅
**Props:**
```typescript
interface FcTableProps {
fields: FieldConfig[];
data: Record<string, any>[];
config?: Partial<TableConfig>;
loading?: boolean;
onRowSelect?: (row: Record<string, any>) => void;
onRowsSelect?: (rows: Record<string, any>[]) => void;
selectedRowIndex?: number;
}
```
### 2.4 FcForm — 입력 폼
`frontend/components/fc/FcForm.tsx` (~130줄)
**상태 관리**: `useState` 직접 사용 (React Hook Form 의존성 제거 — 폼이 단순하고 FieldConfig 동적 필드라 자체 관리가 적합)
**기능:**
- `system: true` 필드 숨김, `visible: true`만 표시, `order`
- `config.columns` (1/2/3)에 따라 CSS Grid 레이아웃
- `config.sections`가 있으면 섹션별로 그룹핑 (라벨 + 구분선)
- `required: true` 필드에 * 표시
- `editable: false` 필드 disabled
- `pk: true` + `type: 'code'`이면 readonly (자동채번)
- `loadRow` 변경 시 폼 데이터 자동 갱신
- 제출 시 `required` 검증 (null/undefined/'' 만 empty, 0/false는 유효)
- 초기화 버튼 (loadRow/initialData로 복원)
**Props:**
```typescript
interface FcFormProps {
fields: FieldConfig[];
config?: Partial<FormConfig>;
initialData?: Record<string, any>;
onSubmit?: (data: Record<string, any>) => void;
loadRow?: Record<string, any>;
}
```
### 2.5 FcSearch — 검색 필터
`frontend/components/fc/FcSearch.tsx` (~110줄)
**기능:**
- `searchable: true` + `!system` 필드만 추출, `order`
- `FieldRenderer(mode='search')`로 각 필드 렌더
- `config.layout: 'inline'` (한 줄 나열) / `'stacked'` (세로)
- `config.autoSearch: true`이면 300ms 디바운스 후 자동 `onSearch`
- 검색/초기화 버튼
- `buildSearchParams()` 변환 로직:
```
date/datetime 범위 → { column_from: '2026-01-01', column_to: '2026-12-31' }
number 범위 → { column_min: 1000, column_max: 9999 }
select 다중 → { column: ['확정', '완료'] }
text 부분 일치 → { column: '삼성' }
```
### 2.6 CellRenderer — 테이블 셀 포맷터
`frontend/components/fc/table/CellRenderer.tsx` (~85줄)
| 타입 | 포맷 |
|---|---|
| number | `#,##0``toLocaleString('ko-KR')`, 소수점 포맷도 지원 |
| date | `YYYY-MM-DD` (Date 파싱 후 수동 포맷) |
| datetime | `YYYY-MM-DD HH:mm` |
| checkbox | ✓ (green Check 아이콘) / ✗ (muted X 아이콘) |
| textarea | 40자 초과 시 `...` 말줄임 + title 툴팁 |
| file | FileText 아이콘 + 파일명 링크 스타일 |
| null/undefined | `-` (muted) |
| 기타 | `String(value)` |
---
## 3. 보조 컴포넌트
### 3.1 FcButton
- `ButtonConfig.variant` 5종 (primary/default/destructive/outline/ghost) → v5 토큰 매핑
- `confirm` 속성 있으면 클릭 시 확인/취소 인라인 팝업
### 3.2 FcButtonBar
- `ButtonBarConfig.buttons[]` 순회하며 `FcButton` 렌더
- `onAction(actionType)` 콜백으로 12종 ActionType 전달
### 3.3 FcPagination
- 총 건수, 페이지 크기 선택기 (10/20/50/100)
- 5페이지 범위 번호 표시 + 처음/이전/다음/끝 버튼
- `onPageChange({ page, size })` 콜백
---
## 4. API 래퍼
### 4.1 fcData.ts
`frontend/lib/api/fcData.ts` (~35줄)
기존 `dataApi` (frontend/lib/api/data.ts)를 감싸는 얇은 래퍼.
| 함수 | 용도 | 내부 호출 |
|---|---|---|
| `fcList(params)` | 목록 조회 (검색+페이징) | `dataApi.getTableData()` |
| `fcGet(tableName, id)` | 단건 조회 | `dataApi.getRecordDetail()` |
| `fcInsert(tableName, data)` | 등록 | `dataApi.createRecord()` |
| `fcUpdate(tableName, id, data)` | 수정 | `dataApi.updateRecord()` |
| `fcDelete(tableName, ids)` | 삭제 (복수) | `dataApi.deleteRecord()` × N |
★ 별도 인터페이스 정의 안 함 — 전부 `Record<string, any>`
---
## 5. 테스트 페이지
### 5.1 `/test-fc` (frontend/app/(main)/test-fc/page.tsx)
**레이아웃**: 좌(검색+테이블+페이지네이션) / 우(폼) 2컬럼
**흐름:**
```
1. 드롭다운에서 테이블 선택
2. getMetaFields(tableName) → FieldConfig[] 수신
3. FcSearch + FcTable + FcForm 동시 렌더
4. FcSearch 검색 → searchParams → fcList() 재조회 → FcTable 갱신
5. FcTable 행 클릭 → selectedRow → FcForm 데이터 로드
6. FcForm 저장 → fcInsert/fcUpdate → 성공 메시지 + FcTable 새로고침
```
**기능:**
- 테이블 목록에 `has_custom_meta` 표시 (★)
- 테이블 선택 시 필드 수, PK 표시
- 수정/신규 모드 자동 전환 (selectedRow 유무)
- 성공/실패 알림 (3초 자동 소멸)
---
## 6. v5 디자인 토큰 사용 목록
모든 컴포넌트에서 즉흥 hex/rgb 사용 안 함. v5-layout.css 토큰만 사용:
| 용도 | 토큰 |
|---|---|
| 배경 | `var(--v5-glass)`, `var(--v5-surface)`, `var(--v5-bg-subtle)` |
| 테두리 | `var(--v5-glass-border)`, `var(--v5-border)`, `var(--v5-border-subtle)` |
| 텍스트 | `var(--v5-text)`, `var(--v5-text-sec)`, `var(--v5-text-muted)` |
| 강조 | `var(--v5-primary)`, `var(--v5-primary-glow)` |
| 상태 | `var(--v5-green)` (✓), `var(--v5-red)` (* 필수, 에러) |
| 호버 | `var(--v5-surface-hover)` |
| 글로우 | `var(--v5-glow-sm)` |
| 블러 | `backdrop-blur-[20px]` (v5 글래스 패턴) |
폰트 크기: 0.65rem(라벨) ~ 0.75rem(본문) — v5 컴팩트 스케일 준수
---
## 7. 덕일 스타일 / 프로젝트 컨벤션 준수
| 규칙 | 준수 |
|---|---|
| 프론트 타입: `Record<string, any>` (FieldConfig만 예외) | ✅ |
| 별도 인터페이스 정의 금지 | ✅ — Props만 인라인 정의 |
| FieldConfig/TableConfig 등은 invyone-component.ts 타입 사용 | ✅ |
| v5 CSS 토큰 사용, 즉흥 값 금지 | ✅ |
| shadcn UI 컴포넌트 재활용 (Input, Select, Checkbox) | ✅ |
| 기존 dataApi 활용 (중복 API 안 만듦) | ✅ |
| 컴팩트 폰트 사이즈 (0.55~0.85rem) | ✅ |
---
## 8. TypeScript 검증 결과
```bash
npx tsc --noEmit 2>&1 | grep -E "(components/fc/|test-fc/|lib/api/fcData)"
# 출력 없음 — 에러 0개
```
기존 코드(admin 페이지)의 타입 에러는 Phase 2와 무관.
---
## 9. Phase 2 범위 밖 (다음에 해야 할 것)
| 항목 | 담당 Phase | 비고 |
|---|---|---|
| entity 팝업 검색 (ref 테이블 조회) | Phase 3+ | 현재는 텍스트 입력 + 검색 버튼 UI만 |
| category 공통코드 옵션 로딩 | Phase 2+ | CODE_CATEGORY → 공통코드 테이블 조회 |
| 실제 파일 업로드 구현 | Phase 3+ | 현재는 파일 선택 UI만 |
| computed 수식 파서 | Phase 5 | AST 기반 안전한 파서 필요 |
| DataPort 이벤트 버스 | Phase 3 | 빌더에서 컴포넌트 간 연결 시 |
| inlineEdit (테이블 인라인 편집) | Phase 3+ | TableConfig.inlineEdit는 정의만 |
| 엑셀 내보내기 | Phase 3+ | toolbar.showExcel UI 미구현 |
| select options의 value/label 분리 | Phase 2+ | 현재 label만 string[]으로 반환 |
| defaultValue, placeholder 자동 세팅 | Phase 2+ | Phase 1에서 미구현 |
---
## 10. 사용법 (다음 Phase에서 참고)
```tsx
import { FcTable, FcForm, FcSearch, FcPagination } from '@/components/fc';
import { getMetaFields } from '@/lib/api/meta';
import { fcList } from '@/lib/api/fcData';
// 1. FieldConfig 가져오기
const meta = await getMetaFields('sales_order_mng');
const fields = meta.fields;
// 2. 데이터 조회
const result = await fcList({ tableName: 'sales_order_mng', page: 1, size: 20 });
// 3. 컴포넌트 렌더
<FcSearch fields={fields} onSearch={handleSearch} />
<FcTable fields={fields} data={result.data} onRowSelect={handleSelect} />
<FcForm fields={fields} loadRow={selectedRow} onSubmit={handleSave} />
<FcPagination total={result.total} page={1} onPageChange={handlePage} />
```
@@ -0,0 +1,533 @@
# Phase 3: 개발자 빌더 — 수동 템플릿 구성
> **목적**: 개발자가 테이블을 선택하고, FieldConfig 기반 컴포넌트를 배치하여 Template JSON을 만드는 빌더 UI 구현
> **전제 조건**: Phase 1 (DB 메타 API) + Phase 2 (FcTable/FcForm/FcSearch 컴포넌트) 완료
> **산출물**: 개발자 빌더 페이지 + Template CRUD API + Template JSON 저장/로드
> **다음 단계**: Phase 4에서 사용자가 이 Template을 대시보드(=메뉴)에 카드로 배치
---
## 1. 핵심 흐름
```
개발자 모드 진입 (톱니바퀴 버튼)
→ 테이블 선택 (드롭다운)
→ FieldConfig[] 자동 로드 (Phase 1 API)
→ 팔레트에서 컴포넌트 선택 → 캔버스에 배치
→ 속성 패널에서 설정 조정 (필드 ON/OFF, 컬럼 순서, config 등)
→ 3뷰 탭 전환 (목록/등록/수정)
→ 저장 → Template JSON이 DB에 저장됨
```
**자동생성(⚡ 버튼)과 프리셋은 Phase 6에서 구현. 이 단계에서는 수동 구성만.**
---
## 2. 현재 존재하는 것
| 파일 | 상태 | 활용 |
|---|---|---|
| `notes/gbpark/2026-04-08-invyone-mockup/js/08-admin-builder.js` | **mockup 완성** | UI/UX 참조 (진실의 원천) |
| `notes/gbpark/2026-04-08-invyone-mockup/index.html` (admin-builder 섹션) | **mockup 완성** | HTML 구조 참조 |
| `notes/gbpark/2026-04-08-invyone-mockup/css/09-developer.css` | **mockup 완성** | 개발자 모드 스타일 참조 |
| `frontend/types/invyone-component.ts` | **타입 완성** | Template, Component, ViewConfig 등 |
| `frontend/components/screen/ScreenDesigner.tsx` | 7,986줄 (VEX식) | 패턴 참고용, 직접 사용 안 함 |
| `backend-spring/.../TemplateStandardController.java` | 기존 VEX 템플릿 API | 확장 또는 신규 생성 |
---
## 3. 빌더 UI 레이아웃
mockup의 `developer.html` + `08-admin-builder.js` 기반. 3패널 IDE 스타일.
```
┌─────────────────────────────────────────────────────────┐
│ 헤더: [INVYONE] [DEV] [테이블 선택 ▼] [뷰 탭: 목록|등록|수정] [저장] [미리보기] │
├──────────┬────────────────────────────┬──────────────────┤
│ 팔레트 │ 캔버스 │ 속성 패널 │
│ (180px) │ (유동) │ (260px) │
│ │ │ │
│ ── 데이터 │ ┌──────────────────────┐ │ 컴포넌트 정보 │
│ 📊 테이블 │ │ [검색 필터] │ │ ───────────── │
│ 🔍 검색 │ │ │ │ 종류: 테이블 │
│ 📝 폼 │ │ [데이터 테이블] │ │ 이름: 수주 목록 │
│ │ │ │ │ │
│ ── 액션 │ │ │ │ 위치·크기 │
│ 🔘 버튼 │ └──────────────────────┘ │ X: 16 Y: 58 │
│ │ ┌──────────────────────┐ │ W: 854 H: 380 │
│ ── 표시 │ │ [페이지네이션] │ │ │
│ 📌 제목 │ └──────────────────────┘ │ 표시할 컬럼 │
│ ── 구분 │ │ ☑ 수주번호 │
│ │ ☑ 수주일 │
│ 빈 슬롯 │ │ ☑ 거래처 │
│ │ │ ☐ 비고 │
├──────────┴────────────────────────────┴──────────────────┤
│ 상태바: 블록 5개 · order_management_test · 수정됨 │
└─────────────────────────────────────────────────────────┘
```
### 3.1 팔레트 (좌측 180px)
**8종 컴포넌트** (아키텍처 문서 Section 3.2 확정):
| 아이콘 | 이름 | ComponentType | 드래그→캔버스 |
|---|---|---|---|
| 📊 | 데이터 테이블 | `table` | FcTable 배치 |
| 🔍 | 검색 필터 | `search` | FcSearch 배치 |
| 📝 | 입력 폼 | `form` | FcForm 배치 |
| 🔘 | 버튼 | `button` | FcButton 배치 |
| 📈 | 통계 카드 | `stats` | 통계 카드 배치 |
| 📌 | 제목/텍스트 | `title` | 텍스트 배치 |
| ── | 구분선 | `divider` | 구분선 배치 |
| 📊 | 차트 | `chart` | (Phase 2+에서 확장) |
드래그앤드롭으로 캔버스에 추가. 팔레트 아이템은 mockup의 `dev-pal-item` 스타일 참고.
### 3.2 캔버스 (중앙)
- 절대 좌표 배치 (position: absolute)
- 블록(컴포넌트)은 점선 보더 + 라벨
- 블록 클릭 → 선택 (파란 보더)
- 블록 드래그 → 이동 (Shift = 8px 스냅)
- 블록 우하단 핸들 → 리사이즈
- 블록 안에 Phase 2의 FcTable/FcForm/FcSearch가 프리뷰로 렌더됨
### 3.3 속성 패널 (우측 260px)
블록 선택 시 해당 컴포넌트의 설정을 편집:
**공통 속성:**
- 컴포넌트 종류 (readonly)
- 이름 (label)
- 위치: X, Y, W, H (숫자 입력, 캔버스와 양방향 동기화)
**타입별 속성 (invyone-component.ts의 Config 타입):**
| ComponentType | 설정 항목 |
|---|---|
| `table` | 표시할 컬럼 체크리스트, 페이지 크기, 선택 모드, 인라인 편집, 자동 로드, 정렬 |
| `form` | 표시할 필드 체크리스트, 컬럼 수(1/2/3), 섹션 구분, 저장 방식(INSERT/UPDATE/UPSERT) |
| `search` | 검색 대상 필드 체크리스트, 날짜 범위, 초기화 버튼, 자동 검색 |
| `button` | 버튼 텍스트, 액션 종류(12종), 스타일(5종), 확인 메시지 |
| `title` | 텍스트, 글꼴 크기, 글꼴 두께, 정렬 |
| `stats` | 통계 항목 목록 (라벨, 컬럼, 집계 방식) |
| `divider` | 선 스타일 (solid/dashed/dotted) |
**필드 체크리스트** (table/form/search 공통):
- 현재 선택된 테이블의 FieldConfig[] 표시
- 체크박스로 visible ON/OFF
- 드래그로 순서 변경
- 클릭하면 필드 상세 설정 열림 (label, width, required 등)
### 3.4 3뷰 탭
헤더에 `[목록 | 등록 | 수정]` 탭:
- **목록 (list)**: 검색 + 테이블 + 버튼바 + 페이지네이션
- **등록 (create)**: 팝업 오버레이 안에 폼 + 버튼
- **수정 (edit)**: 등록과 비슷, `extends: 'create'` 가능
목록은 캔버스 직접 배치, 등록/수정은 팝업 프레임 안에서 배치.
mockup 참조: `08-admin-builder.js``abSwitchView()`, `.ab-popup-overlay` 구조.
---
## 4. Template JSON 구조
`frontend/types/invyone-component.ts``Template` 인터페이스 그대로:
```typescript
interface Template {
templateId: string;
name: string;
category: string;
description?: string;
primaryTable: string;
fields: FieldConfig[]; // 모든 뷰가 공유하는 유일한 필드 정의
views: {
list: ViewConfig; // { components: Component[] }
create: ViewConfig;
edit: ViewConfig; // extends: 'create' 가능
};
connections: Connection[]; // DataPort 연결 (Phase 2의 DataPort)
companyCode: string;
version: number;
status: 'draft' | 'published';
createdAt: string;
updatedAt: string;
}
```
---
## 5. 백엔드 API 설계
### 5.1 Template CRUD
**`GET /api/templates`** — 템플릿 목록
```
Response: { data: [{ templateId, name, category, primaryTable, status, updatedAt }] }
```
**`GET /api/templates/{templateId}`** — 템플릿 상세 (Template JSON 전체)
```
Response: { data: Template }
```
**`POST /api/templates`** — 템플릿 생성
```
Request: Template (templateId 제외, 서버에서 생성)
Response: { data: { templateId: "tpl_xxx" } }
```
**`PUT /api/templates/{templateId}`** — 템플릿 수정
```
Request: Template (전체)
Response: { data: Template }
```
**`DELETE /api/templates/{templateId}`** — 템플릿 삭제
**`PUT /api/templates/{templateId}/publish`** — 템플릿 게시 (draft → published)
### 5.2 DB 테이블
**`templates` 테이블 (신규 생성):**
```sql
CREATE TABLE templates (
template_id VARCHAR(50) PRIMARY KEY,
name VARCHAR(200) NOT NULL,
category VARCHAR(50),
description TEXT,
primary_table VARCHAR(100) NOT NULL,
fields JSONB NOT NULL, -- FieldConfig[] JSON
views JSONB NOT NULL, -- { list, create, edit } JSON
connections JSONB DEFAULT '[]', -- Connection[] JSON
company_code VARCHAR(20) NOT NULL DEFAULT '*',
version INTEGER DEFAULT 1,
status VARCHAR(20) DEFAULT 'draft', -- draft | published
created_by VARCHAR(50),
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(50),
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active VARCHAR(1) DEFAULT 'Y'
);
CREATE INDEX idx_templates_company ON templates(company_code);
CREATE INDEX idx_templates_table ON templates(primary_table);
CREATE INDEX idx_templates_status ON templates(status);
```
### 5.3 Java 구현
**★ 패턴: 전부 BaseService + Map<String, Object>. 엔티티 클래스/DTO 없음.**
**신규 파일:**
| 파일 | 역할 |
|---|---|
| `TemplateController.java` | `/api/templates/*` 엔드포인트 |
| `TemplateService.java` | Template CRUD + 버전 관리 |
| `TemplateMapper.xml` | MyBatis SQL (JSONB 읽기/쓰기) |
**주의:** 기존 `TemplateStandardController`는 VEX용이므로 별도로 신규 생성. 이름 충돌 방지.
**TemplateService.java 구조:**
```java
@Service
@Slf4j
public class TemplateService extends BaseService {
@Autowired
private CommonService commonService;
public Map<String, Object> getTemplateList(Map<String, Object> params) {
commonService.applyPagination(params);
int totalCount = sqlSession.selectOne("template.getTemplateListCnt", params);
List<Map<String, Object>> list = sqlSession.selectList("template.getTemplateList", params);
return commonService.buildListResponse(list, totalCount, params);
}
public Map<String, Object> getTemplateInfo(Map<String, Object> params) {
return sqlSession.selectOne("template.getTemplateInfo", params);
}
@Transactional
public Map<String, Object> insertTemplate(Map<String, Object> params) {
// template_id 자동 생성 (UUID)
// fields, views, connections → ObjectMapper로 JSON 문자열 변환 후 #{fields}::jsonb
sqlSession.insert("template.insertTemplate", params);
return params;
}
@Transactional
public void updateTemplate(Map<String, Object> params) {
sqlSession.update("template.updateTemplate", params);
}
@Transactional
public int deleteTemplate(Map<String, Object> params) {
return sqlSession.update("template.deleteTemplate", params);
}
}
```
**★ 파일명: `template.xml`, namespace: `template` (덕일 스타일)**
```xml
<?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="template">
<select id="getTemplateList" parameterType="map" resultType="map">
SELECT
TEMPLATE_ID
, NAME
, CATEGORY
, PRIMARY_TABLE
, STATUS
, UPDATED_DATE
FROM TEMPLATES
WHERE 1=1
AND IS_ACTIVE != 'D'
<include refid="common.companyCodeFilter"/>
<include refid="common.dynamicOrderBy"/>
<include refid="common.pagination"/>
</select>
<select id="getTemplateListCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM TEMPLATES
WHERE 1=1
AND IS_ACTIVE != 'D'
<include refid="common.companyCodeFilter"/>
</select>
<select id="getTemplateInfo" parameterType="map" resultType="map">
SELECT
TEMPLATE_ID
, NAME
, CATEGORY
, DESCRIPTION
, PRIMARY_TABLE
, FIELDS
, VIEWS
, CONNECTIONS
, COMPANY_CODE
, VERSION
, STATUS
, CREATED_BY
, CREATED_DATE
, UPDATED_BY
, UPDATED_DATE
FROM TEMPLATES
WHERE TEMPLATE_ID = #{template_id}
AND IS_ACTIVE != 'D'
</select>
<insert id="insertTemplate" parameterType="map">
INSERT INTO TEMPLATES (
TEMPLATE_ID
, NAME
, CATEGORY
, DESCRIPTION
, PRIMARY_TABLE
, FIELDS
, VIEWS
, CONNECTIONS
, COMPANY_CODE
, VERSION
, STATUS
, CREATED_BY
, CREATED_DATE
) VALUES (
#{template_id}
, #{name}
, #{category}
, #{description}
, #{primary_table}
, #{fields}::jsonb
, #{views}::jsonb
, #{connections}::jsonb
, #{company_code}
, 1
, 'draft'
, #{user_id}
, NOW()
)
</insert>
<update id="updateTemplate" parameterType="map">
UPDATE TEMPLATES
SET
NAME = #{name}
, CATEGORY = #{category}
, DESCRIPTION = #{description}
, FIELDS = #{fields}::jsonb
, VIEWS = #{views}::jsonb
, CONNECTIONS = #{connections}::jsonb
, VERSION = VERSION + 1
, UPDATED_BY = #{user_id}
, UPDATED_DATE = NOW()
WHERE TEMPLATE_ID = #{template_id}
</update>
<update id="deleteTemplate" parameterType="map">
UPDATE TEMPLATES
SET IS_ACTIVE = 'D'
, UPDATED_BY = #{user_id}
, UPDATED_DATE = NOW()
WHERE TEMPLATE_ID = #{template_id}
</update>
</mapper>
```
---
## 6. 프론트엔드 구현
### 6.1 파일 구조
```
frontend/app/(main)/admin/builder/
└── page.tsx ← 빌더 메인 페이지
frontend/components/builder/
├── BuilderLayout.tsx ← 3패널 레이아웃
├── BuilderPalette.tsx ← 좌측 팔레트 (8종 컴포넌트)
├── BuilderCanvas.tsx ← 중앙 캔버스 (블록 배치/드래그/리사이즈)
├── BuilderProps.tsx ← 우측 속성 패널
├── BuilderToolbar.tsx ← 상단 툴바 (테이블 선택, 뷰 탭, 저장/미리보기)
├── BuilderBlock.tsx ← 캔버스 위의 개별 블록
├── BuilderViewTabs.tsx ← 3뷰 탭 (목록/등록/수정)
├── BuilderPopupFrame.tsx ← 등록/수정 팝업 편집 프레임
├── props/ ← 타입별 속성 패널
│ ├── TableProps.tsx
│ ├── FormProps.tsx
│ ├── SearchProps.tsx
│ ├── ButtonProps.tsx
│ ├── TitleProps.tsx
│ ├── StatsProps.tsx
│ └── FieldListEditor.tsx ← 필드 체크리스트 (공통)
└── hooks/
├── useBuilderState.ts ← 빌더 상태 관리 (Zustand)
└── useBlockDrag.ts ← 블록 드래그/리사이즈 로직
frontend/lib/api/template.ts ← Template CRUD API 클라이언트
```
### 6.2 빌더 상태 (Zustand)
```typescript
interface BuilderState {
// 테이블/필드
tableName: string | null;
fields: FieldConfig[];
// 현재 뷰
currentView: 'list' | 'create' | 'edit';
// 블록 목록 (뷰별)
blocks: {
list: Component[];
create: Component[];
edit: Component[];
};
// 선택된 블록
selectedBlockId: string | null;
// 템플릿 메타
templateId: string | null;
templateName: string;
category: string;
// 변경 상태
isDirty: boolean;
// 액션
setTable: (tableName: string, fields: FieldConfig[]) => void;
switchView: (view: 'list' | 'create' | 'edit') => void;
addBlock: (type: ComponentType, position: Position) => void;
removeBlock: (id: string) => void;
updateBlock: (id: string, updates: Partial<Component>) => void;
selectBlock: (id: string | null) => void;
moveBlock: (id: string, x: number, y: number) => void;
resizeBlock: (id: string, w: number, h: number) => void;
toTemplate: () => Template; // 현재 상태 → Template JSON
fromTemplate: (tpl: Template) => void; // Template JSON → 상태 로드
}
```
---
## 7. 스타일
개발자 모드는 **코스믹 글래스모피즘이 아닌 IDE 스타일** (mockup의 `09-developer.css` 참조):
- 중성 다크 그레이 (#121218, #1a1a22)
- 라이트 모드도 지원 (#f5f5f8, #ededf2)
- 글로우/블러 없음, 깔끔한 보더
- 액센트: 블루 (#5b9ef5)
- 폰트: 0.42~0.62rem (컴팩트)
---
## 8. 참고 파일
| 파일 | 용도 |
|---|---|
| `notes/gbpark/2026-04-08-invyone-mockup/js/08-admin-builder.js` | **빌더 UX 진실의 원천** — 드래그, 리사이즈, 속성 패널, 3뷰 전부 구현됨 |
| `notes/gbpark/2026-04-08-invyone-mockup/css/09-developer.css` | **개발자 모드 스타일** |
| `notes/gbpark/2026-04-08-invyone-mockup/developer.html` | 개발자 모드 HTML 구조 |
| `notes/gbpark/2026-04-08-invyone-mockup/builder-v2.html` | 빌더 v2 standalone |
| `frontend/types/invyone-component.ts` | Template, Component, Config 타입 |
| `notes/gbpark/2026-04-09-invyone-architecture.md` | 팔레트 8종, DataPort, 3뷰 구조 |
---
## 9. Template fields 동기화 규칙 (★ 메타 드리프트 방지)
Template.fields는 저장 시점의 FieldConfig[] 스냅샷이다. DB 스키마(table_type_columns)가 나중에 바뀌면 드리프트가 발생한다.
**동기화 정책:**
1. **빌더에서 Template 열 때마다** Phase 1 API(`GET /api/meta/tables/{tableName}/fields`)를 호출하여 최신 메타와 비교
2. **비교 결과 표시**:
- 새 컬럼 추가됨 → 빌더 상단에 "N개 컬럼 추가됨" 알림 + 필드 목록에 `(신규)` 표시
- 기존 컬럼 삭제됨 → 해당 필드에 `(DB에서 삭제됨)` 경고 표시
- 타입/옵션 변경됨 → 해당 필드에 `(변경됨)` 표시
3. **동기화 버튼** ("메타 동기화") 클릭 시:
- 새 컬럼 → fields에 추가 (visible=false 기본)
- 삭제된 컬럼 → fields에서 제거 (연관 사용자 오버라이드도 자동 무시됨)
- 변경된 타입 → fields 업데이트
4. **자동 동기화는 안 함** — 개발자가 명시적으로 "동기화" 눌러야 적용 (의도치 않은 변경 방지)
5. **사용자 화면(Phase 4)에서는** Template.fields 스냅샷을 그대로 사용 (라이브 메타 조회 안 함 — 성능 + 안정성)
---
## 10. 완료 기준
1. `/admin/builder` 페이지에서 3패널 빌더가 동작한다
2. 드롭다운에서 테이블 선택 → FieldConfig[] 로드 → 팔레트 활성화
3. 팔레트에서 컴포넌트를 캔버스에 드래그앤드롭으로 추가할 수 있다
4. 캔버스에서 블록을 드래그(이동)/리사이즈할 수 있다
5. 블록 클릭 → 속성 패널에 해당 Config가 표시되고, 변경 시 캔버스에 반영된다
6. 필드 체크리스트에서 ON/OFF → 프리뷰에 실시간 반영
7. 3뷰 탭 (목록/등록/수정) 전환이 동작한다
8. 저장 → Template JSON이 DB에 저장된다
9. 기존 Template 로드 → 빌더에 상태 복원
10. 실제 테이블 (예: order_management_test)로 템플릿을 만들고 저장/로드 확인
---
## 10. 다음 단계 연결
Phase 4 (대시보드=메뉴)에서는:
- 사용자가 대시보드를 만들면 → 사이드바 메뉴에 등록됨
- 대시보드에 Template을 카드로 배치 → 카드 안에서 Phase 2의 FcTable/FcForm/FcSearch가 렌더됨
- 개발자가 만든 Template이 "사용 가능한 템플릿 라이브러리"에 나타남
@@ -0,0 +1,480 @@
# Phase 3 구현 작업기록 — 개발자 빌더 (수동 템플릿 구성)
> **작업일**: 2026-04-10
> **설계서**: `notes/gbpark/2026-04-10-phase3-developer-builder.md`
> **상태**: 구현 완료 + TypeScript 타입체크 통과 + DB 테이블 생성 완료
---
## 1. 생성된 파일 (18개)
### 1.1 파일 구조
```
backend-spring/
├── src/main/java/com/erp/
│ ├── controller/TemplateController.java ← CRUD + publish 엔드포인트 (신규)
│ └── service/TemplateService.java ← Template CRUD 비즈니스 로직 (신규)
└── src/main/resources/mapper/
└── template.xml ← MyBatis SQL (신규)
frontend/
├── lib/api/
│ └── template.ts ← Template CRUD API 클라이언트 (신규)
├── styles/
│ └── developer.css ← IDE 스타일 개발자 테마 (신규)
├── components/builder/ ← 빌더 컴포넌트 디렉토리 (전체 신규)
│ ├── BuilderLayout.tsx ← 3패널 셸 + 키보드 단축키 + 상태바
│ ├── BuilderToolbar.tsx ← 헤더 + 도구모음 (테이블 선택, 뷰 탭, 저장)
│ ├── BuilderPalette.tsx ← 좌측 팔레트 (8종 컴포넌트)
│ ├── BuilderCanvas.tsx ← 중앙 캔버스 (드롭 영역, 팝업 뷰)
│ ├── BuilderBlock.tsx ← 개별 블록 (드래그/리사이즈 + 타입별 프리뷰)
│ ├── BuilderProps.tsx ← 우측 속성 패널 (공통 + 타입별 분기)
│ ├── hooks/
│ │ ├── useBuilderState.ts ← Zustand 빌더 상태관리
│ │ └── useBlockDrag.ts ← 블록 드래그/리사이즈 훅
│ └── props/
│ ├── FieldListEditor.tsx ← 필드 체크리스트 (공통)
│ ├── TableProps.tsx ← 테이블 속성 패널
│ ├── FormProps.tsx ← 폼 속성 패널
│ ├── SearchProps.tsx ← 검색 속성 패널
│ ├── ButtonProps.tsx ← 버튼/버튼바 속성 패널
│ └── TitleProps.tsx ← 제목 속성 패널
└── app/(main)/admin/builder/
└── page.tsx ← 빌더 페이지 진입점
```
### 1.2 수정된 파일 (2개)
| 파일 | 변경 내용 |
|---|---|
| `backend-spring/src/main/resources/application.yml` | DB 접속 정보 변경: `39.117.244.52:11132/testvex``211.115.91.141:11134/test_dev` |
| `CLAUDE.local.md` | DB 접속 정보 업데이트 기록 |
---
## 2. 백엔드 상세
### 2.1 TemplateController.java (~100줄)
`/api/templates` REST 컨트롤러. 6개 엔드포인트:
| 메서드 | 엔드포인트 | 설명 |
|---|---|---|
| `GET` | `/api/templates` | 템플릿 목록 (keyword, status, category 필터 + 페이지네이션) |
| `GET` | `/api/templates/{templateId}` | 템플릿 상세 (JSONB 파싱된 Template 전체) |
| `POST` | `/api/templates` | 템플릿 생성 (template_id 자동 생성: `tpl_` + UUID 12자) |
| `PUT` | `/api/templates/{templateId}` | 템플릿 수정 (version 자동 증가) |
| `PUT` | `/api/templates/{templateId}/publish` | 템플릿 게시 (draft → published) |
| `DELETE` | `/api/templates/{templateId}` | 템플릿 삭제 (소프트: IS_ACTIVE='D') |
**패턴**: MetaController와 동일 — `@RequestAttribute("company_code")`, `@RequestAttribute("user_id")`, `ApiResponse.success/error`
### 2.2 TemplateService.java (~120줄)
덕일 스타일 준수 — `extends BaseService`, `@Autowired CommonService`, `sqlSession` 직접 호출.
| 메서드 | 역할 |
|---|---|
| `getTemplateList(params)` | CommonService.applyPagination → selectList + selectOne(Cnt) → buildListResponse |
| `getTemplateInfo(params)` | selectOne + JSONB 파싱 (fields, views, connections) |
| `insertTemplate(params)` | UUID 생성 + JSON 직렬화 + insert |
| `updateTemplate(params)` | JSON 직렬화 + update (VERSION + 1) |
| `publishTemplate(params)` | status='published' 업데이트 |
| `deleteTemplate(params)` | IS_ACTIVE='D' 소프트 삭제 |
**JSONB 처리 유틸 (private):**
- `parseJsonField(row, key)` — PostgreSQL JSONB 문자열 → Java Object (ObjectMapper)
- `stringifyJsonField(params, key)` — Java Object → JSON 문자열 (INSERT/UPDATE 전)
### 2.3 template.xml (~120줄)
MyBatis 매퍼. namespace=`template`, 쿼리 6개:
| 쿼리 ID | 용도 |
|---|---|
| `getTemplateList` | 목록 (keyword LIKE, status/category 필터, companyCodeFilter, dynamicOrderBy, pagination) |
| `getTemplateListCnt` | 목록 카운트 |
| `getTemplateInfo` | 단건 (FIELDS, VIEWS, CONNECTIONS JSONB 포함) |
| `insertTemplate` | 등록 (`#{fields}::jsonb` 캐스팅) |
| `updateTemplate` | 수정 (VERSION + 1) |
| `deleteTemplate` | 소프트 삭제 |
| `publishTemplate` | 게시 상태 변경 |
**common include 사용**: `companyCodeFilter`, `dynamicOrderBy`, `pagination`
**OGNL test**: 바깥 작은따옴표 규칙 준수 (`test='keyword != null and keyword != ""'`)
---
## 3. 프론트엔드 상세
### 3.1 template.ts — API 클라이언트 (~40줄)
| 함수 | 반환 | 설명 |
|---|---|---|
| `getTemplateList(params?)` | `Record<string, any>` | 목록 (list + total_count) |
| `getTemplateInfo(templateId)` | `Record<string, any> \| null` | 상세 (Template JSON 전체) |
| `insertTemplate(data)` | `Record<string, any>` | 생성 → `{ template_id }` 반환 |
| `updateTemplate(templateId, data)` | `void` | 수정 |
| `publishTemplate(templateId)` | `void` | 게시 |
| `deleteTemplate(templateId)` | `void` | 삭제 |
★ 별도 인터페이스 정의 안 함 — 전부 `Record<string, any>` (덕일 스타일)
### 3.2 developer.css — IDE 스타일 테마 (~350줄)
mockup `09-developer.css` 기반 React 포팅. v5 코스믹이 아닌 IDE/Figma 스타일.
**색상 체계 (CSS 변수 `--d-*`):**
| 변수 | 다크 | 라이트 | 용도 |
|---|---|---|---|
| `--d-bg` | #121218 | #f5f5f8 | 기본 배경 |
| `--d-bg2` | #1a1a22 | #ededf2 | 패널 배경 |
| `--d-bg3` | #22222c | #e4e4ec | 입력 배경 |
| `--d-surface` | #2a2a36 | #fff | 호버 배경 |
| `--d-border` | #3a3a48 | #d8d8e2 | 기본 보더 |
| `--d-text` | #e8e8ee | #1a1a24 | 기본 텍스트 |
| `--d-accent` | #5b9ef5 | #3b7dd8 | 액센트 (블루) |
| `--d-red` | #f87171 | #dc2626 | 위험/필수 |
| `--d-green` | #4ade80 | #16a34a | 성공/검색 |
**주요 클래스:**
- `.dev-shell` — 전체 셸 (flex column, 100vh)
- `.dev-hdr` — 헤더 (42px)
- `.dev-toolbar` — 도구모음 (34px)
- `.dev-palette` — 좌측 팔레트 (180px)
- `.dev-canvas` — 중앙 캔버스 (도트 그리드 배경)
- `.dev-props` — 우측 속성 패널 (260px)
- `.dev-block` — 캔버스 위 블록 (점선 보더, 선택 시 solid + 글로우)
- `.dev-status` — 하단 상태바 (22px)
- `.dev-popup-overlay/frame` — 등록/수정 팝업 편집 프레임
**폰트 사이즈**: 0.36rem(배지) ~ 0.72rem(로고) — mockup 컴팩트 스케일 그대로
### 3.3 useBuilderState.ts — Zustand 상태관리 (~280줄)
`create<BuilderState>()(devtools(...))` 패턴 (기존 tabStore와 동일).
**상태:**
| 키 | 타입 | 설명 |
|---|---|---|
| `tableName` | `string \| null` | 선택된 테이블 |
| `fields` | `FieldConfig[]` | 현재 테이블의 필드 목록 |
| `currentView` | `'list' \| 'create' \| 'edit'` | 현재 뷰 탭 |
| `blocks` | `Record<BuilderView, Component[]>` | 뷰별 블록 목록 |
| `selectedBlockId` | `string \| null` | 선택된 블록 |
| `connections` | `Connection[]` | DataPort 연결 목록 |
| `templateId` | `string \| null` | 저장된 템플릿 ID |
| `templateName` | `string` | 템플릿 이름 |
| `category` | `string` | 분류 |
| `description` | `string` | 설명 |
| `isDirty` | `boolean` | 변경 여부 |
**액션 (17개):**
| 액션 | 설명 |
|---|---|
| `setTable(name, fields)` | 테이블 선택 → fields 로드 |
| `switchView(view)` | 뷰 탭 전환 (선택 해제) |
| `addBlock(type, position)` | 블록 추가 (기본 config/size/label 자동 설정) |
| `removeBlock(id)` | 블록 삭제 (연결도 함께 제거) |
| `updateBlock(id, updates)` | 블록 업데이트 |
| `selectBlock(id)` | 블록 선택 |
| `moveBlock(id, x, y)` | 블록 이동 (min 0) |
| `resizeBlock(id, w, h)` | 블록 리사이즈 (min 40x20) |
| `updateBlockConfig(id, config)` | 타입별 config 업데이트 |
| `updateField(column, updates)` | FieldConfig 속성 변경 |
| `setTemplateMeta(meta)` | 템플릿 이름/분류/설명 변경 |
| `addConnection(conn)` | DataPort 연결 추가 |
| `removeConnection(connId)` | 연결 제거 |
| `toTemplate()` | 현재 상태 → Template JSON (저장용) |
| `fromTemplate(tpl)` | Template JSON → 상태 복원 (로드용) |
| `resetBuilder()` | 초기화 |
| `markClean()` | isDirty=false |
**셀렉터 훅:**
- `useCurrentViewBlocks()` — 현재 뷰의 블록 목록
- `useSelectedBlock()` — 선택된 블록 객체
**컴포넌트 기본 설정 (`defaultConfig`):**
| ComponentType | 기본 config |
|---|---|
| `table` | pageSize:20, selectionMode:'single', autoLoad:true, style:'default' |
| `form` | columns:2, saveAction:{method:'UPSERT', refreshAfterSave:true} |
| `search` | dateRangeEnabled:true, showResetButton:true, autoSearch:false, layout:'inline' |
| `button` | text:'버튼', actionType:'save', variant:'default' |
| `button-bar` | buttons:[{등록/primary}, {삭제/destructive}] |
| `title` | text:'제목', fontSize:'0.75rem', fontWeight:'700', align:'left' |
| `stats` | items:[] |
| `divider` | style:'solid' |
| `pagination` | pageSize:20, showSizeSelector:true, sizeOptions:[10,20,50,100] |
**컴포넌트 기본 크기 (`defaultSize`):**
| ComponentType | W × H |
|---|---|
| `table` | 854 × 380 |
| `form` | 440 × 300 |
| `search` | 854 × 42 |
| `button` | 100 × 36 |
| `button-bar` | 370 × 36 |
| `title` | 300 × 36 |
| `pagination` | 854 × 24 |
### 3.4 useBlockDrag.ts — 드래그/리사이즈 훅 (~90줄)
mousedown → document.mousemove → mouseup 패턴.
- `startDrag(e, id, origX, origY, origW, origH)` — 블록 이동 시작
- `startResize(e, id, origX, origY, origW, origH)` — 리사이즈 시작
- **Shift 키**: 8px 스냅 (mockup의 `Math.round(n/8)*8` 그대로)
- mouseup 시 리스너 자동 정리
- `document.body.style.cursor/userSelect` 드래그 중 설정/해제
### 3.5 BuilderLayout.tsx — 3패널 셸 (~55줄)
```
┌─ BuilderToolbar (헤더 + 도구모음) ─────────────────────┐
├──────────┬────────────────────────────┬──────────────────┤
│ Palette │ Canvas │ Props │
│ (180px) │ (flex:1) │ (260px) │
├──────────┴────────────────────────────┴──────────────────┤
│ 상태바: 블록 N개 · 테이블명 · 연결 N개 │
└─────────────────────────────────────────────────────────┘
```
**키보드 단축키:**
- `Delete` / `Backspace` — 선택된 블록 삭제
- `Escape` — 블록 선택 해제
### 3.6 BuilderToolbar.tsx — 헤더 + 도구모음 (~130줄)
**헤더 (dev-hdr):**
- INVYONE 로고 + DEV 배지
- 템플릿 이름 입력
- 저장 버튼
**도구모음 (dev-toolbar):**
- 테이블 드롭다운 (getMetaTableList → has_custom_meta ★ 표시)
- 뷰 탭 (목록 / 등록 / 수정)
- 수정됨 표시 (isDirty)
**저장 흐름:**
1. `toTemplate()` → Template JSON 생성
2. `templateId` 있으면 `updateTemplate()`, 없으면 `insertTemplate()`
3. 성공 시 `markClean()` + templateId 저장
### 3.7 BuilderPalette.tsx — 좌측 팔레트 (~75줄)
8종 컴포넌트를 4개 섹션으로 분류:
| 섹션 | 컴포넌트 |
|---|---|
| 데이터 | 📊 데이터 테이블, 🔍 검색 필터 |
| 입력 | 📝 입력 폼 |
| 액션 | 🔘 버튼, ⬜ 버튼 바 |
| 표시 | 📌 제목/텍스트, 📈 통계 카드, ── 구분선, 📄 페이지네이션 |
- **드래그앤드롭**: `onDragStart``component-type` 데이터 전달 → 캔버스에서 `onDrop`
- **클릭 추가**: 테이블 미선택 시 data 컴포넌트 비활성화 (opacity 0.4)
### 3.8 BuilderCanvas.tsx — 중앙 캔버스 (~80줄)
- **목록 뷰**: 전체 캔버스에 블록 자유 배치 (min 1200×800, 도트 그리드 배경)
- **등록/수정 뷰**: `dev-popup-overlay` + `dev-popup-frame` (500px 너비) 안에 블록 배치
- **드롭 처리**: `onDrop` → 마우스 좌표 계산 → `addBlock(type, {x, y})`
- **빈 캔버스**: 안내 메시지 표시 ("팔레트에서 컴포넌트를 드래그하거나 클릭하여 추가하세요")
- **선택 해제**: 캔버스 빈 공간 클릭 시 `selectBlock(null)`
### 3.9 BuilderBlock.tsx — 개별 블록 (~160줄)
**구조:**
```
.dev-block (position:absolute, 점선 보더)
├── .dev-block-label (블록 이름)
├── .dev-block-content (타입별 프리뷰)
└── .dev-resize-handle (우하단 리사이즈 핸들)
```
**타입별 프리뷰 (BlockPreview 내부 컴포넌트):**
| 타입 | 프리뷰 내용 |
|---|---|
| `table` | `<table>` 헤더 + 3행 더미 (visible 필드 최대 8개) |
| `form` | CSS Grid (columns 수) + 필드 라벨/입력 (최대 10개, required * 표시) |
| `search` | 가로 나열 필드 라벨/입력 (searchable 필드 최대 5개) + 검색 버튼 |
| `title` | fontSize/fontWeight/align 적용된 텍스트 |
| `button` | variant 스타일 적용된 단일 버튼 |
| `button-bar` | 버튼 목록 가로 나열 |
| `pagination` | 총 건수 / 페이지 번호 / 건수 선택기 |
| `divider` | 수평선 |
| `stats` | "통계 카드 프리뷰" 텍스트 |
**FieldOption 렌더링**: `string | {value, label}` 유니온 타입 처리 (tsc 에러 수정)
### 3.10 BuilderProps.tsx — 우측 속성 패널 (~90줄)
**공통 속성 (모든 블록):**
- 컴포넌트 종류 (아이콘 + 한글 라벨)
- 이름 (input, 캔버스 라벨과 양방향 동기화)
- 위치·크기 (X/Y/W/H 4칸 그리드, 캔버스와 양방향)
**타입별 분기:**
- `table` → TableProps
- `form` → FormProps
- `search` → SearchProps
- `button` → SingleButtonProps
- `button-bar` → ButtonBarProps
- `title` → TitleProps
**공통 삭제 버튼**: 하단 빨간 테두리 버튼
### 3.11 FieldListEditor.tsx — 필드 체크리스트 (~110줄)
table/form/search 속성 패널에서 공통으로 사용하는 필드 목록.
**기능:**
- 체크박스 토글 (`visible` 또는 `searchable` 속성)
- 필드 배지 표시 (PK, 필수, 검색, SYS, 계산)
- 타입 배지 (text, number, date 등)
- **클릭하면 상세 펼침** (FieldDetail 패널):
- 표시 이름, 너비 편집
- 토글: 필수, 편집, 검색, 정렬 (dev-toggle 스타일)
### 3.12 타입별 속성 패널
**TableProps.tsx** (~70줄):
- 페이지 크기 (10/20/50/100)
- 선택 방식 (없음/단일/다중)
- 자동 로드, 인라인 편집, 체크박스 토글
- 스타일 (기본/줄무늬/테두리/컴팩트)
- FieldListEditor (visible 토글)
**FormProps.tsx** (~40줄):
- 컬럼 수 (1/2/3칸)
- 저장 방식 (등록/수정/등록+수정)
- FieldListEditor (visible 토글)
**SearchProps.tsx** (~50줄):
- 날짜 범위 검색, 초기화 버튼, 자동 검색 토글
- 레이아웃 (인라인/세로)
- FieldListEditor (searchable 토글)
**ButtonProps.tsx** (~140줄):
- `SingleButtonProps`: 텍스트, 액션 종류(12종), 스타일(5종), 확인 메시지
- `ButtonBarProps`: 버튼 목록 CRUD (추가/삭제/편집), 각 버튼 액션+스타일 설정
**TitleProps.tsx** (~40줄):
- 텍스트, 크기(4단계), 굵기(4단계), 정렬(3종)
---
## 4. DB 변경
### 4.1 templates 테이블 (신규)
```sql
CREATE TABLE templates (
template_id VARCHAR(50) PRIMARY KEY,
name VARCHAR(200) NOT NULL,
category VARCHAR(50),
description TEXT,
primary_table VARCHAR(100) NOT NULL,
fields JSONB NOT NULL,
views JSONB NOT NULL,
connections JSONB DEFAULT '[]',
company_code VARCHAR(20) NOT NULL DEFAULT '*',
version INTEGER DEFAULT 1,
status VARCHAR(20) DEFAULT 'draft',
created_by VARCHAR(50),
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(50),
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active VARCHAR(1) DEFAULT 'Y'
);
```
**인덱스 3개:**
- `idx_templates_company` (company_code)
- `idx_templates_table` (primary_table)
- `idx_templates_status` (status)
**실행 대상**: `211.115.91.141:11134/test_dev` (2026-04-10 실행 완료)
### 4.2 application.yml DB 접속 변경
```
변경 전: jdbc:postgresql://39.117.244.52:11132/testvex (pw: ph0909!!)
변경 후: jdbc:postgresql://211.115.91.141:11134/test_dev (pw: vexplor0909!!)
```
39.117 서버 폐기에 따른 변경.
---
## 5. 덕일 스타일 / 프로젝트 컨벤션 준수
| 규칙 | 준수 |
|---|---|
| 3레이어 (Controller → Service → XML) | ✅ |
| Mapper Interface 금지 | ✅ — sqlSession 직접 호출 |
| Map<String, Object> 사용, DTO 금지 | ✅ — ApiResponse만 예외 |
| BaseService 상속 | ✅ |
| @Autowired CommonService | ✅ |
| XML 파일명: 소문자, Mapper 안 붙임 | ✅ — `template.xml` |
| XML namespace: 파일명과 동일 | ✅ — `namespace="template"` |
| SQL: UPPER_SNAKE | ✅ |
| SELECT 쉼표: 앞에 | ✅ |
| #{파라미터}: snake_case | ✅ |
| OGNL test: 바깥 작은따옴표 | ✅ |
| 프론트 타입: Record<string, any> | ✅ — FieldConfig/Component만 예외 |
| 개발자 모드 CSS: IDE 스타일 (v5 코스믹 아님) | ✅ — `--d-*` 변수 체계 |
| Zustand devtools 미들웨어 | ✅ |
| 기존 apiClient 사용 | ✅ |
---
## 6. 검증 결과
| 검증 항목 | 결과 |
|---|---|
| `./gradlew compileJava` | BUILD SUCCESSFUL |
| `./gradlew bootJar` | BUILD SUCCESSFUL |
| `npx tsc --noEmit` (빌더 관련) | 에러 0개 |
| DB 테이블 생성 (test_dev) | CREATE TABLE + INDEX 3개 성공 |
---
## 7. Phase 3 범위 밖 (다음에 해야 할 것)
| 항목 | 담당 Phase | 비고 |
|---|---|---|
| 메타 드리프트 감지 (빌더에서 Template 열 때 최신 메타 비교) | Phase 3+ | 설계서 Section 9에 정의됨 |
| DataPort 연결 UI (속성 패널에서 연결 추가/삭제) | Phase 3+ | mockup의 `propsConnections()` 참고 |
| 기존 Template 목록/로드 UI | Phase 3+ | 현재는 새 템플릿 생성만 |
| 자동생성 + 프리셋 (⚡ 버튼) | Phase 6 | 설계서 명시 |
| stats/chart 타입 속성 패널 | Phase 3+ | 현재 빈 구조만 |
| 필드 순서 드래그 재정렬 | Phase 3+ | 현재 order 기준 정렬만 |
| Template 게시 워크플로우 UI | Phase 4 | 대시보드에서 게시된 Template 사용 |
---
## 8. 빌더 사용 흐름
```
1. /admin/builder 접속
2. 도구모음에서 테이블 선택 (예: sales_order_mng)
→ Phase 1 API로 FieldConfig[] 자동 로드
3. 팔레트에서 컴포넌트 선택 → 캔버스에 드래그 또는 클릭
(예: 제목 → 검색 필터 → 데이터 테이블 → 페이지네이션)
4. 캔버스에서 블록 드래그(이동) / 우하단 핸들로 리사이즈
5. 블록 클릭 → 우측 속성 패널에서 설정 조정
(필드 ON/OFF, 페이지 크기, 선택 모드 등)
6. 뷰 탭으로 목록/등록/수정 전환
(등록/수정은 팝업 프레임 안에서 편집)
7. 💾 저장 → Template JSON이 DB에 저장
8. 기존 Template 로드 → fromTemplate()으로 상태 복원
```
@@ -0,0 +1,400 @@
# Phase 4: 대시보드(=메뉴) — 사용자 화면 시스템
> **목적**: 사용자가 대시보드(=메뉴)를 만들고, 개발자가 만든 Template을 카드로 배치하여 업무 화면을 완성하는 시스템 구현
> **전제 조건**: Phase 1~3 완료 (FieldConfig API + 규격 컴포넌트 + 개발자 빌더에서 Template 생성 가능)
> **산출물**: 대시보드 CRUD + 사이드바 메뉴 자동 등록 + 템플릿 카드 배치 캔버스 + 사용자 오버라이드
> **다음 단계**: Phase 5에서 카드 간 비즈니스 룰/데이터 흐름 설정 (제어 모드)
---
## 1. 핵심 개념
**대시보드 = 메뉴 = 화면.** "대시보드"라는 이름이지만, 통계/차트 모아놓는 곳이 아니다.
```
사용자가 "수주관리" 대시보드 생성
→ 사이드바 메뉴에 "수주관리" 자동 등록
→ 캔버스에 수주관리 Template 카드 배치
→ 화면 완성!
→ 비즈니스 로직 필요하면 → Phase 5 (제어 모드)
```
---
## 2. 사용자 흐름 (mockup 기준)
mockup의 `index.html` + `js/05-state.js` + `js/01-shell.js` 참조.
### 2.1 대시보드 생성
1. 사이드바 하단의 **`+ 새 대시보드`** 클릭
2. 이름 입력 (예: "수주관리")
3. → 사이드바 메뉴에 자동 등록
4. → 빈 캔버스가 열림 ("아직 템플릿이 없습니다")
### 2.2 템플릿 카드 배치
1. 캔버스 상단의 **`+ 템플릿 추가`** 클릭
2. **템플릿 라이브러리 모달** 열림:
- 좌측: 카테고리 (영업, 생산, 인사, 재고, 관리자...)
- 우측: 해당 카테고리의 Template 카드 목록
- 검색 가능
3. Template 카드 클릭 → 캔버스에 추가
4. 여러 Template을 추가하여 한 대시보드(=화면)에 여러 카드 배치 가능
### 2.3 카드 조작
- **편집 모드** (우상단 편집 버튼) 켜면:
- 카드 드래그 (자유 배치, snap 없음)
- 카드 리사이즈 (우하단 핸들)
- 카드 삭제 (X 버튼)
- **카드 접기** (▼ 버튼) → 미니 KPI 뷰로 축소
- **카드 설정** (⚙ 버튼) → 사용자 오버라이드:
- 컬럼 표시/숨김
- 검색 필터 표시/숨김
- 통계 카드 표시/숨김
### 2.4 저장/복원
- 대시보드 레이아웃(카드 위치/크기/설정) 자동 저장
- 다음 접속 시 그대로 복원
---
## 3. 데이터 모델
### 3.1 대시보드 (DB 테이블)
**`dashboards` 테이블 (신규 생성):**
```sql
CREATE TABLE dashboards (
dashboard_id VARCHAR(50) PRIMARY KEY,
name VARCHAR(200) NOT NULL,
icon VARCHAR(10) DEFAULT '📋',
display_order INTEGER DEFAULT 0,
company_code VARCHAR(20) NOT NULL,
user_id VARCHAR(50), -- NULL이면 회사 공통
is_active VARCHAR(1) DEFAULT 'Y',
created_by VARCHAR(50),
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(50),
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_dashboards_company ON dashboards(company_code);
CREATE INDEX idx_dashboards_user ON dashboards(user_id);
```
### 3.2 대시보드 카드 배치
**`dashboard_cards` 테이블 (신규 생성):**
```sql
CREATE TABLE dashboard_cards (
card_id VARCHAR(50) PRIMARY KEY,
dashboard_id VARCHAR(50) NOT NULL, -- ★ DB FK 제약조건 안 걸음 (앱 레벨 관리)
template_id VARCHAR(50) NOT NULL, -- ★ DB FK 제약조건 안 걸음
position_x INTEGER DEFAULT 0, -- 캔버스 상 X 좌표 (px)
position_y INTEGER DEFAULT 0, -- 캔버스 상 Y 좌표 (px)
width INTEGER DEFAULT 600, -- 카드 너비 (px)
height INTEGER DEFAULT 400, -- 카드 높이 (px)
is_collapsed BOOLEAN DEFAULT FALSE, -- 접힌 상태 여부
display_order INTEGER DEFAULT 0,
is_active VARCHAR(1) DEFAULT 'Y',
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_dcards_dashboard ON dashboard_cards(dashboard_id);
```
### 3.3 사용자 오버라이드
**`user_overrides` 테이블 (신규 생성):**
```sql
CREATE TABLE user_overrides (
override_id VARCHAR(50) PRIMARY KEY,
user_id VARCHAR(50) NOT NULL,
card_id VARCHAR(50) NOT NULL, -- ★ DB FK 제약조건 안 걸음
overrides JSONB NOT NULL, -- { fields: {...}, fieldOrder: [...], gridColumns: {...} }
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, card_id)
);
-- ★ card_id 기준 — 같은 템플릿을 두 번 올려도 카드별로 다른 오버라이드 가능
```
**overrides JSON 구조** (로우코드 플랫폼 SPEC Section 3.2):
```json
{
"fields": {
"fax_number": { "visible": false },
"customer_name": { "label": "고객사" }
},
"fieldOrder": ["order_date", "customer_name", "amount"],
"gridColumns": {
"order_id": { "width": 80 },
"customer_name": { "width": 200 }
}
}
```
**오버라이드 병합 규칙:**
1. 개발자 Template의 FieldConfig[]이 기본값
2. 사용자 override가 있으면 해당 필드만 덮어씀
3. 개발자가 필드를 삭제하면 해당 override는 자동 무시
---
## 4. 백엔드 API 설계
### 4.1 대시보드 CRUD
```
GET /api/dashboards → 목록 (해당 유저 + 회사 공통)
POST /api/dashboards → 생성 (이름, 아이콘)
PUT /api/dashboards/{dashboard_id} → 수정 (이름, 아이콘, 순서)
DELETE /api/dashboards/{dashboard_id} → 삭제
```
### 4.2 대시보드 카드 CRUD
```
GET /api/dashboards/{dashboard_id}/cards → 해당 대시보드의 카드 목록 (+ Template 기본 정보)
POST /api/dashboards/{dashboard_id}/cards → 카드 추가 (templateId, 위치/크기)
PUT /api/dashboards/{dashboard_id}/cards/{card_id} → 카드 이동/리사이즈/접기
DELETE /api/dashboards/{dashboard_id}/cards/{card_id} → 카드 제거
PUT /api/dashboards/{dashboard_id}/cards/batch → 다수 카드 일괄 업데이트 (위치/크기 저장)
```
### 4.3 사이드바 메뉴 (= 대시보드 목록)
```
GET /api/sidebar/menu → 사이드바에 표시할 메뉴 목록
```
Response:
```json
{
"data": [
{
"section": "내 대시보드",
"items": [
{ "dashboardId": "dash-1", "name": "수주관리", "icon": "📦", "order": 1 },
{ "dashboardId": "dash-2", "name": "인사 대시보드", "icon": "👥", "order": 2 }
]
}
]
}
```
### 4.4 템플릿 라이브러리 (카드 추가 시 모달)
```
GET /api/templates/library → 게시된(published) 템플릿 목록 (카테고리별)
```
### 4.5 사용자 오버라이드
```
GET /api/overrides?card_id=xxx → 해당 유저+카드의 오버라이드
PUT /api/overrides → 오버라이드 저장 (card_id 기준)
```
### 4.6 Java 파일
**★ 패턴: 전부 BaseService + Map<String, Object>. 엔티티 클래스/DTO 없음.**
| 파일 | 역할 |
|---|---|
| `DashboardController.java` | 대시보드 + 카드 CRUD |
| `DashboardService.java` | `extends BaseService`, sqlSession으로 Map 기반 CRUD |
| `DashboardMapper.xml` | 대시보드/카드 SQL (`resultType="map"`, `parameterType="map"`) |
| `SidebarController.java` | 사이드바 메뉴 조회 |
| `UserOverrideController.java` | 사용자 오버라이드 CRUD |
| `UserOverrideService.java` | `extends BaseService`, 오버라이드 병합 로직 |
**파일명: `dashboard.xml` (namespace="dashboard"), `userOverride.xml` (namespace="userOverride")**
```java
// DashboardService.java (덕일 스타일)
@Service
@Slf4j
public class DashboardService extends BaseService {
@Autowired
private CommonService commonService;
public List<Map<String, Object>> getDashboardList(Map<String, Object> params) {
return sqlSession.selectList("dashboard.getDashboardList", params);
}
public Map<String, Object> getDashboardInfo(Map<String, Object> params) {
return sqlSession.selectOne("dashboard.getDashboardInfo", params);
}
public List<Map<String, Object>> getDashboardCardList(Map<String, Object> params) {
return sqlSession.selectList("dashboard.getDashboardCardList", params);
}
@Transactional
public Map<String, Object> insertDashboard(Map<String, Object> params) {
sqlSession.insert("dashboard.insertDashboard", params);
return params;
}
@Transactional
public void updateDashboard(Map<String, Object> params) {
sqlSession.update("dashboard.updateDashboard", params);
}
@Transactional
public int deleteDashboard(Map<String, Object> params) {
return sqlSession.update("dashboard.deleteDashboard", params);
}
@Transactional
public void updateCardPositions(Map<String, Object> params) {
List<Map<String, Object>> cards = (List<Map<String, Object>>) params.get("cards");
for (Map<String, Object> card : cards) {
sqlSession.update("dashboard.updateCardPosition", card);
}
}
}
```
---
## 5. 프론트엔드 구현
### 5.1 파일 구조
```
frontend/components/dashboard/
├── DashboardCanvas.tsx ← 캔버스 (카드 자유 배치)
├── DashboardCard.tsx ← 개별 카드 (Template 렌더링)
├── DashboardToolbar.tsx ← 상단 툴바 (편집/저장/+템플릿)
├── DashboardEmpty.tsx ← 빈 대시보드 안내
├── TemplateLibraryModal.tsx ← 템플릿 라이브러리 모달
├── CardSettingsPanel.tsx ← 카드 설정 패널 (사용자 오버라이드)
└── CardMiniView.tsx ← 접힌 카드의 미니 KPI 뷰
frontend/components/layout/
├── AppLayout.tsx ← (기존) 전체 레이아웃 — 수정 필요
├── Sidebar.tsx ← 사이드바 (대시보드 목록 = 메뉴)
└── Header.tsx ← 헤더
frontend/stores/
└── dashboardStore.ts ← 대시보드 상태 (Zustand)
frontend/lib/api/
├── dashboard.ts ← 대시보드 CRUD API
└── override.ts ← 사용자 오버라이드 API
```
### 5.2 DashboardCanvas — 핵심 컴포넌트
```typescript
/**
* ★ 별도 인터페이스 정의 안 함 — Record<string, any> 사용
* ★ 유일하게 타입이 있는 건 FieldConfig, Template 등 (invyone-component.ts 규격)
*
* 카드 데이터 구조 (Record<string, any>):
* card_id, template_id, template(로드된 Template), x, y, width, height, is_collapsed
*/
```
**카드 안에서 Template 렌더링:**
- Template.views.list의 components[]를 순서대로 렌더
- 각 Component는 Phase 2의 FcTable/FcForm/FcSearch를 사용
- Template.fields를 각 컴포넌트에 전달
- Template.connections로 컴포넌트 간 DataPort 연결
### 5.3 Sidebar — 대시보드 = 메뉴
```typescript
/**
* ★ Props도 별도 인터페이스 불필요 — 인라인 또는 Record<string, any> 사용
* 사이드바는 dashboards(Record<string, any>[]), active_dashboard_id, 콜백들을 props로 받음
*/
```
mockup 참조:
- `css/02-shell.css``.side`, `.si`, `.side-add-btn`
- `js/05-state.js``renderSidebar()`, `addDashboard()`, `switchDashboard()`
- 접힌 상태 (60px) + 툴팁
- hover 시 이름 변경/삭제 아이콘
### 5.4 TemplateLibraryModal
mockup 참조: `css/06-modals.css``.lib-modal`, `js/01-shell.js``openLib()/closeLib()`
```
┌──────────────────────────────────────────────┐
│ 템플릿 라이브러리 [검색...] [X] │
├────────────┬─────────────────────────────────┤
│ 카테고리 │ 카드 그리드 │
│ │ │
│ 전체 │ ┌───────┐ ┌───────┐ ┌───────┐ │
│ 영업/CRM │ │ 📦 │ │ 📊 │ │ 👥 │ │
│ 생산/공정 │ │수주관리 │ │매출KPI │ │인사관리│ │
│ 인사/급여 │ │sales │ │chart │ │hr │ │
│ 재고/물류 │ └───────┘ └───────┘ └───────┘ │
│ 관리자 │ │
└────────────┴─────────────────────────────────┘
```
---
## 6. 스타일
사용자 화면은 **v5 Cosmic Glassmorphism** (개발자 빌더의 IDE 스타일과 다름):
- 글래스모피즘: `backdrop-filter:blur(20px) saturate(1.4)` + `var(--glass)`
- 코스믹 배경: 별/성운/입자 (다크 모드), 구름형 (라이트 모드)
- 보라/시안/핑크 액센트
- 컴팩트 폰트 (0.55~0.85rem)
mockup 참조:
- `css/01-tokens.css` (토큰)
- `css/02-shell.css` (사이드바, 헤더)
- `css/03-canvas.css` (캔버스, 카드)
- `css/04-settings.css` (카드 설정)
- `css/06-modals.css` (라이브러리 모달)
- `frontend/styles/v5-layout.css` (React 포팅된 v5 토큰)
---
## 7. 참고 파일
| 파일 | 용도 |
|---|---|
| `notes/gbpark/2026-04-08-invyone-mockup/index.html` | **대시보드 전체 UI (진실의 원천)** |
| `notes/gbpark/2026-04-08-invyone-mockup/js/05-state.js` | 대시보드 상태 관리 로직 (SEED_STATE, renderCanvas, switchDashboard 등) |
| `notes/gbpark/2026-04-08-invyone-mockup/js/01-shell.js` | 테마/모드/사이드바/아바타/라이브러리 모달 |
| `notes/gbpark/2026-04-08-invyone-mockup/js/02-canvas.js` | 편집/드래그/리사이즈/접기/삭제 |
| `notes/gbpark/2026-04-08-invyone-mockup/js/04-templates.js` | templateRenderers, buildCardEl, addCardFromLib |
| `notes/gbpark/2026-04-08-invyone-mockup/css/01-tokens.css` ~ `06-modals.css` | v5 스타일 전체 |
| `notes/gbpark/2026-04-08-lowcode-platform-spec.md` (Section 3.2) | 사용자 오버라이드 레이어 설계 |
| `frontend/styles/v5-layout.css` | React 포팅된 v5 CSS |
| `frontend/components/layout/AppLayout.tsx` | 기존 레이아웃 (수정 대상) |
---
## 8. 완료 기준
1. 사이드바에 대시보드 목록이 표시되고, 클릭 시 해당 대시보드로 전환된다
2. `+ 새 대시보드` → 이름 입력 → 사이드바에 자동 등록된다
3. 대시보드 이름 변경/삭제가 동작한다
4. `+ 템플릿 추가` → 라이브러리 모달에서 Template 선택 → 캔버스에 카드 추가된다
5. 편집 모드에서 카드 드래그/리사이즈가 동작한다
6. 카드 접기/펴기 (미니 KPI 뷰)가 동작한다
7. 카드 안에서 실제 데이터가 렌더링된다 (FcTable/FcSearch 등)
8. 카드 설정(⚙)에서 컬럼 ON/OFF → 실시간 반영
9. 대시보드 레이아웃이 DB에 저장/복원된다
10. 사용자 오버라이드가 Template 위에 올바르게 적용된다
---
## 9. 다음 단계 연결
Phase 5 (제어 모드):
- 대시보드의 카드를 클릭하면 → 해당 카드의 데이터 흐름 시각화
- 카드 간 비즈니스 룰 설정 (카드 A의 데이터가 변경되면 카드 B 자동 갱신 등)
- 대시보드 캔버스 위에 제어 모드 오버레이
@@ -0,0 +1,297 @@
# Phase 4 구현 작업기록 — 대시보드(=메뉴) 사용자 화면 시스템
> **작업일**: 2026-04-10
> **설계서**: `notes/gbpark/2026-04-10-phase4-dashboard-menu.md`
> **상태**: 구현 완료 + 빌드 검증 통과 + DB 테이블 생성 완료
---
## 1. 생성/수정된 파일 (19개)
### 1.1 백엔드 (6개)
| 파일 | 상태 | 역할 |
|---|---|---|
| `backend-spring/src/main/resources/mapper/dashboard.xml` | 수정 (VEX 레거시 교체) | 대시보드/카드/사이드바 SQL 12쿼리 |
| `backend-spring/src/main/resources/mapper/userOverride.xml` | 신규 | 사용자 오버라이드 UPSERT/조회/삭제 3쿼리 |
| `backend-spring/src/main/java/com/erp/service/DashboardService.java` | 수정 (VEX 레거시 교체) | BaseService + sqlSession 덕일 스타일 |
| `backend-spring/src/main/java/com/erp/service/UserOverrideService.java` | 신규 | JSONB 파싱/직렬화 + UPSERT |
| `backend-spring/src/main/java/com/erp/controller/DashboardController.java` | 수정 (VEX 레거시 교체) | `/api/dashboards` 엔드포인트 |
| `backend-spring/src/main/java/com/erp/controller/UserOverrideController.java` | 신규 | `/api/overrides` 엔드포인트 |
### 1.2 프론트엔드 (13개)
| 파일 | 역할 |
|---|---|
| `frontend/lib/api/dashMenu.ts` | 대시보드/카드/사이드바 API 래퍼 |
| `frontend/lib/api/override.ts` | 사용자 오버라이드 API 래퍼 |
| `frontend/stores/dashboardStore.ts` | Zustand 대시보드 상태관리 |
| `frontend/styles/dashboard.css` | v5 Cosmic Glassmorphism 대시보드 CSS |
| `frontend/components/dash/DashboardLayout.tsx` | 전체 레이아웃 (사이드바+캔버스+모달) |
| `frontend/components/dash/DashboardSidebar.tsx` | 대시보드 목록 사이드바 |
| `frontend/components/dash/DashboardToolbar.tsx` | 상단 툴바 (편집/저장/+템플릿) |
| `frontend/components/dash/DashboardCanvas.tsx` | 자유 배치 캔버스 + 드래그/리사이즈 |
| `frontend/components/dash/DashboardCard.tsx` | 카드 (Template→FcTable/FcSearch 렌더) |
| `frontend/components/dash/DashboardEmpty.tsx` | 빈 대시보드 안내 |
| `frontend/components/dash/TemplateLibraryModal.tsx` | 템플릿 라이브러리 모달 |
| `frontend/components/dash/CardSettingsPanel.tsx` | 카드 설정 패널 (컬럼 ON/OFF) |
| `frontend/components/dash/CardMiniView.tsx` | 접힌 카드 미니 뷰 |
| `frontend/app/(main)/dash/page.tsx` | `/dash` 라우트 페이지 |
---
## 2. VEX 레거시 교체 사항
### 2.1 기존 DashboardService/Controller (VEX)
**교체 전**: JdbcTemplate 직접 사용, `dashboards`+`dashboard_elements` VEX 테이블, `/api/dashboard` (단수)
**교체 후**: BaseService + sqlSession 덕일 스타일, `DASHBOARDS`+`DASHBOARD_CARDS` 테이블, `/api/dashboards` (복수)
기존 VEX 대시보드의 유틸리티 엔드포인트 (`execute-query`, `execute-dml`, `table-schema`, `fetch-external-api`)는 제거됨. 필요 시 별도 유틸리티 컨트롤러로 분리 가능.
### 2.2 프론트엔드 경로 분리
| 구분 | VEX 레거시 | Phase 4 (INVYONE) |
|---|---|---|
| API 파일 | `lib/api/dashboard.ts` (유지) | `lib/api/dashMenu.ts` (신규) |
| 컴포넌트 | `components/dashboard/` (유지) | `components/dash/` (신규) |
| 페이지 | `app/(main)/dashboard/` (유지) | `app/(main)/dash/` (신규) |
VEX 레거시 파일은 건드리지 않음. 나중에 VEX 완전 폐기 시 삭제.
---
## 3. 백엔드 상세
### 3.1 dashboard.xml (namespace="dashboard", 12쿼리)
| 쿼리 ID | 용도 |
|---|---|
| `getDashboardList` | 대시보드 목록 (유저+회사공통, 페이지네이션) |
| `getDashboardListCnt` | 목록 카운트 |
| `getDashboardInfo` | 대시보드 단건 |
| `insertDashboard` | 대시보드 생성 |
| `updateDashboard` | 대시보드 수정 (이름/아이콘/순서) |
| `deleteDashboard` | 대시보드 소프트 삭제 (IS_ACTIVE='D') |
| `getDashboardCardList` | 카드 목록 (TEMPLATES JOIN으로 기본 정보 포함) |
| `insertDashboardCard` | 카드 추가 |
| `updateDashboardCard` | 카드 업데이트 (위치/크기/접기) |
| `updateCardPosition` | 카드 일괄 위치 업데이트 (단건, for loop용) |
| `deleteDashboardCard` | 카드 소프트 삭제 |
| `getSidebarMenu` | 사이드바 메뉴 (간략 대시보드 목록) |
### 3.2 userOverride.xml (namespace="userOverride", 3쿼리)
| 쿼리 ID | 용도 |
|---|---|
| `getUserOverride` | 유저+카드 기준 오버라이드 조회 |
| `upsertUserOverride` | ON CONFLICT UPSERT (UNIQUE(user_id, card_id)) |
| `deleteUserOverride` | 오버라이드 삭제 |
### 3.3 DashboardService.java (~100줄)
덕일 스타일 준수: `extends BaseService`, `@Autowired CommonService`, `sqlSession` 직접 호출.
- ID 생성: `dash_` + UUID 12자 / `card_` + UUID 12자
- 기본값: icon=📋, position_x=50, width=600, height=400
### 3.4 DashboardController.java (~120줄)
| 메서드 | 엔드포인트 | 설명 |
|---|---|---|
| `GET` | `/api/dashboards` | 목록 (keyword, page, limit) |
| `GET` | `/api/dashboards/{id}` | 단건 |
| `POST` | `/api/dashboards` | 생성 |
| `PUT` | `/api/dashboards/{id}` | 수정 |
| `DELETE` | `/api/dashboards/{id}` | 삭제 |
| `GET` | `/api/dashboards/{id}/cards` | 카드 목록 (Template JOIN) |
| `POST` | `/api/dashboards/{id}/cards` | 카드 추가 |
| `PUT` | `/api/dashboards/{id}/cards/{cardId}` | 카드 수정 |
| `DELETE` | `/api/dashboards/{id}/cards/{cardId}` | 카드 삭제 |
| `PUT` | `/api/dashboards/{id}/cards/batch` | 카드 일괄 위치 업데이트 |
| `GET` | `/api/dashboards/sidebar/menu` | 사이드바 메뉴 |
### 3.5 UserOverrideController.java (~40줄)
| 메서드 | 엔드포인트 | 설명 |
|---|---|---|
| `GET` | `/api/overrides?card_id=xxx` | 조회 |
| `PUT` | `/api/overrides` | UPSERT |
| `DELETE` | `/api/overrides?card_id=xxx` | 삭제 |
---
## 4. 프론트엔드 상세
### 4.1 dashMenu.ts — API 래퍼 (~55줄)
```
getDashboardList, getDashboardInfo, insertDashboard, updateDashboard, deleteDashboard
getDashboardCards, insertDashboardCard, updateDashboardCard, deleteDashboardCard
updateCardPositionsBatch, getSidebarMenu
```
★ 전부 `Record<string, any>` — 별도 인터페이스 정의 안 함
### 4.2 dashboardStore.ts — Zustand (~80줄)
상태: `dashboards[]`, `activeDashboardId`, `cards[]`, `editMode`, `loading`
액션: `setDashboards`, `setActiveDashboard`, `setCards`, `addCard`, `updateCard`, `removeCard`, `toggleEditMode`, `addDashboard`, `updateDashboardInList`, `removeDashboard`
### 4.3 DashboardLayout.tsx — 전체 오케스트레이터 (~180줄)
```
┌──────────────────────────────────────────────────┐
│ DashboardLayout │
├────────────┬─────────────────────────────────────┤
│ Sidebar │ Toolbar │
│ (220px) ├─────────────────────────────────────┤
│ - 목록 │ Canvas │
│ - +추가 │ - DashboardCard × N │
│ │ - 드래그/리사이즈 │
│ │ + CardSettingsPanel (조건부) │
├────────────┴─────────────────────────────────────┤
│ TemplateLibraryModal (조건부) │
└──────────────────────────────────────────────────┘
```
### 4.4 DashboardCanvas.tsx — 드래그/리사이즈 (~130줄)
mockup의 `02-canvas.js` 로직 포팅:
- mousedown → 드래그 또는 리사이즈 모드 판별
- mousemove → 캔버스 경계 clamp 적용
- mouseup → store에 최종 위치 반영
- snap 없음 (px 단위), 캔버스 밖 방지
### 4.5 DashboardCard.tsx — Template 렌더 (~160줄)
카드 안에서 Phase 2 컴포넌트가 실제 데이터로 동작:
1. `primaryTable``getMetaFields()` → FieldConfig[] 로드
2. `fcList()` → 실제 데이터 조회
3. `FcSearch` + `FcTable` + `FcPagination` 렌더
4. 검색 → 재조회, 페이지네이션 → 재조회
### 4.6 TemplateLibraryModal.tsx (~130줄)
mockup의 `06-modals.css` 스타일 포팅:
- 좌측 카테고리 (7종: 전체/영업/생산/인사/재고/재무/관리자)
- 우측 템플릿 카드 그리드 (auto-fill, minmax 180px)
- 검색 필터
- 클릭 → `onSelectTemplate` 콜백
### 4.7 CardSettingsPanel.tsx (~100줄)
- 필드 visible 토글 (ON/OFF)
- `getUserOverride` + `upsertUserOverride` 실시간 저장
- JSONB overrides 구조: `{ fields: { column: { visible: bool } } }`
### 4.8 dashboard.css — v5 토큰 전부 사용 (~350줄)
| 용도 | 토큰 |
|---|---|
| 배경 | `var(--v5-glass)`, `var(--v5-surface)` |
| 테두리 | `var(--v5-glass-border)`, `var(--v5-border-subtle)` |
| 텍스트 | `var(--v5-text)`, `var(--v5-text-sec)`, `var(--v5-text-muted)` |
| 강조 | `var(--v5-primary)`, `var(--v5-primary-light)` |
| 글로우 | `var(--v5-glow-sm)`, `var(--v5-glow-md)` |
| 유리 | `backdrop-filter: blur(20px) saturate(1.4)` |
다크/라이트 모드 변형: `.dark` 선택자 사용 (mockup 패턴 동일)
---
## 5. DB 테이블 (✅ 생성 완료)
**실행 대상**: `211.115.91.141:11134/test_dev` (2026-04-10 실행 완료)
### 5.1 VEX 레거시 처리
기존 `dashboards` 테이블(VEX 스키마: id, title, description, tags, settings 등)이 있어서 **`dashboards_vex_backup`으로 rename** 후 Phase 4 테이블 신규 생성.
### 5.2 생성된 테이블 3개 + 인덱스 3개
| 테이블 | PK | 용도 |
|---|---|---|
| `DASHBOARDS` | DASHBOARD_ID (VARCHAR 50) | 대시보드 (=메뉴 항목) |
| `DASHBOARD_CARDS` | CARD_ID (VARCHAR 50) | 대시보드 위 카드 배치 (위치/크기) |
| `USER_OVERRIDES` | OVERRIDE_ID (VARCHAR 50) | 사용자별 카드 오버라이드 (JSONB) |
**DASHBOARDS 컬럼**: DASHBOARD_ID, NAME, ICON, DISPLAY_ORDER, COMPANY_CODE, USER_ID, IS_ACTIVE, CREATED_BY, CREATED_DATE, UPDATED_BY, UPDATED_DATE
**DASHBOARD_CARDS 컬럼**: CARD_ID, DASHBOARD_ID, TEMPLATE_ID, POSITION_X, POSITION_Y, WIDTH, HEIGHT, IS_COLLAPSED, DISPLAY_ORDER, IS_ACTIVE, CREATED_DATE, UPDATED_DATE
**USER_OVERRIDES 컬럼**: OVERRIDE_ID, USER_ID, CARD_ID, OVERRIDES(JSONB), CREATED_DATE, UPDATED_DATE + `UNIQUE(USER_ID, CARD_ID)`
**인덱스**: `idx_dashboards_company`, `idx_dashboards_user`, `idx_dcards_dashboard`
★ DB FK 제약조건 안 걸음 (앱 레벨 관리, Phase 4 설계서 Section 3 참조)
---
## 6. 덕일 스타일 / 프로젝트 컨벤션 준수
| 규칙 | 준수 |
|---|---|
| 3레이어 (Controller → Service → XML) | ✅ |
| Mapper Interface 금지 | ✅ — sqlSession 직접 호출 |
| Map<String, Object> 사용, DTO 금지 | ✅ — ApiResponse만 예외 |
| BaseService 상속 | ✅ |
| @Autowired CommonService | ✅ |
| XML 파일명: 소문자, Mapper 안 붙임 | ✅ — `dashboard.xml`, `userOverride.xml` |
| XML namespace: 파일명과 동일 | ✅ |
| SQL: UPPER_SNAKE | ✅ |
| SELECT 쉼표: 앞에 | ✅ |
| #{파라미터}: snake_case | ✅ |
| OGNL test: 바깥 작은따옴표 | ✅ |
| 프론트 타입: Record<string, any> | ✅ — FieldConfig만 예외 |
| v5 CSS 토큰 사용, 즉흥 값 금지 | ✅ |
| 컴팩트 폰트 사이즈 (0.55~0.85rem) | ✅ |
---
## 7. 검증 결과
| 검증 항목 | 결과 |
|---|---|
| `./gradlew compileJava` | BUILD SUCCESSFUL |
| `npx tsc --noEmit` (Phase 4 파일) | 에러 0개 |
| 기존 VEX 코드 에러 | Phase 4와 무관 (기존 camelCase/snake_case 불일치 에러) |
---
## 8. 접속 경로
- 대시보드 메뉴 시스템: `/dash`
- 개발자 빌더: `/admin/builder` (Phase 3)
- 테스트 페이지: `/test-fc` (Phase 2)
---
## 9. Phase 4 범위 밖 (다음에 해야 할 것)
| 항목 | 담당 Phase | 비고 |
|---|---|---|
| AppLayout 사이드바 통합 (대시보드 목록을 기존 사이드바에 표시) | Phase 4+ | 현재는 `/dash` 페이지에 자체 사이드바 |
| 대시보드 순서 드래그 재정렬 | Phase 4+ | 현재 display_order는 수동 |
| Template 게시 워크플로우 (빌더에서 publish → 라이브러리에 표시) | Phase 3+ | 현재 빌더 publish API는 구현됨 |
| 카드 간 DataPort 연결 (제어 모드) | Phase 5 | 대시보드 캔버스 위에 오버레이 |
| 사용자 오버라이드 실시간 반영 (FcTable에 override 적용) | Phase 4+ | 현재 settings에서 toggle만 저장 |
| 카드 미니 KPI 뷰 (접었을 때 실제 데이터 집계) | Phase 4+ | 현재 템플릿 이름/분류만 표시 |
| 대시보드 아이콘 선택 UI | Phase 4+ | 현재 기본 📋 |
---
## 10. 사용 흐름
```
1. /dash 접속
2. 좌측 사이드바: 대시보드 목록 표시
3. "+ 새 대시보드" → 이름 입력 → 사이드바에 자동 등록
4. 대시보드 클릭 → 캔버스 표시
5. "편집" 버튼 → 편집 모드 (드래그/리사이즈)
6. "+ 템플릿 추가" → 라이브러리 모달 → 게시된 Template 선택
7. → 캔버스에 카드 추가 (FcTable/FcSearch가 실제 DB 데이터 표시)
8. 카드 ⚙ → 컬럼 ON/OFF (사용자 오버라이드)
9. 카드 ▼ → 접기 (미니 뷰)
10. "저장" → DB에 카드 위치/크기 일괄 저장
11. 다른 대시보드 클릭 → 전환 (편집 모드 자동 해제)
```
@@ -0,0 +1,357 @@
# Phase 5: 제어 모드 — 비즈니스 룰 / 데이터 흐름
> **목적**: 대시보드의 카드(Template) 간 비즈니스 룰과 데이터 흐름을 시각적으로 정의하는 제어 모드 구현
> **전제 조건**: Phase 1~4 완료 (DB 메타 + 컴포넌트 + 빌더 + 대시보드에 카드 배치까지 동작)
> **산출물**: 제어 모드 UI (SVG 연결선 + 테이블 노드 + 규칙 빌더) + 비즈니스 룰 엔진
> **다음 단계**: Phase 6 (자동생성/프리셋 — 편의 기능, 맨 마지막)
---
## 1. 핵심 개념
**제어 모드 = 같은 캔버스에서 "데이터가 어떻게 흐르는지" 보고 편집하는 모드**
일반 모드: 카드(화면)를 사용
제어 모드: 카드 간 관계/비즈니스 룰을 설정
```
[수주관리 카드] ──수주 확정──→ [발주관리 카드] ──금액>1000만──→ [프로젝트 카드]
(자동 등록) (조건분기)
```
---
## 2. 두 가지 기능
### 2.1 읽기 모드 — 흐름 시각화 (카드 클릭)
카드 클릭 → 해당 카드의 데이터 소스(테이블) + 관련 테이블 + 비즈니스 룰이 트리 형태로 표시
mockup 참조: `js/06-control-mode.js``showCardFlow()`, `enterControlMode()`
### 2.2 편집 모드 — 규칙 빌더 (드래그앤드롭)
팔레트에서 테이블 노드 + 제어 노드를 캔버스에 드래그, I/O 포트로 연결
mockup 참조: `js/07-rule-builder.js``dropTable()`, `dropControl()`, `initPortEvents()`
---
## 3. 읽기 모드 상세
### 3.1 진입
캔버스 상단 툴바의 **`⚡ 제어`** 버튼 클릭 → 제어 모드 진입
### 3.2 시각적 변화
1. 캔버스 격자가 시안 톤으로 변경 (`rgba(0,206,201,.22)`)
2. 모든 카드 반투명 (opacity 0.5)
3. 편집 모드 자동 비활성화
### 3.3 카드 클릭 → 흐름 표시
1. 클릭된 카드만 좌측 고정 + opacity 1, 나머지 fade out (0.08)
2. 카드의 `data-source-table`에서 소스 테이블 추출
3. Phase 1의 `GET /api/meta/tables/{tableName}/relations`로 관계 조회
4. BFS로 도달 가능한 전체 체인 계산
5. **트리 확산 애니메이션**: 선이 그려짐 → 노드 reveal → 또 선 → 또 노드 (depth별 지연)
### 3.4 시각 요소
**테이블 노드** (mockup: `.tbl-node`):
```
┌─ 🏢 DEPARTMENT ─── 4컬럼 ──┐
│ ● dept_code VARCHAR PK │
│ ○ dept_name VARCHAR │
│ ○ company_code VARCHAR FK │
│ ○ parent_dept VARCHAR FK │
└────────────────────────────┘
```
**연결선 4종** (SVG bezier):
| 타입 | 색상 | CSS 클래스 | 용도 |
|---|---|---|---|
| 소스 | 핑크 (#fd79a8) | `ctrl-line-tpl` | 카드 → 소스 테이블 |
| 자동실행 | 보라 (#6c5ce7) | `ctrl-line-auto` | 테이블 A → 테이블 B 자동 등록 |
| 조건분기 | 앰버 (#fdcb6e) | `ctrl-line-cond` | 조건 충족 시 실행 |
| FK | 시안 (#00cec9) | `ctrl-line` | 외래키 관계 (기본 비표시, 비즈니스 룰만) |
**연결선 위 뱃지** (mockup: `.ctrl-badge`):
- 클릭 가능, 해당 룰의 상세 정보 표시
- 조건분기 뱃지: Yes/No 분기 경로 표시
### 3.5 라이트 모드 보정
라이트 모드에서 연결선은 더 진하게 (배경 대비):
- 시안 → `#00a89e`, 보라 → `#5b4acf`, 앰버 → `#d4a017`, 핑크 → `#e0559e`
---
## 4. 규칙 빌더 상세
### 4.1 제어 노드 16종
mockup의 `CTRL_NODE_TYPES` (js/07-rule-builder.js):
| 카테고리 | 노드 | 아이콘 | RGB | 특수 출력 포트 |
|---|---|---|---|---|
| 트리거 | 타이머 | ⏱ | 0,206,201 | — |
| 조건 | 조건분기 | ◇ | 253,203,110 | Yes/No |
| 조건 | 데이터 검증 | ✔ | 255,107,129 | Pass/Fail |
| 액션 | 상태 변경 | 🔄 | 108,92,231 | — |
| 액션 | 자동 등록 | 📝 | 85,239,196 | — |
| 액션 | 계산/수식 | 🧮 | 45,152,218 | — |
| 액션 | 삭제/보관 | 🗑 | 255,71,87 | — |
| 액션 | 문서 생성 | 📄 | 162,155,254 | — |
| 흐름 | 승인/결재 | ✋ | 255,165,2 | Approved/Rejected |
| 흐름 | 대기/지연 | ⏳ | 72,219,251 | — |
| 흐름 | 반복 | 🔁 | 223,142,254 | Each/Done |
| 흐름 | 병렬 실행 | 🔀 | 0,206,201 | — |
| 흐름 | 병합/합류 | ⤵ | 149,175,192 | — |
| 연동 | 외부 호출 | 🌐 | 116,185,255 | — |
| 연동 | 알림 발송 | 📨 | 253,121,168 | — |
| 기록 | 로그 기록 | 📜 | 150,150,160 | — |
### 4.2 노드 구조
```
┌─ [In] ──────────────────── [Out] ──┐
│ 📝 자동 등록 │
│ ───────────────────────── │
│ 클릭하여 설정 │
│ (대상 테이블, 필드 매핑) │ [Yes]
│ │ [No]
└────────────────────────────────────┘
```
각 노드에 I/O 포트:
- **Input 포트** (좌측): 데이터/이벤트를 받음
- **Output 포트** (우측): 결과를 내보냄
- 조건분기/승인 등은 다중 output (Yes/No)
### 4.3 포트 연결 인터랙션
1. output 포트 mousedown → 연결선 드래그 시작 (SVG 임시선)
2. input 포트 위에서 mouseup → 연결 완료
3. 연결 중간에 삭제 뱃지 (hover 시 표시)
4. 같은 노드끼리 연결 금지, 중복 연결 금지
### 4.4 노드 설정 팝오버
노드 body 클릭 → 설정 팝오버:
| 노드 타입 | 설정 항목 |
|---|---|
| 자동 등록 | 대상 테이블, 필드 매핑 (소스→대상), 조건 |
| 상태 변경 | 대상 테이블, 대상 필드, 변경값 |
| 조건분기 | 조건식 (필드, 연산자, 값) |
| 승인/결재 | 결재선, 승인자 |
| 타이머 | 실행 주기, 시작 조건 |
| 외부 호출 | URL, 메서드, 파라미터 매핑 |
| 알림 발송 | 대상 (사용자/이메일/슬랙), 메시지 템플릿 |
| 계산/수식 | 수식 (computed 문법), 대상 필드 |
---
## 5. 데이터 모델
### 5.1 비즈니스 룰 DB
**`business_rules` 테이블 (신규 생성):**
```sql
CREATE TABLE business_rules (
rule_id VARCHAR(50) PRIMARY KEY,
dashboard_id VARCHAR(50), -- ★ DB FK 제약조건 안 걸음 (앱 레벨 관리)
name VARCHAR(200),
description TEXT,
nodes JSONB NOT NULL, -- 노드 배열 (위치, 타입, 설정)
connections JSONB NOT NULL, -- 연결 배열 (from → to)
is_enabled BOOLEAN DEFAULT TRUE,
company_code VARCHAR(20) NOT NULL,
created_by VARCHAR(50),
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(50),
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active VARCHAR(1) DEFAULT 'Y'
);
CREATE INDEX idx_brules_dashboard ON business_rules(dashboard_id);
```
### 5.2 노드 JSON 구조
```
★ 별도 인터페이스 정의 안 함 — Record<string, any> 사용
★ JSONB로 저장되므로 구조만 문서화
노드 (Record<string, any>):
id, type(CTRL_NODE_TYPES 키 또는 'table'), label, x, y,
config(타입별 설정), table_name(table 노드일 때)
연결 (Record<string, any>):
id, from_node_id, from_port('out'|'yes'|'no'|'each'|'done'),
to_node_id, to_port('in')
비즈니스 룰 (Record<string, any>):
rule_id, dashboard_id, name, nodes(배열), connections(배열), is_enabled
```
---
## 6. 백엔드 API
### 6.1 관계 조회 (★ table_relationships 기반 — Phase 1과 소스 분리)
```
GET /api/meta/tables/{tableName}/relations → 테이블 간 업무 관계
```
**★ 2소스 책임 분리:**
- Phase 1 `getTableFields()``table_type_columns` (필드 레벨 entity 참조 → FieldConfig.ref)
- Phase 5 `getMetaRelations()``table_relationships` (테이블 간 업무 관계 → 제어 모드 흐름)
- 제어 모드에서 두 소스 필요하면 **서비스 레이어에서 읽기 시점에만 합친다** (저장 구조 통합 안 함)
### 6.2 비즈니스 룰 CRUD
```
GET /api/dashboards/{dashboard_id}/rules → 해당 대시보드의 룰 목록
GET /api/rules/{rule_id} → 룰 상세 (노드+연결)
POST /api/dashboards/{dashboard_id}/rules → 룰 생성
PUT /api/rules/{rule_id} → 룰 수정
DELETE /api/rules/{rule_id} → 룰 삭제
PUT /api/rules/{rule_id}/toggle → 활성/비활성 토글
```
**★ 덕일 스타일 3레이어. 파일명 1:1 매칭.**
| Java 파일 | XML | namespace |
|---|---|---|
| `BusinessRuleController.java` | `businessRule.xml` | `businessRule` |
| `BusinessRuleService.java` | | |
```java
@Service
@Slf4j
public class BusinessRuleService extends BaseService {
@Autowired
private CommonService commonService;
public Map<String, Object> getBusinessRuleList(Map<String, Object> params) {
commonService.applyPagination(params);
int totalCount = sqlSession.selectOne("businessRule.getBusinessRuleListCnt", params);
List<Map<String, Object>> list = sqlSession.selectList("businessRule.getBusinessRuleList", params);
return commonService.buildListResponse(list, totalCount, params);
}
public Map<String, Object> getBusinessRuleInfo(Map<String, Object> params) {
return sqlSession.selectOne("businessRule.getBusinessRuleInfo", params);
}
@Transactional
public Map<String, Object> insertBusinessRule(Map<String, Object> params) {
// nodes, connections → ObjectMapper로 JSON 문자열 변환 후 #{nodes}::jsonb
sqlSession.insert("businessRule.insertBusinessRule", params);
return params;
}
@Transactional
public void updateBusinessRule(Map<String, Object> params) {
sqlSession.update("businessRule.updateBusinessRule", params);
}
@Transactional
public int deleteBusinessRule(Map<String, Object> params) {
return sqlSession.update("businessRule.deleteBusinessRule", params);
}
}
```
---
## 7. 프론트엔드 구현
### 7.1 파일 구조
```
frontend/components/control/
├── ControlMode.tsx ← 제어 모드 오버레이 (캔버스 위에)
├── ControlToolbar.tsx ← 제어 모드 툴바 (읽기/편집 전환)
├── FlowViewer.tsx ← 읽기 모드: 카드 흐름 시각화
├── RuleBuilder.tsx ← 편집 모드: 규칙 빌더
├── TableNode.tsx ← 테이블 노드 UI
├── ControlNode.tsx ← 제어 노드 UI (16종)
├── NodeConfigPopover.tsx ← 노드 설정 팝오버
├── PortHandle.tsx ← I/O 포트 (드래그 연결)
├── ConnectionLine.tsx ← SVG bezier 연결선
├── ControlPalette.tsx ← 제어 모드 팔레트 (사이드바 교체)
└── hooks/
├── useControlMode.ts ← 제어 모드 상태 관리
├── usePortDrag.ts ← 포트 연결 드래그 로직
└── useFlowAnimation.ts ← 트리 확산 애니메이션
```
### 7.2 SVG 연결선 렌더링
mockup의 bezier 곡선 방식 그대로:
```typescript
// from 좌표 (x1,y1) → to 좌표 (x2,y2)
const dx = x2 - x1;
const d = `M${x1},${y1} C${x1+dx*0.5},${y1} ${x1+dx*0.5},${y2} ${x2},${y2}`;
// SVG <path d={d} class="ctrl-line-auto" marker-end="url(#arr-auto)" />
```
선이 그려지는 애니메이션: `stroke-dashoffset` transition
---
## 8. 스타일
제어 모드 전용 스타일은 mockup의 `css/07-control-mode.css` + `css/08-rule-builder.css` 참조.
핵심:
- 캔버스 격자: 시안 톤 (`rgba(0,206,201,.22)`)
- 테이블 노드: 시안 보더 + 글래스 배경
- 연결선: 점선 + 펄스 애니메이션 (`stroke-dasharray: 6 3`)
- 뱃지: 글래스 + 시안/보라/앰버 보더
---
## 9. 참고 파일
| 파일 | 용도 |
|---|---|
| `notes/gbpark/2026-04-08-invyone-mockup/js/06-control-mode.js` | **흐름 시각화 (진실의 원천)** — 996줄, enterControlMode, showCardFlow, buildCtrlTree, calcFlowPositions |
| `notes/gbpark/2026-04-08-invyone-mockup/js/07-rule-builder.js` | **규칙 빌더 (진실의 원천)** — 752줄, 16종 노드, 포트 드래그, 연결 관리, 설정 팝오버 |
| `notes/gbpark/2026-04-08-invyone-mockup/css/07-control-mode.css` | 제어 모드 스타일 |
| `notes/gbpark/2026-04-08-invyone-mockup/css/08-rule-builder.css` | 규칙 빌더 스타일 |
| `notes/gbpark/2026-04-09-invyone-architecture.md` (Section 9 Phase 3) | 제어 플로우 로드맵 |
| `notes/gbpark/2026-04-08-lowcode-platform-spec.md` | 비즈니스 룰 정의 |
---
## 10. 완료 기준
1. **제어 모드 진입/탈출**: ⚡ 버튼으로 토글, 캔버스 시각이 변함
2. **카드 클릭 → 흐름 표시**: 소스 테이블 + 관련 테이블 + 비즈니스 룰이 트리로 표시
3. **트리 확산 애니메이션**: 선 → 노드 순서로 연쇄 등장
4. **4종 연결선**: 소스(핑크), 자동(보라), 조건(앰버), FK(시안) 구분
5. **팔레트에서 노드 드래그앤드롭**: 테이블 노드 + 제어 노드 캔버스에 배치
6. **포트 연결**: output → input 드래그로 연결, SVG bezier 곡선
7. **노드 설정 팝오버**: 타입별 설정 폼이 동작
8. **규칙 저장/로드**: DB에 저장하고 다시 열면 복원
9. **빈 영역 클릭 → 흐름 닫기**
10. **다크/라이트 모드 지원**
---
## 11. 다음 단계 연결
Phase 6 (자동생성/프리셋):
- 테이블 선택 → FieldConfig 기반으로 Template 자동 생성
- 프리셋 3종 (basic/split/tabs) 자동 배치
- 이 시점에서 모든 기능이 갖춰져 있으므로, 자동생성이 올바른 Template JSON을 생성할 수 있음
@@ -0,0 +1,368 @@
# Phase 5 구현 작업기록 — 제어 모드 (비즈니스 룰 / 데이터 흐름)
> **작업일**: 2026-04-10
> **설계서**: `notes/gbpark/2026-04-10-phase5-control-mode.md`
> **상태**: 구현 완료 + 빌드 검증 통과 + DB 테이블 생성 완료
---
## 1. 생성/수정된 파일 (22개)
### 1.1 백엔드 (6개)
| 파일 | 상태 | 역할 |
|---|---|---|
| `backend-spring/src/main/resources/mapper/meta.xml` | 수정 | `getMetaRelations` 쿼리 추가 (table_relationships) |
| `backend-spring/src/main/java/com/erp/service/MetaService.java` | 수정 | `getMetaRelations()` 메서드 추가 |
| `backend-spring/src/main/java/com/erp/controller/MetaController.java` | 수정 | `GET /api/meta/tables/{tableName}/relations` 엔드포인트 |
| `backend-spring/src/main/resources/mapper/businessRule.xml` | 신규 | 비즈니스 룰 CRUD 7쿼리 (namespace=`businessRule`) |
| `backend-spring/src/main/java/com/erp/service/BusinessRuleService.java` | 신규 | 비즈니스 룰 서비스 (JSONB 파싱/직렬화) |
| `backend-spring/src/main/java/com/erp/controller/BusinessRuleController.java` | 신규 | 룰 CRUD + 토글 엔드포인트 6개 |
### 1.2 프론트엔드 (16개)
| 파일 | 역할 |
|---|---|
| `frontend/lib/api/meta.ts` | `getMetaRelations()` 추가 |
| `frontend/lib/api/businessRule.ts` | 비즈니스 룰 CRUD API 클라이언트 (신규) |
| `frontend/styles/control-mode.css` | 제어 모드 + 규칙 빌더 CSS (~350줄, mockup 포팅) |
| `frontend/components/control/hooks/useControlMode.ts` | Zustand 상태관리 + `CTRL_NODE_TYPES` 16종 |
| `frontend/components/control/hooks/useFlowAnimation.ts` | BFS 체인 + 위치 계산 + 애니메이션 타이밍 |
| `frontend/components/control/hooks/usePortDrag.ts` | 포트 연결 드래그 로직 |
| `frontend/components/control/TableNode.tsx` | 테이블 노드 UI (컬럼 목록 + 드래그) |
| `frontend/components/control/ConnectionLine.tsx` | SVG bezier 연결선 4종 + 마커 + 뱃지 |
| `frontend/components/control/FlowViewer.tsx` | 읽기 모드: 카드 클릭 → 흐름 시각화 |
| `frontend/components/control/ControlNode.tsx` | 제어 노드 16종 (I/O 포트 포함) |
| `frontend/components/control/PortHandle.tsx` | I/O 포트 핸들 (드래그 연결 시작/끝) |
| `frontend/components/control/NodeConfigPopover.tsx` | 노드 설정 팝오버 (타입별 폼) |
| `frontend/components/control/ControlPalette.tsx` | 제어 팔레트 (사이드바 교체) |
| `frontend/components/control/RuleBuilder.tsx` | 편집 모드: 규칙 빌더 (드래그앤드롭) |
| `frontend/components/control/ControlToolbar.tsx` | 읽기/편집 모드 전환 + 저장 버튼 |
| `frontend/components/control/ControlMode.tsx` | 제어 모드 오버레이 메인 컴포넌트 |
### 1.3 기존 파일 수정 (3개)
| 파일 | 변경 내용 |
|---|---|
| `frontend/components/dash/DashboardToolbar.tsx` | ⚡ 제어 모드 토글 버튼 추가 (제어 진입 시 편집 모드 자동 해제) |
| `frontend/components/dash/DashboardLayout.tsx` | `ControlMode` 오버레이 통합 + 제어 편집 시 사이드바→팔레트 교체 |
| `frontend/components/dash/DashboardCanvas.tsx` | `forwardRef` + `control-mode` CSS 클래스 + 제어 모드 시 드래그 비활성화 |
---
## 2. 백엔드 상세
### 2.1 Meta Relations API (2소스 책임 분리)
```
GET /api/meta/tables/{tableName}/relations → table_relationships 기반
```
**★ Phase 1과 소스 분리:**
- Phase 1 `getMetaFields()``table_type_columns` (필드 레벨 entity 참조 → FieldConfig.ref)
- Phase 5 `getMetaRelations()``table_relationships` (테이블 간 업무 관계 → 제어 모드 흐름)
- 제어 모드에서 두 소스 필요하면 **서비스 레이어에서 읽기 시점에만 합침**
### 2.2 BusinessRule CRUD (덕일 스타일 3레이어)
| 엔드포인트 | 메서드 | 설명 |
|---|---|---|
| `/api/dashboards/{id}/rules` | GET | 대시보드별 룰 목록 (페이지네이션) |
| `/api/dashboards/{id}/rules` | POST | 룰 생성 (`rule_` + UUID 12자) |
| `/api/rules/{id}` | GET | 룰 상세 (JSONB → Object 파싱) |
| `/api/rules/{id}` | PUT | 룰 수정 (Object → JSON 직렬화) |
| `/api/rules/{id}` | DELETE | 룰 소프트 삭제 (IS_ACTIVE='D') |
| `/api/rules/{id}/toggle` | PUT | 활성/비활성 토글 |
**businessRule.xml 쿼리 7개:**
`getBusinessRuleList`, `getBusinessRuleListCnt`, `getBusinessRuleInfo`, `insertBusinessRule`, `updateBusinessRule`, `deleteBusinessRule`, `toggleBusinessRule`
**JSONB 처리**: `nodes`/`connections``ObjectMapper`로 파싱/직렬화 + `#{nodes}::jsonb` 캐스팅
---
## 3. 프론트엔드 상세
### 3.1 useControlMode — Zustand 상태관리 (~160줄)
| 상태 | 타입 | 설명 |
|---|---|---|
| `active` | boolean | 제어 모드 ON/OFF |
| `mode` | 'view' \| 'edit' | 읽기/편집 모드 |
| `activeFlowCardId` | string \| null | 흐름 표시 중인 카드 |
| `flowEdges` | Record[] | BFS 결과 엣지 배열 |
| `tablePositions` | Record | 테이블 노드 위치 |
| `ruleNodes` | Record[] | 규칙 빌더 노드 |
| `ruleConnections` | Record[] | 규칙 빌더 연결 |
| `configNodeId` | string \| null | 설정 팝오버 대상 노드 |
**CTRL_NODE_TYPES 16종 (mockup 그대로):**
| 카테고리 | 노드 | 아이콘 | 특수 포트 |
|---|---|---|---|
| 트리거 | 타이머 | ⏱ | — |
| 조건 | 조건분기 | ◇ | Yes/No |
| 조건 | 데이터 검증 | ✔ | Pass/Fail |
| 액션 | 상태 변경 | 🔄 | — |
| 액션 | 자동 등록 | 📝 | — |
| 액션 | 계산/수식 | 🧮 | — |
| 액션 | 삭제/보관 | 🗑 | — |
| 액션 | 문서 생성 | 📄 | — |
| 흐름 | 승인/결재 | ✋ | Approved/Rejected |
| 흐름 | 대기/지연 | ⏳ | — |
| 흐름 | 반복 | 🔁 | Each/Done |
| 흐름 | 병렬 실행 | 🔀 | — |
| 흐름 | 병합/합류 | ⤵ | — |
| 연동 | 외부 호출 | 🌐 | — |
| 연동 | 알림 발송 | 📨 | — |
| 기록 | 로그 기록 | 📜 | — |
### 3.2 FlowViewer — 읽기 모드 (카드 클릭 → 흐름 시각화)
**흐름:**
1. 캔버스에서 카드 클릭
2. `getMetaRelations(sourceTable)` → 업무 관계 조회
3. BFS로 도달 가능한 전체 체인 계산 (depth 무제한)
4. `calcFlowPositions()` — 카드 우측에 depth별 트리 배치 (colGap=270~350, rowGap=240)
5. `calcAnimationTimings()` — depth별 지연 (STEP=500ms, NODE_D=350ms)
6. **트리 확산 애니메이션**: 선 → 노드 순서로 연쇄 등장
**시각 효과:**
- 선택된 카드: opacity 1 + 좌측 고정 + 시안 보더
- 나머지 카드: opacity 0.08
- 테이블 노드: scale(0.3) → scale(1) 트랜지션
- 연결선: `stroke-dashoffset` draw 애니메이션 → pulse 복원
- 빈 영역 클릭: 흐름 닫기 (모든 카드 0.5로 복원)
- 노드 드래그: 실시간 위치 업데이트 + 선 재그리기
### 3.3 RuleBuilder — 편집 모드 (규칙 빌더)
**흐름:**
1. 사이드바 → 제어 팔레트 교체 (DB 테이블 + 제어 노드 16종)
2. 팔레트에서 캔버스로 드래그앤드롭 → 노드 생성
3. 노드 헤더 드래그 → 이동
4. output 포트 mousedown → bezier 임시선 → input 포트 mouseup → 연결 생성
5. 연결 중간 hover → 삭제 뱃지 (✕)
6. 노드 body 클릭 → 설정 팝오버 (타입별 폼)
7. "규칙 저장" → `insertBusinessRule()` / `updateBusinessRule()`
**포트 연결 규칙:**
- 같은 노드끼리 연결 금지
- 중복 연결 금지
- 드래그 중 모든 input 포트 pulse 애니메이션
### 3.4 SVG 연결선 4종
| 타입 | 색상 | CSS 클래스 | 마커 | 용도 |
|---|---|---|---|---|
| 소스 | 핑크 #fd79a8 | `ctrl-line-tpl` | arr-src | 카드 → 소스 테이블 |
| 자동실행 | 보라 #6c5ce7 | `ctrl-line-auto` | arr-auto | 테이블 → 테이블 자동 등록 |
| 조건분기 | 앰버 #fdcb6e | `ctrl-line-cond` | arr-cond | 조건 충족 시 실행 |
| FK | 시안 #00cec9 | `ctrl-line` | arr-fk | 외래키 관계 |
**라이트 모드 보정**: 시안→#00a89e, 보라→#5b4acf, 앰버→#d4a017, 핑크→#e0559e
### 3.5 연결선 위 뱃지
- 일반: 글래스 배경 + 시안 보더 + 라벨 텍스트
- 조건분기: 확장형 (`cb-head` + `cb-cond` + `cb-paths` Yes/No)
- 소스: 핑크 보더
- 자동실행: 보라 보더
### 3.6 NodeConfigPopover — 타입별 설정 폼
| 노드 타입 | 설정 항목 |
|---|---|
| 조건분기 | 필드, 연산자(=, ≠, >, <, 기한경과, 포함), 값 |
| 상태 변경 | 대상 테이블, 변경 필드, 변경값 |
| 자동 등록 | 대상 테이블 |
| 타이머 | 기준 필드, 경과량, 단위(일/시간/주) |
| 알림 발송 | 채널(이메일/SMS/푸시/Slack), 수신자, 메시지 |
| 승인/결재 | 승인자, 승인 조건 |
| 계산/수식 | 대상 테이블, 결과 필드, 수식 |
| 외부 호출 | URL, 메서드(POST/GET/PUT/DELETE) |
| 데이터 검증 | 대상 필드, 검증 규칙 |
| 로그 기록 | 내용 |
### 3.7 Dashboard 통합
**DashboardToolbar:**
- ⚡ 버튼 추가 (제어 모드 진입 시 편집 모드 자동 해제)
- 제어 모드 활성 중: 편집/템플릿추가/저장 버튼 숨김
**DashboardLayout:**
- `ControlMode` 오버레이 (ControlToolbar + FlowViewer/RuleBuilder)
- 제어 편집 모드: 사이드바 → `ControlPalette` 교체
**DashboardCanvas:**
- `forwardRef`로 부모에서 캔버스 DOM 참조
- `control-mode` CSS 클래스 (시안 격자 배경)
- 제어 모드 시 카드 드래그/리사이즈 비활성화
---
## 4. DB 변경
### 4.1 business_rules 테이블 (✅ 생성 완료)
```sql
CREATE TABLE business_rules (
rule_id VARCHAR(50) PRIMARY KEY,
dashboard_id VARCHAR(50),
name VARCHAR(200),
description TEXT,
nodes JSONB NOT NULL,
connections JSONB NOT NULL,
is_enabled BOOLEAN DEFAULT TRUE,
company_code VARCHAR(20) NOT NULL,
created_by VARCHAR(50),
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(50),
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active VARCHAR(1) DEFAULT 'Y'
);
CREATE INDEX idx_brules_dashboard ON business_rules(dashboard_id);
```
**실행 대상**: `211.115.91.141:11134/test_dev` (2026-04-10 실행 완료)
★ DB FK 제약조건 안 걸음 (설계서 명시 — 앱 레벨 관리)
---
## 5. 덕일 스타일 / 프로젝트 컨벤션 준수
| 규칙 | 준수 |
|---|---|
| 3레이어 (Controller → Service → XML) | ✅ |
| Mapper Interface 금지 | ✅ — sqlSession 직접 호출 |
| Map<String, Object> 사용, DTO 금지 | ✅ — ApiResponse만 예외 |
| BaseService 상속 | ✅ |
| @Autowired CommonService | ✅ |
| XML 파일명: 소문자, Mapper 안 붙임 | ✅ — `businessRule.xml` |
| XML namespace: 파일명과 동일 | ✅ — `namespace="businessRule"` |
| SQL: UPPER_SNAKE | ✅ |
| SELECT 쉼표: 앞에 | ✅ |
| #{파라미터}: snake_case | ✅ |
| OGNL test: 바깥 작은따옴표 | ✅ |
| 프론트 타입: Record<string, any> | ✅ — FieldConfig만 예외 |
| v5/ctrl CSS 변수 사용, 즉흥 값 금지 | ✅ |
| 컴팩트 폰트 사이즈 (0.42~0.68rem) | ✅ |
| mockup 진실의 원천 | ✅ — 06-control-mode.js, 07-rule-builder.js |
---
## 6. CSS 토큰 사용 목록
| 용도 | 토큰 |
|---|---|
| 시안 | `--ctrl-cyan` (#00cec9), `--ctrl-cyan-glow` |
| 보라 | `--ctrl-primary` (#6c5ce7) |
| 앰버 | `--ctrl-amber` (#fdcb6e) |
| 핑크 | `--ctrl-pink` (#fd79a8) |
| 그린 | `--ctrl-green` (#55efc4) |
| 레드 | `--ctrl-red` (#ff4757) |
| 유리 배경 | `--ctrl-glass`, `--ctrl-glass-strong` |
| 유리 보더 | `--ctrl-glass-border` |
| 텍스트 | `var(--v5-text)`, `var(--v5-text-sec)`, `var(--v5-text-muted)` |
| 보더 | `var(--v5-border)` |
| 서피스 | `var(--v5-surface)`, `var(--v5-surface-hover)` |
| 블러 | `backdrop-filter: blur(20px) saturate(1.4)` (v5 글래스 패턴) |
---
## 7. 검증 결과
| 검증 항목 | 결과 |
|---|---|
| `./gradlew compileJava` | BUILD SUCCESSFUL |
| `./gradlew bootJar` | BUILD SUCCESSFUL |
| `npx tsc --noEmit` (Phase 5 파일) | 에러 0개 |
| DB 테이블 생성 (test_dev) | CREATE TABLE + INDEX 성공 |
| 기존 레거시 에러 | 2827개 (Phase 5 무관, 변동 없음) |
---
## 8. 완료 기준 대비 상태
| # | 기준 | 상태 |
|---|---|---|
| 1 | ⚡ 버튼으로 제어 모드 토글 | ✅ |
| 2 | 캔버스 시각 변화 (시안 격자, 카드 반투명) | ✅ |
| 3 | 카드 클릭 → 흐름 표시 (소스 테이블 + 관련 테이블 + 비즈니스 룰 트리) | ✅ |
| 4 | 트리 확산 애니메이션 (선→노드 순차 등장) | ✅ |
| 5 | 4종 연결선 (소스/자동/조건/FK) | ✅ |
| 6 | 팔레트에서 노드 드래그앤드롭 | ✅ |
| 7 | 포트 연결 (output→input 드래그, bezier 곡선) | ✅ |
| 8 | 노드 설정 팝오버 (타입별 폼) | ✅ |
| 9 | 규칙 저장/로드 (DB JSONB) | ✅ |
| 10 | 빈 영역 클릭 → 흐름 닫기 | ✅ |
| 11 | 다크/라이트 모드 | ✅ (라이트 보정 CSS 포함) |
---
## 9. 접속 경로
- **대시보드 (제어 모드 포함)**: `/dash` → ⚡ 버튼 클릭
- 개발자 빌더: `/admin/builder` (Phase 3)
- 테스트 페이지: `/test-fc` (Phase 2)
---
## 10. Phase 5 범위 밖 (다음에 해야 할 것)
| 항목 | 비고 |
|---|---|
| `table_relationships` 테이블에 실제 데이터 INSERT | 현재 빈 테이블이면 흐름이 소스→1개만 표시 |
| 비즈니스 룰 실행 엔진 (트리거/조건 평가/액션 실행) | 현재는 시각 편집만 |
| 규칙 로드 UI (기존 규칙 목록에서 선택 → 복원) | 현재는 새 규칙 생성만 |
| computed 수식 파서 (AST 기반 안전한 파서) | Phase 5+ |
| DataPort 이벤트 버스 (카드 간 실시간 데이터 전달) | Phase 5+ |
| 자동생성/프리셋 (Phase 6) | 모든 기능 갖춰진 후 마지막 |
---
## 11. 파일별 코드 요약
### businessRule.xml (107줄)
```
7개 쿼리: getBusinessRuleList, getBusinessRuleListCnt, getBusinessRuleInfo,
insertBusinessRule, updateBusinessRule, deleteBusinessRule, toggleBusinessRule
common include: companyCodeFilter, dynamicOrderBy, pagination
```
### BusinessRuleService.java (~95줄)
```
퍼블릭: getBusinessRuleList, getBusinessRuleInfo, insertBusinessRule,
updateBusinessRule, deleteBusinessRule, toggleBusinessRule
프라이빗: parseJsonField, stringifyJsonField (JSONB 유틸)
```
### BusinessRuleController.java (~100줄)
```
6개 엔드포인트: GET/POST rules + GET/PUT/DELETE/toggle rule
```
### useControlMode.ts (~160줄)
```
Zustand store: active, mode, flowEdges, ruleNodes, ruleConnections
+ CTRL_NODE_TYPES 16종 정의 + genNodeId/genConnId 헬퍼
```
### FlowViewer.tsx (~180줄)
```
카드 클릭 이벤트 → getMetaRelations → BFS → 위치 계산 → 순차 reveal
TableNode + ConnectionSvg + FlowLine + FlowBadge 렌더
```
### RuleBuilder.tsx (~180줄)
```
캔버스 드래그앤드롭 → 노드 생성 (테이블/제어)
포트 연결 SVG + 삭제 뱃지 + NodeConfigPopover
```
### control-mode.css (~350줄)
```
--ctrl-* 변수 체계, 연결선 4종 + pulse 애니메이션,
테이블 노드, 제어 노드, I/O 포트, 설정 팝오버, 팔레트
다크/라이트 모드 보정
```