diff --git a/backend-spring/src/main/java/com/erp/controller/BusinessRuleController.java b/backend-spring/src/main/java/com/erp/controller/BusinessRuleController.java new file mode 100644 index 00000000..fd9b5727 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/controller/BusinessRuleController.java @@ -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>> getBusinessRuleList( + @PathVariable String dashboardId, + @RequestAttribute("company_code") String companyCode, + @RequestParam(required = false, defaultValue = "1") int page, + @RequestParam(required = false, defaultValue = "100") int limit) { + Map 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>> getBusinessRuleInfo( + @PathVariable String ruleId) { + Map params = new HashMap<>(); + params.put("rule_id", ruleId); + Map 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>> insertBusinessRule( + @PathVariable String dashboardId, + @RequestBody Map 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> updateBusinessRule( + @PathVariable String ruleId, + @RequestBody Map 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> deleteBusinessRule( + @PathVariable String ruleId, + @RequestAttribute("user_id") String userId) { + Map 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> toggleBusinessRule( + @PathVariable String ruleId, + @RequestAttribute("user_id") String userId) { + Map params = new HashMap<>(); + params.put("rule_id", ruleId); + params.put("user_id", userId); + businessRuleService.toggleBusinessRule(params); + return ResponseEntity.ok(ApiResponse.success(null)); + } +} diff --git a/backend-spring/src/main/java/com/erp/controller/DashboardController.java b/backend-spring/src/main/java/com/erp/controller/DashboardController.java index 1d2c26a0..47dedfb0 100644 --- a/backend-spring/src/main/java/com/erp/controller/DashboardController.java +++ b/backend-spring/src/main/java/com/erp/controller/DashboardController.java @@ -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>> getDashboards( + public ResponseEntity>> getDashboardList( @RequestAttribute("company_code") String companyCode, - @RequestParam Map params) { + @RequestAttribute("user_id") String userId, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "50") int limit) { + Map 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>> getDashboardsLegacy( - @RequestAttribute("company_code") String companyCode, - @RequestParam Map params) { - params.put("company_code", companyCode); - return ResponseEntity.ok(ApiResponse.success(dashboardService.getDashboardList(params))); - } - - @GetMapping("/public") - public ResponseEntity>> getPublicDashboards( - @RequestParam Map params) { - params.put("company_code", "*"); - params.put("is_public", "true"); - return ResponseEntity.ok(ApiResponse.success(dashboardService.getDashboardList(params))); - } - - @GetMapping("/my") - public ResponseEntity>> getMyDashboards( - @RequestAttribute("company_code") String companyCode, - @RequestParam Map params) { - params.put("company_code", companyCode); - return ResponseEntity.ok(ApiResponse.success(dashboardService.getDashboardList(params))); - } - - @GetMapping("/public/{id}") - public ResponseEntity>> getPublicDashboard( - @PathVariable String id) { - Map 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>> getDashboard( - @RequestAttribute("company_code") String companyCode, - @RequestAttribute(value = "user_id", required = false) String userId, - @PathVariable String id) { - Map 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>> getDashboardInfo( + @PathVariable String dashboardId) { + Map params = new HashMap<>(); + params.put("dashboard_id", dashboardId); + Map result = dashboardService.getDashboardInfo(params); + if (result == null) { + return ResponseEntity.ok(ApiResponse.error("대시보드를 찾을 수 없습니다")); } return ResponseEntity.ok(ApiResponse.success(result)); } @PostMapping - public ResponseEntity>> createDashboard( + public ResponseEntity>> insertDashboard( @RequestAttribute("company_code") String companyCode, - @RequestAttribute(value = "user_id", required = false) String userId, + @RequestAttribute("user_id") String userId, @RequestBody Map body) { - String title = (String) body.get("title"); - if (title == null || title.isBlank()) return ResponseEntity.status(400).body(ApiResponse.error("대시보드 제목이 필요합니다.")); - Map 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>> updateDashboard( - @PathVariable String id, - @RequestAttribute(value = "user_id", required = false) String userId, + @PutMapping("/{dashboardId}") + public ResponseEntity> updateDashboard( + @PathVariable String dashboardId, + @RequestAttribute("user_id") String userId, @RequestBody Map body) { - Map 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>> 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> deleteDashboard( + @PathVariable String dashboardId, + @RequestAttribute("user_id") String userId) { + Map 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>> executeQuery( + // ═══ 카드 CRUD ═══ + + @GetMapping("/{dashboardId}/cards") + public ResponseEntity>>> getDashboardCards( + @PathVariable String dashboardId) { + Map params = new HashMap<>(); + params.put("dashboard_id", dashboardId); + return ResponseEntity.ok(ApiResponse.success(dashboardService.getDashboardCardList(params))); + } + + @PostMapping("/{dashboardId}/cards") + public ResponseEntity>> insertDashboardCard( + @PathVariable String dashboardId, @RequestBody Map body) { - String query = (String) body.get("query"); - if (query == null || query.isBlank()) return ResponseEntity.status(400).body(ApiResponse.error("쿼리가 필요합니다.")); - try { - Map 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>> executeDml( + @PutMapping("/{dashboardId}/cards/{cardId}") + public ResponseEntity> updateDashboardCard( + @PathVariable String dashboardId, + @PathVariable String cardId, @RequestBody Map body) { - String query = (String) body.get("query"); - if (query == null || query.isBlank()) return ResponseEntity.status(400).body(ApiResponse.error("쿼리가 필요합니다.")); - try { - Map 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> fetchExternalApi( + @DeleteMapping("/{dashboardId}/cards/{cardId}") + public ResponseEntity> deleteDashboardCard( + @PathVariable String dashboardId, + @PathVariable String cardId) { + Map params = new HashMap<>(); + params.put("card_id", cardId); + dashboardService.deleteDashboardCard(params); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + @PutMapping("/{dashboardId}/cards/batch") + public ResponseEntity> updateCardPositions( + @PathVariable String dashboardId, + @RequestBody Map body) { + dashboardService.updateCardPositions(body); + return ResponseEntity.ok(ApiResponse.success(null, "일괄 업데이트 완료")); + } + + // ═══ 사이드바 메뉴 ═══ + + @GetMapping("/sidebar/menu") + public ResponseEntity>>> getSidebarMenu( @RequestAttribute("company_code") String companyCode, - @RequestBody Map 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 requestParams = new HashMap<>(body); - requestParams.put("company_code", companyCode); - if (externalConnectionId != null) { - int connId = Integer.parseInt(String.valueOf(externalConnectionId)); - Map result = externalRestApiConnectionService.fetchData(connId, url, null, requestParams); - return ResponseEntity.ok(ApiResponse.success(result)); - } - - // 커넥션 없이 직접 호출 - 기본 응답 - Map result = new LinkedHashMap<>(); - result.put("url", url); - result.put("message", "externalConnectionId가 필요합니다."); - return ResponseEntity.ok(ApiResponse.success(result)); - } - - @PostMapping("/table-schema") - public ResponseEntity>> getTableSchema( - @RequestBody Map body) { - String tableName = (String) body.get("table_name"); - if (tableName == null || tableName.isBlank()) return ResponseEntity.status(400).body(ApiResponse.error("테이블명이 필요합니다.")); - try { - Map 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 params = new HashMap<>(); + params.put("company_code", companyCode); + params.put("user_id", userId); + return ResponseEntity.ok(ApiResponse.success(dashboardService.getSidebarMenu(params))); } } diff --git a/backend-spring/src/main/java/com/erp/controller/MetaController.java b/backend-spring/src/main/java/com/erp/controller/MetaController.java new file mode 100644 index 00000000..1ce5a74e --- /dev/null +++ b/backend-spring/src/main/java/com/erp/controller/MetaController.java @@ -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>>> getMetaTableList( + @RequestAttribute("company_code") String companyCode) { + Map 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>> getMetaFields( + @PathVariable String tableName, + @RequestAttribute("company_code") String companyCode) { + Map 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>>> getMetaRelations( + @PathVariable String tableName, + @RequestAttribute("company_code") String companyCode) { + Map params = new HashMap<>(); + params.put("table_name", tableName); + params.put("company_code", companyCode); + return ResponseEntity.ok(ApiResponse.success(metaService.getMetaRelations(params))); + } +} diff --git a/backend-spring/src/main/java/com/erp/controller/TemplateController.java b/backend-spring/src/main/java/com/erp/controller/TemplateController.java new file mode 100644 index 00000000..e420613c --- /dev/null +++ b/backend-spring/src/main/java/com/erp/controller/TemplateController.java @@ -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>> getTemplateList( + @RequestAttribute("company_code") String companyCode, + @RequestParam Map params) { + params.putIfAbsent("company_code", companyCode); + return ResponseEntity.ok(ApiResponse.success(templateService.getTemplateList(params))); + } + + /** + * GET /api/templates/{templateId} — 템플릿 상세 + */ + @GetMapping("/{templateId}") + public ResponseEntity>> getTemplateInfo( + @PathVariable String templateId, + @RequestAttribute("company_code") String companyCode) { + Map params = new HashMap<>(); + params.put("template_id", templateId); + params.put("company_code", companyCode); + Map result = templateService.getTemplateInfo(params); + if (result == null) { + return ResponseEntity.ok(ApiResponse.error("템플릿을 찾을 수 없습니다")); + } + return ResponseEntity.ok(ApiResponse.success(result)); + } + + /** + * POST /api/templates — 템플릿 생성 + */ + @PostMapping + public ResponseEntity>> insertTemplate( + @RequestBody Map params, + @RequestAttribute("company_code") String companyCode, + @RequestAttribute("user_id") String userId) { + params.put("company_code", companyCode); + params.put("user_id", userId); + Map result = templateService.insertTemplate(params); + return ResponseEntity.ok(ApiResponse.success(result)); + } + + /** + * PUT /api/templates/{templateId} — 템플릿 수정 + */ + @PutMapping("/{templateId}") + public ResponseEntity> updateTemplate( + @PathVariable String templateId, + @RequestBody Map 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> publishTemplate( + @PathVariable String templateId, + @RequestAttribute("user_id") String userId) { + Map 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> deleteTemplate( + @PathVariable String templateId, + @RequestAttribute("user_id") String userId) { + Map params = new HashMap<>(); + params.put("template_id", templateId); + params.put("user_id", userId); + templateService.deleteTemplate(params); + return ResponseEntity.ok(ApiResponse.success(null)); + } +} diff --git a/backend-spring/src/main/java/com/erp/controller/UserOverrideController.java b/backend-spring/src/main/java/com/erp/controller/UserOverrideController.java new file mode 100644 index 00000000..c04128f7 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/controller/UserOverrideController.java @@ -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>> getUserOverride( + @RequestAttribute("user_id") String userId, + @RequestParam String card_id) { + Map params = new HashMap<>(); + params.put("user_id", userId); + params.put("card_id", card_id); + Map result = userOverrideService.getUserOverride(params); + return ResponseEntity.ok(ApiResponse.success(result)); + } + + @PutMapping + public ResponseEntity> upsertUserOverride( + @RequestAttribute("user_id") String userId, + @RequestBody Map body) { + body.put("user_id", userId); + userOverrideService.upsertUserOverride(body); + return ResponseEntity.ok(ApiResponse.success(null, "저장 완료")); + } + + @DeleteMapping + public ResponseEntity> deleteUserOverride( + @RequestAttribute("user_id") String userId, + @RequestParam String card_id) { + Map params = new HashMap<>(); + params.put("user_id", userId); + params.put("card_id", card_id); + userOverrideService.deleteUserOverride(params); + return ResponseEntity.ok(ApiResponse.success(null, "삭제 완료")); + } +} diff --git a/backend-spring/src/main/java/com/erp/service/BusinessRuleService.java b/backend-spring/src/main/java/com/erp/service/BusinessRuleService.java new file mode 100644 index 00000000..1fb0c3e2 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/service/BusinessRuleService.java @@ -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 getBusinessRuleList(Map params) { + commonService.applyPagination(params); + int totalCount = sqlSession.selectOne(NS + "getBusinessRuleListCnt", params); + List> list = sqlSession.selectList(NS + "getBusinessRuleList", params); + return commonService.buildListResponse(list, totalCount, params); + } + + public Map getBusinessRuleInfo(Map params) { + Map row = sqlSession.selectOne(NS + "getBusinessRuleInfo", params); + if (row != null) { + parseJsonField(row, "nodes"); + parseJsonField(row, "connections"); + } + return row; + } + + @Transactional + public Map insertBusinessRule(Map 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 params) { + stringifyJsonField(params, "nodes"); + stringifyJsonField(params, "connections"); + sqlSession.update(NS + "updateBusinessRule", params); + } + + @Transactional + public int deleteBusinessRule(Map params) { + return sqlSession.update(NS + "deleteBusinessRule", params); + } + + @Transactional + public void toggleBusinessRule(Map params) { + sqlSession.update(NS + "toggleBusinessRule", params); + } + + // ── JSONB 유틸 ── + + private void parseJsonField(Map 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 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, "[]"); + } + } +} diff --git a/backend-spring/src/main/java/com/erp/service/DashboardService.java b/backend-spring/src/main/java/com/erp/service/DashboardService.java index 3cc31316..9093dbef 100644 --- a/backend-spring/src/main/java/com/erp/service/DashboardService.java +++ b/backend-spring/src/main/java/com/erp/service/DashboardService.java @@ -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 getDashboardList(Map 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 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); - } - - 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 listArgs = new ArrayList<>(args); - listArgs.add(limit); - listArgs.add(offset); - - List> rows = jdbcTemplate.queryForList(listSql, listArgs.toArray()); - List> dashboards = new ArrayList<>(); - for (Map row : rows) { - dashboards.add(formatDashboardRow(row, false)); - } - - Map result = new LinkedHashMap<>(); - result.put("dashboards", dashboards); - Map 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; + commonService.applyPagination(params); + int totalCount = sqlSession.selectOne(NS + "getDashboardListCnt", params); + List> list = sqlSession.selectList(NS + "getDashboardList", params); + return commonService.buildListResponse(list, totalCount, params); } - // ── 단건 조회 (요소 포함) ───────────────────────────────────────────────────── - public Map getDashboardById(String dashboardId, String companyCode) { - List 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> rows = jdbcTemplate.queryForList( - "SELECT * FROM dashboards WHERE " + where, args.toArray()); - if (rows.isEmpty()) return null; - - Map dashboard = formatDashboardRow(rows.get(0), true); - - List> 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; + public Map getDashboardInfo(Map params) { + return sqlSession.selectOne(NS + "getDashboardInfo", params); } - // ── 생성 ───────────────────────────────────────────────────────────────────── @Transactional - public Map createDashboard(Map 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 insertDashboard(Map 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 result = new HashMap<>(); + result.put("dashboard_id", dashboardId); + return result; } - // ── 수정 ───────────────────────────────────────────────────────────────────── @Transactional - public Map updateDashboard(String dashboardId, Map params, String userId) { - List sets = new ArrayList<>(); - List 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 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 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> getDashboardCardList(Map params) { + return sqlSession.selectList(NS + "getDashboardCardList", params); } - // ── execute-query (SELECT만) ────────────────────────────────────────────────── - public Map executeQuery(String sql) { - String trimmed = sql.trim(); - String lower = trimmed.toLowerCase(); - if (!lower.startsWith("select") && !lower.startsWith("with")) { - throw new IllegalArgumentException("SELECT 또는 WITH 쿼리만 허용됩니다."); - } - List> rows = jdbcTemplate.queryForList(trimmed); - List columns = rows.isEmpty() ? new ArrayList<>() : new ArrayList<>(rows.get(0).keySet()); - Map result = new LinkedHashMap<>(); - result.put("columns", columns); - result.put("rows", rows); - result.put("row_count", rows.size()); + @Transactional + public Map insertDashboardCard(Map 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 result = new HashMap<>(); + result.put("card_id", cardId); return result; } - // ── execute-dml (INSERT/UPDATE/DELETE) ──────────────────────────────────────── - public Map 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 result = new LinkedHashMap<>(); - result.put("row_count", rowCount); - result.put("command", command); - return result; + @Transactional + public void updateDashboardCard(Map params) { + sqlSession.update(NS + "updateDashboardCard", params); } - // ── table-schema ────────────────────────────────────────────────────────────── - public Map getTableSchema(String tableName) { - if (!tableName.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) { - throw new IllegalArgumentException("유효하지 않은 테이블명입니다."); - } - List> cols = jdbcTemplate.queryForList( - "SELECT column_name, data_type, udt_name FROM information_schema.columns" + - " WHERE table_name = ? ORDER BY ordinal_position", - tableName.toLowerCase()); - - Set 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 dateUdt = new HashSet<>(Arrays.asList("timestamp", "timestamptz", "date", "time", "timetz")); - - List dateColumns = new ArrayList<>(); - List> columns = new ArrayList<>(); - for (Map 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 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); - } - - Map result = new LinkedHashMap<>(); - result.put("table_name", tableName); - result.put("columns", columns); - result.put("date_columns", dateColumns); - return result; + @Transactional + public void deleteDashboardCard(Map params) { + sqlSession.update(NS + "deleteDashboardCard", params); } - // ── 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 el = (Map) elements.get(i); - String elementId = UUID.randomUUID().toString(); - Map position = el.get("position") instanceof Map ? (Map) el.get("position") : new HashMap<>(); - Map size = el.get("size") instanceof Map ? (Map) 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 params) { + List> cards = (List>) params.get("cards"); + if (cards != null) { + for (Map card : cards) { + sqlSession.update(NS + "updateCardPosition", card); + } } } - private Map formatDashboardRow(Map row, boolean includeSettings) { - Map 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 formatElement(Map row) { - Map el = new LinkedHashMap<>(); - el.put("id", row.get("id")); - el.put("type", row.get("element_type")); - el.put("subtype", row.get("element_subtype")); - Map pos = new LinkedHashMap<>(); - pos.put("x", row.get("position_x")); - pos.put("y", row.get("position_y")); - el.put("position", pos); - Map 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() {}); } - 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> getSidebarMenu(Map params) { + return sqlSession.selectList(NS + "getSidebarMenu", params); } } diff --git a/backend-spring/src/main/java/com/erp/service/MetaService.java b/backend-spring/src/main/java/com/erp/service/MetaService.java new file mode 100644 index 00000000..c04c38cc --- /dev/null +++ b/backend-spring/src/main/java/com/erp/service/MetaService.java @@ -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 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 SEARCHABLE_TYPES = Set.of( + "text", "select", "entity", "date", "code" + ); + + // ────────────────────────────────────────────────── + // 테이블 목록 + // ────────────────────────────────────────────────── + + public List> getMetaTableList(Map params) { + return sqlSession.selectList(NS + "getMetaTableList", params); + } + + // ────────────────────────────────────────────────── + // FieldConfig[] 반환 + // ────────────────────────────────────────────────── + + public Map getMetaFields(Map params) { + String tableName = (String) params.get("table_name"); + + List> schemaCols = sqlSession.selectList(NS + "getSchemaColumns", params); + List pks = sqlSession.selectList(NS + "getPrimaryKeys", params); + List> 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> fields = buildFieldConfigs(schemaCols, pks, customMeta); + + Map 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> buildFieldConfigs( + List> schemaCols, + List pks, + List> customMeta + ) { + Set pkSet = new HashSet<>(pks); + + Map> metaMap = new LinkedHashMap<>(); + for (Map meta : customMeta) { + String colName = str(meta, "column_name"); + if (colName != null) { + metaMap.put(colName, meta); + } + } + + List> fields = new ArrayList<>(); + + for (Map 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 meta = metaMap.get(columnName); + Map 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 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 ref = buildFieldRef(meta, detailSettings); + if (ref != null) field.put("ref", ref); + } + + // ── select → options ── + if ("select".equals(fieldType) && detailSettings != null) { + List 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 buildFieldRef(Map meta, Map 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 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 extractOptions(Map 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 result = new ArrayList<>(); + for (Object item : optionsList) { + if (item instanceof String) { + // 단순 문자열: value=label로 해석 + result.add(item); + } else if (item instanceof Map) { + // {value, label} 객체: 그대로 보존 + Map optMap = (Map) item; + Map 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 parseDetailSettings(Map 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>() {}); + } 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 map, String key) { + Object val = map.get(key); + return val != null ? val.toString() : null; + } + + private String strFromMap(Map map, String key) { + Object val = map.get(key); + return val != null ? val.toString() : null; + } + + private int num(Map 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> getMetaRelations(Map params) { + return sqlSession.selectList(NS + "getMetaRelations", params); + } +} diff --git a/backend-spring/src/main/java/com/erp/service/TemplateService.java b/backend-spring/src/main/java/com/erp/service/TemplateService.java new file mode 100644 index 00000000..755ae3cc --- /dev/null +++ b/backend-spring/src/main/java/com/erp/service/TemplateService.java @@ -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 getTemplateList(Map params) { + commonService.applyPagination(params); + int totalCount = sqlSession.selectOne(NS + "getTemplateListCnt", params); + List> list = sqlSession.selectList(NS + "getTemplateList", params); + return commonService.buildListResponse(list, totalCount, params); + } + + // ────────────────────────────────────────────────── + // 단건 + // ────────────────────────────────────────────────── + + public Map getTemplateInfo(Map params) { + Map 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 insertTemplate(Map 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 result = new HashMap<>(); + result.put("template_id", templateId); + return result; + } + + // ────────────────────────────────────────────────── + // 수정 + // ────────────────────────────────────────────────── + + @Transactional + public void updateTemplate(Map params) { + stringifyJsonField(params, "fields"); + stringifyJsonField(params, "views"); + stringifyJsonField(params, "connections"); + + sqlSession.update(NS + "updateTemplate", params); + } + + // ────────────────────────────────────────────────── + // 게시 + // ────────────────────────────────────────────────── + + @Transactional + public void publishTemplate(Map params) { + sqlSession.update(NS + "publishTemplate", params); + } + + // ────────────────────────────────────────────────── + // 삭제 (소프트) + // ────────────────────────────────────────────────── + + @Transactional + public int deleteTemplate(Map params) { + return sqlSession.update(NS + "deleteTemplate", params); + } + + // ────────────────────────────────────────────────── + // JSON 유틸 + // ────────────────────────────────────────────────── + + private void parseJsonField(Map 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 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, "[]"); + } + } + } +} diff --git a/backend-spring/src/main/java/com/erp/service/UserOverrideService.java b/backend-spring/src/main/java/com/erp/service/UserOverrideService.java new file mode 100644 index 00000000..f305524b --- /dev/null +++ b/backend-spring/src/main/java/com/erp/service/UserOverrideService.java @@ -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 getUserOverride(Map params) { + Map row = sqlSession.selectOne(NS + "getUserOverride", params); + if (row != null) { + parseJsonField(row, "overrides"); + } + return row; + } + + @Transactional + public void upsertUserOverride(Map 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 params) { + sqlSession.delete(NS + "deleteUserOverride", params); + } + + private void parseJsonField(Map 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 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); + } + } + } +} diff --git a/backend-spring/src/main/resources/application.yml b/backend-spring/src/main/resources/application.yml index a7322563..d58fdd04 100644 --- a/backend-spring/src/main/resources/application.yml +++ b/backend-spring/src/main/resources/application.yml @@ -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 diff --git a/backend-spring/src/main/resources/mapper/businessRule.xml b/backend-spring/src/main/resources/mapper/businessRule.xml new file mode 100644 index 00000000..2d94b431 --- /dev/null +++ b/backend-spring/src/main/resources/mapper/businessRule.xml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + 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 + ) + + + + + 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 BUSINESS_RULES + SET IS_ACTIVE = 'D' + , UPDATED_BY = #{user_id} + , UPDATED_DATE = CURRENT_TIMESTAMP + WHERE RULE_ID = #{rule_id} + + + + + 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' + + + diff --git a/backend-spring/src/main/resources/mapper/dashboard.xml b/backend-spring/src/main/resources/mapper/dashboard.xml index bba1544e..015484e7 100644 --- a/backend-spring/src/main/resources/mapper/dashboard.xml +++ b/backend-spring/src/main/resources/mapper/dashboard.xml @@ -1,63 +1,226 @@ - + - - - AND (NAME ILIKE '%' || #{keyword} || '%') - - + - + - + - + - - INSERT INTO DASHBOARD ( - COMPANY_CODE - , CREATED_DATE - , UPDATED_DATE - ) VALUES ( - #{company_code} - , NOW() - , NOW() - ) - + + INSERT INTO DASHBOARDS ( + DASHBOARD_ID + , NAME + , ICON + , DISPLAY_ORDER + , COMPANY_CODE + , USER_ID + , IS_ACTIVE + , CREATED_BY + , CREATED_DATE + , UPDATED_BY + , UPDATED_DATE + ) VALUES ( + #{dashboard_id} + , #{name} + , #{icon} + , #{display_order} + , #{company_code} + , #{user_id} + , 'Y' + , #{user_id} + , CURRENT_TIMESTAMP + , #{user_id} + , CURRENT_TIMESTAMP + ) + - - UPDATE DASHBOARD - SET - UPDATED_DATE = NOW() - WHERE ID = #{id} - - + + UPDATE DASHBOARDS + SET UPDATED_DATE = CURRENT_TIMESTAMP + , UPDATED_BY = #{user_id} + + , NAME = #{name} + + + , ICON = #{icon} + + + , DISPLAY_ORDER = #{display_order} + + WHERE DASHBOARD_ID = #{dashboard_id} + AND IS_ACTIVE = 'Y' + - - DELETE FROM DASHBOARD - WHERE ID = #{id} - - + + UPDATE DASHBOARDS + SET IS_ACTIVE = 'D' + , UPDATED_DATE = CURRENT_TIMESTAMP + , UPDATED_BY = #{user_id} + WHERE DASHBOARD_ID = #{dashboard_id} + + + + + + + + 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 + ) + + + + UPDATE DASHBOARD_CARDS + SET UPDATED_DATE = CURRENT_TIMESTAMP + + , POSITION_X = #{position_x} + + + , POSITION_Y = #{position_y} + + + , WIDTH = #{width} + + + , HEIGHT = #{height} + + + , IS_COLLAPSED = #{is_collapsed} + + + , DISPLAY_ORDER = #{display_order} + + WHERE CARD_ID = #{card_id} + AND IS_ACTIVE = 'Y' + + + + 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 DASHBOARD_CARDS + SET IS_ACTIVE = 'D' + , UPDATED_DATE = CURRENT_TIMESTAMP + WHERE CARD_ID = #{card_id} + + + + + diff --git a/backend-spring/src/main/resources/mapper/meta.xml b/backend-spring/src/main/resources/mapper/meta.xml new file mode 100644 index 00000000..936a05c8 --- /dev/null +++ b/backend-spring/src/main/resources/mapper/meta.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend-spring/src/main/resources/mapper/template.xml b/backend-spring/src/main/resources/mapper/template.xml new file mode 100644 index 00000000..96b05206 --- /dev/null +++ b/backend-spring/src/main/resources/mapper/template.xml @@ -0,0 +1,149 @@ + + + + + + + + + + + + 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() + ) + + + + 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 TEMPLATES + SET + STATUS = 'published' + , UPDATED_BY = #{user_id} + , UPDATED_DATE = NOW() + WHERE TEMPLATE_ID = #{template_id} + AND IS_ACTIVE != 'D' + + + + UPDATE TEMPLATES + SET + IS_ACTIVE = 'D' + , UPDATED_BY = #{user_id} + , UPDATED_DATE = NOW() + WHERE TEMPLATE_ID = #{template_id} + + + diff --git a/backend-spring/src/main/resources/mapper/userOverride.xml b/backend-spring/src/main/resources/mapper/userOverride.xml new file mode 100644 index 00000000..368bced3 --- /dev/null +++ b/backend-spring/src/main/resources/mapper/userOverride.xml @@ -0,0 +1,45 @@ + + + + + + + + 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 + + + + DELETE FROM USER_OVERRIDES + WHERE USER_ID = #{user_id} + AND CARD_ID = #{card_id} + + + diff --git a/docker/dev/docker-compose.invyone.yml b/docker/dev/docker-compose.invyone.yml index 2a68a69a..b4a19f9c 100644 --- a/docker/dev/docker-compose.invyone.yml +++ b/docker/dev/docker-compose.invyone.yml @@ -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 diff --git a/frontend/app/(main)/admin/builder/page.tsx b/frontend/app/(main)/admin/builder/page.tsx new file mode 100644 index 00000000..c721df91 --- /dev/null +++ b/frontend/app/(main)/admin/builder/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import BuilderLayout from "@/components/builder/BuilderLayout"; + +export default function BuilderPage() { + return ; +} diff --git a/frontend/app/(main)/dash/page.tsx b/frontend/app/(main)/dash/page.tsx new file mode 100644 index 00000000..f925500b --- /dev/null +++ b/frontend/app/(main)/dash/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { DashboardLayout } from '@/components/dash/DashboardLayout'; + +export default function DashPage() { + return ; +} diff --git a/frontend/app/(main)/test-fc/page.tsx b/frontend/app/(main)/test-fc/page.tsx new file mode 100644 index 00000000..5d4e60f8 --- /dev/null +++ b/frontend/app/(main)/test-fc/page.tsx @@ -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[]>([]); + const [selectedTable, setSelectedTable] = useState(''); + + // FieldConfig + const [fields, setFields] = useState([]); + const [tableLabel, setTableLabel] = useState(''); + const [primaryKey, setPrimaryKey] = useState(null); + + // 데이터 + const [data, setData] = useState[]>([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [loading, setLoading] = useState(false); + + // 검색 + 행 선택 + const [searchParams, setSearchParams] = useState>({}); + const [selectedRow, setSelectedRow] = useState | null>(null); + const [selectedRowIndex, setSelectedRowIndex] = useState(-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) => { + setSearchParams(params); + setPage(1); + setSelectedRow(null); + setSelectedRowIndex(-1); + }, []); + + // 5. 행 선택 연동: FcTable → FcForm + const handleRowSelect = useCallback((row: Record) => { + setSelectedRow(row); + const idx = data.findIndex((d) => d === row); + setSelectedRowIndex(idx); + }, [data]); + + // 6. 폼 제출: FcForm → DB 저장 → FcTable 새로고침 + const handleFormSubmit = useCallback(async (formData: Record) => { + 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 ( +
+ {/* 헤더 */} +
+

+ FieldConfig 컴포넌트 테스트 +

+ {message && ( +
+ {message.text} +
+ )} +
+ + {/* 테이블 선택 드롭다운 */} +
+ + + {selectedTable && ( + + {tableLabel} — {fields.length}개 필드, PK: {primaryKey ?? 'N/A'} + + )} +
+ + {/* 메인 콘텐츠: 선택된 테이블이 있을 때만 */} + {selectedTable && fields.length > 0 && ( +
+ {/* 왼쪽: 검색 + 테이블 + 페이지네이션 */} +
+ + + + + +
+ + {/* 오른쪽: 폼 */} +
+
+ {selectedRow ? '수정' : '신규 등록'} +
+ +
+
+ )} + + {/* 선택 안 됐을 때 */} + {!selectedTable && ( +
+ 테이블을 선택하면 FcSearch + FcTable + FcForm이 렌더됩니다 +
+ )} +
+ ); +} diff --git a/frontend/components/builder/BuilderBlock.tsx b/frontend/components/builder/BuilderBlock.tsx new file mode 100644 index 00000000..8d629c14 --- /dev/null +++ b/frontend/components/builder/BuilderBlock.tsx @@ -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 ( +
{ + 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); }} + > +
{block.label}
+
+ +
+
startResize(e, block.id, x, y, w, h)} + /> +
+ ); +} + +/** 블록 내부 프리뷰 렌더 (타입별 분기) */ +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 ; + case "form": + return ; + case "search": + return f.searchable && !f.system)} />; + case "title": + return ; + case "button": + return ; + case "button-bar": + return ; + case "pagination": + return ; + case "divider": + return
; + case "stats": + return
통계 카드 프리뷰
; + default: + return
{block.type}
; + } +} + +function TablePreview({ fields }: { fields: FieldConfig[] }) { + const cols = fields.slice(0, 8); + if (!cols.length) return
테이블을 선택하세요
; + return ( + + + {cols.map((f) => )} + + + {[0, 1, 2].map((r) => ( + {cols.map((f) => )} + ))} + +
{f.label}
+ ); +} + +function FormPreview({ fields, config }: { fields: FieldConfig[]; config: FormConfig }) { + const cols = config?.columns || 2; + const formFields = fields.filter((f) => !f.pk || f.type !== "code"); + return ( +
+ {formFields.slice(0, 10).map((f) => ( +
+
+ {f.label}{f.required && *} +
+
+ {f.type === "select" + ? (typeof f.options?.[0] === "string" ? f.options[0] : typeof f.options?.[0] === "object" ? f.options[0].label : "—") + : "—"} +
+
+ ))} +
+ ); +} + +function SearchPreview({ fields }: { fields: FieldConfig[] }) { + if (!fields.length) return
검색 조건 없음
; + return ( +
+ {fields.slice(0, 5).map((f) => ( +
+
{f.label}
+
+
+ ))} +
+ +
+
+ ); +} + +function TitlePreview({ config }: { config: TitleConfig }) { + return ( +
+ {config.text || "제목"} +
+ ); +} + +function ButtonPreview({ config }: { config: ButtonConfig }) { + const cls = config.variant === "primary" ? "dev-pv-btn primary" : "dev-pv-btn"; + return
{config.text || "버튼"}
; +} + +function ButtonBarPreview({ config }: { config: ButtonBarConfig }) { + return ( +
+ {(config.buttons || []).map((btn, i) => ( +
+ {btn.text} +
+ ))} +
+ ); +} + +function PaginationPreview() { + return ( +
+ 총 0건 +
+ 1 + 2 + 3 +
+ 20건/페이지 +
+ ); +} diff --git a/frontend/components/builder/BuilderCanvas.tsx b/frontend/components/builder/BuilderCanvas.tsx new file mode 100644 index 00000000..ab794dd9 --- /dev/null +++ b/frontend/components/builder/BuilderCanvas.tsx @@ -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 ( +
+
+
+
+ {blocks.length === 0 && ( +
+
📝
+
+ {currentView === "create" ? "등록" : "수정"} 팝업에 컴포넌트를 배치하세요 +
+
+ )} + {blocks.map((block) => ( + + ))} +
+
+
+
+ ); + } + + return ( +
+
+ {blocks.length === 0 && ( +
+
🎨
+
+ 팔레트에서 컴포넌트를 드래그하거나 클릭하여 추가하세요 +
+
+ )} + {blocks.map((block) => ( + + ))} +
+
+ ); +} diff --git a/frontend/components/builder/BuilderLayout.tsx b/frontend/components/builder/BuilderLayout.tsx new file mode 100644 index 00000000..811559e5 --- /dev/null +++ b/frontend/components/builder/BuilderLayout.tsx @@ -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 ( +
+ + +
+ + + +
+ + {/* 상태바 */} +
+ 블록 {blockCount}개 · {tableName || "테이블 미선택"} · 연결 {connections.length}개 + {isDirty ? "수정됨" : "저장됨"} +
+
+ ); +} diff --git a/frontend/components/builder/BuilderPalette.tsx b/frontend/components/builder/BuilderPalette.tsx new file mode 100644 index 00000000..929b7645 --- /dev/null +++ b/frontend/components/builder/BuilderPalette.tsx @@ -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 ( +
+
컴포넌트
+ {PALETTE_ITEMS.map((sec) => ( + +
{sec.section}
+ {sec.items.map((item) => ( +
handleDragStart(e, item.type)} + onClick={() => handleClick(item.type)} + style={{ opacity: !tableName && ["table", "form", "search"].includes(item.type) ? 0.4 : 1 }} + > + {item.icon} + {item.label} +
+ ))} +
+ ))} +
+ ); +} diff --git a/frontend/components/builder/BuilderProps.tsx b/frontend/components/builder/BuilderProps.tsx new file mode 100644 index 00000000..3ef73b4b --- /dev/null +++ b/frontend/components/builder/BuilderProps.tsx @@ -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 = { + 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 ( +
+
속성
+
+ 캔버스에서 컴포넌트를
선택하세요 +
+
+ ); + } + + return ( +
+
{TYPE_LABELS[block.type] || block.type}
+ + {/* 공통: 이름 */} +
컴포넌트 정보
+
+ 이름 + updateBlock(block.id, { label: e.target.value })} + /> +
+ + {/* 공통: 위치/크기 */} +
위치 · 크기
+
+
+ + moveBlock(block.id, Number(e.target.value), block.position.y)} /> +
+
+ + moveBlock(block.id, block.position.x, Number(e.target.value))} /> +
+
+ + resizeBlock(block.id, Number(e.target.value), block.position.h)} /> +
+
+ + resizeBlock(block.id, block.position.w, Number(e.target.value))} /> +
+
+ + {/* 타입별 속성 패널 */} + {block.type === "table" && } + {block.type === "form" && } + {block.type === "search" && } + {block.type === "button" && } + {block.type === "button-bar" && } + {block.type === "title" && } + + {/* 삭제 버튼 */} +
+ +
+
+ ); +} diff --git a/frontend/components/builder/BuilderToolbar.tsx b/frontend/components/builder/BuilderToolbar.tsx new file mode 100644 index 00000000..8aaafde7 --- /dev/null +++ b/frontend/components/builder/BuilderToolbar.tsx @@ -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[]>([]); + 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) => { + 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 = { + 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 ( + <> + {/* 헤더 */} +
+
+ INVYONE + DEV + setTemplateMeta({ templateName: e.target.value })} + placeholder="템플릿 이름" + /> +
+
+ +
+
+ + {/* 도구모음 */} +
+
+ 테이블 + +
+ +
+ + {VIEW_TABS.map((tab) => ( + + ))} +
+ +
+ + {isDirty && ( + + ● 수정됨 + + )} +
+ + ); +} diff --git a/frontend/components/builder/hooks/useBlockDrag.ts b/frontend/components/builder/hooks/useBlockDrag.ts new file mode 100644 index 00000000..31c2602d --- /dev/null +++ b/frontend/components/builder/hooks/useBlockDrag.ts @@ -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(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 }; +} diff --git a/frontend/components/builder/hooks/useBuilderState.ts b/frontend/components/builder/hooks/useBuilderState.ts new file mode 100644 index 00000000..b43b08a4 --- /dev/null +++ b/frontend/components/builder/hooks/useBuilderState.ts @@ -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; + + // 선택된 블록 + 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) => 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) => void; + updateField: (column: string, updates: Partial) => void; + setTemplateMeta: (meta: { templateName?: string; category?: string; description?: string }) => void; + addConnection: (conn: Connection) => void; + removeConnection: (connId: string) => void; + toTemplate: () => Template; + fromTemplate: (tpl: Record) => 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 = { + 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, + selectedBlockId: null as string | null, + connections: [] as Connection[], + templateId: null as string | null, + templateName: "", + category: "", + description: "", + isDirty: false, +}; + +// ─── 스토어 ─── +export const useBuilderState = create()( + 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; + }); +} diff --git a/frontend/components/builder/props/ButtonProps.tsx b/frontend/components/builder/props/ButtonProps.tsx new file mode 100644 index 00000000..8127a208 --- /dev/null +++ b/frontend/components/builder/props/ButtonProps.tsx @@ -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 ( + <> +
버튼 설정
+
+ 텍스트 + update("text", e.target.value)} /> +
+
+ 액션 + +
+
+ 스타일 + +
+
+ 확인 메시지 + update("confirm", e.target.value || undefined)} /> +
+ + ); +} + +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 ( + <> +
버튼 목록
+ {config.buttons.map((btn, i) => ( +
+
+ updateButton(i, "text", e.target.value)} /> + +
+
+ + +
+
+ ))} +
+ +
+ + ); +} diff --git a/frontend/components/builder/props/FieldListEditor.tsx b/frontend/components/builder/props/FieldListEditor.tsx new file mode 100644 index 00000000..9b704003 --- /dev/null +++ b/frontend/components/builder/props/FieldListEditor.tsx @@ -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(null); + + const filteredFields = filter ? fields.filter(filter) : fields; + const sorted = [...filteredFields].sort((a, b) => a.order - b.order); + + return ( +
+ {sorted.map((f) => ( + +
setExpandedCol(expandedCol === f.column ? null : f.column)} + > +
{ + e.stopPropagation(); + updateField(f.column, { [toggleKey]: !f[toggleKey] }); + }} + > + ✓ +
+ {f.label} +
+ {f.pk && PK} + {f.required && 필수} + {f.searchable && 검색} + {f.system && SYS} + {f.computed && 계산} +
+ {f.type} + +
+ {expandedCol === f.column && } +
+ ))} +
+ ); +} + +/** 필드 상세 편집 패널 */ +function FieldDetail({ field }: { field: FieldConfig }) { + const updateField = useBuilderState((s) => s.updateField); + const col = field.column; + + return ( +
e.stopPropagation()}> +
+ + +
+
+ updateField(col, { required: v })} /> + updateField(col, { editable: v })} /> + updateField(col, { searchable: v })} /> + updateField(col, { sortable: v })} /> +
+
+ ); +} + +function Toggle({ label, checked, onToggle }: { label: string; checked: boolean; onToggle: (v: boolean) => void }) { + return ( +
onToggle(!checked)}> +
+ {label} +
+ ); +} diff --git a/frontend/components/builder/props/FormProps.tsx b/frontend/components/builder/props/FormProps.tsx new file mode 100644 index 00000000..5b536a5d --- /dev/null +++ b/frontend/components/builder/props/FormProps.tsx @@ -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 ( + <> +
폼 설정
+
+ 컬럼 수 + +
+
+ 저장 방식 + +
+ +
입력 항목
+
체크: 폼에 표시 · 클릭: 상세 설정
+ !f.system} toggleKey="visible" /> + + ); +} diff --git a/frontend/components/builder/props/SearchProps.tsx b/frontend/components/builder/props/SearchProps.tsx new file mode 100644 index 00000000..3bbc3504 --- /dev/null +++ b/frontend/components/builder/props/SearchProps.tsx @@ -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 ( + <> +
검색 설정
+
+ 날짜 범위 검색 +
update("dateRangeEnabled", !config.dateRangeEnabled)} /> +
+
+ 초기화 버튼 +
update("showResetButton", !config.showResetButton)} /> +
+
+ 자동 검색 +
update("autoSearch", !config.autoSearch)} /> +
+
+ 레이아웃 + +
+ +
검색 조건
+
체크: 검색에 포함 · 클릭: 상세 설정
+ !f.system} toggleKey="searchable" /> + + ); +} diff --git a/frontend/components/builder/props/TableProps.tsx b/frontend/components/builder/props/TableProps.tsx new file mode 100644 index 00000000..dca823b3 --- /dev/null +++ b/frontend/components/builder/props/TableProps.tsx @@ -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 ( + <> +
테이블 설정
+
+ 페이지 크기 + +
+
+ 선택 방식 + +
+
+ 자동 로드 +
update("autoLoad", !config.autoLoad)} /> +
+
+ 인라인 편집 +
update("inlineEdit", !config.inlineEdit)} /> +
+
+ 체크박스 +
update("showCheckbox", !config.showCheckbox)} /> +
+
+ 스타일 + +
+ +
표시할 컬럼
+
체크: 보이기 · 클릭: 상세 설정
+ !f.system} toggleKey="visible" /> + + ); +} diff --git a/frontend/components/builder/props/TitleProps.tsx b/frontend/components/builder/props/TitleProps.tsx new file mode 100644 index 00000000..12a48a8d --- /dev/null +++ b/frontend/components/builder/props/TitleProps.tsx @@ -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 ( + <> +
제목 설정
+
+ 텍스트 + update("text", e.target.value)} /> +
+
+ 크기 + +
+
+ 굵기 + +
+
+ 정렬 + +
+ + ); +} diff --git a/frontend/components/control/ConnectionLine.tsx b/frontend/components/control/ConnectionLine.tsx new file mode 100644 index 00000000..7328bc80 --- /dev/null +++ b/frontend/components/control/ConnectionLine.tsx @@ -0,0 +1,161 @@ +'use client'; + +/** + * SVG 연결선 + 화살표 마커 4종 + 뱃지 + * mockup drawTreeLine/addEdgeBadge 포팅 + */ + +interface ConnectionSvgProps { + children?: React.ReactNode; +} + +/** SVG 컨테이너 (캔버스 위 오버레이, defs 포함) */ +export function ConnectionSvg({ children }: ConnectionSvgProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + {children} + + ); +} + +/** 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 ( + { + 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 ; +} + +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 ( +
{ + if (el && animate) requestAnimationFrame(() => { el.style.opacity = '1'; }); + }} + > +
조건 분기
+
{condText}
+
+ Yes → {actionText} + No → 스킵 +
+
+ ); + } + + return ( +
{ + if (el && animate) requestAnimationFrame(() => { el.style.opacity = '1'; }); + }} + > + {label} +
+ ); +} diff --git a/frontend/components/control/ControlMode.tsx b/frontend/components/control/ControlMode.tsx new file mode 100644 index 00000000..3dbf9e45 --- /dev/null +++ b/frontend/components/control/ControlMode.tsx @@ -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[]; + canvasRef: React.RefObject; +} + +/** + * 제어 모드 오버레이 — 캔버스 위에 렌더 + * ⚡ 버튼으로 토글, 읽기/편집 모드 전환 + */ +export function ControlMode({ dashboardId, cards, canvasRef }: ControlModeProps) { + const { active, mode } = useControlMode(); + + if (!active) return null; + + return ( + <> + {/* 제어 모드 툴바 */} + + + {/* 읽기 모드: 카드 클릭 → 흐름 시각화 */} + {mode === 'view' && ( + + )} + + {/* 편집 모드: 규칙 빌더 */} + {mode === 'edit' && ( + + )} + + ); +} + +/** + * 제어 모드 팔레트 wrapper — 사이드바에 삽입 + */ +export function ControlPaletteWrapper() { + const { active, mode, addRuleNode } = useControlMode(); + if (!active || mode !== 'edit') return null; + + return ( + {}} + onDropControl={() => {}} + /> + ); +} diff --git a/frontend/components/control/ControlNode.tsx b/frontend/components/control/ControlNode.tsx new file mode 100644 index 00000000..69b65cdf --- /dev/null +++ b/frontend/components/control/ControlNode.tsx @@ -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; + 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(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 ( +
+ {/* Input 포트 */} + + + {/* 헤더 */} +
+
{def.icon}
+ {def.label} + +
+ + {/* 본문 */} +
setConfigNodeId(node.id)} + > +
+ {node.config?.summary || '클릭하여 설정'} +
+
+ + {/* Output 포트 */} +
+ {outPorts.map((p) => ( + + ))} +
+
+ ); +} diff --git a/frontend/components/control/ControlPalette.tsx b/frontend/components/control/ControlPalette.tsx new file mode 100644 index 00000000..d6a46681 --- /dev/null +++ b/frontend/components/control/ControlPalette.tsx @@ -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[]>([]); + + useEffect(() => { + getMetaTableList().then(setTables).catch(() => {}); + }, []); + + const handleDragStart = (e: React.DragEvent, data: Record) => { + e.dataTransfer.setData('text/plain', JSON.stringify(data)); + e.dataTransfer.effectAllowed = 'copy'; + }; + + const catLabels: Record = { + '트리거': '트리거', + '조건': '조건 / 분기', + '액션': '액션', + '흐름': '흐름 제어', + '연동': '외부 연동', + '기록': '기록', + }; + const cats = ['트리거', '조건', '액션', '흐름', '연동', '기록']; + + return ( +
+ {/* DB 테이블 섹션 */} +
DB 테이블
+ {tables.map((t) => { + const name = t.table_name ?? t.TABLE_NAME; + const label = t.table_label ?? t.TABLE_LABEL ?? name; + return ( +
handleDragStart(e, { kind: 'table', name })} + > + 🏢 + {name} +
+ ); + })} + + {/* 제어 노드 — 카테고리별 그룹 */} + {cats.map((cat) => { + const items = Object.entries(CTRL_NODE_TYPES).filter(([, d]) => d.cat === cat); + if (!items.length) return null; + return ( +
+
{catLabels[cat] ?? cat}
+ {items.map(([type, def]) => ( +
handleDragStart(e, { kind: 'control', type })} + > + {def.icon} + {def.label} +
+ ))} +
+ ); + })} +
+ ); +} diff --git a/frontend/components/control/ControlToolbar.tsx b/frontend/components/control/ControlToolbar.tsx new file mode 100644 index 00000000..66fe00fe --- /dev/null +++ b/frontend/components/control/ControlToolbar.tsx @@ -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[]>([]); + 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 ( +
+ ⚡ 제어 모드 +
+ + +
+ {mode === 'edit' && ( +
+ {/* ★ 기존 규칙 로드 버튼 */} + + {/* ★ 규칙 목록 드롭다운 */} + {showRuleList && ruleList.length > 0 && ( +
+ {ruleList.map((rule) => { + const id = rule.rule_id ?? rule.RULE_ID; + const name = rule.name ?? rule.NAME ?? id; + const isActive = id === activeRuleId; + return ( + + ); + })} +
+ )} + {ruleNodes.length > 0 && ( + + )} +
+ )} +
+ ); +} diff --git a/frontend/components/control/FlowViewer.tsx b/frontend/components/control/FlowViewer.tsx new file mode 100644 index 00000000..d2f9f227 --- /dev/null +++ b/frontend/components/control/FlowViewer.tsx @@ -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[]; + canvasRef: React.RefObject; + dashboardId: string; +} + +/** 저장된 룰 그래프 (노드+연결선, 별도 오버레이) */ +interface RuleOverlay { + ruleName: string; + nodes: Record[]; + connections: Record[]; +} + +/** + * 룰 노드의 포트 위치 계산 (RuleBuilder.portPos와 동일 로직) + * - table 노드: width 200, 포트 in=좌측, out=우측 (y+18) + * - control 노드: width 160, in=좌측 (y+40), out=우측 (다중 포트 분배) + */ +function computePortPos(node: Record, 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)[node.type]; + const outPorts = def?.out || [{ port: 'out', label: '→', cls: '' }]; + const idx = outPorts.findIndex((p: Record) => 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 = {}; + +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>({}); + const [animTimings, setAnimTimings] = useState<{ edge: Record; lineDelay: number; nodeDelay: number }[]>([]); + const [revealedNodes, setRevealedNodes] = useState>(new Set()); + const [ruleOverlays, setRuleOverlays] = useState([]); + const animRef = useRef[]>([]); + + // 카드 클릭 → 흐름 표시 + 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[] = []; + 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) => 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[] = 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(); + 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 = {}; + 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(); + 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 연결선 */} + + {animTimings.map(({ edge, lineDelay }, idx) => { + const from = getFromPos(edge.from); + const to = getToPos(edge.to); + if (!from || !to) return null; + return ( + + ); + })} + + + {/* 연결선 뱃지 */} + {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 ( + + ); + })} + + {/* 테이블 노드 (table_relationships 레이어) */} + {Object.entries(tablePositions).map(([name, pos]) => { + const meta = tableMetas[name]; + if (!meta) return null; + const revealed = revealedNodes.has(name); + return ( + + ); + })} + + {/* ★ 비즈니스 룰 오버레이 (별도 레이어 — 저장된 노드 type별로 렌더) */} + {ruleOverlays.map((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 ( +
+ {}} + /> +
+ ); + } + // ★ 그 외는 제어 노드 (CTRL_NODE_TYPES) + const nodeType = node.type ?? 'auto-insert'; + const typeDef = (CTRL_NODE_TYPES as Record)[nodeType]; + return ( +
+
+
{typeDef?.icon ?? '⚡'}
+ {typeDef?.label ?? nodeType} +
+
+
+ [{overlay.ruleName}] +
+
+
+ ); + })} + + {/* 룰 연결선 (SVG) — RuleBuilder.portPos와 동일한 앵커 계산 */} + + {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 ( + + ); + })} + +
+ ))} + + ); +} diff --git a/frontend/components/control/NodeConfigPopover.tsx b/frontend/components/control/NodeConfigPopover.tsx new file mode 100644 index 00000000..0d6b12e3 --- /dev/null +++ b/frontend/components/control/NodeConfigPopover.tsx @@ -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(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) => { + updateRuleNode(node.id, { config: { ...node.config, ...config, summary } }); + setConfigNodeId(null); + }; + + return ( +
+
{def.icon} {def.label} 설정
+ setConfigNodeId(null)} /> +
+ ); +} + +function ConfigForm({ type, config, onSave, onClose }: { + type: string; config: Record; + onSave: (summary: string, config: Record) => void; + onClose: () => void; +}) { + const [vals, setVals] = useState>(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)} +
+ + +
+ + ); +} + +function renderFields( + type: string, + vals: Record, + set: (k: string, v: any) => void +) { + switch (type) { + case 'condition': + return ( + <> + + set('field', v)} placeholder="STATUS" /> + + + ', '<', '기한 경과', '포함']} /> + + + set('value', v)} placeholder="비교값" /> + + + ); + case 'status-change': + return ( + <> + + set('table', v)} placeholder="테이블명" /> + + + set('field', v)} /> + + + set('value', v)} placeholder="새 값" /> + + + ); + case 'auto-insert': + return ( + + set('table', v)} placeholder="테이블명" /> + + ); + case 'timer': + return ( + <> + + set('field', v)} placeholder="ORDER_DATE" /> + + +
+ set('amount', v)} placeholder="0" /> + set('unit', v)} options={['일', '시간', '주']} /> +
+
+ + ); + case 'notification': + return ( + <> + + set('channel', v)} + options={['이메일', 'SMS', '푸시', 'Slack']} /> + + + set('target', v)} + options={['담당자', '관리자', '전체']} /> + + +