중간 세이브
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.BusinessRuleService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class BusinessRuleController {
|
||||
|
||||
private final BusinessRuleService businessRuleService;
|
||||
|
||||
/**
|
||||
* GET /api/dashboards/{dashboardId}/rules — 해당 대시보드의 룰 목록
|
||||
*/
|
||||
@GetMapping("/api/dashboards/{dashboardId}/rules")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getBusinessRuleList(
|
||||
@PathVariable String dashboardId,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam(required = false, defaultValue = "1") int page,
|
||||
@RequestParam(required = false, defaultValue = "100") int limit) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("dashboard_id", dashboardId);
|
||||
params.put("company_code", companyCode);
|
||||
params.put("page", page);
|
||||
params.put("limit", limit);
|
||||
return ResponseEntity.ok(ApiResponse.success(businessRuleService.getBusinessRuleList(params)));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/rules/{ruleId} — 룰 상세 (노드 + 연결)
|
||||
*/
|
||||
@GetMapping("/api/rules/{ruleId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getBusinessRuleInfo(
|
||||
@PathVariable String ruleId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("rule_id", ruleId);
|
||||
Map<String, Object> result = businessRuleService.getBusinessRuleInfo(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.ok(ApiResponse.error("Rule not found"));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/dashboards/{dashboardId}/rules — 룰 생성
|
||||
*/
|
||||
@PostMapping("/api/dashboards/{dashboardId}/rules")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertBusinessRule(
|
||||
@PathVariable String dashboardId,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
body.put("dashboard_id", dashboardId);
|
||||
body.put("company_code", companyCode);
|
||||
body.put("user_id", userId);
|
||||
return ResponseEntity.ok(ApiResponse.success(businessRuleService.insertBusinessRule(body)));
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/rules/{ruleId} — 룰 수정
|
||||
*/
|
||||
@PutMapping("/api/rules/{ruleId}")
|
||||
public ResponseEntity<ApiResponse<Void>> updateBusinessRule(
|
||||
@PathVariable String ruleId,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
body.put("rule_id", ruleId);
|
||||
body.put("user_id", userId);
|
||||
businessRuleService.updateBusinessRule(body);
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/rules/{ruleId} — 룰 삭제
|
||||
*/
|
||||
@DeleteMapping("/api/rules/{ruleId}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteBusinessRule(
|
||||
@PathVariable String ruleId,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("rule_id", ruleId);
|
||||
params.put("user_id", userId);
|
||||
businessRuleService.deleteBusinessRule(params);
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/rules/{ruleId}/toggle — 활성/비활성 토글
|
||||
*/
|
||||
@PutMapping("/api/rules/{ruleId}/toggle")
|
||||
public ResponseEntity<ApiResponse<Void>> toggleBusinessRule(
|
||||
@PathVariable String ruleId,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("rule_id", ruleId);
|
||||
params.put("user_id", userId);
|
||||
businessRuleService.toggleBusinessRule(params);
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
}
|
||||
@@ -2,165 +2,137 @@ package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.DashboardService;
|
||||
import com.erp.service.ExternalRestApiConnectionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/dashboard")
|
||||
@RequestMapping("/api/dashboards")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DashboardController {
|
||||
|
||||
private final DashboardService dashboardService;
|
||||
private final ExternalRestApiConnectionService externalRestApiConnectionService;
|
||||
|
||||
// ═══ 대시보드 CRUD ═══
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getDashboards(
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getDashboardList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "50") int limit) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("user_id", userId);
|
||||
params.put("keyword", keyword);
|
||||
params.put("page", page);
|
||||
params.put("limit", limit);
|
||||
return ResponseEntity.ok(ApiResponse.success(dashboardService.getDashboardList(params)));
|
||||
}
|
||||
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getDashboardsLegacy(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(dashboardService.getDashboardList(params)));
|
||||
}
|
||||
|
||||
@GetMapping("/public")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getPublicDashboards(
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", "*");
|
||||
params.put("is_public", "true");
|
||||
return ResponseEntity.ok(ApiResponse.success(dashboardService.getDashboardList(params)));
|
||||
}
|
||||
|
||||
@GetMapping("/my")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getMyDashboards(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(dashboardService.getDashboardList(params)));
|
||||
}
|
||||
|
||||
@GetMapping("/public/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getPublicDashboard(
|
||||
@PathVariable String id) {
|
||||
Map<String, Object> result = dashboardService.getDashboardById(id, null);
|
||||
if (result == null) return ResponseEntity.status(404).body(ApiResponse.error("대시보드를 찾을 수 없습니다."));
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getDashboard(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "user_id", required = false) String userId,
|
||||
@PathVariable String id) {
|
||||
Map<String, Object> result = dashboardService.getDashboardById(id, companyCode);
|
||||
if (result == null) return ResponseEntity.status(404).body(ApiResponse.error("대시보드를 찾을 수 없거나 접근 권한이 없습니다."));
|
||||
// 다른 사람 것 조회 시 조회수 증가
|
||||
if (userId != null && !userId.equals(result.get("created_by"))) {
|
||||
dashboardService.incrementViewCount(id);
|
||||
@GetMapping("/{dashboardId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getDashboardInfo(
|
||||
@PathVariable String dashboardId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("dashboard_id", dashboardId);
|
||||
Map<String, Object> result = dashboardService.getDashboardInfo(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.ok(ApiResponse.error("대시보드를 찾을 수 없습니다"));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> createDashboard(
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertDashboard(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "user_id", required = false) String userId,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String title = (String) body.get("title");
|
||||
if (title == null || title.isBlank()) return ResponseEntity.status(400).body(ApiResponse.error("대시보드 제목이 필요합니다."));
|
||||
Map<String, Object> result = dashboardService.createDashboard(body, userId, companyCode);
|
||||
return ResponseEntity.status(201).body(ApiResponse.success(result, "대시보드가 생성되었습니다."));
|
||||
body.put("company_code", companyCode);
|
||||
body.put("user_id", userId);
|
||||
return ResponseEntity.ok(ApiResponse.success(dashboardService.insertDashboard(body)));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateDashboard(
|
||||
@PathVariable String id,
|
||||
@RequestAttribute(value = "user_id", required = false) String userId,
|
||||
@PutMapping("/{dashboardId}")
|
||||
public ResponseEntity<ApiResponse<Void>> updateDashboard(
|
||||
@PathVariable String dashboardId,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
Map<String, Object> result = dashboardService.updateDashboard(id, body, userId);
|
||||
if (result == null) return ResponseEntity.status(404).body(ApiResponse.error("대시보드를 찾을 수 없거나 수정 권한이 없습니다."));
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "대시보드가 수정되었습니다."));
|
||||
body.put("dashboard_id", dashboardId);
|
||||
body.put("user_id", userId);
|
||||
dashboardService.updateDashboard(body);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "수정 완료"));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteDashboard(
|
||||
@PathVariable String id,
|
||||
@RequestAttribute(value = "user_id", required = false) String userId) {
|
||||
boolean deleted = dashboardService.deleteDashboard(id, userId);
|
||||
if (!deleted) return ResponseEntity.status(404).body(ApiResponse.error("대시보드를 찾을 수 없거나 삭제 권한이 없습니다."));
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "대시보드가 삭제되었습니다."));
|
||||
@DeleteMapping("/{dashboardId}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteDashboard(
|
||||
@PathVariable String dashboardId,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("dashboard_id", dashboardId);
|
||||
params.put("user_id", userId);
|
||||
dashboardService.deleteDashboard(params);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "삭제 완료"));
|
||||
}
|
||||
|
||||
@PostMapping("/execute-query")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> executeQuery(
|
||||
// ═══ 카드 CRUD ═══
|
||||
|
||||
@GetMapping("/{dashboardId}/cards")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getDashboardCards(
|
||||
@PathVariable String dashboardId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("dashboard_id", dashboardId);
|
||||
return ResponseEntity.ok(ApiResponse.success(dashboardService.getDashboardCardList(params)));
|
||||
}
|
||||
|
||||
@PostMapping("/{dashboardId}/cards")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertDashboardCard(
|
||||
@PathVariable String dashboardId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String query = (String) body.get("query");
|
||||
if (query == null || query.isBlank()) return ResponseEntity.status(400).body(ApiResponse.error("쿼리가 필요합니다."));
|
||||
try {
|
||||
Map<String, Object> result = dashboardService.executeQuery(query);
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "쿼리가 실행되었습니다."));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
body.put("dashboard_id", dashboardId);
|
||||
return ResponseEntity.ok(ApiResponse.success(dashboardService.insertDashboardCard(body)));
|
||||
}
|
||||
|
||||
@PostMapping("/execute-dml")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> executeDml(
|
||||
@PutMapping("/{dashboardId}/cards/{cardId}")
|
||||
public ResponseEntity<ApiResponse<Void>> updateDashboardCard(
|
||||
@PathVariable String dashboardId,
|
||||
@PathVariable String cardId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String query = (String) body.get("query");
|
||||
if (query == null || query.isBlank()) return ResponseEntity.status(400).body(ApiResponse.error("쿼리가 필요합니다."));
|
||||
try {
|
||||
Map<String, Object> result = dashboardService.executeDml(query);
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "쿼리가 실행되었습니다."));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
body.put("dashboard_id", dashboardId);
|
||||
body.put("card_id", cardId);
|
||||
dashboardService.updateDashboardCard(body);
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
@PostMapping("/fetch-external-api")
|
||||
public ResponseEntity<ApiResponse<Object>> fetchExternalApi(
|
||||
@DeleteMapping("/{dashboardId}/cards/{cardId}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteDashboardCard(
|
||||
@PathVariable String dashboardId,
|
||||
@PathVariable String cardId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("card_id", cardId);
|
||||
dashboardService.deleteDashboardCard(params);
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
@PutMapping("/{dashboardId}/cards/batch")
|
||||
public ResponseEntity<ApiResponse<Void>> updateCardPositions(
|
||||
@PathVariable String dashboardId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
dashboardService.updateCardPositions(body);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "일괄 업데이트 완료"));
|
||||
}
|
||||
|
||||
// ═══ 사이드바 메뉴 ═══
|
||||
|
||||
@GetMapping("/sidebar/menu")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getSidebarMenu(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String url = (String) body.get("url");
|
||||
if (url == null || url.isBlank()) return ResponseEntity.status(400).body(ApiResponse.error("URL이 필요합니다."));
|
||||
Object externalConnectionId = body.get("external_connection_id");
|
||||
|
||||
Map<String, Object> requestParams = new HashMap<>(body);
|
||||
requestParams.put("company_code", companyCode);
|
||||
if (externalConnectionId != null) {
|
||||
int connId = Integer.parseInt(String.valueOf(externalConnectionId));
|
||||
Map<String, Object> result = externalRestApiConnectionService.fetchData(connId, url, null, requestParams);
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
// 커넥션 없이 직접 호출 - 기본 응답
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("url", url);
|
||||
result.put("message", "externalConnectionId가 필요합니다.");
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@PostMapping("/table-schema")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getTableSchema(
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String tableName = (String) body.get("table_name");
|
||||
if (tableName == null || tableName.isBlank()) return ResponseEntity.status(400).body(ApiResponse.error("테이블명이 필요합니다."));
|
||||
try {
|
||||
Map<String, Object> result = dashboardService.getTableSchema(tableName);
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("user_id", userId);
|
||||
return ResponseEntity.ok(ApiResponse.success(dashboardService.getSidebarMenu(params)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.MetaService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/meta")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class MetaController {
|
||||
|
||||
private final MetaService metaService;
|
||||
|
||||
/**
|
||||
* GET /api/meta/tables — 접근 가능한 테이블 목록
|
||||
*/
|
||||
@GetMapping("/tables")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getMetaTableList(
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(metaService.getMetaTableList(params)));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/meta/tables/{tableName}/fields — 특정 테이블의 FieldConfig[] 반환
|
||||
*/
|
||||
@GetMapping("/tables/{tableName}/fields")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getMetaFields(
|
||||
@PathVariable String tableName,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(metaService.getMetaFields(params)));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/meta/tables/{tableName}/relations — 테이블 간 업무 관계 (Phase 5 제어 모드)
|
||||
*/
|
||||
@GetMapping("/tables/{tableName}/relations")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getMetaRelations(
|
||||
@PathVariable String tableName,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(metaService.getMetaRelations(params)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.TemplateService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/templates")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TemplateController {
|
||||
|
||||
private final TemplateService templateService;
|
||||
|
||||
/**
|
||||
* GET /api/templates — 템플릿 목록
|
||||
*/
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getTemplateList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.putIfAbsent("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(templateService.getTemplateList(params)));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/templates/{templateId} — 템플릿 상세
|
||||
*/
|
||||
@GetMapping("/{templateId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getTemplateInfo(
|
||||
@PathVariable String templateId,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("template_id", templateId);
|
||||
params.put("company_code", companyCode);
|
||||
Map<String, Object> result = templateService.getTemplateInfo(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.ok(ApiResponse.error("템플릿을 찾을 수 없습니다"));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/templates — 템플릿 생성
|
||||
*/
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertTemplate(
|
||||
@RequestBody Map<String, Object> params,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("user_id", userId);
|
||||
Map<String, Object> result = templateService.insertTemplate(params);
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/templates/{templateId} — 템플릿 수정
|
||||
*/
|
||||
@PutMapping("/{templateId}")
|
||||
public ResponseEntity<ApiResponse<Void>> updateTemplate(
|
||||
@PathVariable String templateId,
|
||||
@RequestBody Map<String, Object> params,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
params.put("template_id", templateId);
|
||||
params.put("user_id", userId);
|
||||
templateService.updateTemplate(params);
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/templates/{templateId}/publish — 템플릿 게시
|
||||
*/
|
||||
@PutMapping("/{templateId}/publish")
|
||||
public ResponseEntity<ApiResponse<Void>> publishTemplate(
|
||||
@PathVariable String templateId,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("template_id", templateId);
|
||||
params.put("user_id", userId);
|
||||
templateService.publishTemplate(params);
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/templates/{templateId} — 템플릿 삭제 (소프트)
|
||||
*/
|
||||
@DeleteMapping("/{templateId}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteTemplate(
|
||||
@PathVariable String templateId,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("template_id", templateId);
|
||||
params.put("user_id", userId);
|
||||
templateService.deleteTemplate(params);
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.UserOverrideService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/overrides")
|
||||
@RequiredArgsConstructor
|
||||
public class UserOverrideController {
|
||||
|
||||
private final UserOverrideService userOverrideService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getUserOverride(
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestParam String card_id) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("user_id", userId);
|
||||
params.put("card_id", card_id);
|
||||
Map<String, Object> result = userOverrideService.getUserOverride(params);
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
public ResponseEntity<ApiResponse<Void>> upsertUserOverride(
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("user_id", userId);
|
||||
userOverrideService.upsertUserOverride(body);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "저장 완료"));
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
public ResponseEntity<ApiResponse<Void>> deleteUserOverride(
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestParam String card_id) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("user_id", userId);
|
||||
params.put("card_id", card_id);
|
||||
userOverrideService.deleteUserOverride(params);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "삭제 완료"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class BusinessRuleService extends BaseService {
|
||||
|
||||
@Autowired
|
||||
private CommonService commonService;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
private static final String NS = "businessRule.";
|
||||
|
||||
public Map<String, Object> getBusinessRuleList(Map<String, Object> params) {
|
||||
commonService.applyPagination(params);
|
||||
int totalCount = sqlSession.selectOne(NS + "getBusinessRuleListCnt", params);
|
||||
List<Map<String, Object>> list = sqlSession.selectList(NS + "getBusinessRuleList", params);
|
||||
return commonService.buildListResponse(list, totalCount, params);
|
||||
}
|
||||
|
||||
public Map<String, Object> getBusinessRuleInfo(Map<String, Object> params) {
|
||||
Map<String, Object> row = sqlSession.selectOne(NS + "getBusinessRuleInfo", params);
|
||||
if (row != null) {
|
||||
parseJsonField(row, "nodes");
|
||||
parseJsonField(row, "connections");
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertBusinessRule(Map<String, Object> params) {
|
||||
String ruleId = "rule_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
|
||||
params.put("rule_id", ruleId);
|
||||
stringifyJsonField(params, "nodes");
|
||||
stringifyJsonField(params, "connections");
|
||||
sqlSession.insert(NS + "insertBusinessRule", params);
|
||||
return Map.of("rule_id", ruleId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateBusinessRule(Map<String, Object> params) {
|
||||
stringifyJsonField(params, "nodes");
|
||||
stringifyJsonField(params, "connections");
|
||||
sqlSession.update(NS + "updateBusinessRule", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int deleteBusinessRule(Map<String, Object> params) {
|
||||
return sqlSession.update(NS + "deleteBusinessRule", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void toggleBusinessRule(Map<String, Object> params) {
|
||||
sqlSession.update(NS + "toggleBusinessRule", params);
|
||||
}
|
||||
|
||||
// ── JSONB 유틸 ──
|
||||
|
||||
private void parseJsonField(Map<String, Object> row, String key) {
|
||||
Object val = row.get(key);
|
||||
if (val instanceof String) {
|
||||
try {
|
||||
row.put(key, objectMapper.readValue((String) val, Object.class));
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void stringifyJsonField(Map<String, Object> params, String key) {
|
||||
Object val = params.get(key);
|
||||
if (val != null && !(val instanceof String)) {
|
||||
try {
|
||||
params.put(key, objectMapper.writeValueAsString(val));
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to stringify field '{}': {}", key, e.getMessage());
|
||||
params.put(key, "[]");
|
||||
}
|
||||
}
|
||||
if (val == null) {
|
||||
params.put(key, "[]");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,347 +1,106 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class DashboardService extends BaseService {
|
||||
|
||||
@Autowired private JdbcTemplate jdbcTemplate;
|
||||
@Autowired private ObjectMapper objectMapper;
|
||||
@Autowired
|
||||
private CommonService commonService;
|
||||
|
||||
private static final String NS = "dashboard.";
|
||||
|
||||
// ═══ 대시보드 CRUD ═══
|
||||
|
||||
// ── 목록 조회 ────────────────────────────────────────────────────────────────
|
||||
public Map<String, Object> getDashboardList(Map<String, Object> params) {
|
||||
String companyCode = (String) params.getOrDefault("company_code", "*");
|
||||
String search = (String) params.get("search");
|
||||
String category = (String) params.get("category");
|
||||
int page = toInt(params.get("page"), 1);
|
||||
int limit = Math.min(toInt(params.get("limit"), 20), 100);
|
||||
int offset = (page - 1) * limit;
|
||||
|
||||
List<Object> args = new ArrayList<>();
|
||||
StringBuilder where = new StringBuilder("d.deleted_date IS NULL");
|
||||
|
||||
if (!"*".equals(companyCode)) {
|
||||
where.append(" AND d.company_code = ?");
|
||||
args.add(companyCode);
|
||||
}
|
||||
if (search != null && !search.isBlank()) {
|
||||
where.append(" AND (d.title ILIKE ? OR d.description ILIKE ?)");
|
||||
args.add("%" + search + "%");
|
||||
args.add("%" + search + "%");
|
||||
}
|
||||
if (category != null && !category.isBlank()) {
|
||||
where.append(" AND d.category = ?");
|
||||
args.add(category);
|
||||
commonService.applyPagination(params);
|
||||
int totalCount = sqlSession.selectOne(NS + "getDashboardListCnt", params);
|
||||
List<Map<String, Object>> list = sqlSession.selectList(NS + "getDashboardList", params);
|
||||
return commonService.buildListResponse(list, totalCount, params);
|
||||
}
|
||||
|
||||
String countSql = "SELECT COUNT(DISTINCT d.id) FROM dashboards d WHERE " + where;
|
||||
int total = jdbcTemplate.queryForObject(countSql, Integer.class, args.toArray());
|
||||
|
||||
String listSql = "SELECT d.id, d.title, d.description, d.thumbnail_url, d.is_public," +
|
||||
" d.created_by, d.created_date, d.updated_date, d.tags, d.category, d.view_count, d.company_code," +
|
||||
" u.user_name as created_by_name," +
|
||||
" COUNT(de.id) as elements_count" +
|
||||
" FROM dashboards d" +
|
||||
" LEFT JOIN dashboard_elements de ON d.id = de.dashboard_id" +
|
||||
" LEFT JOIN user_info u ON d.created_by = u.user_id" +
|
||||
" WHERE " + where +
|
||||
" GROUP BY d.id, d.title, d.description, d.thumbnail_url, d.is_public," +
|
||||
" d.created_by, d.created_date, d.updated_date, d.tags, d.category, d.view_count, d.company_code, u.user_name" +
|
||||
" ORDER BY d.updated_date DESC LIMIT ? OFFSET ?";
|
||||
List<Object> listArgs = new ArrayList<>(args);
|
||||
listArgs.add(limit);
|
||||
listArgs.add(offset);
|
||||
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(listSql, listArgs.toArray());
|
||||
List<Map<String, Object>> dashboards = new ArrayList<>();
|
||||
for (Map<String, Object> row : rows) {
|
||||
dashboards.add(formatDashboardRow(row, false));
|
||||
public Map<String, Object> getDashboardInfo(Map<String, Object> params) {
|
||||
return sqlSession.selectOne(NS + "getDashboardInfo", params);
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("dashboards", dashboards);
|
||||
Map<String, Object> pagination = new LinkedHashMap<>();
|
||||
pagination.put("page", page);
|
||||
pagination.put("limit", limit);
|
||||
pagination.put("total", total);
|
||||
pagination.put("total_pages", (int) Math.ceil((double) total / limit));
|
||||
result.put("pagination", pagination);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 단건 조회 (요소 포함) ─────────────────────────────────────────────────────
|
||||
public Map<String, Object> getDashboardById(String dashboardId, String companyCode) {
|
||||
List<Object> args = new ArrayList<>();
|
||||
args.add(dashboardId);
|
||||
String where = "id = ? AND deleted_date IS NULL";
|
||||
if (companyCode != null && !"*".equals(companyCode)) {
|
||||
where += " AND company_code = ?";
|
||||
args.add(companyCode);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT * FROM dashboards WHERE " + where, args.toArray());
|
||||
if (rows.isEmpty()) return null;
|
||||
|
||||
Map<String, Object> dashboard = formatDashboardRow(rows.get(0), true);
|
||||
|
||||
List<Map<String, Object>> elements = jdbcTemplate.queryForList(
|
||||
"SELECT * FROM dashboard_elements WHERE dashboard_id = ? ORDER BY display_order ASC",
|
||||
dashboardId);
|
||||
dashboard.put("elements", elements.stream().map(this::formatElement).collect(java.util.stream.Collectors.toList()));
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
// ── 생성 ─────────────────────────────────────────────────────────────────────
|
||||
@Transactional
|
||||
public Map<String, Object> createDashboard(Map<String, Object> params, String userId, String companyCode) {
|
||||
String dashboardId = UUID.randomUUID().toString();
|
||||
String title = String.valueOf(params.get("title"));
|
||||
String description = (String) params.get("description");
|
||||
boolean isPublic = Boolean.TRUE.equals(params.get("is_public")) || "true".equals(String.valueOf(params.get("is_public")));
|
||||
String tagsJson = toJson(params.getOrDefault("tags", new ArrayList<>()));
|
||||
String category = (String) params.get("category");
|
||||
String settingsJson = toJson(params.getOrDefault("settings", new HashMap<>()));
|
||||
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO dashboards (id, title, description, is_public, created_by, created_date, updated_date, tags, category, view_count, settings, company_code)" +
|
||||
" VALUES (?, ?, ?, ?, ?, NOW(), NOW(), ?::jsonb, ?, 0, ?::jsonb, ?)",
|
||||
dashboardId, title, description, isPublic, userId, tagsJson, category, settingsJson, companyCode != null ? companyCode : "DEFAULT");
|
||||
|
||||
insertElements(dashboardId, params.get("elements"));
|
||||
return getDashboardById(dashboardId, "*");
|
||||
public Map<String, Object> insertDashboard(Map<String, Object> params) {
|
||||
String dashboardId = "dash_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
|
||||
params.put("dashboard_id", dashboardId);
|
||||
if (params.get("icon") == null) {
|
||||
params.put("icon", "\uD83D\uDCCB");
|
||||
}
|
||||
if (params.get("display_order") == null) {
|
||||
params.put("display_order", 0);
|
||||
}
|
||||
sqlSession.insert(NS + "insertDashboard", params);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("dashboard_id", dashboardId);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 수정 ─────────────────────────────────────────────────────────────────────
|
||||
@Transactional
|
||||
public Map<String, Object> updateDashboard(String dashboardId, Map<String, Object> params, String userId) {
|
||||
List<String> sets = new ArrayList<>();
|
||||
List<Object> args = new ArrayList<>();
|
||||
|
||||
if (params.containsKey("title")) { sets.add("title = ?"); args.add(params.get("title")); }
|
||||
if (params.containsKey("description")) { sets.add("description = ?"); args.add(params.get("description")); }
|
||||
if (params.containsKey("is_public")) {
|
||||
sets.add("is_public = ?");
|
||||
args.add(Boolean.TRUE.equals(params.get("is_public")) || "true".equals(String.valueOf(params.get("is_public"))));
|
||||
}
|
||||
if (params.containsKey("tags")) { sets.add("tags = ?::jsonb"); args.add(toJson(params.get("tags"))); }
|
||||
if (params.containsKey("category")) { sets.add("category = ?"); args.add(params.get("category")); }
|
||||
if (params.containsKey("settings")) { sets.add("settings = ?::jsonb"); args.add(toJson(params.get("settings"))); }
|
||||
sets.add("updated_date = NOW()");
|
||||
|
||||
args.add(dashboardId);
|
||||
args.add(userId);
|
||||
|
||||
int updated = jdbcTemplate.update(
|
||||
"UPDATE dashboards SET " + String.join(", ", sets) +
|
||||
" WHERE id = ? AND created_by = ? AND deleted_date IS NULL",
|
||||
args.toArray());
|
||||
if (updated == 0) return null;
|
||||
|
||||
if (params.containsKey("elements")) {
|
||||
jdbcTemplate.update("DELETE FROM dashboard_elements WHERE dashboard_id = ?", dashboardId);
|
||||
insertElements(dashboardId, params.get("elements"));
|
||||
}
|
||||
return getDashboardById(dashboardId, "*");
|
||||
public void updateDashboard(Map<String, Object> params) {
|
||||
sqlSession.update(NS + "updateDashboard", params);
|
||||
}
|
||||
|
||||
// ── 삭제 (소프트) ────────────────────────────────────────────────────────────
|
||||
@Transactional
|
||||
public boolean deleteDashboard(String dashboardId, String userId) {
|
||||
int deleted = jdbcTemplate.update(
|
||||
"UPDATE dashboards SET deleted_date = NOW(), updated_date = NOW() WHERE id = ? AND created_by = ? AND deleted_date IS NULL",
|
||||
dashboardId, userId);
|
||||
return deleted > 0;
|
||||
public int deleteDashboard(Map<String, Object> params) {
|
||||
return sqlSession.update(NS + "deleteDashboard", params);
|
||||
}
|
||||
|
||||
// ── 조회수 증가 ───────────────────────────────────────────────────────────────
|
||||
public void incrementViewCount(String dashboardId) {
|
||||
jdbcTemplate.update("UPDATE dashboards SET view_count = view_count + 1 WHERE id = ? AND deleted_date IS NULL", dashboardId);
|
||||
// ═══ 카드 CRUD ═══
|
||||
|
||||
public List<Map<String, Object>> getDashboardCardList(Map<String, Object> params) {
|
||||
return sqlSession.selectList(NS + "getDashboardCardList", params);
|
||||
}
|
||||
|
||||
// ── execute-query (SELECT만) ──────────────────────────────────────────────────
|
||||
public Map<String, Object> executeQuery(String sql) {
|
||||
String trimmed = sql.trim();
|
||||
String lower = trimmed.toLowerCase();
|
||||
if (!lower.startsWith("select") && !lower.startsWith("with")) {
|
||||
throw new IllegalArgumentException("SELECT 또는 WITH 쿼리만 허용됩니다.");
|
||||
}
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(trimmed);
|
||||
List<String> columns = rows.isEmpty() ? new ArrayList<>() : new ArrayList<>(rows.get(0).keySet());
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("columns", columns);
|
||||
result.put("rows", rows);
|
||||
result.put("row_count", rows.size());
|
||||
@Transactional
|
||||
public Map<String, Object> insertDashboardCard(Map<String, Object> params) {
|
||||
String cardId = "card_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
|
||||
params.put("card_id", cardId);
|
||||
if (params.get("position_x") == null) params.put("position_x", 50);
|
||||
if (params.get("position_y") == null) params.put("position_y", 50);
|
||||
if (params.get("width") == null) params.put("width", 600);
|
||||
if (params.get("height") == null) params.put("height", 400);
|
||||
if (params.get("display_order") == null) params.put("display_order", 0);
|
||||
sqlSession.insert(NS + "insertDashboardCard", params);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("card_id", cardId);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── execute-dml (INSERT/UPDATE/DELETE) ────────────────────────────────────────
|
||||
public Map<String, Object> executeDml(String sql) {
|
||||
String trimmed = sql.trim();
|
||||
String lower = trimmed.toLowerCase();
|
||||
boolean isAllowed = lower.startsWith("insert") || lower.startsWith("update") || lower.startsWith("delete");
|
||||
if (!isAllowed) throw new IllegalArgumentException("INSERT, UPDATE, DELETE 쿼리만 허용됩니다.");
|
||||
String[] dangerous = {"drop table", "drop database", "truncate", "alter table", "create table"};
|
||||
for (String d : dangerous) {
|
||||
if (lower.contains(d)) throw new IllegalArgumentException("허용되지 않는 쿼리입니다.");
|
||||
}
|
||||
String command = trimmed.toUpperCase().split("\\s+")[0];
|
||||
int rowCount = jdbcTemplate.update(trimmed);
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("row_count", rowCount);
|
||||
result.put("command", command);
|
||||
return result;
|
||||
@Transactional
|
||||
public void updateDashboardCard(Map<String, Object> params) {
|
||||
sqlSession.update(NS + "updateDashboardCard", params);
|
||||
}
|
||||
|
||||
// ── table-schema ──────────────────────────────────────────────────────────────
|
||||
public Map<String, Object> getTableSchema(String tableName) {
|
||||
if (!tableName.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) {
|
||||
throw new IllegalArgumentException("유효하지 않은 테이블명입니다.");
|
||||
}
|
||||
List<Map<String, Object>> cols = jdbcTemplate.queryForList(
|
||||
"SELECT column_name, data_type, udt_name FROM information_schema.columns" +
|
||||
" WHERE table_name = ? ORDER BY ordinal_position",
|
||||
tableName.toLowerCase());
|
||||
|
||||
Set<String> dateTypes = new HashSet<>(Arrays.asList(
|
||||
"timestamp", "timestamp without time zone", "timestamp with time zone",
|
||||
"date", "time", "time without time zone", "time with time zone"));
|
||||
Set<String> dateUdt = new HashSet<>(Arrays.asList("timestamp", "timestamptz", "date", "time", "timetz"));
|
||||
|
||||
List<String> dateColumns = new ArrayList<>();
|
||||
List<Map<String, Object>> columns = new ArrayList<>();
|
||||
for (Map<String, Object> col : cols) {
|
||||
String dt = String.valueOf(col.get("data_type")).toLowerCase();
|
||||
String udt = String.valueOf(col.get("udt_name")).toLowerCase();
|
||||
if (dateTypes.contains(dt) || dateUdt.contains(udt)) {
|
||||
dateColumns.add(String.valueOf(col.get("column_name")));
|
||||
}
|
||||
Map<String, Object> c = new LinkedHashMap<>();
|
||||
c.put("name", col.get("column_name"));
|
||||
c.put("type", col.get("data_type"));
|
||||
c.put("udt_name", col.get("udt_name"));
|
||||
columns.add(c);
|
||||
@Transactional
|
||||
public void deleteDashboardCard(Map<String, Object> params) {
|
||||
sqlSession.update(NS + "deleteDashboardCard", params);
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("table_name", tableName);
|
||||
result.put("columns", columns);
|
||||
result.put("date_columns", dateColumns);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
@Transactional
|
||||
@SuppressWarnings("unchecked")
|
||||
private void insertElements(String dashboardId, Object elementsObj) {
|
||||
if (!(elementsObj instanceof List)) return;
|
||||
List<?> elements = (List<?>) elementsObj;
|
||||
for (int i = 0; i < elements.size(); i++) {
|
||||
if (!(elements.get(i) instanceof Map)) continue;
|
||||
Map<String, Object> el = (Map<String, Object>) elements.get(i);
|
||||
String elementId = UUID.randomUUID().toString();
|
||||
Map<String, Object> position = el.get("position") instanceof Map ? (Map<String, Object>) el.get("position") : new HashMap<>();
|
||||
Map<String, Object> size = el.get("size") instanceof Map ? (Map<String, Object>) el.get("size") : new HashMap<>();
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO dashboard_elements (id, dashboard_id, element_type, element_subtype," +
|
||||
" position_x, position_y, width, height, title, custom_title, show_header, content," +
|
||||
" data_source_config, chart_config, list_config, yard_config, custom_metric_config," +
|
||||
" display_order, created_date, updated_date)" +
|
||||
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?::jsonb,?::jsonb,?::jsonb,?::jsonb,?::jsonb,?,NOW(),NOW())",
|
||||
elementId, dashboardId,
|
||||
el.get("type"), el.get("subtype"),
|
||||
toInt(position.get("x"), 0), toInt(position.get("y"), 0),
|
||||
toInt(size.get("width"), 4), toInt(size.get("height"), 3),
|
||||
el.get("title"), el.get("custom_title"),
|
||||
!Boolean.FALSE.equals(el.get("show_header")),
|
||||
el.get("content"),
|
||||
toJson(el.getOrDefault("data_source", new HashMap<>())),
|
||||
toJson(el.getOrDefault("chart_config", new HashMap<>())),
|
||||
el.get("list_config") != null ? toJson(el.get("list_config")) : null,
|
||||
el.get("yard_config") != null ? toJson(el.get("yard_config")) : null,
|
||||
el.get("custom_metric_config") != null ? toJson(el.get("custom_metric_config")) : null,
|
||||
i);
|
||||
public void updateCardPositions(Map<String, Object> params) {
|
||||
List<Map<String, Object>> cards = (List<Map<String, Object>>) params.get("cards");
|
||||
if (cards != null) {
|
||||
for (Map<String, Object> card : cards) {
|
||||
sqlSession.update(NS + "updateCardPosition", card);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> formatDashboardRow(Map<String, Object> row, boolean includeSettings) {
|
||||
Map<String, Object> d = new LinkedHashMap<>();
|
||||
d.put("id", row.get("id"));
|
||||
d.put("title", row.get("title"));
|
||||
d.put("description", row.get("description"));
|
||||
d.put("thumbnail_url", row.get("thumbnail_url"));
|
||||
d.put("is_public", row.get("is_public"));
|
||||
d.put("created_by", row.get("created_by"));
|
||||
d.put("created_by_name", row.get("created_by_name") != null ? row.get("created_by_name") : row.get("created_by"));
|
||||
d.put("created_date", row.get("created_date"));
|
||||
d.put("updated_date", row.get("updated_date"));
|
||||
d.put("tags", parseJson(row.get("tags"), new ArrayList<>()));
|
||||
d.put("category", row.get("category"));
|
||||
d.put("view_count", toInt(row.get("view_count"), 0));
|
||||
d.put("company_code", row.get("company_code"));
|
||||
if (row.containsKey("elements_count")) d.put("elements_count", toInt(row.get("elements_count"), 0));
|
||||
if (includeSettings) d.put("settings", parseJson(row.get("settings"), null));
|
||||
return d;
|
||||
}
|
||||
// ═══ 사이드바 메뉴 ═══
|
||||
|
||||
private Map<String, Object> formatElement(Map<String, Object> row) {
|
||||
Map<String, Object> el = new LinkedHashMap<>();
|
||||
el.put("id", row.get("id"));
|
||||
el.put("type", row.get("element_type"));
|
||||
el.put("subtype", row.get("element_subtype"));
|
||||
Map<String, Object> pos = new LinkedHashMap<>();
|
||||
pos.put("x", row.get("position_x"));
|
||||
pos.put("y", row.get("position_y"));
|
||||
el.put("position", pos);
|
||||
Map<String, Object> size = new LinkedHashMap<>();
|
||||
size.put("width", row.get("width"));
|
||||
size.put("height", row.get("height"));
|
||||
el.put("size", size);
|
||||
el.put("title", row.get("title"));
|
||||
el.put("custom_title", row.get("custom_title"));
|
||||
el.put("show_header", !Boolean.FALSE.equals(row.get("show_header")));
|
||||
el.put("content", row.get("content"));
|
||||
el.put("data_source", parseJson(row.get("data_source_config"), new HashMap<>()));
|
||||
el.put("chart_config", parseJson(row.get("chart_config"), new HashMap<>()));
|
||||
if (row.get("list_config") != null) el.put("list_config", parseJson(row.get("list_config"), null));
|
||||
if (row.get("yard_config") != null) el.put("yard_config", parseJson(row.get("yard_config"), null));
|
||||
if (row.get("custom_metric_config") != null) el.put("custom_metric_config", parseJson(row.get("custom_metric_config"), null));
|
||||
return el;
|
||||
}
|
||||
|
||||
private String toJson(Object obj) {
|
||||
if (obj == null) return "{}";
|
||||
if (obj instanceof String) return (String) obj;
|
||||
try { return objectMapper.writeValueAsString(obj); }
|
||||
catch (Exception e) { return "{}"; }
|
||||
}
|
||||
|
||||
private Object parseJson(Object value, Object defaultValue) {
|
||||
if (value == null) return defaultValue;
|
||||
String jsonStr = null;
|
||||
if (value instanceof String) {
|
||||
jsonStr = (String) value;
|
||||
} else if (value instanceof org.postgresql.util.PGobject pgObj) {
|
||||
jsonStr = pgObj.getValue();
|
||||
}
|
||||
if (jsonStr != null) {
|
||||
try { return objectMapper.readValue(jsonStr, new TypeReference<Object>() {}); }
|
||||
catch (Exception e) { return defaultValue; }
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private int toInt(Object v, int def) {
|
||||
if (v == null) return def;
|
||||
if (v instanceof Number) return ((Number) v).intValue();
|
||||
try { return Integer.parseInt(v.toString()); } catch (Exception e) { return def; }
|
||||
public List<Map<String, Object>> getSidebarMenu(Map<String, Object> params) {
|
||||
return sqlSession.selectList(NS + "getSidebarMenu", params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class MetaService extends BaseService {
|
||||
|
||||
@Autowired
|
||||
private CommonService commonService;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
private static final String NS = "meta.";
|
||||
|
||||
private static final Set<String> SYSTEM_FIELDS = Set.of(
|
||||
"company_code", "created_by", "created_date", "updated_by", "updated_date",
|
||||
"is_active", "deleted_date", "deleted_by", "writer", "write_date"
|
||||
);
|
||||
|
||||
private static final Set<String> SEARCHABLE_TYPES = Set.of(
|
||||
"text", "select", "entity", "date", "code"
|
||||
);
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 테이블 목록
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
public List<Map<String, Object>> getMetaTableList(Map<String, Object> params) {
|
||||
return sqlSession.selectList(NS + "getMetaTableList", params);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// FieldConfig[] 반환
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
public Map<String, Object> getMetaFields(Map<String, Object> params) {
|
||||
String tableName = (String) params.get("table_name");
|
||||
|
||||
List<Map<String, Object>> schemaCols = sqlSession.selectList(NS + "getSchemaColumns", params);
|
||||
List<String> pks = sqlSession.selectList(NS + "getPrimaryKeys", params);
|
||||
List<Map<String, Object>> customMeta = sqlSession.selectList(NS + "getCustomMeta", params);
|
||||
|
||||
String tableLabel = sqlSession.selectOne(NS + "getTableLabel", params);
|
||||
if (tableLabel == null || tableLabel.isBlank()) {
|
||||
tableLabel = tableName;
|
||||
}
|
||||
|
||||
String primaryKey = pks.isEmpty() ? null : String.join(",", pks);
|
||||
List<Map<String, Object>> fields = buildFieldConfigs(schemaCols, pks, customMeta);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("table_name", tableName);
|
||||
result.put("table_label", tableLabel);
|
||||
result.put("primary_key", primaryKey);
|
||||
result.put("fields", fields);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// FieldConfig 빌드 (핵심)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
private List<Map<String, Object>> buildFieldConfigs(
|
||||
List<Map<String, Object>> schemaCols,
|
||||
List<String> pks,
|
||||
List<Map<String, Object>> customMeta
|
||||
) {
|
||||
Set<String> pkSet = new HashSet<>(pks);
|
||||
|
||||
Map<String, Map<String, Object>> metaMap = new LinkedHashMap<>();
|
||||
for (Map<String, Object> meta : customMeta) {
|
||||
String colName = str(meta, "column_name");
|
||||
if (colName != null) {
|
||||
metaMap.put(colName, meta);
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, Object>> fields = new ArrayList<>();
|
||||
|
||||
for (Map<String, Object> schemaCol : schemaCols) {
|
||||
String columnName = str(schemaCol, "column_name");
|
||||
String dataType = str(schemaCol, "data_type");
|
||||
String isNullable = str(schemaCol, "is_nullable");
|
||||
String columnDefault = str(schemaCol, "column_default");
|
||||
int ordinalPosition = num(schemaCol, "ordinal_position");
|
||||
|
||||
Map<String, Object> meta = metaMap.get(columnName);
|
||||
Map<String, Object> detailSettings = meta != null ? parseDetailSettings(meta) : null;
|
||||
|
||||
boolean isPk = pkSet.contains(columnName);
|
||||
boolean isSystem = SYSTEM_FIELDS.contains(columnName);
|
||||
|
||||
// ── type ──
|
||||
String fieldType;
|
||||
if (meta != null && str(meta, "input_type") != null && !str(meta, "input_type").isBlank()) {
|
||||
fieldType = mapInputTypeToFieldType(str(meta, "input_type"));
|
||||
} else {
|
||||
fieldType = mapDataTypeToFieldType(dataType);
|
||||
}
|
||||
|
||||
// ── label ──
|
||||
String label = columnName;
|
||||
if (meta != null && str(meta, "column_label") != null && !str(meta, "column_label").isBlank()) {
|
||||
label = str(meta, "column_label");
|
||||
}
|
||||
|
||||
// ── order ──
|
||||
int order = ordinalPosition;
|
||||
if (meta != null && meta.get("display_order") != null) {
|
||||
int displayOrder = num(meta, "display_order");
|
||||
if (displayOrder > 0) order = displayOrder;
|
||||
}
|
||||
|
||||
// ── visible ──
|
||||
boolean visible = !isSystem;
|
||||
if (!isSystem && meta != null && meta.get("is_visible") != null) {
|
||||
String isVisible = meta.get("is_visible").toString();
|
||||
visible = "Y".equalsIgnoreCase(isVisible) || "true".equalsIgnoreCase(isVisible);
|
||||
}
|
||||
|
||||
// ── required (★ 앱 레벨 메타 우선, DB 스키마 폴백) ──
|
||||
boolean required;
|
||||
if (!isSystem && meta != null && meta.get("is_nullable") != null) {
|
||||
// table_type_columns.IS_NULLABLE가 있으면 앱 레벨 메타가 진실
|
||||
String metaNullable = meta.get("is_nullable").toString();
|
||||
required = "NO".equalsIgnoreCase(metaNullable);
|
||||
} else {
|
||||
// 없으면 information_schema 폴백
|
||||
required = "NO".equalsIgnoreCase(isNullable) && columnDefault == null && !isSystem;
|
||||
}
|
||||
|
||||
// ── editable ──
|
||||
boolean editable = !isPk && !isSystem;
|
||||
if ("code".equals(fieldType)) editable = false;
|
||||
|
||||
// ── searchable ──
|
||||
boolean searchable = !isSystem && SEARCHABLE_TYPES.contains(fieldType);
|
||||
|
||||
// ── build field map ──
|
||||
Map<String, Object> field = new LinkedHashMap<>();
|
||||
field.put("column", columnName);
|
||||
field.put("label", label);
|
||||
field.put("type", fieldType);
|
||||
field.put("visible", visible);
|
||||
field.put("order", order);
|
||||
field.put("width", getDefaultWidth(fieldType));
|
||||
field.put("align", "number".equals(fieldType) ? "right" : "left");
|
||||
field.put("required", required);
|
||||
field.put("editable", editable);
|
||||
field.put("pk", isPk);
|
||||
field.put("system", isSystem);
|
||||
field.put("searchable", searchable);
|
||||
field.put("sortable", !isSystem);
|
||||
field.put("format", getDefaultFormat(fieldType));
|
||||
field.put("options", null);
|
||||
field.put("ref", null);
|
||||
field.put("computed", null);
|
||||
|
||||
// ── entity → ref ──
|
||||
if ("entity".equals(fieldType) && meta != null) {
|
||||
Map<String, Object> ref = buildFieldRef(meta, detailSettings);
|
||||
if (ref != null) field.put("ref", ref);
|
||||
}
|
||||
|
||||
// ── select → options ──
|
||||
if ("select".equals(fieldType) && detailSettings != null) {
|
||||
List<Object> options = extractOptions(detailSettings);
|
||||
if (options != null && !options.isEmpty()) field.put("options", options);
|
||||
}
|
||||
|
||||
// ── computed ──
|
||||
if (detailSettings != null && detailSettings.containsKey("computed")) {
|
||||
String computed = detailSettings.get("computed").toString();
|
||||
if (!computed.isBlank()) {
|
||||
field.put("computed", computed);
|
||||
field.put("editable", false);
|
||||
}
|
||||
}
|
||||
|
||||
fields.add(field);
|
||||
}
|
||||
|
||||
fields.sort(Comparator.comparingInt(f -> num(f, "order")));
|
||||
return fields;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 타입 매핑
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
private String mapDataTypeToFieldType(String dataType) {
|
||||
if (dataType == null) return "text";
|
||||
return switch (dataType.toLowerCase()) {
|
||||
case "character varying", "varchar" -> "text";
|
||||
case "text" -> "textarea";
|
||||
case "integer", "bigint", "smallint" -> "number";
|
||||
case "numeric", "decimal", "real", "double precision" -> "number";
|
||||
case "boolean" -> "checkbox";
|
||||
case "date" -> "date";
|
||||
case "timestamp without time zone", "timestamp with time zone" -> "datetime";
|
||||
case "jsonb", "json" -> "textarea";
|
||||
case "bytea" -> "file";
|
||||
default -> "text";
|
||||
};
|
||||
}
|
||||
|
||||
private String mapInputTypeToFieldType(String inputType) {
|
||||
if (inputType == null) return "text";
|
||||
return switch (inputType.toLowerCase()) {
|
||||
case "text" -> "text";
|
||||
case "number", "decimal" -> "number";
|
||||
case "date" -> "date";
|
||||
case "datetime" -> "datetime";
|
||||
case "select", "category" -> "select";
|
||||
case "entity" -> "entity";
|
||||
case "checkbox", "boolean" -> "checkbox";
|
||||
case "textarea", "text_area" -> "textarea";
|
||||
case "file" -> "file";
|
||||
case "code", "numbering" -> "code";
|
||||
case "email", "password", "tel" -> "text";
|
||||
default -> "text";
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// entity ref 빌드
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
private Map<String, Object> buildFieldRef(Map<String, Object> meta, Map<String, Object> detailSettings) {
|
||||
// 1차: top-level 컬럼
|
||||
String refTable = str(meta, "reference_table");
|
||||
String refColumn = str(meta, "reference_column");
|
||||
String displayCol = str(meta, "display_column");
|
||||
|
||||
// 2차: detail_settings JSON 폴백
|
||||
if ((refTable == null || refTable.isBlank()) && detailSettings != null) {
|
||||
refTable = strFromMap(detailSettings, "referenceTable");
|
||||
}
|
||||
if ((refColumn == null || refColumn.isBlank()) && detailSettings != null) {
|
||||
refColumn = strFromMap(detailSettings, "referenceColumn");
|
||||
}
|
||||
if ((displayCol == null || displayCol.isBlank()) && detailSettings != null) {
|
||||
displayCol = strFromMap(detailSettings, "displayColumn");
|
||||
}
|
||||
|
||||
if (refTable == null || refTable.isBlank()) return null;
|
||||
|
||||
Map<String, Object> ref = new LinkedHashMap<>();
|
||||
ref.put("table", refTable);
|
||||
ref.put("value_column", refColumn != null && !refColumn.isBlank() ? refColumn : "id");
|
||||
ref.put("display_column", displayCol != null && !displayCol.isBlank() ? displayCol : refColumn);
|
||||
|
||||
// search_columns
|
||||
if (detailSettings != null && detailSettings.containsKey("searchColumns")) {
|
||||
Object sc = detailSettings.get("searchColumns");
|
||||
if (sc instanceof List) {
|
||||
ref.put("search_columns", sc);
|
||||
}
|
||||
}
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// select options 추출
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* select options 추출.
|
||||
* ★ FieldOption 규격: string이면 value=label, 객체면 {value, label} 그대로 보존
|
||||
* - string[]: ["임시저장", "확정"] → 그대로 반환
|
||||
* - [{value,label}]: [{value:"DIRECT",label:"직접배송"}] → 그대로 반환
|
||||
* - 프론트에서 select 렌더러는 항상 value를 저장/전송, label을 표시
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Object> extractOptions(Map<String, Object> detailSettings) {
|
||||
if (detailSettings == null || !detailSettings.containsKey("options")) return null;
|
||||
Object optionsObj = detailSettings.get("options");
|
||||
if (!(optionsObj instanceof List)) return null;
|
||||
|
||||
List<?> optionsList = (List<?>) optionsObj;
|
||||
if (optionsList.isEmpty()) return null;
|
||||
|
||||
List<Object> result = new ArrayList<>();
|
||||
for (Object item : optionsList) {
|
||||
if (item instanceof String) {
|
||||
// 단순 문자열: value=label로 해석
|
||||
result.add(item);
|
||||
} else if (item instanceof Map) {
|
||||
// {value, label} 객체: 그대로 보존
|
||||
Map<String, Object> optMap = (Map<String, Object>) item;
|
||||
Map<String, Object> opt = new LinkedHashMap<>();
|
||||
Object value = optMap.get("value");
|
||||
Object label = optMap.get("label");
|
||||
opt.put("value", value != null ? value.toString() : "");
|
||||
opt.put("label", label != null ? label.toString() : (value != null ? value.toString() : ""));
|
||||
result.add(opt);
|
||||
}
|
||||
}
|
||||
return result.isEmpty() ? null : result;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// detail_settings 파싱
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
private Map<String, Object> parseDetailSettings(Map<String, Object> meta) {
|
||||
Object detailObj = meta.get("detail_settings");
|
||||
if (detailObj == null) return null;
|
||||
|
||||
String detailStr = detailObj.toString().trim();
|
||||
if (detailStr.isEmpty() || "{}".equals(detailStr)) return null;
|
||||
|
||||
try {
|
||||
return objectMapper.readValue(detailStr, new TypeReference<Map<String, Object>>() {});
|
||||
} catch (Exception e) {
|
||||
log.warn("detail_settings 파싱 실패: {}", detailStr);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 기본값
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
private int getDefaultWidth(String fieldType) {
|
||||
return switch (fieldType) {
|
||||
case "number" -> 100;
|
||||
case "date" -> 120;
|
||||
case "datetime" -> 160;
|
||||
case "select" -> 130;
|
||||
case "entity" -> 180;
|
||||
case "checkbox" -> 80;
|
||||
case "code" -> 120;
|
||||
case "textarea" -> 200;
|
||||
default -> 150;
|
||||
};
|
||||
}
|
||||
|
||||
private String getDefaultFormat(String fieldType) {
|
||||
return switch (fieldType) {
|
||||
case "number" -> "#,##0";
|
||||
case "date" -> "YYYY-MM-DD";
|
||||
case "datetime" -> "YYYY-MM-DD HH:mm";
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 유틸
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
private String str(Map<String, Object> map, String key) {
|
||||
Object val = map.get(key);
|
||||
return val != null ? val.toString() : null;
|
||||
}
|
||||
|
||||
private String strFromMap(Map<String, Object> map, String key) {
|
||||
Object val = map.get(key);
|
||||
return val != null ? val.toString() : null;
|
||||
}
|
||||
|
||||
private int num(Map<String, Object> map, String key) {
|
||||
Object val = map.get(key);
|
||||
if (val instanceof Number) return ((Number) val).intValue();
|
||||
if (val != null) {
|
||||
try { return Integer.parseInt(val.toString()); }
|
||||
catch (NumberFormatException e) { return 0; }
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 테이블 간 업무 관계 (Phase 5 — 제어 모드)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
public List<Map<String, Object>> getMetaRelations(Map<String, Object> params) {
|
||||
return sqlSession.selectList(NS + "getMetaRelations", params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class TemplateService extends BaseService {
|
||||
|
||||
@Autowired
|
||||
private CommonService commonService;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
private static final String NS = "template.";
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 목록
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
public Map<String, Object> getTemplateList(Map<String, Object> params) {
|
||||
commonService.applyPagination(params);
|
||||
int totalCount = sqlSession.selectOne(NS + "getTemplateListCnt", params);
|
||||
List<Map<String, Object>> list = sqlSession.selectList(NS + "getTemplateList", params);
|
||||
return commonService.buildListResponse(list, totalCount, params);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 단건
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
public Map<String, Object> getTemplateInfo(Map<String, Object> params) {
|
||||
Map<String, Object> row = sqlSession.selectOne(NS + "getTemplateInfo", params);
|
||||
if (row == null) return null;
|
||||
|
||||
// JSONB 문자열 → 객체 변환
|
||||
parseJsonField(row, "fields");
|
||||
parseJsonField(row, "views");
|
||||
parseJsonField(row, "connections");
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 등록
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertTemplate(Map<String, Object> params) {
|
||||
String templateId = "tpl_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
|
||||
params.put("template_id", templateId);
|
||||
|
||||
// 객체 → JSON 문자열 변환
|
||||
stringifyJsonField(params, "fields");
|
||||
stringifyJsonField(params, "views");
|
||||
stringifyJsonField(params, "connections");
|
||||
|
||||
sqlSession.insert(NS + "insertTemplate", params);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("template_id", templateId);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 수정
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
@Transactional
|
||||
public void updateTemplate(Map<String, Object> params) {
|
||||
stringifyJsonField(params, "fields");
|
||||
stringifyJsonField(params, "views");
|
||||
stringifyJsonField(params, "connections");
|
||||
|
||||
sqlSession.update(NS + "updateTemplate", params);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 게시
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
@Transactional
|
||||
public void publishTemplate(Map<String, Object> params) {
|
||||
sqlSession.update(NS + "publishTemplate", params);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 삭제 (소프트)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
@Transactional
|
||||
public int deleteTemplate(Map<String, Object> params) {
|
||||
return sqlSession.update(NS + "deleteTemplate", params);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// JSON 유틸
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
private void parseJsonField(Map<String, Object> row, String key) {
|
||||
Object val = row.get(key);
|
||||
if (val instanceof String) {
|
||||
try {
|
||||
row.put(key, objectMapper.readValue((String) val, Object.class));
|
||||
} catch (Exception e) {
|
||||
log.warn("JSON 파싱 실패: key={}, value={}", key, val, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void stringifyJsonField(Map<String, Object> params, String key) {
|
||||
Object val = params.get(key);
|
||||
if (val != null && !(val instanceof String)) {
|
||||
try {
|
||||
params.put(key, objectMapper.writeValueAsString(val));
|
||||
} catch (Exception e) {
|
||||
log.warn("JSON 직렬화 실패: key={}", key, e);
|
||||
params.put(key, "[]");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class UserOverrideService extends BaseService {
|
||||
|
||||
@Autowired
|
||||
private CommonService commonService;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
private static final String NS = "userOverride.";
|
||||
|
||||
public Map<String, Object> getUserOverride(Map<String, Object> params) {
|
||||
Map<String, Object> row = sqlSession.selectOne(NS + "getUserOverride", params);
|
||||
if (row != null) {
|
||||
parseJsonField(row, "overrides");
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void upsertUserOverride(Map<String, Object> params) {
|
||||
if (params.get("override_id") == null) {
|
||||
String overrideId = "ovr_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
|
||||
params.put("override_id", overrideId);
|
||||
}
|
||||
stringifyJsonField(params, "overrides");
|
||||
sqlSession.insert(NS + "upsertUserOverride", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteUserOverride(Map<String, Object> params) {
|
||||
sqlSession.delete(NS + "deleteUserOverride", params);
|
||||
}
|
||||
|
||||
private void parseJsonField(Map<String, Object> row, String key) {
|
||||
Object val = row.get(key);
|
||||
if (val instanceof String) {
|
||||
try {
|
||||
row.put(key, objectMapper.readValue((String) val, Object.class));
|
||||
} catch (Exception e) {
|
||||
log.warn("JSON parse failed: key={}", key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void stringifyJsonField(Map<String, Object> params, String key) {
|
||||
Object val = params.get(key);
|
||||
if (val != null && !(val instanceof String)) {
|
||||
try {
|
||||
params.put(key, objectMapper.writeValueAsString(val));
|
||||
} catch (Exception e) {
|
||||
log.warn("JSON stringify failed: key={}", key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,9 @@ spring:
|
||||
jackson:
|
||||
default-property-inclusion: always
|
||||
datasource:
|
||||
url: jdbc:postgresql://39.117.244.52:11132/testvex
|
||||
url: jdbc:postgresql://211.115.91.141:11134/test_dev
|
||||
username: postgres
|
||||
password: "ph0909!!"
|
||||
password: "vexplor0909!!"
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
maximum-pool-size: 10
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="businessRule">
|
||||
|
||||
<!-- ═══ 대시보드별 비즈니스 룰 목록 ═══ -->
|
||||
<select id="getBusinessRuleList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
RULE_ID
|
||||
, DASHBOARD_ID
|
||||
, NAME
|
||||
, DESCRIPTION
|
||||
, IS_ENABLED
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_BY
|
||||
, UPDATED_DATE
|
||||
FROM BUSINESS_RULES
|
||||
WHERE IS_ACTIVE = 'Y'
|
||||
<if test='dashboard_id != null and dashboard_id != ""'>
|
||||
AND DASHBOARD_ID = #{dashboard_id}
|
||||
</if>
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="common.dynamicOrderBy"/>
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<!-- ═══ 목록 카운트 ═══ -->
|
||||
<select id="getBusinessRuleListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM BUSINESS_RULES
|
||||
WHERE IS_ACTIVE = 'Y'
|
||||
<if test='dashboard_id != null and dashboard_id != ""'>
|
||||
AND DASHBOARD_ID = #{dashboard_id}
|
||||
</if>
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<!-- ═══ 룰 상세 (노드 + 연결 JSONB 포함) ═══ -->
|
||||
<select id="getBusinessRuleInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
RULE_ID
|
||||
, DASHBOARD_ID
|
||||
, NAME
|
||||
, DESCRIPTION
|
||||
, NODES
|
||||
, CONNECTIONS
|
||||
, IS_ENABLED
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_BY
|
||||
, UPDATED_DATE
|
||||
FROM BUSINESS_RULES
|
||||
WHERE RULE_ID = #{rule_id}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
</select>
|
||||
|
||||
<!-- ═══ 룰 등록 ═══ -->
|
||||
<insert id="insertBusinessRule" parameterType="map">
|
||||
INSERT INTO BUSINESS_RULES (
|
||||
RULE_ID
|
||||
, DASHBOARD_ID
|
||||
, NAME
|
||||
, DESCRIPTION
|
||||
, NODES
|
||||
, CONNECTIONS
|
||||
, IS_ENABLED
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
) VALUES (
|
||||
#{rule_id}
|
||||
, #{dashboard_id}
|
||||
, #{name}
|
||||
, #{description}
|
||||
, #{nodes}::jsonb
|
||||
, #{connections}::jsonb
|
||||
, COALESCE(#{is_enabled}, true)
|
||||
, #{company_code}
|
||||
, #{user_id}
|
||||
, CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<!-- ═══ 룰 수정 ═══ -->
|
||||
<update id="updateBusinessRule" parameterType="map">
|
||||
UPDATE BUSINESS_RULES
|
||||
SET
|
||||
NAME = #{name}
|
||||
, DESCRIPTION = #{description}
|
||||
, NODES = #{nodes}::jsonb
|
||||
, CONNECTIONS = #{connections}::jsonb
|
||||
, UPDATED_BY = #{user_id}
|
||||
, UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE RULE_ID = #{rule_id}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
</update>
|
||||
|
||||
<!-- ═══ 룰 삭제 (소프트) ═══ -->
|
||||
<update id="deleteBusinessRule" parameterType="map">
|
||||
UPDATE BUSINESS_RULES
|
||||
SET IS_ACTIVE = 'D'
|
||||
, UPDATED_BY = #{user_id}
|
||||
, UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE RULE_ID = #{rule_id}
|
||||
</update>
|
||||
|
||||
<!-- ═══ 활성/비활성 토글 ═══ -->
|
||||
<update id="toggleBusinessRule" parameterType="map">
|
||||
UPDATE BUSINESS_RULES
|
||||
SET IS_ENABLED = NOT IS_ENABLED
|
||||
, UPDATED_BY = #{user_id}
|
||||
, UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE RULE_ID = #{rule_id}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
@@ -1,63 +1,226 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="dashboard">
|
||||
|
||||
<sql id="dashboardSearchCondition">
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (NAME ILIKE '%' || #{keyword} || '%')
|
||||
</if>
|
||||
</sql>
|
||||
<!-- ═══ 대시보드 CRUD ═══ -->
|
||||
|
||||
<select id="getDashboardList" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM DASHBOARD
|
||||
WHERE 1=1
|
||||
SELECT DASHBOARD_ID
|
||||
, NAME
|
||||
, ICON
|
||||
, DISPLAY_ORDER
|
||||
, COMPANY_CODE
|
||||
, USER_ID
|
||||
, IS_ACTIVE
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_BY
|
||||
, UPDATED_DATE
|
||||
FROM DASHBOARDS
|
||||
WHERE IS_ACTIVE = 'Y'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="dashboardSearchCondition"/>
|
||||
<include refid="common.dynamicOrderBy"/>
|
||||
AND (USER_ID = #{user_id} OR USER_ID IS NULL)
|
||||
<if test='keyword != null and keyword != ""'>
|
||||
AND NAME LIKE CONCAT('%', #{keyword}, '%')
|
||||
</if>
|
||||
ORDER BY DISPLAY_ORDER ASC, CREATED_DATE ASC
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getDashboardListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM DASHBOARD
|
||||
WHERE 1=1
|
||||
FROM DASHBOARDS
|
||||
WHERE IS_ACTIVE = 'Y'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="dashboardSearchCondition"/>
|
||||
AND (USER_ID = #{user_id} OR USER_ID IS NULL)
|
||||
<if test='keyword != null and keyword != ""'>
|
||||
AND NAME LIKE CONCAT('%', #{keyword}, '%')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getDashboardInfo" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM DASHBOARD
|
||||
WHERE ID = #{id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
SELECT DASHBOARD_ID
|
||||
, NAME
|
||||
, ICON
|
||||
, DISPLAY_ORDER
|
||||
, COMPANY_CODE
|
||||
, USER_ID
|
||||
, IS_ACTIVE
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_BY
|
||||
, UPDATED_DATE
|
||||
FROM DASHBOARDS
|
||||
WHERE DASHBOARD_ID = #{dashboard_id}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
</select>
|
||||
|
||||
<insert id="insertDashboard" parameterType="map" useGeneratedKeys="true" keyProperty="id">
|
||||
INSERT INTO DASHBOARD (
|
||||
COMPANY_CODE
|
||||
<insert id="insertDashboard" parameterType="map">
|
||||
INSERT INTO DASHBOARDS (
|
||||
DASHBOARD_ID
|
||||
, NAME
|
||||
, ICON
|
||||
, DISPLAY_ORDER
|
||||
, COMPANY_CODE
|
||||
, USER_ID
|
||||
, IS_ACTIVE
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_BY
|
||||
, UPDATED_DATE
|
||||
) VALUES (
|
||||
#{company_code}
|
||||
, NOW()
|
||||
, NOW()
|
||||
#{dashboard_id}
|
||||
, #{name}
|
||||
, #{icon}
|
||||
, #{display_order}
|
||||
, #{company_code}
|
||||
, #{user_id}
|
||||
, 'Y'
|
||||
, #{user_id}
|
||||
, CURRENT_TIMESTAMP
|
||||
, #{user_id}
|
||||
, CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateDashboard" parameterType="map">
|
||||
UPDATE DASHBOARD
|
||||
SET
|
||||
UPDATED_DATE = NOW()
|
||||
WHERE ID = #{id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
UPDATE DASHBOARDS
|
||||
SET UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
, UPDATED_BY = #{user_id}
|
||||
<if test='name != null'>
|
||||
, NAME = #{name}
|
||||
</if>
|
||||
<if test='icon != null'>
|
||||
, ICON = #{icon}
|
||||
</if>
|
||||
<if test='display_order != null'>
|
||||
, DISPLAY_ORDER = #{display_order}
|
||||
</if>
|
||||
WHERE DASHBOARD_ID = #{dashboard_id}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
</update>
|
||||
|
||||
<delete id="deleteDashboard" parameterType="map">
|
||||
DELETE FROM DASHBOARD
|
||||
WHERE ID = #{id}
|
||||
<update id="deleteDashboard" parameterType="map">
|
||||
UPDATE DASHBOARDS
|
||||
SET IS_ACTIVE = 'D'
|
||||
, UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
, UPDATED_BY = #{user_id}
|
||||
WHERE DASHBOARD_ID = #{dashboard_id}
|
||||
</update>
|
||||
|
||||
<!-- ═══ 대시보드 카드 ═══ -->
|
||||
|
||||
<select id="getDashboardCardList" parameterType="map" resultType="map">
|
||||
SELECT DC.CARD_ID
|
||||
, DC.DASHBOARD_ID
|
||||
, DC.TEMPLATE_ID
|
||||
, DC.POSITION_X
|
||||
, DC.POSITION_Y
|
||||
, DC.WIDTH
|
||||
, DC.HEIGHT
|
||||
, DC.IS_COLLAPSED
|
||||
, DC.DISPLAY_ORDER
|
||||
, DC.IS_ACTIVE
|
||||
, DC.CREATED_DATE
|
||||
, DC.UPDATED_DATE
|
||||
, T.NAME AS TEMPLATE_NAME
|
||||
, T.CATEGORY AS TEMPLATE_CATEGORY
|
||||
, T.DESCRIPTION AS TEMPLATE_DESCRIPTION
|
||||
, T.PRIMARY_TABLE
|
||||
, T.STATUS AS TEMPLATE_STATUS
|
||||
FROM DASHBOARD_CARDS DC
|
||||
LEFT JOIN TEMPLATES T ON DC.TEMPLATE_ID = T.TEMPLATE_ID AND T.IS_ACTIVE = 'Y'
|
||||
WHERE DC.DASHBOARD_ID = #{dashboard_id}
|
||||
AND DC.IS_ACTIVE = 'Y'
|
||||
ORDER BY DC.DISPLAY_ORDER ASC, DC.CREATED_DATE ASC
|
||||
</select>
|
||||
|
||||
<insert id="insertDashboardCard" parameterType="map">
|
||||
INSERT INTO DASHBOARD_CARDS (
|
||||
CARD_ID
|
||||
, DASHBOARD_ID
|
||||
, TEMPLATE_ID
|
||||
, POSITION_X
|
||||
, POSITION_Y
|
||||
, WIDTH
|
||||
, HEIGHT
|
||||
, IS_COLLAPSED
|
||||
, DISPLAY_ORDER
|
||||
, IS_ACTIVE
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
) VALUES (
|
||||
#{card_id}
|
||||
, #{dashboard_id}
|
||||
, #{template_id}
|
||||
, #{position_x}
|
||||
, #{position_y}
|
||||
, #{width}
|
||||
, #{height}
|
||||
, FALSE
|
||||
, #{display_order}
|
||||
, 'Y'
|
||||
, CURRENT_TIMESTAMP
|
||||
, CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateDashboardCard" parameterType="map">
|
||||
UPDATE DASHBOARD_CARDS
|
||||
SET UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
<if test='position_x != null'>
|
||||
, POSITION_X = #{position_x}
|
||||
</if>
|
||||
<if test='position_y != null'>
|
||||
, POSITION_Y = #{position_y}
|
||||
</if>
|
||||
<if test='width != null'>
|
||||
, WIDTH = #{width}
|
||||
</if>
|
||||
<if test='height != null'>
|
||||
, HEIGHT = #{height}
|
||||
</if>
|
||||
<if test='is_collapsed != null'>
|
||||
, IS_COLLAPSED = #{is_collapsed}
|
||||
</if>
|
||||
<if test='display_order != null'>
|
||||
, DISPLAY_ORDER = #{display_order}
|
||||
</if>
|
||||
WHERE CARD_ID = #{card_id}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
</update>
|
||||
|
||||
<update id="updateCardPosition" parameterType="map">
|
||||
UPDATE DASHBOARD_CARDS
|
||||
SET POSITION_X = #{position_x}
|
||||
, POSITION_Y = #{position_y}
|
||||
, WIDTH = #{width}
|
||||
, HEIGHT = #{height}
|
||||
, IS_COLLAPSED = #{is_collapsed}
|
||||
, UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE CARD_ID = #{card_id}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
</update>
|
||||
|
||||
<update id="deleteDashboardCard" parameterType="map">
|
||||
UPDATE DASHBOARD_CARDS
|
||||
SET IS_ACTIVE = 'D'
|
||||
, UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE CARD_ID = #{card_id}
|
||||
</update>
|
||||
|
||||
<!-- ═══ 사이드바 메뉴 ═══ -->
|
||||
|
||||
<select id="getSidebarMenu" parameterType="map" resultType="map">
|
||||
SELECT DASHBOARD_ID
|
||||
, NAME
|
||||
, ICON
|
||||
, DISPLAY_ORDER
|
||||
FROM DASHBOARDS
|
||||
WHERE IS_ACTIVE = 'Y'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</delete>
|
||||
AND (USER_ID = #{user_id} OR USER_ID IS NULL)
|
||||
ORDER BY DISPLAY_ORDER ASC, CREATED_DATE ASC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="meta">
|
||||
|
||||
<!-- ═══ 테이블 목록 (public 스키마, 시스템 테이블 제외) ═══ -->
|
||||
<select id="getMetaTableList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
T.TABLE_NAME
|
||||
, COALESCE(TL.TABLE_LABEL, T.TABLE_NAME) AS TABLE_LABEL
|
||||
, (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS C
|
||||
WHERE C.TABLE_SCHEMA = 'public' AND C.TABLE_NAME = T.TABLE_NAME) AS COLUMN_COUNT
|
||||
, CASE WHEN EXISTS(
|
||||
SELECT 1 FROM TABLE_TYPE_COLUMNS TTC
|
||||
WHERE TTC.TABLE_NAME = T.TABLE_NAME
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (TTC.COMPANY_CODE = #{company_code} OR TTC.COMPANY_CODE = '*')
|
||||
</if>
|
||||
) THEN true ELSE false END AS HAS_CUSTOM_META
|
||||
FROM INFORMATION_SCHEMA.TABLES T
|
||||
LEFT JOIN TABLE_LABELS TL
|
||||
ON T.TABLE_NAME = TL.TABLE_NAME
|
||||
WHERE T.TABLE_SCHEMA = 'public'
|
||||
AND T.TABLE_TYPE = 'BASE TABLE'
|
||||
AND T.TABLE_NAME NOT LIKE 'pg_%'
|
||||
AND T.TABLE_NAME NOT IN ('spatial_ref_sys')
|
||||
ORDER BY T.TABLE_NAME
|
||||
</select>
|
||||
|
||||
<!-- ═══ 특정 테이블의 컬럼 정보 (information_schema) ═══ -->
|
||||
<select id="getSchemaColumns" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
COLUMN_NAME
|
||||
, DATA_TYPE
|
||||
, IS_NULLABLE
|
||||
, COLUMN_DEFAULT
|
||||
, CHARACTER_MAXIMUM_LENGTH
|
||||
, ORDINAL_POSITION
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = 'public'
|
||||
AND TABLE_NAME = #{table_name}
|
||||
ORDER BY ORDINAL_POSITION
|
||||
</select>
|
||||
|
||||
<!-- ═══ PK 컬럼 목록 ═══ -->
|
||||
<select id="getPrimaryKeys" parameterType="map" resultType="string">
|
||||
SELECT KCU.COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS TC
|
||||
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE KCU
|
||||
ON TC.CONSTRAINT_NAME = KCU.CONSTRAINT_NAME
|
||||
AND TC.TABLE_SCHEMA = KCU.TABLE_SCHEMA
|
||||
WHERE TC.CONSTRAINT_TYPE = 'PRIMARY KEY'
|
||||
AND TC.TABLE_SCHEMA = 'public'
|
||||
AND TC.TABLE_NAME = #{table_name}
|
||||
</select>
|
||||
|
||||
<!-- ═══ TABLE_TYPE_COLUMNS 커스텀 메타 (회사 우선, '*' 폴백) ═══ -->
|
||||
<select id="getCustomMeta" parameterType="map" resultType="map">
|
||||
SELECT DISTINCT ON (COLUMN_NAME)
|
||||
COLUMN_NAME
|
||||
, COLUMN_LABEL
|
||||
, INPUT_TYPE
|
||||
, COALESCE(DETAIL_SETTINGS::TEXT, '') AS DETAIL_SETTINGS
|
||||
, DISPLAY_ORDER
|
||||
, IS_NULLABLE
|
||||
, IS_VISIBLE
|
||||
, REFERENCE_TABLE
|
||||
, REFERENCE_COLUMN
|
||||
, DISPLAY_COLUMN
|
||||
, CODE_CATEGORY
|
||||
, CODE_VALUE
|
||||
, COMPANY_CODE
|
||||
FROM TABLE_TYPE_COLUMNS
|
||||
WHERE TABLE_NAME = #{table_name}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
ORDER BY COLUMN_NAME
|
||||
, CASE WHEN COMPANY_CODE = #{company_code} THEN 0 ELSE 1 END
|
||||
</select>
|
||||
|
||||
<!-- ═══ 테이블 라벨 단건 조회 ═══ -->
|
||||
<select id="getTableLabel" parameterType="map" resultType="string">
|
||||
SELECT TABLE_LABEL
|
||||
FROM TABLE_LABELS
|
||||
WHERE TABLE_NAME = #{table_name}
|
||||
</select>
|
||||
|
||||
<!-- ═══ 테이블 간 업무 관계 (table_relationships 기반) ═══ -->
|
||||
<select id="getMetaRelations" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
SOURCE_TABLE
|
||||
, TARGET_TABLE
|
||||
, RELATION_TYPE
|
||||
, COALESCE(LABEL, '') AS LABEL
|
||||
, COALESCE(DESCRIPTION, '') AS DESCRIPTION
|
||||
, SOURCE_COLUMN
|
||||
, TARGET_COLUMN
|
||||
FROM TABLE_RELATIONSHIPS
|
||||
WHERE (SOURCE_TABLE = #{table_name} OR TARGET_TABLE = #{table_name})
|
||||
AND IS_ACTIVE = 'Y'
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
ORDER BY SOURCE_TABLE, TARGET_TABLE
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,149 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="template">
|
||||
|
||||
<select id="getTemplateList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
TEMPLATE_ID
|
||||
, NAME
|
||||
, CATEGORY
|
||||
, DESCRIPTION
|
||||
, PRIMARY_TABLE
|
||||
, STATUS
|
||||
, VERSION
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_BY
|
||||
, UPDATED_DATE
|
||||
FROM TEMPLATES
|
||||
WHERE 1=1
|
||||
AND IS_ACTIVE != 'D'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<if test='keyword != null and keyword != ""'>
|
||||
AND (UPPER(NAME) LIKE '%' || UPPER(#{keyword}) || '%'
|
||||
OR UPPER(PRIMARY_TABLE) LIKE '%' || UPPER(#{keyword}) || '%')
|
||||
</if>
|
||||
<if test='status != null and status != ""'>
|
||||
AND STATUS = #{status}
|
||||
</if>
|
||||
<if test='category != null and category != ""'>
|
||||
AND CATEGORY = #{category}
|
||||
</if>
|
||||
<include refid="common.dynamicOrderBy"/>
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getTemplateListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM TEMPLATES
|
||||
WHERE 1=1
|
||||
AND IS_ACTIVE != 'D'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<if test='keyword != null and keyword != ""'>
|
||||
AND (UPPER(NAME) LIKE '%' || UPPER(#{keyword}) || '%'
|
||||
OR UPPER(PRIMARY_TABLE) LIKE '%' || UPPER(#{keyword}) || '%')
|
||||
</if>
|
||||
<if test='status != null and status != ""'>
|
||||
AND STATUS = #{status}
|
||||
</if>
|
||||
<if test='category != null and category != ""'>
|
||||
AND CATEGORY = #{category}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getTemplateInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
TEMPLATE_ID
|
||||
, NAME
|
||||
, CATEGORY
|
||||
, DESCRIPTION
|
||||
, PRIMARY_TABLE
|
||||
, FIELDS
|
||||
, VIEWS
|
||||
, CONNECTIONS
|
||||
, COMPANY_CODE
|
||||
, VERSION
|
||||
, STATUS
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_BY
|
||||
, UPDATED_DATE
|
||||
FROM TEMPLATES
|
||||
WHERE TEMPLATE_ID = #{template_id}
|
||||
AND IS_ACTIVE != 'D'
|
||||
</select>
|
||||
|
||||
<insert id="insertTemplate" parameterType="map">
|
||||
INSERT INTO TEMPLATES (
|
||||
TEMPLATE_ID
|
||||
, NAME
|
||||
, CATEGORY
|
||||
, DESCRIPTION
|
||||
, PRIMARY_TABLE
|
||||
, FIELDS
|
||||
, VIEWS
|
||||
, CONNECTIONS
|
||||
, COMPANY_CODE
|
||||
, VERSION
|
||||
, STATUS
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_BY
|
||||
, UPDATED_DATE
|
||||
) VALUES (
|
||||
#{template_id}
|
||||
, #{name}
|
||||
, #{category}
|
||||
, #{description}
|
||||
, #{primary_table}
|
||||
, #{fields}::jsonb
|
||||
, #{views}::jsonb
|
||||
, #{connections}::jsonb
|
||||
, #{company_code}
|
||||
, 1
|
||||
, 'draft'
|
||||
, #{user_id}
|
||||
, NOW()
|
||||
, #{user_id}
|
||||
, NOW()
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateTemplate" parameterType="map">
|
||||
UPDATE TEMPLATES
|
||||
SET
|
||||
NAME = #{name}
|
||||
, CATEGORY = #{category}
|
||||
, DESCRIPTION = #{description}
|
||||
, PRIMARY_TABLE = #{primary_table}
|
||||
, FIELDS = #{fields}::jsonb
|
||||
, VIEWS = #{views}::jsonb
|
||||
, CONNECTIONS = #{connections}::jsonb
|
||||
, VERSION = VERSION + 1
|
||||
, UPDATED_BY = #{user_id}
|
||||
, UPDATED_DATE = NOW()
|
||||
WHERE TEMPLATE_ID = #{template_id}
|
||||
AND IS_ACTIVE != 'D'
|
||||
</update>
|
||||
|
||||
<update id="publishTemplate" parameterType="map">
|
||||
UPDATE TEMPLATES
|
||||
SET
|
||||
STATUS = 'published'
|
||||
, UPDATED_BY = #{user_id}
|
||||
, UPDATED_DATE = NOW()
|
||||
WHERE TEMPLATE_ID = #{template_id}
|
||||
AND IS_ACTIVE != 'D'
|
||||
</update>
|
||||
|
||||
<update id="deleteTemplate" parameterType="map">
|
||||
UPDATE TEMPLATES
|
||||
SET
|
||||
IS_ACTIVE = 'D'
|
||||
, UPDATED_BY = #{user_id}
|
||||
, UPDATED_DATE = NOW()
|
||||
WHERE TEMPLATE_ID = #{template_id}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="userOverride">
|
||||
|
||||
<select id="getUserOverride" parameterType="map" resultType="map">
|
||||
SELECT OVERRIDE_ID
|
||||
, USER_ID
|
||||
, CARD_ID
|
||||
, OVERRIDES
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
FROM USER_OVERRIDES
|
||||
WHERE USER_ID = #{user_id}
|
||||
AND CARD_ID = #{card_id}
|
||||
</select>
|
||||
|
||||
<insert id="upsertUserOverride" parameterType="map">
|
||||
INSERT INTO USER_OVERRIDES (
|
||||
OVERRIDE_ID
|
||||
, USER_ID
|
||||
, CARD_ID
|
||||
, OVERRIDES
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
) VALUES (
|
||||
#{override_id}
|
||||
, #{user_id}
|
||||
, #{card_id}
|
||||
, #{overrides}::jsonb
|
||||
, CURRENT_TIMESTAMP
|
||||
, CURRENT_TIMESTAMP
|
||||
)
|
||||
ON CONFLICT (USER_ID, CARD_ID)
|
||||
DO UPDATE SET
|
||||
OVERRIDES = #{overrides}::jsonb
|
||||
, UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
</insert>
|
||||
|
||||
<delete id="deleteUserOverride" parameterType="map">
|
||||
DELETE FROM USER_OVERRIDES
|
||||
WHERE USER_ID = #{user_id}
|
||||
AND CARD_ID = #{card_id}
|
||||
</delete>
|
||||
|
||||
</mapper>
|
||||
@@ -1,3 +1,5 @@
|
||||
name: invyone
|
||||
|
||||
# invyone (= 옛 TEST-VEX, React + Java 풀스택) 도커 컴포즈
|
||||
# 사무실 우분투 호스팅용. restart unless-stopped 로 항상 떠있게.
|
||||
#
|
||||
@@ -19,6 +21,7 @@ services:
|
||||
# Spring Boot 백엔드
|
||||
# ========================
|
||||
backend-spring:
|
||||
image: invyone-backend-spring
|
||||
build:
|
||||
context: ../../backend-spring
|
||||
dockerfile: ../docker/dev/backend-spring.Dockerfile
|
||||
@@ -55,6 +58,7 @@ services:
|
||||
# Next.js 프론트엔드
|
||||
# ========================
|
||||
frontend:
|
||||
image: invyone-frontend
|
||||
build:
|
||||
context: ../../frontend
|
||||
dockerfile: ../docker/dev/frontend.Dockerfile
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import BuilderLayout from "@/components/builder/BuilderLayout";
|
||||
|
||||
export default function BuilderPage() {
|
||||
return <BuilderLayout />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { DashboardLayout } from '@/components/dash/DashboardLayout';
|
||||
|
||||
export default function DashPage() {
|
||||
return <DashboardLayout />;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { getMetaTableList, getMetaFields } from '@/lib/api/meta';
|
||||
import { fcList, fcInsert, fcUpdate } from '@/lib/api/fcData';
|
||||
import { FcSearch, FcTable, FcForm, FcPagination } from '@/components/fc';
|
||||
import type { FieldConfig } from '@/types/invyone-component';
|
||||
|
||||
export default function TestFcPage() {
|
||||
// 테이블 목록
|
||||
const [tables, setTables] = useState<Record<string, any>[]>([]);
|
||||
const [selectedTable, setSelectedTable] = useState('');
|
||||
|
||||
// FieldConfig
|
||||
const [fields, setFields] = useState<FieldConfig[]>([]);
|
||||
const [tableLabel, setTableLabel] = useState('');
|
||||
const [primaryKey, setPrimaryKey] = useState<string | null>(null);
|
||||
|
||||
// 데이터
|
||||
const [data, setData] = useState<Record<string, any>[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 검색 + 행 선택
|
||||
const [searchParams, setSearchParams] = useState<Record<string, any>>({});
|
||||
const [selectedRow, setSelectedRow] = useState<Record<string, any> | null>(null);
|
||||
const [selectedRowIndex, setSelectedRowIndex] = useState<number>(-1);
|
||||
|
||||
// 알림
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
// 1. 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
getMetaTableList()
|
||||
.then(setTables)
|
||||
.catch((err) => console.error('테이블 목록 로드 실패:', err));
|
||||
}, []);
|
||||
|
||||
// 2. 테이블 선택 시 FieldConfig 로드
|
||||
useEffect(() => {
|
||||
if (!selectedTable) {
|
||||
setFields([]);
|
||||
setData([]);
|
||||
setSelectedRow(null);
|
||||
return;
|
||||
}
|
||||
|
||||
getMetaFields(selectedTable)
|
||||
.then((meta) => {
|
||||
setFields(meta.fields);
|
||||
setTableLabel(meta.table_label);
|
||||
setPrimaryKey(meta.primary_key);
|
||||
setSearchParams({});
|
||||
setSelectedRow(null);
|
||||
setSelectedRowIndex(-1);
|
||||
setPage(1);
|
||||
})
|
||||
.catch((err) => console.error('FieldConfig 로드 실패:', err));
|
||||
}, [selectedTable]);
|
||||
|
||||
// 3. 데이터 조회 (테이블 선택, 검색, 페이지 변경 시)
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!selectedTable || fields.length === 0) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fcList({
|
||||
tableName: selectedTable,
|
||||
page,
|
||||
size: pageSize,
|
||||
...searchParams,
|
||||
});
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
} catch (err) {
|
||||
console.error('데이터 조회 실패:', err);
|
||||
setData([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedTable, fields.length, page, pageSize, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// 4. 검색 연동: FcSearch → FcTable
|
||||
const handleSearch = useCallback((params: Record<string, any>) => {
|
||||
setSearchParams(params);
|
||||
setPage(1);
|
||||
setSelectedRow(null);
|
||||
setSelectedRowIndex(-1);
|
||||
}, []);
|
||||
|
||||
// 5. 행 선택 연동: FcTable → FcForm
|
||||
const handleRowSelect = useCallback((row: Record<string, any>) => {
|
||||
setSelectedRow(row);
|
||||
const idx = data.findIndex((d) => d === row);
|
||||
setSelectedRowIndex(idx);
|
||||
}, [data]);
|
||||
|
||||
// 6. 폼 제출: FcForm → DB 저장 → FcTable 새로고침
|
||||
const handleFormSubmit = useCallback(async (formData: Record<string, any>) => {
|
||||
try {
|
||||
if (selectedRow && primaryKey && selectedRow[primaryKey]) {
|
||||
// 수정
|
||||
await fcUpdate(selectedTable, selectedRow[primaryKey], formData);
|
||||
showMessage('success', '수정되었습니다');
|
||||
} else {
|
||||
// 등록
|
||||
await fcInsert(selectedTable, formData);
|
||||
showMessage('success', '등록되었습니다');
|
||||
}
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
showMessage('error', err?.message ?? '저장 실패');
|
||||
}
|
||||
}, [selectedTable, selectedRow, primaryKey, fetchData]);
|
||||
|
||||
// 페이지 변경
|
||||
const handlePageChange = useCallback((params: { page: number; size: number }) => {
|
||||
setPage(params.page);
|
||||
setPageSize(params.size);
|
||||
}, []);
|
||||
|
||||
function showMessage(type: 'success' | 'error', text: string) {
|
||||
setMessage({ type, text });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-3 max-w-[1400px]">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-sm font-bold text-[var(--v5-text)]">
|
||||
FieldConfig 컴포넌트 테스트
|
||||
</h1>
|
||||
{message && (
|
||||
<div className={`px-3 py-1 rounded text-xs font-medium
|
||||
${message.type === 'success'
|
||||
? 'bg-[var(--v5-green)]/20 text-[var(--v5-green)]'
|
||||
: 'bg-[var(--v5-red)]/20 text-[var(--v5-red)]'}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 드롭다운 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-medium text-[var(--v5-text-sec)]">테이블:</label>
|
||||
<select
|
||||
value={selectedTable}
|
||||
onChange={(e) => setSelectedTable(e.target.value)}
|
||||
className="h-7 rounded-md border border-[var(--v5-border)] bg-[var(--v5-surface)]
|
||||
px-2 text-xs text-[var(--v5-text)] outline-none min-w-[250px]"
|
||||
>
|
||||
<option value="">테이블 선택...</option>
|
||||
{tables.map((t) => (
|
||||
<option key={t.table_name} value={t.table_name}>
|
||||
{t.table_label !== t.table_name ? `${t.table_label} (${t.table_name})` : t.table_name}
|
||||
{t.has_custom_meta ? ' ★' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedTable && (
|
||||
<span className="text-xs text-[var(--v5-text-muted)]">
|
||||
{tableLabel} — {fields.length}개 필드, PK: {primaryKey ?? 'N/A'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠: 선택된 테이블이 있을 때만 */}
|
||||
{selectedTable && fields.length > 0 && (
|
||||
<div className="grid grid-cols-[1fr_350px] gap-3">
|
||||
{/* 왼쪽: 검색 + 테이블 + 페이지네이션 */}
|
||||
<div className="space-y-2">
|
||||
<FcSearch
|
||||
fields={fields}
|
||||
onSearch={handleSearch}
|
||||
config={{ layout: 'inline', autoSearch: false }}
|
||||
/>
|
||||
|
||||
<FcTable
|
||||
fields={fields}
|
||||
data={data}
|
||||
loading={loading}
|
||||
onRowSelect={handleRowSelect}
|
||||
selectedRowIndex={selectedRowIndex}
|
||||
config={{ pageSize, selectionMode: 'single', style: 'compact' }}
|
||||
/>
|
||||
|
||||
<FcPagination
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 폼 */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-[var(--v5-text-sec)]">
|
||||
{selectedRow ? '수정' : '신규 등록'}
|
||||
</div>
|
||||
<FcForm
|
||||
fields={fields}
|
||||
loadRow={selectedRow ?? undefined}
|
||||
onSubmit={handleFormSubmit}
|
||||
config={{ columns: 1, saveAction: { method: selectedRow ? 'UPDATE' : 'INSERT', refreshAfterSave: true } }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 선택 안 됐을 때 */}
|
||||
{!selectedTable && (
|
||||
<div className="flex items-center justify-center h-60 rounded-md border border-dashed border-[var(--v5-border)]
|
||||
text-[var(--v5-text-muted)] text-xs">
|
||||
테이블을 선택하면 FcSearch + FcTable + FcForm이 렌더됩니다
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { useBuilderState } from "./hooks/useBuilderState";
|
||||
import { useBlockDrag } from "./hooks/useBlockDrag";
|
||||
import type { Component, TableConfig, FormConfig, SearchConfig, TitleConfig, ButtonConfig, ButtonBarConfig } from "@/types/invyone-component";
|
||||
import type { FieldConfig } from "@/types/invyone-component";
|
||||
|
||||
interface BuilderBlockProps {
|
||||
block: Component;
|
||||
}
|
||||
|
||||
/** 캔버스 위의 개별 블록 — 드래그 이동 + 리사이즈 + 프리뷰 */
|
||||
export default function BuilderBlock({ block }: BuilderBlockProps) {
|
||||
const selectedBlockId = useBuilderState((s) => s.selectedBlockId);
|
||||
const fields = useBuilderState((s) => s.fields);
|
||||
const selectBlock = useBuilderState((s) => s.selectBlock);
|
||||
const { startDrag, startResize } = useBlockDrag();
|
||||
|
||||
const isSelected = selectedBlockId === block.id;
|
||||
const { x, y, w, h } = block.position;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`dev-block${isSelected ? " selected" : ""}`}
|
||||
style={{ left: x, top: y, width: w, height: h }}
|
||||
onMouseDown={(e) => {
|
||||
if ((e.target as HTMLElement).classList.contains("dev-resize-handle")) return;
|
||||
startDrag(e, block.id, x, y, w, h);
|
||||
}}
|
||||
onClick={(e) => { e.stopPropagation(); selectBlock(block.id); }}
|
||||
>
|
||||
<div className="dev-block-label">{block.label}</div>
|
||||
<div className="dev-block-content">
|
||||
<BlockPreview block={block} fields={fields} />
|
||||
</div>
|
||||
<div
|
||||
className="dev-resize-handle"
|
||||
onMouseDown={(e) => startResize(e, block.id, x, y, w, h)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 블록 내부 프리뷰 렌더 (타입별 분기) */
|
||||
function BlockPreview({ block, fields }: { block: Component; fields: FieldConfig[] }) {
|
||||
const visibleFields = useMemo(
|
||||
() => fields.filter((f) => f.visible && !f.system).sort((a, b) => a.order - b.order),
|
||||
[fields]
|
||||
);
|
||||
|
||||
switch (block.type) {
|
||||
case "table":
|
||||
return <TablePreview fields={visibleFields} />;
|
||||
case "form":
|
||||
return <FormPreview fields={visibleFields} config={block.config as FormConfig} />;
|
||||
case "search":
|
||||
return <SearchPreview fields={fields.filter((f) => f.searchable && !f.system)} />;
|
||||
case "title":
|
||||
return <TitlePreview config={block.config as TitleConfig} />;
|
||||
case "button":
|
||||
return <ButtonPreview config={block.config as ButtonConfig} />;
|
||||
case "button-bar":
|
||||
return <ButtonBarPreview config={block.config as ButtonBarConfig} />;
|
||||
case "pagination":
|
||||
return <PaginationPreview />;
|
||||
case "divider":
|
||||
return <div style={{ borderTop: "1px solid var(--d-border)", margin: "0.3rem 0" }} />;
|
||||
case "stats":
|
||||
return <div style={{ fontSize: "0.44rem", color: "var(--d-text3)" }}>통계 카드 프리뷰</div>;
|
||||
default:
|
||||
return <div style={{ fontSize: "0.44rem", color: "var(--d-text3)" }}>{block.type}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
function TablePreview({ fields }: { fields: FieldConfig[] }) {
|
||||
const cols = fields.slice(0, 8);
|
||||
if (!cols.length) return <div style={{ fontSize: "0.44rem", color: "var(--d-text3)" }}>테이블을 선택하세요</div>;
|
||||
return (
|
||||
<table className="dev-pv-table">
|
||||
<thead>
|
||||
<tr>{cols.map((f) => <th key={f.column}>{f.label}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[0, 1, 2].map((r) => (
|
||||
<tr key={r}>{cols.map((f) => <td key={f.column}>—</td>)}</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function FormPreview({ fields, config }: { fields: FieldConfig[]; config: FormConfig }) {
|
||||
const cols = config?.columns || 2;
|
||||
const formFields = fields.filter((f) => !f.pk || f.type !== "code");
|
||||
return (
|
||||
<div style={{ display: "grid", gridTemplateColumns: `repeat(${cols}, 1fr)`, gap: "0.2rem 0.4rem" }}>
|
||||
{formFields.slice(0, 10).map((f) => (
|
||||
<div className="dev-pv-field" key={f.column}>
|
||||
<div className="dev-pv-field-label">
|
||||
{f.label}{f.required && <span style={{ color: "var(--d-red)" }}> *</span>}
|
||||
</div>
|
||||
<div className="dev-pv-field-input">
|
||||
{f.type === "select"
|
||||
? (typeof f.options?.[0] === "string" ? f.options[0] : typeof f.options?.[0] === "object" ? f.options[0].label : "—")
|
||||
: "—"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchPreview({ fields }: { fields: FieldConfig[] }) {
|
||||
if (!fields.length) return <div style={{ fontSize: "0.44rem", color: "var(--d-text3)" }}>검색 조건 없음</div>;
|
||||
return (
|
||||
<div className="dev-pv-search">
|
||||
{fields.slice(0, 5).map((f) => (
|
||||
<div className="dev-pv-search-item" key={f.column}>
|
||||
<div className="dev-pv-search-label">{f.label}</div>
|
||||
<div className="dev-pv-search-input">—</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="dev-pv-search-item" style={{ justifyContent: "flex-end" }}>
|
||||
<button className="dev-pv-btn primary" style={{ marginTop: "auto" }}>검색</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TitlePreview({ config }: { config: TitleConfig }) {
|
||||
return (
|
||||
<div style={{ fontSize: config.fontSize, fontWeight: config.fontWeight, textAlign: config.align, color: "var(--d-text)" }}>
|
||||
{config.text || "제목"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonPreview({ config }: { config: ButtonConfig }) {
|
||||
const cls = config.variant === "primary" ? "dev-pv-btn primary" : "dev-pv-btn";
|
||||
return <div className={cls}>{config.text || "버튼"}</div>;
|
||||
}
|
||||
|
||||
function ButtonBarPreview({ config }: { config: ButtonBarConfig }) {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: "0.2rem", padding: "0.2rem" }}>
|
||||
{(config.buttons || []).map((btn, i) => (
|
||||
<div key={i} className={btn.variant === "primary" ? "dev-pv-btn primary" : "dev-pv-btn"}>
|
||||
{btn.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationPreview() {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", fontSize: "0.42rem", color: "var(--d-text3)", padding: "0.1rem" }}>
|
||||
<span>총 0건</span>
|
||||
<div style={{ display: "flex", gap: "0.15rem" }}>
|
||||
<span style={{ padding: "0.1rem 0.25rem", borderRadius: 3, background: "var(--d-accent)", color: "#fff" }}>1</span>
|
||||
<span style={{ padding: "0.1rem 0.25rem" }}>2</span>
|
||||
<span style={{ padding: "0.1rem 0.25rem" }}>3</span>
|
||||
</div>
|
||||
<span>20건/페이지</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { useBuilderState, useCurrentViewBlocks } from "./hooks/useBuilderState";
|
||||
import BuilderBlock from "./BuilderBlock";
|
||||
import type { ComponentType } from "@/types/invyone-component";
|
||||
|
||||
export default function BuilderCanvas() {
|
||||
const blocks = useCurrentViewBlocks();
|
||||
const addBlock = useBuilderState((s) => s.addBlock);
|
||||
const selectBlock = useBuilderState((s) => s.selectBlock);
|
||||
const currentView = useBuilderState((s) => s.currentView);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const type = e.dataTransfer.getData("component-type") as ComponentType;
|
||||
if (!type) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = Math.round(e.clientX - rect.left);
|
||||
const y = Math.round(e.clientY - rect.top);
|
||||
addBlock(type, { x, y, w: 0, h: 0 });
|
||||
},
|
||||
[addBlock]
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}, []);
|
||||
|
||||
const handleCanvasClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest(".dev-block")) return;
|
||||
selectBlock(null);
|
||||
},
|
||||
[selectBlock]
|
||||
);
|
||||
|
||||
// 팝업 뷰 (등록/수정)
|
||||
if (currentView !== "list") {
|
||||
return (
|
||||
<div className="dev-canvas" onClick={handleCanvasClick}>
|
||||
<div className="dev-popup-overlay">
|
||||
<div className="dev-popup-frame">
|
||||
<div
|
||||
className="dev-canvas-inner"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
{blocks.length === 0 && (
|
||||
<div className="dev-empty">
|
||||
<div className="dev-empty-icon">📝</div>
|
||||
<div className="dev-empty-text">
|
||||
{currentView === "create" ? "등록" : "수정"} 팝업에 컴포넌트를 배치하세요
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{blocks.map((block) => (
|
||||
<BuilderBlock key={block.id} block={block} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="dev-canvas"
|
||||
onClick={handleCanvasClick}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
<div className="dev-canvas-inner">
|
||||
{blocks.length === 0 && (
|
||||
<div className="dev-empty">
|
||||
<div className="dev-empty-icon">🎨</div>
|
||||
<div className="dev-empty-text">
|
||||
팔레트에서 컴포넌트를 드래그하거나 클릭하여 추가하세요
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{blocks.map((block) => (
|
||||
<BuilderBlock key={block.id} block={block} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useCallback } from "react";
|
||||
import { useBuilderState } from "./hooks/useBuilderState";
|
||||
import BuilderToolbar from "./BuilderToolbar";
|
||||
import BuilderPalette from "./BuilderPalette";
|
||||
import BuilderCanvas from "./BuilderCanvas";
|
||||
import BuilderProps from "./BuilderProps";
|
||||
|
||||
import "@/styles/developer.css";
|
||||
|
||||
export default function BuilderLayout() {
|
||||
const blocks = useBuilderState((s) => s.blocks);
|
||||
const currentView = useBuilderState((s) => s.currentView);
|
||||
const tableName = useBuilderState((s) => s.tableName);
|
||||
const connections = useBuilderState((s) => s.connections);
|
||||
const isDirty = useBuilderState((s) => s.isDirty);
|
||||
const selectedBlockId = useBuilderState((s) => s.selectedBlockId);
|
||||
const removeBlock = useBuilderState((s) => s.removeBlock);
|
||||
const selectBlock = useBuilderState((s) => s.selectBlock);
|
||||
|
||||
const viewBlocks = blocks[currentView];
|
||||
const blockCount = viewBlocks.length;
|
||||
|
||||
// 키보드 단축키: Delete/Backspace → 블록 삭제, 화살표 → 블록 이동
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (!selectedBlockId) return;
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return;
|
||||
|
||||
if (e.key === "Delete" || e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
removeBlock(selectedBlockId);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
selectBlock(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [selectedBlockId, removeBlock, selectBlock]);
|
||||
|
||||
return (
|
||||
<div className="dev-shell">
|
||||
<BuilderToolbar />
|
||||
|
||||
<div className="dev-body">
|
||||
<BuilderPalette />
|
||||
<BuilderCanvas />
|
||||
<BuilderProps />
|
||||
</div>
|
||||
|
||||
{/* 상태바 */}
|
||||
<div className="dev-status">
|
||||
<span>블록 {blockCount}개 · {tableName || "테이블 미선택"} · 연결 {connections.length}개</span>
|
||||
<span>{isDirty ? "수정됨" : "저장됨"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { useBuilderState } from "./hooks/useBuilderState";
|
||||
import type { ComponentType } from "@/types/invyone-component";
|
||||
|
||||
interface PaletteItem {
|
||||
type: ComponentType;
|
||||
label: string;
|
||||
icon: string;
|
||||
cat: "data" | "input" | "action" | "display";
|
||||
}
|
||||
|
||||
const PALETTE_ITEMS: { section: string; items: PaletteItem[] }[] = [
|
||||
{
|
||||
section: "데이터",
|
||||
items: [
|
||||
{ type: "table", label: "데이터 테이블", icon: "📊", cat: "data" },
|
||||
{ type: "search", label: "검색 필터", icon: "🔍", cat: "data" },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "입력",
|
||||
items: [
|
||||
{ type: "form", label: "입력 폼", icon: "📝", cat: "input" },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "액션",
|
||||
items: [
|
||||
{ type: "button", label: "버튼", icon: "🔘", cat: "action" },
|
||||
{ type: "button-bar", label: "버튼 바", icon: "⬜", cat: "action" },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "표시",
|
||||
items: [
|
||||
{ type: "title", label: "제목/텍스트", icon: "📌", cat: "display" },
|
||||
{ type: "stats", label: "통계 카드", icon: "📈", cat: "display" },
|
||||
{ type: "divider", label: "구분선", icon: "——", cat: "display" },
|
||||
{ type: "pagination", label: "페이지네이션", icon: "📄", cat: "display" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function BuilderPalette() {
|
||||
const addBlock = useBuilderState((s) => s.addBlock);
|
||||
const tableName = useBuilderState((s) => s.tableName);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent, type: ComponentType) => {
|
||||
e.dataTransfer.setData("component-type", type);
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(type: ComponentType) => {
|
||||
// 클릭으로도 추가 가능 (캔버스 중앙에 배치)
|
||||
addBlock(type, { x: 16, y: 16, w: 0, h: 0 });
|
||||
},
|
||||
[addBlock]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="dev-palette">
|
||||
<div className="dev-pal-header">컴포넌트</div>
|
||||
{PALETTE_ITEMS.map((sec) => (
|
||||
<React.Fragment key={sec.section}>
|
||||
<div className="dev-pal-sec">{sec.section}</div>
|
||||
{sec.items.map((item) => (
|
||||
<div
|
||||
key={item.type}
|
||||
className="dev-pal-item"
|
||||
data-cat={item.cat}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, item.type)}
|
||||
onClick={() => handleClick(item.type)}
|
||||
style={{ opacity: !tableName && ["table", "form", "search"].includes(item.type) ? 0.4 : 1 }}
|
||||
>
|
||||
<span className="dev-pal-icon">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useBuilderState, useSelectedBlock } from "./hooks/useBuilderState";
|
||||
import TableProps from "./props/TableProps";
|
||||
import FormProps from "./props/FormProps";
|
||||
import SearchProps from "./props/SearchProps";
|
||||
import { SingleButtonProps, ButtonBarProps } from "./props/ButtonProps";
|
||||
import TitleProps from "./props/TitleProps";
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
table: "📊 데이터 테이블",
|
||||
form: "📝 입력 폼",
|
||||
search: "🔍 검색 필터",
|
||||
button: "🔘 버튼",
|
||||
"button-bar": "⬜ 버튼 바",
|
||||
title: "📌 제목/텍스트",
|
||||
stats: "📈 통계 카드",
|
||||
divider: "── 구분선",
|
||||
pagination: "📄 페이지네이션",
|
||||
};
|
||||
|
||||
/** 우측 속성 패널 */
|
||||
export default function BuilderProps() {
|
||||
const block = useSelectedBlock();
|
||||
const updateBlock = useBuilderState((s) => s.updateBlock);
|
||||
const removeBlock = useBuilderState((s) => s.removeBlock);
|
||||
const moveBlock = useBuilderState((s) => s.moveBlock);
|
||||
const resizeBlock = useBuilderState((s) => s.resizeBlock);
|
||||
|
||||
if (!block) {
|
||||
return (
|
||||
<div className="dev-props">
|
||||
<div className="dev-prop-header">속성</div>
|
||||
<div style={{ padding: "1rem 0.6rem", textAlign: "center", color: "var(--d-text3)", fontSize: "0.5rem" }}>
|
||||
캔버스에서 컴포넌트를<br />선택하세요
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dev-props">
|
||||
<div className="dev-prop-header">{TYPE_LABELS[block.type] || block.type}</div>
|
||||
|
||||
{/* 공통: 이름 */}
|
||||
<div className="dev-prop-sec">컴포넌트 정보</div>
|
||||
<div className="dev-prop-row">
|
||||
<span className="dev-prop-label">이름</span>
|
||||
<input
|
||||
className="dev-input"
|
||||
value={block.label}
|
||||
onChange={(e) => updateBlock(block.id, { label: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공통: 위치/크기 */}
|
||||
<div className="dev-prop-sec">위치 · 크기</div>
|
||||
<div className="dev-pos-grid">
|
||||
<div className="dev-pos-item">
|
||||
<label>X</label>
|
||||
<input type="number" value={Math.round(block.position.x)}
|
||||
onChange={(e) => moveBlock(block.id, Number(e.target.value), block.position.y)} />
|
||||
</div>
|
||||
<div className="dev-pos-item">
|
||||
<label>Y</label>
|
||||
<input type="number" value={Math.round(block.position.y)}
|
||||
onChange={(e) => moveBlock(block.id, block.position.x, Number(e.target.value))} />
|
||||
</div>
|
||||
<div className="dev-pos-item">
|
||||
<label>W</label>
|
||||
<input type="number" value={Math.round(block.position.w)}
|
||||
onChange={(e) => resizeBlock(block.id, Number(e.target.value), block.position.h)} />
|
||||
</div>
|
||||
<div className="dev-pos-item">
|
||||
<label>H</label>
|
||||
<input type="number" value={Math.round(block.position.h)}
|
||||
onChange={(e) => resizeBlock(block.id, block.position.w, Number(e.target.value))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 타입별 속성 패널 */}
|
||||
{block.type === "table" && <TableProps block={block} />}
|
||||
{block.type === "form" && <FormProps block={block} />}
|
||||
{block.type === "search" && <SearchProps block={block} />}
|
||||
{block.type === "button" && <SingleButtonProps block={block} />}
|
||||
{block.type === "button-bar" && <ButtonBarProps block={block} />}
|
||||
{block.type === "title" && <TitleProps block={block} />}
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<div style={{ padding: "0.6rem" }}>
|
||||
<button className="dev-delete-btn" onClick={() => removeBlock(block.id)}>
|
||||
🗑 블록 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useBuilderState } from "./hooks/useBuilderState";
|
||||
import type { BuilderView } from "./hooks/useBuilderState";
|
||||
import { getMetaTableList, getMetaFields } from "@/lib/api/meta";
|
||||
import { insertTemplate, updateTemplate } from "@/lib/api/template";
|
||||
|
||||
const VIEW_TABS: { key: BuilderView; label: string }[] = [
|
||||
{ key: "list", label: "목록" },
|
||||
{ key: "create", label: "등록" },
|
||||
{ key: "edit", label: "수정" },
|
||||
];
|
||||
|
||||
export default function BuilderToolbar() {
|
||||
const [tables, setTables] = useState<Record<string, any>[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const tableName = useBuilderState((s) => s.tableName);
|
||||
const templateName = useBuilderState((s) => s.templateName);
|
||||
const currentView = useBuilderState((s) => s.currentView);
|
||||
const templateId = useBuilderState((s) => s.templateId);
|
||||
const isDirty = useBuilderState((s) => s.isDirty);
|
||||
const setTable = useBuilderState((s) => s.setTable);
|
||||
const switchView = useBuilderState((s) => s.switchView);
|
||||
const setTemplateMeta = useBuilderState((s) => s.setTemplateMeta);
|
||||
const toTemplate = useBuilderState((s) => s.toTemplate);
|
||||
const markClean = useBuilderState((s) => s.markClean);
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
getMetaTableList()
|
||||
.then(setTables)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 테이블 선택
|
||||
const handleTableChange = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const name = e.target.value;
|
||||
if (!name) return;
|
||||
try {
|
||||
const meta = await getMetaFields(name);
|
||||
setTable(name, meta.fields);
|
||||
if (!templateName) {
|
||||
setTemplateMeta({ templateName: meta.table_label || name });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[setTable, setTemplateMeta, templateName]
|
||||
);
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const tpl = toTemplate();
|
||||
const payload: Record<string, any> = {
|
||||
name: tpl.name,
|
||||
category: tpl.category,
|
||||
description: tpl.description,
|
||||
primary_table: tpl.primaryTable,
|
||||
fields: tpl.fields,
|
||||
views: tpl.views,
|
||||
connections: tpl.connections,
|
||||
};
|
||||
if (templateId) {
|
||||
await updateTemplate(templateId, payload);
|
||||
} else {
|
||||
const result = await insertTemplate(payload);
|
||||
useBuilderState.setState({ templateId: result.template_id });
|
||||
}
|
||||
markClean();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [toTemplate, templateId, markClean]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 헤더 */}
|
||||
<div className="dev-hdr">
|
||||
<div className="dev-hdr-l">
|
||||
<span className="dev-logo">INVYONE</span>
|
||||
<span className="dev-badge">DEV</span>
|
||||
<input
|
||||
className="dev-input"
|
||||
style={{ minWidth: 160, fontWeight: 600, fontSize: "0.62rem" }}
|
||||
value={templateName}
|
||||
onChange={(e) => setTemplateMeta({ templateName: e.target.value })}
|
||||
placeholder="템플릿 이름"
|
||||
/>
|
||||
</div>
|
||||
<div className="dev-hdr-r">
|
||||
<button
|
||||
className={`dev-btn primary`}
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "저장 중..." : "💾 저장"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 도구모음 */}
|
||||
<div className="dev-toolbar">
|
||||
<div className="dev-tb-group">
|
||||
<span className="dev-tb-label">테이블</span>
|
||||
<select
|
||||
className="dev-select"
|
||||
value={tableName || ""}
|
||||
onChange={handleTableChange}
|
||||
>
|
||||
<option value="">테이블 선택...</option>
|
||||
{tables.map((t) => (
|
||||
<option key={t.table_name} value={t.table_name}>
|
||||
{t.table_label || t.table_name}
|
||||
{t.has_custom_meta ? " ★" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="dev-tb-group">
|
||||
<span className="dev-tb-label">뷰</span>
|
||||
{VIEW_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`dev-view-tab${currentView === tab.key ? " active" : ""}`}
|
||||
onClick={() => switchView(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{isDirty && (
|
||||
<span style={{ fontSize: "0.42rem", color: "var(--d-orange)", fontWeight: 600 }}>
|
||||
● 수정됨
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useBuilderState } from "./useBuilderState";
|
||||
|
||||
interface DragState {
|
||||
id: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
origX: number;
|
||||
origY: number;
|
||||
origW: number;
|
||||
origH: number;
|
||||
mode: "move" | "resize";
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 드래그(이동)/리사이즈 훅.
|
||||
* mousedown → mousemove → mouseup 패턴.
|
||||
* Shift 키: 8px 스냅.
|
||||
*/
|
||||
export function useBlockDrag() {
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const moveBlock = useBuilderState((s) => s.moveBlock);
|
||||
const resizeBlock = useBuilderState((s) => s.resizeBlock);
|
||||
const selectBlock = useBuilderState((s) => s.selectBlock);
|
||||
|
||||
const startDrag = useCallback(
|
||||
(e: React.MouseEvent, id: string, origX: number, origY: number, origW: number, origH: number) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectBlock(id);
|
||||
dragRef.current = { id, startX: e.clientX, startY: e.clientY, origX, origY, origW, origH, mode: "move" };
|
||||
document.body.style.cursor = "grabbing";
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.mode !== "move") return;
|
||||
let nx = d.origX + (ev.clientX - d.startX);
|
||||
let ny = d.origY + (ev.clientY - d.startY);
|
||||
if (ev.shiftKey) { nx = Math.round(nx / 8) * 8; ny = Math.round(ny / 8) * 8; }
|
||||
moveBlock(d.id, Math.round(nx), Math.round(ny));
|
||||
};
|
||||
const onUp = () => {
|
||||
dragRef.current = null;
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[moveBlock, selectBlock]
|
||||
);
|
||||
|
||||
const startResize = useCallback(
|
||||
(e: React.MouseEvent, id: string, origX: number, origY: number, origW: number, origH: number) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragRef.current = { id, startX: e.clientX, startY: e.clientY, origX, origY, origW, origH, mode: "resize" };
|
||||
document.body.style.cursor = "nwse-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.mode !== "resize") return;
|
||||
let nw = d.origW + (ev.clientX - d.startX);
|
||||
let nh = d.origH + (ev.clientY - d.startY);
|
||||
if (ev.shiftKey) { nw = Math.round(nw / 8) * 8; nh = Math.round(nh / 8) * 8; }
|
||||
resizeBlock(d.id, Math.round(nw), Math.round(nh));
|
||||
};
|
||||
const onUp = () => {
|
||||
dragRef.current = null;
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[resizeBlock]
|
||||
);
|
||||
|
||||
return { startDrag, startResize };
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { devtools } from "zustand/middleware";
|
||||
import type {
|
||||
FieldConfig,
|
||||
Component,
|
||||
ComponentType,
|
||||
Position,
|
||||
ViewConfig,
|
||||
Template,
|
||||
Connection,
|
||||
TableConfig,
|
||||
FormConfig,
|
||||
SearchConfig,
|
||||
ButtonConfig,
|
||||
ButtonBarConfig,
|
||||
TitleConfig,
|
||||
StatsConfig,
|
||||
DividerConfig,
|
||||
PaginationConfig,
|
||||
ComponentTypeConfig,
|
||||
} from "@/types/invyone-component";
|
||||
|
||||
// ─── 뷰 타입 ───
|
||||
export type BuilderView = "list" | "create" | "edit";
|
||||
|
||||
// ─── 상태 인터페이스 ───
|
||||
interface BuilderState {
|
||||
// 테이블/필드
|
||||
tableName: string | null;
|
||||
fields: FieldConfig[];
|
||||
|
||||
// 현재 뷰
|
||||
currentView: BuilderView;
|
||||
|
||||
// 블록 목록 (뷰별)
|
||||
blocks: Record<BuilderView, Component[]>;
|
||||
|
||||
// 선택된 블록
|
||||
selectedBlockId: string | null;
|
||||
|
||||
// 연결
|
||||
connections: Connection[];
|
||||
|
||||
// 템플릿 메타
|
||||
templateId: string | null;
|
||||
templateName: string;
|
||||
category: string;
|
||||
description: string;
|
||||
|
||||
// 변경 상태
|
||||
isDirty: boolean;
|
||||
|
||||
// 액션
|
||||
setTable: (tableName: string, fields: FieldConfig[]) => void;
|
||||
switchView: (view: BuilderView) => void;
|
||||
addBlock: (type: ComponentType, position: Position) => void;
|
||||
removeBlock: (id: string) => void;
|
||||
updateBlock: (id: string, updates: Partial<Component>) => void;
|
||||
selectBlock: (id: string | null) => void;
|
||||
moveBlock: (id: string, x: number, y: number) => void;
|
||||
resizeBlock: (id: string, w: number, h: number) => void;
|
||||
updateBlockConfig: (id: string, config: Partial<ComponentTypeConfig>) => void;
|
||||
updateField: (column: string, updates: Partial<FieldConfig>) => void;
|
||||
setTemplateMeta: (meta: { templateName?: string; category?: string; description?: string }) => void;
|
||||
addConnection: (conn: Connection) => void;
|
||||
removeConnection: (connId: string) => void;
|
||||
toTemplate: () => Template;
|
||||
fromTemplate: (tpl: Record<string, any>) => void;
|
||||
resetBuilder: () => void;
|
||||
markClean: () => void;
|
||||
}
|
||||
|
||||
// ─── ID 생성 ───
|
||||
let _blockIdCounter = 0;
|
||||
function genBlockId(): string {
|
||||
return `blk_${Date.now().toString(36)}_${(++_blockIdCounter).toString(36)}`;
|
||||
}
|
||||
function genConnId(): string {
|
||||
return `conn_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
// ─── 컴포넌트 기본 설정 ───
|
||||
function defaultConfig(type: ComponentType): ComponentTypeConfig {
|
||||
switch (type) {
|
||||
case "table":
|
||||
return {
|
||||
pageSize: 20,
|
||||
selectionMode: "single",
|
||||
showCheckbox: false,
|
||||
inlineEdit: false,
|
||||
autoLoad: true,
|
||||
toolbar: { showExcel: false, showRefresh: true, showFilter: false },
|
||||
style: "default",
|
||||
} satisfies TableConfig;
|
||||
case "form":
|
||||
return {
|
||||
columns: 2,
|
||||
saveAction: { method: "UPSERT", refreshAfterSave: true },
|
||||
} satisfies FormConfig;
|
||||
case "search":
|
||||
return {
|
||||
dateRangeEnabled: true,
|
||||
showResetButton: true,
|
||||
autoSearch: false,
|
||||
layout: "inline",
|
||||
} satisfies SearchConfig;
|
||||
case "button":
|
||||
return {
|
||||
text: "버튼",
|
||||
actionType: "save",
|
||||
variant: "default",
|
||||
} satisfies ButtonConfig;
|
||||
case "button-bar":
|
||||
return {
|
||||
buttons: [
|
||||
{ text: "등록", actionType: "add", variant: "primary" },
|
||||
{ text: "삭제", actionType: "delete", variant: "destructive" },
|
||||
],
|
||||
} satisfies ButtonBarConfig;
|
||||
case "title":
|
||||
return {
|
||||
text: "제목",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: "700",
|
||||
align: "left",
|
||||
} satisfies TitleConfig;
|
||||
case "stats":
|
||||
return { items: [] } satisfies StatsConfig;
|
||||
case "divider":
|
||||
return { style: "solid" } satisfies DividerConfig;
|
||||
case "pagination":
|
||||
return {
|
||||
pageSize: 20,
|
||||
showSizeSelector: true,
|
||||
sizeOptions: [10, 20, 50, 100],
|
||||
} satisfies PaginationConfig;
|
||||
default:
|
||||
return { text: "", fontSize: "0.75rem", fontWeight: "400", align: "left" } satisfies TitleConfig;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 컴포넌트 기본 크기 ───
|
||||
function defaultSize(type: ComponentType): { w: number; h: number } {
|
||||
switch (type) {
|
||||
case "table": return { w: 854, h: 380 };
|
||||
case "form": return { w: 440, h: 300 };
|
||||
case "search": return { w: 854, h: 42 };
|
||||
case "button": return { w: 100, h: 36 };
|
||||
case "button-bar": return { w: 370, h: 36 };
|
||||
case "title": return { w: 300, h: 36 };
|
||||
case "stats": return { w: 400, h: 80 };
|
||||
case "divider": return { w: 854, h: 8 };
|
||||
case "pagination": return { w: 854, h: 24 };
|
||||
default: return { w: 200, h: 100 };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 컴포넌트 기본 라벨 ───
|
||||
function defaultLabel(type: ComponentType): string {
|
||||
const map: Record<string, string> = {
|
||||
table: "데이터 테이블",
|
||||
form: "입력 폼",
|
||||
search: "검색 필터",
|
||||
button: "버튼",
|
||||
"button-bar": "버튼 바",
|
||||
title: "제목",
|
||||
stats: "통계 카드",
|
||||
divider: "구분선",
|
||||
pagination: "페이지네이션",
|
||||
chart: "차트",
|
||||
tabs: "탭",
|
||||
"split-panel": "분할 패널",
|
||||
};
|
||||
return map[type] || type;
|
||||
}
|
||||
|
||||
// ─── 초기 상태 ───
|
||||
const initialState = {
|
||||
tableName: null as string | null,
|
||||
fields: [] as FieldConfig[],
|
||||
currentView: "list" as BuilderView,
|
||||
blocks: { list: [], create: [], edit: [] } as Record<BuilderView, Component[]>,
|
||||
selectedBlockId: null as string | null,
|
||||
connections: [] as Connection[],
|
||||
templateId: null as string | null,
|
||||
templateName: "",
|
||||
category: "",
|
||||
description: "",
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
// ─── 스토어 ───
|
||||
export const useBuilderState = create<BuilderState>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
setTable: (tableName, fields) =>
|
||||
set({ tableName, fields, isDirty: true }),
|
||||
|
||||
switchView: (view) =>
|
||||
set({ currentView: view, selectedBlockId: null }),
|
||||
|
||||
addBlock: (type, position) => {
|
||||
const state = get();
|
||||
const size = defaultSize(type);
|
||||
const block: Component = {
|
||||
id: genBlockId(),
|
||||
type,
|
||||
label: defaultLabel(type),
|
||||
position: { x: position.x, y: position.y, w: size.w, h: size.h },
|
||||
config: defaultConfig(type),
|
||||
};
|
||||
const viewBlocks = [...state.blocks[state.currentView], block];
|
||||
set({
|
||||
blocks: { ...state.blocks, [state.currentView]: viewBlocks },
|
||||
selectedBlockId: block.id,
|
||||
isDirty: true,
|
||||
});
|
||||
},
|
||||
|
||||
removeBlock: (id) => {
|
||||
const state = get();
|
||||
const viewBlocks = state.blocks[state.currentView].filter((b) => b.id !== id);
|
||||
const connections = state.connections.filter(
|
||||
(c) => c.from.componentId !== id && c.to.componentId !== id
|
||||
);
|
||||
set({
|
||||
blocks: { ...state.blocks, [state.currentView]: viewBlocks },
|
||||
connections,
|
||||
selectedBlockId: state.selectedBlockId === id ? null : state.selectedBlockId,
|
||||
isDirty: true,
|
||||
});
|
||||
},
|
||||
|
||||
updateBlock: (id, updates) => {
|
||||
const state = get();
|
||||
const viewBlocks = state.blocks[state.currentView].map((b) =>
|
||||
b.id === id ? { ...b, ...updates } : b
|
||||
);
|
||||
set({ blocks: { ...state.blocks, [state.currentView]: viewBlocks }, isDirty: true });
|
||||
},
|
||||
|
||||
selectBlock: (id) => set({ selectedBlockId: id }),
|
||||
|
||||
moveBlock: (id, x, y) => {
|
||||
const state = get();
|
||||
const viewBlocks = state.blocks[state.currentView].map((b) =>
|
||||
b.id === id ? { ...b, position: { ...b.position, x: Math.max(0, x), y: Math.max(0, y) } } : b
|
||||
);
|
||||
set({ blocks: { ...state.blocks, [state.currentView]: viewBlocks }, isDirty: true });
|
||||
},
|
||||
|
||||
resizeBlock: (id, w, h) => {
|
||||
const state = get();
|
||||
const viewBlocks = state.blocks[state.currentView].map((b) =>
|
||||
b.id === id
|
||||
? { ...b, position: { ...b.position, w: Math.max(40, w), h: Math.max(20, h) } }
|
||||
: b
|
||||
);
|
||||
set({ blocks: { ...state.blocks, [state.currentView]: viewBlocks }, isDirty: true });
|
||||
},
|
||||
|
||||
updateBlockConfig: (id, configUpdates) => {
|
||||
const state = get();
|
||||
const viewBlocks = state.blocks[state.currentView].map((b) =>
|
||||
b.id === id ? { ...b, config: { ...b.config, ...configUpdates } as ComponentTypeConfig } : b
|
||||
);
|
||||
set({ blocks: { ...state.blocks, [state.currentView]: viewBlocks }, isDirty: true });
|
||||
},
|
||||
|
||||
updateField: (column, updates) => {
|
||||
const state = get();
|
||||
const fields = state.fields.map((f) =>
|
||||
f.column === column ? { ...f, ...updates } : f
|
||||
);
|
||||
set({ fields, isDirty: true });
|
||||
},
|
||||
|
||||
setTemplateMeta: (meta) =>
|
||||
set({
|
||||
...(meta.templateName !== undefined ? { templateName: meta.templateName } : {}),
|
||||
...(meta.category !== undefined ? { category: meta.category } : {}),
|
||||
...(meta.description !== undefined ? { description: meta.description } : {}),
|
||||
isDirty: true,
|
||||
}),
|
||||
|
||||
addConnection: (conn) => {
|
||||
const state = get();
|
||||
if (!conn.id) conn.id = genConnId();
|
||||
set({ connections: [...state.connections, conn], isDirty: true });
|
||||
},
|
||||
|
||||
removeConnection: (connId) => {
|
||||
const state = get();
|
||||
set({ connections: state.connections.filter((c) => c.id !== connId), isDirty: true });
|
||||
},
|
||||
|
||||
toTemplate: (): Template => {
|
||||
const s = get();
|
||||
return {
|
||||
templateId: s.templateId || "",
|
||||
name: s.templateName,
|
||||
category: s.category,
|
||||
description: s.description || undefined,
|
||||
primaryTable: s.tableName || "",
|
||||
fields: s.fields,
|
||||
views: {
|
||||
list: { components: s.blocks.list },
|
||||
create: { components: s.blocks.create },
|
||||
edit: { components: s.blocks.edit },
|
||||
},
|
||||
connections: s.connections,
|
||||
companyCode: "*",
|
||||
version: 1,
|
||||
status: "draft",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
|
||||
fromTemplate: (tpl) => {
|
||||
set({
|
||||
templateId: tpl.template_id ?? tpl.templateId ?? null,
|
||||
templateName: tpl.name ?? "",
|
||||
category: tpl.category ?? "",
|
||||
description: tpl.description ?? "",
|
||||
tableName: tpl.primary_table ?? tpl.primaryTable ?? null,
|
||||
fields: tpl.fields ?? [],
|
||||
blocks: {
|
||||
list: tpl.views?.list?.components ?? [],
|
||||
create: tpl.views?.create?.components ?? [],
|
||||
edit: tpl.views?.edit?.components ?? [],
|
||||
},
|
||||
connections: tpl.connections ?? [],
|
||||
currentView: "list",
|
||||
selectedBlockId: null,
|
||||
isDirty: false,
|
||||
});
|
||||
},
|
||||
|
||||
resetBuilder: () => set({ ...initialState }),
|
||||
|
||||
markClean: () => set({ isDirty: false }),
|
||||
}),
|
||||
{ name: "builder-state" }
|
||||
)
|
||||
);
|
||||
|
||||
// ─── 셀렉터 훅 ───
|
||||
export function useCurrentViewBlocks() {
|
||||
return useBuilderState((s) => s.blocks[s.currentView]);
|
||||
}
|
||||
|
||||
export function useSelectedBlock(): Component | null {
|
||||
return useBuilderState((s) => {
|
||||
if (!s.selectedBlockId) return null;
|
||||
return s.blocks[s.currentView].find((b) => b.id === s.selectedBlockId) ?? null;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useBuilderState } from "../hooks/useBuilderState";
|
||||
import type { Component, ButtonConfig, ButtonBarConfig } from "@/types/invyone-component";
|
||||
|
||||
const ACTION_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "save", label: "저장" },
|
||||
{ value: "edit", label: "수정" },
|
||||
{ value: "delete", label: "삭제" },
|
||||
{ value: "add", label: "신규" },
|
||||
{ value: "cancel", label: "취소" },
|
||||
{ value: "close", label: "닫기" },
|
||||
{ value: "navigate", label: "화면 이동" },
|
||||
{ value: "popup", label: "팝업 열기" },
|
||||
{ value: "search", label: "검색" },
|
||||
{ value: "reset", label: "초기화" },
|
||||
{ value: "submit", label: "제출" },
|
||||
{ value: "approval", label: "승인" },
|
||||
];
|
||||
|
||||
const VARIANT_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "primary", label: "강조 (파란색)" },
|
||||
{ value: "default", label: "기본 (테두리)" },
|
||||
{ value: "destructive", label: "위험 (빨간색)" },
|
||||
{ value: "outline", label: "아웃라인" },
|
||||
{ value: "ghost", label: "투명" },
|
||||
];
|
||||
|
||||
export function SingleButtonProps({ block }: { block: Component }) {
|
||||
const updateBlockConfig = useBuilderState((s) => s.updateBlockConfig);
|
||||
const config = block.config as ButtonConfig;
|
||||
|
||||
const update = (key: string, val: any) => updateBlockConfig(block.id, { [key]: val });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dev-prop-sec">버튼 설정</div>
|
||||
<div className="dev-prop-row">
|
||||
<span className="dev-prop-label">텍스트</span>
|
||||
<input className="dev-input" value={config.text}
|
||||
onChange={(e) => update("text", e.target.value)} />
|
||||
</div>
|
||||
<div className="dev-prop-row inline">
|
||||
<span className="dev-prop-label">액션</span>
|
||||
<select className="dev-select" value={config.actionType}
|
||||
onChange={(e) => update("actionType", e.target.value)}>
|
||||
{ACTION_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="dev-prop-row inline">
|
||||
<span className="dev-prop-label">스타일</span>
|
||||
<select className="dev-select" value={config.variant}
|
||||
onChange={(e) => update("variant", e.target.value)}>
|
||||
{VARIANT_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="dev-prop-row">
|
||||
<span className="dev-prop-label">확인 메시지</span>
|
||||
<input className="dev-input" value={config.confirm || ""}
|
||||
placeholder="비워두면 확인 없이 실행"
|
||||
onChange={(e) => update("confirm", e.target.value || undefined)} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ButtonBarProps({ block }: { block: Component }) {
|
||||
const updateBlockConfig = useBuilderState((s) => s.updateBlockConfig);
|
||||
const config = block.config as ButtonBarConfig;
|
||||
|
||||
const updateButton = (idx: number, key: string, val: any) => {
|
||||
const buttons = [...config.buttons];
|
||||
buttons[idx] = { ...buttons[idx], [key]: val };
|
||||
updateBlockConfig(block.id, { buttons } as any);
|
||||
};
|
||||
|
||||
const addButton = () => {
|
||||
const buttons = [...config.buttons, { text: "버튼", actionType: "save" as const, variant: "default" as const }];
|
||||
updateBlockConfig(block.id, { buttons } as any);
|
||||
};
|
||||
|
||||
const removeButton = (idx: number) => {
|
||||
const buttons = config.buttons.filter((_, i) => i !== idx);
|
||||
updateBlockConfig(block.id, { buttons } as any);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dev-prop-sec">버튼 목록</div>
|
||||
{config.buttons.map((btn, i) => (
|
||||
<div key={i} style={{ padding: "0.15rem 0.6rem", borderBottom: "1px dashed var(--d-border)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.2rem", marginBottom: "0.1rem" }}>
|
||||
<input className="dev-input" style={{ flex: 1 }} value={btn.text}
|
||||
onChange={(e) => updateButton(i, "text", e.target.value)} />
|
||||
<button className="dev-delete-btn" style={{ width: "auto", padding: "0.15rem 0.3rem", fontSize: "0.4rem" }}
|
||||
onClick={() => removeButton(i)}>✕</button>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "0.2rem" }}>
|
||||
<select className="dev-select" style={{ flex: 1, fontSize: "0.42rem" }} value={btn.actionType}
|
||||
onChange={(e) => updateButton(i, "actionType", e.target.value)}>
|
||||
{ACTION_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<select className="dev-select" style={{ flex: 1, fontSize: "0.42rem" }} value={btn.variant}
|
||||
onChange={(e) => updateButton(i, "variant", e.target.value)}>
|
||||
{VARIANT_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ padding: "0.2rem 0.6rem" }}>
|
||||
<button className="dev-btn" style={{ width: "100%", justifyContent: "center" }}
|
||||
onClick={addButton}>
|
||||
+ 버튼 추가
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useBuilderState } from "../hooks/useBuilderState";
|
||||
import type { FieldConfig } from "@/types/invyone-component";
|
||||
|
||||
interface FieldListEditorProps {
|
||||
/** 필터 함수: 어떤 필드를 목록에 표시할지 */
|
||||
filter?: (f: FieldConfig) => boolean;
|
||||
/** 체크박스 토글 대상 속성 */
|
||||
toggleKey?: "visible" | "searchable";
|
||||
}
|
||||
|
||||
/** 필드 체크리스트 (table/form/search 속성 패널 공통) */
|
||||
export default function FieldListEditor({ filter, toggleKey = "visible" }: FieldListEditorProps) {
|
||||
const fields = useBuilderState((s) => s.fields);
|
||||
const updateField = useBuilderState((s) => s.updateField);
|
||||
const [expandedCol, setExpandedCol] = useState<string | null>(null);
|
||||
|
||||
const filteredFields = filter ? fields.filter(filter) : fields;
|
||||
const sorted = [...filteredFields].sort((a, b) => a.order - b.order);
|
||||
|
||||
return (
|
||||
<div className="dev-field-list">
|
||||
{sorted.map((f) => (
|
||||
<React.Fragment key={f.column}>
|
||||
<div
|
||||
className="dev-field-item"
|
||||
onClick={() => setExpandedCol(expandedCol === f.column ? null : f.column)}
|
||||
>
|
||||
<div
|
||||
className={`dev-field-check${f[toggleKey] ? " on" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateField(f.column, { [toggleKey]: !f[toggleKey] });
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
<span className="dev-field-name">{f.label}</span>
|
||||
<div style={{ display: "flex", gap: 2 }}>
|
||||
{f.pk && <span className="dev-fc-badge pk">PK</span>}
|
||||
{f.required && <span className="dev-fc-badge req">필수</span>}
|
||||
{f.searchable && <span className="dev-fc-badge sch">검색</span>}
|
||||
{f.system && <span className="dev-fc-badge sys">SYS</span>}
|
||||
{f.computed && <span className="dev-fc-badge cmp">계산</span>}
|
||||
</div>
|
||||
<span className="dev-field-type">{f.type}</span>
|
||||
<span className="dev-field-drag">⋮</span>
|
||||
</div>
|
||||
{expandedCol === f.column && <FieldDetail field={f} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 필드 상세 편집 패널 */
|
||||
function FieldDetail({ field }: { field: FieldConfig }) {
|
||||
const updateField = useBuilderState((s) => s.updateField);
|
||||
const col = field.column;
|
||||
|
||||
return (
|
||||
<div style={{ padding: "0.3rem 0.4rem", background: "var(--d-bg3)", borderRadius: 4, margin: "0.1rem 0", fontSize: "0.46rem" }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: "flex", gap: "0.3rem", marginBottom: "0.2rem" }}>
|
||||
<label style={{ flex: 1, display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<span style={{ fontSize: "0.4rem", fontWeight: 700, color: "var(--d-text3)" }}>표시 이름</span>
|
||||
<input className="dev-input" value={field.label}
|
||||
onChange={(e) => updateField(col, { label: e.target.value })} />
|
||||
</label>
|
||||
<label style={{ width: 80, display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<span style={{ fontSize: "0.4rem", fontWeight: 700, color: "var(--d-text3)" }}>너비</span>
|
||||
<input className="dev-input" type="number" value={field.width ?? ""}
|
||||
placeholder="자동"
|
||||
onChange={(e) => updateField(col, { width: parseInt(e.target.value) || undefined })} />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
<Toggle label="필수" checked={field.required} onToggle={(v) => updateField(col, { required: v })} />
|
||||
<Toggle label="편집" checked={field.editable} onToggle={(v) => updateField(col, { editable: v })} />
|
||||
<Toggle label="검색" checked={!!field.searchable} onToggle={(v) => updateField(col, { searchable: v })} />
|
||||
<Toggle label="정렬" checked={field.sortable !== false} onToggle={(v) => updateField(col, { sortable: v })} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ label, checked, onToggle }: { label: string; checked: boolean; onToggle: (v: boolean) => void }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.2rem", cursor: "pointer" }}
|
||||
onClick={() => onToggle(!checked)}>
|
||||
<div className={`dev-toggle${checked ? " on" : ""}`} />
|
||||
<span style={{ fontSize: "0.42rem", color: "var(--d-text3)" }}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useBuilderState } from "../hooks/useBuilderState";
|
||||
import FieldListEditor from "./FieldListEditor";
|
||||
import type { Component, FormConfig } from "@/types/invyone-component";
|
||||
|
||||
export default function FormProps({ block }: { block: Component }) {
|
||||
const updateBlockConfig = useBuilderState((s) => s.updateBlockConfig);
|
||||
const config = block.config as FormConfig;
|
||||
|
||||
const update = (key: string, val: any) => updateBlockConfig(block.id, { [key]: val });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dev-prop-sec">폼 설정</div>
|
||||
<div className="dev-prop-row inline">
|
||||
<span className="dev-prop-label">컬럼 수</span>
|
||||
<select className="dev-select" value={config.columns}
|
||||
onChange={(e) => update("columns", Number(e.target.value))}>
|
||||
<option value={1}>1칸</option>
|
||||
<option value={2}>2칸</option>
|
||||
<option value={3}>3칸</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="dev-prop-row inline">
|
||||
<span className="dev-prop-label">저장 방식</span>
|
||||
<select className="dev-select" value={config.saveAction?.method || "UPSERT"}
|
||||
onChange={(e) => update("saveAction", { ...config.saveAction, method: e.target.value })}>
|
||||
<option value="INSERT">등록</option>
|
||||
<option value="UPDATE">수정</option>
|
||||
<option value="UPSERT">등록/수정</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="dev-prop-sec">입력 항목</div>
|
||||
<div className="dev-hint">체크: 폼에 표시 · 클릭: 상세 설정</div>
|
||||
<FieldListEditor filter={(f) => !f.system} toggleKey="visible" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useBuilderState } from "../hooks/useBuilderState";
|
||||
import FieldListEditor from "./FieldListEditor";
|
||||
import type { Component, SearchConfig } from "@/types/invyone-component";
|
||||
|
||||
export default function SearchProps({ block }: { block: Component }) {
|
||||
const updateBlockConfig = useBuilderState((s) => s.updateBlockConfig);
|
||||
const config = block.config as SearchConfig;
|
||||
|
||||
const update = (key: string, val: any) => updateBlockConfig(block.id, { [key]: val });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dev-prop-sec">검색 설정</div>
|
||||
<div className="dev-prop-row inline">
|
||||
<span className="dev-prop-label">날짜 범위 검색</span>
|
||||
<div className={`dev-toggle${config.dateRangeEnabled ? " on" : ""}`}
|
||||
onClick={() => update("dateRangeEnabled", !config.dateRangeEnabled)} />
|
||||
</div>
|
||||
<div className="dev-prop-row inline">
|
||||
<span className="dev-prop-label">초기화 버튼</span>
|
||||
<div className={`dev-toggle${config.showResetButton ? " on" : ""}`}
|
||||
onClick={() => update("showResetButton", !config.showResetButton)} />
|
||||
</div>
|
||||
<div className="dev-prop-row inline">
|
||||
<span className="dev-prop-label">자동 검색</span>
|
||||
<div className={`dev-toggle${config.autoSearch ? " on" : ""}`}
|
||||
onClick={() => update("autoSearch", !config.autoSearch)} />
|
||||
</div>
|
||||
<div className="dev-prop-row inline">
|
||||
<span className="dev-prop-label">레이아웃</span>
|
||||
<select className="dev-select" value={config.layout}
|
||||
onChange={(e) => update("layout", e.target.value)}>
|
||||
<option value="inline">인라인</option>
|
||||
<option value="stacked">세로</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="dev-prop-sec">검색 조건</div>
|
||||
<div className="dev-hint">체크: 검색에 포함 · 클릭: 상세 설정</div>
|
||||
<FieldListEditor filter={(f) => !f.system} toggleKey="searchable" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useBuilderState } from "../hooks/useBuilderState";
|
||||
import FieldListEditor from "./FieldListEditor";
|
||||
import type { Component, TableConfig } from "@/types/invyone-component";
|
||||
|
||||
export default function TableProps({ block }: { block: Component }) {
|
||||
const updateBlockConfig = useBuilderState((s) => s.updateBlockConfig);
|
||||
const config = block.config as TableConfig;
|
||||
|
||||
const update = (key: string, val: any) => updateBlockConfig(block.id, { [key]: val });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dev-prop-sec">테이블 설정</div>
|
||||
<div className="dev-prop-row inline">
|
||||
<span className="dev-prop-label">페이지 크기</span>
|
||||
<select className="dev-select" value={config.pageSize}
|
||||
onChange={(e) => update("pageSize", Number(e.target.value))}>
|
||||
{[10, 20, 50, 100].map((n) => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="dev-prop-row inline">
|
||||
<span className="dev-prop-label">선택 방식</span>
|
||||
<select className="dev-select" value={config.selectionMode}
|
||||
onChange={(e) => update("selectionMode", e.target.value)}>
|
||||
<option value="none">없음</option>
|
||||
<option value="single">단일</option>
|
||||
<option value="multiple">다중</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="dev-prop-row inline">
|
||||
<span className="dev-prop-label">자동 로드</span>
|
||||
<div className={`dev-toggle${config.autoLoad ? " on" : ""}`}
|
||||
onClick={() => update("autoLoad", !config.autoLoad)} />
|
||||
</div>
|
||||
<div className="dev-prop-row inline">
|
||||
<span className="dev-prop-label">인라인 편집</span>
|
||||
<div className={`dev-toggle${config.inlineEdit ? " on" : ""}`}
|
||||
onClick={() => update("inlineEdit", !config.inlineEdit)} />
|
||||
</div>
|
||||
<div className="dev-prop-row inline">
|
||||
<span className="dev-prop-label">체크박스</span>
|
||||
<div className={`dev-toggle${config.showCheckbox ? " on" : ""}`}
|
||||
onClick={() => update("showCheckbox", !config.showCheckbox)} />
|
||||
</div>
|
||||
<div className="dev-prop-row inline">
|
||||
<span className="dev-prop-label">스타일</span>
|
||||
<select className="dev-select" value={config.style}
|
||||
onChange={(e) => update("style", e.target.value)}>
|
||||
<option value="default">기본</option>
|
||||
<option value="striped">줄무늬</option>
|
||||
<option value="bordered">테두리</option>
|
||||
<option value="compact">컴팩트</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="dev-prop-sec">표시할 컬럼</div>
|
||||
<div className="dev-hint">체크: 보이기 · 클릭: 상세 설정</div>
|
||||
<FieldListEditor filter={(f) => !f.system} toggleKey="visible" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useBuilderState } from "../hooks/useBuilderState";
|
||||
import type { Component, TitleConfig } from "@/types/invyone-component";
|
||||
|
||||
export default function TitleProps({ block }: { block: Component }) {
|
||||
const updateBlockConfig = useBuilderState((s) => s.updateBlockConfig);
|
||||
const config = block.config as TitleConfig;
|
||||
|
||||
const update = (key: string, val: any) => updateBlockConfig(block.id, { [key]: val });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dev-prop-sec">제목 설정</div>
|
||||
<div className="dev-prop-row">
|
||||
<span className="dev-prop-label">텍스트</span>
|
||||
<input className="dev-input" value={config.text}
|
||||
onChange={(e) => update("text", e.target.value)} />
|
||||
</div>
|
||||
<div className="dev-prop-row inline">
|
||||
<span className="dev-prop-label">크기</span>
|
||||
<select className="dev-select" value={config.fontSize}
|
||||
onChange={(e) => update("fontSize", e.target.value)}>
|
||||
<option value="0.5rem">작게</option>
|
||||
<option value="0.75rem">보통</option>
|
||||
<option value="1rem">크게</option>
|
||||
<option value="1.2rem">아주 크게</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="dev-prop-row inline">
|
||||
<span className="dev-prop-label">굵기</span>
|
||||
<select className="dev-select" value={config.fontWeight}
|
||||
onChange={(e) => update("fontWeight", e.target.value)}>
|
||||
<option value="400">보통</option>
|
||||
<option value="500">약간 굵게</option>
|
||||
<option value="700">굵게</option>
|
||||
<option value="800">아주 굵게</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="dev-prop-row inline">
|
||||
<span className="dev-prop-label">정렬</span>
|
||||
<select className="dev-select" value={config.align}
|
||||
onChange={(e) => update("align", e.target.value)}>
|
||||
<option value="left">왼쪽</option>
|
||||
<option value="center">가운데</option>
|
||||
<option value="right">오른쪽</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* SVG 연결선 + 화살표 마커 4종 + 뱃지
|
||||
* mockup drawTreeLine/addEdgeBadge 포팅
|
||||
*/
|
||||
|
||||
interface ConnectionSvgProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** SVG 컨테이너 (캔버스 위 오버레이, defs 포함) */
|
||||
export function ConnectionSvg({ children }: ConnectionSvgProps) {
|
||||
return (
|
||||
<svg className="ctrl-svg" width="100%" height="100%" style={{ overflow: 'visible' }}>
|
||||
<defs>
|
||||
<marker id="arr-fk" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-cyan)" opacity=".7" />
|
||||
</marker>
|
||||
<marker id="arr-auto" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-primary)" opacity=".8" />
|
||||
</marker>
|
||||
<marker id="arr-cond" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-amber)" opacity=".8" />
|
||||
</marker>
|
||||
<marker id="arr-src" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-pink)" opacity=".8" />
|
||||
</marker>
|
||||
<marker id="arr-rule" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-cyan)" opacity=".8" />
|
||||
</marker>
|
||||
<marker id="arr-yes" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-green)" opacity=".8" />
|
||||
</marker>
|
||||
<marker id="arr-no" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0,8 3,0 6" fill="var(--v5-text-muted, #888)" opacity=".5" />
|
||||
</marker>
|
||||
</defs>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** bezier 경로 계산: from(x1,y1) → to(x2,y2) */
|
||||
export function bezierPath(x1: number, y1: number, x2: number, y2: number): string {
|
||||
const dx = x2 - x1;
|
||||
return `M${x1},${y1} C${x1 + dx * 0.5},${y1} ${x1 + dx * 0.5},${y2} ${x2},${y2}`;
|
||||
}
|
||||
|
||||
/** 타입별 CSS 클래스 + 마커 */
|
||||
export function lineStyle(type: string): { cls: string; marker: string } {
|
||||
switch (type) {
|
||||
case 'source': return { cls: 'ctrl-line-tpl', marker: 'url(#arr-src)' };
|
||||
case 'auto': return { cls: 'ctrl-line-auto', marker: 'url(#arr-auto)' };
|
||||
case 'cond': return { cls: 'ctrl-line-cond', marker: 'url(#arr-cond)' };
|
||||
default: return { cls: 'ctrl-line', marker: 'url(#arr-fk)' };
|
||||
}
|
||||
}
|
||||
|
||||
interface FlowLineProps {
|
||||
x1: number; y1: number;
|
||||
x2: number; y2: number;
|
||||
type: string;
|
||||
animate?: boolean;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
/** 단일 연결선 (SVG path) */
|
||||
export function FlowLine({ x1, y1, x2, y2, type, animate, delay }: FlowLineProps) {
|
||||
const { cls, marker } = lineStyle(type);
|
||||
const d = bezierPath(x1, y1, x2, y2);
|
||||
|
||||
if (animate) {
|
||||
return (
|
||||
<path
|
||||
d={d}
|
||||
className={cls}
|
||||
markerEnd={marker}
|
||||
style={{
|
||||
strokeDasharray: '1000',
|
||||
strokeDashoffset: '1000',
|
||||
animation: 'none',
|
||||
transition: `stroke-dashoffset 0.4s ease-out ${(delay ?? 0)}ms`,
|
||||
}}
|
||||
ref={(el) => {
|
||||
if (!el) return;
|
||||
const len = el.getTotalLength();
|
||||
el.style.strokeDasharray = String(len);
|
||||
el.style.strokeDashoffset = String(len);
|
||||
requestAnimationFrame(() => {
|
||||
el.style.strokeDashoffset = '0';
|
||||
});
|
||||
setTimeout(() => {
|
||||
el.style.transition = 'none';
|
||||
el.style.strokeDasharray = '';
|
||||
el.style.strokeDashoffset = '';
|
||||
el.style.animation = '';
|
||||
}, 500 + (delay ?? 0));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <path d={d} className={cls} markerEnd={marker} />;
|
||||
}
|
||||
|
||||
interface FlowBadgeProps {
|
||||
x: number; y: number;
|
||||
label: string;
|
||||
type: string;
|
||||
animate?: boolean;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
/** 연결선 위 뱃지 (HTML) */
|
||||
export function FlowBadge({ x, y, label, type, animate, delay }: FlowBadgeProps) {
|
||||
const cls = type === 'source' ? 'tpl-link' : type === 'auto' ? 'auto' : type === 'cond' ? 'cond' : '';
|
||||
|
||||
if (type === 'cond') {
|
||||
const parts = label.split('→');
|
||||
const condText = (parts[0] || '조건').trim();
|
||||
const actionText = (parts[1] || '실행').trim();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ctrl-badge cond`}
|
||||
style={{
|
||||
left: x, top: y,
|
||||
opacity: animate ? 0 : 1,
|
||||
transition: animate ? `opacity .3s ${(delay ?? 0)}ms` : undefined,
|
||||
}}
|
||||
ref={(el) => {
|
||||
if (el && animate) requestAnimationFrame(() => { el.style.opacity = '1'; });
|
||||
}}
|
||||
>
|
||||
<div className="cb-head"><div className="cb-icon">◇</div>조건 분기</div>
|
||||
<div className="cb-cond">{condText}</div>
|
||||
<div className="cb-paths">
|
||||
<span className="cb-yes">Yes → {actionText}</span>
|
||||
<span className="cb-no">No → 스킵</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ctrl-badge ${cls}`}
|
||||
style={{
|
||||
left: x, top: y,
|
||||
opacity: animate ? 0 : 1,
|
||||
transition: animate ? `opacity .3s ${(delay ?? 0)}ms` : undefined,
|
||||
}}
|
||||
ref={(el) => {
|
||||
if (el && animate) requestAnimationFrame(() => { el.style.opacity = '1'; });
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { useControlMode } from './hooks/useControlMode';
|
||||
import { ControlToolbar } from './ControlToolbar';
|
||||
import { ControlPalette } from './ControlPalette';
|
||||
import { FlowViewer } from './FlowViewer';
|
||||
import { RuleBuilder } from './RuleBuilder';
|
||||
import '@/styles/control-mode.css';
|
||||
|
||||
interface ControlModeProps {
|
||||
dashboardId: string;
|
||||
cards: Record<string, any>[];
|
||||
canvasRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 제어 모드 오버레이 — 캔버스 위에 렌더
|
||||
* ⚡ 버튼으로 토글, 읽기/편집 모드 전환
|
||||
*/
|
||||
export function ControlMode({ dashboardId, cards, canvasRef }: ControlModeProps) {
|
||||
const { active, mode } = useControlMode();
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 제어 모드 툴바 */}
|
||||
<ControlToolbar dashboardId={dashboardId} />
|
||||
|
||||
{/* 읽기 모드: 카드 클릭 → 흐름 시각화 */}
|
||||
{mode === 'view' && (
|
||||
<FlowViewer cards={cards} canvasRef={canvasRef} dashboardId={dashboardId} />
|
||||
)}
|
||||
|
||||
{/* 편집 모드: 규칙 빌더 */}
|
||||
{mode === 'edit' && (
|
||||
<RuleBuilder canvasRef={canvasRef} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 제어 모드 팔레트 wrapper — 사이드바에 삽입
|
||||
*/
|
||||
export function ControlPaletteWrapper() {
|
||||
const { active, mode, addRuleNode } = useControlMode();
|
||||
if (!active || mode !== 'edit') return null;
|
||||
|
||||
return (
|
||||
<ControlPalette
|
||||
onDropTable={() => {}}
|
||||
onDropControl={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
|
||||
import { PortHandle } from './PortHandle';
|
||||
|
||||
interface ControlNodeProps {
|
||||
node: Record<string, any>;
|
||||
onDragStart?: (nodeId: string, port: string, e: React.MouseEvent) => void;
|
||||
onDragEnd?: (nodeId: string, port: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 제어 노드 (16종) — mockup buildCtrlNode 포팅
|
||||
*/
|
||||
export function ControlNode({ node, onDragStart, onDragEnd }: ControlNodeProps) {
|
||||
const { removeRuleNode, moveRuleNode, setConfigNodeId } = useControlMode();
|
||||
const nodeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const def = CTRL_NODE_TYPES[node.type];
|
||||
if (!def) return null;
|
||||
|
||||
const outPorts = def.out || [{ port: 'out', label: '→', cls: '' }];
|
||||
|
||||
const handleHeadMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const sl = node.x, st = node.y;
|
||||
const el = nodeRef.current;
|
||||
if (el) el.style.zIndex = '30';
|
||||
|
||||
const mv = (ev: MouseEvent) => {
|
||||
moveRuleNode(node.id, sl + ev.clientX - sx, st + ev.clientY - sy);
|
||||
};
|
||||
const up = () => {
|
||||
if (el) el.style.zIndex = '20';
|
||||
document.removeEventListener('mousemove', mv);
|
||||
document.removeEventListener('mouseup', up);
|
||||
};
|
||||
document.addEventListener('mousemove', mv);
|
||||
document.addEventListener('mouseup', up);
|
||||
}, [node.id, node.x, node.y, moveRuleNode]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={nodeRef}
|
||||
className="ctrl-action-node"
|
||||
data-node-id={node.id}
|
||||
data-node-type={node.type}
|
||||
style={{
|
||||
left: node.x,
|
||||
top: node.y,
|
||||
['--na-rgb' as string]: def.rgb,
|
||||
}}
|
||||
>
|
||||
{/* Input 포트 */}
|
||||
<PortHandle
|
||||
nodeId={node.id}
|
||||
port="in"
|
||||
type="in"
|
||||
onDragEnd={onDragEnd}
|
||||
/>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="ctrl-an-head" onMouseDown={handleHeadMouseDown}>
|
||||
<div className="ctrl-an-icon">{def.icon}</div>
|
||||
<span className="ctrl-an-name">{def.label}</span>
|
||||
<button
|
||||
className="ctrl-an-del"
|
||||
onClick={(e) => { e.stopPropagation(); removeRuleNode(node.id); }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div
|
||||
className="ctrl-an-body"
|
||||
onClick={() => setConfigNodeId(node.id)}
|
||||
>
|
||||
<div className="ctrl-an-summary">
|
||||
{node.config?.summary || '클릭하여 설정'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output 포트 */}
|
||||
<div className="ctrl-an-ports-out">
|
||||
{outPorts.map((p) => (
|
||||
<PortHandle
|
||||
key={p.port}
|
||||
nodeId={node.id}
|
||||
port={p.port}
|
||||
type="out"
|
||||
cls={p.cls}
|
||||
label={p.label}
|
||||
onDragStart={onDragStart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CTRL_NODE_TYPES } from './hooks/useControlMode';
|
||||
import { getMetaTableList } from '@/lib/api/meta';
|
||||
|
||||
interface ControlPaletteProps {
|
||||
onDropTable: (tableName: string, x: number, y: number) => void;
|
||||
onDropControl: (type: string, x: number, y: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 제어 모드 팔레트 — 사이드바 교체
|
||||
* mockup renderCtrlPalette 포팅
|
||||
*/
|
||||
export function ControlPalette({ onDropTable, onDropControl }: ControlPaletteProps) {
|
||||
const [tables, setTables] = useState<Record<string, any>[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getMetaTableList().then(setTables).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, data: Record<string, any>) => {
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify(data));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
};
|
||||
|
||||
const catLabels: Record<string, string> = {
|
||||
'트리거': '트리거',
|
||||
'조건': '조건 / 분기',
|
||||
'액션': '액션',
|
||||
'흐름': '흐름 제어',
|
||||
'연동': '외부 연동',
|
||||
'기록': '기록',
|
||||
};
|
||||
const cats = ['트리거', '조건', '액션', '흐름', '연동', '기록'];
|
||||
|
||||
return (
|
||||
<div style={{ overflowY: 'auto', flex: 1 }}>
|
||||
{/* DB 테이블 섹션 */}
|
||||
<div className="ctrl-palette-section">DB 테이블</div>
|
||||
{tables.map((t) => {
|
||||
const name = t.table_name ?? t.TABLE_NAME;
|
||||
const label = t.table_label ?? t.TABLE_LABEL ?? name;
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="ctrl-palette-item"
|
||||
draggable
|
||||
title={`${label} — 캔버스로 드래그`}
|
||||
onDragStart={(e) => handleDragStart(e, { kind: 'table', name })}
|
||||
>
|
||||
<span className="cp-icon">🏢</span>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 제어 노드 — 카테고리별 그룹 */}
|
||||
{cats.map((cat) => {
|
||||
const items = Object.entries(CTRL_NODE_TYPES).filter(([, d]) => d.cat === cat);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div key={cat}>
|
||||
<div className="ctrl-palette-section">{catLabels[cat] ?? cat}</div>
|
||||
{items.map(([type, def]) => (
|
||||
<div
|
||||
key={type}
|
||||
className="ctrl-palette-item"
|
||||
draggable
|
||||
title={`${def.label} — 캔버스로 드래그`}
|
||||
onDragStart={(e) => handleDragStart(e, { kind: 'control', type })}
|
||||
>
|
||||
<span className="cp-icon">{def.icon}</span>
|
||||
<span>{def.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Eye, Wrench, Save, FolderOpen } from 'lucide-react';
|
||||
import { useControlMode } from './hooks/useControlMode';
|
||||
import { getBusinessRuleList, getBusinessRuleInfo, insertBusinessRule, updateBusinessRule } from '@/lib/api/businessRule';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ControlToolbarProps {
|
||||
dashboardId: string;
|
||||
}
|
||||
|
||||
export function ControlToolbar({ dashboardId }: ControlToolbarProps) {
|
||||
const { mode, setMode, ruleNodes, ruleConnections, activeRuleId, setActiveRuleId, setRuleNodes, setRuleConnections } = useControlMode();
|
||||
const [ruleList, setRuleList] = useState<Record<string, any>[]>([]);
|
||||
const [showRuleList, setShowRuleList] = useState(false);
|
||||
|
||||
// ★ 편집 모드 진입 시 기존 규칙 목록 로드
|
||||
useEffect(() => {
|
||||
if (mode !== 'edit') return;
|
||||
getBusinessRuleList(dashboardId)
|
||||
.then((res) => setRuleList(res?.list ?? res?.data ?? []))
|
||||
.catch(() => setRuleList([]));
|
||||
}, [mode, dashboardId]);
|
||||
|
||||
// ★ 기존 규칙 로드 → 편집 상태 복원
|
||||
const handleLoadRule = useCallback(async (ruleId: string) => {
|
||||
try {
|
||||
const detail = await getBusinessRuleInfo(ruleId);
|
||||
if (!detail) { toast.error('규칙을 찾을 수 없습니다'); return; }
|
||||
setRuleNodes(detail.nodes ?? []);
|
||||
setRuleConnections(detail.connections ?? []);
|
||||
setActiveRuleId(ruleId);
|
||||
setShowRuleList(false);
|
||||
toast.success(`"${detail.name ?? ruleId}" 로드됨`);
|
||||
} catch {
|
||||
toast.error('규칙 로드 실패');
|
||||
}
|
||||
}, [setRuleNodes, setRuleConnections, setActiveRuleId]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const data = {
|
||||
name: `규칙 ${new Date().toLocaleString('ko-KR')}`,
|
||||
nodes: ruleNodes,
|
||||
connections: ruleConnections,
|
||||
};
|
||||
if (activeRuleId) {
|
||||
await updateBusinessRule(activeRuleId, data);
|
||||
toast.success('규칙 저장됨');
|
||||
} else {
|
||||
const result = await insertBusinessRule(dashboardId, data);
|
||||
if (result?.rule_id) setActiveRuleId(result.rule_id);
|
||||
toast.success('규칙 생성됨');
|
||||
}
|
||||
} catch {
|
||||
toast.error('저장 실패');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctrl-toolbar">
|
||||
<span style={{ fontWeight: 700, color: 'var(--ctrl-cyan)', marginRight: '.5rem' }}>⚡ 제어 모드</span>
|
||||
<div className="ctrl-toolbar-mode">
|
||||
<button
|
||||
className={`ctrl-mode-btn${mode === 'view' ? ' on' : ''}`}
|
||||
onClick={() => setMode('view')}
|
||||
>
|
||||
<Eye size={12} style={{ marginRight: 3 }} />
|
||||
읽기
|
||||
</button>
|
||||
<button
|
||||
className={`ctrl-mode-btn${mode === 'edit' ? ' on' : ''}`}
|
||||
onClick={() => setMode('edit')}
|
||||
>
|
||||
<Wrench size={12} style={{ marginRight: 3 }} />
|
||||
편집
|
||||
</button>
|
||||
</div>
|
||||
{mode === 'edit' && (
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '.3rem', position: 'relative' }}>
|
||||
{/* ★ 기존 규칙 로드 버튼 */}
|
||||
<button className="ctrl-mode-btn" onClick={() => setShowRuleList(!showRuleList)}>
|
||||
<FolderOpen size={12} style={{ marginRight: 3 }} />
|
||||
불러오기{ruleList.length > 0 ? ` (${ruleList.length})` : ''}
|
||||
</button>
|
||||
{/* ★ 규칙 목록 드롭다운 */}
|
||||
{showRuleList && ruleList.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', right: 0, marginTop: 4,
|
||||
background: 'var(--ctrl-glass-strong, rgba(17,16,42,.65))',
|
||||
border: '1px solid var(--ctrl-glass-border, rgba(162,155,254,.12))',
|
||||
borderRadius: 8, padding: '.3rem', minWidth: 200, zIndex: 100,
|
||||
backdropFilter: 'blur(20px) saturate(1.4)',
|
||||
}}>
|
||||
{ruleList.map((rule) => {
|
||||
const id = rule.rule_id ?? rule.RULE_ID;
|
||||
const name = rule.name ?? rule.NAME ?? id;
|
||||
const isActive = id === activeRuleId;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => handleLoadRule(id)}
|
||||
style={{
|
||||
display: 'block', width: '100%', textAlign: 'left',
|
||||
padding: '.3rem .5rem', borderRadius: 6, border: 'none',
|
||||
background: isActive ? 'rgba(0,206,201,.12)' : 'transparent',
|
||||
color: isActive ? 'var(--ctrl-cyan)' : 'var(--v5-text-sec)',
|
||||
fontSize: '.55rem', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{ruleNodes.length > 0 && (
|
||||
<button className="ctrl-mode-btn" onClick={handleSave}>
|
||||
<Save size={12} style={{ marginRight: 3 }} />
|
||||
규칙 저장
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useControlMode, CTRL_NODE_TYPES } from './hooks/useControlMode';
|
||||
import { useFlowAnimation } from './hooks/useFlowAnimation';
|
||||
import { getMetaFields, getMetaRelations } from '@/lib/api/meta';
|
||||
import { getBusinessRuleList, getBusinessRuleInfo } from '@/lib/api/businessRule';
|
||||
import { TableNode } from './TableNode';
|
||||
import { ControlNode } from './ControlNode';
|
||||
import { ConnectionSvg, FlowLine, FlowBadge, bezierPath } from './ConnectionLine';
|
||||
import type { FieldConfig } from '@/types/invyone-component';
|
||||
|
||||
interface FlowViewerProps {
|
||||
cards: Record<string, any>[];
|
||||
canvasRef: React.RefObject<HTMLDivElement | null>;
|
||||
dashboardId: string;
|
||||
}
|
||||
|
||||
/** 저장된 룰 그래프 (노드+연결선, 별도 오버레이) */
|
||||
interface RuleOverlay {
|
||||
ruleName: string;
|
||||
nodes: Record<string, any>[];
|
||||
connections: Record<string, any>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 룰 노드의 포트 위치 계산 (RuleBuilder.portPos와 동일 로직)
|
||||
* - table 노드: width 200, 포트 in=좌측, out=우측 (y+18)
|
||||
* - control 노드: width 160, in=좌측 (y+40), out=우측 (다중 포트 분배)
|
||||
*/
|
||||
function computePortPos(node: Record<string, any>, port: string): { x: number; y: number } | null {
|
||||
if (!node) return null;
|
||||
const nx = node.x ?? 0;
|
||||
const ny = node.y ?? 0;
|
||||
|
||||
if (node.type === 'table') {
|
||||
if (port === 'in') return { x: nx, y: ny + 18 };
|
||||
return { x: nx + 200, y: ny + 18 };
|
||||
}
|
||||
|
||||
// 제어 노드
|
||||
if (port === 'in') return { x: nx, y: ny + 40 };
|
||||
|
||||
// output 포트 — 타입별 위치
|
||||
const def = (CTRL_NODE_TYPES as Record<string, any>)[node.type];
|
||||
const outPorts = def?.out || [{ port: 'out', label: '→', cls: '' }];
|
||||
const idx = outPorts.findIndex((p: Record<string, any>) => p.port === port);
|
||||
const total = outPorts.length;
|
||||
const nodeH = 80;
|
||||
const centerY = ny + nodeH / 2;
|
||||
const gap = 8;
|
||||
const startY = centerY - ((total - 1) * gap) / 2;
|
||||
|
||||
return { x: nx + 160, y: startY + (idx >= 0 ? idx : 0) * gap };
|
||||
}
|
||||
|
||||
/** 테이블 메타 캐시 */
|
||||
const metaCache: Record<string, { label: string; icon: string; columns: FieldConfig[] }> = {};
|
||||
|
||||
async function loadTableMeta(tableName: string) {
|
||||
if (metaCache[tableName]) return metaCache[tableName];
|
||||
try {
|
||||
const meta = await getMetaFields(tableName);
|
||||
const result = {
|
||||
label: meta.table_label ?? tableName,
|
||||
icon: '🏢',
|
||||
columns: (meta.fields ?? []).filter((f: FieldConfig) => !f.system).slice(0, 8),
|
||||
};
|
||||
metaCache[tableName] = result;
|
||||
return result;
|
||||
} catch {
|
||||
return { label: tableName, icon: '🏢', columns: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function FlowViewer({ cards, canvasRef, dashboardId }: FlowViewerProps) {
|
||||
const {
|
||||
activeFlowCardId,
|
||||
flowEdges,
|
||||
tablePositions,
|
||||
setActiveFlowCard,
|
||||
setFlowEdges,
|
||||
setTablePositions,
|
||||
} = useControlMode();
|
||||
|
||||
const { showFlow } = useFlowAnimation();
|
||||
const [tableMetas, setTableMetas] = useState<Record<string, { label: string; icon: string; columns: FieldConfig[] }>>({});
|
||||
const [animTimings, setAnimTimings] = useState<{ edge: Record<string, any>; lineDelay: number; nodeDelay: number }[]>([]);
|
||||
const [revealedNodes, setRevealedNodes] = useState<Set<string>>(new Set());
|
||||
const [ruleOverlays, setRuleOverlays] = useState<RuleOverlay[]>([]);
|
||||
const animRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
// 카드 클릭 → 흐름 표시
|
||||
const handleCardClick = useCallback(async (cardId: string) => {
|
||||
// 같은 카드 클릭 → 닫기
|
||||
if (activeFlowCardId === cardId) {
|
||||
clearFlow();
|
||||
return;
|
||||
}
|
||||
|
||||
const card = cards.find((c) => (c.card_id ?? c.CARD_ID) === cardId);
|
||||
if (!card) return;
|
||||
|
||||
const sourceTable = card.primary_table ?? card.PRIMARY_TABLE;
|
||||
if (!sourceTable) return;
|
||||
|
||||
// 카드 위치 계산
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
const cardEl = cv.querySelector(`[data-card-id="${cardId}"]`) as HTMLElement;
|
||||
if (!cardEl) return;
|
||||
|
||||
const cardRight = cardEl.offsetLeft + cardEl.offsetWidth;
|
||||
const cardCenterY = cardEl.offsetTop + cardEl.offsetHeight / 2;
|
||||
const canvasHeight = cv.clientHeight;
|
||||
|
||||
// ★ 2소스 분리 조회: table_relationships (구조) + business_rules (자동화)
|
||||
let relations: Record<string, any>[] = [];
|
||||
try { relations = await getMetaRelations(sourceTable); } catch { /* 빈 배열 사용 */ }
|
||||
|
||||
// ★ 현재 대시보드의 활성 비즈니스 룰 조회 → 별도 오버레이 (BFS에 합치지 않음)
|
||||
// ★ 단, sourceTable과 관련된 룰만 표시 (해당 테이블 노드를 포함하는 룰)
|
||||
const overlays: RuleOverlay[] = [];
|
||||
try {
|
||||
const rulesRes = await getBusinessRuleList(dashboardId);
|
||||
const ruleList = (rulesRes?.list ?? rulesRes?.data ?? [])
|
||||
.filter((r: Record<string, any>) => r.is_enabled === true || r.IS_ENABLED === true);
|
||||
for (const rule of ruleList) {
|
||||
const ruleId = rule.rule_id ?? rule.RULE_ID;
|
||||
if (!ruleId) continue;
|
||||
const ruleDetail = await getBusinessRuleInfo(ruleId);
|
||||
if (!ruleDetail) continue;
|
||||
const nodes: Record<string, any>[] = ruleDetail.nodes ?? [];
|
||||
// ★ 룰이 sourceTable을 포함하는지 확인 (table 타입 노드의 table_name 매칭)
|
||||
const involvesSourceTable = nodes.some((n) =>
|
||||
n.type === 'table' && (n.table_name === sourceTable || n.tableName === sourceTable)
|
||||
);
|
||||
if (!involvesSourceTable) continue;
|
||||
overlays.push({
|
||||
ruleName: rule.name ?? rule.NAME ?? ruleId,
|
||||
nodes,
|
||||
connections: ruleDetail.connections ?? [],
|
||||
});
|
||||
}
|
||||
} catch { /* 룰 조회 실패 시 관계만 표시 */ }
|
||||
setRuleOverlays(overlays);
|
||||
|
||||
// 흐름 계산 (★ table_relationships만 — 룰은 별도 오버레이)
|
||||
const result = showFlow(cardId, sourceTable, relations, { right: cardRight, centerY: cardCenterY }, canvasHeight);
|
||||
|
||||
// 테이블 메타 로드
|
||||
const tableNames = new Set<string>();
|
||||
result.edges.forEach((e) => {
|
||||
if (!e.to.startsWith('CARD:')) tableNames.add(e.to);
|
||||
if (!e.from.startsWith('CARD:')) tableNames.add(e.from);
|
||||
});
|
||||
|
||||
const metas: Record<string, { label: string; icon: string; columns: FieldConfig[] }> = {};
|
||||
await Promise.all(
|
||||
Array.from(tableNames).map(async (name) => {
|
||||
metas[name] = await loadTableMeta(name);
|
||||
})
|
||||
);
|
||||
setTableMetas(metas);
|
||||
|
||||
// 상태 업데이트
|
||||
setActiveFlowCard(cardId);
|
||||
setFlowEdges(result.edges);
|
||||
setTablePositions(result.positions);
|
||||
setAnimTimings(result.timings);
|
||||
|
||||
// 애니메이션: 순차 reveal
|
||||
const revealed = new Set<string>();
|
||||
setRevealedNodes(new Set());
|
||||
|
||||
animRef.current.forEach(clearTimeout);
|
||||
animRef.current = [];
|
||||
|
||||
result.timings.forEach(({ edge, nodeDelay }) => {
|
||||
const t = setTimeout(() => {
|
||||
if (!edge.to.startsWith('CARD:') && !revealed.has(edge.to)) {
|
||||
revealed.add(edge.to);
|
||||
setRevealedNodes(new Set(revealed));
|
||||
}
|
||||
}, nodeDelay);
|
||||
animRef.current.push(t);
|
||||
});
|
||||
}, [activeFlowCardId, cards, canvasRef, setActiveFlowCard, setFlowEdges, setTablePositions, showFlow]);
|
||||
|
||||
// 클릭 이벤트 위임
|
||||
useEffect(() => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
|
||||
const handler = (e: MouseEvent) => {
|
||||
const cardEl = (e.target as HTMLElement).closest('[data-card-id]') as HTMLElement;
|
||||
if (cardEl) {
|
||||
const id = cardEl.dataset.cardId;
|
||||
if (id) handleCardClick(id);
|
||||
return;
|
||||
}
|
||||
// 빈 영역 클릭 → 흐름 닫기
|
||||
if ((e.target as HTMLElement).closest('.tbl-node') ||
|
||||
(e.target as HTMLElement).closest('.ctrl-badge')) return;
|
||||
if (activeFlowCardId) clearFlow();
|
||||
};
|
||||
|
||||
cv.addEventListener('click', handler);
|
||||
return () => cv.removeEventListener('click', handler);
|
||||
}, [canvasRef, handleCardClick, activeFlowCardId]);
|
||||
|
||||
const clearFlow = useCallback(() => {
|
||||
animRef.current.forEach(clearTimeout);
|
||||
animRef.current = [];
|
||||
setActiveFlowCard(null);
|
||||
setFlowEdges([]);
|
||||
setTablePositions({});
|
||||
setAnimTimings([]);
|
||||
setRevealedNodes(new Set());
|
||||
setTableMetas({});
|
||||
setRuleOverlays([]);
|
||||
}, [setActiveFlowCard, setFlowEdges, setTablePositions]);
|
||||
|
||||
// 테이블 노드 드래그 → 위치 업데이트 + 선 재그리기
|
||||
const handleNodeMove = useCallback((name: string, x: number, y: number) => {
|
||||
setTablePositions({ ...tablePositions, [name]: { x, y } });
|
||||
}, [tablePositions, setTablePositions]);
|
||||
|
||||
if (!activeFlowCardId || flowEdges.length === 0) return null;
|
||||
|
||||
// 카드 위치 가져오기
|
||||
const getCardPos = (cardId: string) => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return { right: 0, centerY: 0 };
|
||||
const el = cv.querySelector(`[data-card-id="${cardId}"]`) as HTMLElement;
|
||||
if (!el) return { right: 0, centerY: 0 };
|
||||
return { right: el.offsetLeft + el.offsetWidth, centerY: el.offsetTop + el.offsetHeight / 2 };
|
||||
};
|
||||
|
||||
// 좌표 계산
|
||||
const getFromPos = (from: string) => {
|
||||
if (from.startsWith('CARD:')) {
|
||||
const cardId = from.split(':')[1];
|
||||
const pos = getCardPos(cardId);
|
||||
return { x: pos.right, y: pos.centerY };
|
||||
}
|
||||
const p = tablePositions[from];
|
||||
if (!p) return null;
|
||||
return { x: p.x + 200, y: p.y + 80 }; // 노드 우측 중앙
|
||||
};
|
||||
|
||||
const getToPos = (to: string) => {
|
||||
const p = tablePositions[to];
|
||||
if (!p) return null;
|
||||
return { x: p.x, y: p.y + 80 }; // 노드 좌측 중앙
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* SVG 연결선 */}
|
||||
<ConnectionSvg>
|
||||
{animTimings.map(({ edge, lineDelay }, idx) => {
|
||||
const from = getFromPos(edge.from);
|
||||
const to = getToPos(edge.to);
|
||||
if (!from || !to) return null;
|
||||
return (
|
||||
<FlowLine
|
||||
key={`line-${idx}`}
|
||||
x1={from.x} y1={from.y}
|
||||
x2={to.x} y2={to.y}
|
||||
type={edge.type}
|
||||
animate
|
||||
delay={lineDelay}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ConnectionSvg>
|
||||
|
||||
{/* 연결선 뱃지 */}
|
||||
{animTimings.map(({ edge, nodeDelay }, idx) => {
|
||||
const from = getFromPos(edge.from);
|
||||
const to = getToPos(edge.to);
|
||||
if (!from || !to) return null;
|
||||
const mx = (from.x + to.x) / 2;
|
||||
const my = (from.y + to.y) / 2;
|
||||
return (
|
||||
<FlowBadge
|
||||
key={`badge-${idx}`}
|
||||
x={mx} y={my}
|
||||
label={edge.label}
|
||||
type={edge.type}
|
||||
animate
|
||||
delay={nodeDelay}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 테이블 노드 (table_relationships 레이어) */}
|
||||
{Object.entries(tablePositions).map(([name, pos]) => {
|
||||
const meta = tableMetas[name];
|
||||
if (!meta) return null;
|
||||
const revealed = revealedNodes.has(name);
|
||||
return (
|
||||
<TableNode
|
||||
key={name}
|
||||
tableName={name}
|
||||
label={meta.label}
|
||||
icon={meta.icon}
|
||||
columns={meta.columns}
|
||||
x={pos.x}
|
||||
y={pos.y}
|
||||
onMove={handleNodeMove}
|
||||
style={{
|
||||
opacity: revealed ? 1 : 0,
|
||||
transform: revealed ? 'scale(1)' : 'scale(0.3)',
|
||||
transition: 'opacity .35s ease-out, transform .35s cubic-bezier(.16,1,.3,1)',
|
||||
pointerEvents: revealed ? 'auto' : 'none',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* ★ 비즈니스 룰 오버레이 (별도 레이어 — 저장된 노드 type별로 렌더) */}
|
||||
{ruleOverlays.map((overlay, oi) => (
|
||||
<div key={`rule-overlay-${oi}`}>
|
||||
{/* 룰 노드 → type별로 TableNode 또는 ControlNode (read-only) */}
|
||||
{overlay.nodes.map((node) => {
|
||||
// ★ type === 'table'이면 TableNode로 렌더
|
||||
if (node.type === 'table') {
|
||||
const tableName = node.table_name ?? node.tableName ?? node.label ?? node.id;
|
||||
const columns: FieldConfig[] = (node.columns ?? []).slice(0, 8);
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: node.x ?? 0,
|
||||
top: node.y ?? 0,
|
||||
opacity: 0.85,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<TableNode
|
||||
tableName={tableName}
|
||||
label={node.label ?? tableName}
|
||||
icon="🏢"
|
||||
columns={columns}
|
||||
x={0}
|
||||
y={0}
|
||||
onMove={() => {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// ★ 그 외는 제어 노드 (CTRL_NODE_TYPES)
|
||||
const nodeType = node.type ?? 'auto-insert';
|
||||
const typeDef = (CTRL_NODE_TYPES as Record<string, any>)[nodeType];
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
className="ctrl-action-node"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: node.x ?? 0,
|
||||
top: node.y ?? 0,
|
||||
width: 160,
|
||||
opacity: 0.85,
|
||||
pointerEvents: 'none',
|
||||
// @ts-ignore
|
||||
'--na-rgb': typeDef?.rgb ?? '108,92,231',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div className="ctrl-an-head" style={{ cursor: 'default' }}>
|
||||
<div className="ctrl-an-icon">{typeDef?.icon ?? '⚡'}</div>
|
||||
<span className="ctrl-an-name">{typeDef?.label ?? nodeType}</span>
|
||||
</div>
|
||||
<div className="ctrl-an-body">
|
||||
<div className="ctrl-an-summary" style={{ fontSize: '.45rem', color: 'var(--v5-text-muted)' }}>
|
||||
[{overlay.ruleName}]
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 룰 연결선 (SVG) — RuleBuilder.portPos와 동일한 앵커 계산 */}
|
||||
<ConnectionSvg>
|
||||
{overlay.connections.map((conn, ci) => {
|
||||
const fromNode = overlay.nodes.find((n) => n.id === (conn.from_node_id ?? conn.fromNodeId));
|
||||
const toNode = overlay.nodes.find((n) => n.id === (conn.to_node_id ?? conn.toNodeId));
|
||||
if (!fromNode || !toNode) return null;
|
||||
|
||||
const fromPort = conn.from_port ?? conn.fromPort ?? 'out';
|
||||
const toPort = conn.to_port ?? conn.toPort ?? 'in';
|
||||
const f = computePortPos(fromNode, fromPort);
|
||||
const t = computePortPos(toNode, toPort);
|
||||
if (!f || !t) return null;
|
||||
|
||||
const lineType = fromPort === 'yes' ? 'auto' : fromPort === 'no' ? 'cond' : 'auto';
|
||||
return (
|
||||
<FlowLine
|
||||
key={`rule-line-${oi}-${ci}`}
|
||||
x1={f.x} y1={f.y} x2={t.x} y2={t.y}
|
||||
type={lineType}
|
||||
animate={false}
|
||||
delay={0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ConnectionSvg>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
|
||||
|
||||
/**
|
||||
* 노드 설정 팝오버 (mockup showNodeConfig/_buildCfgForm 포팅)
|
||||
* 노드 타입별 설정 폼
|
||||
*/
|
||||
export function NodeConfigPopover() {
|
||||
const { configNodeId, ruleNodes, setConfigNodeId, updateRuleNode } = useControlMode();
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const node = configNodeId ? ruleNodes.find((n) => n.id === configNodeId) : null;
|
||||
const def = node ? CTRL_NODE_TYPES[node.type] : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (configNodeId && node) {
|
||||
requestAnimationFrame(() => setOpen(true));
|
||||
} else {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [configNodeId, node]);
|
||||
|
||||
// 외부 클릭 닫기
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!configNodeId) return;
|
||||
if ((e.target as HTMLElement).closest('.ctrl-cfg-pop')) return;
|
||||
if ((e.target as HTMLElement).closest('.ctrl-an-body')) return;
|
||||
setConfigNodeId(null);
|
||||
};
|
||||
document.addEventListener('click', handler);
|
||||
return () => document.removeEventListener('click', handler);
|
||||
}, [configNodeId, setConfigNodeId]);
|
||||
|
||||
if (!node || !def) return null;
|
||||
|
||||
const handleSave = (summary: string, config: Record<string, any>) => {
|
||||
updateRuleNode(node.id, { config: { ...node.config, ...config, summary } });
|
||||
setConfigNodeId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popRef}
|
||||
className={`ctrl-cfg-pop${open ? ' open' : ''}`}
|
||||
style={{ left: node.x + 172, top: node.y }}
|
||||
>
|
||||
<div className="cfg-hd">{def.icon} {def.label} 설정</div>
|
||||
<ConfigForm type={node.type} config={node.config ?? {}} onSave={handleSave} onClose={() => setConfigNodeId(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigForm({ type, config, onSave, onClose }: {
|
||||
type: string; config: Record<string, any>;
|
||||
onSave: (summary: string, config: Record<string, any>) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [vals, setVals] = useState<Record<string, any>>(config);
|
||||
const set = (k: string, v: any) => setVals((p) => ({ ...p, [k]: v }));
|
||||
|
||||
const handleSave = () => {
|
||||
let summary = '';
|
||||
switch (type) {
|
||||
case 'condition':
|
||||
summary = `${vals.field || '?'} ${vals.op || '='} "${vals.value || '?'}"`;
|
||||
break;
|
||||
case 'status-change':
|
||||
summary = `${vals.table || '?'}.${vals.field || 'STATUS'} → "${vals.value || '?'}"`;
|
||||
break;
|
||||
case 'auto-insert':
|
||||
summary = `→ ${vals.table || '?'} INSERT`;
|
||||
break;
|
||||
case 'timer':
|
||||
summary = `${vals.field || '?'} +${vals.amount || 0}${vals.unit || '일'} 경과`;
|
||||
break;
|
||||
case 'notification':
|
||||
summary = `${vals.channel || '이메일'} → ${vals.target || '담당자'}`;
|
||||
break;
|
||||
case 'approval':
|
||||
summary = `${vals.approver || '팀장'} 승인 (${vals.condition || ''})`;
|
||||
break;
|
||||
case 'calculation':
|
||||
summary = `${vals.table || '?'}.${vals.field || '?'} = ${vals.formula || '?'}`;
|
||||
break;
|
||||
case 'webhook':
|
||||
summary = `${vals.method || 'POST'} ${(vals.url || '').slice(0, 25)}...`;
|
||||
break;
|
||||
case 'validation':
|
||||
summary = `${vals.field || '?'} ${vals.rule || '필수값'}`;
|
||||
break;
|
||||
case 'log':
|
||||
summary = `로그: ${vals.content || '?'}`;
|
||||
break;
|
||||
default:
|
||||
summary = vals.summary || '설정됨';
|
||||
}
|
||||
onSave(summary, vals);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderFields(type, vals, set)}
|
||||
<div className="cfg-ft">
|
||||
<button className="cfg-btn save" onClick={handleSave}>저장</button>
|
||||
<button className="cfg-btn" onClick={onClose}>닫기</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFields(
|
||||
type: string,
|
||||
vals: Record<string, any>,
|
||||
set: (k: string, v: any) => void
|
||||
) {
|
||||
switch (type) {
|
||||
case 'condition':
|
||||
return (
|
||||
<>
|
||||
<CfgSec label="필드">
|
||||
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="STATUS" />
|
||||
</CfgSec>
|
||||
<CfgSec label="연산자">
|
||||
<CfgSelect value={vals.op ?? '='} onChange={(v) => set('op', v)}
|
||||
options={['=', '≠', '>', '<', '기한 경과', '포함']} />
|
||||
</CfgSec>
|
||||
<CfgSec label="값">
|
||||
<CfgInput value={vals.value ?? ''} onChange={(v) => set('value', v)} placeholder="비교값" />
|
||||
</CfgSec>
|
||||
</>
|
||||
);
|
||||
case 'status-change':
|
||||
return (
|
||||
<>
|
||||
<CfgSec label="대상 테이블">
|
||||
<CfgInput value={vals.table ?? ''} onChange={(v) => set('table', v)} placeholder="테이블명" />
|
||||
</CfgSec>
|
||||
<CfgSec label="변경 필드">
|
||||
<CfgInput value={vals.field ?? 'STATUS'} onChange={(v) => set('field', v)} />
|
||||
</CfgSec>
|
||||
<CfgSec label="변경값">
|
||||
<CfgInput value={vals.value ?? ''} onChange={(v) => set('value', v)} placeholder="새 값" />
|
||||
</CfgSec>
|
||||
</>
|
||||
);
|
||||
case 'auto-insert':
|
||||
return (
|
||||
<CfgSec label="대상 테이블">
|
||||
<CfgInput value={vals.table ?? ''} onChange={(v) => set('table', v)} placeholder="테이블명" />
|
||||
</CfgSec>
|
||||
);
|
||||
case 'timer':
|
||||
return (
|
||||
<>
|
||||
<CfgSec label="기준 필드">
|
||||
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="ORDER_DATE" />
|
||||
</CfgSec>
|
||||
<CfgSec label="경과 기준">
|
||||
<div style={{ display: 'flex', gap: '.3rem' }}>
|
||||
<CfgInput value={vals.amount ?? '0'} onChange={(v) => set('amount', v)} placeholder="0" />
|
||||
<CfgSelect value={vals.unit ?? '일'} onChange={(v) => set('unit', v)} options={['일', '시간', '주']} />
|
||||
</div>
|
||||
</CfgSec>
|
||||
</>
|
||||
);
|
||||
case 'notification':
|
||||
return (
|
||||
<>
|
||||
<CfgSec label="채널">
|
||||
<CfgSelect value={vals.channel ?? '이메일'} onChange={(v) => set('channel', v)}
|
||||
options={['이메일', 'SMS', '푸시', 'Slack']} />
|
||||
</CfgSec>
|
||||
<CfgSec label="수신자">
|
||||
<CfgSelect value={vals.target ?? '담당자'} onChange={(v) => set('target', v)}
|
||||
options={['담당자', '관리자', '전체']} />
|
||||
</CfgSec>
|
||||
<CfgSec label="메시지">
|
||||
<textarea className="cfg-ta" rows={2} value={vals.message ?? ''}
|
||||
onChange={(e) => set('message', e.target.value)} />
|
||||
</CfgSec>
|
||||
</>
|
||||
);
|
||||
case 'approval':
|
||||
return (
|
||||
<>
|
||||
<CfgSec label="승인자">
|
||||
<CfgSelect value={vals.approver ?? '팀장'} onChange={(v) => set('approver', v)}
|
||||
options={['팀장', '부서장', '관리자', '지정 사용자']} />
|
||||
</CfgSec>
|
||||
<CfgSec label="승인 조건">
|
||||
<CfgInput value={vals.condition ?? ''} onChange={(v) => set('condition', v)} placeholder="조건식" />
|
||||
</CfgSec>
|
||||
</>
|
||||
);
|
||||
case 'calculation':
|
||||
return (
|
||||
<>
|
||||
<CfgSec label="대상 테이블">
|
||||
<CfgInput value={vals.table ?? ''} onChange={(v) => set('table', v)} placeholder="테이블명" />
|
||||
</CfgSec>
|
||||
<CfgSec label="결과 필드">
|
||||
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="필드명" />
|
||||
</CfgSec>
|
||||
<CfgSec label="수식">
|
||||
<CfgInput value={vals.formula ?? ''} onChange={(v) => set('formula', v)} placeholder="QTY * UNIT_PRICE" />
|
||||
</CfgSec>
|
||||
</>
|
||||
);
|
||||
case 'webhook':
|
||||
return (
|
||||
<>
|
||||
<CfgSec label="URL">
|
||||
<CfgInput value={vals.url ?? ''} onChange={(v) => set('url', v)} placeholder="https://..." />
|
||||
</CfgSec>
|
||||
<CfgSec label="메서드">
|
||||
<CfgSelect value={vals.method ?? 'POST'} onChange={(v) => set('method', v)}
|
||||
options={['POST', 'GET', 'PUT', 'DELETE']} />
|
||||
</CfgSec>
|
||||
</>
|
||||
);
|
||||
case 'validation':
|
||||
return (
|
||||
<>
|
||||
<CfgSec label="대상 필드">
|
||||
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="필드명" />
|
||||
</CfgSec>
|
||||
<CfgSec label="검증 규칙">
|
||||
<CfgSelect value={vals.rule ?? '필수값 (NOT NULL)'} onChange={(v) => set('rule', v)}
|
||||
options={['필수값 (NOT NULL)', '범위 체크', '정규식 매칭', '참조 무결성', '커스텀 조건']} />
|
||||
</CfgSec>
|
||||
</>
|
||||
);
|
||||
case 'log':
|
||||
return (
|
||||
<CfgSec label="내용">
|
||||
<CfgInput value={vals.content ?? ''} onChange={(v) => set('content', v)} placeholder="액션 설명" />
|
||||
</CfgSec>
|
||||
);
|
||||
default:
|
||||
return <div className="cfg-sec" style={{ color: 'var(--v5-text-muted)', fontSize: '.55rem' }}>설정 없음</div>;
|
||||
}
|
||||
}
|
||||
|
||||
function CfgSec({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="cfg-sec">
|
||||
<label className="cfg-lb">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CfgInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) {
|
||||
return (
|
||||
<input className="cfg-inp" value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} />
|
||||
);
|
||||
}
|
||||
|
||||
function CfgSelect({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: string[] }) {
|
||||
return (
|
||||
<select className="cfg-sel" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
{options.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* I/O 포트 핸들 — 노드 양쪽 원형 (드래그 연결 시작/끝점)
|
||||
* mockup initPortEvents 포팅
|
||||
*/
|
||||
|
||||
interface PortHandleProps {
|
||||
nodeId: string;
|
||||
port: string;
|
||||
type: 'in' | 'out';
|
||||
cls?: string;
|
||||
label?: string;
|
||||
isTable?: boolean;
|
||||
onDragStart?: (nodeId: string, port: string, e: React.MouseEvent) => void;
|
||||
onDragEnd?: (nodeId: string, port: string) => void;
|
||||
}
|
||||
|
||||
export function PortHandle({ nodeId, port, type, cls, label, isTable, onDragStart, onDragEnd }: PortHandleProps) {
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (type !== 'out' || !onDragStart) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDragStart(nodeId, port, e);
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: React.MouseEvent) => {
|
||||
if (type !== 'in' || !onDragEnd) return;
|
||||
e.stopPropagation();
|
||||
onDragEnd(nodeId, port);
|
||||
};
|
||||
|
||||
const className = [
|
||||
'ctrl-io-port',
|
||||
`port-${type}`,
|
||||
cls ?? '',
|
||||
isTable ? 'tbl-io' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
data-node={nodeId}
|
||||
data-port={port}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
{label && <span className="port-label">{label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useControlMode, genNodeId, CTRL_NODE_TYPES } from './hooks/useControlMode';
|
||||
import { usePortDrag } from './hooks/usePortDrag';
|
||||
import { ControlNode } from './ControlNode';
|
||||
import { TableNode } from './TableNode';
|
||||
import { PortHandle } from './PortHandle';
|
||||
import { NodeConfigPopover } from './NodeConfigPopover';
|
||||
import { bezierPath } from './ConnectionLine';
|
||||
import { getMetaFields } from '@/lib/api/meta';
|
||||
import type { FieldConfig } from '@/types/invyone-component';
|
||||
|
||||
interface RuleBuilderProps {
|
||||
canvasRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
/** 테이블 필드 캐시 */
|
||||
const fieldCache: Record<string, FieldConfig[]> = {};
|
||||
|
||||
/**
|
||||
* 규칙 빌더 — 편집 모드
|
||||
* mockup initRuleBuilder/dropTable/dropControl 포팅
|
||||
*/
|
||||
export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
|
||||
const {
|
||||
ruleNodes,
|
||||
ruleConnections,
|
||||
addRuleNode,
|
||||
moveRuleNode,
|
||||
} = useControlMode();
|
||||
|
||||
const { startDrag, finishDrag } = usePortDrag(canvasRef);
|
||||
|
||||
// 캔버스 드롭 처리
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
let d: Record<string, any>;
|
||||
try { d = JSON.parse(e.dataTransfer.getData('text/plain')); } catch { return; }
|
||||
if (!d?.kind) return;
|
||||
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
const r = cv.getBoundingClientRect();
|
||||
const x = e.clientX - r.left + cv.scrollLeft;
|
||||
const y = e.clientY - r.top + cv.scrollTop;
|
||||
|
||||
if (d.kind === 'table') {
|
||||
// 중복 방지
|
||||
if (ruleNodes.find((n) => n.type === 'table' && n.table_name === d.name)) return;
|
||||
|
||||
// 필드 로드
|
||||
let cols: FieldConfig[] = [];
|
||||
if (fieldCache[d.name]) {
|
||||
cols = fieldCache[d.name];
|
||||
} else {
|
||||
try {
|
||||
const meta = await getMetaFields(d.name);
|
||||
cols = (meta.fields ?? []).filter((f: FieldConfig) => !f.system).slice(0, 8);
|
||||
fieldCache[d.name] = cols;
|
||||
} catch { /* 빈 필드 */ }
|
||||
}
|
||||
|
||||
addRuleNode({
|
||||
id: genNodeId('tbl'),
|
||||
type: 'table',
|
||||
table_name: d.name,
|
||||
label: d.name,
|
||||
x: x - 100,
|
||||
y: y - 40,
|
||||
columns: cols,
|
||||
});
|
||||
} else if (d.kind === 'control' && CTRL_NODE_TYPES[d.type]) {
|
||||
addRuleNode({
|
||||
id: genNodeId('ctrl'),
|
||||
type: d.type,
|
||||
label: CTRL_NODE_TYPES[d.type].label,
|
||||
x: x - 80,
|
||||
y: y - 30,
|
||||
config: {},
|
||||
});
|
||||
}
|
||||
}, [canvasRef, ruleNodes, addRuleNode]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}, []);
|
||||
|
||||
// 노드 좌표에서 포트 위치 계산
|
||||
const portPos = useCallback((nodeId: string, port: string) => {
|
||||
const node = ruleNodes.find((n) => n.id === nodeId);
|
||||
if (!node) return null;
|
||||
|
||||
if (node.type === 'table') {
|
||||
if (port === 'in') return { x: node.x, y: node.y + 18 };
|
||||
return { x: node.x + 200, y: node.y + 18 };
|
||||
}
|
||||
|
||||
// 제어 노드
|
||||
if (port === 'in') return { x: node.x, y: node.y + 40 };
|
||||
|
||||
// output 포트 — 타입별 위치
|
||||
const def = CTRL_NODE_TYPES[node.type];
|
||||
const outPorts = def?.out || [{ port: 'out', label: '→', cls: '' }];
|
||||
const idx = outPorts.findIndex((p) => p.port === port);
|
||||
const total = outPorts.length;
|
||||
const nodeH = 80;
|
||||
const centerY = node.y + nodeH / 2;
|
||||
const gap = 8;
|
||||
const startY = centerY - ((total - 1) * gap) / 2;
|
||||
|
||||
return { x: node.x + 160, y: startY + idx * gap };
|
||||
}, [ruleNodes]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 드롭존 (캔버스 전체에 이벤트 걸기 위한 투명 레이어) */}
|
||||
<div
|
||||
style={{ position: 'absolute', inset: 0, zIndex: 5 }}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
/>
|
||||
|
||||
{/* 연결선 SVG */}
|
||||
<svg className="ctrl-svg" id="rule-svg" width="100%" height="100%" style={{ overflow: 'visible' }}>
|
||||
<defs>
|
||||
<marker id="arr-rule" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-cyan)" opacity=".8" />
|
||||
</marker>
|
||||
<marker id="arr-yes" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-green)" opacity=".8" />
|
||||
</marker>
|
||||
<marker id="arr-no" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0,8 3,0 6" fill="var(--v5-text-muted, #888)" opacity=".5" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{ruleConnections.map((c) => {
|
||||
const f = portPos(c.from_node_id, c.from_port);
|
||||
const t = portPos(c.to_node_id, c.to_port);
|
||||
if (!f || !t) return null;
|
||||
|
||||
const cls = c.from_port === 'yes' ? 'rule-conn-path conn-yes'
|
||||
: c.from_port === 'no' ? 'rule-conn-path conn-no'
|
||||
: 'rule-conn-path';
|
||||
const marker = c.from_port === 'yes' ? 'url(#arr-yes)'
|
||||
: c.from_port === 'no' ? 'url(#arr-no)'
|
||||
: 'url(#arr-rule)';
|
||||
|
||||
return (
|
||||
<path
|
||||
key={c.id}
|
||||
d={bezierPath(f.x, f.y, t.x, t.y)}
|
||||
className={cls}
|
||||
markerEnd={marker}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* 연결 삭제 뱃지 */}
|
||||
{ruleConnections.map((c) => {
|
||||
const f = portPos(c.from_node_id, c.from_port);
|
||||
const t = portPos(c.to_node_id, c.to_port);
|
||||
if (!f || !t) return null;
|
||||
const mx = (f.x + t.x) / 2, my = (f.y + t.y) / 2;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`badge-${c.id}`}
|
||||
className="rule-conn-badge"
|
||||
style={{ left: mx, top: my }}
|
||||
>
|
||||
<span
|
||||
className="conn-x"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
useControlMode.getState().removeRuleConnection(c.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 노드 렌더 */}
|
||||
{ruleNodes.map((node) => {
|
||||
if (node.type === 'table') {
|
||||
return (
|
||||
<div key={node.id} style={{ position: 'absolute', left: 0, top: 0 }}>
|
||||
<TableNode
|
||||
tableName={node.table_name}
|
||||
label={node.label}
|
||||
icon="🏢"
|
||||
columns={node.columns ?? []}
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
onMove={(_, x, y) => moveRuleNode(node.id, x, y)}
|
||||
style={{ overflow: 'visible' }}
|
||||
/>
|
||||
{/* I/O 포트 */}
|
||||
<PortHandle nodeId={node.id} port="in" type="in" isTable onDragEnd={finishDrag} />
|
||||
<div style={{ position: 'absolute', left: node.x + 194, top: node.y + 12 }}>
|
||||
<PortHandle nodeId={node.id} port="out" type="out" isTable label="→" onDragStart={startDrag} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
onDragStart={startDrag}
|
||||
onDragEnd={finishDrag}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 설정 팝오버 */}
|
||||
<NodeConfigPopover />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useCallback } from 'react';
|
||||
|
||||
interface TableNodeProps {
|
||||
tableName: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
columns: Record<string, any>[];
|
||||
x: number;
|
||||
y: number;
|
||||
style?: React.CSSProperties;
|
||||
onMove?: (name: string, x: number, y: number) => void;
|
||||
}
|
||||
|
||||
export function TableNode({ tableName, label, icon, columns, x, y, style, onMove }: TableNodeProps) {
|
||||
const nodeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (!onMove) return;
|
||||
e.preventDefault();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const sl = x, st = y;
|
||||
const el = nodeRef.current;
|
||||
if (el) el.style.zIndex = '30';
|
||||
|
||||
const move = (ev: MouseEvent) => {
|
||||
onMove(tableName, sl + ev.clientX - sx, st + ev.clientY - sy);
|
||||
};
|
||||
const up = () => {
|
||||
if (el) el.style.zIndex = '20';
|
||||
document.removeEventListener('mousemove', move);
|
||||
document.removeEventListener('mouseup', up);
|
||||
};
|
||||
document.addEventListener('mousemove', move);
|
||||
document.addEventListener('mouseup', up);
|
||||
}, [onMove, tableName, x, y]);
|
||||
|
||||
const dtypeIcons: Record<string, string> = {
|
||||
text: 'Aa', number: '#', date: '📅', select: '▼', checkbox: '☑', file: '📎', code: '⚡',
|
||||
textarea: 'Aa', datetime: '📅', entity: '🔗',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={nodeRef}
|
||||
className="tbl-node"
|
||||
data-table={tableName}
|
||||
style={{ left: x, top: y, ...style }}
|
||||
>
|
||||
<div className="tbl-node-head" onMouseDown={handleMouseDown}>
|
||||
<div className="tbl-icon">{icon}</div>
|
||||
<span className="tbl-name">{tableName}</span>
|
||||
<span className="tbl-badge">{label}</span>
|
||||
</div>
|
||||
<div className="tbl-node-cols">
|
||||
{columns.map((col) => {
|
||||
const name = col.column ?? col.name ?? col.COLUMN_NAME ?? '';
|
||||
const type = col.type ?? col.dtype ?? 'text';
|
||||
const mark = col.pk ? 'PK' : col.mark === 'FK' ? 'FK' : '';
|
||||
const portCls = mark === 'PK' ? 'pk' : mark === 'FK' ? 'fk' : '';
|
||||
const displayName = col.label ?? col.dname ?? name;
|
||||
const dtIcon = dtypeIcons[type] || 'Aa';
|
||||
|
||||
return (
|
||||
<div key={name} className="tbl-col" data-col={name}>
|
||||
<div className={`tbl-port ${portCls}`} />
|
||||
<span className="tbl-col-name">{displayName}</span>
|
||||
<span className="tbl-col-type">{dtIcon} {type}</span>
|
||||
{mark && <span className={`tbl-col-mark ${mark.toLowerCase()}`}>{mark}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
|
||||
/**
|
||||
* 제어 노드 16종 정의 (mockup CTRL_NODE_TYPES)
|
||||
* out: 커스텀 출력 포트. 없으면 기본 [{port:'out', label:'→'}]
|
||||
*/
|
||||
export const CTRL_NODE_TYPES: Record<string, {
|
||||
icon: string; label: string; rgb: string; cat: string;
|
||||
out?: { port: string; label: string; cls: string }[];
|
||||
}> = {
|
||||
'timer': { icon: '⏱', label: '타이머', rgb: '0,206,201', cat: '트리거' },
|
||||
'condition': { icon: '◇', label: '조건분기', rgb: '253,203,110', cat: '조건',
|
||||
out: [{ port: 'yes', label: 'Y', cls: 'port-yes' }, { port: 'no', label: 'N', cls: 'port-no' }] },
|
||||
'validation': { icon: '✔', label: '데이터 검증', rgb: '255,107,129', cat: '조건',
|
||||
out: [{ port: 'pass', label: '✓', cls: 'port-yes' }, { port: 'fail', label: '✗', cls: 'port-no' }] },
|
||||
'status-change': { icon: '🔄', label: '상태 변경', rgb: '108,92,231', cat: '액션' },
|
||||
'auto-insert': { icon: '📝', label: '자동 등록', rgb: '85,239,196', cat: '액션' },
|
||||
'calculation': { icon: '🧮', label: '계산/수식', rgb: '45,152,218', cat: '액션' },
|
||||
'delete': { icon: '🗑', label: '삭제/보관', rgb: '255,71,87', cat: '액션' },
|
||||
'document': { icon: '📄', label: '문서 생성', rgb: '162,155,254', cat: '액션' },
|
||||
'approval': { icon: '✋', label: '승인/결재', rgb: '255,165,2', cat: '흐름',
|
||||
out: [{ port: 'approved', label: '✓', cls: 'port-yes' }, { port: 'rejected', label: '✗', cls: 'port-no' }] },
|
||||
'delay': { icon: '⏳', label: '대기/지연', rgb: '72,219,251', cat: '흐름' },
|
||||
'loop': { icon: '🔁', label: '반복', rgb: '223,142,254', cat: '흐름',
|
||||
out: [{ port: 'each', label: '→', cls: '' }, { port: 'done', label: '✓', cls: 'port-yes' }] },
|
||||
'parallel': { icon: '🔀', label: '병렬 실행', rgb: '0,206,201', cat: '흐름' },
|
||||
'merge': { icon: '⤵', label: '병합/합류', rgb: '149,175,192', cat: '흐름' },
|
||||
'webhook': { icon: '🌐', label: '외부 호출', rgb: '116,185,255', cat: '연동' },
|
||||
'notification': { icon: '📨', label: '알림 발송', rgb: '253,121,168', cat: '연동' },
|
||||
'log': { icon: '📜', label: '로그 기록', rgb: '150,150,160', cat: '기록' },
|
||||
};
|
||||
|
||||
interface ControlModeState {
|
||||
/** 제어 모드 활성 여부 */
|
||||
active: boolean;
|
||||
/** 읽기 / 편집 모드 */
|
||||
mode: 'view' | 'edit';
|
||||
/** 활성 흐름 — 클릭된 카드 ID */
|
||||
activeFlowCardId: string | null;
|
||||
/** 흐름 엣지 배열 (BFS 결과) */
|
||||
flowEdges: Record<string, any>[];
|
||||
/** 테이블 노드 위치 */
|
||||
tablePositions: Record<string, { x: number; y: number }>;
|
||||
|
||||
/** 규칙 빌더 — 노드 */
|
||||
ruleNodes: Record<string, any>[];
|
||||
/** 규칙 빌더 — 연결 */
|
||||
ruleConnections: Record<string, any>[];
|
||||
/** 현재 편집 중인 룰 ID */
|
||||
activeRuleId: string | null;
|
||||
|
||||
/** 설정 팝오버 대상 노드 ID */
|
||||
configNodeId: string | null;
|
||||
|
||||
// 액션
|
||||
toggleControlMode: () => void;
|
||||
setMode: (mode: 'view' | 'edit') => void;
|
||||
setActiveFlowCard: (cardId: string | null) => void;
|
||||
setFlowEdges: (edges: Record<string, any>[]) => void;
|
||||
setTablePositions: (pos: Record<string, { x: number; y: number }>) => void;
|
||||
setRuleNodes: (nodes: Record<string, any>[]) => void;
|
||||
addRuleNode: (node: Record<string, any>) => void;
|
||||
updateRuleNode: (nodeId: string, updates: Record<string, any>) => void;
|
||||
removeRuleNode: (nodeId: string) => void;
|
||||
moveRuleNode: (nodeId: string, x: number, y: number) => void;
|
||||
setRuleConnections: (conns: Record<string, any>[]) => void;
|
||||
addRuleConnection: (conn: Record<string, any>) => void;
|
||||
removeRuleConnection: (connId: string) => void;
|
||||
setActiveRuleId: (ruleId: string | null) => void;
|
||||
setConfigNodeId: (nodeId: string | null) => void;
|
||||
resetControlMode: () => void;
|
||||
}
|
||||
|
||||
let _nodeSeq = 0;
|
||||
export function genNodeId(prefix: string) { return `${prefix}-${++_nodeSeq}`; }
|
||||
let _connSeq = 0;
|
||||
export function genConnId() { return `conn-${++_connSeq}`; }
|
||||
|
||||
export const useControlMode = create<ControlModeState>()(
|
||||
devtools(
|
||||
(set) => ({
|
||||
active: false,
|
||||
mode: 'view',
|
||||
activeFlowCardId: null,
|
||||
flowEdges: [],
|
||||
tablePositions: {},
|
||||
ruleNodes: [],
|
||||
ruleConnections: [],
|
||||
activeRuleId: null,
|
||||
configNodeId: null,
|
||||
|
||||
toggleControlMode: () =>
|
||||
set((s) => ({
|
||||
active: !s.active,
|
||||
mode: 'view',
|
||||
activeFlowCardId: null,
|
||||
flowEdges: [],
|
||||
tablePositions: {},
|
||||
configNodeId: null,
|
||||
})),
|
||||
|
||||
setMode: (mode) => set({ mode, configNodeId: null }),
|
||||
|
||||
setActiveFlowCard: (cardId) => set({ activeFlowCardId: cardId }),
|
||||
|
||||
setFlowEdges: (edges) => set({ flowEdges: edges }),
|
||||
|
||||
setTablePositions: (pos) => set({ tablePositions: pos }),
|
||||
|
||||
setRuleNodes: (nodes) => set({ ruleNodes: nodes }),
|
||||
|
||||
addRuleNode: (node) => set((s) => ({ ruleNodes: [...s.ruleNodes, node] })),
|
||||
|
||||
updateRuleNode: (nodeId, updates) =>
|
||||
set((s) => ({
|
||||
ruleNodes: s.ruleNodes.map((n) =>
|
||||
n.id === nodeId ? { ...n, ...updates } : n
|
||||
),
|
||||
})),
|
||||
|
||||
removeRuleNode: (nodeId) =>
|
||||
set((s) => ({
|
||||
ruleNodes: s.ruleNodes.filter((n) => n.id !== nodeId),
|
||||
ruleConnections: s.ruleConnections.filter(
|
||||
(c) => c.from_node_id !== nodeId && c.to_node_id !== nodeId
|
||||
),
|
||||
})),
|
||||
|
||||
moveRuleNode: (nodeId, x, y) =>
|
||||
set((s) => ({
|
||||
ruleNodes: s.ruleNodes.map((n) =>
|
||||
n.id === nodeId ? { ...n, x, y } : n
|
||||
),
|
||||
})),
|
||||
|
||||
setRuleConnections: (conns) => set({ ruleConnections: conns }),
|
||||
|
||||
addRuleConnection: (conn) =>
|
||||
set((s) => ({ ruleConnections: [...s.ruleConnections, conn] })),
|
||||
|
||||
removeRuleConnection: (connId) =>
|
||||
set((s) => ({
|
||||
ruleConnections: s.ruleConnections.filter((c) => c.id !== connId),
|
||||
})),
|
||||
|
||||
setActiveRuleId: (ruleId) => set({ activeRuleId: ruleId }),
|
||||
|
||||
setConfigNodeId: (nodeId) => set({ configNodeId: nodeId }),
|
||||
|
||||
resetControlMode: () =>
|
||||
set({
|
||||
active: false,
|
||||
mode: 'view',
|
||||
activeFlowCardId: null,
|
||||
flowEdges: [],
|
||||
tablePositions: {},
|
||||
ruleNodes: [],
|
||||
ruleConnections: [],
|
||||
activeRuleId: null,
|
||||
configNodeId: null,
|
||||
}),
|
||||
}),
|
||||
{ name: 'control-mode-store' }
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* 트리 확산 애니메이션용 위치 계산 + 타이밍 (mockup calcFlowPositions 포팅)
|
||||
*
|
||||
* 카드 우측에서 depth별로 트리 형태 배치
|
||||
* depth별 시간 지연으로 선 → 노드 순차 등장
|
||||
*/
|
||||
|
||||
interface FlowEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
type: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* BFS로 카드에서 도달 가능한 전체 체인 계산
|
||||
* ★ 양방향 탐색: outgoing (from===cur) + incoming (to===cur)
|
||||
* → inbound 관계도 놓치지 않음
|
||||
*/
|
||||
export function buildFlowChain(
|
||||
rootKey: string,
|
||||
allEdges: FlowEdge[]
|
||||
): { edges: FlowEdge[]; depths: Record<string, number> } {
|
||||
const reachable = new Set([rootKey]);
|
||||
const queue = [rootKey];
|
||||
|
||||
while (queue.length) {
|
||||
const cur = queue.shift()!;
|
||||
// ★ outgoing: from === cur
|
||||
allEdges
|
||||
.filter((e) => e.from === cur)
|
||||
.forEach((e) => {
|
||||
if (!reachable.has(e.to)) {
|
||||
reachable.add(e.to);
|
||||
queue.push(e.to);
|
||||
}
|
||||
});
|
||||
// ★ incoming: to === cur (양방향 탐색)
|
||||
allEdges
|
||||
.filter((e) => e.to === cur)
|
||||
.forEach((e) => {
|
||||
if (!reachable.has(e.from)) {
|
||||
reachable.add(e.from);
|
||||
queue.push(e.from);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const edges = allEdges.filter((e) => reachable.has(e.from) && reachable.has(e.to));
|
||||
|
||||
// depth 계산 (양방향)
|
||||
const depths: Record<string, number> = { [rootKey]: 0 };
|
||||
const q2 = [rootKey];
|
||||
while (q2.length) {
|
||||
const cur = q2.shift()!;
|
||||
// outgoing
|
||||
edges
|
||||
.filter((e) => e.from === cur)
|
||||
.forEach((e) => {
|
||||
if (depths[e.to] === undefined) {
|
||||
depths[e.to] = depths[cur] + 1;
|
||||
q2.push(e.to);
|
||||
}
|
||||
});
|
||||
// incoming
|
||||
edges
|
||||
.filter((e) => e.to === cur)
|
||||
.forEach((e) => {
|
||||
if (depths[e.from] === undefined) {
|
||||
depths[e.from] = depths[cur] + 1;
|
||||
q2.push(e.from);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { edges, depths };
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 우측에 테이블 노드를 트리 형태로 배치
|
||||
*/
|
||||
export function calcFlowPositions(
|
||||
cardRight: number,
|
||||
cardCenterY: number,
|
||||
canvasHeight: number,
|
||||
depths: Record<string, number>
|
||||
): Record<string, { x: number; y: number }> {
|
||||
const startX = cardRight + 80;
|
||||
|
||||
// depth별 노드 그룹핑 (CARD: 제외)
|
||||
const depthNodes: Record<number, string[]> = {};
|
||||
Object.entries(depths).forEach(([name, d]) => {
|
||||
if (name.startsWith('CARD:')) return;
|
||||
if (!depthNodes[d]) depthNodes[d] = [];
|
||||
depthNodes[d].push(name);
|
||||
});
|
||||
|
||||
const maxD = Math.max(1, ...Object.keys(depthNodes).map(Number));
|
||||
const colGap = Math.max(270, Math.min(350, (1200 - startX - 230) / maxD));
|
||||
const rowGap = 240;
|
||||
|
||||
const pos: Record<string, { x: number; y: number }> = {};
|
||||
Object.entries(depthNodes).forEach(([dStr, nodes]) => {
|
||||
const di = parseInt(dStr);
|
||||
const totalH = nodes.length * rowGap;
|
||||
const startY = Math.max(20, (canvasHeight - totalH) / 2);
|
||||
nodes.forEach((name, i) => {
|
||||
pos[name] = {
|
||||
x: startX + (di - 1) * colGap,
|
||||
y: startY + i * rowGap,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
/**
|
||||
* depth별 애니메이션 타이밍 계산
|
||||
* 선 → 노드 순서로 연쇄 등장
|
||||
*/
|
||||
export function calcAnimationTimings(
|
||||
edges: FlowEdge[],
|
||||
depths: Record<string, number>
|
||||
): { edge: FlowEdge; lineDelay: number; nodeDelay: number }[] {
|
||||
const STEP = 500;
|
||||
const NODE_D = 350;
|
||||
|
||||
// 엣지를 depth별 그룹핑
|
||||
const depthEdges: Record<number, FlowEdge[]> = {};
|
||||
edges.forEach((edge) => {
|
||||
const fd = depths[edge.from] ?? 0;
|
||||
const d = fd + 1;
|
||||
if (!depthEdges[d]) depthEdges[d] = [];
|
||||
depthEdges[d].push(edge);
|
||||
});
|
||||
|
||||
const result: { edge: FlowEdge; lineDelay: number; nodeDelay: number }[] = [];
|
||||
const maxDepth = Math.max(0, ...Object.keys(depthEdges).map(Number));
|
||||
|
||||
for (let d = 1; d <= maxDepth; d++) {
|
||||
const edgesAtDepth = depthEdges[d] || [];
|
||||
const base = 300 + (d - 1) * STEP;
|
||||
edgesAtDepth.forEach((edge, i) => {
|
||||
result.push({
|
||||
edge,
|
||||
lineDelay: base + i * 120,
|
||||
nodeDelay: base + i * 120 + NODE_D,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* useFlowAnimation — 흐름 표시 관리 훅
|
||||
*/
|
||||
export function useFlowAnimation() {
|
||||
const showFlow = useCallback(
|
||||
(
|
||||
cardId: string,
|
||||
sourceTable: string,
|
||||
relations: Record<string, any>[],
|
||||
cardRect: { right: number; centerY: number },
|
||||
canvasHeight: number
|
||||
) => {
|
||||
// 1. 엣지 구성: 카드 → 소스 테이블 + relations
|
||||
const rootKey = `CARD:${cardId}`;
|
||||
const allEdges: FlowEdge[] = [
|
||||
{ from: rootKey, to: sourceTable, type: 'source', label: '데이터 소스' },
|
||||
];
|
||||
|
||||
relations.forEach((rel) => {
|
||||
const type = rel.relation_type ?? rel.RELATION_TYPE ?? 'auto';
|
||||
const label = rel.label ?? rel.LABEL ?? `${rel.source_table ?? rel.SOURCE_TABLE} → ${rel.target_table ?? rel.TARGET_TABLE}`;
|
||||
const from = rel.source_table ?? rel.SOURCE_TABLE;
|
||||
const to = rel.target_table ?? rel.TARGET_TABLE;
|
||||
if (from && to) {
|
||||
allEdges.push({ from, to, type, label });
|
||||
}
|
||||
});
|
||||
|
||||
// 2. BFS 체인 + depth 계산
|
||||
const { edges, depths } = buildFlowChain(rootKey, allEdges);
|
||||
|
||||
// 3. 위치 계산
|
||||
const positions = calcFlowPositions(
|
||||
cardRect.right,
|
||||
cardRect.centerY,
|
||||
canvasHeight,
|
||||
depths
|
||||
);
|
||||
|
||||
// 4. 애니메이션 타이밍
|
||||
const timings = calcAnimationTimings(edges, depths);
|
||||
|
||||
return { edges, depths, positions, timings };
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { showFlow };
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useRef, useCallback, useEffect } from 'react';
|
||||
import { useControlMode, genConnId } from './useControlMode';
|
||||
import { bezierPath } from '../ConnectionLine';
|
||||
|
||||
/**
|
||||
* 포트 연결 드래그 로직 (mockup startPortDrag/finishPortDrag 포팅)
|
||||
* output 포트 mousedown → 임시 선 → input 포트 mouseup → 연결 생성
|
||||
*/
|
||||
export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
|
||||
const { addRuleConnection, ruleConnections } = useControlMode();
|
||||
|
||||
const dragRef = useRef<{
|
||||
fromNodeId: string;
|
||||
fromPort: string;
|
||||
line: SVGPathElement;
|
||||
x1: number;
|
||||
y1: number;
|
||||
} | null>(null);
|
||||
|
||||
const startDrag = useCallback((nodeId: string, port: string, e: React.MouseEvent) => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
|
||||
// SVG 확보
|
||||
let svg = cv.querySelector('#rule-svg') as SVGSVGElement | null;
|
||||
if (!svg) {
|
||||
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.id = 'rule-svg';
|
||||
svg.classList.add('ctrl-svg');
|
||||
svg.setAttribute('width', '100%');
|
||||
svg.setAttribute('height', '100%');
|
||||
svg.style.overflow = 'visible';
|
||||
cv.appendChild(svg);
|
||||
}
|
||||
|
||||
const cr = cv.getBoundingClientRect();
|
||||
const portEl = (e.target as HTMLElement).closest('.ctrl-io-port') as HTMLElement;
|
||||
if (!portEl) return;
|
||||
const pr = portEl.getBoundingClientRect();
|
||||
const x1 = pr.left + pr.width / 2 - cr.left + cv.scrollLeft;
|
||||
const y1 = pr.top + pr.height / 2 - cr.top + cv.scrollTop;
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
line.classList.add('rule-temp-line');
|
||||
line.setAttribute('d', `M${x1},${y1} L${x1},${y1}`);
|
||||
svg.appendChild(line);
|
||||
|
||||
dragRef.current = { fromNodeId: nodeId, fromPort: port, line, x1, y1 };
|
||||
cv.classList.add('port-dragging');
|
||||
portEl.classList.add('port-active');
|
||||
}, [canvasRef]);
|
||||
|
||||
const finishDrag = useCallback((toNodeId: string, toPort: string) => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
|
||||
// 같은 노드 연결 방지
|
||||
if (d.fromNodeId === toNodeId) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
// 중복 방지
|
||||
if (ruleConnections.find((c) =>
|
||||
c.from_node_id === d.fromNodeId && c.from_port === d.fromPort && c.to_node_id === toNodeId
|
||||
)) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
addRuleConnection({
|
||||
id: genConnId(),
|
||||
from_node_id: d.fromNodeId,
|
||||
from_port: d.fromPort,
|
||||
to_node_id: toNodeId,
|
||||
to_port: toPort,
|
||||
});
|
||||
|
||||
cleanup();
|
||||
}, [addRuleConnection, ruleConnections]);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
d.line.remove();
|
||||
canvasRef.current?.classList.remove('port-dragging');
|
||||
document.querySelectorAll('.port-active').forEach((el) => el.classList.remove('port-active'));
|
||||
document.querySelectorAll('.port-hover').forEach((el) => el.classList.remove('port-hover'));
|
||||
dragRef.current = null;
|
||||
}, [canvasRef]);
|
||||
|
||||
// 마우스 이동/종료 전역 핸들러
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
const cr = cv.getBoundingClientRect();
|
||||
const x2 = e.clientX - cr.left + cv.scrollLeft;
|
||||
const y2 = e.clientY - cr.top + cv.scrollTop;
|
||||
d.line.setAttribute('d', bezierPath(d.x1, d.y1, x2, y2));
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
if (dragRef.current) cleanup();
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [canvasRef, cleanup]);
|
||||
|
||||
return { startDrag, finishDrag };
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
interface CardMiniViewProps {
|
||||
templateName: string;
|
||||
category?: string;
|
||||
tableName?: string;
|
||||
}
|
||||
|
||||
export function CardMiniView({ templateName, category, tableName }: CardMiniViewProps) {
|
||||
return (
|
||||
<div className="dash-mini-body">
|
||||
<div className="dash-mini-stats">
|
||||
<div className="dash-mini-stat">
|
||||
<div className="ms-label">템플릿</div>
|
||||
<div className="ms-value" style={{ fontSize: '.85rem' }}>{templateName}</div>
|
||||
</div>
|
||||
{category && (
|
||||
<div className="dash-mini-stat">
|
||||
<div className="ms-label">분류</div>
|
||||
<div className="ms-value" style={{ fontSize: '.85rem' }}>{category}</div>
|
||||
</div>
|
||||
)}
|
||||
{tableName && (
|
||||
<div className="dash-mini-stat">
|
||||
<div className="ms-label">테이블</div>
|
||||
<div className="ms-value" style={{ fontSize: '.75rem' }}>{tableName}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Settings } from 'lucide-react';
|
||||
import { getMetaFields } from '@/lib/api/meta';
|
||||
import { getUserOverride, upsertUserOverride } from '@/lib/api/override';
|
||||
import { FieldConfig } from '@/types/invyone-component';
|
||||
|
||||
interface CardSettingsPanelProps {
|
||||
cardId: string;
|
||||
tableName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CardSettingsPanel({ cardId, tableName, onClose }: CardSettingsPanelProps) {
|
||||
const [fields, setFields] = useState<FieldConfig[]>([]);
|
||||
const [overrides, setOverrides] = useState<Record<string, any>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const [meta, ovr] = await Promise.all([
|
||||
getMetaFields(tableName),
|
||||
getUserOverride(cardId),
|
||||
]);
|
||||
if (meta?.fields) setFields(meta.fields.filter((f: FieldConfig) => !f.system));
|
||||
if (ovr?.overrides) setOverrides(ovr.overrides);
|
||||
} catch (err) {
|
||||
console.error('[CardSettings] Load failed:', err);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [cardId, tableName]);
|
||||
|
||||
const isFieldHidden = (column: string) => {
|
||||
return overrides?.fields?.[column]?.visible === false;
|
||||
};
|
||||
|
||||
const toggleField = async (column: string) => {
|
||||
const currentlyHidden = isFieldHidden(column);
|
||||
const newOverrides = {
|
||||
...overrides,
|
||||
fields: {
|
||||
...(overrides.fields ?? {}),
|
||||
[column]: { ...(overrides.fields?.[column] ?? {}), visible: currentlyHidden },
|
||||
},
|
||||
};
|
||||
|
||||
// visible: true 이면 삭제 (기본값이니까)
|
||||
if (currentlyHidden) {
|
||||
delete newOverrides.fields[column].visible;
|
||||
if (Object.keys(newOverrides.fields[column]).length === 0) {
|
||||
delete newOverrides.fields[column];
|
||||
}
|
||||
}
|
||||
|
||||
setOverrides(newOverrides);
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await upsertUserOverride({ card_id: cardId, overrides: newOverrides });
|
||||
} catch (err) {
|
||||
console.error('[CardSettings] Save failed:', err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dash-settings">
|
||||
<div className="dash-settings-head">
|
||||
<div className="dash-settings-title">
|
||||
<Settings size={13} />
|
||||
<span>카드 설정</span>
|
||||
</div>
|
||||
<button
|
||||
className="dash-card-btn"
|
||||
onClick={onClose}
|
||||
style={{ width: 22, height: 22, borderRadius: 6 }}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dash-settings-body">
|
||||
<div style={{
|
||||
fontSize: '.55rem', fontWeight: 700, color: 'var(--v5-primary)',
|
||||
textTransform: 'uppercase', letterSpacing: '.08em',
|
||||
padding: '.3rem 0', borderBottom: '1px solid var(--v5-border-subtle)',
|
||||
marginBottom: '.4rem',
|
||||
}}>
|
||||
컬럼 표시/숨김
|
||||
</div>
|
||||
{fields.map((f) => (
|
||||
<div key={f.column} className="dash-settings-row">
|
||||
<span className="dash-settings-label">{f.label}</span>
|
||||
<div
|
||||
className={`dash-toggle${!isFieldHidden(f.column) ? ' on' : ''}`}
|
||||
onClick={() => toggleField(f.column)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{saving && (
|
||||
<div style={{ textAlign: 'center', fontSize: '.6rem', color: 'var(--v5-text-muted)', marginTop: '.5rem' }}>
|
||||
저장 중...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useCallback, useEffect, forwardRef } from 'react';
|
||||
import { useDashboardStore } from '@/stores/dashboardStore';
|
||||
import { DashboardCard } from './DashboardCard';
|
||||
import { DashboardEmpty } from './DashboardEmpty';
|
||||
|
||||
interface DashboardCanvasProps {
|
||||
dashboardName: string;
|
||||
onOpenLibrary: () => void;
|
||||
onOpenSettings?: (cardId: string) => void;
|
||||
controlMode?: boolean;
|
||||
}
|
||||
|
||||
export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(function DashboardCanvas({
|
||||
dashboardName,
|
||||
onOpenLibrary,
|
||||
onOpenSettings,
|
||||
controlMode: controlActive,
|
||||
}, externalRef) {
|
||||
const cards = useDashboardStore((s) => s.cards);
|
||||
const editMode = useDashboardStore((s) => s.editMode);
|
||||
const updateCard = useDashboardStore((s) => s.updateCard);
|
||||
const removeCard = useDashboardStore((s) => s.removeCard);
|
||||
const internalRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = (externalRef as React.RefObject<HTMLDivElement | null>) ?? internalRef;
|
||||
const dragRef = useRef<{
|
||||
cardId: string;
|
||||
startX: number; startY: number;
|
||||
origLeft: number; origTop: number;
|
||||
origW: number; origH: number;
|
||||
mode: 'drag' | 'resize';
|
||||
el: HTMLElement;
|
||||
} | null>(null);
|
||||
|
||||
// 캔버스 경계 clamp
|
||||
const clamp = useCallback((l: number, t: number, w: number, h: number) => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return { l, t, w, h };
|
||||
const cw = cv.clientWidth;
|
||||
const ch = cv.clientHeight;
|
||||
w = Math.min(w, cw);
|
||||
h = Math.min(h, ch);
|
||||
l = Math.max(0, Math.min(l, cw - w));
|
||||
t = Math.max(0, Math.min(t, ch - h));
|
||||
return { l, t, w, h };
|
||||
}, []);
|
||||
|
||||
// 마우스 다운 → 드래그/리사이즈 시작
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (!editMode) return;
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// 버튼/입력 클릭은 무시
|
||||
if (target.closest('button') || target.closest('input') || target.closest('select')) return;
|
||||
|
||||
const cardEl = target.closest('.dash-card') as HTMLElement;
|
||||
if (!cardEl) return;
|
||||
|
||||
const cardId = cardEl.dataset.cardId;
|
||||
if (!cardId) return;
|
||||
|
||||
const isResize = target.closest('[data-resize]') !== null;
|
||||
e.preventDefault();
|
||||
|
||||
dragRef.current = {
|
||||
cardId,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origLeft: cardEl.offsetLeft,
|
||||
origTop: cardEl.offsetTop,
|
||||
origW: cardEl.offsetWidth,
|
||||
origH: cardEl.offsetHeight,
|
||||
mode: isResize ? 'resize' : 'drag',
|
||||
el: cardEl,
|
||||
};
|
||||
|
||||
cardEl.classList.add(isResize ? 'resizing' : 'dragging');
|
||||
document.body.style.cursor = isResize ? 'nwse-resize' : 'grabbing';
|
||||
document.body.style.userSelect = 'none';
|
||||
}, [editMode]);
|
||||
|
||||
// 마우스 이동
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
|
||||
const dx = e.clientX - d.startX;
|
||||
const dy = e.clientY - d.startY;
|
||||
|
||||
if (d.mode === 'drag') {
|
||||
const c = clamp(d.origLeft + dx, d.origTop + dy, d.origW, d.origH);
|
||||
d.el.style.left = c.l + 'px';
|
||||
d.el.style.top = c.t + 'px';
|
||||
} else {
|
||||
const nw = Math.max(220, d.origW + dx);
|
||||
const nh = Math.max(140, d.origH + dy);
|
||||
const c = clamp(d.origLeft, d.origTop, nw, nh);
|
||||
d.el.style.width = c.w + 'px';
|
||||
d.el.style.height = c.h + 'px';
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
|
||||
d.el.classList.remove('dragging', 'resizing');
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
// 최종 위치를 store에 반영
|
||||
updateCard(d.cardId, {
|
||||
position_x: d.el.offsetLeft,
|
||||
position_y: d.el.offsetTop,
|
||||
width: d.el.offsetWidth,
|
||||
height: d.el.offsetHeight,
|
||||
});
|
||||
|
||||
dragRef.current = null;
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [clamp, updateCard]);
|
||||
|
||||
const handleToggleCollapse = useCallback((cardId: string) => {
|
||||
const card = cards.find((c) => (c.card_id ?? c.CARD_ID) === cardId);
|
||||
if (!card) return;
|
||||
const wasCollapsed = card.is_collapsed ?? card.IS_COLLAPSED ?? false;
|
||||
updateCard(cardId, { is_collapsed: !wasCollapsed });
|
||||
}, [cards, updateCard]);
|
||||
|
||||
const handleRemove = useCallback((cardId: string) => {
|
||||
if (!confirm('이 카드를 삭제하시겠습니까?')) return;
|
||||
removeCard(cardId);
|
||||
}, [removeCard]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className={`dash-canvas${editMode ? ' edit-mode' : ''}${controlActive ? ' control-mode' : ''}`}
|
||||
onMouseDown={controlActive ? undefined : handleMouseDown}
|
||||
>
|
||||
{cards.length === 0 ? (
|
||||
<DashboardEmpty dashboardName={dashboardName} onOpenLibrary={onOpenLibrary} />
|
||||
) : (
|
||||
cards.map((card) => {
|
||||
const id = card.card_id ?? card.CARD_ID;
|
||||
const x = Number(card.position_x ?? card.POSITION_X ?? 50);
|
||||
const y = Number(card.position_y ?? card.POSITION_Y ?? 50);
|
||||
const w = Number(card.width ?? card.WIDTH ?? 600);
|
||||
const h = Number(card.height ?? card.HEIGHT ?? 400);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
data-card-id={id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x + 'px',
|
||||
top: y + 'px',
|
||||
width: w + 'px',
|
||||
height: h + 'px',
|
||||
}}
|
||||
>
|
||||
<DashboardCard
|
||||
card={card}
|
||||
editMode={editMode}
|
||||
onRemove={handleRemove}
|
||||
onToggleCollapse={handleToggleCollapse}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { RefreshCw, ChevronDown, X, Settings } from 'lucide-react';
|
||||
import { getTemplateInfo } from '@/lib/api/template';
|
||||
import { getMetaFields } from '@/lib/api/meta';
|
||||
import { fcList } from '@/lib/api/fcData';
|
||||
import { FcTable, FcSearch, FcPagination } from '@/components/fc';
|
||||
import { FieldConfig } from '@/types/invyone-component';
|
||||
import { CardMiniView } from './CardMiniView';
|
||||
|
||||
interface DashboardCardProps {
|
||||
card: Record<string, any>;
|
||||
editMode: boolean;
|
||||
onRemove: (cardId: string) => void;
|
||||
onToggleCollapse: (cardId: string) => void;
|
||||
onOpenSettings?: (cardId: string) => void;
|
||||
}
|
||||
|
||||
export function DashboardCard({
|
||||
card,
|
||||
editMode,
|
||||
onRemove,
|
||||
onToggleCollapse,
|
||||
onOpenSettings,
|
||||
}: DashboardCardProps) {
|
||||
const cardId = card.card_id ?? card.CARD_ID;
|
||||
const templateId = card.template_id ?? card.TEMPLATE_ID;
|
||||
const templateName = card.template_name ?? card.TEMPLATE_NAME ?? '템플릿';
|
||||
const templateCategory = card.template_category ?? card.TEMPLATE_CATEGORY ?? '';
|
||||
const primaryTable = card.primary_table ?? card.PRIMARY_TABLE ?? '';
|
||||
const isCollapsed = card.is_collapsed ?? card.IS_COLLAPSED ?? false;
|
||||
|
||||
const [fields, setFields] = useState<FieldConfig[]>([]);
|
||||
const [data, setData] = useState<Record<string, any>[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [searchParams, setSearchParams] = useState<Record<string, any>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [templateLoaded, setTemplateLoaded] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// Template + FieldConfig 로드
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
if (!primaryTable) return;
|
||||
|
||||
const loadTemplate = async () => {
|
||||
try {
|
||||
const meta = await getMetaFields(primaryTable);
|
||||
if (mountedRef.current && meta?.fields) {
|
||||
setFields(meta.fields);
|
||||
setTemplateLoaded(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[DashboardCard] Failed to load fields for ${primaryTable}:`, err);
|
||||
}
|
||||
};
|
||||
loadTemplate();
|
||||
|
||||
return () => { mountedRef.current = false; };
|
||||
}, [primaryTable]);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
if (!primaryTable || !templateLoaded) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fcList({
|
||||
tableName: primaryTable,
|
||||
page,
|
||||
size: pageSize,
|
||||
...searchParams,
|
||||
});
|
||||
if (mountedRef.current) {
|
||||
setData(result?.data ?? result?.list ?? []);
|
||||
setTotalCount(result?.total ?? result?.total_count ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[DashboardCard] Failed to load data:`, err);
|
||||
} finally {
|
||||
if (mountedRef.current) setLoading(false);
|
||||
}
|
||||
}, [primaryTable, templateLoaded, page, pageSize, searchParams]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const handleSearch = (params: Record<string, any>) => {
|
||||
setSearchParams(params);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handlePageChange = ({ page: newPage, size }: { page: number; size: number }) => {
|
||||
setPage(newPage);
|
||||
setPageSize(size);
|
||||
};
|
||||
|
||||
const visibleFields = fields.filter((f) => f.visible && !f.system);
|
||||
const searchableFields = fields.filter((f) => f.searchable && !f.system);
|
||||
|
||||
return (
|
||||
<div className={`dash-card${isCollapsed ? ' collapsed' : ''}`}>
|
||||
{/* 카드 헤더 */}
|
||||
<div className="dash-card-head">
|
||||
<div className="dash-card-head-l">
|
||||
<div className="dash-card-icon">📋</div>
|
||||
<div className="dash-card-title">{templateName}</div>
|
||||
{templateCategory && (
|
||||
<div className="dash-card-bdg">{templateCategory}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="dash-card-head-r">
|
||||
<button
|
||||
className="dash-card-btn"
|
||||
title="새로고침"
|
||||
onClick={(e) => { e.stopPropagation(); loadData(); }}
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
</button>
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
className="dash-card-btn"
|
||||
title="설정"
|
||||
onClick={(e) => { e.stopPropagation(); onOpenSettings(cardId); }}
|
||||
>
|
||||
<Settings size={13} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="dash-card-btn"
|
||||
title="접기/펴기"
|
||||
onClick={(e) => { e.stopPropagation(); onToggleCollapse(cardId); }}
|
||||
>
|
||||
<ChevronDown size={13} style={{
|
||||
transform: isCollapsed ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform .25s',
|
||||
}} />
|
||||
</button>
|
||||
{editMode && (
|
||||
<button
|
||||
className="dash-card-btn danger"
|
||||
title="카드 삭제"
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(cardId); }}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 — Template 컴포넌트 렌더 */}
|
||||
<div className="dash-card-body">
|
||||
{!primaryTable ? (
|
||||
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--v5-text-muted)', fontSize: '.7rem' }}>
|
||||
테이블이 지정되지 않은 템플릿입니다.
|
||||
</div>
|
||||
) : !templateLoaded ? (
|
||||
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--v5-text-muted)', fontSize: '.7rem' }}>
|
||||
필드 로딩 중...
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '.35rem', height: '100%' }}>
|
||||
{searchableFields.length > 0 && (
|
||||
<FcSearch
|
||||
fields={fields}
|
||||
onSearch={handleSearch}
|
||||
config={{ layout: 'inline', autoSearch: false, dateRangeEnabled: true, showResetButton: true }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||
<FcTable
|
||||
fields={visibleFields}
|
||||
data={data}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
{totalCount > 0 && (
|
||||
<FcPagination
|
||||
total={totalCount}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 접힌 상태: 미니 뷰 */}
|
||||
<CardMiniView
|
||||
templateName={templateName}
|
||||
category={templateCategory}
|
||||
tableName={primaryTable}
|
||||
/>
|
||||
|
||||
{/* 리사이즈 핸들 */}
|
||||
<div className="dash-resize-handle" data-resize="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
interface DashboardEmptyProps {
|
||||
dashboardName: string;
|
||||
onOpenLibrary: () => void;
|
||||
}
|
||||
|
||||
export function DashboardEmpty({ dashboardName, onOpenLibrary }: DashboardEmptyProps) {
|
||||
return (
|
||||
<div className="dash-empty">
|
||||
<div className="dash-empty-icon">📋</div>
|
||||
<div className="dash-empty-title">{dashboardName}</div>
|
||||
<div className="dash-empty-desc">
|
||||
아직 템플릿이 없습니다. <b>+ 템플릿 추가</b> 버튼으로 첫 카드를 배치하세요.
|
||||
</div>
|
||||
<button className="dash-empty-btn" onClick={onOpenLibrary}>
|
||||
+ 템플릿 추가
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useDashboardStore } from '@/stores/dashboardStore';
|
||||
import {
|
||||
getDashboardList,
|
||||
getDashboardCards,
|
||||
insertDashboard,
|
||||
updateDashboard,
|
||||
deleteDashboard,
|
||||
insertDashboardCard,
|
||||
updateCardPositionsBatch,
|
||||
deleteDashboardCard,
|
||||
} from '@/lib/api/dashMenu';
|
||||
import { DashboardSidebar } from './DashboardSidebar';
|
||||
import { DashboardToolbar } from './DashboardToolbar';
|
||||
import { DashboardCanvas } from './DashboardCanvas';
|
||||
import { TemplateLibraryModal } from './TemplateLibraryModal';
|
||||
import { CardSettingsPanel } from './CardSettingsPanel';
|
||||
import { ControlMode } from '@/components/control/ControlMode';
|
||||
import { ControlPalette } from '@/components/control/ControlPalette';
|
||||
import { useControlMode } from '@/components/control/hooks/useControlMode';
|
||||
import { toast } from 'sonner';
|
||||
import '@/styles/dashboard.css';
|
||||
|
||||
export function DashboardLayout() {
|
||||
const {
|
||||
dashboards,
|
||||
activeDashboardId,
|
||||
cards,
|
||||
editMode,
|
||||
setDashboards,
|
||||
setActiveDashboard,
|
||||
setCards,
|
||||
addCard,
|
||||
setEditMode,
|
||||
} = useDashboardStore();
|
||||
|
||||
const controlActive = useControlMode((s) => s.active);
|
||||
const controlMode = useControlMode((s) => s.mode);
|
||||
|
||||
const [libOpen, setLibOpen] = useState(false);
|
||||
const [settingsCardId, setSettingsCardId] = useState<string | null>(null);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 대시보드 목록 로드
|
||||
const loadDashboards = useCallback(async () => {
|
||||
try {
|
||||
const result = await getDashboardList({ limit: 100 });
|
||||
const list: Record<string, any>[] = result?.list ?? [];
|
||||
setDashboards(list);
|
||||
|
||||
// 첫 번째 대시보드 자동 선택
|
||||
if (list.length > 0 && !activeDashboardId) {
|
||||
const firstId = list[0].dashboard_id ?? list[0].DASHBOARD_ID;
|
||||
setActiveDashboard(firstId);
|
||||
}
|
||||
setInitialized(true);
|
||||
} catch (err) {
|
||||
console.error('[Dashboard] Load failed:', err);
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [setDashboards, setActiveDashboard, activeDashboardId]);
|
||||
|
||||
useEffect(() => { loadDashboards(); }, []);
|
||||
|
||||
// 대시보드 전환 시 카드 로드
|
||||
const loadCards = useCallback(async (dashId: string) => {
|
||||
try {
|
||||
const cardList = await getDashboardCards(dashId);
|
||||
setCards(cardList ?? []);
|
||||
} catch (err) {
|
||||
console.error('[Dashboard] Load cards failed:', err);
|
||||
setCards([]);
|
||||
}
|
||||
}, [setCards]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeDashboardId) {
|
||||
loadCards(activeDashboardId);
|
||||
setEditMode(false);
|
||||
}
|
||||
}, [activeDashboardId, loadCards, setEditMode]);
|
||||
|
||||
// 활성 대시보드 정보
|
||||
const activeDash = dashboards.find(
|
||||
(d) => (d.dashboard_id ?? d.DASHBOARD_ID) === activeDashboardId
|
||||
);
|
||||
const dashName = activeDash?.name ?? activeDash?.NAME ?? '대시보드';
|
||||
|
||||
// 대시보드 CRUD
|
||||
const handleAddDashboard = async () => {
|
||||
const name = prompt('새 대시보드 이름:', '새 대시보드');
|
||||
if (!name?.trim()) return;
|
||||
try {
|
||||
const result = await insertDashboard({ name: name.trim() });
|
||||
await loadDashboards();
|
||||
if (result?.dashboard_id) {
|
||||
setActiveDashboard(result.dashboard_id);
|
||||
}
|
||||
toast.success(`"${name.trim()}" 대시보드를 만들었습니다`);
|
||||
} catch (err) {
|
||||
toast.error('대시보드 생성 실패');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameDashboard = async (id: string) => {
|
||||
const dash = dashboards.find((d) => (d.dashboard_id ?? d.DASHBOARD_ID) === id);
|
||||
if (!dash) return;
|
||||
const newName = prompt('새 이름:', dash.name ?? dash.NAME ?? '');
|
||||
if (!newName?.trim()) return;
|
||||
try {
|
||||
await updateDashboard(id, { name: newName.trim() });
|
||||
await loadDashboards();
|
||||
toast.success('이름 변경됨');
|
||||
} catch (err) {
|
||||
toast.error('이름 변경 실패');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDashboard = async (id: string) => {
|
||||
if (dashboards.length <= 1) {
|
||||
toast.warning('마지막 대시보드는 삭제할 수 없습니다');
|
||||
return;
|
||||
}
|
||||
const dash = dashboards.find((d) => (d.dashboard_id ?? d.DASHBOARD_ID) === id);
|
||||
if (!confirm(`"${dash?.name ?? dash?.NAME}" 을 삭제합니다.`)) return;
|
||||
try {
|
||||
await deleteDashboard(id);
|
||||
await loadDashboards();
|
||||
toast.info('대시보드 삭제됨');
|
||||
} catch (err) {
|
||||
toast.error('삭제 실패');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchDashboard = (id: string) => {
|
||||
if (id === activeDashboardId) return;
|
||||
setActiveDashboard(id);
|
||||
};
|
||||
|
||||
// 템플릿 추가 (라이브러리 → 카드)
|
||||
const handleSelectTemplate = async (template: Record<string, any>) => {
|
||||
if (!activeDashboardId) return;
|
||||
const templateId = template.template_id ?? template.TEMPLATE_ID;
|
||||
try {
|
||||
const result = await insertDashboardCard(activeDashboardId, {
|
||||
template_id: templateId,
|
||||
position_x: 50 + Math.floor(Math.random() * 200),
|
||||
position_y: 30 + Math.floor(Math.random() * 100),
|
||||
width: 700,
|
||||
height: 450,
|
||||
});
|
||||
// 새 카드를 store에 추가 (서버 응답 + 원본 template 정보 결합)
|
||||
addCard({
|
||||
...result,
|
||||
template_id: templateId,
|
||||
template_name: template.name ?? template.NAME,
|
||||
template_category: template.category ?? template.CATEGORY,
|
||||
primary_table: template.primary_table ?? template.PRIMARY_TABLE,
|
||||
position_x: 50 + Math.floor(Math.random() * 200),
|
||||
position_y: 30 + Math.floor(Math.random() * 100),
|
||||
width: 700,
|
||||
height: 450,
|
||||
is_collapsed: false,
|
||||
});
|
||||
setLibOpen(false);
|
||||
if (!editMode) setEditMode(true);
|
||||
toast.success(`${template.name ?? template.NAME} 카드를 추가했습니다`);
|
||||
} catch (err) {
|
||||
toast.error('카드 추가 실패');
|
||||
}
|
||||
};
|
||||
|
||||
// 레이아웃 저장
|
||||
const handleSaveLayout = async () => {
|
||||
if (!activeDashboardId) return;
|
||||
try {
|
||||
const cardPositions = cards.map((c) => ({
|
||||
card_id: c.card_id ?? c.CARD_ID,
|
||||
position_x: c.position_x ?? c.POSITION_X ?? 0,
|
||||
position_y: c.position_y ?? c.POSITION_Y ?? 0,
|
||||
width: c.width ?? c.WIDTH ?? 600,
|
||||
height: c.height ?? c.HEIGHT ?? 400,
|
||||
is_collapsed: c.is_collapsed ?? c.IS_COLLAPSED ?? false,
|
||||
}));
|
||||
await updateCardPositionsBatch(activeDashboardId, cardPositions);
|
||||
toast.success(`${cards.length}개 카드 레이아웃 저장됨`);
|
||||
} catch (err) {
|
||||
toast.error('저장 실패');
|
||||
}
|
||||
};
|
||||
|
||||
// 설정 카드 정보
|
||||
const settingsCard = settingsCardId
|
||||
? cards.find((c) => (c.card_id ?? c.CARD_ID) === settingsCardId)
|
||||
: null;
|
||||
|
||||
if (!initialized) {
|
||||
return (
|
||||
<div className="dash-shell" style={{ alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ color: 'var(--v5-text-muted)', fontSize: '.8rem' }}>로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dash-shell">
|
||||
{/* 사이드바 — 제어 편집 모드에서는 팔레트로 교체 */}
|
||||
{controlActive && controlMode === 'edit' ? (
|
||||
<div className="dash-side">
|
||||
<div className="dash-side-sec" style={{ color: 'var(--ctrl-cyan)' }}>제어 팔레트</div>
|
||||
<ControlPalette onDropTable={() => {}} onDropControl={() => {}} />
|
||||
</div>
|
||||
) : (
|
||||
<DashboardSidebar
|
||||
onAddDashboard={handleAddDashboard}
|
||||
onRenameDashboard={handleRenameDashboard}
|
||||
onDeleteDashboard={handleDeleteDashboard}
|
||||
onSwitchDashboard={handleSwitchDashboard}
|
||||
/>
|
||||
)}
|
||||
<div className="dash-content">
|
||||
{activeDashboardId ? (
|
||||
<>
|
||||
<DashboardToolbar
|
||||
dashboardName={dashName}
|
||||
cardCount={cards.length}
|
||||
onOpenLibrary={() => setLibOpen(true)}
|
||||
onSaveLayout={handleSaveLayout}
|
||||
/>
|
||||
{/* 제어 모드 툴바 + 오버레이 */}
|
||||
<div style={{ position: 'relative', flex: 1 }}>
|
||||
<DashboardCanvas
|
||||
ref={canvasRef}
|
||||
dashboardName={dashName}
|
||||
onOpenLibrary={() => setLibOpen(true)}
|
||||
onOpenSettings={(id) => setSettingsCardId(id)}
|
||||
controlMode={controlActive}
|
||||
/>
|
||||
<ControlMode
|
||||
dashboardId={activeDashboardId}
|
||||
cards={cards}
|
||||
canvasRef={canvasRef}
|
||||
/>
|
||||
{settingsCard && !controlActive && (
|
||||
<CardSettingsPanel
|
||||
cardId={settingsCardId!}
|
||||
tableName={settingsCard.primary_table ?? settingsCard.PRIMARY_TABLE ?? ''}
|
||||
onClose={() => setSettingsCardId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexDirection: 'column', gap: '.8rem',
|
||||
}}>
|
||||
<div style={{ fontSize: '3rem', opacity: .25 }}>📋</div>
|
||||
<div style={{ fontSize: '.9rem', fontWeight: 700, color: 'var(--v5-text-sec)' }}>
|
||||
대시보드가 없습니다
|
||||
</div>
|
||||
<button className="dash-empty-btn" onClick={handleAddDashboard}>
|
||||
+ 새 대시보드 만들기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TemplateLibraryModal
|
||||
open={libOpen}
|
||||
onClose={() => setLibOpen(false)}
|
||||
onSelectTemplate={handleSelectTemplate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Plus, Pencil, X } from 'lucide-react';
|
||||
import { useDashboardStore } from '@/stores/dashboardStore';
|
||||
|
||||
interface DashboardSidebarProps {
|
||||
onAddDashboard: () => void;
|
||||
onRenameDashboard: (id: string) => void;
|
||||
onDeleteDashboard: (id: string) => void;
|
||||
onSwitchDashboard: (id: string) => void;
|
||||
}
|
||||
|
||||
export function DashboardSidebar({
|
||||
onAddDashboard,
|
||||
onRenameDashboard,
|
||||
onDeleteDashboard,
|
||||
onSwitchDashboard,
|
||||
}: DashboardSidebarProps) {
|
||||
const dashboards = useDashboardStore((s) => s.dashboards);
|
||||
const activeDashboardId = useDashboardStore((s) => s.activeDashboardId);
|
||||
|
||||
return (
|
||||
<div className="dash-side">
|
||||
<div className="dash-side-sec">내 대시보드</div>
|
||||
{dashboards.map((d) => {
|
||||
const id = d.dashboard_id ?? d.DASHBOARD_ID;
|
||||
const name = d.name ?? d.NAME ?? '대시보드';
|
||||
const icon = d.icon ?? d.ICON ?? '📋';
|
||||
const isActive = id === activeDashboardId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`dash-si${isActive ? ' on' : ''}`}
|
||||
onClick={() => onSwitchDashboard(id)}
|
||||
>
|
||||
<span className="ic">{icon}</span>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{name}
|
||||
</span>
|
||||
<div className="dash-si-actions">
|
||||
<button
|
||||
className="dash-si-act"
|
||||
title="이름 변경"
|
||||
onClick={(e) => { e.stopPropagation(); onRenameDashboard(id); }}
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
<button
|
||||
className="dash-si-act danger"
|
||||
title="삭제"
|
||||
onClick={(e) => { e.stopPropagation(); onDeleteDashboard(id); }}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button className="dash-add-btn" onClick={onAddDashboard}>
|
||||
<Plus size={14} />
|
||||
<span>새 대시보드</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { Edit3, Save, Plus, Zap } from 'lucide-react';
|
||||
import { useDashboardStore } from '@/stores/dashboardStore';
|
||||
import { useControlMode } from '@/components/control/hooks/useControlMode';
|
||||
|
||||
interface DashboardToolbarProps {
|
||||
dashboardName: string;
|
||||
cardCount: number;
|
||||
onOpenLibrary: () => void;
|
||||
onSaveLayout: () => void;
|
||||
}
|
||||
|
||||
export function DashboardToolbar({
|
||||
dashboardName,
|
||||
cardCount,
|
||||
onOpenLibrary,
|
||||
onSaveLayout,
|
||||
}: DashboardToolbarProps) {
|
||||
const editMode = useDashboardStore((s) => s.editMode);
|
||||
const toggleEditMode = useDashboardStore((s) => s.toggleEditMode);
|
||||
const setEditMode = useDashboardStore((s) => s.setEditMode);
|
||||
const controlActive = useControlMode((s) => s.active);
|
||||
const toggleControlMode = useControlMode((s) => s.toggleControlMode);
|
||||
|
||||
const handleToggleControl = () => {
|
||||
if (!controlActive) {
|
||||
// 제어 모드 진입 시 편집 모드 끄기
|
||||
setEditMode(false);
|
||||
}
|
||||
toggleControlMode();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dash-toolbar">
|
||||
<div className="dash-toolbar-l">
|
||||
<span className="dash-cv-title">{dashboardName}</span>
|
||||
<span className="dash-cv-meta">{`템플릿 ${cardCount}개`}</span>
|
||||
</div>
|
||||
<div className="dash-toolbar-r">
|
||||
{/* ⚡ 제어 모드 토글 */}
|
||||
<button
|
||||
className={`dash-btn${controlActive ? ' control-on' : ''}`}
|
||||
onClick={handleToggleControl}
|
||||
title={controlActive ? '제어 모드 끄기' : '제어 모드 — 데이터 흐름 시각화'}
|
||||
>
|
||||
<Zap size={13} />
|
||||
<span>{controlActive ? '제어 ✓' : '제어'}</span>
|
||||
</button>
|
||||
|
||||
{!controlActive && (
|
||||
<>
|
||||
<button
|
||||
className={`dash-btn${editMode ? ' on' : ''}`}
|
||||
onClick={toggleEditMode}
|
||||
title={editMode ? '편집 모드 끄기' : '편집 모드 켜기'}
|
||||
>
|
||||
<Edit3 size={13} />
|
||||
<span>{editMode ? '편집 중' : '편집'}</span>
|
||||
</button>
|
||||
<button className="dash-btn primary" onClick={onOpenLibrary}>
|
||||
<Plus size={13} />
|
||||
<span>템플릿 추가</span>
|
||||
</button>
|
||||
{editMode && (
|
||||
<button className="dash-btn" onClick={onSaveLayout}>
|
||||
<Save size={13} />
|
||||
<span>저장</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { getTemplateList } from '@/lib/api/template';
|
||||
|
||||
interface TemplateLibraryModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelectTemplate: (template: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: '', label: '전체', icon: '📋' },
|
||||
{ id: 'sales', label: '영업/CRM', icon: '💰' },
|
||||
{ id: 'production', label: '생산/공정', icon: '🏭' },
|
||||
{ id: 'hr', label: '인사/급여', icon: '👥' },
|
||||
{ id: 'inventory', label: '재고/물류', icon: '📦' },
|
||||
{ id: 'finance', label: '재무/회계', icon: '💳' },
|
||||
{ id: 'admin', label: '관리자', icon: '⚙' },
|
||||
];
|
||||
|
||||
export function TemplateLibraryModal({ open, onClose, onSelectTemplate }: TemplateLibraryModalProps) {
|
||||
const [templates, setTemplates] = useState<Record<string, any>[]>([]);
|
||||
const [activeCategory, setActiveCategory] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getTemplateList({ status: 'published', limit: 100 });
|
||||
setTemplates(result?.list ?? []);
|
||||
} catch (err) {
|
||||
console.error('[TemplateLibrary] Load failed:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
if (open) document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
const filtered = templates.filter((t) => {
|
||||
const name = (t.name ?? t.NAME ?? '').toLowerCase();
|
||||
const cat = (t.category ?? t.CATEGORY ?? '').toLowerCase();
|
||||
const desc = (t.description ?? t.DESCRIPTION ?? '').toLowerCase();
|
||||
const matchKeyword = !keyword || name.includes(keyword.toLowerCase()) || desc.includes(keyword.toLowerCase());
|
||||
const matchCategory = !activeCategory || cat === activeCategory;
|
||||
return matchKeyword && matchCategory;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`dash-lib-backdrop${open ? ' open' : ''}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className={`dash-lib-modal${open ? ' open' : ''}`}>
|
||||
{/* 헤더 */}
|
||||
<div className="dash-lib-head">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '.7rem' }}>
|
||||
<span className="dash-lib-title">템플릿 라이브러리</span>
|
||||
<span style={{ fontSize: '.6rem', color: 'var(--v5-text-muted)' }}>
|
||||
{filtered.length}개
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '.5rem' }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '.5rem',
|
||||
padding: '.45rem .75rem', borderRadius: '10px',
|
||||
background: 'var(--v5-surface)', border: '1px solid var(--v5-glass-border)',
|
||||
width: '220px',
|
||||
}}>
|
||||
<Search size={13} style={{ color: 'var(--v5-text-muted)', flexShrink: 0 }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="템플릿 검색..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
style={{
|
||||
flex: 1, border: 'none', background: 'transparent',
|
||||
color: 'var(--v5-text)', fontSize: '.7rem', fontFamily: 'inherit', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button className="dash-lib-close" onClick={onClose}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="dash-lib-body">
|
||||
{/* 카테고리 */}
|
||||
<div className="dash-lib-cats">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<div
|
||||
key={cat.id}
|
||||
className={`dash-lib-cat${activeCategory === cat.id ? ' on' : ''}`}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
>
|
||||
<span className="ic">{cat.icon}</span>
|
||||
<span>{cat.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 카드 그리드 */}
|
||||
<div className="dash-lib-grid">
|
||||
{loading ? (
|
||||
<div style={{ padding: '3rem', textAlign: 'center', color: 'var(--v5-text-muted)', fontSize: '.7rem' }}>
|
||||
템플릿 로딩 중...
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div style={{ padding: '3rem', textAlign: 'center', color: 'var(--v5-text-muted)', fontSize: '.7rem' }}>
|
||||
{templates.length === 0
|
||||
? '게시된 템플릿이 없습니다. 개발자 빌더에서 템플릿을 만들고 게시하세요.'
|
||||
: '검색 결과가 없습니다.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="dash-lib-cards">
|
||||
{filtered.map((t) => {
|
||||
const tid = t.template_id ?? t.TEMPLATE_ID;
|
||||
const name = t.name ?? t.NAME ?? '템플릿';
|
||||
const cat = t.category ?? t.CATEGORY ?? '';
|
||||
const desc = t.description ?? t.DESCRIPTION ?? '';
|
||||
const table = t.primary_table ?? t.PRIMARY_TABLE ?? '';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tid}
|
||||
className="dash-lib-card"
|
||||
onClick={() => onSelectTemplate(t)}
|
||||
>
|
||||
<div className="dash-lib-card-icon">📋</div>
|
||||
<div className="dash-lib-card-name">{name}</div>
|
||||
{desc && <div className="dash-lib-card-desc">{desc}</div>}
|
||||
<div style={{ display: 'flex', gap: '.2rem', marginTop: 'auto', flexWrap: 'wrap' }}>
|
||||
{cat && <span className="dash-lib-card-tag">{cat}</span>}
|
||||
{table && <span className="dash-lib-card-tag">{table}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { ButtonConfig } from '@/types/invyone-component';
|
||||
|
||||
interface FcButtonProps {
|
||||
config: ButtonConfig;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const VARIANT_CLASSES: Record<string, string> = {
|
||||
primary: 'bg-[var(--v5-primary)] text-white hover:opacity-90 shadow-[var(--v5-glow-sm)]',
|
||||
default: 'bg-[var(--v5-surface)] text-[var(--v5-text)] border border-[var(--v5-border)] hover:border-[var(--v5-primary)]',
|
||||
destructive: 'bg-[var(--v5-red)] text-white hover:opacity-90',
|
||||
outline: 'bg-transparent text-[var(--v5-text)] border border-[var(--v5-border)] hover:bg-[var(--v5-surface-hover)]',
|
||||
ghost: 'bg-transparent text-[var(--v5-text-sec)] hover:bg-[var(--v5-surface-hover)]',
|
||||
};
|
||||
|
||||
export function FcButton({ config, onClick, disabled }: FcButtonProps) {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
if (config.confirm && !confirming) {
|
||||
setConfirming(true);
|
||||
return;
|
||||
}
|
||||
setConfirming(false);
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
const handleCancel = () => setConfirming(false);
|
||||
|
||||
const variantClass = VARIANT_CLASSES[config.variant] ?? VARIANT_CLASSES.default;
|
||||
|
||||
if (confirming) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<span className="text-[0.65rem] text-[var(--v5-red)]">{config.confirm}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className="px-2 py-0.5 rounded text-[0.65rem] bg-[var(--v5-red)] text-white"
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="px-2 py-0.5 rounded text-[0.65rem] border border-[var(--v5-border)] text-[var(--v5-text-sec)]"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
className={`px-3 py-1 rounded text-xs font-medium transition-all
|
||||
disabled:opacity-40 disabled:cursor-not-allowed ${variantClass}`}
|
||||
>
|
||||
{config.text}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { FcButton } from './FcButton';
|
||||
import type { ActionType, ButtonBarConfig } from '@/types/invyone-component';
|
||||
|
||||
interface FcButtonBarProps {
|
||||
config: ButtonBarConfig;
|
||||
onAction?: (actionType: ActionType) => void;
|
||||
}
|
||||
|
||||
export function FcButtonBar({ config, onAction }: FcButtonBarProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{config.buttons.map((btn, idx) => (
|
||||
<FcButton
|
||||
key={`${btn.actionType}-${idx}`}
|
||||
config={btn}
|
||||
onClick={() => onAction?.(btn.actionType)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useEffect, useState, useCallback } from 'react';
|
||||
import { FieldRenderer } from './fields/FieldRenderer';
|
||||
import type { FieldConfig, FormConfig } from '@/types/invyone-component';
|
||||
|
||||
const DEFAULT_CONFIG: FormConfig = {
|
||||
columns: 2,
|
||||
saveAction: {
|
||||
method: 'UPSERT',
|
||||
refreshAfterSave: true,
|
||||
},
|
||||
};
|
||||
|
||||
interface FcFormProps {
|
||||
fields: FieldConfig[];
|
||||
config?: Partial<FormConfig>;
|
||||
initialData?: Record<string, any>;
|
||||
onSubmit?: (data: Record<string, any>) => void;
|
||||
onSaved?: (data: Record<string, any>) => void;
|
||||
loadRow?: Record<string, any>;
|
||||
}
|
||||
|
||||
/** required 검증: null, undefined, '' 만 empty. 0, false는 유효. */
|
||||
function isFieldEmpty(value: any): boolean {
|
||||
return value === null || value === undefined || value === '';
|
||||
}
|
||||
|
||||
export function FcForm({
|
||||
fields,
|
||||
config: configOverride,
|
||||
initialData,
|
||||
onSubmit,
|
||||
loadRow,
|
||||
}: FcFormProps) {
|
||||
const config = useMemo(() => ({ ...DEFAULT_CONFIG, ...configOverride }), [configOverride]);
|
||||
|
||||
// 표시할 필드: system이 아니고 visible인 것, order 순
|
||||
const formFields = useMemo(
|
||||
() => fields
|
||||
.filter((f) => !f.system && f.visible)
|
||||
.sort((a, b) => a.order - b.order),
|
||||
[fields],
|
||||
);
|
||||
|
||||
// 폼 데이터 상태
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isModified, setIsModified] = useState(false);
|
||||
|
||||
// initialData 또는 loadRow가 변경되면 폼 데이터 갱신
|
||||
useEffect(() => {
|
||||
const source = loadRow ?? initialData ?? {};
|
||||
setFormData({ ...source });
|
||||
setErrors({});
|
||||
setIsModified(false);
|
||||
}, [loadRow, initialData]);
|
||||
|
||||
// 필드 값 변경
|
||||
const handleChange = useCallback((column: string, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [column]: value }));
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[column];
|
||||
return next;
|
||||
});
|
||||
setIsModified(true);
|
||||
}, []);
|
||||
|
||||
// 제출
|
||||
const handleSubmit = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// required 검증
|
||||
const newErrors: Record<string, string> = {};
|
||||
for (const field of formFields) {
|
||||
if (field.required && isFieldEmpty(formData[field.column])) {
|
||||
newErrors[field.column] = `${field.label}은(는) 필수입니다`;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit?.(formData);
|
||||
}, [formData, formFields, onSubmit]);
|
||||
|
||||
// 초기화
|
||||
const handleReset = useCallback(() => {
|
||||
const source = loadRow ?? initialData ?? {};
|
||||
setFormData({ ...source });
|
||||
setErrors({});
|
||||
setIsModified(false);
|
||||
}, [loadRow, initialData]);
|
||||
|
||||
const gridCols = config.columns === 3 ? 'grid-cols-3' : config.columns === 1 ? 'grid-cols-1' : 'grid-cols-2';
|
||||
|
||||
// 섹션이 있으면 섹션별로 그룹핑
|
||||
const sections = config.sections;
|
||||
|
||||
const renderField = (field: FieldConfig) => {
|
||||
const isDisabled = !field.editable || (field.pk && field.type === 'code');
|
||||
return (
|
||||
<div key={field.column} className="space-y-0.5">
|
||||
<label className="flex items-center gap-0.5 text-xs font-medium text-[var(--v5-text-sec)]">
|
||||
{field.label}
|
||||
{field.required && <span className="text-[var(--v5-red)]">*</span>}
|
||||
</label>
|
||||
<FieldRenderer
|
||||
field={field}
|
||||
value={formData[field.column]}
|
||||
onChange={(v) => handleChange(field.column, v)}
|
||||
mode="form"
|
||||
disabled={isDisabled}
|
||||
error={errors[field.column]}
|
||||
/>
|
||||
{errors[field.column] && (
|
||||
<p className="text-[0.65rem] text-[var(--v5-red)]">{errors[field.column]}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="fc-form rounded-md border border-[var(--v5-glass-border)] p-3
|
||||
bg-[var(--v5-glass)] backdrop-blur-[20px]"
|
||||
>
|
||||
{sections && sections.length > 0 ? (
|
||||
// 섹션별 렌더링
|
||||
sections.map((section) => {
|
||||
const sectionFields = formFields.filter((f) => section.fields.includes(f.column));
|
||||
if (sectionFields.length === 0) return null;
|
||||
return (
|
||||
<div key={section.label} className="mb-3">
|
||||
<h4 className="text-xs font-semibold text-[var(--v5-primary)] mb-2 pb-1
|
||||
border-b border-[var(--v5-border-subtle)]">
|
||||
{section.label}
|
||||
</h4>
|
||||
<div className={`grid ${gridCols} gap-x-3 gap-y-2`}>
|
||||
{sectionFields.map(renderField)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// 단일 섹션
|
||||
<div className={`grid ${gridCols} gap-x-3 gap-y-2`}>
|
||||
{formFields.map(renderField)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex items-center justify-end gap-2 mt-3 pt-2 border-t border-[var(--v5-border-subtle)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
disabled={!isModified}
|
||||
className="px-3 py-1 rounded text-xs border border-[var(--v5-border)]
|
||||
text-[var(--v5-text-sec)] bg-[var(--v5-surface)]
|
||||
hover:border-[var(--v5-primary)] hover:text-[var(--v5-primary)]
|
||||
disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-3 py-1 rounded text-xs font-medium text-white
|
||||
bg-[var(--v5-primary)] hover:opacity-90
|
||||
shadow-[var(--v5-glow-sm)] transition-all"
|
||||
>
|
||||
{config.saveAction.method === 'INSERT' ? '등록' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
|
||||
import type { PaginationConfig } from '@/types/invyone-component';
|
||||
|
||||
const DEFAULT_CONFIG: PaginationConfig = {
|
||||
pageSize: 20,
|
||||
showSizeSelector: true,
|
||||
sizeOptions: [10, 20, 50, 100],
|
||||
};
|
||||
|
||||
interface FcPaginationProps {
|
||||
config?: Partial<PaginationConfig>;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize?: number;
|
||||
onPageChange?: (params: { page: number; size: number }) => void;
|
||||
}
|
||||
|
||||
export function FcPagination({
|
||||
config: configOverride,
|
||||
total,
|
||||
page,
|
||||
pageSize: pageSizeOverride,
|
||||
onPageChange,
|
||||
}: FcPaginationProps) {
|
||||
const config = useMemo(() => ({ ...DEFAULT_CONFIG, ...configOverride }), [configOverride]);
|
||||
const size = pageSizeOverride ?? config.pageSize;
|
||||
const totalPages = Math.max(1, Math.ceil(total / size));
|
||||
|
||||
const goTo = (p: number) => {
|
||||
const clamped = Math.max(1, Math.min(p, totalPages));
|
||||
if (clamped !== page) onPageChange?.({ page: clamped, size });
|
||||
};
|
||||
|
||||
// 보여줄 페이지 번호 계산 (최대 5개)
|
||||
const pages = useMemo(() => {
|
||||
const maxVisible = 5;
|
||||
let start = Math.max(1, page - Math.floor(maxVisible / 2));
|
||||
const end = Math.min(totalPages, start + maxVisible - 1);
|
||||
start = Math.max(1, end - maxVisible + 1);
|
||||
const arr: number[] = [];
|
||||
for (let i = start; i <= end; i++) arr.push(i);
|
||||
return arr;
|
||||
}, [page, totalPages]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 text-xs text-[var(--v5-text-sec)]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>총 {total.toLocaleString()}건</span>
|
||||
{config.showSizeSelector && (
|
||||
<select
|
||||
value={size}
|
||||
onChange={(e) => onPageChange?.({ page: 1, size: Number(e.target.value) })}
|
||||
className="h-6 rounded border border-[var(--v5-border)] bg-[var(--v5-surface)]
|
||||
px-1 text-xs text-[var(--v5-text)] outline-none"
|
||||
>
|
||||
{config.sizeOptions.map((s) => (
|
||||
<option key={s} value={s}>{s}건</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
<PgBtn onClick={() => goTo(1)} disabled={page <= 1}><ChevronsLeft className="h-3 w-3" /></PgBtn>
|
||||
<PgBtn onClick={() => goTo(page - 1)} disabled={page <= 1}><ChevronLeft className="h-3 w-3" /></PgBtn>
|
||||
{pages.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => goTo(p)}
|
||||
className={`h-6 min-w-[24px] rounded text-xs transition-colors
|
||||
${p === page
|
||||
? 'bg-[var(--v5-primary)] text-white shadow-[var(--v5-glow-sm)]'
|
||||
: 'hover:bg-[var(--v5-surface-hover)] text-[var(--v5-text-sec)]'}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
<PgBtn onClick={() => goTo(page + 1)} disabled={page >= totalPages}><ChevronRight className="h-3 w-3" /></PgBtn>
|
||||
<PgBtn onClick={() => goTo(totalPages)} disabled={page >= totalPages}><ChevronsRight className="h-3 w-3" /></PgBtn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PgBtn({ children, onClick, disabled }: { children: React.ReactNode; onClick: () => void; disabled: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="flex h-6 w-6 items-center justify-center rounded
|
||||
text-[var(--v5-text-muted)] hover:text-[var(--v5-primary)] hover:bg-[var(--v5-surface-hover)]
|
||||
disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { Search, RotateCcw } from 'lucide-react';
|
||||
import { FieldRenderer } from './fields/FieldRenderer';
|
||||
import type { FieldConfig, SearchConfig } from '@/types/invyone-component';
|
||||
|
||||
const DEFAULT_CONFIG: SearchConfig = {
|
||||
dateRangeEnabled: true,
|
||||
showResetButton: true,
|
||||
autoSearch: false,
|
||||
layout: 'inline',
|
||||
};
|
||||
|
||||
interface FcSearchProps {
|
||||
fields: FieldConfig[];
|
||||
config?: Partial<SearchConfig>;
|
||||
onSearch?: (params: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
export function FcSearch({ fields, config: configOverride, onSearch }: FcSearchProps) {
|
||||
const config = useMemo(() => ({ ...DEFAULT_CONFIG, ...configOverride }), [configOverride]);
|
||||
|
||||
// searchable 필드만 추출, order 순
|
||||
const searchFields = useMemo(
|
||||
() => fields
|
||||
.filter((f) => f.searchable && !f.system)
|
||||
.sort((a, b) => a.order - b.order),
|
||||
[fields],
|
||||
);
|
||||
|
||||
const [values, setValues] = useState<Record<string, any>>({});
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const handleChange = useCallback((column: string, value: any) => {
|
||||
setValues((prev) => {
|
||||
const next = { ...prev };
|
||||
if (value === undefined || value === '' || value === null) {
|
||||
delete next[column];
|
||||
} else {
|
||||
next[column] = value;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// autoSearch: 값 변경 시 300ms 디바운스 후 자동 검색
|
||||
useEffect(() => {
|
||||
if (!config.autoSearch) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onSearch?.(buildSearchParams(values, searchFields));
|
||||
}, 300);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [values, config.autoSearch, onSearch, searchFields]);
|
||||
|
||||
const handleSubmit = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSearch?.(buildSearchParams(values, searchFields));
|
||||
}, [values, searchFields, onSearch]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setValues({});
|
||||
onSearch?.({});
|
||||
}, [onSearch]);
|
||||
|
||||
if (searchFields.length === 0) return null;
|
||||
|
||||
const isInline = config.layout === 'inline';
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="fc-search rounded-md border border-[var(--v5-glass-border)] p-2
|
||||
bg-[var(--v5-glass)] backdrop-blur-[20px]"
|
||||
>
|
||||
<div className={isInline
|
||||
? 'flex flex-wrap items-end gap-2'
|
||||
: 'grid grid-cols-1 gap-2'
|
||||
}>
|
||||
{searchFields.map((field) => (
|
||||
<div
|
||||
key={field.column}
|
||||
className={isInline ? 'flex flex-col gap-0.5 min-w-[140px] max-w-[220px]' : 'space-y-0.5'}
|
||||
>
|
||||
<label className="text-[0.65rem] font-medium text-[var(--v5-text-muted)] whitespace-nowrap">
|
||||
{field.label}
|
||||
</label>
|
||||
<FieldRenderer
|
||||
field={field}
|
||||
value={values[field.column]}
|
||||
onChange={(v) => handleChange(field.column, v)}
|
||||
mode="search"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex items-end gap-1 ml-auto">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center gap-1 h-7 px-2.5 rounded text-xs font-medium text-white
|
||||
bg-[var(--v5-primary)] hover:opacity-90 shadow-[var(--v5-glow-sm)] transition-all"
|
||||
>
|
||||
<Search className="h-3 w-3" />
|
||||
검색
|
||||
</button>
|
||||
{config.showResetButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="flex items-center gap-1 h-7 px-2 rounded text-xs border border-[var(--v5-border)]
|
||||
text-[var(--v5-text-sec)] bg-[var(--v5-surface)]
|
||||
hover:border-[var(--v5-primary)] hover:text-[var(--v5-primary)] transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
초기화
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 값을 API 파라미터 형식으로 변환.
|
||||
* date 범위: { order_date_from, order_date_to }
|
||||
* number 범위: { amount_min, amount_max }
|
||||
* select 다중: { status: ['확정','완료'] }
|
||||
* text 부분 일치: { customer_name: '삼성' }
|
||||
*/
|
||||
function buildSearchParams(values: Record<string, any>, fields: FieldConfig[]): Record<string, any> {
|
||||
const params: Record<string, any> = {};
|
||||
|
||||
for (const field of fields) {
|
||||
const val = values[field.column];
|
||||
if (val === undefined || val === null || val === '') continue;
|
||||
|
||||
if ((field.type === 'date' || field.type === 'datetime') && typeof val === 'object' && !Array.isArray(val)) {
|
||||
// 범위: {from, to}
|
||||
if (val.from) params[`${field.column}_from`] = val.from;
|
||||
if (val.to) params[`${field.column}_to`] = val.to;
|
||||
} else if (field.type === 'number' && typeof val === 'object' && !Array.isArray(val)) {
|
||||
// 범위: {min, max}
|
||||
if (val.min !== undefined) params[`${field.column}_min`] = val.min;
|
||||
if (val.max !== undefined) params[`${field.column}_max`] = val.max;
|
||||
} else {
|
||||
params[field.column] = val;
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
type RowSelectionState,
|
||||
} from '@tanstack/react-table';
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown, Loader2 } from 'lucide-react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { CellRenderer } from './table/CellRenderer';
|
||||
import type { FieldConfig, TableConfig } from '@/types/invyone-component';
|
||||
|
||||
const DEFAULT_CONFIG: TableConfig = {
|
||||
pageSize: 20,
|
||||
selectionMode: 'single',
|
||||
showCheckbox: false,
|
||||
inlineEdit: false,
|
||||
autoLoad: true,
|
||||
toolbar: { showExcel: false, showRefresh: true, showFilter: false },
|
||||
style: 'compact',
|
||||
};
|
||||
|
||||
interface FcTableProps {
|
||||
fields: FieldConfig[];
|
||||
data: Record<string, any>[];
|
||||
config?: Partial<TableConfig>;
|
||||
loading?: boolean;
|
||||
onRowSelect?: (row: Record<string, any>) => void;
|
||||
onRowsSelect?: (rows: Record<string, any>[]) => void;
|
||||
selectedRowIndex?: number;
|
||||
}
|
||||
|
||||
export function FcTable({
|
||||
fields,
|
||||
data,
|
||||
config: configOverride,
|
||||
loading,
|
||||
onRowSelect,
|
||||
onRowsSelect,
|
||||
selectedRowIndex,
|
||||
}: FcTableProps) {
|
||||
const config = useMemo(() => ({ ...DEFAULT_CONFIG, ...configOverride }), [configOverride]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>(
|
||||
config.defaultSort
|
||||
? [{ id: config.defaultSort.column, desc: config.defaultSort.direction === 'desc' }]
|
||||
: [],
|
||||
);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
// visible + order 기준으로 필드 정렬
|
||||
const visibleFields = useMemo(
|
||||
() => fields.filter((f) => f.visible).sort((a, b) => a.order - b.order),
|
||||
[fields],
|
||||
);
|
||||
|
||||
// TanStack 컬럼 정의 생성
|
||||
const columns = useMemo<ColumnDef<Record<string, any>>[]>(() => {
|
||||
const cols: ColumnDef<Record<string, any>>[] = [];
|
||||
|
||||
// 체크박스 컬럼
|
||||
if (config.showCheckbox && config.selectionMode === 'multiple') {
|
||||
cols.push({
|
||||
id: '_select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
||||
/>
|
||||
),
|
||||
size: 36,
|
||||
enableSorting: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 데이터 컬럼
|
||||
for (const field of visibleFields) {
|
||||
cols.push({
|
||||
id: field.column,
|
||||
accessorKey: field.column,
|
||||
header: ({ column }) => {
|
||||
if (!field.sortable) {
|
||||
return <span>{field.label}</span>;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-0.5 hover:text-[var(--v5-primary)] transition-colors"
|
||||
onClick={() => column.toggleSorting()}
|
||||
>
|
||||
<span>{field.label}</span>
|
||||
{column.getIsSorted() === 'asc' ? (
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
) : column.getIsSorted() === 'desc' ? (
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowUpDown className="h-2.5 w-2.5 opacity-40" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
cell: ({ getValue }) => <CellRenderer field={field} value={getValue()} />,
|
||||
size: field.width ?? getDefaultWidth(field.type),
|
||||
enableSorting: field.sortable ?? true,
|
||||
meta: { align: field.align ?? (field.type === 'number' ? 'right' : 'left') },
|
||||
});
|
||||
}
|
||||
|
||||
return cols;
|
||||
}, [visibleFields, config.showCheckbox, config.selectionMode]);
|
||||
|
||||
// 행 선택 변경 콜백
|
||||
const handleRowSelectionChange = useCallback(
|
||||
(updater: RowSelectionState | ((old: RowSelectionState) => RowSelectionState)) => {
|
||||
setRowSelection((prev) => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater;
|
||||
if (onRowsSelect) {
|
||||
const selectedRows = Object.keys(next)
|
||||
.filter((k) => next[k])
|
||||
.map((k) => data[Number(k)])
|
||||
.filter(Boolean);
|
||||
onRowsSelect(selectedRows);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[data, onRowsSelect],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: { sorting, rowSelection },
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: handleRowSelectionChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
enableMultiRowSelection: config.selectionMode === 'multiple',
|
||||
});
|
||||
|
||||
// 데이터 변경 시 선택 초기화
|
||||
useEffect(() => {
|
||||
setRowSelection({});
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="fc-table-wrap rounded-md border border-[var(--v5-glass-border)] overflow-hidden
|
||||
bg-[var(--v5-glass)] backdrop-blur-[20px]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((hg) => (
|
||||
<tr key={hg.id} className="border-b border-[var(--v5-border)]">
|
||||
{hg.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
className="px-2 py-1.5 text-left font-semibold text-[var(--v5-text-sec)]
|
||||
bg-[var(--v5-surface)] whitespace-nowrap select-none"
|
||||
style={{ width: header.getSize() }}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="text-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin mx-auto text-[var(--v5-primary)]" />
|
||||
</td>
|
||||
</tr>
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="text-center py-8 text-[var(--v5-text-muted)]">
|
||||
데이터가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row, idx) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
onClick={() => onRowSelect?.(row.original)}
|
||||
className={`border-b border-[var(--v5-border-subtle)] cursor-pointer transition-colors
|
||||
hover:bg-[var(--v5-surface-hover)]
|
||||
${selectedRowIndex === idx ? 'bg-[var(--v5-primary-glow)]' : ''}`}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const align = (cell.column.columnDef.meta as any)?.align ?? 'left';
|
||||
return (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={`px-2 py-1 whitespace-nowrap text-[var(--v5-text)]
|
||||
${align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left'}`}
|
||||
style={{ maxWidth: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultWidth(type: string): number {
|
||||
switch (type) {
|
||||
case 'number': return 100;
|
||||
case 'date': return 120;
|
||||
case 'datetime': return 160;
|
||||
case 'checkbox': return 60;
|
||||
case 'entity': return 180;
|
||||
case 'textarea': return 200;
|
||||
case 'code': return 120;
|
||||
default: return 150;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { FieldConfig } from '@/types/invyone-component';
|
||||
|
||||
interface CheckboxFieldProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
mode: 'form' | 'search';
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function CheckboxField({ field, value, onChange, mode, disabled, error }: CheckboxFieldProps) {
|
||||
if (mode === 'search') {
|
||||
// 검색: Select (전체/✓/✗)
|
||||
return (
|
||||
<Select
|
||||
value={value === true ? 'true' : value === false ? 'false' : ''}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'true') onChange(true);
|
||||
else if (v === 'false') onChange(false);
|
||||
else onChange(undefined);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className={`h-7 text-xs ${error ? 'border-destructive' : ''}`}>
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all" className="text-xs">전체</SelectItem>
|
||||
<SelectItem value="true" className="text-xs">✓</SelectItem>
|
||||
<SelectItem value="false" className="text-xs">✗</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// 폼: 체크박스
|
||||
const checked = value === true || value === 'true' || value === 't';
|
||||
return (
|
||||
<div className="flex items-center gap-2 h-7">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(c) => onChange(c === true)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="text-xs text-[var(--v5-text-sec)]">{field.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Lock } from 'lucide-react';
|
||||
import type { FieldConfig } from '@/types/invyone-component';
|
||||
|
||||
interface CodeFieldProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
mode: 'form' | 'search';
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function CodeField({ field, value, onChange, mode, disabled, error }: CodeFieldProps) {
|
||||
if (mode === 'search') {
|
||||
// 검색: 완전 일치 텍스트 입력
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value || undefined)}
|
||||
placeholder={field.placeholder ?? field.label}
|
||||
disabled={disabled}
|
||||
className={`h-7 text-xs ${error ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 폼: readonly 표시 (자동채번)
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
readOnly
|
||||
disabled
|
||||
placeholder="자동채번"
|
||||
className="h-7 text-xs flex-1 bg-[var(--v5-bg-subtle)] text-[var(--v5-text-muted)]"
|
||||
/>
|
||||
<Lock className="h-3 w-3 shrink-0 text-[var(--v5-text-muted)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { FieldConfig } from '@/types/invyone-component';
|
||||
|
||||
interface DateFieldProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
mode: 'form' | 'search';
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function DateField({ field, value, onChange, mode, disabled, error }: DateFieldProps) {
|
||||
if (mode === 'search') {
|
||||
// 검색: DateRangePicker (시작~종료)
|
||||
const from = value?.from ?? '';
|
||||
const to = value?.to ?? '';
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="date"
|
||||
value={from}
|
||||
onChange={(e) => onChange({ ...value, from: e.target.value || undefined })}
|
||||
disabled={disabled}
|
||||
className={`h-7 text-xs flex-1 ${error ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
<span className="text-xs text-[var(--v5-text-muted)]">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={to}
|
||||
onChange={(e) => onChange({ ...value, to: e.target.value || undefined })}
|
||||
disabled={disabled}
|
||||
className={`h-7 text-xs flex-1 ${error ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
disabled={disabled}
|
||||
className={`h-7 text-xs ${error ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { FieldConfig } from '@/types/invyone-component';
|
||||
|
||||
interface DateTimeFieldProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
mode: 'form' | 'search';
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function DateTimeField({ field, value, onChange, mode, disabled, error }: DateTimeFieldProps) {
|
||||
if (mode === 'search') {
|
||||
const from = value?.from ?? '';
|
||||
const to = value?.to ?? '';
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={from}
|
||||
onChange={(e) => onChange({ ...value, from: e.target.value || undefined })}
|
||||
disabled={disabled}
|
||||
className={`h-7 text-xs flex-1 ${error ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
<span className="text-xs text-[var(--v5-text-muted)]">~</span>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={to}
|
||||
onChange={(e) => onChange({ ...value, to: e.target.value || undefined })}
|
||||
disabled={disabled}
|
||||
className={`h-7 text-xs flex-1 ${error ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
disabled={disabled}
|
||||
className={`h-7 text-xs ${error ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Search } from 'lucide-react';
|
||||
import type { FieldConfig } from '@/types/invyone-component';
|
||||
|
||||
interface EntityFieldProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
mode: 'form' | 'search';
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* entity 타입 필드 — 팝업 검색 + 입력
|
||||
* Phase 2에서는 텍스트 입력 + 검색 버튼 UI만 구현.
|
||||
* 실제 팝업 검색은 Phase 3+ 에서 ref 테이블 조회와 함께 구현.
|
||||
*/
|
||||
export function EntityField({ field, value, onChange, mode, disabled, error }: EntityFieldProps) {
|
||||
const displayLabel = field.ref
|
||||
? `${field.label} (${field.ref.table})`
|
||||
: field.label;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
placeholder={field.placeholder ?? displayLabel}
|
||||
disabled={disabled}
|
||||
className={`h-7 text-xs flex-1 ${error ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-input
|
||||
bg-[var(--v5-surface)] text-[var(--v5-text-muted)] transition-colors
|
||||
hover:border-[var(--v5-primary)] hover:text-[var(--v5-primary)]
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={`${field.label} 검색`}
|
||||
>
|
||||
<Search className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import type { FieldConfig } from '@/types/invyone-component';
|
||||
import { TextField } from './TextField';
|
||||
import { NumberField } from './NumberField';
|
||||
import { DateField } from './DateField';
|
||||
import { DateTimeField } from './DateTimeField';
|
||||
import { SelectField } from './SelectField';
|
||||
import { EntityField } from './EntityField';
|
||||
import { CheckboxField } from './CheckboxField';
|
||||
import { TextareaField } from './TextareaField';
|
||||
import { FileField } from './FileField';
|
||||
import { CodeField } from './CodeField';
|
||||
|
||||
export interface FieldRendererProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
mode: 'form' | 'search';
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FieldConfig.type을 보고 적절한 입력 위젯을 렌더한다.
|
||||
*
|
||||
* mode='form': 폼 렌더링 규칙 (date→DatePicker, select→Select)
|
||||
* mode='search': 검색 렌더링 규칙 (date→DateRangePicker, select→MultiSelect)
|
||||
*/
|
||||
export function FieldRenderer(props: FieldRendererProps) {
|
||||
switch (props.field.type) {
|
||||
case 'text':
|
||||
return <TextField {...props} />;
|
||||
case 'number':
|
||||
return <NumberField {...props} />;
|
||||
case 'date':
|
||||
return <DateField {...props} />;
|
||||
case 'datetime':
|
||||
return <DateTimeField {...props} />;
|
||||
case 'select':
|
||||
return <SelectField {...props} />;
|
||||
case 'entity':
|
||||
return <EntityField {...props} />;
|
||||
case 'checkbox':
|
||||
return <CheckboxField {...props} />;
|
||||
case 'textarea':
|
||||
return <TextareaField {...props} />;
|
||||
case 'file':
|
||||
return <FileField {...props} />;
|
||||
case 'code':
|
||||
return <CodeField {...props} />;
|
||||
default:
|
||||
return <TextField {...props} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { Upload } from 'lucide-react';
|
||||
import type { FieldConfig } from '@/types/invyone-component';
|
||||
|
||||
interface FileFieldProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
mode: 'form' | 'search';
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* file 타입: 검색에서는 렌더하지 않음.
|
||||
* Phase 2에서는 파일 선택 UI만 제공, 실제 업로드는 Phase 3+.
|
||||
*/
|
||||
export function FileField({ field, value, onChange, mode, disabled, error }: FileFieldProps) {
|
||||
if (mode === 'search') {
|
||||
return null; // 검색 불가
|
||||
}
|
||||
|
||||
const fileName = typeof value === 'string' ? value : value?.name ?? '';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
className={`flex h-7 cursor-pointer items-center gap-1.5 rounded-md border px-2 text-xs
|
||||
transition-colors
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-[var(--v5-primary)] hover:text-[var(--v5-primary)]'}
|
||||
${error ? 'border-destructive' : 'border-input'}
|
||||
bg-[var(--v5-surface)] text-[var(--v5-text-sec)]`}
|
||||
>
|
||||
<Upload className="h-3 w-3" />
|
||||
<span>파일 선택</span>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
onChange(file ?? null);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{fileName && (
|
||||
<span className="truncate text-xs text-[var(--v5-text-sec)]">{fileName}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { FieldConfig } from '@/types/invyone-component';
|
||||
|
||||
interface NumberFieldProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
mode: 'form' | 'search';
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function NumberField({ field, value, onChange, mode, disabled, error }: NumberFieldProps) {
|
||||
if (mode === 'search') {
|
||||
// 검색: min~max 범위 입력 2개
|
||||
const min = value?.min ?? '';
|
||||
const max = value?.max ?? '';
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={min}
|
||||
onChange={(e) => onChange({ ...value, min: e.target.value === '' ? undefined : Number(e.target.value) })}
|
||||
placeholder="최소"
|
||||
disabled={disabled}
|
||||
className={`h-7 text-xs flex-1 ${error ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
<span className="text-xs text-[var(--v5-text-muted)]">~</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={max}
|
||||
onChange={(e) => onChange({ ...value, max: e.target.value === '' ? undefined : Number(e.target.value) })}
|
||||
placeholder="최대"
|
||||
disabled={disabled}
|
||||
className={`h-7 text-xs flex-1 ${error ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value === '' ? null : Number(e.target.value))}
|
||||
placeholder={field.placeholder ?? field.label}
|
||||
disabled={disabled}
|
||||
className={`h-7 text-xs text-right ${error ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import type { FieldConfig, FieldOption } from '@/types/invyone-component';
|
||||
|
||||
interface SelectFieldProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
mode: 'form' | 'search';
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** FieldOption → { value, label } 정규화. string이면 value=label */
|
||||
function normalizeOption(opt: FieldOption): { value: string; label: string } {
|
||||
if (typeof opt === 'string') return { value: opt, label: opt };
|
||||
return { value: opt.value, label: opt.label };
|
||||
}
|
||||
|
||||
/** value로 label 찾기 */
|
||||
function findLabel(options: FieldOption[], val: string): string {
|
||||
for (const opt of options) {
|
||||
const norm = normalizeOption(opt);
|
||||
if (norm.value === val) return norm.label;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
export function SelectField({ field, value, onChange, mode, disabled, error }: SelectFieldProps) {
|
||||
const rawOptions = field.options ?? [];
|
||||
const options = rawOptions.map(normalizeOption);
|
||||
|
||||
if (mode === 'search') {
|
||||
// 검색: MultiSelect (다중, 체크박스) — ★ value를 저장/전송
|
||||
const selected: string[] = Array.isArray(value) ? value : [];
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const toggle = (optValue: string) => {
|
||||
const next = selected.includes(optValue)
|
||||
? selected.filter((v) => v !== optValue)
|
||||
: [...selected, optValue];
|
||||
onChange(next.length > 0 ? next : undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
disabled={disabled}
|
||||
className={`flex h-7 w-full items-center justify-between rounded-md border bg-transparent px-2 text-xs
|
||||
${error ? 'border-destructive' : 'border-input'}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{selected.length > 0
|
||||
? selected.map((v) => findLabel(rawOptions, v)).join(', ')
|
||||
: field.label}
|
||||
</span>
|
||||
<svg className="h-3 w-3 opacity-50 shrink-0" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-md border bg-popover p-1 shadow-md">
|
||||
{options.map((opt) => (
|
||||
<label
|
||||
key={opt.value}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-xs hover:bg-accent"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected.includes(opt.value)}
|
||||
onCheckedChange={() => toggle(opt.value)}
|
||||
/>
|
||||
<span>{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
{options.length === 0 && (
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground">옵션 없음</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 폼: 단일 선택 — ★ value를 저장, label을 표시
|
||||
return (
|
||||
<Select
|
||||
value={value ?? ''}
|
||||
onValueChange={(v) => onChange(v || null)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className={`h-7 text-xs ${error ? 'border-destructive' : ''}`}>
|
||||
<SelectValue placeholder={field.placeholder ?? field.label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { FieldConfig } from '@/types/invyone-component';
|
||||
|
||||
interface TextFieldProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
mode: 'form' | 'search';
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function TextField({ field, value, onChange, mode, disabled, error }: TextFieldProps) {
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder ?? field.label}
|
||||
disabled={disabled}
|
||||
className={`h-7 text-xs ${error ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { FieldConfig } from '@/types/invyone-component';
|
||||
|
||||
interface TextareaFieldProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
mode: 'form' | 'search';
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function TextareaField({ field, value, onChange, mode, disabled, error }: TextareaFieldProps) {
|
||||
if (mode === 'search') {
|
||||
// 검색: 일반 텍스트 입력 (부분 일치)
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value || undefined)}
|
||||
placeholder={field.placeholder ?? field.label}
|
||||
disabled={disabled}
|
||||
className={`h-7 text-xs ${error ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<textarea
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder ?? field.label}
|
||||
disabled={disabled}
|
||||
rows={3}
|
||||
className={`flex w-full rounded-md border bg-transparent px-3 py-2 text-xs
|
||||
placeholder:text-muted-foreground
|
||||
focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]
|
||||
disabled:cursor-not-allowed disabled:opacity-50 outline-none
|
||||
${error ? 'border-destructive' : 'border-input'}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// FieldConfig 기반 컴포넌트 공개 exports
|
||||
export { FcTable } from './FcTable';
|
||||
export { FcForm } from './FcForm';
|
||||
export { FcSearch } from './FcSearch';
|
||||
export { FcButton } from './FcButton';
|
||||
export { FcButtonBar } from './FcButtonBar';
|
||||
export { FcPagination } from './FcPagination';
|
||||
export { FieldRenderer } from './fields/FieldRenderer';
|
||||
export { CellRenderer } from './table/CellRenderer';
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { Check, X, FileText } from 'lucide-react';
|
||||
import type { FieldConfig, FieldOption } from '@/types/invyone-component';
|
||||
|
||||
interface CellRendererProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* FieldType에 따라 셀 내용을 포맷팅한다.
|
||||
* 렌더링 계약(spec Section 2.2) 테이블 렌더 컬럼 기준.
|
||||
*/
|
||||
export function CellRenderer({ field, value }: CellRendererProps) {
|
||||
if (value === null || value === undefined) {
|
||||
return <span className="text-[var(--v5-text-muted)]">-</span>;
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case 'number':
|
||||
return <span>{formatNumber(value, field.format)}</span>;
|
||||
|
||||
case 'date':
|
||||
return <span>{formatDate(value)}</span>;
|
||||
|
||||
case 'datetime':
|
||||
return <span>{formatDateTime(value)}</span>;
|
||||
|
||||
case 'checkbox':
|
||||
return value === true || value === 'true' || value === 't'
|
||||
? <Check className="h-3.5 w-3.5 text-[var(--v5-green)]" />
|
||||
: <X className="h-3.5 w-3.5 text-[var(--v5-text-muted)]" />;
|
||||
|
||||
case 'textarea':
|
||||
// 40자 말줄임
|
||||
return <span title={String(value)}>
|
||||
{String(value).length > 40 ? String(value).slice(0, 40) + '...' : String(value)}
|
||||
</span>;
|
||||
|
||||
case 'file':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[var(--v5-primary)] cursor-pointer">
|
||||
<FileText className="h-3 w-3" />
|
||||
<span className="truncate max-w-[120px]">{String(value)}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
case 'entity':
|
||||
// ref.displayColumn 값은 조인된 데이터에서 오므로 그대로 표시
|
||||
return <span>{String(value)}</span>;
|
||||
|
||||
case 'select':
|
||||
// ★ select: value→label 변환하여 표시
|
||||
return <span>{resolveOptionLabel(field.options, value)}</span>;
|
||||
|
||||
default:
|
||||
// text, code: 텍스트 그대로
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
/** select value → label 변환. 못 찾으면 value 그대로 표시 */
|
||||
function resolveOptionLabel(options: FieldOption[] | undefined, value: any): string {
|
||||
if (!options || !value) return String(value ?? '');
|
||||
const strVal = String(value);
|
||||
for (const opt of options) {
|
||||
if (typeof opt === 'string') {
|
||||
if (opt === strVal) return opt;
|
||||
} else {
|
||||
if (opt.value === strVal) return opt.label;
|
||||
}
|
||||
}
|
||||
return strVal;
|
||||
}
|
||||
|
||||
function formatNumber(value: any, format?: string): string {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return String(value);
|
||||
// #,##0 포맷 — 천 단위 콤마
|
||||
if (format === '#,##0' || !format) {
|
||||
return num.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
|
||||
}
|
||||
// #,##0.00 등 소수점
|
||||
const match = format.match(/\.(\d+)/);
|
||||
if (match) {
|
||||
const decimals = match[1].length;
|
||||
return num.toLocaleString('ko-KR', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||
}
|
||||
return num.toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
function formatDate(value: any): string {
|
||||
if (!value) return '';
|
||||
try {
|
||||
const d = new Date(value);
|
||||
if (isNaN(d.getTime())) return String(value);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value: any): string {
|
||||
if (!value) return '';
|
||||
try {
|
||||
const d = new Date(value);
|
||||
if (isNaN(d.getTime())) return String(value);
|
||||
const date = formatDate(value);
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
const min = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${date} ${h}:${min}`;
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@@ -935,8 +935,17 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
</aside>
|
||||
|
||||
{/* Content area */}
|
||||
{/* ★ INVYONE 신규 페이지는 children 직접 렌더, 기존 VEX 페이지는 탭 시스템 */}
|
||||
<main className="v5-content flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{pathname && (
|
||||
pathname.startsWith('/dash') ||
|
||||
pathname.startsWith('/admin/builder') ||
|
||||
pathname.startsWith('/test-fc')
|
||||
) ? (
|
||||
<div className="relative min-h-0 flex-1 overflow-auto">{children}</div>
|
||||
) : (
|
||||
<TabContent />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* INVYONE 비즈니스 룰 API 클라이언트 (Phase 5 — 제어 모드)
|
||||
* ★ 별도 인터페이스 정의 안 함 — 전부 Record<string, any>
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export async function getBusinessRuleList(dashboardId: string): Promise<Record<string, any>> {
|
||||
const res = await apiClient.get(`/dashboards/${dashboardId}/rules`);
|
||||
return res.data.data ?? { list: [], total_count: 0 };
|
||||
}
|
||||
|
||||
export async function getBusinessRuleInfo(ruleId: string): Promise<Record<string, any> | null> {
|
||||
const res = await apiClient.get(`/rules/${ruleId}`);
|
||||
return res.data.data ?? null;
|
||||
}
|
||||
|
||||
export async function insertBusinessRule(
|
||||
dashboardId: string,
|
||||
data: Record<string, any>
|
||||
): Promise<Record<string, any>> {
|
||||
const res = await apiClient.post(`/dashboards/${dashboardId}/rules`, data);
|
||||
return res.data.data;
|
||||
}
|
||||
|
||||
export async function updateBusinessRule(
|
||||
ruleId: string,
|
||||
data: Record<string, any>
|
||||
): Promise<void> {
|
||||
await apiClient.put(`/rules/${ruleId}`, data);
|
||||
}
|
||||
|
||||
export async function deleteBusinessRule(ruleId: string): Promise<void> {
|
||||
await apiClient.delete(`/rules/${ruleId}`);
|
||||
}
|
||||
|
||||
export async function toggleBusinessRule(ruleId: string): Promise<void> {
|
||||
await apiClient.put(`/rules/${ruleId}/toggle`);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
// ═══ 대시보드 CRUD (Phase 4 — /api/dashboards) ═══
|
||||
|
||||
export async function getDashboardList(params?: Record<string, any>) {
|
||||
const res = await apiClient.get('/dashboards', { params });
|
||||
return res.data.data;
|
||||
}
|
||||
|
||||
export async function getDashboardInfo(dashboardId: string) {
|
||||
const res = await apiClient.get(`/dashboards/${dashboardId}`);
|
||||
return res.data.success ? res.data.data : null;
|
||||
}
|
||||
|
||||
export async function insertDashboard(data: Record<string, any>) {
|
||||
const res = await apiClient.post('/dashboards', data);
|
||||
return res.data.data;
|
||||
}
|
||||
|
||||
export async function updateDashboard(dashboardId: string, data: Record<string, any>) {
|
||||
await apiClient.put(`/dashboards/${dashboardId}`, data);
|
||||
}
|
||||
|
||||
export async function deleteDashboard(dashboardId: string) {
|
||||
await apiClient.delete(`/dashboards/${dashboardId}`);
|
||||
}
|
||||
|
||||
// ═══ 카드 CRUD ═══
|
||||
|
||||
export async function getDashboardCards(dashboardId: string) {
|
||||
const res = await apiClient.get(`/dashboards/${dashboardId}/cards`);
|
||||
return res.data.data ?? [];
|
||||
}
|
||||
|
||||
export async function insertDashboardCard(dashboardId: string, data: Record<string, any>) {
|
||||
const res = await apiClient.post(`/dashboards/${dashboardId}/cards`, data);
|
||||
return res.data.data;
|
||||
}
|
||||
|
||||
export async function updateDashboardCard(dashboardId: string, cardId: string, data: Record<string, any>) {
|
||||
await apiClient.put(`/dashboards/${dashboardId}/cards/${cardId}`, data);
|
||||
}
|
||||
|
||||
export async function deleteDashboardCard(dashboardId: string, cardId: string) {
|
||||
await apiClient.delete(`/dashboards/${dashboardId}/cards/${cardId}`);
|
||||
}
|
||||
|
||||
export async function updateCardPositionsBatch(dashboardId: string, cards: Record<string, any>[]) {
|
||||
await apiClient.put(`/dashboards/${dashboardId}/cards/batch`, { cards });
|
||||
}
|
||||
|
||||
// ═══ 사이드바 메뉴 ═══
|
||||
|
||||
export async function getSidebarMenu() {
|
||||
const res = await apiClient.get('/dashboards/sidebar/menu');
|
||||
return res.data.data ?? [];
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* FieldConfig 기반 데이터 CRUD API 래퍼
|
||||
* 기존 dataApi를 활용하면서 FieldConfig 컴포넌트에서 쓰기 편한 형태로 감쌈
|
||||
*
|
||||
* ★ 별도 인터페이스 정의 안 함 — Record<string, any>
|
||||
*/
|
||||
|
||||
import { dataApi } from './data';
|
||||
|
||||
/** FieldConfig 기반 목록 조회 */
|
||||
export async function fcList(params: Record<string, any>): Promise<Record<string, any>> {
|
||||
const { tableName, page = 1, size = 20, sortBy, sortOrder, ...filters } = params;
|
||||
return dataApi.getTableData(tableName, {
|
||||
page,
|
||||
size,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
filters,
|
||||
});
|
||||
}
|
||||
|
||||
/** FieldConfig 기반 단건 조회 */
|
||||
export async function fcGet(tableName: string, id: string): Promise<Record<string, any>> {
|
||||
const result = await dataApi.getRecordDetail(tableName, id);
|
||||
return result.data ?? {};
|
||||
}
|
||||
|
||||
/** FieldConfig 기반 등록 */
|
||||
export async function fcInsert(tableName: string, data: Record<string, any>): Promise<any> {
|
||||
return dataApi.createRecord(tableName, data);
|
||||
}
|
||||
|
||||
/** FieldConfig 기반 수정 */
|
||||
export async function fcUpdate(tableName: string, id: string, data: Record<string, any>): Promise<any> {
|
||||
return dataApi.updateRecord(tableName, id, data);
|
||||
}
|
||||
|
||||
/** FieldConfig 기반 삭제 */
|
||||
export async function fcDelete(tableName: string, ids: string[]): Promise<any> {
|
||||
// 단건씩 삭제 (bulk delete API가 있으면 교체)
|
||||
const results = await Promise.all(ids.map((id) => dataApi.deleteRecord(tableName, id)));
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* INVYONE 메타 API 클라이언트
|
||||
* DB 테이블 메타 → FieldConfig 변환 API
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type { FieldConfig, FieldRef } from '@/types/invyone-component';
|
||||
|
||||
/**
|
||||
* 접근 가능한 테이블 목록 조회
|
||||
* ★ 일반 API는 Record<string, any> — 별도 인터페이스 정의 안 함
|
||||
*/
|
||||
export async function getMetaTableList(): Promise<Record<string, any>[]> {
|
||||
const res = await apiClient.get('/meta/tables');
|
||||
return res.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 FieldConfig[] 반환
|
||||
* ★ fields만 FieldConfig[] 타입 (invyone-component.ts 규격 예외)
|
||||
*/
|
||||
export async function getMetaFields(tableName: string): Promise<{
|
||||
table_name: string;
|
||||
table_label: string;
|
||||
primary_key: string | null;
|
||||
fields: FieldConfig[];
|
||||
[key: string]: any;
|
||||
}> {
|
||||
const res = await apiClient.get(`/meta/tables/${tableName}/fields`);
|
||||
const data = res.data.data;
|
||||
|
||||
// 백엔드 snake_case → 프론트 camelCase 변환 (FieldConfig 규격 맞춤)
|
||||
if (data?.fields) {
|
||||
data.fields = data.fields.map(toFieldConfig);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 백엔드 응답 → FieldConfig 변환
|
||||
* 대부분 키가 단일 단어라 변환 불필요, ref 내부만 camelCase 변환
|
||||
*/
|
||||
function toFieldConfig(raw: Record<string, any>): FieldConfig {
|
||||
const field: FieldConfig = {
|
||||
column: raw.column,
|
||||
label: raw.label,
|
||||
type: raw.type,
|
||||
visible: raw.visible,
|
||||
order: raw.order,
|
||||
required: raw.required,
|
||||
editable: raw.editable,
|
||||
};
|
||||
|
||||
if (raw.width != null) field.width = raw.width;
|
||||
if (raw.align != null) field.align = raw.align;
|
||||
if (raw.defaultValue != null || raw.default_value != null) {
|
||||
field.defaultValue = raw.defaultValue ?? raw.default_value;
|
||||
}
|
||||
if (raw.placeholder != null) field.placeholder = raw.placeholder;
|
||||
if (raw.options != null) field.options = raw.options;
|
||||
if (raw.format != null) field.format = raw.format;
|
||||
if (raw.computed != null) field.computed = raw.computed;
|
||||
if (raw.pk != null) field.pk = raw.pk;
|
||||
if (raw.system != null) field.system = raw.system;
|
||||
if (raw.searchable != null) field.searchable = raw.searchable;
|
||||
if (raw.sortable != null) field.sortable = raw.sortable;
|
||||
|
||||
// ref: snake_case → camelCase 변환
|
||||
if (raw.ref != null) {
|
||||
field.ref = toFieldRef(raw.ref);
|
||||
}
|
||||
|
||||
return field;
|
||||
}
|
||||
|
||||
function toFieldRef(raw: Record<string, any>): FieldRef {
|
||||
return {
|
||||
table: raw.table,
|
||||
valueColumn: raw.value_column ?? raw.valueColumn,
|
||||
displayColumn: raw.display_column ?? raw.displayColumn,
|
||||
...(raw.search_columns || raw.searchColumns
|
||||
? { searchColumns: raw.search_columns ?? raw.searchColumns }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 간 업무 관계 조회 (Phase 5 — 제어 모드)
|
||||
* table_relationships 기반, 필드 참조(table_type_columns)와 별도
|
||||
*/
|
||||
export async function getMetaRelations(tableName: string): Promise<Record<string, any>[]> {
|
||||
const res = await apiClient.get(`/meta/tables/${tableName}/relations`);
|
||||
return res.data.data ?? [];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
export async function getUserOverride(cardId: string) {
|
||||
const res = await apiClient.get('/overrides', { params: { card_id: cardId } });
|
||||
return res.data.data;
|
||||
}
|
||||
|
||||
export async function upsertUserOverride(data: Record<string, any>) {
|
||||
await apiClient.put('/overrides', data);
|
||||
}
|
||||
|
||||
export async function deleteUserOverride(cardId: string) {
|
||||
await apiClient.delete('/overrides', { params: { card_id: cardId } });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* INVYONE Template CRUD API 클라이언트
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
/** 템플릿 목록 조회 */
|
||||
export async function getTemplateList(params?: Record<string, any>): Promise<Record<string, any>> {
|
||||
const res = await apiClient.get('/templates', { params });
|
||||
return res.data.data;
|
||||
}
|
||||
|
||||
/** 템플릿 상세 조회 */
|
||||
export async function getTemplateInfo(templateId: string): Promise<Record<string, any> | null> {
|
||||
const res = await apiClient.get(`/templates/${templateId}`);
|
||||
if (!res.data.success) return null;
|
||||
return res.data.data;
|
||||
}
|
||||
|
||||
/** 템플릿 생성 — template_id 반환 */
|
||||
export async function insertTemplate(data: Record<string, any>): Promise<Record<string, any>> {
|
||||
const res = await apiClient.post('/templates', data);
|
||||
return res.data.data;
|
||||
}
|
||||
|
||||
/** 템플릿 수정 */
|
||||
export async function updateTemplate(templateId: string, data: Record<string, any>): Promise<void> {
|
||||
await apiClient.put(`/templates/${templateId}`, data);
|
||||
}
|
||||
|
||||
/** 템플릿 게시 (draft → published) */
|
||||
export async function publishTemplate(templateId: string): Promise<void> {
|
||||
await apiClient.put(`/templates/${templateId}/publish`);
|
||||
}
|
||||
|
||||
/** 템플릿 삭제 (소프트) */
|
||||
export async function deleteTemplate(templateId: string): Promise<void> {
|
||||
await apiClient.delete(`/templates/${templateId}`);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
|
||||
interface DashboardState {
|
||||
dashboards: Record<string, any>[];
|
||||
activeDashboardId: string | null;
|
||||
cards: Record<string, any>[];
|
||||
editMode: boolean;
|
||||
loading: boolean;
|
||||
|
||||
setDashboards: (dashboards: Record<string, any>[]) => void;
|
||||
setActiveDashboard: (id: string | null) => void;
|
||||
setCards: (cards: Record<string, any>[]) => void;
|
||||
addCard: (card: Record<string, any>) => void;
|
||||
updateCard: (cardId: string, updates: Record<string, any>) => void;
|
||||
removeCard: (cardId: string) => void;
|
||||
toggleEditMode: () => void;
|
||||
setEditMode: (on: boolean) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
addDashboard: (dashboard: Record<string, any>) => void;
|
||||
updateDashboardInList: (id: string, updates: Record<string, any>) => void;
|
||||
removeDashboard: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useDashboardStore = create<DashboardState>()(
|
||||
devtools(
|
||||
(set) => ({
|
||||
dashboards: [],
|
||||
activeDashboardId: null,
|
||||
cards: [],
|
||||
editMode: false,
|
||||
loading: false,
|
||||
|
||||
setDashboards: (dashboards) => set({ dashboards }),
|
||||
|
||||
setActiveDashboard: (id) => set({ activeDashboardId: id, editMode: false }),
|
||||
|
||||
setCards: (cards) => set({ cards }),
|
||||
|
||||
addCard: (card) => set((s) => ({ cards: [...s.cards, card] })),
|
||||
|
||||
updateCard: (cardId, updates) =>
|
||||
set((s) => ({
|
||||
cards: s.cards.map((c) =>
|
||||
(c.card_id ?? c.CARD_ID) === cardId ? { ...c, ...updates } : c
|
||||
),
|
||||
})),
|
||||
|
||||
removeCard: (cardId) =>
|
||||
set((s) => ({
|
||||
cards: s.cards.filter((c) => (c.card_id ?? c.CARD_ID) !== cardId),
|
||||
})),
|
||||
|
||||
toggleEditMode: () => set((s) => ({ editMode: !s.editMode })),
|
||||
|
||||
setEditMode: (on) => set({ editMode: on }),
|
||||
|
||||
setLoading: (loading) => set({ loading }),
|
||||
|
||||
addDashboard: (dashboard) =>
|
||||
set((s) => ({ dashboards: [...s.dashboards, dashboard] })),
|
||||
|
||||
updateDashboardInList: (id, updates) =>
|
||||
set((s) => ({
|
||||
dashboards: s.dashboards.map((d) =>
|
||||
(d.dashboard_id ?? d.DASHBOARD_ID) === id ? { ...d, ...updates } : d
|
||||
),
|
||||
})),
|
||||
|
||||
removeDashboard: (id) =>
|
||||
set((s) => {
|
||||
const filtered = s.dashboards.filter(
|
||||
(d) => (d.dashboard_id ?? d.DASHBOARD_ID) !== id
|
||||
);
|
||||
const isActive = s.activeDashboardId === id;
|
||||
return {
|
||||
dashboards: filtered,
|
||||
activeDashboardId: isActive
|
||||
? (filtered[0]?.dashboard_id ?? filtered[0]?.DASHBOARD_ID ?? null)
|
||||
: s.activeDashboardId,
|
||||
};
|
||||
}),
|
||||
}),
|
||||
{ name: 'dashboard-store' }
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,390 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Phase 5 — 제어 모드 + 규칙 빌더
|
||||
mockup css/07-control-mode.css + css/08-rule-builder.css → React 포팅
|
||||
★ --v5-* / --ctrl-* 변수 사용, 즉흥 hex 금지
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── 제어 모드 변수 ── */
|
||||
:root {
|
||||
--ctrl-cyan: #00cec9;
|
||||
--ctrl-cyan-glow: rgba(0, 206, 201, .3);
|
||||
--ctrl-primary: #6c5ce7;
|
||||
--ctrl-amber: #fdcb6e;
|
||||
--ctrl-pink: #fd79a8;
|
||||
--ctrl-green: #55efc4;
|
||||
--ctrl-red: #ff4757;
|
||||
--ctrl-glass: rgba(255, 255, 255, .06);
|
||||
--ctrl-glass-strong: rgba(255, 255, 255, .08);
|
||||
--ctrl-glass-border: rgba(0, 206, 201, .25);
|
||||
}
|
||||
.dark {
|
||||
--ctrl-glass: rgba(255, 255, 255, .04);
|
||||
--ctrl-glass-strong: rgba(255, 255, 255, .06);
|
||||
}
|
||||
|
||||
/* ═══ 제어 모드 캔버스 배경 ═══ */
|
||||
.dash-canvas.control-mode {
|
||||
background-image: radial-gradient(circle at 0.5px 0.5px, rgba(0,206,201,.22) 0.5px, transparent 0);
|
||||
background-size: 24px 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
.dark .dash-canvas.control-mode {
|
||||
background-image: radial-gradient(circle at 0.5px 0.5px, rgba(85,239,196,.18) 0.5px, transparent 0);
|
||||
}
|
||||
|
||||
/* 카드 축소 + 클릭 가능 */
|
||||
.dash-canvas.control-mode .dash-card {
|
||||
transition: all .5s cubic-bezier(.16,1,.3,1);
|
||||
opacity: .5; z-index: 25; cursor: pointer;
|
||||
}
|
||||
.dash-canvas.control-mode .dash-card:hover { opacity: .8; box-shadow: 0 0 20px var(--ctrl-cyan-glow); }
|
||||
.dash-canvas.control-mode .dash-card.flow-active {
|
||||
opacity: 1; border-color: var(--ctrl-cyan);
|
||||
box-shadow: 0 0 30px rgba(0,206,201,.3);
|
||||
}
|
||||
|
||||
/* ── 제어 모드 토글 버튼 활성 ── */
|
||||
.dash-btn.control-on {
|
||||
background: linear-gradient(135deg, var(--ctrl-cyan), #55efc4) !important;
|
||||
color: #06050e !important; border-color: transparent !important;
|
||||
box-shadow: 0 0 20px rgba(0,206,201,.3) !important; font-weight: 700;
|
||||
}
|
||||
.dash-btn.control-on:hover { box-shadow: 0 0 30px rgba(0,206,201,.45) !important; }
|
||||
|
||||
/* ═══ SVG 오버레이 ═══ */
|
||||
.ctrl-svg {
|
||||
position: absolute; inset: 0; width: 100%; height: 100%;
|
||||
pointer-events: none; z-index: 10; overflow: visible;
|
||||
}
|
||||
|
||||
/* ── 연결선 4종 ── */
|
||||
.ctrl-line { fill: none; stroke: var(--ctrl-cyan); stroke-width: 1.5; opacity: .55;
|
||||
stroke-dasharray: 6 3; animation: ctrlPulse 1.5s linear infinite; }
|
||||
.ctrl-line-auto { fill: none; stroke: var(--ctrl-primary); stroke-width: 2.5; opacity: .6;
|
||||
stroke-dasharray: 6 4; animation: ctrlPulse 1.2s linear infinite; }
|
||||
.ctrl-line-cond { fill: none; stroke: var(--ctrl-amber); stroke-width: 2; opacity: .55;
|
||||
stroke-dasharray: 4 4; animation: ctrlPulse 1.8s linear infinite; }
|
||||
.ctrl-line-tpl { fill: none; stroke: var(--ctrl-pink); stroke-width: 2.5; opacity: .65;
|
||||
stroke-dasharray: 5 5; animation: ctrlPulse 1.4s linear infinite; }
|
||||
@keyframes ctrlPulse { to { stroke-dashoffset: -18; } }
|
||||
|
||||
/* ★ 라이트 모드 보정 */
|
||||
html:not(.dark) .ctrl-line { stroke: #00a89e; stroke-width: 2; opacity: .5; }
|
||||
html:not(.dark) .ctrl-line-auto { stroke: #5b4acf; stroke-width: 3; opacity: .6; }
|
||||
html:not(.dark) .ctrl-line-cond { stroke: #d4a017; stroke-width: 2.5; opacity: .55; }
|
||||
html:not(.dark) .ctrl-line-tpl { stroke: #e0559e; stroke-width: 3; opacity: .6; }
|
||||
|
||||
/* ═══ 연결선 위 뱃지 ═══ */
|
||||
.ctrl-badge {
|
||||
position: absolute; padding: .2rem .6rem; border-radius: 9px;
|
||||
background: var(--ctrl-glass-strong);
|
||||
backdrop-filter: blur(16px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.4);
|
||||
border: 1px solid rgba(0,206,201,.3);
|
||||
font-size: .55rem; font-weight: 700; color: var(--ctrl-cyan);
|
||||
white-space: nowrap; z-index: 15; cursor: pointer;
|
||||
transition: all .25s; box-shadow: 0 4px 16px rgba(0,206,201,.12);
|
||||
pointer-events: auto; transform: translate(-50%, -50%);
|
||||
}
|
||||
.ctrl-badge:hover {
|
||||
border-color: var(--ctrl-cyan);
|
||||
box-shadow: 0 8px 24px rgba(0,206,201,.3); transform: translate(-50%,-50%) scale(1.05);
|
||||
}
|
||||
.ctrl-badge.auto { border-color: rgba(108,92,231,.35); color: var(--ctrl-primary);
|
||||
box-shadow: 0 4px 16px rgba(108,92,231,.12); }
|
||||
.ctrl-badge.tpl-link { border-color: rgba(253,121,168,.35); color: var(--ctrl-pink);
|
||||
box-shadow: 0 4px 16px rgba(253,121,168,.12); font-size: .5rem; }
|
||||
|
||||
/* 조건분기 뱃지 */
|
||||
.ctrl-badge.cond {
|
||||
border-color: rgba(253,203,110,.4); color: var(--v5-text, #e8e8ee);
|
||||
box-shadow: 0 6px 20px rgba(253,203,110,.15); padding: .45rem .7rem;
|
||||
min-width: 120px; text-align: left; white-space: normal; line-height: 1.4;
|
||||
border-radius: 10px; border-width: 2px;
|
||||
}
|
||||
.ctrl-badge.cond .cb-head {
|
||||
display: flex; align-items: center; gap: .3rem;
|
||||
font-size: .5rem; font-weight: 700; color: var(--ctrl-amber); margin-bottom: .25rem;
|
||||
}
|
||||
.ctrl-badge.cond .cb-icon {
|
||||
width: 16px; height: 16px; border-radius: 4px;
|
||||
display: flex; align-items: center; justify-content: center; font-size: .6rem;
|
||||
background: rgba(253,203,110,.15); border: 1px solid rgba(253,203,110,.3);
|
||||
}
|
||||
.ctrl-badge.cond .cb-cond {
|
||||
font-size: .6rem; font-weight: 600; padding: .2rem .4rem;
|
||||
border-radius: 6px; background: rgba(253,203,110,.08);
|
||||
border: 1px dashed rgba(253,203,110,.25); margin-bottom: .25rem;
|
||||
}
|
||||
.ctrl-badge.cond .cb-paths { display: flex; gap: .5rem; font-size: .48rem; font-weight: 700; }
|
||||
.ctrl-badge.cond .cb-yes { color: var(--ctrl-green); }
|
||||
.ctrl-badge.cond .cb-yes::before { content: '●'; margin-right: .15rem; font-size: .4rem; }
|
||||
.ctrl-badge.cond .cb-no { color: var(--v5-text-muted, #888); }
|
||||
.ctrl-badge.cond .cb-no::before { content: '○'; margin-right: .15rem; font-size: .4rem; }
|
||||
|
||||
html:not(.dark) .ctrl-badge { background: rgba(255,255,255,.92);
|
||||
border-color: rgba(0,0,0,.15); box-shadow: 0 2px 12px rgba(0,0,0,.1); }
|
||||
|
||||
/* ═══ 테이블 노드 ═══ */
|
||||
.tbl-node {
|
||||
position: absolute; width: 200px;
|
||||
background: var(--ctrl-glass-strong);
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
border: 1px solid var(--ctrl-glass-border); border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.08), 0 0 20px rgba(0,206,201,.08);
|
||||
z-index: 20; overflow: hidden; transition: border-color .2s, box-shadow .2s;
|
||||
}
|
||||
.dark .tbl-node { box-shadow: 0 8px 24px rgba(0,0,0,.5), 0 0 20px rgba(85,239,196,.06); }
|
||||
.tbl-node:hover {
|
||||
border-color: var(--ctrl-cyan);
|
||||
box-shadow: 0 12px 32px rgba(0,0,0,.12), 0 0 30px rgba(0,206,201,.18);
|
||||
}
|
||||
|
||||
.tbl-node-head {
|
||||
display: flex; align-items: center; gap: .4rem; padding: .55rem .7rem;
|
||||
background: linear-gradient(135deg, rgba(0,206,201,.12), rgba(0,206,201,.04));
|
||||
border-bottom: 1px solid rgba(0,206,201,.15); cursor: grab;
|
||||
}
|
||||
.tbl-node-head:active { cursor: grabbing; }
|
||||
.tbl-icon {
|
||||
width: 20px; height: 20px; border-radius: 5px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: .7rem; background: rgba(0,206,201,.12); flex-shrink: 0;
|
||||
}
|
||||
.tbl-name { flex: 1; font-size: .65rem; font-weight: 700; color: var(--v5-text, #e8e8ee); letter-spacing: -.01em; }
|
||||
.tbl-badge {
|
||||
font-size: .45rem; padding: .1rem .35rem; border-radius: 999px;
|
||||
background: rgba(0,206,201,.1); color: var(--ctrl-cyan); font-weight: 700;
|
||||
}
|
||||
|
||||
.tbl-node-cols { padding: .35rem 0; max-height: 160px; overflow-y: auto; }
|
||||
.tbl-col {
|
||||
display: flex; align-items: center; gap: .35rem; padding: .22rem .65rem;
|
||||
font-size: .58rem; color: var(--v5-text-sec, #aaa); transition: background .1s;
|
||||
}
|
||||
.tbl-col:hover { background: var(--v5-surface-hover, rgba(255,255,255,.05)); color: var(--v5-text, #eee); }
|
||||
|
||||
.tbl-port {
|
||||
width: 8px; height: 8px; border-radius: 50%; background: var(--v5-surface, #2a2a36);
|
||||
border: 2px solid rgba(0,206,201,.35); flex-shrink: 0; cursor: crosshair; transition: all .2s;
|
||||
}
|
||||
.tbl-port:hover {
|
||||
background: var(--ctrl-cyan); border-color: var(--ctrl-cyan);
|
||||
box-shadow: 0 0 8px rgba(0,206,201,.5); transform: scale(1.3);
|
||||
}
|
||||
.tbl-port.pk { border-color: var(--ctrl-primary); background: rgba(108,92,231,.15); }
|
||||
.tbl-port.fk { border-color: var(--ctrl-amber); background: rgba(253,203,110,.15); }
|
||||
|
||||
.tbl-col-name { flex: 1; font-weight: 500; }
|
||||
.tbl-col-type { font-size: .45rem; color: var(--v5-text-muted, #777); font-weight: 600; margin-left: auto; }
|
||||
.tbl-col-mark { font-size: .42rem; font-weight: 700; padding: .05rem .25rem; border-radius: 4px; margin-left: .2rem; }
|
||||
.tbl-col-mark.pk { color: var(--ctrl-primary); background: rgba(108,92,231,.1); }
|
||||
.tbl-col-mark.fk { color: var(--ctrl-amber); background: rgba(253,203,110,.1); }
|
||||
|
||||
/* ═══ 제어 노드 (액션/조건/타이머) ═══ */
|
||||
.ctrl-action-node {
|
||||
position: absolute; width: 160px;
|
||||
background: var(--ctrl-glass-strong);
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
border: 1px solid rgba(var(--na-rgb, 0,206,201), .25); border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.08), 0 0 16px rgba(var(--na-rgb, 0,206,201), .08);
|
||||
z-index: 20; overflow: visible; transition: border-color .2s, box-shadow .2s;
|
||||
}
|
||||
.ctrl-action-node:hover {
|
||||
border-color: rgba(var(--na-rgb, 0,206,201), .5);
|
||||
box-shadow: 0 12px 32px rgba(0,0,0,.12), 0 0 24px rgba(var(--na-rgb, 0,206,201), .18);
|
||||
}
|
||||
|
||||
.ctrl-an-head {
|
||||
display: flex; align-items: center; gap: .4rem; padding: .5rem .65rem;
|
||||
background: linear-gradient(135deg, rgba(var(--na-rgb, 0,206,201), .1), rgba(var(--na-rgb, 0,206,201), .03));
|
||||
border-bottom: 1px solid rgba(var(--na-rgb, 0,206,201), .12);
|
||||
border-radius: 12px 12px 0 0; cursor: grab;
|
||||
}
|
||||
.ctrl-an-head:active { cursor: grabbing; }
|
||||
.ctrl-an-icon {
|
||||
width: 22px; height: 22px; border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center; font-size: .8rem;
|
||||
background: linear-gradient(135deg, rgba(var(--na-rgb, 0,206,201), .18), rgba(var(--na-rgb, 0,206,201), .06));
|
||||
border: 1px solid rgba(var(--na-rgb, 0,206,201), .25);
|
||||
}
|
||||
.ctrl-an-name { flex: 1; font-size: .62rem; font-weight: 700; color: var(--v5-text, #e8e8ee); }
|
||||
.ctrl-an-del {
|
||||
width: 18px; height: 18px; border-radius: 5px;
|
||||
border: 1px solid transparent; background: transparent;
|
||||
color: var(--v5-text-muted, #888); font-size: .55rem; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; transition: all .15s;
|
||||
}
|
||||
.ctrl-an-del:hover {
|
||||
background: rgba(255,71,87,.1); border-color: rgba(255,71,87,.3); color: var(--ctrl-red);
|
||||
}
|
||||
|
||||
.ctrl-an-body {
|
||||
padding: .5rem .65rem; cursor: pointer;
|
||||
border-radius: 0 0 12px 12px; transition: background .15s;
|
||||
}
|
||||
.ctrl-an-body:hover { background: rgba(var(--na-rgb, 0,206,201), .06); }
|
||||
.ctrl-an-summary { font-size: .55rem; color: var(--v5-text-sec, #aaa); line-height: 1.4; }
|
||||
|
||||
/* ═══ I/O 포트 ═══ */
|
||||
.ctrl-io-port {
|
||||
position: absolute; width: 10px; height: 10px; border-radius: 50%;
|
||||
border: 2px solid; cursor: crosshair; transition: all .2s; z-index: 25;
|
||||
}
|
||||
.ctrl-io-port.port-in {
|
||||
left: -6px; top: 50%; transform: translateY(-50%);
|
||||
border-color: var(--ctrl-cyan); background: var(--v5-surface, #2a2a36);
|
||||
}
|
||||
.ctrl-io-port.port-in.tbl-io { top: 18px; transform: none; }
|
||||
.ctrl-io-port.port-out { border-color: var(--ctrl-cyan); background: var(--ctrl-cyan); }
|
||||
.ctrl-io-port.port-out.tbl-io { position: absolute; right: -6px; top: 18px; }
|
||||
.ctrl-io-port.port-yes { border-color: var(--ctrl-green); background: var(--ctrl-green); }
|
||||
.ctrl-io-port.port-no { border-color: var(--v5-text-muted, #888); background: var(--v5-text-muted, #888); opacity: .6; }
|
||||
|
||||
.ctrl-an-ports-out {
|
||||
position: absolute; right: -6px; top: 50%; transform: translateY(-50%);
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.ctrl-an-ports-out .ctrl-io-port { position: relative; right: auto; top: auto; transform: none; }
|
||||
|
||||
.port-label {
|
||||
position: absolute; right: 14px; top: 50%; transform: translateY(-50%);
|
||||
font-size: .42rem; font-weight: 700; color: var(--v5-text-muted, #888);
|
||||
pointer-events: none; white-space: nowrap;
|
||||
}
|
||||
|
||||
.ctrl-io-port:hover { box-shadow: 0 0 10px rgba(0,206,201,.5); transform: scale(1.3); }
|
||||
.ctrl-io-port.port-in:hover { background: var(--ctrl-cyan); transform: translateY(-50%) scale(1.3); }
|
||||
.ctrl-io-port.port-in.tbl-io:hover { transform: scale(1.3); }
|
||||
.ctrl-io-port.port-hover {
|
||||
background: var(--ctrl-cyan) !important;
|
||||
box-shadow: 0 0 14px rgba(0,206,201,.6) !important;
|
||||
transform: translateY(-50%) scale(1.5) !important;
|
||||
}
|
||||
.ctrl-io-port.port-hover.tbl-io { transform: scale(1.5) !important; }
|
||||
|
||||
/* 드래그 중 모든 input 포트 pulse */
|
||||
.dash-canvas.port-dragging .ctrl-io-port.port-in { animation: portPulse 1.2s ease infinite; }
|
||||
@keyframes portPulse {
|
||||
0%, 100% { box-shadow: 0 0 4px rgba(0,206,201,.3); }
|
||||
50% { box-shadow: 0 0 14px rgba(0,206,201,.6); }
|
||||
}
|
||||
|
||||
/* ═══ 규칙 연결선 ═══ */
|
||||
.rule-temp-line { fill: none; stroke: var(--ctrl-cyan); stroke-width: 2;
|
||||
stroke-dasharray: 6 3; opacity: .7; pointer-events: none; }
|
||||
.rule-conn-path { fill: none; stroke: var(--ctrl-cyan); stroke-width: 2; opacity: .6;
|
||||
stroke-dasharray: 6 3; animation: ctrlPulse 1.5s linear infinite; }
|
||||
.rule-conn-path.conn-yes { stroke: var(--ctrl-green); }
|
||||
.rule-conn-path.conn-no { stroke: var(--v5-text-muted, #888); opacity: .35; }
|
||||
|
||||
/* 연결 삭제 버튼 */
|
||||
.rule-conn-badge {
|
||||
position: absolute; transform: translate(-50%, -50%);
|
||||
z-index: 15; pointer-events: auto; padding: 6px;
|
||||
}
|
||||
.conn-x {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 20px; height: 20px; border-radius: 50%;
|
||||
background: var(--ctrl-glass-strong);
|
||||
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
||||
border: 1.5px solid rgba(255,71,87,.15);
|
||||
color: var(--v5-text-muted, #888); font-size: .55rem; font-weight: 700; cursor: pointer;
|
||||
opacity: 0; transition: all .2s; transform: scale(.6);
|
||||
}
|
||||
.rule-conn-badge:hover .conn-x {
|
||||
opacity: 1; transform: scale(1);
|
||||
background: rgba(255,71,87,.15); border-color: rgba(255,71,87,.5); color: var(--ctrl-red);
|
||||
box-shadow: 0 2px 12px rgba(255,71,87,.2);
|
||||
}
|
||||
|
||||
/* ═══ 설정 팝오버 ═══ */
|
||||
.ctrl-cfg-pop {
|
||||
position: absolute; width: 220px;
|
||||
background: var(--ctrl-glass-strong);
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||
border: 1px solid rgba(108,92,231,.3); border-radius: 12px;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,.15), 0 0 24px rgba(108,92,231,.1);
|
||||
z-index: 50; padding: .7rem;
|
||||
opacity: 0; transform: translateX(-8px); transition: opacity .2s, transform .2s;
|
||||
}
|
||||
.ctrl-cfg-pop.open { opacity: 1; transform: translateX(0); }
|
||||
|
||||
.cfg-hd { font-size: .65rem; font-weight: 700; color: var(--v5-text, #eee);
|
||||
padding-bottom: .5rem; border-bottom: 1px solid var(--v5-border, #3a3a48); margin-bottom: .5rem; }
|
||||
.cfg-sec { margin-bottom: .5rem; }
|
||||
.cfg-lb { display: block; font-size: .48rem; font-weight: 700; color: var(--v5-text-muted, #888);
|
||||
text-transform: uppercase; letter-spacing: .05em; margin-bottom: .15rem; }
|
||||
.cfg-sel, .cfg-inp, .cfg-ta {
|
||||
width: 100%; padding: .3rem .45rem; border-radius: 7px;
|
||||
border: 1px solid var(--v5-border, #3a3a48); background: var(--v5-surface, #2a2a36);
|
||||
color: var(--v5-text, #eee); font-size: .55rem; font-family: inherit;
|
||||
outline: none; transition: border-color .15s; box-sizing: border-box;
|
||||
}
|
||||
.cfg-sel:focus, .cfg-inp:focus, .cfg-ta:focus { border-color: var(--ctrl-primary); }
|
||||
.cfg-add-btn {
|
||||
width: 100%; padding: .25rem; border-radius: 6px;
|
||||
border: 1px dashed var(--v5-border, #3a3a48); background: transparent;
|
||||
color: var(--v5-text-muted, #888); font-size: .5rem; cursor: pointer; margin-top: .3rem;
|
||||
}
|
||||
.cfg-add-btn:hover { border-color: var(--ctrl-cyan); color: var(--ctrl-cyan); }
|
||||
.cfg-ft {
|
||||
display: flex; gap: .3rem; padding-top: .5rem;
|
||||
border-top: 1px solid var(--v5-border, #3a3a48); margin-top: .5rem;
|
||||
}
|
||||
.cfg-btn {
|
||||
flex: 1; padding: .3rem; border-radius: 7px;
|
||||
border: 1px solid var(--v5-border, #3a3a48); background: var(--v5-surface, #2a2a36);
|
||||
color: var(--v5-text, #eee); font-size: .55rem; font-weight: 600;
|
||||
cursor: pointer; transition: all .15s;
|
||||
}
|
||||
.cfg-btn.save { background: var(--ctrl-primary); border-color: var(--ctrl-primary); color: #fff; }
|
||||
.cfg-btn:hover { opacity: .85; }
|
||||
|
||||
/* ═══ 팔레트 ═══ */
|
||||
.ctrl-palette-section {
|
||||
font-size: .52rem; font-weight: 700; color: var(--ctrl-cyan);
|
||||
text-transform: uppercase; letter-spacing: .08em; padding: .7rem .65rem .3rem;
|
||||
}
|
||||
.ctrl-palette-item {
|
||||
display: flex; align-items: center; gap: .5rem; padding: .45rem .65rem;
|
||||
border-radius: 8px; font-size: .68rem; font-weight: 500;
|
||||
color: var(--v5-text-sec, #aaa); cursor: grab; transition: all .2s;
|
||||
}
|
||||
.ctrl-palette-item:hover {
|
||||
background: rgba(0,206,201,.08); color: var(--v5-text, #eee); transform: translateX(2px);
|
||||
}
|
||||
.ctrl-palette-item .cp-icon { font-size: .8rem; width: 20px; text-align: center; }
|
||||
.ctrl-palette-item[draggable="true"] { cursor: grab; }
|
||||
.ctrl-palette-item[draggable="true"]:active { cursor: grabbing; }
|
||||
|
||||
/* ═══ 제어 모드 툴바 ═══ */
|
||||
.ctrl-toolbar {
|
||||
display: flex; align-items: center; gap: .5rem; padding: .3rem .5rem;
|
||||
background: var(--ctrl-glass); border-bottom: 1px solid var(--ctrl-glass-border);
|
||||
font-size: .6rem;
|
||||
}
|
||||
.ctrl-toolbar-mode {
|
||||
display: flex; gap: .25rem;
|
||||
}
|
||||
.ctrl-mode-btn {
|
||||
padding: .25rem .6rem; border-radius: 6px; border: 1px solid var(--v5-border, #3a3a48);
|
||||
background: transparent; color: var(--v5-text-sec, #aaa); font-size: .55rem;
|
||||
font-weight: 600; cursor: pointer; transition: all .2s;
|
||||
}
|
||||
.ctrl-mode-btn.on {
|
||||
background: rgba(0,206,201,.12); border-color: var(--ctrl-cyan);
|
||||
color: var(--ctrl-cyan); font-weight: 700;
|
||||
}
|
||||
.ctrl-mode-btn:hover:not(.on) { background: var(--v5-surface-hover, rgba(255,255,255,.04)); }
|
||||
|
||||
html:not(.dark) .ctrl-action-node {
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,.06), 0 0 12px rgba(var(--na-rgb, 0,206,201), .06);
|
||||
}
|
||||
html:not(.dark) .rule-conn-path { stroke-width: 2.5; opacity: .5; }
|
||||
html:not(.dark) .ctrl-cfg-pop {
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.1); border-color: rgba(108,92,231,.2);
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Phase 4 — Dashboard (= Menu) v5 Cosmic Glassmorphism
|
||||
mockup css/01~06 기반, --v5-* 변수 사용
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── 대시보드 셸 ── */
|
||||
.dash-shell {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ── 사이드바 ── */
|
||||
.dash-side {
|
||||
width: 220px;
|
||||
background: var(--v5-glass);
|
||||
backdrop-filter: blur(20px) saturate(1.3);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.3);
|
||||
border-right: 1px solid var(--v5-glass-border);
|
||||
padding: .85rem .6rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
flex-shrink: 0;
|
||||
transition: width .35s cubic-bezier(.4,0,.2,1), padding .35s;
|
||||
}
|
||||
.dash-side-sec {
|
||||
font-size: .55rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .12em; color: var(--v5-text-muted);
|
||||
padding: .5rem .65rem .35rem;
|
||||
}
|
||||
.dash-si {
|
||||
padding: .5rem .7rem; border-radius: 10px; font-size: .77rem;
|
||||
color: var(--v5-text-sec); cursor: pointer;
|
||||
transition: all .25s cubic-bezier(.4,0,.2,1);
|
||||
font-weight: 450; display: flex; align-items: center; gap: .6rem;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.dash-si .ic { width: 16px; height: 16px; display: flex; align-items: center;
|
||||
justify-content: center; opacity: .65; flex-shrink: 0; font-size: .85rem; }
|
||||
.dash-si:hover { background: var(--v5-surface-hover); color: var(--v5-text);
|
||||
transform: translateX(2px); }
|
||||
.dash-si.on {
|
||||
background: linear-gradient(135deg, rgba(108,92,231,.12), rgba(108,92,231,.05));
|
||||
color: var(--v5-primary); font-weight: 600;
|
||||
border: 1px solid rgba(108,92,231,.15); box-shadow: var(--v5-glow-sm);
|
||||
}
|
||||
.dark .dash-si.on {
|
||||
background: linear-gradient(135deg, rgba(162,155,254,.14), rgba(162,155,254,.05));
|
||||
border-color: rgba(162,155,254,.15);
|
||||
}
|
||||
.dash-si::before { content: ''; position: absolute; left: 0; top: 0; width: 3px;
|
||||
height: 100%; background: var(--v5-primary); border-radius: 0 2px 2px 0;
|
||||
transform: scaleY(0); transition: transform .2s cubic-bezier(.4,0,.2,1); }
|
||||
.dash-si.on::before { transform: scaleY(1); }
|
||||
|
||||
/* 사이드바 호버 액션 (rename/delete) */
|
||||
.dash-si-actions { margin-left: auto; display: flex; gap: .15rem;
|
||||
opacity: 0; transition: opacity .15s; }
|
||||
.dash-si:hover .dash-si-actions { opacity: 1; }
|
||||
.dash-si-act { width: 18px; height: 18px; border-radius: 5px; border: none;
|
||||
background: transparent; color: var(--v5-text-muted); cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all .15s; font-size: .55rem; }
|
||||
.dash-si-act:hover { background: var(--v5-surface-hover); color: var(--v5-primary); }
|
||||
.dash-si-act.danger:hover { background: rgba(255,71,87,.12); color: var(--v5-red); }
|
||||
|
||||
/* 새 대시보드 추가 버튼 */
|
||||
.dash-add-btn {
|
||||
display: flex; align-items: center; gap: .5rem; padding: .45rem .7rem;
|
||||
border-radius: 10px; border: 1px dashed var(--v5-glass-border);
|
||||
background: transparent; color: var(--v5-text-muted); cursor: pointer;
|
||||
font-size: .7rem; font-weight: 600; font-family: inherit;
|
||||
transition: all .2s; margin: .3rem 0;
|
||||
}
|
||||
.dash-add-btn:hover { border-color: var(--v5-primary); color: var(--v5-primary);
|
||||
background: rgba(108,92,231,.04); }
|
||||
|
||||
/* ── 콘텐츠 영역 ── */
|
||||
.dash-content { flex: 1; overflow: auto; display: flex; flex-direction: column; }
|
||||
|
||||
/* ── 캔버스 툴바 ── */
|
||||
.dash-toolbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: .75rem 1.25rem; background: var(--v5-glass);
|
||||
backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--v5-glass-border); flex-shrink: 0;
|
||||
}
|
||||
.dash-toolbar-l { display: flex; align-items: center; gap: .75rem; }
|
||||
.dash-cv-title { font-size: .95rem; font-weight: 700; color: var(--v5-text);
|
||||
letter-spacing: -.01em; }
|
||||
.dash-cv-meta {
|
||||
font-size: .6rem; color: var(--v5-text-muted); padding: .2rem .55rem;
|
||||
border-radius: 999px; background: var(--v5-surface);
|
||||
border: 1px solid var(--v5-glass-border);
|
||||
}
|
||||
.dash-toolbar-r { display: flex; align-items: center; gap: .5rem; }
|
||||
.dash-btn {
|
||||
display: flex; align-items: center; gap: .4rem; padding: .42rem .8rem;
|
||||
border-radius: 10px; border: 1px solid var(--v5-glass-border);
|
||||
background: var(--v5-surface); color: var(--v5-text-sec);
|
||||
font-size: .68rem; font-weight: 600; cursor: pointer; font-family: inherit;
|
||||
transition: all .2s;
|
||||
}
|
||||
.dash-btn:hover { border-color: var(--v5-primary); color: var(--v5-primary);
|
||||
box-shadow: var(--v5-glow-sm); }
|
||||
.dash-btn.primary {
|
||||
background: linear-gradient(135deg, var(--v5-primary), var(--v5-primary-light));
|
||||
color: white; border-color: transparent; box-shadow: var(--v5-glow-sm);
|
||||
}
|
||||
.dash-btn.primary:hover { transform: translateY(-1px); box-shadow: var(--v5-glow-md); }
|
||||
.dash-btn.on {
|
||||
background: linear-gradient(135deg, var(--v5-primary), var(--v5-primary-light));
|
||||
color: white; border-color: transparent; box-shadow: var(--v5-glow-sm);
|
||||
}
|
||||
|
||||
/* ── 캔버스 ── */
|
||||
.dash-canvas {
|
||||
flex: 1; position: relative; padding: 0; min-height: 600px; overflow: hidden;
|
||||
background-image: radial-gradient(circle at 0.5px 0.5px, var(--v5-glass-border) 0.5px, transparent 0);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
.dash-canvas.edit-mode {
|
||||
background-image: radial-gradient(circle at 0.5px 0.5px, rgba(108,92,231,.25) 0.5px, transparent 0);
|
||||
outline: 1px dashed rgba(108,92,231,.18); outline-offset: -8px;
|
||||
}
|
||||
.dark .dash-canvas.edit-mode {
|
||||
background-image: radial-gradient(circle at 0.5px 0.5px, rgba(162,155,254,.3) 0.5px, transparent 0);
|
||||
}
|
||||
|
||||
/* ── 카드 ── */
|
||||
.dash-card {
|
||||
position: absolute; background: var(--v5-glass-strong, rgba(255,255,255,.65));
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
border: 1px solid var(--v5-glass-border); border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.06), var(--v5-glow-sm);
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
transition: box-shadow .25s, border-color .25s;
|
||||
}
|
||||
.dark .dash-card { box-shadow: 0 8px 32px rgba(0,0,0,0.4), var(--v5-glow-sm); }
|
||||
.dash-card:hover { border-color: rgba(108,92,231,.25);
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.08), var(--v5-glow-md); }
|
||||
.dash-canvas.edit-mode .dash-card { cursor: move;
|
||||
border-style: solid; border-color: rgba(108,92,231,.3); }
|
||||
.dash-card.dragging {
|
||||
box-shadow: 0 24px 60px rgba(108,92,231,.35), var(--v5-glow-lg, var(--v5-glow-md));
|
||||
border-color: var(--v5-primary); z-index: 50;
|
||||
}
|
||||
.dash-card.resizing {
|
||||
box-shadow: 0 24px 60px rgba(0,206,201,.3), var(--v5-glow-lg, var(--v5-glow-md));
|
||||
border-color: var(--v5-cyan); z-index: 50;
|
||||
}
|
||||
|
||||
/* 카드 헤더 */
|
||||
.dash-card-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: .65rem .9rem;
|
||||
border-bottom: 1px solid var(--v5-border-subtle); flex-shrink: 0;
|
||||
background: var(--v5-glass);
|
||||
}
|
||||
.dash-card-head-l { display: flex; align-items: center; gap: .55rem; }
|
||||
.dash-card-icon {
|
||||
width: 24px; height: 24px; border-radius: 7px;
|
||||
display: flex; align-items: center; justify-content: center; font-size: .85rem;
|
||||
background: linear-gradient(135deg, rgba(108,92,231,.15), rgba(0,206,201,.1));
|
||||
border: 1px solid rgba(108,92,231,.18);
|
||||
}
|
||||
.dash-card-title { font-size: .78rem; font-weight: 700; color: var(--v5-text);
|
||||
letter-spacing: -.01em; }
|
||||
.dash-card-bdg {
|
||||
font-size: .5rem; font-weight: 700; color: var(--v5-primary);
|
||||
padding: .1rem .4rem; border-radius: 999px;
|
||||
background: rgba(108,92,231,.08); border: 1px solid rgba(108,92,231,.18);
|
||||
text-transform: uppercase; letter-spacing: .05em;
|
||||
}
|
||||
.dash-card-head-r { display: flex; align-items: center; gap: .3rem; }
|
||||
.dash-card-btn {
|
||||
width: 22px; height: 22px; border-radius: 6px; border: none;
|
||||
background: transparent; color: var(--v5-text-muted); cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; transition: all .15s;
|
||||
}
|
||||
.dash-card-btn:hover { background: var(--v5-surface-hover); color: var(--v5-primary); }
|
||||
.dash-card-btn.danger:hover { background: rgba(255,71,87,.12); color: var(--v5-red); }
|
||||
|
||||
/* 카드 본문 */
|
||||
.dash-card-body { flex: 1; overflow: auto; padding: .5rem; }
|
||||
|
||||
/* 리사이즈 핸들 */
|
||||
.dash-resize-handle {
|
||||
position: absolute; right: 0; bottom: 0; width: 18px; height: 18px;
|
||||
cursor: nwse-resize; display: none; align-items: center; justify-content: center;
|
||||
color: var(--v5-text-muted); opacity: .6; transition: opacity .2s;
|
||||
}
|
||||
.dash-resize-handle:hover { opacity: 1; color: var(--v5-primary); }
|
||||
.dash-canvas.edit-mode .dash-resize-handle { display: flex; }
|
||||
.dash-resize-handle::before {
|
||||
content: ''; position: absolute; right: 3px; bottom: 3px; width: 10px; height: 10px;
|
||||
background: linear-gradient(135deg, transparent 50%, currentColor 50%, currentColor 60%,
|
||||
transparent 60%, transparent 70%, currentColor 70%, currentColor 80%, transparent 80%);
|
||||
}
|
||||
|
||||
/* 접힌 카드 */
|
||||
.dash-card.collapsed .dash-card-body { display: none; }
|
||||
.dash-card.collapsed .dash-mini-body { display: flex; flex-direction: column;
|
||||
flex: 1; overflow: hidden; padding: .65rem .8rem; gap: .5rem; }
|
||||
.dash-card:not(.collapsed) .dash-mini-body { display: none; }
|
||||
|
||||
/* 미니 본문 통계 */
|
||||
.dash-mini-stats {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||
gap: .45rem; flex: 1; align-content: start;
|
||||
}
|
||||
.dash-mini-stat {
|
||||
padding: .55rem .65rem; border-radius: 9px; background: var(--v5-glass);
|
||||
border: 1px solid var(--v5-glass-border); display: flex; flex-direction: column;
|
||||
justify-content: center; min-height: 54px;
|
||||
}
|
||||
.dash-mini-stat .ms-label { font-size: .5rem; font-weight: 600;
|
||||
color: var(--v5-text-muted); text-transform: uppercase; letter-spacing: .06em; }
|
||||
.dash-mini-stat .ms-value { font-size: 1.15rem; font-weight: 800;
|
||||
color: var(--v5-text); margin-top: .15rem; letter-spacing: -.02em; line-height: 1; }
|
||||
|
||||
/* ── 빈 대시보드 ── */
|
||||
.dash-empty {
|
||||
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
|
||||
display: flex; flex-direction: column; align-items: center; gap: .6rem;
|
||||
color: var(--v5-text-muted); text-align: center;
|
||||
}
|
||||
.dash-empty-icon { font-size: 3rem; opacity: .25; }
|
||||
.dash-empty-title { font-size: .95rem; font-weight: 700; color: var(--v5-text-sec); }
|
||||
.dash-empty-desc { font-size: .65rem; color: var(--v5-text-muted);
|
||||
max-width: 280px; line-height: 1.5; }
|
||||
.dash-empty-btn {
|
||||
margin-top: .4rem; padding: .5rem 1.2rem; border-radius: 10px; border: none;
|
||||
background: linear-gradient(135deg, var(--v5-primary), var(--v5-primary-light));
|
||||
color: white; font-size: .7rem; font-weight: 700; cursor: pointer;
|
||||
font-family: inherit; box-shadow: var(--v5-glow-sm); transition: all .2s;
|
||||
}
|
||||
.dash-empty-btn:hover { transform: translateY(-1px); box-shadow: var(--v5-glow-md); }
|
||||
|
||||
/* ── 라이브러리 모달 ── */
|
||||
.dash-lib-backdrop {
|
||||
position: fixed; inset: 0; background: rgba(6,5,14,0.5);
|
||||
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
||||
z-index: 200; opacity: 0; pointer-events: none;
|
||||
transition: opacity .3s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.dash-lib-backdrop.open { opacity: 1; pointer-events: auto; }
|
||||
|
||||
.dash-lib-modal {
|
||||
position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%) scale(.96);
|
||||
width: min(920px, 90vw); height: min(620px, 85vh);
|
||||
background: var(--v5-glass-strong, rgba(255,255,255,.65));
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||
border: 1px solid var(--v5-glass-border); border-radius: 20px;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.2), var(--v5-glow-lg, var(--v5-glow-md));
|
||||
z-index: 201; display: flex; flex-direction: column; overflow: hidden;
|
||||
opacity: 0; pointer-events: none; transition: all .3s cubic-bezier(.16,1,.3,1);
|
||||
}
|
||||
.dash-lib-modal.open { opacity: 1; transform: translate(-50%,-50%) scale(1);
|
||||
pointer-events: auto; }
|
||||
|
||||
.dash-lib-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 1rem 1.25rem; border-bottom: 1px solid var(--v5-glass-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dash-lib-title { font-size: .95rem; font-weight: 800; color: var(--v5-text); }
|
||||
.dash-lib-close {
|
||||
width: 30px; height: 30px; border-radius: 9px;
|
||||
border: 1px solid var(--v5-glass-border); background: var(--v5-surface);
|
||||
color: var(--v5-text-muted); cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; transition: all .2s;
|
||||
}
|
||||
.dash-lib-close:hover { border-color: var(--v5-red); color: var(--v5-red); }
|
||||
|
||||
.dash-lib-body { display: flex; flex: 1; overflow: hidden; }
|
||||
|
||||
.dash-lib-cats {
|
||||
width: 160px; flex-shrink: 0; background: var(--v5-glass);
|
||||
border-right: 1px solid var(--v5-glass-border);
|
||||
padding: .7rem .5rem; overflow-y: auto; display: flex; flex-direction: column; gap: 1px;
|
||||
}
|
||||
.dash-lib-cat {
|
||||
display: flex; align-items: center; gap: .5rem; padding: .45rem .55rem;
|
||||
border-radius: 9px; font-size: .68rem; font-weight: 500;
|
||||
color: var(--v5-text-sec); cursor: pointer; transition: all .2s;
|
||||
}
|
||||
.dash-lib-cat:hover { background: var(--v5-surface-hover); color: var(--v5-text); }
|
||||
.dash-lib-cat.on {
|
||||
background: linear-gradient(135deg, rgba(108,92,231,.12), rgba(108,92,231,.04));
|
||||
color: var(--v5-primary); font-weight: 600;
|
||||
}
|
||||
|
||||
.dash-lib-grid { flex: 1; overflow-y: auto; padding: 1rem 1.25rem; }
|
||||
.dash-lib-cards {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: .7rem;
|
||||
}
|
||||
.dash-lib-card {
|
||||
padding: .85rem; border-radius: 13px; background: var(--v5-glass);
|
||||
border: 1px solid var(--v5-glass-border); cursor: pointer;
|
||||
transition: all .25s; display: flex; flex-direction: column; gap: .4rem;
|
||||
}
|
||||
.dash-lib-card:hover { border-color: var(--v5-primary);
|
||||
transform: translateY(-2px); box-shadow: var(--v5-glow-md); }
|
||||
.dash-lib-card-icon {
|
||||
width: 34px; height: 34px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 1.15rem;
|
||||
background: linear-gradient(135deg, rgba(108,92,231,.15), rgba(0,206,201,.08));
|
||||
border: 1px solid rgba(108,92,231,.15);
|
||||
}
|
||||
.dash-lib-card-name { font-size: .78rem; font-weight: 700; color: var(--v5-text); }
|
||||
.dash-lib-card-desc { font-size: .55rem; color: var(--v5-text-muted); line-height: 1.4; }
|
||||
.dash-lib-card-tag {
|
||||
font-size: .48rem; padding: .1rem .35rem; border-radius: 5px;
|
||||
background: rgba(108,92,231,.08); color: var(--v5-primary); font-weight: 600;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ── 카드 설정 패널 ── */
|
||||
.dash-settings {
|
||||
position: absolute; top: 46px; right: 10px; width: 280px;
|
||||
max-height: calc(100% - 60px);
|
||||
background: var(--v5-glass-strong, rgba(255,255,255,.65));
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||
border: 1px solid var(--v5-glass-border); border-radius: 14px;
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,.15), var(--v5-glow-md);
|
||||
z-index: 60; display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.dash-settings-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: .6rem .85rem; border-bottom: 1px solid var(--v5-glass-border);
|
||||
}
|
||||
.dash-settings-title { font-size: .75rem; font-weight: 700; color: var(--v5-text); }
|
||||
.dash-settings-body { flex: 1; overflow-y: auto; padding: .55rem .85rem .85rem; }
|
||||
.dash-settings-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: .4rem .1rem;
|
||||
}
|
||||
.dash-settings-row + .dash-settings-row { border-top: 1px dashed var(--v5-border-subtle); }
|
||||
.dash-settings-label { font-size: .7rem; font-weight: 500; color: var(--v5-text-sec); }
|
||||
|
||||
/* 토글 스위치 */
|
||||
.dash-toggle {
|
||||
position: relative; width: 32px; height: 18px; border-radius: 999px;
|
||||
background: var(--v5-surface); border: 1px solid var(--v5-glass-border);
|
||||
cursor: pointer; transition: all .25s; flex-shrink: 0;
|
||||
}
|
||||
.dash-toggle::after {
|
||||
content: ''; position: absolute; top: 1px; left: 1px; width: 14px; height: 14px;
|
||||
border-radius: 50%; background: var(--v5-text-muted);
|
||||
transition: all .25s; box-shadow: 0 1px 3px rgba(0,0,0,.15);
|
||||
}
|
||||
.dash-toggle.on {
|
||||
background: linear-gradient(135deg, var(--v5-primary), var(--v5-primary-light));
|
||||
border-color: transparent; box-shadow: var(--v5-glow-sm);
|
||||
}
|
||||
.dash-toggle.on::after { left: 15px; background: white; }
|
||||
|
||||
/* ── Toast ── */
|
||||
.dash-toast {
|
||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px);
|
||||
background: var(--v5-glass-strong, rgba(255,255,255,.65));
|
||||
backdrop-filter: blur(20px) saturate(1.4); border: 1px solid var(--v5-glass-border);
|
||||
border-radius: 12px; padding: .65rem 1.1rem;
|
||||
font-size: .7rem; font-weight: 600; color: var(--v5-text);
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,.15), var(--v5-glow-md);
|
||||
z-index: 300; opacity: 0; pointer-events: none;
|
||||
transition: all .3s cubic-bezier(.16,1,.3,1);
|
||||
display: flex; align-items: center; gap: .5rem;
|
||||
}
|
||||
.dash-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
@@ -0,0 +1,381 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
★ 개발자 빌더 — IDE 스타일 프로페셔널 테마
|
||||
코스믹 글래스모피즘 X → 깔끔한 IDE/Figma 스타일
|
||||
mockup 09-developer.css 기반 React 포팅
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ─── 개발자 전용 색상 (다크) ─── */
|
||||
.dark .dev-shell {
|
||||
--d-bg: #121218;
|
||||
--d-bg2: #1a1a22;
|
||||
--d-bg3: #22222c;
|
||||
--d-surface: #2a2a36;
|
||||
--d-surface2: #32323f;
|
||||
--d-border: #3a3a48;
|
||||
--d-border2: #4a4a58;
|
||||
--d-text: #e8e8ee;
|
||||
--d-text2: #b0b0be;
|
||||
--d-text3: #78788a;
|
||||
--d-accent: #5b9ef5;
|
||||
--d-accent2: #4a8de6;
|
||||
--d-green: #4ade80;
|
||||
--d-cyan: #22d3ee;
|
||||
--d-orange: #fb923c;
|
||||
--d-pink: #f472b6;
|
||||
--d-red: #f87171;
|
||||
}
|
||||
|
||||
/* ─── 개발자 전용 색상 (라이트) ─── */
|
||||
:root:not(.dark) .dev-shell,
|
||||
html:not(.dark) .dev-shell {
|
||||
--d-bg: #f5f5f8;
|
||||
--d-bg2: #ededf2;
|
||||
--d-bg3: #e4e4ec;
|
||||
--d-surface: #fff;
|
||||
--d-surface2: #f8f8fb;
|
||||
--d-border: #d8d8e2;
|
||||
--d-border2: #c4c4d0;
|
||||
--d-text: #1a1a24;
|
||||
--d-text2: #5a5a6e;
|
||||
--d-text3: #8a8a9e;
|
||||
--d-accent: #3b7dd8;
|
||||
--d-accent2: #2d6bc4;
|
||||
--d-green: #16a34a;
|
||||
--d-cyan: #0891b2;
|
||||
--d-orange: #ea580c;
|
||||
--d-pink: #db2777;
|
||||
--d-red: #dc2626;
|
||||
}
|
||||
|
||||
/* ═══ 셸 ═══ */
|
||||
.dev-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: var(--d-bg);
|
||||
color: var(--d-text);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ─── 헤더 ─── */
|
||||
.dev-hdr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 0.8rem;
|
||||
height: 42px;
|
||||
background: var(--d-bg2);
|
||||
border-bottom: 1px solid var(--d-border);
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dev-hdr-l { display: flex; align-items: center; gap: 0.6rem; }
|
||||
.dev-logo { font-size: 0.72rem; font-weight: 800; letter-spacing: -0.02em; color: var(--d-accent); }
|
||||
.dev-badge {
|
||||
font-size: 0.48rem; font-weight: 700; padding: 0.12rem 0.4rem; border-radius: 4px;
|
||||
background: var(--d-accent); color: #fff;
|
||||
}
|
||||
.dev-hdr-r { display: flex; align-items: center; gap: 0.35rem; }
|
||||
|
||||
/* ─── 도구모음 ─── */
|
||||
.dev-toolbar {
|
||||
display: flex; align-items: center; gap: 0.4rem; padding: 0 0.8rem;
|
||||
background: var(--d-bg2); border-bottom: 1px solid var(--d-border); height: 34px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dev-tb-group {
|
||||
display: flex; align-items: center; gap: 0.25rem;
|
||||
padding-right: 0.5rem; border-right: 1px solid var(--d-border); margin-right: 0.15rem;
|
||||
}
|
||||
.dev-tb-group:last-child { border-right: none; }
|
||||
.dev-tb-label {
|
||||
font-size: 0.44rem; font-weight: 700; color: var(--d-text3);
|
||||
text-transform: uppercase; letter-spacing: 0.04em; margin-right: 0.15rem;
|
||||
}
|
||||
|
||||
/* ─── 공통 버튼/셀렉트/인풋 ─── */
|
||||
.dev-btn {
|
||||
padding: 0.22rem 0.55rem; border-radius: 5px; border: 1px solid var(--d-border);
|
||||
background: var(--d-bg3); color: var(--d-text2); font-size: 0.52rem; font-weight: 600;
|
||||
cursor: pointer; transition: all 0.12s; display: flex; align-items: center; gap: 0.25rem;
|
||||
}
|
||||
.dev-btn:hover { border-color: var(--d-accent); color: var(--d-text); background: var(--d-surface); }
|
||||
.dev-btn.primary { background: var(--d-accent); border-color: var(--d-accent); color: #fff; }
|
||||
.dev-btn.primary:hover { background: var(--d-accent2); }
|
||||
|
||||
.dev-select {
|
||||
padding: 0.12rem 0.3rem; border-radius: 4px; border: 1px solid var(--d-border);
|
||||
background: var(--d-bg3); color: var(--d-text); font-size: 0.52rem; outline: none;
|
||||
}
|
||||
.dev-select:focus { border-color: var(--d-accent); }
|
||||
|
||||
.dev-input {
|
||||
padding: 0.18rem 0.35rem; border-radius: 4px; border: 1px solid var(--d-border);
|
||||
background: var(--d-bg3); color: var(--d-text); font-size: 0.5rem; outline: none;
|
||||
width: 100%; box-sizing: border-box;
|
||||
}
|
||||
.dev-input:focus { border-color: var(--d-accent); }
|
||||
|
||||
/* ─── 뷰 탭 ─── */
|
||||
.dev-view-tab {
|
||||
padding: 0.12rem 0.4rem; border-radius: 4px; border: 1px solid transparent;
|
||||
background: transparent; color: var(--d-text3); font-size: 0.52rem; cursor: pointer;
|
||||
transition: all 0.1s; font-weight: 600;
|
||||
}
|
||||
.dev-view-tab:hover { background: var(--d-surface); color: var(--d-text); }
|
||||
.dev-view-tab.active { background: var(--d-accent); color: #fff; border-color: var(--d-accent); }
|
||||
|
||||
/* ═══ 3패널 ═══ */
|
||||
.dev-body { display: flex; flex: 1; overflow: hidden; }
|
||||
|
||||
/* ─── 좌: 팔레트 ─── */
|
||||
.dev-palette {
|
||||
width: 180px; min-width: 180px; border-right: 1px solid var(--d-border);
|
||||
background: var(--d-bg2); overflow-y: auto; flex-shrink: 0;
|
||||
}
|
||||
.dev-pal-header {
|
||||
padding: 0.4rem 0.55rem; font-size: 0.48rem; font-weight: 700;
|
||||
color: var(--d-text3); text-transform: uppercase; letter-spacing: 0.06em;
|
||||
border-bottom: 1px solid var(--d-border);
|
||||
}
|
||||
.dev-pal-sec {
|
||||
padding: 0.4rem 0.55rem 0.15rem; font-size: 0.42rem; font-weight: 700;
|
||||
color: var(--d-accent); text-transform: uppercase; letter-spacing: 0.05em;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.dev-pal-item {
|
||||
display: flex; align-items: center; gap: 0.4rem; padding: 0.28rem 0.55rem;
|
||||
font-size: 0.56rem; font-weight: 500; color: var(--d-text2); cursor: grab;
|
||||
transition: all 0.1s; border-radius: 4px; margin: 1px 3px;
|
||||
}
|
||||
.dev-pal-item:hover { background: var(--d-surface); color: var(--d-text); }
|
||||
.dev-pal-item:active { cursor: grabbing; background: var(--d-surface2); }
|
||||
.dev-pal-icon {
|
||||
width: 18px; height: 18px; border-radius: 4px; display: flex;
|
||||
align-items: center; justify-content: center; font-size: 0.65rem; flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 카테고리별 아이콘 색상 */
|
||||
.dev-pal-item[data-cat="data"] .dev-pal-icon { color: var(--d-accent); }
|
||||
.dev-pal-item[data-cat="input"] .dev-pal-icon { color: var(--d-green); }
|
||||
.dev-pal-item[data-cat="action"] .dev-pal-icon { color: var(--d-pink); }
|
||||
.dev-pal-item[data-cat="display"] .dev-pal-icon { color: var(--d-orange); }
|
||||
|
||||
/* ─── 중: 캔버스 ─── */
|
||||
.dev-canvas {
|
||||
flex: 1; overflow: auto; position: relative; background: var(--d-bg);
|
||||
}
|
||||
.dark .dev-canvas {
|
||||
background-image: radial-gradient(circle, rgba(255,255,255,0.03) 0.5px, transparent 0.5px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
:root:not(.dark) .dev-canvas,
|
||||
html:not(.dark) .dev-canvas {
|
||||
background-image: radial-gradient(circle, rgba(0,0,0,0.06) 0.5px, transparent 0.5px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
.dev-canvas-inner {
|
||||
position: relative; min-width: 1200px; min-height: 800px; padding: 16px;
|
||||
}
|
||||
|
||||
/* 블록 */
|
||||
.dev-block {
|
||||
position: absolute; border: 1.5px dashed var(--d-border2); border-radius: 6px;
|
||||
background: var(--d-bg2); cursor: pointer; transition: border-color 0.1s, box-shadow 0.1s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dev-block:hover { border-color: var(--d-accent); box-shadow: 0 0 0 1px var(--d-accent); }
|
||||
.dev-block.selected {
|
||||
border-color: var(--d-accent); border-style: solid; border-width: 2px;
|
||||
box-shadow: 0 0 0 3px rgba(91,158,245,0.15);
|
||||
}
|
||||
.dev-block-label {
|
||||
position: absolute; top: -1px; left: 6px; padding: 0 0.3rem;
|
||||
font-size: 0.4rem; font-weight: 700; color: var(--d-accent); background: var(--d-bg);
|
||||
letter-spacing: 0.02em; z-index: 1;
|
||||
}
|
||||
.dev-block.selected .dev-block-label {
|
||||
background: var(--d-accent); color: #fff;
|
||||
border-radius: 0 0 3px 3px; padding: 0.02rem 0.3rem;
|
||||
}
|
||||
.dev-block-content {
|
||||
padding: 0.4rem; font-size: 0.5rem; color: var(--d-text2);
|
||||
pointer-events: none; height: 100%; display: flex; flex-direction: column;
|
||||
padding-top: 0.7rem;
|
||||
}
|
||||
|
||||
/* 리사이즈 핸들 */
|
||||
.dev-resize-handle {
|
||||
position: absolute; bottom: 0; right: 0; width: 12px; height: 12px;
|
||||
cursor: nwse-resize; z-index: 2;
|
||||
}
|
||||
.dev-resize-handle::after {
|
||||
content: ''; position: absolute; bottom: 2px; right: 2px;
|
||||
width: 6px; height: 6px; border-right: 2px solid var(--d-border2);
|
||||
border-bottom: 2px solid var(--d-border2);
|
||||
}
|
||||
.dev-block.selected .dev-resize-handle::after {
|
||||
border-color: var(--d-accent);
|
||||
}
|
||||
|
||||
/* ─── 우: 속성 패널 ─── */
|
||||
.dev-props {
|
||||
width: 260px; min-width: 260px; border-left: 1px solid var(--d-border);
|
||||
background: var(--d-bg2); overflow-y: auto; flex-shrink: 0;
|
||||
}
|
||||
.dev-prop-header {
|
||||
padding: 0.4rem 0.6rem; font-size: 0.55rem; font-weight: 700; color: var(--d-text);
|
||||
border-bottom: 1px solid var(--d-border); display: flex; align-items: center; gap: 0.25rem;
|
||||
background: var(--d-bg3);
|
||||
}
|
||||
.dev-prop-sec {
|
||||
padding: 0.4rem 0.6rem 0.15rem; font-size: 0.42rem; font-weight: 700;
|
||||
color: var(--d-accent); text-transform: uppercase; letter-spacing: 0.04em;
|
||||
border-top: 1px solid var(--d-border); margin-top: 0.15rem;
|
||||
}
|
||||
.dev-prop-sec:first-of-type { border-top: none; margin-top: 0; }
|
||||
|
||||
.dev-prop-row { padding: 0.2rem 0.6rem; display: flex; flex-direction: column; gap: 0.08rem; }
|
||||
.dev-prop-row.inline { flex-direction: row; align-items: center; justify-content: space-between; }
|
||||
.dev-prop-label { font-size: 0.46rem; font-weight: 600; color: var(--d-text3); }
|
||||
|
||||
/* 위치 그리드 (X/Y/W/H 4칸) */
|
||||
.dev-pos-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 0.2rem; padding: 0.2rem 0.6rem;
|
||||
}
|
||||
.dev-pos-item { display: flex; align-items: center; gap: 0.2rem; }
|
||||
.dev-pos-item label {
|
||||
font-size: 0.42rem; font-weight: 700; color: var(--d-text3); width: 14px;
|
||||
}
|
||||
.dev-pos-item input {
|
||||
flex: 1; padding: 0.12rem 0.2rem; border-radius: 3px; border: 1px solid var(--d-border);
|
||||
background: var(--d-bg3); color: var(--d-text); font-size: 0.46rem; text-align: center;
|
||||
outline: none; width: 0;
|
||||
}
|
||||
.dev-pos-item input:focus { border-color: var(--d-accent); }
|
||||
|
||||
/* 토글 */
|
||||
.dev-toggle {
|
||||
width: 26px; height: 14px; border-radius: 7px; background: var(--d-border);
|
||||
position: relative; cursor: pointer; transition: background 0.12s; flex-shrink: 0;
|
||||
}
|
||||
.dev-toggle.on { background: var(--d-accent); }
|
||||
.dev-toggle::after {
|
||||
content: ''; position: absolute; width: 10px; height: 10px; border-radius: 50%;
|
||||
background: #fff; top: 2px; left: 2px; transition: left 0.12s;
|
||||
}
|
||||
.dev-toggle.on::after { left: 14px; }
|
||||
|
||||
/* 필드 목록 */
|
||||
.dev-field-list { padding: 0 0.6rem 0.2rem; max-height: 280px; overflow-y: auto; }
|
||||
.dev-field-item {
|
||||
display: flex; align-items: center; gap: 0.3rem; padding: 0.18rem 0;
|
||||
font-size: 0.48rem; color: var(--d-text2); border-bottom: 1px dashed var(--d-border);
|
||||
cursor: pointer; transition: background 0.08s; border-radius: 2px;
|
||||
}
|
||||
.dev-field-item:last-child { border-bottom: none; }
|
||||
.dev-field-item:hover { background: var(--d-surface); }
|
||||
.dev-field-check {
|
||||
width: 14px; height: 14px; border-radius: 3px; border: 1.5px solid var(--d-border2);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 0.42rem;
|
||||
cursor: pointer; transition: all 0.1s; flex-shrink: 0; color: transparent;
|
||||
}
|
||||
.dev-field-check.on { background: var(--d-accent); border-color: var(--d-accent); color: #fff; }
|
||||
.dev-field-name { flex: 1; font-weight: 500; color: var(--d-text); font-size: 0.48rem; }
|
||||
.dev-field-type {
|
||||
font-size: 0.4rem; color: var(--d-text3); font-weight: 600;
|
||||
padding: 0.08rem 0.25rem; border-radius: 3px; background: var(--d-surface);
|
||||
}
|
||||
.dev-field-drag { color: var(--d-text3); cursor: grab; font-size: 0.5rem; }
|
||||
|
||||
/* 필드 배지 */
|
||||
.dev-fc-badge {
|
||||
font-size: 0.36rem; font-weight: 700; padding: 0.04rem 0.18rem; border-radius: 2px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.dev-fc-badge.pk { background: var(--d-accent); color: #fff; }
|
||||
.dev-fc-badge.req { background: var(--d-red); color: #fff; }
|
||||
.dev-fc-badge.sch { background: var(--d-green); color: #fff; }
|
||||
.dev-fc-badge.sys { background: var(--d-text3); color: #fff; }
|
||||
.dev-fc-badge.cmp { background: var(--d-orange); color: #fff; }
|
||||
|
||||
/* ═══ 상태바 ═══ */
|
||||
.dev-status {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 0.8rem; height: 22px; font-size: 0.42rem; color: var(--d-text3);
|
||||
background: var(--d-bg2); border-top: 1px solid var(--d-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ═══ 빈 캔버스 ═══ */
|
||||
.dev-empty {
|
||||
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||
text-align: center; color: var(--d-text3);
|
||||
}
|
||||
.dev-empty-icon { font-size: 2rem; margin-bottom: 0.4rem; opacity: 0.3; }
|
||||
.dev-empty-text { font-size: 0.6rem; font-weight: 500; }
|
||||
|
||||
/* ═══ 프리뷰 테이블 ═══ */
|
||||
.dev-pv-table { width: 100%; border-collapse: collapse; font-size: 0.48rem; }
|
||||
.dev-pv-table th {
|
||||
text-align: left; padding: 0.22rem 0.35rem; font-weight: 700; color: var(--d-text3);
|
||||
border-bottom: 1px solid var(--d-border); font-size: 0.42rem; text-transform: uppercase;
|
||||
}
|
||||
.dev-pv-table td {
|
||||
padding: 0.22rem 0.35rem; border-bottom: 1px dashed var(--d-border); color: var(--d-text2);
|
||||
}
|
||||
|
||||
/* ═══ 프리뷰 폼 ═══ */
|
||||
.dev-pv-field { display: flex; flex-direction: column; gap: 0.1rem; margin-bottom: 0.35rem; }
|
||||
.dev-pv-field-label { font-size: 0.42rem; font-weight: 700; color: var(--d-text3); }
|
||||
.dev-pv-field-input {
|
||||
padding: 0.22rem 0.35rem; border-radius: 4px; border: 1px solid var(--d-border);
|
||||
background: var(--d-bg3); font-size: 0.48rem; color: var(--d-text2);
|
||||
}
|
||||
|
||||
/* ═══ 프리뷰 검색 ═══ */
|
||||
.dev-pv-search { display: flex; gap: 0.25rem; flex-wrap: wrap; }
|
||||
.dev-pv-search-item { display: flex; flex-direction: column; gap: 0.06rem; }
|
||||
.dev-pv-search-label { font-size: 0.38rem; font-weight: 600; color: var(--d-text3); }
|
||||
.dev-pv-search-input {
|
||||
padding: 0.18rem 0.3rem; border-radius: 3px; border: 1px solid var(--d-border);
|
||||
background: var(--d-bg3); font-size: 0.44rem; color: var(--d-text2); min-width: 80px;
|
||||
}
|
||||
|
||||
/* ═══ 프리뷰 버튼 ═══ */
|
||||
.dev-pv-btn {
|
||||
display: inline-flex; align-items: center; gap: 0.2rem; padding: 0.18rem 0.45rem;
|
||||
border-radius: 4px; font-size: 0.46rem; font-weight: 600; border: 1px solid var(--d-border);
|
||||
color: var(--d-text2);
|
||||
}
|
||||
.dev-pv-btn.primary { background: var(--d-accent); border-color: var(--d-accent); color: #fff; }
|
||||
|
||||
/* ═══ 팝업 오버레이 ═══ */
|
||||
.dev-popup-overlay {
|
||||
position: absolute; inset: 0; background: rgba(0,0,0,0.3);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 20;
|
||||
}
|
||||
.dev-popup-frame {
|
||||
width: 500px; min-height: 300px; border-radius: 8px;
|
||||
border: 1px solid var(--d-border); background: var(--d-bg2);
|
||||
position: relative; overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
}
|
||||
.dev-popup-frame .dev-canvas-inner {
|
||||
min-width: unset; min-height: 300px; padding: 12px;
|
||||
}
|
||||
|
||||
/* ═══ 힌트 텍스트 ═══ */
|
||||
.dev-hint {
|
||||
font-size: 0.42rem; color: var(--d-text3); padding: 0.15rem 0.6rem; font-style: italic;
|
||||
}
|
||||
|
||||
/* ═══ 삭제 버튼 ═══ */
|
||||
.dev-delete-btn {
|
||||
width: 100%; padding: 0.25rem; border-radius: 4px; border: 1px solid var(--d-red);
|
||||
background: transparent; color: var(--d-red); font-size: 0.46rem; font-weight: 600;
|
||||
cursor: pointer; transition: all 0.12s;
|
||||
}
|
||||
.dev-delete-btn:hover { background: var(--d-red); color: #fff; }
|
||||
@@ -47,6 +47,13 @@ export interface FieldRef {
|
||||
searchColumns?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* select 타입의 선택지.
|
||||
* - string이면 value=label로 해석 (단순 목록)
|
||||
* - { value, label } 객체면 value를 저장, label을 표시 (코드값/표시값 분리)
|
||||
*/
|
||||
export type FieldOption = string | { value: string; label: string };
|
||||
|
||||
/**
|
||||
* 모든 컴포넌트가 공유하는 유일한 필드 정의.
|
||||
*
|
||||
@@ -92,8 +99,13 @@ export interface FieldConfig {
|
||||
|
||||
// ─── 타입별 확장 ───
|
||||
|
||||
/** select 타입: 선택지 목록 */
|
||||
options?: string[];
|
||||
/** select 타입: 선택지 목록
|
||||
* - string이면 value=label로 해석
|
||||
* - { value, label } 객체면 value를 저장, label을 표시
|
||||
* - select 렌더러/검색 파라미터는 항상 value를 저장/전송
|
||||
* - 테이블 셀은 label을 표시
|
||||
*/
|
||||
options?: FieldOption[];
|
||||
/** entity 타입: FK 참조 정보 */
|
||||
ref?: FieldRef;
|
||||
/** 포맷 문자열 (number: '#,##0', date: 'YYYY-MM-DD' 등) */
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
# Phase 1: DB 메타 읽기 → FieldConfig 변환
|
||||
|
||||
> **목적**: PostgreSQL 테이블의 스키마(컬럼명, 타입, PK/FK, nullable 등)를 읽어서 INVYONE의 FieldConfig[] 배열로 변환하는 파이프라인 구축
|
||||
> **전제 조건**: 없음 (최초 단계)
|
||||
> **산출물**: 테이블명을 주면 FieldConfig[]을 반환하는 백엔드 API + 프론트엔드 타입/API 클라이언트
|
||||
> **다음 단계**: Phase 2가 이 FieldConfig[]을 받아서 테이블/폼/검색 컴포넌트로 렌더링
|
||||
|
||||
---
|
||||
|
||||
## 1. 핵심 개념
|
||||
|
||||
INVYONE의 모든 UI는 **FieldConfig**라는 단일 규격으로 동작한다. FieldConfig는 DB 테이블의 컬럼 메타데이터를 UI가 소비할 수 있는 형태로 변환한 것이다.
|
||||
|
||||
```
|
||||
PostgreSQL information_schema + table_type_columns (기존)
|
||||
→ Java Service에서 변환
|
||||
→ FieldConfig[] JSON 반환
|
||||
→ 프론트엔드가 이걸 받아서 렌더링
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 현재 존재하는 것
|
||||
|
||||
### 2.1 백엔드 (backend-spring)
|
||||
|
||||
| 파일 | 역할 | 활용 |
|
||||
|---|---|---|
|
||||
| `TableManagementController.java` | 테이블/컬럼 CRUD API | **확장 대상** — FieldConfig 변환 API 추가 |
|
||||
| `TableManagementService.java` | 테이블 메타 조회 서비스 | **확장 대상** — 변환 로직 추가 |
|
||||
| `mapper/TableManagementMapper.xml` | MyBatis SQL | **확장 대상** — 조회 쿼리 추가 |
|
||||
| `DataController.java` | 범용 데이터 CRUD | 참고용 — 나중에 Phase 2에서 활용 |
|
||||
|
||||
### 2.2 DB 테이블 (이미 존재)
|
||||
|
||||
**`table_type_columns`** — VEX에서 가져온 컬럼 메타데이터 테이블:
|
||||
- `table_name`, `column_name`, `input_type`, `detail_settings` (JSONB)
|
||||
- `is_nullable`, `display_order`, `company_code`
|
||||
- 회사별 오버라이드 지원 (`company_code = '*'`이면 글로벌)
|
||||
|
||||
**PostgreSQL `information_schema.columns`** — DB 원본 스키마:
|
||||
- `table_name`, `column_name`, `data_type`, `is_nullable`, `column_default`
|
||||
- `character_maximum_length`, `ordinal_position`
|
||||
|
||||
**PostgreSQL `information_schema.table_constraints` + `key_column_usage`** — PK/FK 정보
|
||||
|
||||
### 2.3 프론트엔드
|
||||
|
||||
| 파일 | 역할 |
|
||||
|---|---|
|
||||
| `frontend/types/invyone-component.ts` | **FieldConfig 타입 정의 (이미 완성)** — 이 파일의 `FieldConfig` 인터페이스가 진실의 원천 |
|
||||
| `frontend/lib/api/tableSchema.ts` | 테이블 스키마 API 클라이언트 (기존 VEX용, 참고) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 상세 설계
|
||||
|
||||
### 3.1 FieldConfig 타입 (이미 정의됨 — frontend/types/invyone-component.ts)
|
||||
|
||||
```typescript
|
||||
interface FieldConfig {
|
||||
// 식별
|
||||
column: string; // DB 컬럼명
|
||||
label: string; // 화면 표시 라벨
|
||||
|
||||
// 타입
|
||||
type: FieldType; // 'text' | 'number' | 'date' | 'datetime' | 'select' | 'entity' | 'checkbox' | 'textarea' | 'file' | 'code'
|
||||
|
||||
// 표시
|
||||
visible: boolean;
|
||||
order: number;
|
||||
width?: number;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
|
||||
// 입력
|
||||
required: boolean;
|
||||
editable: boolean;
|
||||
defaultValue?: unknown;
|
||||
placeholder?: string;
|
||||
|
||||
// 타입별 확장
|
||||
options?: string[]; // select용
|
||||
ref?: FieldRef; // entity FK 참조
|
||||
format?: string; // 포맷 문자열
|
||||
computed?: string; // 자동 계산 수식
|
||||
|
||||
// 메타
|
||||
pk?: boolean;
|
||||
system?: boolean; // company_code 같은 시스템 필드
|
||||
searchable?: boolean;
|
||||
sortable?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 PostgreSQL → FieldType 매핑 규칙
|
||||
|
||||
DB의 `data_type`을 FieldType으로 변환하는 규칙:
|
||||
|
||||
```
|
||||
PostgreSQL data_type → FieldType
|
||||
─────────────────────────────────────────
|
||||
character varying, varchar → 'text'
|
||||
text → 'textarea'
|
||||
integer, bigint, smallint → 'number'
|
||||
numeric, decimal, real, double→ 'number'
|
||||
boolean → 'checkbox'
|
||||
date → 'date'
|
||||
timestamp, timestamptz → 'datetime'
|
||||
jsonb, json → 'textarea'
|
||||
bytea → 'file'
|
||||
(그 외) → 'text' (기본값)
|
||||
```
|
||||
|
||||
**단, `table_type_columns.input_type`이 있으면 그것이 우선한다.**
|
||||
|
||||
`table_type_columns.input_type` → FieldType 매핑:
|
||||
|
||||
```
|
||||
input_type → FieldType
|
||||
────────────────────────────
|
||||
text → 'text'
|
||||
number → 'number'
|
||||
date → 'date'
|
||||
datetime → 'datetime'
|
||||
select → 'select'
|
||||
entity → 'entity'
|
||||
checkbox → 'checkbox'
|
||||
boolean → 'checkbox'
|
||||
textarea → 'textarea'
|
||||
text_area → 'textarea'
|
||||
file → 'file'
|
||||
code → 'code'
|
||||
numbering → 'code'
|
||||
category → 'select'
|
||||
decimal → 'number'
|
||||
email → 'text'
|
||||
password → 'text'
|
||||
tel → 'text'
|
||||
(그 외) → 'text'
|
||||
```
|
||||
|
||||
### 3.3 변환 로직 상세
|
||||
|
||||
입력: `table_name` (String), `company_code` (String)
|
||||
|
||||
```
|
||||
Step 1: information_schema.columns에서 해당 테이블의 모든 컬럼 조회
|
||||
Step 2: information_schema.table_constraints + key_column_usage에서 PK 컬럼 목록 조회
|
||||
Step 3: table_type_columns에서 해당 테이블의 커스텀 메타 조회 (company_code 우선순위: 해당 회사 > '*')
|
||||
→ input_type='entity'인 컬럼의 detail_settings JSON에서 FK 참조 정보 추출
|
||||
→ referenceTable, referenceColumn, displayColumn
|
||||
Step 4: Step 1~3 병합하여 FieldConfig[] 생성
|
||||
```
|
||||
|
||||
**★ FK는 DB 제약조건(FOREIGN KEY)이 아닌 `table_type_columns.detail_settings` JSON으로 관리.**
|
||||
VEX는 비즈니스 테이블에 DB 레벨 FK 제약조건을 걸지 않는다. 관계 정보는 전부 메타데이터 테이블에서 관리한다.
|
||||
이유: 유연성 (마이그레이션 없이 관계 변경), 멀티테넌시 (회사별 관계 설정), 데이터 이관 시 제약조건 우회 가능.
|
||||
|
||||
#### Step 5 병합 규칙:
|
||||
|
||||
```
|
||||
FieldConfig.column = information_schema.column_name
|
||||
FieldConfig.label = table_type_columns.column_label (있으면) || column_name을 한글화 시도 (없으면 그대로)
|
||||
FieldConfig.type = table_type_columns.input_type 매핑 (있으면) || data_type 매핑
|
||||
FieldConfig.visible = true (기본값, table_type_columns에서 오버라이드 가능)
|
||||
FieldConfig.order = table_type_columns.display_order (있으면) || information_schema.ordinal_position
|
||||
FieldConfig.required = (is_nullable = 'NO') 이고 (column_default가 없으면) true
|
||||
FieldConfig.editable = PK가 아니고 system이 아니면 true
|
||||
FieldConfig.pk = PK 컬럼이면 true
|
||||
FieldConfig.system = column_name이 'company_code', 'created_by', 'created_date', 'updated_by', 'updated_date', 'is_active' 중 하나면 true
|
||||
FieldConfig.searchable = table_type_columns에서 지정 (없으면 text/select/entity/date만 true)
|
||||
FieldConfig.sortable = true (기본값)
|
||||
FieldConfig.ref = table_type_columns.input_type='entity'이면 detail_settings에서 { table, value_column, display_column } 추출
|
||||
FieldConfig.options = table_type_columns.detail_settings에서 options 추출 (select 타입)
|
||||
FieldConfig.format = number이면 '#,##0', date이면 'YYYY-MM-DD', datetime이면 'YYYY-MM-DD HH:mm'
|
||||
FieldConfig.width = type에 따라 기본값: text=150, number=100, date=120, select=130, entity=180
|
||||
FieldConfig.align = number이면 'right', 나머지 'left'
|
||||
```
|
||||
|
||||
### 3.4 백엔드 API 설계
|
||||
|
||||
#### 3.4.1 신규 API 엔드포인트
|
||||
|
||||
**`GET /api/meta/tables`** — 접근 가능한 테이블 목록
|
||||
|
||||
```
|
||||
Request: (없음, JWT에서 company_code 추출)
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"table_name": "order_management_test",
|
||||
"table_label": "수주관리",
|
||||
"column_count": 13,
|
||||
"has_custom_meta": true
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**`GET /api/meta/tables/{tableName}/fields`** — 특정 테이블의 FieldConfig[] 반환
|
||||
|
||||
```
|
||||
Request: GET /api/meta/tables/order_management_test/fields
|
||||
Headers: Authorization: Bearer {jwt}
|
||||
|
||||
Response (★ 백엔드 응답은 전부 snake_case, 프론트에서 camelCase 변환):
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"table_name": "order_management_test",
|
||||
"table_label": "수주관리",
|
||||
"primary_key": "order_no",
|
||||
"fields": [
|
||||
{
|
||||
"column": "order_no",
|
||||
"label": "수주번호",
|
||||
"type": "code",
|
||||
"visible": true,
|
||||
"order": 1,
|
||||
"width": 120,
|
||||
"align": "left",
|
||||
"required": true,
|
||||
"editable": false,
|
||||
"pk": true,
|
||||
"system": false,
|
||||
"searchable": true,
|
||||
"sortable": true,
|
||||
"format": null,
|
||||
"options": null,
|
||||
"ref": null,
|
||||
"computed": null
|
||||
},
|
||||
{
|
||||
"column": "order_date",
|
||||
"label": "수주일",
|
||||
"type": "date",
|
||||
"visible": true,
|
||||
"order": 2,
|
||||
"width": 120,
|
||||
"align": "left",
|
||||
"required": true,
|
||||
"editable": true,
|
||||
"pk": false,
|
||||
"system": false,
|
||||
"searchable": true,
|
||||
"sortable": true,
|
||||
"format": "YYYY-MM-DD",
|
||||
"options": null,
|
||||
"ref": null,
|
||||
"computed": null
|
||||
},
|
||||
{
|
||||
"column": "customer",
|
||||
"label": "거래처",
|
||||
"type": "entity",
|
||||
"visible": true,
|
||||
"order": 3,
|
||||
"width": 180,
|
||||
"align": "left",
|
||||
"required": true,
|
||||
"editable": true,
|
||||
"pk": false,
|
||||
"system": false,
|
||||
"searchable": true,
|
||||
"sortable": true,
|
||||
"format": null,
|
||||
"options": null,
|
||||
"ref": {
|
||||
"table": "customer_mng",
|
||||
"value_column": "customer_code",
|
||||
"display_column": "customer_name",
|
||||
"search_columns": ["customer_name", "biz_number"]
|
||||
},
|
||||
"computed": null
|
||||
},
|
||||
{
|
||||
"column": "amount",
|
||||
"label": "금액",
|
||||
"type": "number",
|
||||
"visible": true,
|
||||
"order": 7,
|
||||
"width": 100,
|
||||
"align": "right",
|
||||
"required": false,
|
||||
"editable": false,
|
||||
"pk": false,
|
||||
"system": false,
|
||||
"searchable": false,
|
||||
"sortable": true,
|
||||
"format": "#,##0",
|
||||
"options": null,
|
||||
"ref": null,
|
||||
"computed": "quantity * unit_price"
|
||||
},
|
||||
{
|
||||
"column": "status",
|
||||
"label": "상태",
|
||||
"type": "select",
|
||||
"visible": true,
|
||||
"order": 9,
|
||||
"width": 100,
|
||||
"align": "center",
|
||||
"required": false,
|
||||
"editable": true,
|
||||
"pk": false,
|
||||
"system": false,
|
||||
"searchable": true,
|
||||
"sortable": true,
|
||||
"format": null,
|
||||
"options": ["임시저장", "확정", "완료", "취소"],
|
||||
"ref": null,
|
||||
"computed": null
|
||||
},
|
||||
{
|
||||
"column": "company_code",
|
||||
"label": "회사코드",
|
||||
"type": "text",
|
||||
"visible": false,
|
||||
"order": 99,
|
||||
"required": false,
|
||||
"editable": false,
|
||||
"pk": false,
|
||||
"system": true,
|
||||
"searchable": false,
|
||||
"sortable": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**★ 관계 정보 2소스 책임 분리 (B안 확정):**
|
||||
|
||||
| 소스 | 용도 | 사용 Phase |
|
||||
|---|---|---|
|
||||
| `table_type_columns.detail_settings` | **필드 레벨 참조** — FieldConfig.ref, entity picker, 폼/검색/테이블 렌더링 | Phase 1 (`getTableFields`) |
|
||||
| `table_relationships` | **테이블 간 관계** — 제어 모드, 데이터플로우, 업무적 연결 (one-to-many 등) | Phase 5 (`getMetaRelations`) |
|
||||
|
||||
- Phase 1의 `getTableFields()`는 `table_type_columns`만 읽는다 (필드 참조 원천)
|
||||
- Phase 5의 관계 그래프는 `table_relationships`를 읽는다 (업무 관계 원천)
|
||||
- 제어 모드에서 두 소스가 필요하면 **서비스 레이어에서 읽기 시점에만 합친다** (저장 구조 통합 안 함)
|
||||
|
||||
따라서 **이 Phase 1에서는 relations API를 만들지 않는다.** `getTableFields()`가 `table_type_columns`에서 entity 참조(ref)를 추출하는 것까지만 책임진다. `table_relationships` 기반 관계 API는 Phase 5에서 구현한다.
|
||||
|
||||
#### 3.4.2 구현할 Java 파일 (★ 파이프라인 규칙 준수)
|
||||
|
||||
**★ 덕일 스타일 3레이어. Mapper Interface 금지. 파일명 1:1 매칭.**
|
||||
|
||||
| 파일 | 경로 | 비고 |
|
||||
|---|---|---|
|
||||
| `MetaController.java` | `controller/` | `/api/meta/*` |
|
||||
| `MetaService.java` | `service/` | `extends BaseService` |
|
||||
| `meta.xml` | `resources/mapper/` | `namespace="meta"` (소문자, Mapper 안 붙임) |
|
||||
|
||||
**MetaController.java:**
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/meta")
|
||||
@Slf4j
|
||||
public class MetaController {
|
||||
|
||||
@Autowired
|
||||
private MetaService metaService;
|
||||
|
||||
@GetMapping("/tables")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getMetaTableList(
|
||||
@RequestAttribute("companyCode") String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(metaService.getMetaTableList(params)));
|
||||
}
|
||||
|
||||
@GetMapping("/tables/{tableName}/fields")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getMetaFields(
|
||||
@PathVariable String tableName,
|
||||
@RequestAttribute("companyCode") String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(metaService.getMetaFields(params)));
|
||||
}
|
||||
|
||||
// ★ relations API는 Phase 1에서 안 만듦
|
||||
// table_relationships 기반 관계 조회는 Phase 5 (제어 모드)에서 구현
|
||||
// Phase 1은 getTableFields()의 FieldConfig.ref (entity 참조)까지만 책임
|
||||
}
|
||||
```
|
||||
|
||||
**MetaService.java:**
|
||||
|
||||
```java
|
||||
@Service
|
||||
@Slf4j
|
||||
public class MetaService extends BaseService {
|
||||
|
||||
@Autowired
|
||||
private CommonService commonService;
|
||||
|
||||
/** 테이블 목록 */
|
||||
public List<Map<String, Object>> getMetaTableList(Map<String, Object> params) {
|
||||
return sqlSession.selectList("meta.getMetaTableList", params);
|
||||
}
|
||||
|
||||
/** 특정 테이블의 FieldConfig[] 반환 */
|
||||
public Map<String, Object> getMetaFields(Map<String, Object> params) {
|
||||
List<Map<String, Object>> schemaCols = sqlSession.selectList("meta.getSchemaColumns", params);
|
||||
List<String> pks = sqlSession.selectList("meta.getPrimaryKeys", params);
|
||||
List<Map<String, Object>> customMeta = sqlSession.selectList("meta.getCustomMeta", params);
|
||||
// ★ FK는 DB 제약조건이 아닌 customMeta(table_type_columns)의 detail_settings에서 추출
|
||||
// input_type='entity'인 컬럼의 detail_settings JSON → referenceTable/referenceColumn/displayColumn
|
||||
|
||||
List<Map<String, Object>> fields = buildFieldConfigs(schemaCols, pks, customMeta);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("table_name", params.get("table_name"));
|
||||
result.put("fields", fields);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ★ relations 조회는 Phase 5에서 table_relationships 기반으로 구현
|
||||
// Phase 1은 getMetaFields()의 FieldConfig.ref (entity 참조)까지만 책임
|
||||
|
||||
/** 병합하여 FieldConfig Map 리스트 생성 (상세 로직은 Section 3.3 참고) */
|
||||
private List<Map<String, Object>> buildFieldConfigs(
|
||||
List<Map<String, Object>> schemaCols,
|
||||
List<String> pks,
|
||||
List<Map<String, Object>> customMeta
|
||||
) {
|
||||
// column, label, type, visible, order, required, editable, pk, system 등
|
||||
// customMeta가 있으면 우선 적용
|
||||
// ★ input_type='entity'이면 detail_settings JSON 파싱 → ref Map 생성
|
||||
// ObjectMapper로 detail_settings 파싱 → referenceTable, referenceColumn, displayColumn 추출
|
||||
// system 컬럼은 visible=false, system=true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.4.3 MyBatis SQL — `meta.xml` (★ 덕일 스타일)
|
||||
|
||||
**★ 파일명: `meta.xml` (소문자, Mapper 안 붙임)**
|
||||
**★ namespace: `meta`**
|
||||
**★ SQL 키워드/테이블/컬럼: UPPER_SNAKE, #{파라미터}: snake_case**
|
||||
**★ SELECT 쉼표 앞에, OGNL test 바깥 작은따옴표**
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="meta">
|
||||
|
||||
<!-- ═══ 테이블 목록 (public 스키마, 시스템 테이블 제외) ═══ -->
|
||||
<select id="getMetaTableList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
T.TABLE_NAME
|
||||
, (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS C
|
||||
WHERE C.TABLE_SCHEMA = 'public' AND C.TABLE_NAME = T.TABLE_NAME) AS COLUMN_COUNT
|
||||
, CASE WHEN EXISTS(
|
||||
SELECT 1 FROM TABLE_TYPE_COLUMNS TTC
|
||||
WHERE TTC.TABLE_NAME = T.TABLE_NAME
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (TTC.COMPANY_CODE = #{company_code} OR TTC.COMPANY_CODE = '*')
|
||||
</if>
|
||||
) THEN true ELSE false END AS HAS_CUSTOM_META
|
||||
FROM INFORMATION_SCHEMA.TABLES T
|
||||
WHERE T.TABLE_SCHEMA = 'public'
|
||||
AND T.TABLE_TYPE = 'BASE TABLE'
|
||||
AND T.TABLE_NAME NOT LIKE 'pg_%'
|
||||
AND T.TABLE_NAME NOT IN ('spatial_ref_sys')
|
||||
ORDER BY T.TABLE_NAME
|
||||
</select>
|
||||
|
||||
<!-- ═══ 특정 테이블의 컬럼 정보 ═══ -->
|
||||
<select id="getSchemaColumns" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
COLUMN_NAME
|
||||
, DATA_TYPE
|
||||
, IS_NULLABLE
|
||||
, COLUMN_DEFAULT
|
||||
, CHARACTER_MAXIMUM_LENGTH
|
||||
, ORDINAL_POSITION
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = 'public'
|
||||
AND TABLE_NAME = #{table_name}
|
||||
ORDER BY ORDINAL_POSITION
|
||||
</select>
|
||||
|
||||
<!-- ═══ PK 컬럼 목록 ═══ -->
|
||||
<select id="getPrimaryKeys" parameterType="map" resultType="string">
|
||||
SELECT KCU.COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS TC
|
||||
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE KCU
|
||||
ON TC.CONSTRAINT_NAME = KCU.CONSTRAINT_NAME
|
||||
AND TC.TABLE_SCHEMA = KCU.TABLE_SCHEMA
|
||||
WHERE TC.CONSTRAINT_TYPE = 'PRIMARY KEY'
|
||||
AND TC.TABLE_SCHEMA = 'public'
|
||||
AND TC.TABLE_NAME = #{table_name}
|
||||
</select>
|
||||
|
||||
<!-- ═══ 관계 조회 (★ DB FK 제약조건이 아닌 table_type_columns 메타 기반) ═══ -->
|
||||
<select id="getMetaRelations" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
TABLE_NAME AS FROM_TABLE
|
||||
, COLUMN_NAME AS FROM_COLUMN
|
||||
, INPUT_TYPE
|
||||
, DETAIL_SETTINGS
|
||||
FROM TABLE_TYPE_COLUMNS
|
||||
WHERE TABLE_NAME = #{table_name}
|
||||
AND INPUT_TYPE IN ('entity', 'category')
|
||||
AND DETAIL_SETTINGS IS NOT NULL
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
<!--
|
||||
★ detail_settings JSON에서 참조 정보 추출 (Java Service에서 파싱):
|
||||
{
|
||||
"referenceTable": "partner_info",
|
||||
"referenceColumn": "id",
|
||||
"displayColumn": "partner_name"
|
||||
}
|
||||
→ FieldConfig.ref = { table, value_column, display_column } 로 변환
|
||||
-->
|
||||
|
||||
<!-- ═══ TABLE_TYPE_COLUMNS 커스텀 메타 (회사 우선, '*' 폴백) ═══ -->
|
||||
<select id="getCustomMeta" parameterType="map" resultType="map">
|
||||
SELECT DISTINCT ON (COLUMN_NAME)
|
||||
COLUMN_NAME
|
||||
, INPUT_TYPE
|
||||
, DETAIL_SETTINGS
|
||||
, DISPLAY_ORDER
|
||||
, IS_NULLABLE
|
||||
, COMPANY_CODE
|
||||
FROM TABLE_TYPE_COLUMNS
|
||||
WHERE TABLE_NAME = #{table_name}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
ORDER BY COLUMN_NAME
|
||||
, CASE WHEN COMPANY_CODE = #{company_code} THEN 0 ELSE 1 END
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
```
|
||||
|
||||
### 3.5 프론트엔드 API 클라이언트
|
||||
|
||||
**신규 파일: `frontend/lib/api/meta.ts`**
|
||||
|
||||
```typescript
|
||||
import { client } from './client';
|
||||
|
||||
import type { FieldConfig } from '@/types/invyone-component';
|
||||
|
||||
/**
|
||||
* ★ 일반 API는 Record<string, any> — 별도 인터페이스 정의 안 함
|
||||
* ★ 단, FieldConfig만은 invyone-component.ts에 규격 확정된 예외 타입
|
||||
* → getTableFields()의 fields 배열은 FieldConfig[]로 캐스팅
|
||||
*/
|
||||
|
||||
/** 접근 가능한 테이블 목록 */
|
||||
export async function getTableList(): Promise<Record<string, any>[]> {
|
||||
const res = await client.get('/api/meta/tables');
|
||||
return res.data.data;
|
||||
}
|
||||
|
||||
/** 특정 테이블의 FieldConfig[] 반환 (★ fields만 FieldConfig[] 타입) */
|
||||
export async function getTableFields(tableName: string): Promise<{
|
||||
table_name: string;
|
||||
fields: FieldConfig[];
|
||||
[key: string]: any;
|
||||
}> {
|
||||
const res = await client.get(`/api/meta/tables/${tableName}/fields`);
|
||||
return res.data.data;
|
||||
}
|
||||
|
||||
// ★ relations API는 Phase 5에서 구현 (table_relationships 기반)
|
||||
// Phase 1은 getTableFields()의 FieldConfig.ref까지만 책임
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 시스템 필드 판별 규칙
|
||||
|
||||
다음 컬럼명은 자동으로 `system: true`로 설정하고, `visible: false`, `editable: false`로 만든다:
|
||||
|
||||
```
|
||||
company_code, created_by, created_date, updated_by, updated_date,
|
||||
is_active, deleted_date, deleted_by, writer, write_date
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 참고 파일
|
||||
|
||||
| 파일 | 용도 |
|
||||
|---|---|
|
||||
| `frontend/types/invyone-component.ts` | **FieldConfig 타입 정의 (진실의 원천)** — 이 파일의 인터페이스와 정확히 일치하는 JSON을 반환해야 함 |
|
||||
| `notes/gbpark/2026-04-09-invyone-architecture.md` | 아키텍처 결정 문서 — 변환 규칙의 근거 |
|
||||
| `notes/gbpark/2026-04-08-invyone-component-spec.md` | 컴포넌트 규격 — FieldType별 렌더링 계약 |
|
||||
| `backend-spring/src/main/java/com/erp/service/TableManagementService.java` | 기존 테이블 관리 서비스 — 참고용 (패턴) |
|
||||
| `backend-spring/src/main/resources/mapper/TableManagementMapper.xml` | 기존 MyBatis 매퍼 — SQL 패턴 참고 |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/js/08-admin-builder.js` | mockup의 테이블 메타 데이터 — `AB_TABLE_FIELDS` 객체가 FieldConfig의 mockup 버전 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 완료 기준
|
||||
|
||||
1. **`GET /api/meta/tables`** 호출 시 DB의 public 스키마 테이블 목록이 반환된다
|
||||
2. **`GET /api/meta/tables/{tableName}/fields`** 호출 시:
|
||||
- `FieldConfig[]` 형태의 JSON이 반환된다
|
||||
- `frontend/types/invyone-component.ts`의 `FieldConfig` 인터페이스와 필드명이 정확히 일치한다
|
||||
- PK 컬럼에 `pk: true`가 설정된다
|
||||
- system 컬럼(company_code 등)에 `system: true`, `visible: false`가 설정된다
|
||||
- `table_type_columns`에 커스텀 메타가 있으면 그것이 우선 적용된다
|
||||
- select 타입 필드에 `options[]`가 포함된다
|
||||
- entity 타입 필드에 `ref` 객체가 포함된다
|
||||
3. ~~relations API는 Phase 1에서 안 만듦~~ (Phase 5에서 `table_relationships` 기반으로 구현)
|
||||
4. 프론트엔드 `lib/api/meta.ts`에서 위 API들을 호출하는 함수가 있다
|
||||
5. 실제 DB 테이블 (예: `order_management_test`, `user_info`)로 테스트하여 합리적인 FieldConfig가 생성됨을 확인
|
||||
|
||||
---
|
||||
|
||||
## 7. 다음 단계 연결
|
||||
|
||||
Phase 2는 이 API가 반환하는 `FieldConfig[]`을 받아서:
|
||||
- **테이블 컴포넌트**: FieldConfig[]을 컬럼으로 렌더링
|
||||
- **폼 컴포넌트**: FieldConfig[]을 입력 필드로 렌더링
|
||||
- **검색 컴포넌트**: FieldConfig[]을 검색 조건으로 렌더링
|
||||
|
||||
Phase 2의 컴포넌트들은 `getTableFields(tableName)`을 호출하여 FieldConfig[]을 가져온 뒤 렌더링한다.
|
||||
@@ -0,0 +1,298 @@
|
||||
# Phase 1 구현 작업기록 — DB 메타 → FieldConfig 변환
|
||||
|
||||
> **작업일**: 2026-04-10
|
||||
> **설계서**: `notes/gbpark/2026-04-10-phase1-db-meta-to-fieldconfig.md`
|
||||
> **상태**: 구현 완료 + DB 실테스트 통과
|
||||
|
||||
---
|
||||
|
||||
## 1. 생성된 파일 (4개)
|
||||
|
||||
### 1.1 `backend-spring/src/main/resources/mapper/meta.xml`
|
||||
|
||||
MyBatis XML 매퍼. namespace=`meta`, 쿼리 5개:
|
||||
|
||||
| 쿼리 ID | 용도 | 반환 |
|
||||
|---|---|---|
|
||||
| `getMetaTableList` | public 스키마 테이블 목록 | table_name, table_label, column_count, has_custom_meta |
|
||||
| `getSchemaColumns` | information_schema.columns 전체 컬럼 | column_name, data_type, is_nullable, column_default, ordinal_position |
|
||||
| `getPrimaryKeys` | PK 컬럼명 목록 | string (column_name) |
|
||||
| `getCustomMeta` | table_type_columns 커스텀 메타 (회사 우선순위) | column_name, column_label, input_type, detail_settings, reference_table 등 |
|
||||
| `getTableLabel` | TABLE_LABELS에서 라벨 단건 | string (table_label) |
|
||||
|
||||
**회사 우선순위 처리**: `getCustomMeta`에서 `DISTINCT ON (COLUMN_NAME)` + `CASE WHEN COMPANY_CODE = #{company_code} THEN 0 ELSE 1 END` 정렬로 회사별 메타가 '*'(글로벌)보다 우선.
|
||||
|
||||
---
|
||||
|
||||
### 1.2 `backend-spring/src/main/java/com/erp/service/MetaService.java`
|
||||
|
||||
핵심 변환 로직. 덕일 스타일 준수 — extends BaseService, @Autowired CommonService, sqlSession 직접 호출.
|
||||
|
||||
#### 주요 메서드
|
||||
|
||||
| 메서드 | 역할 |
|
||||
|---|---|
|
||||
| `getMetaTableList(params)` | 테이블 목록 pass-through |
|
||||
| `getMetaFields(params)` | 4개 쿼리 오케스트레이션 → buildFieldConfigs 호출 → 결과 조립 |
|
||||
| `buildFieldConfigs(schemaCols, pks, customMeta)` | **핵심** — 3소스 병합하여 FieldConfig Map 리스트 생성 |
|
||||
| `mapDataTypeToFieldType(dataType)` | PostgreSQL data_type → FieldType 변환 (12개 매핑) |
|
||||
| `mapInputTypeToFieldType(inputType)` | table_type_columns.input_type → FieldType 변환 (15개 매핑) |
|
||||
| `buildFieldRef(meta, detailSettings)` | entity 타입의 ref 객체 빌드 (top-level 컬럼 우선 → detail_settings 폴백) |
|
||||
| `extractOptions(detailSettings)` | select 타입의 options 추출 (`string[]` 및 `[{value,label}]` 둘 다 처리) |
|
||||
| `parseDetailSettings(meta)` | detail_settings JSONB → Map 파싱 (ObjectMapper) |
|
||||
|
||||
#### buildFieldConfigs 병합 규칙 (필드별)
|
||||
|
||||
```
|
||||
column = information_schema.column_name
|
||||
label = table_type_columns.column_label (있으면) || column_name
|
||||
type = table_type_columns.input_type 매핑 (있으면) || data_type 매핑
|
||||
visible = system이면 무조건 false, 아니면 is_visible
|
||||
order = display_order > 0이면 우선 || ordinal_position
|
||||
required = ★ 앱 레벨 메타(table_type_columns.is_nullable) 우선, 없으면 DB 스키마 폴백
|
||||
editable = !PK && !system && type!='code'
|
||||
pk = information_schema PK 제약조건
|
||||
system = SYSTEM_FIELDS 목록에 포함 여부
|
||||
searchable = !system && (text|select|entity|date|code)
|
||||
sortable = !system
|
||||
format = number→'#,##0', date→'YYYY-MM-DD', datetime→'YYYY-MM-DD HH:mm'
|
||||
width = type별 기본값 (number:100, date:120, entity:180 등)
|
||||
align = number→'right', 나머지→'left'
|
||||
options = detail_settings.options (string[] 또는 [{value,label}] → label 추출)
|
||||
ref = entity일 때 reference_table/column/display_column (top-level 우선 → detail_settings 폴백)
|
||||
computed = detail_settings.computed (있으면 editable=false 강제)
|
||||
```
|
||||
|
||||
#### required 판정 로직 (★ 수동 수정 반영)
|
||||
|
||||
```java
|
||||
// ★ 앱 레벨 메타 우선, DB 스키마 폴백
|
||||
if (!isSystem && meta != null && meta.get("is_nullable") != null) {
|
||||
// table_type_columns.IS_NULLABLE가 있으면 앱 레벨 메타가 진실
|
||||
required = "NO".equalsIgnoreCase(metaNullable);
|
||||
} else {
|
||||
// 없으면 information_schema 폴백
|
||||
required = "NO".equalsIgnoreCase(isNullable) && columnDefault == null && !isSystem;
|
||||
}
|
||||
```
|
||||
|
||||
원래 구현: information_schema.is_nullable + column_default 기반.
|
||||
수정 이유: VEX에서 앱 레벨 메타(table_type_columns.IS_NULLABLE)가 DB 스키마보다 우선하는 패턴. 관리자가 설정한 필수 여부가 DB 제약조건보다 비즈니스적으로 정확함.
|
||||
|
||||
#### system 필드 목록
|
||||
|
||||
```
|
||||
company_code, created_by, created_date, updated_by, updated_date,
|
||||
is_active, deleted_date, deleted_by, writer, write_date
|
||||
```
|
||||
|
||||
→ 자동으로 `visible: false`, `editable: false`, `required: false`, `searchable: false`, `sortable: false`
|
||||
|
||||
#### 타입 매핑 테이블
|
||||
|
||||
**PostgreSQL data_type → FieldType:**
|
||||
|
||||
| data_type | FieldType |
|
||||
|---|---|
|
||||
| character varying, varchar | text |
|
||||
| text | textarea |
|
||||
| integer, bigint, smallint | number |
|
||||
| numeric, decimal, real, double precision | number |
|
||||
| boolean | checkbox |
|
||||
| date | date |
|
||||
| timestamp (with/without tz) | datetime |
|
||||
| jsonb, json | textarea |
|
||||
| bytea | file |
|
||||
| 기타 | text |
|
||||
|
||||
**input_type → FieldType (custom meta 우선):**
|
||||
|
||||
| input_type | FieldType |
|
||||
|---|---|
|
||||
| text, email, password, tel | text |
|
||||
| number, decimal | number |
|
||||
| date | date |
|
||||
| datetime | datetime |
|
||||
| select, category | select |
|
||||
| entity | entity |
|
||||
| checkbox, boolean | checkbox |
|
||||
| textarea, text_area | textarea |
|
||||
| file | file |
|
||||
| code, numbering | code |
|
||||
| 기타 | text |
|
||||
|
||||
#### entity ref 빌드 로직
|
||||
|
||||
```
|
||||
1차: top-level 컬럼 (REFERENCE_TABLE, REFERENCE_COLUMN, DISPLAY_COLUMN)
|
||||
2차: detail_settings JSON 폴백 (referenceTable, referenceColumn, displayColumn)
|
||||
→ table이 없으면 ref=null
|
||||
→ value_column 기본값 "id", display_column 기본값 = value_column
|
||||
→ search_columns는 detail_settings에서만 추출
|
||||
```
|
||||
|
||||
#### select options 추출 로직
|
||||
|
||||
```
|
||||
detail_settings.options 필드에서 추출
|
||||
- string[] 형식: ["대기", "확정", ...] → 그대로 반환
|
||||
- [{value, label}] 형식: [{value:"PENDING", label:"대기"}, ...] → label만 추출
|
||||
- options 없으면 null (category 타입은 common code 연동 필요 — Phase 1 범위 밖)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.3 `backend-spring/src/main/java/com/erp/controller/MetaController.java`
|
||||
|
||||
REST 컨트롤러. 2개 엔드포인트:
|
||||
|
||||
| 엔드포인트 | 메서드 | 설명 |
|
||||
|---|---|---|
|
||||
| `GET /api/meta/tables` | `getMetaTableList` | 접근 가능한 테이블 목록 |
|
||||
| `GET /api/meta/tables/{tableName}/fields` | `getMetaFields` | 특정 테이블의 FieldConfig[] 반환 |
|
||||
|
||||
- `@RequestAttribute("company_code")` — JWT 필터에서 주입 (snake_case, 기존 패턴 따름)
|
||||
- `@RequiredArgsConstructor` + `private final MetaService` — 기존 컨트롤러 패턴 따름
|
||||
- relations API는 Phase 1에서 안 만듦 (Phase 5 담당)
|
||||
|
||||
---
|
||||
|
||||
### 1.4 `frontend/lib/api/meta.ts`
|
||||
|
||||
프론트엔드 API 클라이언트. 2개 함수:
|
||||
|
||||
| 함수 | 반환 타입 | 설명 |
|
||||
|---|---|---|
|
||||
| `getMetaTableList()` | `Record<string, any>[]` | 테이블 목록 (별도 인터페이스 안 만듦) |
|
||||
| `getMetaFields(tableName)` | `{table_name, table_label, primary_key, fields: FieldConfig[]}` | FieldConfig 규격 타입 사용 (유일한 예외) |
|
||||
|
||||
**snake_case → camelCase 변환 처리:**
|
||||
- `toFieldConfig(raw)` — 대부분 단일 단어라 변환 불필요, `default_value` → `defaultValue`만 처리
|
||||
- `toFieldRef(raw)` — `value_column` → `valueColumn`, `display_column` → `displayColumn`, `search_columns` → `searchColumns`
|
||||
- `apiClient` 사용 (baseURL에 `/api` 이미 포함 → `/meta/tables`로 호출)
|
||||
|
||||
---
|
||||
|
||||
## 2. 덕일 스타일 준수 체크리스트
|
||||
|
||||
| 규칙 | 준수 |
|
||||
|---|---|
|
||||
| 3레이어 (Controller → Service → XML) | O |
|
||||
| Mapper Interface 금지 | O — sqlSession 직접 호출 |
|
||||
| Map<String, Object> 사용, DTO 금지 | O — ApiResponse만 예외 |
|
||||
| BaseService 상속 | O |
|
||||
| @Autowired CommonService | O |
|
||||
| XML 파일명: 소문자, Mapper 안 붙임 | O — `meta.xml` |
|
||||
| XML namespace: 파일명과 동일 | O — `namespace="meta"` |
|
||||
| SQL: UPPER_SNAKE | O |
|
||||
| SELECT 쉼표: 앞에 | O |
|
||||
| #{파라미터}: snake_case | O |
|
||||
| OGNL test: 바깥 작은따옴표 | O |
|
||||
| 프론트 타입: Record<string, any> (FieldConfig만 예외) | O |
|
||||
|
||||
---
|
||||
|
||||
## 3. 실제 DB 테스트 결과
|
||||
|
||||
**테스트 환경**: `test_dev` DB (211.115.91.141:11134)
|
||||
**테스트 계정**: shkim (SUPER_ADMIN)
|
||||
**테스트 테이블**: `sales_order_mng` (entity/select/category/number/date 타입 다수 보유)
|
||||
|
||||
### 3.1 GET /api/meta/tables
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{"table_name": "approval_definitions", "table_label": "approval_definitions", "column_count": 15, "has_custom_meta": false},
|
||||
{"table_name": "sales_order_mng", "table_label": "수주관리", "column_count": 44, "has_custom_meta": true},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 GET /api/meta/tables/sales_order_mng/fields
|
||||
|
||||
**검증 결과:**
|
||||
|
||||
| 검증 항목 | 결과 | 상세 |
|
||||
|---|---|---|
|
||||
| table_label | OK | "수주관리" (TABLE_LABELS에서) |
|
||||
| primary_key | OK | "id" |
|
||||
| PK 감지 | OK | id: `pk:true, editable:false` |
|
||||
| system 필드 | OK | company_code, created_by/date, updated_by/date, writer → `visible:false, system:true` |
|
||||
| entity + ref | OK | partner_id: `ref:{table:"customer_mng", value_column:"customer_code", display_column:"customer_name"}` |
|
||||
| entity (top-level 컬럼) | OK | delivery_partner_id: `ref:{table:"delivery_destination", ...}` |
|
||||
| select + options ({value,label}) | OK | shipping_method: `options:["직접배송","택배","화물","퀵서비스"]` |
|
||||
| select + options (문자열) | OK | status: `options:["대기","확정","출하","완료","취소"]` |
|
||||
| custom meta 우선 | OK | id(integer) → table_type_columns에 input_type='text' 설정 → type:"text" |
|
||||
| date 포맷 | OK | `format:"YYYY-MM-DD"` |
|
||||
| number 포맷/정렬 | OK | `format:"#,##0", align:"right"` |
|
||||
| 컴파일 | OK | `./gradlew compileJava` + `./gradlew bootJar` 성공 |
|
||||
| 프론트 타입체크 | OK | `npx tsc --noEmit` — meta.ts 에러 0개 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase 1 범위 밖 (나중에 해야 할 것)
|
||||
|
||||
| 항목 | 담당 Phase | 비고 |
|
||||
|---|---|---|
|
||||
| relations API (table_relationships) | Phase 5 | 제어 모드에서 사용 |
|
||||
| category 타입의 common code 옵션 로딩 | Phase 2+ | CODE_CATEGORY → 공통코드 테이블 조회 |
|
||||
| entity ref가 없는 entity 필드 처리 | Phase 2+ | part_code, manager_id 등 — reference_table 미설정 |
|
||||
| FieldConfig.defaultValue, placeholder 세팅 | Phase 2+ | detail_settings에서 추출 가능하나 현재 미구현 |
|
||||
| select options의 value/label 분리 저장 | Phase 2+ | 현재 label만 string[]로 반환, value 매핑 필요 시 타입 확장 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 파일별 코드 요약
|
||||
|
||||
### meta.xml (89줄)
|
||||
|
||||
```
|
||||
5개 쿼리:
|
||||
- getMetaTableList: INFORMATION_SCHEMA.TABLES + TABLE_LABELS + TABLE_TYPE_COLUMNS EXISTS
|
||||
- getSchemaColumns: INFORMATION_SCHEMA.COLUMNS
|
||||
- getPrimaryKeys: TABLE_CONSTRAINTS + KEY_COLUMN_USAGE
|
||||
- getCustomMeta: TABLE_TYPE_COLUMNS (DISTINCT ON + 회사 우선순위)
|
||||
- getTableLabel: TABLE_LABELS 단건
|
||||
```
|
||||
|
||||
### MetaService.java (375줄)
|
||||
|
||||
```
|
||||
퍼블릭 메서드 2개:
|
||||
- getMetaTableList(params) → List<Map>
|
||||
- getMetaFields(params) → Map (table_name, table_label, primary_key, fields[])
|
||||
|
||||
프라이빗 메서드 10개:
|
||||
- buildFieldConfigs: 3소스 병합 (핵심, ~120줄)
|
||||
- mapDataTypeToFieldType: PG타입→FieldType (12매핑)
|
||||
- mapInputTypeToFieldType: input_type→FieldType (15매핑)
|
||||
- buildFieldRef: entity ref 조립 (top-level 우선 → detail_settings 폴백)
|
||||
- extractOptions: select options 추출 (string[] + {value,label} 대응)
|
||||
- parseDetailSettings: JSONB→Map (ObjectMapper)
|
||||
- getDefaultWidth: 타입별 기본 너비
|
||||
- getDefaultFormat: 타입별 기본 포맷
|
||||
- str, strFromMap, num: Map 유틸
|
||||
```
|
||||
|
||||
### MetaController.java (45줄)
|
||||
|
||||
```
|
||||
2개 엔드포인트:
|
||||
- GET /api/meta/tables → getMetaTableList
|
||||
- GET /api/meta/tables/{tableName}/fields → getMetaFields
|
||||
```
|
||||
|
||||
### meta.ts (86줄)
|
||||
|
||||
```
|
||||
2개 API 함수:
|
||||
- getMetaTableList() → Record<string, any>[]
|
||||
- getMetaFields(tableName) → {fields: FieldConfig[], ...}
|
||||
|
||||
2개 변환 헬퍼:
|
||||
- toFieldConfig(raw) → FieldConfig
|
||||
- toFieldRef(raw) → FieldRef (snake→camel)
|
||||
```
|
||||
@@ -0,0 +1,386 @@
|
||||
# Phase 2: 규격 기반 컴포넌트 — FieldConfig로 테이블/폼/검색 렌더링
|
||||
|
||||
> **목적**: FieldConfig[]을 입력으로 받아 테이블/폼/검색 UI를 렌더링하는 React 컴포넌트 구현
|
||||
> **전제 조건**: Phase 1 완료 (GET /api/meta/tables/{tableName}/fields API가 FieldConfig[]을 반환)
|
||||
> **산출물**: FieldConfig 기반 3대 핵심 컴포넌트 (FcTable, FcForm, FcSearch) + 보조 컴포넌트 (FcButton, FcButtonBar, FcPagination)
|
||||
> **다음 단계**: Phase 3에서 이 컴포넌트들을 개발자 빌더 캔버스에 배치
|
||||
|
||||
---
|
||||
|
||||
## 1. 핵심 원칙
|
||||
|
||||
**"같은 FieldConfig가 컴포넌트에 따라 다르게 렌더된다"**
|
||||
|
||||
```
|
||||
FieldConfig { column: 'order_date', label: '수주일', type: 'date' }
|
||||
|
||||
→ FcTable에서: <td>2026-04-08</td> (format 적용된 텍스트)
|
||||
→ FcForm에서: <DatePicker label="수주일" /> (입력 위젯)
|
||||
→ FcSearch에서: <DateRangePicker /> (범위 검색)
|
||||
```
|
||||
|
||||
이 렌더링 규칙은 `notes/gbpark/2026-04-08-invyone-component-spec.md` Section 2.2에 명시되어 있다. **반드시 이 매핑 테이블을 따를 것.**
|
||||
|
||||
---
|
||||
|
||||
## 2. 현재 존재하는 것
|
||||
|
||||
### 2.1 타입 정의 (이미 완성)
|
||||
|
||||
`frontend/types/invyone-component.ts` — FieldConfig, Component, DataPort, Template, 모든 Config 타입이 정의됨. **이 파일의 타입을 그대로 사용한다. 새로 만들지 말 것.**
|
||||
|
||||
### 2.2 기존 컴포넌트 (참고용, 직접 사용 금지)
|
||||
|
||||
기존 `frontend/lib/registry/components/`에 85개+ 컴포넌트가 있지만, 이것들은 VEX 규격(ColumnConfig 354줄 등)에 맞춰 만들어진 것이므로 **FieldConfig 기반 신규 컴포넌트를 만든다.** 기존 컴포넌트는 코드 패턴 참고용으로만 사용.
|
||||
|
||||
### 2.3 UI 라이브러리 (활용)
|
||||
|
||||
| 라이브러리 | 용도 |
|
||||
|---|---|
|
||||
| Radix UI | 기본 UI 프리미티브 (Dialog, Select, Checkbox 등) |
|
||||
| TanStack Table v8 | 테이블 렌더링 |
|
||||
| React Hook Form v7 | 폼 상태 관리 |
|
||||
| Zod v4 | 폼 유효성 검증 |
|
||||
| date-fns v4 | 날짜 포맷팅 |
|
||||
| Lucide React | 아이콘 |
|
||||
|
||||
### 2.4 디자인 시스템
|
||||
|
||||
v5 Cosmic Glassmorphism (`frontend/styles/v5-layout.css`, `frontend/app/globals.css`). 모든 컴포넌트는 v5 토큰을 따른다. 즉흥 hex/rgb 금지.
|
||||
|
||||
---
|
||||
|
||||
## 3. 파일 구조
|
||||
|
||||
```
|
||||
frontend/components/fc/ ← FieldConfig 기반 컴포넌트 (fc = FieldConfig)
|
||||
├── FcTable.tsx ← 데이터 테이블
|
||||
├── FcForm.tsx ← 입력 폼
|
||||
├── FcSearch.tsx ← 검색 필터
|
||||
├── FcButton.tsx ← 단일 버튼
|
||||
├── FcButtonBar.tsx ← 버튼 그룹
|
||||
├── FcPagination.tsx ← 페이지네이션
|
||||
├── fields/ ← FieldType별 렌더러
|
||||
│ ├── FieldRenderer.tsx ← FieldType→위젯 디스패처
|
||||
│ ├── TextField.tsx
|
||||
│ ├── NumberField.tsx
|
||||
│ ├── DateField.tsx
|
||||
│ ├── DateTimeField.tsx
|
||||
│ ├── SelectField.tsx
|
||||
│ ├── EntityField.tsx
|
||||
│ ├── CheckboxField.tsx
|
||||
│ ├── TextareaField.tsx
|
||||
│ ├── FileField.tsx
|
||||
│ └── CodeField.tsx
|
||||
├── table/ ← 테이블 전용 셀 렌더러
|
||||
│ └── CellRenderer.tsx ← FieldType→셀 포맷 디스패처
|
||||
└── index.ts ← 공개 exports
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 상세 설계
|
||||
|
||||
### 4.1 FieldRenderer — FieldType→위젯 디스패처
|
||||
|
||||
모든 폼/검색 입력 필드를 렌더링하는 허브 컴포넌트.
|
||||
|
||||
```typescript
|
||||
// frontend/components/fc/fields/FieldRenderer.tsx
|
||||
|
||||
interface FieldRendererProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
mode: 'form' | 'search'; // 폼인지 검색인지에 따라 위젯이 달라짐
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FieldConfig.type을 보고 적절한 입력 위젯을 렌더한다.
|
||||
*
|
||||
* mode='form' 이면 폼 렌더링 규칙 적용:
|
||||
* date → DatePicker(단일), select → Select(단일)
|
||||
*
|
||||
* mode='search' 이면 검색 렌더링 규칙 적용:
|
||||
* date → DateRangePicker(범위), select → MultiSelect(다중)
|
||||
*/
|
||||
function FieldRenderer({ field, value, onChange, mode, disabled, error }: FieldRendererProps) {
|
||||
// field.type에 따라 switch/case로 분기
|
||||
// 각 케이스에서 fields/ 폴더의 개별 컴포넌트를 렌더
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 렌더링 계약 (★ 반드시 이 테이블대로 구현)
|
||||
|
||||
| FieldType | FcTable (셀) | FcForm (입력) | FcSearch (검색) |
|
||||
|---|---|---|---|
|
||||
| `text` | 텍스트 그대로 | `<input type="text">` | `<input>` (부분 일치) |
|
||||
| `number` | format 적용 (#,##0) | `<input type="number">` | min~max 범위 입력 2개 |
|
||||
| `date` | format 적용 (YYYY-MM-DD) | DatePicker (단일) | **DateRangePicker (시작~종료)** |
|
||||
| `datetime` | format 적용 | DateTimePicker | DateTimeRangePicker |
|
||||
| `select` | 텍스트 그대로 | `<Select>` (단일) | **MultiSelect (다중, 체크박스)** |
|
||||
| `entity` | ref.displayColumn 값 표시 | 팝업 검색 버튼 + 입력 | 팝업 검색 (단일) |
|
||||
| `checkbox` | ✓/✗ 아이콘 | `<Checkbox>` | `<Select>` (전체/✓/✗) |
|
||||
| `textarea` | 말줄임 (...) 40자 | `<textarea>` rows=3 | `<input>` (부분 일치) |
|
||||
| `file` | 파일명 링크 | 파일 업로드 | — (검색 불가, 렌더 안 함) |
|
||||
| `code` | 텍스트 그대로 | readonly 표시 | `<input>` (완전 일치) |
|
||||
|
||||
### 4.3 FcTable — 데이터 테이블
|
||||
|
||||
```typescript
|
||||
// frontend/components/fc/FcTable.tsx
|
||||
|
||||
interface FcTableProps {
|
||||
fields: FieldConfig[]; // 컬럼 정의
|
||||
data: Record<string, any>[]; // 행 데이터
|
||||
config?: TableConfig; // 테이블 설정 (invyone-component.ts)
|
||||
loading?: boolean;
|
||||
|
||||
// ─── DataPort 출력 (콜백) ───
|
||||
onRowSelect?: (row: Record<string, any>) => void; // selectedRow(row)
|
||||
onRowsSelect?: (rows: Record<string, any>[]) => void; // selectedRows(rows)
|
||||
|
||||
// ─── DataPort 입력 ───
|
||||
searchParams?: Record<string, any>; // 검색 조건 (적용 시 필터링/API 재호출)
|
||||
}
|
||||
```
|
||||
|
||||
**렌더링 규칙:**
|
||||
1. `fields`에서 `visible: true`인 것만 컬럼으로 표시
|
||||
2. `order` 순으로 정렬
|
||||
3. `width` 적용 (없으면 타입별 기본값)
|
||||
4. `align` 적용
|
||||
5. `sortable: true`인 컬럼은 헤더 클릭 시 정렬
|
||||
6. 셀 값은 `CellRenderer`가 FieldType에 따라 포맷팅
|
||||
7. 행 클릭 시 `onRowSelect` 호출
|
||||
8. 체크박스 선택 시 `onRowsSelect` 호출
|
||||
|
||||
**데이터 조회:**
|
||||
- `config.autoLoad: true`이면 마운트 시 자동으로 `GET /api/data/{tableName}` 호출
|
||||
- `searchParams`가 변경되면 재조회
|
||||
- 페이지네이션은 FcPagination과 연동
|
||||
|
||||
### 4.4 FcForm — 입력 폼
|
||||
|
||||
```typescript
|
||||
// frontend/components/fc/FcForm.tsx
|
||||
|
||||
interface FcFormProps {
|
||||
fields: FieldConfig[]; // 필드 정의
|
||||
config?: FormConfig; // 폼 설정
|
||||
initialData?: Record<string, any>; // 수정 모드 시 초기값
|
||||
|
||||
// ─── DataPort 출력 ───
|
||||
onSubmit?: (data: Record<string, any>) => void; // formData(row)
|
||||
onSaved?: (data: Record<string, any>) => void; // savedRow(row)
|
||||
|
||||
// ─── DataPort 입력 ───
|
||||
loadRow?: Record<string, any>; // 외부에서 행 데이터 로드
|
||||
}
|
||||
```
|
||||
|
||||
**렌더링 규칙:**
|
||||
1. `fields`에서 `system: true`인 것은 숨김
|
||||
2. `visible: true`인 것만 표시
|
||||
3. `order` 순으로 배치
|
||||
4. `config.columns` (1/2/3)에 따라 그리드 레이아웃
|
||||
5. 각 필드는 `FieldRenderer(mode='form')`로 렌더
|
||||
6. `required: true`인 필드에 * 표시
|
||||
7. `editable: false`인 필드는 disabled
|
||||
8. `pk: true`이고 `type: 'code'`이면 자동채번 표시 (readonly)
|
||||
9. `config.sections`가 있으면 섹션별로 그룹핑
|
||||
|
||||
**유효성 검증:**
|
||||
- 제출 시 `required: true` + 값이 비어있으면 에러 표시
|
||||
- empty 판단: `value === null || value === undefined || value === ''` (0, false는 유효)
|
||||
|
||||
### 4.5 FcSearch — 검색 필터
|
||||
|
||||
```typescript
|
||||
// frontend/components/fc/FcSearch.tsx
|
||||
|
||||
interface FcSearchProps {
|
||||
fields: FieldConfig[]; // 전체 필드 (searchable: true인 것만 렌더)
|
||||
config?: SearchConfig;
|
||||
|
||||
// ─── DataPort 출력 ───
|
||||
onSearch?: (params: Record<string, any>) => void; // searchParams(params)
|
||||
}
|
||||
```
|
||||
|
||||
**렌더링 규칙:**
|
||||
1. `fields`에서 `searchable: true`인 것만 표시
|
||||
2. 각 필드는 `FieldRenderer(mode='search')`로 렌더
|
||||
3. `config.layout: 'inline'`이면 한 줄에 나열, `'stacked'`이면 세로
|
||||
4. "검색" 버튼 + "초기화" 버튼 (showResetButton)
|
||||
5. `config.autoSearch: true`이면 입력 시 300ms 디바운스 후 자동 검색
|
||||
6. 검색 버튼 클릭 또는 자동 검색 시 `onSearch(params)` 호출
|
||||
|
||||
**검색 파라미터 형식:**
|
||||
```typescript
|
||||
// date 범위: { order_date_from: '2026-01-01', order_date_to: '2026-12-31' }
|
||||
// select 다중: { status: ['확정', '완료'] }
|
||||
// text 부분 일치: { customer_name: '삼성' }
|
||||
// number 범위: { amount_min: 1000, amount_max: 9999 }
|
||||
```
|
||||
|
||||
### 4.6 FcButton / FcButtonBar
|
||||
|
||||
```typescript
|
||||
// frontend/components/fc/FcButton.tsx
|
||||
interface FcButtonProps {
|
||||
config: ButtonConfig;
|
||||
onClick?: () => void; // clicked(value)
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// frontend/components/fc/FcButtonBar.tsx
|
||||
interface FcButtonBarProps {
|
||||
config: ButtonBarConfig;
|
||||
onAction?: (actionType: ActionType) => void;
|
||||
}
|
||||
```
|
||||
|
||||
**ActionType 12종 (invyone-component.ts에 정의됨):**
|
||||
save, edit, delete, add, cancel, close, navigate, popup, search, reset, submit, approval
|
||||
|
||||
### 4.7 FcPagination
|
||||
|
||||
```typescript
|
||||
// frontend/components/fc/FcPagination.tsx
|
||||
interface FcPaginationProps {
|
||||
config?: PaginationConfig;
|
||||
total: number;
|
||||
page: number;
|
||||
onPageChange?: (params: { page: number; size: number }) => void; // pageChange(params)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.8 CellRenderer — 테이블 셀 포맷터
|
||||
|
||||
```typescript
|
||||
// frontend/components/fc/table/CellRenderer.tsx
|
||||
interface CellRendererProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* FieldType에 따라 셀 내용을 포맷팅한다:
|
||||
* - number: format 적용 (#,##0 → 1,234,567)
|
||||
* - date: format 적용 (YYYY-MM-DD)
|
||||
* - checkbox: ✓/✗ 아이콘
|
||||
* - entity: ref.displayColumn 값 표시 (별도 조회 필요 시 캐시)
|
||||
* - textarea: 40자 말줄임 (...)
|
||||
* - file: 파일명 링크
|
||||
* - 나머지: 텍스트 그대로
|
||||
*/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 데이터 연동 (CRUD API)
|
||||
|
||||
FcTable이 데이터를 조회하고, FcForm이 데이터를 저장할 때 사용하는 API.
|
||||
|
||||
기존 `DataController.java`가 범용 CRUD를 제공하지만, FieldConfig 기반으로 파라미터를 맞춰주는 래퍼가 필요.
|
||||
|
||||
**프론트엔드 API: `frontend/lib/api/fcData.ts`**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* ★ 별도 인터페이스 정의 안 함 — 백엔드가 Map<String, Object>이므로 프론트도 Record<string, any>
|
||||
* ★ 유일하게 타입이 있는 건 FieldConfig (invyone-component.ts에 규격 확정된 것)
|
||||
*/
|
||||
|
||||
/** FieldConfig 기반 목록 조회 */
|
||||
export async function fcList(params: Record<string, any>): Promise<Record<string, any>>;
|
||||
|
||||
/** FieldConfig 기반 단건 조회 */
|
||||
export async function fcGet(tableName: string, id: string): Promise<Record<string, any>>;
|
||||
|
||||
/** FieldConfig 기반 등록 */
|
||||
export async function fcInsert(tableName: string, data: Record<string, any>): Promise<any>;
|
||||
|
||||
/** FieldConfig 기반 수정 */
|
||||
export async function fcUpdate(tableName: string, id: string, data: Record<string, any>): Promise<any>;
|
||||
|
||||
/** FieldConfig 기반 삭제 (soft delete) */
|
||||
export async function fcDelete(tableName: string, ids: string[]): Promise<any>;
|
||||
```
|
||||
|
||||
**백엔드**: 기존 `DataController` 또는 `DataAdvancedController`의 엔드포인트를 활용. 필요하면 FieldConfig의 searchable/computed 등을 반영하는 동적 쿼리 빌더 추가.
|
||||
|
||||
---
|
||||
|
||||
## 6. 테스트 페이지
|
||||
|
||||
구현 확인용 테스트 페이지를 하나 만든다:
|
||||
|
||||
**`frontend/app/(main)/test-fc/page.tsx`**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* FieldConfig 컴포넌트 테스트 페이지
|
||||
*
|
||||
* 1. 드롭다운에서 테이블 선택
|
||||
* 2. getTableFields(tableName) 호출 → FieldConfig[] 수신
|
||||
* 3. FcSearch + FcTable + FcForm을 동시에 렌더
|
||||
* 4. FcSearch → searchParams → FcTable (검색 연동)
|
||||
* 5. FcTable 행 클릭 → selectedRow → FcForm (데이터 로드)
|
||||
* 6. FcForm 저장 → DB에 실제 저장 → FcTable 새로고침
|
||||
*/
|
||||
```
|
||||
|
||||
이 페이지가 동작하면 Phase 2 완료.
|
||||
|
||||
---
|
||||
|
||||
## 7. 스타일링 규칙
|
||||
|
||||
1. v5 CSS 토큰 사용 (`var(--v5-primary)`, `var(--glass)`, `var(--glow-sm)` 등)
|
||||
2. 컴팩트 폰트 (0.55~0.85rem)
|
||||
3. 글래스모피즘: `backdrop-filter:blur(20px) saturate(1.4)` + `var(--glass)` + `var(--glass-border)`
|
||||
4. 다크/라이트 모드 둘 다 지원
|
||||
5. CSS 파일: `frontend/styles/fc-components.css` (필요 시) 또는 Tailwind 클래스 활용
|
||||
6. 기존 `v5-layout.css`와 `globals.css`의 토큰 먼저 확인 후 재사용
|
||||
|
||||
---
|
||||
|
||||
## 8. 참고 파일
|
||||
|
||||
| 파일 | 용도 |
|
||||
|---|---|
|
||||
| `notes/gbpark/2026-04-10-phase1-implementation-log.md` | **Phase 1 구현 결과** — API 응답 형태, 병합 규칙, 실 DB 테스트 결과, 범위 밖 항목 |
|
||||
| `frontend/types/invyone-component.ts` | **모든 타입 정의 (진실의 원천)** |
|
||||
| `notes/gbpark/2026-04-08-invyone-component-spec.md` (Section 2.2) | **렌더링 계약 테이블** — 반드시 이대로 구현 |
|
||||
| `notes/gbpark/2026-04-09-invyone-architecture.md` (Section 4) | 렌더링 계약 요약 |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/js/08-admin-builder.js` | mockup의 프리뷰 렌더 함수 (pvTable, pvForm, pvSearch) — UI 감각 참고 |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/css/05-widgets.css` | HR 테이블 스타일 — 테이블 CSS 참고 |
|
||||
| `frontend/styles/v5-layout.css` | v5 디자인 토큰 |
|
||||
| `frontend/app/globals.css` | Tailwind/shadcn 토큰 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 완료 기준
|
||||
|
||||
1. **FcTable**: FieldConfig[]과 데이터를 받아 테이블을 렌더링한다. 정렬, 행 선택, 페이지네이션이 동작한다.
|
||||
2. **FcForm**: FieldConfig[]을 받아 입력 폼을 렌더링한다. 10종 FieldType 전부 동작한다. required 검증이 동작한다.
|
||||
3. **FcSearch**: FieldConfig[]에서 searchable 필드만 추출하여 검색 UI를 렌더한다. 검색 시 params 객체를 반환한다.
|
||||
4. **FcSearch → FcTable 연동**: 검색 실행 시 테이블이 필터링된 데이터를 표시한다.
|
||||
5. **FcTable → FcForm 연동**: 행 클릭 시 폼에 해당 행 데이터가 로드된다.
|
||||
6. **FcForm 저장**: 폼 제출 시 DB에 INSERT/UPDATE가 실행되고 테이블이 새로고침된다.
|
||||
7. **테스트 페이지** (`/test-fc`)에서 위 전체 흐름이 실제 DB 데이터로 동작한다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 다음 단계 연결
|
||||
|
||||
Phase 3 (개발자 빌더)는 이 컴포넌트들을:
|
||||
- 팔레트에서 선택하여 캔버스에 배치
|
||||
- 속성 패널에서 config(TableConfig, FormConfig 등) 조정
|
||||
- Template JSON으로 저장/로드
|
||||
|
||||
Phase 3은 FcTable/FcForm/FcSearch를 "배치하고 설정하는 UI"이고, Phase 2는 그 컴포넌트 자체다.
|
||||
@@ -0,0 +1,305 @@
|
||||
# Phase 2 구현 작업기록 — FieldConfig 기반 컴포넌트 (FcTable/FcForm/FcSearch)
|
||||
|
||||
> **작업일**: 2026-04-10
|
||||
> **설계서**: `notes/gbpark/2026-04-10-phase2-fieldconfig-components.md`
|
||||
> **상태**: 구현 완료 + TypeScript 타입체크 통과 (tsc --noEmit 에러 0개)
|
||||
|
||||
---
|
||||
|
||||
## 1. 생성된 파일 (17개)
|
||||
|
||||
### 1.1 파일 구조
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── components/fc/ ← FieldConfig 기반 컴포넌트 (신규)
|
||||
│ ├── index.ts ← 공개 exports
|
||||
│ ├── FcTable.tsx ← 데이터 테이블 (TanStack Table v8)
|
||||
│ ├── FcForm.tsx ← 입력 폼 (자체 상태 관리)
|
||||
│ ├── FcSearch.tsx ← 검색 필터
|
||||
│ ├── FcButton.tsx ← 단일 버튼 (confirm 팝업 포함)
|
||||
│ ├── FcButtonBar.tsx ← 버튼 그룹
|
||||
│ ├── FcPagination.tsx ← 페이지네이션
|
||||
│ ├── fields/ ← FieldType별 렌더러 (10종)
|
||||
│ │ ├── FieldRenderer.tsx ← FieldType→위젯 디스패처
|
||||
│ │ ├── TextField.tsx
|
||||
│ │ ├── NumberField.tsx
|
||||
│ │ ├── DateField.tsx
|
||||
│ │ ├── DateTimeField.tsx
|
||||
│ │ ├── SelectField.tsx
|
||||
│ │ ├── EntityField.tsx
|
||||
│ │ ├── CheckboxField.tsx
|
||||
│ │ ├── TextareaField.tsx
|
||||
│ │ ├── FileField.tsx
|
||||
│ │ └── CodeField.tsx
|
||||
│ └── table/ ← 테이블 전용
|
||||
│ └── CellRenderer.tsx ← 셀 포맷 디스패처
|
||||
├── lib/api/
|
||||
│ └── fcData.ts ← FieldConfig 기반 CRUD API 래퍼 (신규)
|
||||
└── app/(main)/test-fc/
|
||||
└── page.tsx ← 테스트 페이지 (신규)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 핵심 컴포넌트 상세
|
||||
|
||||
### 2.1 FieldRenderer — FieldType→위젯 디스패처
|
||||
|
||||
`frontend/components/fc/fields/FieldRenderer.tsx`
|
||||
|
||||
- `mode='form'` / `mode='search'` 분기로 같은 FieldConfig가 다른 위젯으로 렌더
|
||||
- switch/case로 10종 FieldType을 개별 컴포넌트에 위임
|
||||
- 모든 필드 공통 인터페이스: `{ field, value, onChange, mode, disabled?, error? }`
|
||||
|
||||
### 2.2 렌더링 계약 구현 (spec Section 2.2 전부 준수)
|
||||
|
||||
| FieldType | FcTable 셀 | FcForm 입력 | FcSearch 검색 |
|
||||
|---|---|---|---|
|
||||
| `text` | 텍스트 그대로 | `<Input type="text">` | `<Input>` (부분 일치) |
|
||||
| `number` | `#,##0` 포맷 (toLocaleString) | `<Input type="number">` | min~max 범위 2개 |
|
||||
| `date` | YYYY-MM-DD 포맷 | `<Input type="date">` | from~to 범위 2개 |
|
||||
| `datetime` | YYYY-MM-DD HH:mm 포맷 | `<Input type="datetime-local">` | from~to 범위 2개 |
|
||||
| `select` | 텍스트 그대로 | Radix `<Select>` 단일 | MultiSelect (Checkbox 드롭다운) |
|
||||
| `entity` | ref.displayColumn 그대로 | `<Input>` + 검색 버튼 | `<Input>` + 검색 버튼 |
|
||||
| `checkbox` | ✓(green) / ✗(muted) 아이콘 | Radix `<Checkbox>` | `<Select>` 전체/✓/✗ |
|
||||
| `textarea` | 40자 말줄임 (...) | `<textarea rows={3}>` | `<Input>` (부분 일치) |
|
||||
| `file` | 파일명 + FileText 아이콘 | 파일 업로드 버튼 | — (렌더 안 함) |
|
||||
| `code` | 텍스트 그대로 | readonly + Lock 아이콘 | `<Input>` (완전 일치) |
|
||||
|
||||
### 2.3 FcTable — 데이터 테이블
|
||||
|
||||
`frontend/components/fc/FcTable.tsx` (~190줄)
|
||||
|
||||
**기술 스택**: TanStack Table v8 (`@tanstack/react-table`)
|
||||
|
||||
**기능:**
|
||||
- `fields`에서 `visible: true`인 것만 컬럼으로 표시, `order` 순 정렬
|
||||
- `width`, `align` 적용 (없으면 타입별 기본값)
|
||||
- `sortable: true`인 컬럼은 헤더 클릭 시 정렬 토글 (asc→desc→none)
|
||||
- 행 클릭 시 `onRowSelect(row)` 호출 + 선택 행 하이라이트
|
||||
- `showCheckbox: true` + `selectionMode: 'multiple'`일 때 체크박스 전체선택/개별선택
|
||||
- 로딩 스피너, 빈 데이터 표시
|
||||
- `CellRenderer`가 FieldType에 따라 셀 포맷팅
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface FcTableProps {
|
||||
fields: FieldConfig[];
|
||||
data: Record<string, any>[];
|
||||
config?: Partial<TableConfig>;
|
||||
loading?: boolean;
|
||||
onRowSelect?: (row: Record<string, any>) => void;
|
||||
onRowsSelect?: (rows: Record<string, any>[]) => void;
|
||||
selectedRowIndex?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 FcForm — 입력 폼
|
||||
|
||||
`frontend/components/fc/FcForm.tsx` (~130줄)
|
||||
|
||||
**상태 관리**: `useState` 직접 사용 (React Hook Form 의존성 제거 — 폼이 단순하고 FieldConfig 동적 필드라 자체 관리가 적합)
|
||||
|
||||
**기능:**
|
||||
- `system: true` 필드 숨김, `visible: true`만 표시, `order` 순
|
||||
- `config.columns` (1/2/3)에 따라 CSS Grid 레이아웃
|
||||
- `config.sections`가 있으면 섹션별로 그룹핑 (라벨 + 구분선)
|
||||
- `required: true` 필드에 * 표시
|
||||
- `editable: false` 필드 disabled
|
||||
- `pk: true` + `type: 'code'`이면 readonly (자동채번)
|
||||
- `loadRow` 변경 시 폼 데이터 자동 갱신
|
||||
- 제출 시 `required` 검증 (null/undefined/'' 만 empty, 0/false는 유효)
|
||||
- 초기화 버튼 (loadRow/initialData로 복원)
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface FcFormProps {
|
||||
fields: FieldConfig[];
|
||||
config?: Partial<FormConfig>;
|
||||
initialData?: Record<string, any>;
|
||||
onSubmit?: (data: Record<string, any>) => void;
|
||||
loadRow?: Record<string, any>;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 FcSearch — 검색 필터
|
||||
|
||||
`frontend/components/fc/FcSearch.tsx` (~110줄)
|
||||
|
||||
**기능:**
|
||||
- `searchable: true` + `!system` 필드만 추출, `order` 순
|
||||
- `FieldRenderer(mode='search')`로 각 필드 렌더
|
||||
- `config.layout: 'inline'` (한 줄 나열) / `'stacked'` (세로)
|
||||
- `config.autoSearch: true`이면 300ms 디바운스 후 자동 `onSearch`
|
||||
- 검색/초기화 버튼
|
||||
- `buildSearchParams()` 변환 로직:
|
||||
|
||||
```
|
||||
date/datetime 범위 → { column_from: '2026-01-01', column_to: '2026-12-31' }
|
||||
number 범위 → { column_min: 1000, column_max: 9999 }
|
||||
select 다중 → { column: ['확정', '완료'] }
|
||||
text 부분 일치 → { column: '삼성' }
|
||||
```
|
||||
|
||||
### 2.6 CellRenderer — 테이블 셀 포맷터
|
||||
|
||||
`frontend/components/fc/table/CellRenderer.tsx` (~85줄)
|
||||
|
||||
| 타입 | 포맷 |
|
||||
|---|---|
|
||||
| number | `#,##0` → `toLocaleString('ko-KR')`, 소수점 포맷도 지원 |
|
||||
| date | `YYYY-MM-DD` (Date 파싱 후 수동 포맷) |
|
||||
| datetime | `YYYY-MM-DD HH:mm` |
|
||||
| checkbox | ✓ (green Check 아이콘) / ✗ (muted X 아이콘) |
|
||||
| textarea | 40자 초과 시 `...` 말줄임 + title 툴팁 |
|
||||
| file | FileText 아이콘 + 파일명 링크 스타일 |
|
||||
| null/undefined | `-` (muted) |
|
||||
| 기타 | `String(value)` |
|
||||
|
||||
---
|
||||
|
||||
## 3. 보조 컴포넌트
|
||||
|
||||
### 3.1 FcButton
|
||||
|
||||
- `ButtonConfig.variant` 5종 (primary/default/destructive/outline/ghost) → v5 토큰 매핑
|
||||
- `confirm` 속성 있으면 클릭 시 확인/취소 인라인 팝업
|
||||
|
||||
### 3.2 FcButtonBar
|
||||
|
||||
- `ButtonBarConfig.buttons[]` 순회하며 `FcButton` 렌더
|
||||
- `onAction(actionType)` 콜백으로 12종 ActionType 전달
|
||||
|
||||
### 3.3 FcPagination
|
||||
|
||||
- 총 건수, 페이지 크기 선택기 (10/20/50/100)
|
||||
- 5페이지 범위 번호 표시 + 처음/이전/다음/끝 버튼
|
||||
- `onPageChange({ page, size })` 콜백
|
||||
|
||||
---
|
||||
|
||||
## 4. API 래퍼
|
||||
|
||||
### 4.1 fcData.ts
|
||||
|
||||
`frontend/lib/api/fcData.ts` (~35줄)
|
||||
|
||||
기존 `dataApi` (frontend/lib/api/data.ts)를 감싸는 얇은 래퍼.
|
||||
|
||||
| 함수 | 용도 | 내부 호출 |
|
||||
|---|---|---|
|
||||
| `fcList(params)` | 목록 조회 (검색+페이징) | `dataApi.getTableData()` |
|
||||
| `fcGet(tableName, id)` | 단건 조회 | `dataApi.getRecordDetail()` |
|
||||
| `fcInsert(tableName, data)` | 등록 | `dataApi.createRecord()` |
|
||||
| `fcUpdate(tableName, id, data)` | 수정 | `dataApi.updateRecord()` |
|
||||
| `fcDelete(tableName, ids)` | 삭제 (복수) | `dataApi.deleteRecord()` × N |
|
||||
|
||||
★ 별도 인터페이스 정의 안 함 — 전부 `Record<string, any>`
|
||||
|
||||
---
|
||||
|
||||
## 5. 테스트 페이지
|
||||
|
||||
### 5.1 `/test-fc` (frontend/app/(main)/test-fc/page.tsx)
|
||||
|
||||
**레이아웃**: 좌(검색+테이블+페이지네이션) / 우(폼) 2컬럼
|
||||
|
||||
**흐름:**
|
||||
```
|
||||
1. 드롭다운에서 테이블 선택
|
||||
2. getMetaFields(tableName) → FieldConfig[] 수신
|
||||
3. FcSearch + FcTable + FcForm 동시 렌더
|
||||
4. FcSearch 검색 → searchParams → fcList() 재조회 → FcTable 갱신
|
||||
5. FcTable 행 클릭 → selectedRow → FcForm 데이터 로드
|
||||
6. FcForm 저장 → fcInsert/fcUpdate → 성공 메시지 + FcTable 새로고침
|
||||
```
|
||||
|
||||
**기능:**
|
||||
- 테이블 목록에 `has_custom_meta` 표시 (★)
|
||||
- 테이블 선택 시 필드 수, PK 표시
|
||||
- 수정/신규 모드 자동 전환 (selectedRow 유무)
|
||||
- 성공/실패 알림 (3초 자동 소멸)
|
||||
|
||||
---
|
||||
|
||||
## 6. v5 디자인 토큰 사용 목록
|
||||
|
||||
모든 컴포넌트에서 즉흥 hex/rgb 사용 안 함. v5-layout.css 토큰만 사용:
|
||||
|
||||
| 용도 | 토큰 |
|
||||
|---|---|
|
||||
| 배경 | `var(--v5-glass)`, `var(--v5-surface)`, `var(--v5-bg-subtle)` |
|
||||
| 테두리 | `var(--v5-glass-border)`, `var(--v5-border)`, `var(--v5-border-subtle)` |
|
||||
| 텍스트 | `var(--v5-text)`, `var(--v5-text-sec)`, `var(--v5-text-muted)` |
|
||||
| 강조 | `var(--v5-primary)`, `var(--v5-primary-glow)` |
|
||||
| 상태 | `var(--v5-green)` (✓), `var(--v5-red)` (* 필수, 에러) |
|
||||
| 호버 | `var(--v5-surface-hover)` |
|
||||
| 글로우 | `var(--v5-glow-sm)` |
|
||||
| 블러 | `backdrop-blur-[20px]` (v5 글래스 패턴) |
|
||||
|
||||
폰트 크기: 0.65rem(라벨) ~ 0.75rem(본문) — v5 컴팩트 스케일 준수
|
||||
|
||||
---
|
||||
|
||||
## 7. 덕일 스타일 / 프로젝트 컨벤션 준수
|
||||
|
||||
| 규칙 | 준수 |
|
||||
|---|---|
|
||||
| 프론트 타입: `Record<string, any>` (FieldConfig만 예외) | ✅ |
|
||||
| 별도 인터페이스 정의 금지 | ✅ — Props만 인라인 정의 |
|
||||
| FieldConfig/TableConfig 등은 invyone-component.ts 타입 사용 | ✅ |
|
||||
| v5 CSS 토큰 사용, 즉흥 값 금지 | ✅ |
|
||||
| shadcn UI 컴포넌트 재활용 (Input, Select, Checkbox) | ✅ |
|
||||
| 기존 dataApi 활용 (중복 API 안 만듦) | ✅ |
|
||||
| 컴팩트 폰트 사이즈 (0.55~0.85rem) | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 8. TypeScript 검증 결과
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit 2>&1 | grep -E "(components/fc/|test-fc/|lib/api/fcData)"
|
||||
# 출력 없음 — 에러 0개
|
||||
```
|
||||
|
||||
기존 코드(admin 페이지)의 타입 에러는 Phase 2와 무관.
|
||||
|
||||
---
|
||||
|
||||
## 9. Phase 2 범위 밖 (다음에 해야 할 것)
|
||||
|
||||
| 항목 | 담당 Phase | 비고 |
|
||||
|---|---|---|
|
||||
| entity 팝업 검색 (ref 테이블 조회) | Phase 3+ | 현재는 텍스트 입력 + 검색 버튼 UI만 |
|
||||
| category 공통코드 옵션 로딩 | Phase 2+ | CODE_CATEGORY → 공통코드 테이블 조회 |
|
||||
| 실제 파일 업로드 구현 | Phase 3+ | 현재는 파일 선택 UI만 |
|
||||
| computed 수식 파서 | Phase 5 | AST 기반 안전한 파서 필요 |
|
||||
| DataPort 이벤트 버스 | Phase 3 | 빌더에서 컴포넌트 간 연결 시 |
|
||||
| inlineEdit (테이블 인라인 편집) | Phase 3+ | TableConfig.inlineEdit는 정의만 |
|
||||
| 엑셀 내보내기 | Phase 3+ | toolbar.showExcel UI 미구현 |
|
||||
| select options의 value/label 분리 | Phase 2+ | 현재 label만 string[]으로 반환 |
|
||||
| defaultValue, placeholder 자동 세팅 | Phase 2+ | Phase 1에서 미구현 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 사용법 (다음 Phase에서 참고)
|
||||
|
||||
```tsx
|
||||
import { FcTable, FcForm, FcSearch, FcPagination } from '@/components/fc';
|
||||
import { getMetaFields } from '@/lib/api/meta';
|
||||
import { fcList } from '@/lib/api/fcData';
|
||||
|
||||
// 1. FieldConfig 가져오기
|
||||
const meta = await getMetaFields('sales_order_mng');
|
||||
const fields = meta.fields;
|
||||
|
||||
// 2. 데이터 조회
|
||||
const result = await fcList({ tableName: 'sales_order_mng', page: 1, size: 20 });
|
||||
|
||||
// 3. 컴포넌트 렌더
|
||||
<FcSearch fields={fields} onSearch={handleSearch} />
|
||||
<FcTable fields={fields} data={result.data} onRowSelect={handleSelect} />
|
||||
<FcForm fields={fields} loadRow={selectedRow} onSubmit={handleSave} />
|
||||
<FcPagination total={result.total} page={1} onPageChange={handlePage} />
|
||||
```
|
||||
@@ -0,0 +1,533 @@
|
||||
# Phase 3: 개발자 빌더 — 수동 템플릿 구성
|
||||
|
||||
> **목적**: 개발자가 테이블을 선택하고, FieldConfig 기반 컴포넌트를 배치하여 Template JSON을 만드는 빌더 UI 구현
|
||||
> **전제 조건**: Phase 1 (DB 메타 API) + Phase 2 (FcTable/FcForm/FcSearch 컴포넌트) 완료
|
||||
> **산출물**: 개발자 빌더 페이지 + Template CRUD API + Template JSON 저장/로드
|
||||
> **다음 단계**: Phase 4에서 사용자가 이 Template을 대시보드(=메뉴)에 카드로 배치
|
||||
|
||||
---
|
||||
|
||||
## 1. 핵심 흐름
|
||||
|
||||
```
|
||||
개발자 모드 진입 (톱니바퀴 버튼)
|
||||
→ 테이블 선택 (드롭다운)
|
||||
→ FieldConfig[] 자동 로드 (Phase 1 API)
|
||||
→ 팔레트에서 컴포넌트 선택 → 캔버스에 배치
|
||||
→ 속성 패널에서 설정 조정 (필드 ON/OFF, 컬럼 순서, config 등)
|
||||
→ 3뷰 탭 전환 (목록/등록/수정)
|
||||
→ 저장 → Template JSON이 DB에 저장됨
|
||||
```
|
||||
|
||||
**자동생성(⚡ 버튼)과 프리셋은 Phase 6에서 구현. 이 단계에서는 수동 구성만.**
|
||||
|
||||
---
|
||||
|
||||
## 2. 현재 존재하는 것
|
||||
|
||||
| 파일 | 상태 | 활용 |
|
||||
|---|---|---|
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/js/08-admin-builder.js` | **mockup 완성** | UI/UX 참조 (진실의 원천) |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/index.html` (admin-builder 섹션) | **mockup 완성** | HTML 구조 참조 |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/css/09-developer.css` | **mockup 완성** | 개발자 모드 스타일 참조 |
|
||||
| `frontend/types/invyone-component.ts` | **타입 완성** | Template, Component, ViewConfig 등 |
|
||||
| `frontend/components/screen/ScreenDesigner.tsx` | 7,986줄 (VEX식) | 패턴 참고용, 직접 사용 안 함 |
|
||||
| `backend-spring/.../TemplateStandardController.java` | 기존 VEX 템플릿 API | 확장 또는 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 빌더 UI 레이아웃
|
||||
|
||||
mockup의 `developer.html` + `08-admin-builder.js` 기반. 3패널 IDE 스타일.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 헤더: [INVYONE] [DEV] [테이블 선택 ▼] [뷰 탭: 목록|등록|수정] [저장] [미리보기] │
|
||||
├──────────┬────────────────────────────┬──────────────────┤
|
||||
│ 팔레트 │ 캔버스 │ 속성 패널 │
|
||||
│ (180px) │ (유동) │ (260px) │
|
||||
│ │ │ │
|
||||
│ ── 데이터 │ ┌──────────────────────┐ │ 컴포넌트 정보 │
|
||||
│ 📊 테이블 │ │ [검색 필터] │ │ ───────────── │
|
||||
│ 🔍 검색 │ │ │ │ 종류: 테이블 │
|
||||
│ 📝 폼 │ │ [데이터 테이블] │ │ 이름: 수주 목록 │
|
||||
│ │ │ │ │ │
|
||||
│ ── 액션 │ │ │ │ 위치·크기 │
|
||||
│ 🔘 버튼 │ └──────────────────────┘ │ X: 16 Y: 58 │
|
||||
│ │ ┌──────────────────────┐ │ W: 854 H: 380 │
|
||||
│ ── 표시 │ │ [페이지네이션] │ │ │
|
||||
│ 📌 제목 │ └──────────────────────┘ │ 표시할 컬럼 │
|
||||
│ ── 구분 │ │ ☑ 수주번호 │
|
||||
│ │ ☑ 수주일 │
|
||||
│ 빈 슬롯 │ │ ☑ 거래처 │
|
||||
│ │ │ ☐ 비고 │
|
||||
├──────────┴────────────────────────────┴──────────────────┤
|
||||
│ 상태바: 블록 5개 · order_management_test · 수정됨 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.1 팔레트 (좌측 180px)
|
||||
|
||||
**8종 컴포넌트** (아키텍처 문서 Section 3.2 확정):
|
||||
|
||||
| 아이콘 | 이름 | ComponentType | 드래그→캔버스 |
|
||||
|---|---|---|---|
|
||||
| 📊 | 데이터 테이블 | `table` | FcTable 배치 |
|
||||
| 🔍 | 검색 필터 | `search` | FcSearch 배치 |
|
||||
| 📝 | 입력 폼 | `form` | FcForm 배치 |
|
||||
| 🔘 | 버튼 | `button` | FcButton 배치 |
|
||||
| 📈 | 통계 카드 | `stats` | 통계 카드 배치 |
|
||||
| 📌 | 제목/텍스트 | `title` | 텍스트 배치 |
|
||||
| ── | 구분선 | `divider` | 구분선 배치 |
|
||||
| 📊 | 차트 | `chart` | (Phase 2+에서 확장) |
|
||||
|
||||
드래그앤드롭으로 캔버스에 추가. 팔레트 아이템은 mockup의 `dev-pal-item` 스타일 참고.
|
||||
|
||||
### 3.2 캔버스 (중앙)
|
||||
|
||||
- 절대 좌표 배치 (position: absolute)
|
||||
- 블록(컴포넌트)은 점선 보더 + 라벨
|
||||
- 블록 클릭 → 선택 (파란 보더)
|
||||
- 블록 드래그 → 이동 (Shift = 8px 스냅)
|
||||
- 블록 우하단 핸들 → 리사이즈
|
||||
- 블록 안에 Phase 2의 FcTable/FcForm/FcSearch가 프리뷰로 렌더됨
|
||||
|
||||
### 3.3 속성 패널 (우측 260px)
|
||||
|
||||
블록 선택 시 해당 컴포넌트의 설정을 편집:
|
||||
|
||||
**공통 속성:**
|
||||
- 컴포넌트 종류 (readonly)
|
||||
- 이름 (label)
|
||||
- 위치: X, Y, W, H (숫자 입력, 캔버스와 양방향 동기화)
|
||||
|
||||
**타입별 속성 (invyone-component.ts의 Config 타입):**
|
||||
|
||||
| ComponentType | 설정 항목 |
|
||||
|---|---|
|
||||
| `table` | 표시할 컬럼 체크리스트, 페이지 크기, 선택 모드, 인라인 편집, 자동 로드, 정렬 |
|
||||
| `form` | 표시할 필드 체크리스트, 컬럼 수(1/2/3), 섹션 구분, 저장 방식(INSERT/UPDATE/UPSERT) |
|
||||
| `search` | 검색 대상 필드 체크리스트, 날짜 범위, 초기화 버튼, 자동 검색 |
|
||||
| `button` | 버튼 텍스트, 액션 종류(12종), 스타일(5종), 확인 메시지 |
|
||||
| `title` | 텍스트, 글꼴 크기, 글꼴 두께, 정렬 |
|
||||
| `stats` | 통계 항목 목록 (라벨, 컬럼, 집계 방식) |
|
||||
| `divider` | 선 스타일 (solid/dashed/dotted) |
|
||||
|
||||
**필드 체크리스트** (table/form/search 공통):
|
||||
- 현재 선택된 테이블의 FieldConfig[] 표시
|
||||
- 체크박스로 visible ON/OFF
|
||||
- 드래그로 순서 변경
|
||||
- 클릭하면 필드 상세 설정 열림 (label, width, required 등)
|
||||
|
||||
### 3.4 3뷰 탭
|
||||
|
||||
헤더에 `[목록 | 등록 | 수정]` 탭:
|
||||
|
||||
- **목록 (list)**: 검색 + 테이블 + 버튼바 + 페이지네이션
|
||||
- **등록 (create)**: 팝업 오버레이 안에 폼 + 버튼
|
||||
- **수정 (edit)**: 등록과 비슷, `extends: 'create'` 가능
|
||||
|
||||
목록은 캔버스 직접 배치, 등록/수정은 팝업 프레임 안에서 배치.
|
||||
|
||||
mockup 참조: `08-admin-builder.js`의 `abSwitchView()`, `.ab-popup-overlay` 구조.
|
||||
|
||||
---
|
||||
|
||||
## 4. Template JSON 구조
|
||||
|
||||
`frontend/types/invyone-component.ts`의 `Template` 인터페이스 그대로:
|
||||
|
||||
```typescript
|
||||
interface Template {
|
||||
templateId: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description?: string;
|
||||
primaryTable: string;
|
||||
fields: FieldConfig[]; // 모든 뷰가 공유하는 유일한 필드 정의
|
||||
views: {
|
||||
list: ViewConfig; // { components: Component[] }
|
||||
create: ViewConfig;
|
||||
edit: ViewConfig; // extends: 'create' 가능
|
||||
};
|
||||
connections: Connection[]; // DataPort 연결 (Phase 2의 DataPort)
|
||||
companyCode: string;
|
||||
version: number;
|
||||
status: 'draft' | 'published';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 백엔드 API 설계
|
||||
|
||||
### 5.1 Template CRUD
|
||||
|
||||
**`GET /api/templates`** — 템플릿 목록
|
||||
```
|
||||
Response: { data: [{ templateId, name, category, primaryTable, status, updatedAt }] }
|
||||
```
|
||||
|
||||
**`GET /api/templates/{templateId}`** — 템플릿 상세 (Template JSON 전체)
|
||||
```
|
||||
Response: { data: Template }
|
||||
```
|
||||
|
||||
**`POST /api/templates`** — 템플릿 생성
|
||||
```
|
||||
Request: Template (templateId 제외, 서버에서 생성)
|
||||
Response: { data: { templateId: "tpl_xxx" } }
|
||||
```
|
||||
|
||||
**`PUT /api/templates/{templateId}`** — 템플릿 수정
|
||||
```
|
||||
Request: Template (전체)
|
||||
Response: { data: Template }
|
||||
```
|
||||
|
||||
**`DELETE /api/templates/{templateId}`** — 템플릿 삭제
|
||||
|
||||
**`PUT /api/templates/{templateId}/publish`** — 템플릿 게시 (draft → published)
|
||||
|
||||
### 5.2 DB 테이블
|
||||
|
||||
**`templates` 테이블 (신규 생성):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE templates (
|
||||
template_id VARCHAR(50) PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
category VARCHAR(50),
|
||||
description TEXT,
|
||||
primary_table VARCHAR(100) NOT NULL,
|
||||
fields JSONB NOT NULL, -- FieldConfig[] JSON
|
||||
views JSONB NOT NULL, -- { list, create, edit } JSON
|
||||
connections JSONB DEFAULT '[]', -- Connection[] JSON
|
||||
company_code VARCHAR(20) NOT NULL DEFAULT '*',
|
||||
version INTEGER DEFAULT 1,
|
||||
status VARCHAR(20) DEFAULT 'draft', -- draft | published
|
||||
created_by VARCHAR(50),
|
||||
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by VARCHAR(50),
|
||||
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active VARCHAR(1) DEFAULT 'Y'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_templates_company ON templates(company_code);
|
||||
CREATE INDEX idx_templates_table ON templates(primary_table);
|
||||
CREATE INDEX idx_templates_status ON templates(status);
|
||||
```
|
||||
|
||||
### 5.3 Java 구현
|
||||
|
||||
**★ 패턴: 전부 BaseService + Map<String, Object>. 엔티티 클래스/DTO 없음.**
|
||||
|
||||
**신규 파일:**
|
||||
|
||||
| 파일 | 역할 |
|
||||
|---|---|
|
||||
| `TemplateController.java` | `/api/templates/*` 엔드포인트 |
|
||||
| `TemplateService.java` | Template CRUD + 버전 관리 |
|
||||
| `TemplateMapper.xml` | MyBatis SQL (JSONB 읽기/쓰기) |
|
||||
|
||||
**주의:** 기존 `TemplateStandardController`는 VEX용이므로 별도로 신규 생성. 이름 충돌 방지.
|
||||
|
||||
**TemplateService.java 구조:**
|
||||
|
||||
```java
|
||||
@Service
|
||||
@Slf4j
|
||||
public class TemplateService extends BaseService {
|
||||
|
||||
@Autowired
|
||||
private CommonService commonService;
|
||||
|
||||
public Map<String, Object> getTemplateList(Map<String, Object> params) {
|
||||
commonService.applyPagination(params);
|
||||
int totalCount = sqlSession.selectOne("template.getTemplateListCnt", params);
|
||||
List<Map<String, Object>> list = sqlSession.selectList("template.getTemplateList", params);
|
||||
return commonService.buildListResponse(list, totalCount, params);
|
||||
}
|
||||
|
||||
public Map<String, Object> getTemplateInfo(Map<String, Object> params) {
|
||||
return sqlSession.selectOne("template.getTemplateInfo", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertTemplate(Map<String, Object> params) {
|
||||
// template_id 자동 생성 (UUID)
|
||||
// fields, views, connections → ObjectMapper로 JSON 문자열 변환 후 #{fields}::jsonb
|
||||
sqlSession.insert("template.insertTemplate", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateTemplate(Map<String, Object> params) {
|
||||
sqlSession.update("template.updateTemplate", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int deleteTemplate(Map<String, Object> params) {
|
||||
return sqlSession.update("template.deleteTemplate", params);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**★ 파일명: `template.xml`, namespace: `template` (덕일 스타일)**
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="template">
|
||||
|
||||
<select id="getTemplateList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
TEMPLATE_ID
|
||||
, NAME
|
||||
, CATEGORY
|
||||
, PRIMARY_TABLE
|
||||
, STATUS
|
||||
, UPDATED_DATE
|
||||
FROM TEMPLATES
|
||||
WHERE 1=1
|
||||
AND IS_ACTIVE != 'D'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="common.dynamicOrderBy"/>
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getTemplateListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM TEMPLATES
|
||||
WHERE 1=1
|
||||
AND IS_ACTIVE != 'D'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<select id="getTemplateInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
TEMPLATE_ID
|
||||
, NAME
|
||||
, CATEGORY
|
||||
, DESCRIPTION
|
||||
, PRIMARY_TABLE
|
||||
, FIELDS
|
||||
, VIEWS
|
||||
, CONNECTIONS
|
||||
, COMPANY_CODE
|
||||
, VERSION
|
||||
, STATUS
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_BY
|
||||
, UPDATED_DATE
|
||||
FROM TEMPLATES
|
||||
WHERE TEMPLATE_ID = #{template_id}
|
||||
AND IS_ACTIVE != 'D'
|
||||
</select>
|
||||
|
||||
<insert id="insertTemplate" parameterType="map">
|
||||
INSERT INTO TEMPLATES (
|
||||
TEMPLATE_ID
|
||||
, NAME
|
||||
, CATEGORY
|
||||
, DESCRIPTION
|
||||
, PRIMARY_TABLE
|
||||
, FIELDS
|
||||
, VIEWS
|
||||
, CONNECTIONS
|
||||
, COMPANY_CODE
|
||||
, VERSION
|
||||
, STATUS
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
) VALUES (
|
||||
#{template_id}
|
||||
, #{name}
|
||||
, #{category}
|
||||
, #{description}
|
||||
, #{primary_table}
|
||||
, #{fields}::jsonb
|
||||
, #{views}::jsonb
|
||||
, #{connections}::jsonb
|
||||
, #{company_code}
|
||||
, 1
|
||||
, 'draft'
|
||||
, #{user_id}
|
||||
, NOW()
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateTemplate" parameterType="map">
|
||||
UPDATE TEMPLATES
|
||||
SET
|
||||
NAME = #{name}
|
||||
, CATEGORY = #{category}
|
||||
, DESCRIPTION = #{description}
|
||||
, FIELDS = #{fields}::jsonb
|
||||
, VIEWS = #{views}::jsonb
|
||||
, CONNECTIONS = #{connections}::jsonb
|
||||
, VERSION = VERSION + 1
|
||||
, UPDATED_BY = #{user_id}
|
||||
, UPDATED_DATE = NOW()
|
||||
WHERE TEMPLATE_ID = #{template_id}
|
||||
</update>
|
||||
|
||||
<update id="deleteTemplate" parameterType="map">
|
||||
UPDATE TEMPLATES
|
||||
SET IS_ACTIVE = 'D'
|
||||
, UPDATED_BY = #{user_id}
|
||||
, UPDATED_DATE = NOW()
|
||||
WHERE TEMPLATE_ID = #{template_id}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 프론트엔드 구현
|
||||
|
||||
### 6.1 파일 구조
|
||||
|
||||
```
|
||||
frontend/app/(main)/admin/builder/
|
||||
└── page.tsx ← 빌더 메인 페이지
|
||||
|
||||
frontend/components/builder/
|
||||
├── BuilderLayout.tsx ← 3패널 레이아웃
|
||||
├── BuilderPalette.tsx ← 좌측 팔레트 (8종 컴포넌트)
|
||||
├── BuilderCanvas.tsx ← 중앙 캔버스 (블록 배치/드래그/리사이즈)
|
||||
├── BuilderProps.tsx ← 우측 속성 패널
|
||||
├── BuilderToolbar.tsx ← 상단 툴바 (테이블 선택, 뷰 탭, 저장/미리보기)
|
||||
├── BuilderBlock.tsx ← 캔버스 위의 개별 블록
|
||||
├── BuilderViewTabs.tsx ← 3뷰 탭 (목록/등록/수정)
|
||||
├── BuilderPopupFrame.tsx ← 등록/수정 팝업 편집 프레임
|
||||
├── props/ ← 타입별 속성 패널
|
||||
│ ├── TableProps.tsx
|
||||
│ ├── FormProps.tsx
|
||||
│ ├── SearchProps.tsx
|
||||
│ ├── ButtonProps.tsx
|
||||
│ ├── TitleProps.tsx
|
||||
│ ├── StatsProps.tsx
|
||||
│ └── FieldListEditor.tsx ← 필드 체크리스트 (공통)
|
||||
└── hooks/
|
||||
├── useBuilderState.ts ← 빌더 상태 관리 (Zustand)
|
||||
└── useBlockDrag.ts ← 블록 드래그/리사이즈 로직
|
||||
|
||||
frontend/lib/api/template.ts ← Template CRUD API 클라이언트
|
||||
```
|
||||
|
||||
### 6.2 빌더 상태 (Zustand)
|
||||
|
||||
```typescript
|
||||
interface BuilderState {
|
||||
// 테이블/필드
|
||||
tableName: string | null;
|
||||
fields: FieldConfig[];
|
||||
|
||||
// 현재 뷰
|
||||
currentView: 'list' | 'create' | 'edit';
|
||||
|
||||
// 블록 목록 (뷰별)
|
||||
blocks: {
|
||||
list: Component[];
|
||||
create: Component[];
|
||||
edit: Component[];
|
||||
};
|
||||
|
||||
// 선택된 블록
|
||||
selectedBlockId: string | null;
|
||||
|
||||
// 템플릿 메타
|
||||
templateId: string | null;
|
||||
templateName: string;
|
||||
category: string;
|
||||
|
||||
// 변경 상태
|
||||
isDirty: boolean;
|
||||
|
||||
// 액션
|
||||
setTable: (tableName: string, fields: FieldConfig[]) => void;
|
||||
switchView: (view: 'list' | 'create' | 'edit') => void;
|
||||
addBlock: (type: ComponentType, position: Position) => void;
|
||||
removeBlock: (id: string) => void;
|
||||
updateBlock: (id: string, updates: Partial<Component>) => void;
|
||||
selectBlock: (id: string | null) => void;
|
||||
moveBlock: (id: string, x: number, y: number) => void;
|
||||
resizeBlock: (id: string, w: number, h: number) => void;
|
||||
toTemplate: () => Template; // 현재 상태 → Template JSON
|
||||
fromTemplate: (tpl: Template) => void; // Template JSON → 상태 로드
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 스타일
|
||||
|
||||
개발자 모드는 **코스믹 글래스모피즘이 아닌 IDE 스타일** (mockup의 `09-developer.css` 참조):
|
||||
- 중성 다크 그레이 (#121218, #1a1a22)
|
||||
- 라이트 모드도 지원 (#f5f5f8, #ededf2)
|
||||
- 글로우/블러 없음, 깔끔한 보더
|
||||
- 액센트: 블루 (#5b9ef5)
|
||||
- 폰트: 0.42~0.62rem (컴팩트)
|
||||
|
||||
---
|
||||
|
||||
## 8. 참고 파일
|
||||
|
||||
| 파일 | 용도 |
|
||||
|---|---|
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/js/08-admin-builder.js` | **빌더 UX 진실의 원천** — 드래그, 리사이즈, 속성 패널, 3뷰 전부 구현됨 |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/css/09-developer.css` | **개발자 모드 스타일** |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/developer.html` | 개발자 모드 HTML 구조 |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/builder-v2.html` | 빌더 v2 standalone |
|
||||
| `frontend/types/invyone-component.ts` | Template, Component, Config 타입 |
|
||||
| `notes/gbpark/2026-04-09-invyone-architecture.md` | 팔레트 8종, DataPort, 3뷰 구조 |
|
||||
|
||||
---
|
||||
|
||||
## 9. Template fields 동기화 규칙 (★ 메타 드리프트 방지)
|
||||
|
||||
Template.fields는 저장 시점의 FieldConfig[] 스냅샷이다. DB 스키마(table_type_columns)가 나중에 바뀌면 드리프트가 발생한다.
|
||||
|
||||
**동기화 정책:**
|
||||
|
||||
1. **빌더에서 Template 열 때마다** Phase 1 API(`GET /api/meta/tables/{tableName}/fields`)를 호출하여 최신 메타와 비교
|
||||
2. **비교 결과 표시**:
|
||||
- 새 컬럼 추가됨 → 빌더 상단에 "N개 컬럼 추가됨" 알림 + 필드 목록에 `(신규)` 표시
|
||||
- 기존 컬럼 삭제됨 → 해당 필드에 `(DB에서 삭제됨)` 경고 표시
|
||||
- 타입/옵션 변경됨 → 해당 필드에 `(변경됨)` 표시
|
||||
3. **동기화 버튼** ("메타 동기화") 클릭 시:
|
||||
- 새 컬럼 → fields에 추가 (visible=false 기본)
|
||||
- 삭제된 컬럼 → fields에서 제거 (연관 사용자 오버라이드도 자동 무시됨)
|
||||
- 변경된 타입 → fields 업데이트
|
||||
4. **자동 동기화는 안 함** — 개발자가 명시적으로 "동기화" 눌러야 적용 (의도치 않은 변경 방지)
|
||||
5. **사용자 화면(Phase 4)에서는** Template.fields 스냅샷을 그대로 사용 (라이브 메타 조회 안 함 — 성능 + 안정성)
|
||||
|
||||
---
|
||||
|
||||
## 10. 완료 기준
|
||||
|
||||
1. `/admin/builder` 페이지에서 3패널 빌더가 동작한다
|
||||
2. 드롭다운에서 테이블 선택 → FieldConfig[] 로드 → 팔레트 활성화
|
||||
3. 팔레트에서 컴포넌트를 캔버스에 드래그앤드롭으로 추가할 수 있다
|
||||
4. 캔버스에서 블록을 드래그(이동)/리사이즈할 수 있다
|
||||
5. 블록 클릭 → 속성 패널에 해당 Config가 표시되고, 변경 시 캔버스에 반영된다
|
||||
6. 필드 체크리스트에서 ON/OFF → 프리뷰에 실시간 반영
|
||||
7. 3뷰 탭 (목록/등록/수정) 전환이 동작한다
|
||||
8. 저장 → Template JSON이 DB에 저장된다
|
||||
9. 기존 Template 로드 → 빌더에 상태 복원
|
||||
10. 실제 테이블 (예: order_management_test)로 템플릿을 만들고 저장/로드 확인
|
||||
|
||||
---
|
||||
|
||||
## 10. 다음 단계 연결
|
||||
|
||||
Phase 4 (대시보드=메뉴)에서는:
|
||||
- 사용자가 대시보드를 만들면 → 사이드바 메뉴에 등록됨
|
||||
- 대시보드에 Template을 카드로 배치 → 카드 안에서 Phase 2의 FcTable/FcForm/FcSearch가 렌더됨
|
||||
- 개발자가 만든 Template이 "사용 가능한 템플릿 라이브러리"에 나타남
|
||||
@@ -0,0 +1,480 @@
|
||||
# Phase 3 구현 작업기록 — 개발자 빌더 (수동 템플릿 구성)
|
||||
|
||||
> **작업일**: 2026-04-10
|
||||
> **설계서**: `notes/gbpark/2026-04-10-phase3-developer-builder.md`
|
||||
> **상태**: 구현 완료 + TypeScript 타입체크 통과 + DB 테이블 생성 완료
|
||||
|
||||
---
|
||||
|
||||
## 1. 생성된 파일 (18개)
|
||||
|
||||
### 1.1 파일 구조
|
||||
|
||||
```
|
||||
backend-spring/
|
||||
├── src/main/java/com/erp/
|
||||
│ ├── controller/TemplateController.java ← CRUD + publish 엔드포인트 (신규)
|
||||
│ └── service/TemplateService.java ← Template CRUD 비즈니스 로직 (신규)
|
||||
└── src/main/resources/mapper/
|
||||
└── template.xml ← MyBatis SQL (신규)
|
||||
|
||||
frontend/
|
||||
├── lib/api/
|
||||
│ └── template.ts ← Template CRUD API 클라이언트 (신규)
|
||||
├── styles/
|
||||
│ └── developer.css ← IDE 스타일 개발자 테마 (신규)
|
||||
├── components/builder/ ← 빌더 컴포넌트 디렉토리 (전체 신규)
|
||||
│ ├── BuilderLayout.tsx ← 3패널 셸 + 키보드 단축키 + 상태바
|
||||
│ ├── BuilderToolbar.tsx ← 헤더 + 도구모음 (테이블 선택, 뷰 탭, 저장)
|
||||
│ ├── BuilderPalette.tsx ← 좌측 팔레트 (8종 컴포넌트)
|
||||
│ ├── BuilderCanvas.tsx ← 중앙 캔버스 (드롭 영역, 팝업 뷰)
|
||||
│ ├── BuilderBlock.tsx ← 개별 블록 (드래그/리사이즈 + 타입별 프리뷰)
|
||||
│ ├── BuilderProps.tsx ← 우측 속성 패널 (공통 + 타입별 분기)
|
||||
│ ├── hooks/
|
||||
│ │ ├── useBuilderState.ts ← Zustand 빌더 상태관리
|
||||
│ │ └── useBlockDrag.ts ← 블록 드래그/리사이즈 훅
|
||||
│ └── props/
|
||||
│ ├── FieldListEditor.tsx ← 필드 체크리스트 (공통)
|
||||
│ ├── TableProps.tsx ← 테이블 속성 패널
|
||||
│ ├── FormProps.tsx ← 폼 속성 패널
|
||||
│ ├── SearchProps.tsx ← 검색 속성 패널
|
||||
│ ├── ButtonProps.tsx ← 버튼/버튼바 속성 패널
|
||||
│ └── TitleProps.tsx ← 제목 속성 패널
|
||||
└── app/(main)/admin/builder/
|
||||
└── page.tsx ← 빌더 페이지 진입점
|
||||
```
|
||||
|
||||
### 1.2 수정된 파일 (2개)
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
|---|---|
|
||||
| `backend-spring/src/main/resources/application.yml` | DB 접속 정보 변경: `39.117.244.52:11132/testvex` → `211.115.91.141:11134/test_dev` |
|
||||
| `CLAUDE.local.md` | DB 접속 정보 업데이트 기록 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 백엔드 상세
|
||||
|
||||
### 2.1 TemplateController.java (~100줄)
|
||||
|
||||
`/api/templates` REST 컨트롤러. 6개 엔드포인트:
|
||||
|
||||
| 메서드 | 엔드포인트 | 설명 |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/templates` | 템플릿 목록 (keyword, status, category 필터 + 페이지네이션) |
|
||||
| `GET` | `/api/templates/{templateId}` | 템플릿 상세 (JSONB 파싱된 Template 전체) |
|
||||
| `POST` | `/api/templates` | 템플릿 생성 (template_id 자동 생성: `tpl_` + UUID 12자) |
|
||||
| `PUT` | `/api/templates/{templateId}` | 템플릿 수정 (version 자동 증가) |
|
||||
| `PUT` | `/api/templates/{templateId}/publish` | 템플릿 게시 (draft → published) |
|
||||
| `DELETE` | `/api/templates/{templateId}` | 템플릿 삭제 (소프트: IS_ACTIVE='D') |
|
||||
|
||||
**패턴**: MetaController와 동일 — `@RequestAttribute("company_code")`, `@RequestAttribute("user_id")`, `ApiResponse.success/error`
|
||||
|
||||
### 2.2 TemplateService.java (~120줄)
|
||||
|
||||
덕일 스타일 준수 — `extends BaseService`, `@Autowired CommonService`, `sqlSession` 직접 호출.
|
||||
|
||||
| 메서드 | 역할 |
|
||||
|---|---|
|
||||
| `getTemplateList(params)` | CommonService.applyPagination → selectList + selectOne(Cnt) → buildListResponse |
|
||||
| `getTemplateInfo(params)` | selectOne + JSONB 파싱 (fields, views, connections) |
|
||||
| `insertTemplate(params)` | UUID 생성 + JSON 직렬화 + insert |
|
||||
| `updateTemplate(params)` | JSON 직렬화 + update (VERSION + 1) |
|
||||
| `publishTemplate(params)` | status='published' 업데이트 |
|
||||
| `deleteTemplate(params)` | IS_ACTIVE='D' 소프트 삭제 |
|
||||
|
||||
**JSONB 처리 유틸 (private):**
|
||||
- `parseJsonField(row, key)` — PostgreSQL JSONB 문자열 → Java Object (ObjectMapper)
|
||||
- `stringifyJsonField(params, key)` — Java Object → JSON 문자열 (INSERT/UPDATE 전)
|
||||
|
||||
### 2.3 template.xml (~120줄)
|
||||
|
||||
MyBatis 매퍼. namespace=`template`, 쿼리 6개:
|
||||
|
||||
| 쿼리 ID | 용도 |
|
||||
|---|---|
|
||||
| `getTemplateList` | 목록 (keyword LIKE, status/category 필터, companyCodeFilter, dynamicOrderBy, pagination) |
|
||||
| `getTemplateListCnt` | 목록 카운트 |
|
||||
| `getTemplateInfo` | 단건 (FIELDS, VIEWS, CONNECTIONS JSONB 포함) |
|
||||
| `insertTemplate` | 등록 (`#{fields}::jsonb` 캐스팅) |
|
||||
| `updateTemplate` | 수정 (VERSION + 1) |
|
||||
| `deleteTemplate` | 소프트 삭제 |
|
||||
| `publishTemplate` | 게시 상태 변경 |
|
||||
|
||||
**common include 사용**: `companyCodeFilter`, `dynamicOrderBy`, `pagination`
|
||||
**OGNL test**: 바깥 작은따옴표 규칙 준수 (`test='keyword != null and keyword != ""'`)
|
||||
|
||||
---
|
||||
|
||||
## 3. 프론트엔드 상세
|
||||
|
||||
### 3.1 template.ts — API 클라이언트 (~40줄)
|
||||
|
||||
| 함수 | 반환 | 설명 |
|
||||
|---|---|---|
|
||||
| `getTemplateList(params?)` | `Record<string, any>` | 목록 (list + total_count) |
|
||||
| `getTemplateInfo(templateId)` | `Record<string, any> \| null` | 상세 (Template JSON 전체) |
|
||||
| `insertTemplate(data)` | `Record<string, any>` | 생성 → `{ template_id }` 반환 |
|
||||
| `updateTemplate(templateId, data)` | `void` | 수정 |
|
||||
| `publishTemplate(templateId)` | `void` | 게시 |
|
||||
| `deleteTemplate(templateId)` | `void` | 삭제 |
|
||||
|
||||
★ 별도 인터페이스 정의 안 함 — 전부 `Record<string, any>` (덕일 스타일)
|
||||
|
||||
### 3.2 developer.css — IDE 스타일 테마 (~350줄)
|
||||
|
||||
mockup `09-developer.css` 기반 React 포팅. v5 코스믹이 아닌 IDE/Figma 스타일.
|
||||
|
||||
**색상 체계 (CSS 변수 `--d-*`):**
|
||||
|
||||
| 변수 | 다크 | 라이트 | 용도 |
|
||||
|---|---|---|---|
|
||||
| `--d-bg` | #121218 | #f5f5f8 | 기본 배경 |
|
||||
| `--d-bg2` | #1a1a22 | #ededf2 | 패널 배경 |
|
||||
| `--d-bg3` | #22222c | #e4e4ec | 입력 배경 |
|
||||
| `--d-surface` | #2a2a36 | #fff | 호버 배경 |
|
||||
| `--d-border` | #3a3a48 | #d8d8e2 | 기본 보더 |
|
||||
| `--d-text` | #e8e8ee | #1a1a24 | 기본 텍스트 |
|
||||
| `--d-accent` | #5b9ef5 | #3b7dd8 | 액센트 (블루) |
|
||||
| `--d-red` | #f87171 | #dc2626 | 위험/필수 |
|
||||
| `--d-green` | #4ade80 | #16a34a | 성공/검색 |
|
||||
|
||||
**주요 클래스:**
|
||||
- `.dev-shell` — 전체 셸 (flex column, 100vh)
|
||||
- `.dev-hdr` — 헤더 (42px)
|
||||
- `.dev-toolbar` — 도구모음 (34px)
|
||||
- `.dev-palette` — 좌측 팔레트 (180px)
|
||||
- `.dev-canvas` — 중앙 캔버스 (도트 그리드 배경)
|
||||
- `.dev-props` — 우측 속성 패널 (260px)
|
||||
- `.dev-block` — 캔버스 위 블록 (점선 보더, 선택 시 solid + 글로우)
|
||||
- `.dev-status` — 하단 상태바 (22px)
|
||||
- `.dev-popup-overlay/frame` — 등록/수정 팝업 편집 프레임
|
||||
|
||||
**폰트 사이즈**: 0.36rem(배지) ~ 0.72rem(로고) — mockup 컴팩트 스케일 그대로
|
||||
|
||||
### 3.3 useBuilderState.ts — Zustand 상태관리 (~280줄)
|
||||
|
||||
`create<BuilderState>()(devtools(...))` 패턴 (기존 tabStore와 동일).
|
||||
|
||||
**상태:**
|
||||
|
||||
| 키 | 타입 | 설명 |
|
||||
|---|---|---|
|
||||
| `tableName` | `string \| null` | 선택된 테이블 |
|
||||
| `fields` | `FieldConfig[]` | 현재 테이블의 필드 목록 |
|
||||
| `currentView` | `'list' \| 'create' \| 'edit'` | 현재 뷰 탭 |
|
||||
| `blocks` | `Record<BuilderView, Component[]>` | 뷰별 블록 목록 |
|
||||
| `selectedBlockId` | `string \| null` | 선택된 블록 |
|
||||
| `connections` | `Connection[]` | DataPort 연결 목록 |
|
||||
| `templateId` | `string \| null` | 저장된 템플릿 ID |
|
||||
| `templateName` | `string` | 템플릿 이름 |
|
||||
| `category` | `string` | 분류 |
|
||||
| `description` | `string` | 설명 |
|
||||
| `isDirty` | `boolean` | 변경 여부 |
|
||||
|
||||
**액션 (17개):**
|
||||
|
||||
| 액션 | 설명 |
|
||||
|---|---|
|
||||
| `setTable(name, fields)` | 테이블 선택 → fields 로드 |
|
||||
| `switchView(view)` | 뷰 탭 전환 (선택 해제) |
|
||||
| `addBlock(type, position)` | 블록 추가 (기본 config/size/label 자동 설정) |
|
||||
| `removeBlock(id)` | 블록 삭제 (연결도 함께 제거) |
|
||||
| `updateBlock(id, updates)` | 블록 업데이트 |
|
||||
| `selectBlock(id)` | 블록 선택 |
|
||||
| `moveBlock(id, x, y)` | 블록 이동 (min 0) |
|
||||
| `resizeBlock(id, w, h)` | 블록 리사이즈 (min 40x20) |
|
||||
| `updateBlockConfig(id, config)` | 타입별 config 업데이트 |
|
||||
| `updateField(column, updates)` | FieldConfig 속성 변경 |
|
||||
| `setTemplateMeta(meta)` | 템플릿 이름/분류/설명 변경 |
|
||||
| `addConnection(conn)` | DataPort 연결 추가 |
|
||||
| `removeConnection(connId)` | 연결 제거 |
|
||||
| `toTemplate()` | 현재 상태 → Template JSON (저장용) |
|
||||
| `fromTemplate(tpl)` | Template JSON → 상태 복원 (로드용) |
|
||||
| `resetBuilder()` | 초기화 |
|
||||
| `markClean()` | isDirty=false |
|
||||
|
||||
**셀렉터 훅:**
|
||||
- `useCurrentViewBlocks()` — 현재 뷰의 블록 목록
|
||||
- `useSelectedBlock()` — 선택된 블록 객체
|
||||
|
||||
**컴포넌트 기본 설정 (`defaultConfig`):**
|
||||
|
||||
| ComponentType | 기본 config |
|
||||
|---|---|
|
||||
| `table` | pageSize:20, selectionMode:'single', autoLoad:true, style:'default' |
|
||||
| `form` | columns:2, saveAction:{method:'UPSERT', refreshAfterSave:true} |
|
||||
| `search` | dateRangeEnabled:true, showResetButton:true, autoSearch:false, layout:'inline' |
|
||||
| `button` | text:'버튼', actionType:'save', variant:'default' |
|
||||
| `button-bar` | buttons:[{등록/primary}, {삭제/destructive}] |
|
||||
| `title` | text:'제목', fontSize:'0.75rem', fontWeight:'700', align:'left' |
|
||||
| `stats` | items:[] |
|
||||
| `divider` | style:'solid' |
|
||||
| `pagination` | pageSize:20, showSizeSelector:true, sizeOptions:[10,20,50,100] |
|
||||
|
||||
**컴포넌트 기본 크기 (`defaultSize`):**
|
||||
|
||||
| ComponentType | W × H |
|
||||
|---|---|
|
||||
| `table` | 854 × 380 |
|
||||
| `form` | 440 × 300 |
|
||||
| `search` | 854 × 42 |
|
||||
| `button` | 100 × 36 |
|
||||
| `button-bar` | 370 × 36 |
|
||||
| `title` | 300 × 36 |
|
||||
| `pagination` | 854 × 24 |
|
||||
|
||||
### 3.4 useBlockDrag.ts — 드래그/리사이즈 훅 (~90줄)
|
||||
|
||||
mousedown → document.mousemove → mouseup 패턴.
|
||||
|
||||
- `startDrag(e, id, origX, origY, origW, origH)` — 블록 이동 시작
|
||||
- `startResize(e, id, origX, origY, origW, origH)` — 리사이즈 시작
|
||||
- **Shift 키**: 8px 스냅 (mockup의 `Math.round(n/8)*8` 그대로)
|
||||
- mouseup 시 리스너 자동 정리
|
||||
- `document.body.style.cursor/userSelect` 드래그 중 설정/해제
|
||||
|
||||
### 3.5 BuilderLayout.tsx — 3패널 셸 (~55줄)
|
||||
|
||||
```
|
||||
┌─ BuilderToolbar (헤더 + 도구모음) ─────────────────────┐
|
||||
├──────────┬────────────────────────────┬──────────────────┤
|
||||
│ Palette │ Canvas │ Props │
|
||||
│ (180px) │ (flex:1) │ (260px) │
|
||||
├──────────┴────────────────────────────┴──────────────────┤
|
||||
│ 상태바: 블록 N개 · 테이블명 · 연결 N개 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**키보드 단축키:**
|
||||
- `Delete` / `Backspace` — 선택된 블록 삭제
|
||||
- `Escape` — 블록 선택 해제
|
||||
|
||||
### 3.6 BuilderToolbar.tsx — 헤더 + 도구모음 (~130줄)
|
||||
|
||||
**헤더 (dev-hdr):**
|
||||
- INVYONE 로고 + DEV 배지
|
||||
- 템플릿 이름 입력
|
||||
- 저장 버튼
|
||||
|
||||
**도구모음 (dev-toolbar):**
|
||||
- 테이블 드롭다운 (getMetaTableList → has_custom_meta ★ 표시)
|
||||
- 뷰 탭 (목록 / 등록 / 수정)
|
||||
- 수정됨 표시 (isDirty)
|
||||
|
||||
**저장 흐름:**
|
||||
1. `toTemplate()` → Template JSON 생성
|
||||
2. `templateId` 있으면 `updateTemplate()`, 없으면 `insertTemplate()`
|
||||
3. 성공 시 `markClean()` + templateId 저장
|
||||
|
||||
### 3.7 BuilderPalette.tsx — 좌측 팔레트 (~75줄)
|
||||
|
||||
8종 컴포넌트를 4개 섹션으로 분류:
|
||||
|
||||
| 섹션 | 컴포넌트 |
|
||||
|---|---|
|
||||
| 데이터 | 📊 데이터 테이블, 🔍 검색 필터 |
|
||||
| 입력 | 📝 입력 폼 |
|
||||
| 액션 | 🔘 버튼, ⬜ 버튼 바 |
|
||||
| 표시 | 📌 제목/텍스트, 📈 통계 카드, ── 구분선, 📄 페이지네이션 |
|
||||
|
||||
- **드래그앤드롭**: `onDragStart` → `component-type` 데이터 전달 → 캔버스에서 `onDrop`
|
||||
- **클릭 추가**: 테이블 미선택 시 data 컴포넌트 비활성화 (opacity 0.4)
|
||||
|
||||
### 3.8 BuilderCanvas.tsx — 중앙 캔버스 (~80줄)
|
||||
|
||||
- **목록 뷰**: 전체 캔버스에 블록 자유 배치 (min 1200×800, 도트 그리드 배경)
|
||||
- **등록/수정 뷰**: `dev-popup-overlay` + `dev-popup-frame` (500px 너비) 안에 블록 배치
|
||||
- **드롭 처리**: `onDrop` → 마우스 좌표 계산 → `addBlock(type, {x, y})`
|
||||
- **빈 캔버스**: 안내 메시지 표시 ("팔레트에서 컴포넌트를 드래그하거나 클릭하여 추가하세요")
|
||||
- **선택 해제**: 캔버스 빈 공간 클릭 시 `selectBlock(null)`
|
||||
|
||||
### 3.9 BuilderBlock.tsx — 개별 블록 (~160줄)
|
||||
|
||||
**구조:**
|
||||
```
|
||||
.dev-block (position:absolute, 점선 보더)
|
||||
├── .dev-block-label (블록 이름)
|
||||
├── .dev-block-content (타입별 프리뷰)
|
||||
└── .dev-resize-handle (우하단 리사이즈 핸들)
|
||||
```
|
||||
|
||||
**타입별 프리뷰 (BlockPreview 내부 컴포넌트):**
|
||||
|
||||
| 타입 | 프리뷰 내용 |
|
||||
|---|---|
|
||||
| `table` | `<table>` 헤더 + 3행 더미 (visible 필드 최대 8개) |
|
||||
| `form` | CSS Grid (columns 수) + 필드 라벨/입력 (최대 10개, required * 표시) |
|
||||
| `search` | 가로 나열 필드 라벨/입력 (searchable 필드 최대 5개) + 검색 버튼 |
|
||||
| `title` | fontSize/fontWeight/align 적용된 텍스트 |
|
||||
| `button` | variant 스타일 적용된 단일 버튼 |
|
||||
| `button-bar` | 버튼 목록 가로 나열 |
|
||||
| `pagination` | 총 건수 / 페이지 번호 / 건수 선택기 |
|
||||
| `divider` | 수평선 |
|
||||
| `stats` | "통계 카드 프리뷰" 텍스트 |
|
||||
|
||||
**FieldOption 렌더링**: `string | {value, label}` 유니온 타입 처리 (tsc 에러 수정)
|
||||
|
||||
### 3.10 BuilderProps.tsx — 우측 속성 패널 (~90줄)
|
||||
|
||||
**공통 속성 (모든 블록):**
|
||||
- 컴포넌트 종류 (아이콘 + 한글 라벨)
|
||||
- 이름 (input, 캔버스 라벨과 양방향 동기화)
|
||||
- 위치·크기 (X/Y/W/H 4칸 그리드, 캔버스와 양방향)
|
||||
|
||||
**타입별 분기:**
|
||||
- `table` → TableProps
|
||||
- `form` → FormProps
|
||||
- `search` → SearchProps
|
||||
- `button` → SingleButtonProps
|
||||
- `button-bar` → ButtonBarProps
|
||||
- `title` → TitleProps
|
||||
|
||||
**공통 삭제 버튼**: 하단 빨간 테두리 버튼
|
||||
|
||||
### 3.11 FieldListEditor.tsx — 필드 체크리스트 (~110줄)
|
||||
|
||||
table/form/search 속성 패널에서 공통으로 사용하는 필드 목록.
|
||||
|
||||
**기능:**
|
||||
- 체크박스 토글 (`visible` 또는 `searchable` 속성)
|
||||
- 필드 배지 표시 (PK, 필수, 검색, SYS, 계산)
|
||||
- 타입 배지 (text, number, date 등)
|
||||
- **클릭하면 상세 펼침** (FieldDetail 패널):
|
||||
- 표시 이름, 너비 편집
|
||||
- 토글: 필수, 편집, 검색, 정렬 (dev-toggle 스타일)
|
||||
|
||||
### 3.12 타입별 속성 패널
|
||||
|
||||
**TableProps.tsx** (~70줄):
|
||||
- 페이지 크기 (10/20/50/100)
|
||||
- 선택 방식 (없음/단일/다중)
|
||||
- 자동 로드, 인라인 편집, 체크박스 토글
|
||||
- 스타일 (기본/줄무늬/테두리/컴팩트)
|
||||
- FieldListEditor (visible 토글)
|
||||
|
||||
**FormProps.tsx** (~40줄):
|
||||
- 컬럼 수 (1/2/3칸)
|
||||
- 저장 방식 (등록/수정/등록+수정)
|
||||
- FieldListEditor (visible 토글)
|
||||
|
||||
**SearchProps.tsx** (~50줄):
|
||||
- 날짜 범위 검색, 초기화 버튼, 자동 검색 토글
|
||||
- 레이아웃 (인라인/세로)
|
||||
- FieldListEditor (searchable 토글)
|
||||
|
||||
**ButtonProps.tsx** (~140줄):
|
||||
- `SingleButtonProps`: 텍스트, 액션 종류(12종), 스타일(5종), 확인 메시지
|
||||
- `ButtonBarProps`: 버튼 목록 CRUD (추가/삭제/편집), 각 버튼 액션+스타일 설정
|
||||
|
||||
**TitleProps.tsx** (~40줄):
|
||||
- 텍스트, 크기(4단계), 굵기(4단계), 정렬(3종)
|
||||
|
||||
---
|
||||
|
||||
## 4. DB 변경
|
||||
|
||||
### 4.1 templates 테이블 (신규)
|
||||
|
||||
```sql
|
||||
CREATE TABLE templates (
|
||||
template_id VARCHAR(50) PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
category VARCHAR(50),
|
||||
description TEXT,
|
||||
primary_table VARCHAR(100) NOT NULL,
|
||||
fields JSONB NOT NULL,
|
||||
views JSONB NOT NULL,
|
||||
connections JSONB DEFAULT '[]',
|
||||
company_code VARCHAR(20) NOT NULL DEFAULT '*',
|
||||
version INTEGER DEFAULT 1,
|
||||
status VARCHAR(20) DEFAULT 'draft',
|
||||
created_by VARCHAR(50),
|
||||
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by VARCHAR(50),
|
||||
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active VARCHAR(1) DEFAULT 'Y'
|
||||
);
|
||||
```
|
||||
|
||||
**인덱스 3개:**
|
||||
- `idx_templates_company` (company_code)
|
||||
- `idx_templates_table` (primary_table)
|
||||
- `idx_templates_status` (status)
|
||||
|
||||
**실행 대상**: `211.115.91.141:11134/test_dev` (2026-04-10 실행 완료)
|
||||
|
||||
### 4.2 application.yml DB 접속 변경
|
||||
|
||||
```
|
||||
변경 전: jdbc:postgresql://39.117.244.52:11132/testvex (pw: ph0909!!)
|
||||
변경 후: jdbc:postgresql://211.115.91.141:11134/test_dev (pw: vexplor0909!!)
|
||||
```
|
||||
|
||||
39.117 서버 폐기에 따른 변경.
|
||||
|
||||
---
|
||||
|
||||
## 5. 덕일 스타일 / 프로젝트 컨벤션 준수
|
||||
|
||||
| 규칙 | 준수 |
|
||||
|---|---|
|
||||
| 3레이어 (Controller → Service → XML) | ✅ |
|
||||
| Mapper Interface 금지 | ✅ — sqlSession 직접 호출 |
|
||||
| Map<String, Object> 사용, DTO 금지 | ✅ — ApiResponse만 예외 |
|
||||
| BaseService 상속 | ✅ |
|
||||
| @Autowired CommonService | ✅ |
|
||||
| XML 파일명: 소문자, Mapper 안 붙임 | ✅ — `template.xml` |
|
||||
| XML namespace: 파일명과 동일 | ✅ — `namespace="template"` |
|
||||
| SQL: UPPER_SNAKE | ✅ |
|
||||
| SELECT 쉼표: 앞에 | ✅ |
|
||||
| #{파라미터}: snake_case | ✅ |
|
||||
| OGNL test: 바깥 작은따옴표 | ✅ |
|
||||
| 프론트 타입: Record<string, any> | ✅ — FieldConfig/Component만 예외 |
|
||||
| 개발자 모드 CSS: IDE 스타일 (v5 코스믹 아님) | ✅ — `--d-*` 변수 체계 |
|
||||
| Zustand devtools 미들웨어 | ✅ |
|
||||
| 기존 apiClient 사용 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 6. 검증 결과
|
||||
|
||||
| 검증 항목 | 결과 |
|
||||
|---|---|
|
||||
| `./gradlew compileJava` | BUILD SUCCESSFUL |
|
||||
| `./gradlew bootJar` | BUILD SUCCESSFUL |
|
||||
| `npx tsc --noEmit` (빌더 관련) | 에러 0개 |
|
||||
| DB 테이블 생성 (test_dev) | CREATE TABLE + INDEX 3개 성공 |
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase 3 범위 밖 (다음에 해야 할 것)
|
||||
|
||||
| 항목 | 담당 Phase | 비고 |
|
||||
|---|---|---|
|
||||
| 메타 드리프트 감지 (빌더에서 Template 열 때 최신 메타 비교) | Phase 3+ | 설계서 Section 9에 정의됨 |
|
||||
| DataPort 연결 UI (속성 패널에서 연결 추가/삭제) | Phase 3+ | mockup의 `propsConnections()` 참고 |
|
||||
| 기존 Template 목록/로드 UI | Phase 3+ | 현재는 새 템플릿 생성만 |
|
||||
| 자동생성 + 프리셋 (⚡ 버튼) | Phase 6 | 설계서 명시 |
|
||||
| stats/chart 타입 속성 패널 | Phase 3+ | 현재 빈 구조만 |
|
||||
| 필드 순서 드래그 재정렬 | Phase 3+ | 현재 order 기준 정렬만 |
|
||||
| Template 게시 워크플로우 UI | Phase 4 | 대시보드에서 게시된 Template 사용 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 빌더 사용 흐름
|
||||
|
||||
```
|
||||
1. /admin/builder 접속
|
||||
2. 도구모음에서 테이블 선택 (예: sales_order_mng)
|
||||
→ Phase 1 API로 FieldConfig[] 자동 로드
|
||||
3. 팔레트에서 컴포넌트 선택 → 캔버스에 드래그 또는 클릭
|
||||
(예: 제목 → 검색 필터 → 데이터 테이블 → 페이지네이션)
|
||||
4. 캔버스에서 블록 드래그(이동) / 우하단 핸들로 리사이즈
|
||||
5. 블록 클릭 → 우측 속성 패널에서 설정 조정
|
||||
(필드 ON/OFF, 페이지 크기, 선택 모드 등)
|
||||
6. 뷰 탭으로 목록/등록/수정 전환
|
||||
(등록/수정은 팝업 프레임 안에서 편집)
|
||||
7. 💾 저장 → Template JSON이 DB에 저장
|
||||
8. 기존 Template 로드 → fromTemplate()으로 상태 복원
|
||||
```
|
||||
@@ -0,0 +1,400 @@
|
||||
# Phase 4: 대시보드(=메뉴) — 사용자 화면 시스템
|
||||
|
||||
> **목적**: 사용자가 대시보드(=메뉴)를 만들고, 개발자가 만든 Template을 카드로 배치하여 업무 화면을 완성하는 시스템 구현
|
||||
> **전제 조건**: Phase 1~3 완료 (FieldConfig API + 규격 컴포넌트 + 개발자 빌더에서 Template 생성 가능)
|
||||
> **산출물**: 대시보드 CRUD + 사이드바 메뉴 자동 등록 + 템플릿 카드 배치 캔버스 + 사용자 오버라이드
|
||||
> **다음 단계**: Phase 5에서 카드 간 비즈니스 룰/데이터 흐름 설정 (제어 모드)
|
||||
|
||||
---
|
||||
|
||||
## 1. 핵심 개념
|
||||
|
||||
**대시보드 = 메뉴 = 화면.** "대시보드"라는 이름이지만, 통계/차트 모아놓는 곳이 아니다.
|
||||
|
||||
```
|
||||
사용자가 "수주관리" 대시보드 생성
|
||||
→ 사이드바 메뉴에 "수주관리" 자동 등록
|
||||
→ 캔버스에 수주관리 Template 카드 배치
|
||||
→ 화면 완성!
|
||||
→ 비즈니스 로직 필요하면 → Phase 5 (제어 모드)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 사용자 흐름 (mockup 기준)
|
||||
|
||||
mockup의 `index.html` + `js/05-state.js` + `js/01-shell.js` 참조.
|
||||
|
||||
### 2.1 대시보드 생성
|
||||
|
||||
1. 사이드바 하단의 **`+ 새 대시보드`** 클릭
|
||||
2. 이름 입력 (예: "수주관리")
|
||||
3. → 사이드바 메뉴에 자동 등록
|
||||
4. → 빈 캔버스가 열림 ("아직 템플릿이 없습니다")
|
||||
|
||||
### 2.2 템플릿 카드 배치
|
||||
|
||||
1. 캔버스 상단의 **`+ 템플릿 추가`** 클릭
|
||||
2. **템플릿 라이브러리 모달** 열림:
|
||||
- 좌측: 카테고리 (영업, 생산, 인사, 재고, 관리자...)
|
||||
- 우측: 해당 카테고리의 Template 카드 목록
|
||||
- 검색 가능
|
||||
3. Template 카드 클릭 → 캔버스에 추가
|
||||
4. 여러 Template을 추가하여 한 대시보드(=화면)에 여러 카드 배치 가능
|
||||
|
||||
### 2.3 카드 조작
|
||||
|
||||
- **편집 모드** (우상단 편집 버튼) 켜면:
|
||||
- 카드 드래그 (자유 배치, snap 없음)
|
||||
- 카드 리사이즈 (우하단 핸들)
|
||||
- 카드 삭제 (X 버튼)
|
||||
- **카드 접기** (▼ 버튼) → 미니 KPI 뷰로 축소
|
||||
- **카드 설정** (⚙ 버튼) → 사용자 오버라이드:
|
||||
- 컬럼 표시/숨김
|
||||
- 검색 필터 표시/숨김
|
||||
- 통계 카드 표시/숨김
|
||||
|
||||
### 2.4 저장/복원
|
||||
|
||||
- 대시보드 레이아웃(카드 위치/크기/설정) 자동 저장
|
||||
- 다음 접속 시 그대로 복원
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터 모델
|
||||
|
||||
### 3.1 대시보드 (DB 테이블)
|
||||
|
||||
**`dashboards` 테이블 (신규 생성):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE dashboards (
|
||||
dashboard_id VARCHAR(50) PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
icon VARCHAR(10) DEFAULT '📋',
|
||||
display_order INTEGER DEFAULT 0,
|
||||
company_code VARCHAR(20) NOT NULL,
|
||||
user_id VARCHAR(50), -- NULL이면 회사 공통
|
||||
is_active VARCHAR(1) DEFAULT 'Y',
|
||||
created_by VARCHAR(50),
|
||||
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by VARCHAR(50),
|
||||
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dashboards_company ON dashboards(company_code);
|
||||
CREATE INDEX idx_dashboards_user ON dashboards(user_id);
|
||||
```
|
||||
|
||||
### 3.2 대시보드 카드 배치
|
||||
|
||||
**`dashboard_cards` 테이블 (신규 생성):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE dashboard_cards (
|
||||
card_id VARCHAR(50) PRIMARY KEY,
|
||||
dashboard_id VARCHAR(50) NOT NULL, -- ★ DB FK 제약조건 안 걸음 (앱 레벨 관리)
|
||||
template_id VARCHAR(50) NOT NULL, -- ★ DB FK 제약조건 안 걸음
|
||||
position_x INTEGER DEFAULT 0, -- 캔버스 상 X 좌표 (px)
|
||||
position_y INTEGER DEFAULT 0, -- 캔버스 상 Y 좌표 (px)
|
||||
width INTEGER DEFAULT 600, -- 카드 너비 (px)
|
||||
height INTEGER DEFAULT 400, -- 카드 높이 (px)
|
||||
is_collapsed BOOLEAN DEFAULT FALSE, -- 접힌 상태 여부
|
||||
display_order INTEGER DEFAULT 0,
|
||||
is_active VARCHAR(1) DEFAULT 'Y',
|
||||
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dcards_dashboard ON dashboard_cards(dashboard_id);
|
||||
```
|
||||
|
||||
### 3.3 사용자 오버라이드
|
||||
|
||||
**`user_overrides` 테이블 (신규 생성):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_overrides (
|
||||
override_id VARCHAR(50) PRIMARY KEY,
|
||||
user_id VARCHAR(50) NOT NULL,
|
||||
card_id VARCHAR(50) NOT NULL, -- ★ DB FK 제약조건 안 걸음
|
||||
overrides JSONB NOT NULL, -- { fields: {...}, fieldOrder: [...], gridColumns: {...} }
|
||||
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, card_id)
|
||||
);
|
||||
-- ★ card_id 기준 — 같은 템플릿을 두 번 올려도 카드별로 다른 오버라이드 가능
|
||||
```
|
||||
|
||||
**overrides JSON 구조** (로우코드 플랫폼 SPEC Section 3.2):
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"fax_number": { "visible": false },
|
||||
"customer_name": { "label": "고객사" }
|
||||
},
|
||||
"fieldOrder": ["order_date", "customer_name", "amount"],
|
||||
"gridColumns": {
|
||||
"order_id": { "width": 80 },
|
||||
"customer_name": { "width": 200 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**오버라이드 병합 규칙:**
|
||||
1. 개발자 Template의 FieldConfig[]이 기본값
|
||||
2. 사용자 override가 있으면 해당 필드만 덮어씀
|
||||
3. 개발자가 필드를 삭제하면 해당 override는 자동 무시
|
||||
|
||||
---
|
||||
|
||||
## 4. 백엔드 API 설계
|
||||
|
||||
### 4.1 대시보드 CRUD
|
||||
|
||||
```
|
||||
GET /api/dashboards → 목록 (해당 유저 + 회사 공통)
|
||||
POST /api/dashboards → 생성 (이름, 아이콘)
|
||||
PUT /api/dashboards/{dashboard_id} → 수정 (이름, 아이콘, 순서)
|
||||
DELETE /api/dashboards/{dashboard_id} → 삭제
|
||||
```
|
||||
|
||||
### 4.2 대시보드 카드 CRUD
|
||||
|
||||
```
|
||||
GET /api/dashboards/{dashboard_id}/cards → 해당 대시보드의 카드 목록 (+ Template 기본 정보)
|
||||
POST /api/dashboards/{dashboard_id}/cards → 카드 추가 (templateId, 위치/크기)
|
||||
PUT /api/dashboards/{dashboard_id}/cards/{card_id} → 카드 이동/리사이즈/접기
|
||||
DELETE /api/dashboards/{dashboard_id}/cards/{card_id} → 카드 제거
|
||||
PUT /api/dashboards/{dashboard_id}/cards/batch → 다수 카드 일괄 업데이트 (위치/크기 저장)
|
||||
```
|
||||
|
||||
### 4.3 사이드바 메뉴 (= 대시보드 목록)
|
||||
|
||||
```
|
||||
GET /api/sidebar/menu → 사이드바에 표시할 메뉴 목록
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"section": "내 대시보드",
|
||||
"items": [
|
||||
{ "dashboardId": "dash-1", "name": "수주관리", "icon": "📦", "order": 1 },
|
||||
{ "dashboardId": "dash-2", "name": "인사 대시보드", "icon": "👥", "order": 2 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 템플릿 라이브러리 (카드 추가 시 모달)
|
||||
|
||||
```
|
||||
GET /api/templates/library → 게시된(published) 템플릿 목록 (카테고리별)
|
||||
```
|
||||
|
||||
### 4.5 사용자 오버라이드
|
||||
|
||||
```
|
||||
GET /api/overrides?card_id=xxx → 해당 유저+카드의 오버라이드
|
||||
PUT /api/overrides → 오버라이드 저장 (card_id 기준)
|
||||
```
|
||||
|
||||
### 4.6 Java 파일
|
||||
|
||||
**★ 패턴: 전부 BaseService + Map<String, Object>. 엔티티 클래스/DTO 없음.**
|
||||
|
||||
| 파일 | 역할 |
|
||||
|---|---|
|
||||
| `DashboardController.java` | 대시보드 + 카드 CRUD |
|
||||
| `DashboardService.java` | `extends BaseService`, sqlSession으로 Map 기반 CRUD |
|
||||
| `DashboardMapper.xml` | 대시보드/카드 SQL (`resultType="map"`, `parameterType="map"`) |
|
||||
| `SidebarController.java` | 사이드바 메뉴 조회 |
|
||||
| `UserOverrideController.java` | 사용자 오버라이드 CRUD |
|
||||
| `UserOverrideService.java` | `extends BaseService`, 오버라이드 병합 로직 |
|
||||
|
||||
**파일명: `dashboard.xml` (namespace="dashboard"), `userOverride.xml` (namespace="userOverride")**
|
||||
|
||||
```java
|
||||
// DashboardService.java (덕일 스타일)
|
||||
@Service
|
||||
@Slf4j
|
||||
public class DashboardService extends BaseService {
|
||||
|
||||
@Autowired
|
||||
private CommonService commonService;
|
||||
|
||||
public List<Map<String, Object>> getDashboardList(Map<String, Object> params) {
|
||||
return sqlSession.selectList("dashboard.getDashboardList", params);
|
||||
}
|
||||
public Map<String, Object> getDashboardInfo(Map<String, Object> params) {
|
||||
return sqlSession.selectOne("dashboard.getDashboardInfo", params);
|
||||
}
|
||||
public List<Map<String, Object>> getDashboardCardList(Map<String, Object> params) {
|
||||
return sqlSession.selectList("dashboard.getDashboardCardList", params);
|
||||
}
|
||||
@Transactional
|
||||
public Map<String, Object> insertDashboard(Map<String, Object> params) {
|
||||
sqlSession.insert("dashboard.insertDashboard", params);
|
||||
return params;
|
||||
}
|
||||
@Transactional
|
||||
public void updateDashboard(Map<String, Object> params) {
|
||||
sqlSession.update("dashboard.updateDashboard", params);
|
||||
}
|
||||
@Transactional
|
||||
public int deleteDashboard(Map<String, Object> params) {
|
||||
return sqlSession.update("dashboard.deleteDashboard", params);
|
||||
}
|
||||
@Transactional
|
||||
public void updateCardPositions(Map<String, Object> params) {
|
||||
List<Map<String, Object>> cards = (List<Map<String, Object>>) params.get("cards");
|
||||
for (Map<String, Object> card : cards) {
|
||||
sqlSession.update("dashboard.updateCardPosition", card);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 프론트엔드 구현
|
||||
|
||||
### 5.1 파일 구조
|
||||
|
||||
```
|
||||
frontend/components/dashboard/
|
||||
├── DashboardCanvas.tsx ← 캔버스 (카드 자유 배치)
|
||||
├── DashboardCard.tsx ← 개별 카드 (Template 렌더링)
|
||||
├── DashboardToolbar.tsx ← 상단 툴바 (편집/저장/+템플릿)
|
||||
├── DashboardEmpty.tsx ← 빈 대시보드 안내
|
||||
├── TemplateLibraryModal.tsx ← 템플릿 라이브러리 모달
|
||||
├── CardSettingsPanel.tsx ← 카드 설정 패널 (사용자 오버라이드)
|
||||
└── CardMiniView.tsx ← 접힌 카드의 미니 KPI 뷰
|
||||
|
||||
frontend/components/layout/
|
||||
├── AppLayout.tsx ← (기존) 전체 레이아웃 — 수정 필요
|
||||
├── Sidebar.tsx ← 사이드바 (대시보드 목록 = 메뉴)
|
||||
└── Header.tsx ← 헤더
|
||||
|
||||
frontend/stores/
|
||||
└── dashboardStore.ts ← 대시보드 상태 (Zustand)
|
||||
|
||||
frontend/lib/api/
|
||||
├── dashboard.ts ← 대시보드 CRUD API
|
||||
└── override.ts ← 사용자 오버라이드 API
|
||||
```
|
||||
|
||||
### 5.2 DashboardCanvas — 핵심 컴포넌트
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* ★ 별도 인터페이스 정의 안 함 — Record<string, any> 사용
|
||||
* ★ 유일하게 타입이 있는 건 FieldConfig, Template 등 (invyone-component.ts 규격)
|
||||
*
|
||||
* 카드 데이터 구조 (Record<string, any>):
|
||||
* card_id, template_id, template(로드된 Template), x, y, width, height, is_collapsed
|
||||
*/
|
||||
```
|
||||
|
||||
**카드 안에서 Template 렌더링:**
|
||||
- Template.views.list의 components[]를 순서대로 렌더
|
||||
- 각 Component는 Phase 2의 FcTable/FcForm/FcSearch를 사용
|
||||
- Template.fields를 각 컴포넌트에 전달
|
||||
- Template.connections로 컴포넌트 간 DataPort 연결
|
||||
|
||||
### 5.3 Sidebar — 대시보드 = 메뉴
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* ★ Props도 별도 인터페이스 불필요 — 인라인 또는 Record<string, any> 사용
|
||||
* 사이드바는 dashboards(Record<string, any>[]), active_dashboard_id, 콜백들을 props로 받음
|
||||
*/
|
||||
```
|
||||
|
||||
mockup 참조:
|
||||
- `css/02-shell.css`의 `.side`, `.si`, `.side-add-btn`
|
||||
- `js/05-state.js`의 `renderSidebar()`, `addDashboard()`, `switchDashboard()`
|
||||
- 접힌 상태 (60px) + 툴팁
|
||||
- hover 시 이름 변경/삭제 아이콘
|
||||
|
||||
### 5.4 TemplateLibraryModal
|
||||
|
||||
mockup 참조: `css/06-modals.css`의 `.lib-modal`, `js/01-shell.js`의 `openLib()/closeLib()`
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 템플릿 라이브러리 [검색...] [X] │
|
||||
├────────────┬─────────────────────────────────┤
|
||||
│ 카테고리 │ 카드 그리드 │
|
||||
│ │ │
|
||||
│ 전체 │ ┌───────┐ ┌───────┐ ┌───────┐ │
|
||||
│ 영업/CRM │ │ 📦 │ │ 📊 │ │ 👥 │ │
|
||||
│ 생산/공정 │ │수주관리 │ │매출KPI │ │인사관리│ │
|
||||
│ 인사/급여 │ │sales │ │chart │ │hr │ │
|
||||
│ 재고/물류 │ └───────┘ └───────┘ └───────┘ │
|
||||
│ 관리자 │ │
|
||||
└────────────┴─────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 스타일
|
||||
|
||||
사용자 화면은 **v5 Cosmic Glassmorphism** (개발자 빌더의 IDE 스타일과 다름):
|
||||
- 글래스모피즘: `backdrop-filter:blur(20px) saturate(1.4)` + `var(--glass)`
|
||||
- 코스믹 배경: 별/성운/입자 (다크 모드), 구름형 (라이트 모드)
|
||||
- 보라/시안/핑크 액센트
|
||||
- 컴팩트 폰트 (0.55~0.85rem)
|
||||
|
||||
mockup 참조:
|
||||
- `css/01-tokens.css` (토큰)
|
||||
- `css/02-shell.css` (사이드바, 헤더)
|
||||
- `css/03-canvas.css` (캔버스, 카드)
|
||||
- `css/04-settings.css` (카드 설정)
|
||||
- `css/06-modals.css` (라이브러리 모달)
|
||||
- `frontend/styles/v5-layout.css` (React 포팅된 v5 토큰)
|
||||
|
||||
---
|
||||
|
||||
## 7. 참고 파일
|
||||
|
||||
| 파일 | 용도 |
|
||||
|---|---|
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/index.html` | **대시보드 전체 UI (진실의 원천)** |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/js/05-state.js` | 대시보드 상태 관리 로직 (SEED_STATE, renderCanvas, switchDashboard 등) |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/js/01-shell.js` | 테마/모드/사이드바/아바타/라이브러리 모달 |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/js/02-canvas.js` | 편집/드래그/리사이즈/접기/삭제 |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/js/04-templates.js` | templateRenderers, buildCardEl, addCardFromLib |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/css/01-tokens.css` ~ `06-modals.css` | v5 스타일 전체 |
|
||||
| `notes/gbpark/2026-04-08-lowcode-platform-spec.md` (Section 3.2) | 사용자 오버라이드 레이어 설계 |
|
||||
| `frontend/styles/v5-layout.css` | React 포팅된 v5 CSS |
|
||||
| `frontend/components/layout/AppLayout.tsx` | 기존 레이아웃 (수정 대상) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 완료 기준
|
||||
|
||||
1. 사이드바에 대시보드 목록이 표시되고, 클릭 시 해당 대시보드로 전환된다
|
||||
2. `+ 새 대시보드` → 이름 입력 → 사이드바에 자동 등록된다
|
||||
3. 대시보드 이름 변경/삭제가 동작한다
|
||||
4. `+ 템플릿 추가` → 라이브러리 모달에서 Template 선택 → 캔버스에 카드 추가된다
|
||||
5. 편집 모드에서 카드 드래그/리사이즈가 동작한다
|
||||
6. 카드 접기/펴기 (미니 KPI 뷰)가 동작한다
|
||||
7. 카드 안에서 실제 데이터가 렌더링된다 (FcTable/FcSearch 등)
|
||||
8. 카드 설정(⚙)에서 컬럼 ON/OFF → 실시간 반영
|
||||
9. 대시보드 레이아웃이 DB에 저장/복원된다
|
||||
10. 사용자 오버라이드가 Template 위에 올바르게 적용된다
|
||||
|
||||
---
|
||||
|
||||
## 9. 다음 단계 연결
|
||||
|
||||
Phase 5 (제어 모드):
|
||||
- 대시보드의 카드를 클릭하면 → 해당 카드의 데이터 흐름 시각화
|
||||
- 카드 간 비즈니스 룰 설정 (카드 A의 데이터가 변경되면 카드 B 자동 갱신 등)
|
||||
- 대시보드 캔버스 위에 제어 모드 오버레이
|
||||
@@ -0,0 +1,297 @@
|
||||
# Phase 4 구현 작업기록 — 대시보드(=메뉴) 사용자 화면 시스템
|
||||
|
||||
> **작업일**: 2026-04-10
|
||||
> **설계서**: `notes/gbpark/2026-04-10-phase4-dashboard-menu.md`
|
||||
> **상태**: 구현 완료 + 빌드 검증 통과 + DB 테이블 생성 완료
|
||||
|
||||
---
|
||||
|
||||
## 1. 생성/수정된 파일 (19개)
|
||||
|
||||
### 1.1 백엔드 (6개)
|
||||
|
||||
| 파일 | 상태 | 역할 |
|
||||
|---|---|---|
|
||||
| `backend-spring/src/main/resources/mapper/dashboard.xml` | 수정 (VEX 레거시 교체) | 대시보드/카드/사이드바 SQL 12쿼리 |
|
||||
| `backend-spring/src/main/resources/mapper/userOverride.xml` | 신규 | 사용자 오버라이드 UPSERT/조회/삭제 3쿼리 |
|
||||
| `backend-spring/src/main/java/com/erp/service/DashboardService.java` | 수정 (VEX 레거시 교체) | BaseService + sqlSession 덕일 스타일 |
|
||||
| `backend-spring/src/main/java/com/erp/service/UserOverrideService.java` | 신규 | JSONB 파싱/직렬화 + UPSERT |
|
||||
| `backend-spring/src/main/java/com/erp/controller/DashboardController.java` | 수정 (VEX 레거시 교체) | `/api/dashboards` 엔드포인트 |
|
||||
| `backend-spring/src/main/java/com/erp/controller/UserOverrideController.java` | 신규 | `/api/overrides` 엔드포인트 |
|
||||
|
||||
### 1.2 프론트엔드 (13개)
|
||||
|
||||
| 파일 | 역할 |
|
||||
|---|---|
|
||||
| `frontend/lib/api/dashMenu.ts` | 대시보드/카드/사이드바 API 래퍼 |
|
||||
| `frontend/lib/api/override.ts` | 사용자 오버라이드 API 래퍼 |
|
||||
| `frontend/stores/dashboardStore.ts` | Zustand 대시보드 상태관리 |
|
||||
| `frontend/styles/dashboard.css` | v5 Cosmic Glassmorphism 대시보드 CSS |
|
||||
| `frontend/components/dash/DashboardLayout.tsx` | 전체 레이아웃 (사이드바+캔버스+모달) |
|
||||
| `frontend/components/dash/DashboardSidebar.tsx` | 대시보드 목록 사이드바 |
|
||||
| `frontend/components/dash/DashboardToolbar.tsx` | 상단 툴바 (편집/저장/+템플릿) |
|
||||
| `frontend/components/dash/DashboardCanvas.tsx` | 자유 배치 캔버스 + 드래그/리사이즈 |
|
||||
| `frontend/components/dash/DashboardCard.tsx` | 카드 (Template→FcTable/FcSearch 렌더) |
|
||||
| `frontend/components/dash/DashboardEmpty.tsx` | 빈 대시보드 안내 |
|
||||
| `frontend/components/dash/TemplateLibraryModal.tsx` | 템플릿 라이브러리 모달 |
|
||||
| `frontend/components/dash/CardSettingsPanel.tsx` | 카드 설정 패널 (컬럼 ON/OFF) |
|
||||
| `frontend/components/dash/CardMiniView.tsx` | 접힌 카드 미니 뷰 |
|
||||
| `frontend/app/(main)/dash/page.tsx` | `/dash` 라우트 페이지 |
|
||||
|
||||
---
|
||||
|
||||
## 2. VEX 레거시 교체 사항
|
||||
|
||||
### 2.1 기존 DashboardService/Controller (VEX)
|
||||
|
||||
**교체 전**: JdbcTemplate 직접 사용, `dashboards`+`dashboard_elements` VEX 테이블, `/api/dashboard` (단수)
|
||||
**교체 후**: BaseService + sqlSession 덕일 스타일, `DASHBOARDS`+`DASHBOARD_CARDS` 테이블, `/api/dashboards` (복수)
|
||||
|
||||
기존 VEX 대시보드의 유틸리티 엔드포인트 (`execute-query`, `execute-dml`, `table-schema`, `fetch-external-api`)는 제거됨. 필요 시 별도 유틸리티 컨트롤러로 분리 가능.
|
||||
|
||||
### 2.2 프론트엔드 경로 분리
|
||||
|
||||
| 구분 | VEX 레거시 | Phase 4 (INVYONE) |
|
||||
|---|---|---|
|
||||
| API 파일 | `lib/api/dashboard.ts` (유지) | `lib/api/dashMenu.ts` (신규) |
|
||||
| 컴포넌트 | `components/dashboard/` (유지) | `components/dash/` (신규) |
|
||||
| 페이지 | `app/(main)/dashboard/` (유지) | `app/(main)/dash/` (신규) |
|
||||
|
||||
VEX 레거시 파일은 건드리지 않음. 나중에 VEX 완전 폐기 시 삭제.
|
||||
|
||||
---
|
||||
|
||||
## 3. 백엔드 상세
|
||||
|
||||
### 3.1 dashboard.xml (namespace="dashboard", 12쿼리)
|
||||
|
||||
| 쿼리 ID | 용도 |
|
||||
|---|---|
|
||||
| `getDashboardList` | 대시보드 목록 (유저+회사공통, 페이지네이션) |
|
||||
| `getDashboardListCnt` | 목록 카운트 |
|
||||
| `getDashboardInfo` | 대시보드 단건 |
|
||||
| `insertDashboard` | 대시보드 생성 |
|
||||
| `updateDashboard` | 대시보드 수정 (이름/아이콘/순서) |
|
||||
| `deleteDashboard` | 대시보드 소프트 삭제 (IS_ACTIVE='D') |
|
||||
| `getDashboardCardList` | 카드 목록 (TEMPLATES JOIN으로 기본 정보 포함) |
|
||||
| `insertDashboardCard` | 카드 추가 |
|
||||
| `updateDashboardCard` | 카드 업데이트 (위치/크기/접기) |
|
||||
| `updateCardPosition` | 카드 일괄 위치 업데이트 (단건, for loop용) |
|
||||
| `deleteDashboardCard` | 카드 소프트 삭제 |
|
||||
| `getSidebarMenu` | 사이드바 메뉴 (간략 대시보드 목록) |
|
||||
|
||||
### 3.2 userOverride.xml (namespace="userOverride", 3쿼리)
|
||||
|
||||
| 쿼리 ID | 용도 |
|
||||
|---|---|
|
||||
| `getUserOverride` | 유저+카드 기준 오버라이드 조회 |
|
||||
| `upsertUserOverride` | ON CONFLICT UPSERT (UNIQUE(user_id, card_id)) |
|
||||
| `deleteUserOverride` | 오버라이드 삭제 |
|
||||
|
||||
### 3.3 DashboardService.java (~100줄)
|
||||
|
||||
덕일 스타일 준수: `extends BaseService`, `@Autowired CommonService`, `sqlSession` 직접 호출.
|
||||
- ID 생성: `dash_` + UUID 12자 / `card_` + UUID 12자
|
||||
- 기본값: icon=📋, position_x=50, width=600, height=400
|
||||
|
||||
### 3.4 DashboardController.java (~120줄)
|
||||
|
||||
| 메서드 | 엔드포인트 | 설명 |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/dashboards` | 목록 (keyword, page, limit) |
|
||||
| `GET` | `/api/dashboards/{id}` | 단건 |
|
||||
| `POST` | `/api/dashboards` | 생성 |
|
||||
| `PUT` | `/api/dashboards/{id}` | 수정 |
|
||||
| `DELETE` | `/api/dashboards/{id}` | 삭제 |
|
||||
| `GET` | `/api/dashboards/{id}/cards` | 카드 목록 (Template JOIN) |
|
||||
| `POST` | `/api/dashboards/{id}/cards` | 카드 추가 |
|
||||
| `PUT` | `/api/dashboards/{id}/cards/{cardId}` | 카드 수정 |
|
||||
| `DELETE` | `/api/dashboards/{id}/cards/{cardId}` | 카드 삭제 |
|
||||
| `PUT` | `/api/dashboards/{id}/cards/batch` | 카드 일괄 위치 업데이트 |
|
||||
| `GET` | `/api/dashboards/sidebar/menu` | 사이드바 메뉴 |
|
||||
|
||||
### 3.5 UserOverrideController.java (~40줄)
|
||||
|
||||
| 메서드 | 엔드포인트 | 설명 |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/overrides?card_id=xxx` | 조회 |
|
||||
| `PUT` | `/api/overrides` | UPSERT |
|
||||
| `DELETE` | `/api/overrides?card_id=xxx` | 삭제 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 프론트엔드 상세
|
||||
|
||||
### 4.1 dashMenu.ts — API 래퍼 (~55줄)
|
||||
|
||||
```
|
||||
getDashboardList, getDashboardInfo, insertDashboard, updateDashboard, deleteDashboard
|
||||
getDashboardCards, insertDashboardCard, updateDashboardCard, deleteDashboardCard
|
||||
updateCardPositionsBatch, getSidebarMenu
|
||||
```
|
||||
|
||||
★ 전부 `Record<string, any>` — 별도 인터페이스 정의 안 함
|
||||
|
||||
### 4.2 dashboardStore.ts — Zustand (~80줄)
|
||||
|
||||
상태: `dashboards[]`, `activeDashboardId`, `cards[]`, `editMode`, `loading`
|
||||
액션: `setDashboards`, `setActiveDashboard`, `setCards`, `addCard`, `updateCard`, `removeCard`, `toggleEditMode`, `addDashboard`, `updateDashboardInList`, `removeDashboard`
|
||||
|
||||
### 4.3 DashboardLayout.tsx — 전체 오케스트레이터 (~180줄)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ DashboardLayout │
|
||||
├────────────┬─────────────────────────────────────┤
|
||||
│ Sidebar │ Toolbar │
|
||||
│ (220px) ├─────────────────────────────────────┤
|
||||
│ - 목록 │ Canvas │
|
||||
│ - +추가 │ - DashboardCard × N │
|
||||
│ │ - 드래그/리사이즈 │
|
||||
│ │ + CardSettingsPanel (조건부) │
|
||||
├────────────┴─────────────────────────────────────┤
|
||||
│ TemplateLibraryModal (조건부) │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.4 DashboardCanvas.tsx — 드래그/리사이즈 (~130줄)
|
||||
|
||||
mockup의 `02-canvas.js` 로직 포팅:
|
||||
- mousedown → 드래그 또는 리사이즈 모드 판별
|
||||
- mousemove → 캔버스 경계 clamp 적용
|
||||
- mouseup → store에 최종 위치 반영
|
||||
- snap 없음 (px 단위), 캔버스 밖 방지
|
||||
|
||||
### 4.5 DashboardCard.tsx — Template 렌더 (~160줄)
|
||||
|
||||
카드 안에서 Phase 2 컴포넌트가 실제 데이터로 동작:
|
||||
1. `primaryTable`로 `getMetaFields()` → FieldConfig[] 로드
|
||||
2. `fcList()` → 실제 데이터 조회
|
||||
3. `FcSearch` + `FcTable` + `FcPagination` 렌더
|
||||
4. 검색 → 재조회, 페이지네이션 → 재조회
|
||||
|
||||
### 4.6 TemplateLibraryModal.tsx (~130줄)
|
||||
|
||||
mockup의 `06-modals.css` 스타일 포팅:
|
||||
- 좌측 카테고리 (7종: 전체/영업/생산/인사/재고/재무/관리자)
|
||||
- 우측 템플릿 카드 그리드 (auto-fill, minmax 180px)
|
||||
- 검색 필터
|
||||
- 클릭 → `onSelectTemplate` 콜백
|
||||
|
||||
### 4.7 CardSettingsPanel.tsx (~100줄)
|
||||
|
||||
- 필드 visible 토글 (ON/OFF)
|
||||
- `getUserOverride` + `upsertUserOverride` 실시간 저장
|
||||
- JSONB overrides 구조: `{ fields: { column: { visible: bool } } }`
|
||||
|
||||
### 4.8 dashboard.css — v5 토큰 전부 사용 (~350줄)
|
||||
|
||||
| 용도 | 토큰 |
|
||||
|---|---|
|
||||
| 배경 | `var(--v5-glass)`, `var(--v5-surface)` |
|
||||
| 테두리 | `var(--v5-glass-border)`, `var(--v5-border-subtle)` |
|
||||
| 텍스트 | `var(--v5-text)`, `var(--v5-text-sec)`, `var(--v5-text-muted)` |
|
||||
| 강조 | `var(--v5-primary)`, `var(--v5-primary-light)` |
|
||||
| 글로우 | `var(--v5-glow-sm)`, `var(--v5-glow-md)` |
|
||||
| 유리 | `backdrop-filter: blur(20px) saturate(1.4)` |
|
||||
|
||||
다크/라이트 모드 변형: `.dark` 선택자 사용 (mockup 패턴 동일)
|
||||
|
||||
---
|
||||
|
||||
## 5. DB 테이블 (✅ 생성 완료)
|
||||
|
||||
**실행 대상**: `211.115.91.141:11134/test_dev` (2026-04-10 실행 완료)
|
||||
|
||||
### 5.1 VEX 레거시 처리
|
||||
|
||||
기존 `dashboards` 테이블(VEX 스키마: id, title, description, tags, settings 등)이 있어서 **`dashboards_vex_backup`으로 rename** 후 Phase 4 테이블 신규 생성.
|
||||
|
||||
### 5.2 생성된 테이블 3개 + 인덱스 3개
|
||||
|
||||
| 테이블 | PK | 용도 |
|
||||
|---|---|---|
|
||||
| `DASHBOARDS` | DASHBOARD_ID (VARCHAR 50) | 대시보드 (=메뉴 항목) |
|
||||
| `DASHBOARD_CARDS` | CARD_ID (VARCHAR 50) | 대시보드 위 카드 배치 (위치/크기) |
|
||||
| `USER_OVERRIDES` | OVERRIDE_ID (VARCHAR 50) | 사용자별 카드 오버라이드 (JSONB) |
|
||||
|
||||
**DASHBOARDS 컬럼**: DASHBOARD_ID, NAME, ICON, DISPLAY_ORDER, COMPANY_CODE, USER_ID, IS_ACTIVE, CREATED_BY, CREATED_DATE, UPDATED_BY, UPDATED_DATE
|
||||
|
||||
**DASHBOARD_CARDS 컬럼**: CARD_ID, DASHBOARD_ID, TEMPLATE_ID, POSITION_X, POSITION_Y, WIDTH, HEIGHT, IS_COLLAPSED, DISPLAY_ORDER, IS_ACTIVE, CREATED_DATE, UPDATED_DATE
|
||||
|
||||
**USER_OVERRIDES 컬럼**: OVERRIDE_ID, USER_ID, CARD_ID, OVERRIDES(JSONB), CREATED_DATE, UPDATED_DATE + `UNIQUE(USER_ID, CARD_ID)`
|
||||
|
||||
**인덱스**: `idx_dashboards_company`, `idx_dashboards_user`, `idx_dcards_dashboard`
|
||||
|
||||
★ DB FK 제약조건 안 걸음 (앱 레벨 관리, Phase 4 설계서 Section 3 참조)
|
||||
|
||||
---
|
||||
|
||||
## 6. 덕일 스타일 / 프로젝트 컨벤션 준수
|
||||
|
||||
| 규칙 | 준수 |
|
||||
|---|---|
|
||||
| 3레이어 (Controller → Service → XML) | ✅ |
|
||||
| Mapper Interface 금지 | ✅ — sqlSession 직접 호출 |
|
||||
| Map<String, Object> 사용, DTO 금지 | ✅ — ApiResponse만 예외 |
|
||||
| BaseService 상속 | ✅ |
|
||||
| @Autowired CommonService | ✅ |
|
||||
| XML 파일명: 소문자, Mapper 안 붙임 | ✅ — `dashboard.xml`, `userOverride.xml` |
|
||||
| XML namespace: 파일명과 동일 | ✅ |
|
||||
| SQL: UPPER_SNAKE | ✅ |
|
||||
| SELECT 쉼표: 앞에 | ✅ |
|
||||
| #{파라미터}: snake_case | ✅ |
|
||||
| OGNL test: 바깥 작은따옴표 | ✅ |
|
||||
| 프론트 타입: Record<string, any> | ✅ — FieldConfig만 예외 |
|
||||
| v5 CSS 토큰 사용, 즉흥 값 금지 | ✅ |
|
||||
| 컴팩트 폰트 사이즈 (0.55~0.85rem) | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 7. 검증 결과
|
||||
|
||||
| 검증 항목 | 결과 |
|
||||
|---|---|
|
||||
| `./gradlew compileJava` | BUILD SUCCESSFUL |
|
||||
| `npx tsc --noEmit` (Phase 4 파일) | 에러 0개 |
|
||||
| 기존 VEX 코드 에러 | Phase 4와 무관 (기존 camelCase/snake_case 불일치 에러) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 접속 경로
|
||||
|
||||
- 대시보드 메뉴 시스템: `/dash`
|
||||
- 개발자 빌더: `/admin/builder` (Phase 3)
|
||||
- 테스트 페이지: `/test-fc` (Phase 2)
|
||||
|
||||
---
|
||||
|
||||
## 9. Phase 4 범위 밖 (다음에 해야 할 것)
|
||||
|
||||
| 항목 | 담당 Phase | 비고 |
|
||||
|---|---|---|
|
||||
| AppLayout 사이드바 통합 (대시보드 목록을 기존 사이드바에 표시) | Phase 4+ | 현재는 `/dash` 페이지에 자체 사이드바 |
|
||||
| 대시보드 순서 드래그 재정렬 | Phase 4+ | 현재 display_order는 수동 |
|
||||
| Template 게시 워크플로우 (빌더에서 publish → 라이브러리에 표시) | Phase 3+ | 현재 빌더 publish API는 구현됨 |
|
||||
| 카드 간 DataPort 연결 (제어 모드) | Phase 5 | 대시보드 캔버스 위에 오버레이 |
|
||||
| 사용자 오버라이드 실시간 반영 (FcTable에 override 적용) | Phase 4+ | 현재 settings에서 toggle만 저장 |
|
||||
| 카드 미니 KPI 뷰 (접었을 때 실제 데이터 집계) | Phase 4+ | 현재 템플릿 이름/분류만 표시 |
|
||||
| 대시보드 아이콘 선택 UI | Phase 4+ | 현재 기본 📋 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 사용 흐름
|
||||
|
||||
```
|
||||
1. /dash 접속
|
||||
2. 좌측 사이드바: 대시보드 목록 표시
|
||||
3. "+ 새 대시보드" → 이름 입력 → 사이드바에 자동 등록
|
||||
4. 대시보드 클릭 → 캔버스 표시
|
||||
5. "편집" 버튼 → 편집 모드 (드래그/리사이즈)
|
||||
6. "+ 템플릿 추가" → 라이브러리 모달 → 게시된 Template 선택
|
||||
7. → 캔버스에 카드 추가 (FcTable/FcSearch가 실제 DB 데이터 표시)
|
||||
8. 카드 ⚙ → 컬럼 ON/OFF (사용자 오버라이드)
|
||||
9. 카드 ▼ → 접기 (미니 뷰)
|
||||
10. "저장" → DB에 카드 위치/크기 일괄 저장
|
||||
11. 다른 대시보드 클릭 → 전환 (편집 모드 자동 해제)
|
||||
```
|
||||
@@ -0,0 +1,357 @@
|
||||
# Phase 5: 제어 모드 — 비즈니스 룰 / 데이터 흐름
|
||||
|
||||
> **목적**: 대시보드의 카드(Template) 간 비즈니스 룰과 데이터 흐름을 시각적으로 정의하는 제어 모드 구현
|
||||
> **전제 조건**: Phase 1~4 완료 (DB 메타 + 컴포넌트 + 빌더 + 대시보드에 카드 배치까지 동작)
|
||||
> **산출물**: 제어 모드 UI (SVG 연결선 + 테이블 노드 + 규칙 빌더) + 비즈니스 룰 엔진
|
||||
> **다음 단계**: Phase 6 (자동생성/프리셋 — 편의 기능, 맨 마지막)
|
||||
|
||||
---
|
||||
|
||||
## 1. 핵심 개념
|
||||
|
||||
**제어 모드 = 같은 캔버스에서 "데이터가 어떻게 흐르는지" 보고 편집하는 모드**
|
||||
|
||||
일반 모드: 카드(화면)를 사용
|
||||
제어 모드: 카드 간 관계/비즈니스 룰을 설정
|
||||
|
||||
```
|
||||
[수주관리 카드] ──수주 확정──→ [발주관리 카드] ──금액>1000만──→ [프로젝트 카드]
|
||||
(자동 등록) (조건분기)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 두 가지 기능
|
||||
|
||||
### 2.1 읽기 모드 — 흐름 시각화 (카드 클릭)
|
||||
|
||||
카드 클릭 → 해당 카드의 데이터 소스(테이블) + 관련 테이블 + 비즈니스 룰이 트리 형태로 표시
|
||||
|
||||
mockup 참조: `js/06-control-mode.js`의 `showCardFlow()`, `enterControlMode()`
|
||||
|
||||
### 2.2 편집 모드 — 규칙 빌더 (드래그앤드롭)
|
||||
|
||||
팔레트에서 테이블 노드 + 제어 노드를 캔버스에 드래그, I/O 포트로 연결
|
||||
|
||||
mockup 참조: `js/07-rule-builder.js`의 `dropTable()`, `dropControl()`, `initPortEvents()`
|
||||
|
||||
---
|
||||
|
||||
## 3. 읽기 모드 상세
|
||||
|
||||
### 3.1 진입
|
||||
|
||||
캔버스 상단 툴바의 **`⚡ 제어`** 버튼 클릭 → 제어 모드 진입
|
||||
|
||||
### 3.2 시각적 변화
|
||||
|
||||
1. 캔버스 격자가 시안 톤으로 변경 (`rgba(0,206,201,.22)`)
|
||||
2. 모든 카드 반투명 (opacity 0.5)
|
||||
3. 편집 모드 자동 비활성화
|
||||
|
||||
### 3.3 카드 클릭 → 흐름 표시
|
||||
|
||||
1. 클릭된 카드만 좌측 고정 + opacity 1, 나머지 fade out (0.08)
|
||||
2. 카드의 `data-source-table`에서 소스 테이블 추출
|
||||
3. Phase 1의 `GET /api/meta/tables/{tableName}/relations`로 관계 조회
|
||||
4. BFS로 도달 가능한 전체 체인 계산
|
||||
5. **트리 확산 애니메이션**: 선이 그려짐 → 노드 reveal → 또 선 → 또 노드 (depth별 지연)
|
||||
|
||||
### 3.4 시각 요소
|
||||
|
||||
**테이블 노드** (mockup: `.tbl-node`):
|
||||
```
|
||||
┌─ 🏢 DEPARTMENT ─── 4컬럼 ──┐
|
||||
│ ● dept_code VARCHAR PK │
|
||||
│ ○ dept_name VARCHAR │
|
||||
│ ○ company_code VARCHAR FK │
|
||||
│ ○ parent_dept VARCHAR FK │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
**연결선 4종** (SVG bezier):
|
||||
|
||||
| 타입 | 색상 | CSS 클래스 | 용도 |
|
||||
|---|---|---|---|
|
||||
| 소스 | 핑크 (#fd79a8) | `ctrl-line-tpl` | 카드 → 소스 테이블 |
|
||||
| 자동실행 | 보라 (#6c5ce7) | `ctrl-line-auto` | 테이블 A → 테이블 B 자동 등록 |
|
||||
| 조건분기 | 앰버 (#fdcb6e) | `ctrl-line-cond` | 조건 충족 시 실행 |
|
||||
| FK | 시안 (#00cec9) | `ctrl-line` | 외래키 관계 (기본 비표시, 비즈니스 룰만) |
|
||||
|
||||
**연결선 위 뱃지** (mockup: `.ctrl-badge`):
|
||||
- 클릭 가능, 해당 룰의 상세 정보 표시
|
||||
- 조건분기 뱃지: Yes/No 분기 경로 표시
|
||||
|
||||
### 3.5 라이트 모드 보정
|
||||
|
||||
라이트 모드에서 연결선은 더 진하게 (배경 대비):
|
||||
- 시안 → `#00a89e`, 보라 → `#5b4acf`, 앰버 → `#d4a017`, 핑크 → `#e0559e`
|
||||
|
||||
---
|
||||
|
||||
## 4. 규칙 빌더 상세
|
||||
|
||||
### 4.1 제어 노드 16종
|
||||
|
||||
mockup의 `CTRL_NODE_TYPES` (js/07-rule-builder.js):
|
||||
|
||||
| 카테고리 | 노드 | 아이콘 | RGB | 특수 출력 포트 |
|
||||
|---|---|---|---|---|
|
||||
| 트리거 | 타이머 | ⏱ | 0,206,201 | — |
|
||||
| 조건 | 조건분기 | ◇ | 253,203,110 | Yes/No |
|
||||
| 조건 | 데이터 검증 | ✔ | 255,107,129 | Pass/Fail |
|
||||
| 액션 | 상태 변경 | 🔄 | 108,92,231 | — |
|
||||
| 액션 | 자동 등록 | 📝 | 85,239,196 | — |
|
||||
| 액션 | 계산/수식 | 🧮 | 45,152,218 | — |
|
||||
| 액션 | 삭제/보관 | 🗑 | 255,71,87 | — |
|
||||
| 액션 | 문서 생성 | 📄 | 162,155,254 | — |
|
||||
| 흐름 | 승인/결재 | ✋ | 255,165,2 | Approved/Rejected |
|
||||
| 흐름 | 대기/지연 | ⏳ | 72,219,251 | — |
|
||||
| 흐름 | 반복 | 🔁 | 223,142,254 | Each/Done |
|
||||
| 흐름 | 병렬 실행 | 🔀 | 0,206,201 | — |
|
||||
| 흐름 | 병합/합류 | ⤵ | 149,175,192 | — |
|
||||
| 연동 | 외부 호출 | 🌐 | 116,185,255 | — |
|
||||
| 연동 | 알림 발송 | 📨 | 253,121,168 | — |
|
||||
| 기록 | 로그 기록 | 📜 | 150,150,160 | — |
|
||||
|
||||
### 4.2 노드 구조
|
||||
|
||||
```
|
||||
┌─ [In] ──────────────────── [Out] ──┐
|
||||
│ 📝 자동 등록 │
|
||||
│ ───────────────────────── │
|
||||
│ 클릭하여 설정 │
|
||||
│ (대상 테이블, 필드 매핑) │ [Yes]
|
||||
│ │ [No]
|
||||
└────────────────────────────────────┘
|
||||
```
|
||||
|
||||
각 노드에 I/O 포트:
|
||||
- **Input 포트** (좌측): 데이터/이벤트를 받음
|
||||
- **Output 포트** (우측): 결과를 내보냄
|
||||
- 조건분기/승인 등은 다중 output (Yes/No)
|
||||
|
||||
### 4.3 포트 연결 인터랙션
|
||||
|
||||
1. output 포트 mousedown → 연결선 드래그 시작 (SVG 임시선)
|
||||
2. input 포트 위에서 mouseup → 연결 완료
|
||||
3. 연결 중간에 삭제 뱃지 (hover 시 표시)
|
||||
4. 같은 노드끼리 연결 금지, 중복 연결 금지
|
||||
|
||||
### 4.4 노드 설정 팝오버
|
||||
|
||||
노드 body 클릭 → 설정 팝오버:
|
||||
|
||||
| 노드 타입 | 설정 항목 |
|
||||
|---|---|
|
||||
| 자동 등록 | 대상 테이블, 필드 매핑 (소스→대상), 조건 |
|
||||
| 상태 변경 | 대상 테이블, 대상 필드, 변경값 |
|
||||
| 조건분기 | 조건식 (필드, 연산자, 값) |
|
||||
| 승인/결재 | 결재선, 승인자 |
|
||||
| 타이머 | 실행 주기, 시작 조건 |
|
||||
| 외부 호출 | URL, 메서드, 파라미터 매핑 |
|
||||
| 알림 발송 | 대상 (사용자/이메일/슬랙), 메시지 템플릿 |
|
||||
| 계산/수식 | 수식 (computed 문법), 대상 필드 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 데이터 모델
|
||||
|
||||
### 5.1 비즈니스 룰 DB
|
||||
|
||||
**`business_rules` 테이블 (신규 생성):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE business_rules (
|
||||
rule_id VARCHAR(50) PRIMARY KEY,
|
||||
dashboard_id VARCHAR(50), -- ★ DB FK 제약조건 안 걸음 (앱 레벨 관리)
|
||||
name VARCHAR(200),
|
||||
description TEXT,
|
||||
nodes JSONB NOT NULL, -- 노드 배열 (위치, 타입, 설정)
|
||||
connections JSONB NOT NULL, -- 연결 배열 (from → to)
|
||||
is_enabled BOOLEAN DEFAULT TRUE,
|
||||
company_code VARCHAR(20) NOT NULL,
|
||||
created_by VARCHAR(50),
|
||||
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by VARCHAR(50),
|
||||
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active VARCHAR(1) DEFAULT 'Y'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_brules_dashboard ON business_rules(dashboard_id);
|
||||
```
|
||||
|
||||
### 5.2 노드 JSON 구조
|
||||
|
||||
```
|
||||
★ 별도 인터페이스 정의 안 함 — Record<string, any> 사용
|
||||
★ JSONB로 저장되므로 구조만 문서화
|
||||
|
||||
노드 (Record<string, any>):
|
||||
id, type(CTRL_NODE_TYPES 키 또는 'table'), label, x, y,
|
||||
config(타입별 설정), table_name(table 노드일 때)
|
||||
|
||||
연결 (Record<string, any>):
|
||||
id, from_node_id, from_port('out'|'yes'|'no'|'each'|'done'),
|
||||
to_node_id, to_port('in')
|
||||
|
||||
비즈니스 룰 (Record<string, any>):
|
||||
rule_id, dashboard_id, name, nodes(배열), connections(배열), is_enabled
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 백엔드 API
|
||||
|
||||
### 6.1 관계 조회 (★ table_relationships 기반 — Phase 1과 소스 분리)
|
||||
|
||||
```
|
||||
GET /api/meta/tables/{tableName}/relations → 테이블 간 업무 관계
|
||||
```
|
||||
|
||||
**★ 2소스 책임 분리:**
|
||||
- Phase 1 `getTableFields()` → `table_type_columns` (필드 레벨 entity 참조 → FieldConfig.ref)
|
||||
- Phase 5 `getMetaRelations()` → `table_relationships` (테이블 간 업무 관계 → 제어 모드 흐름)
|
||||
- 제어 모드에서 두 소스 필요하면 **서비스 레이어에서 읽기 시점에만 합친다** (저장 구조 통합 안 함)
|
||||
|
||||
### 6.2 비즈니스 룰 CRUD
|
||||
|
||||
```
|
||||
GET /api/dashboards/{dashboard_id}/rules → 해당 대시보드의 룰 목록
|
||||
GET /api/rules/{rule_id} → 룰 상세 (노드+연결)
|
||||
POST /api/dashboards/{dashboard_id}/rules → 룰 생성
|
||||
PUT /api/rules/{rule_id} → 룰 수정
|
||||
DELETE /api/rules/{rule_id} → 룰 삭제
|
||||
PUT /api/rules/{rule_id}/toggle → 활성/비활성 토글
|
||||
```
|
||||
|
||||
**★ 덕일 스타일 3레이어. 파일명 1:1 매칭.**
|
||||
|
||||
| Java 파일 | XML | namespace |
|
||||
|---|---|---|
|
||||
| `BusinessRuleController.java` | `businessRule.xml` | `businessRule` |
|
||||
| `BusinessRuleService.java` | | |
|
||||
|
||||
```java
|
||||
@Service
|
||||
@Slf4j
|
||||
public class BusinessRuleService extends BaseService {
|
||||
|
||||
@Autowired
|
||||
private CommonService commonService;
|
||||
|
||||
public Map<String, Object> getBusinessRuleList(Map<String, Object> params) {
|
||||
commonService.applyPagination(params);
|
||||
int totalCount = sqlSession.selectOne("businessRule.getBusinessRuleListCnt", params);
|
||||
List<Map<String, Object>> list = sqlSession.selectList("businessRule.getBusinessRuleList", params);
|
||||
return commonService.buildListResponse(list, totalCount, params);
|
||||
}
|
||||
|
||||
public Map<String, Object> getBusinessRuleInfo(Map<String, Object> params) {
|
||||
return sqlSession.selectOne("businessRule.getBusinessRuleInfo", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertBusinessRule(Map<String, Object> params) {
|
||||
// nodes, connections → ObjectMapper로 JSON 문자열 변환 후 #{nodes}::jsonb
|
||||
sqlSession.insert("businessRule.insertBusinessRule", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateBusinessRule(Map<String, Object> params) {
|
||||
sqlSession.update("businessRule.updateBusinessRule", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int deleteBusinessRule(Map<String, Object> params) {
|
||||
return sqlSession.update("businessRule.deleteBusinessRule", params);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 프론트엔드 구현
|
||||
|
||||
### 7.1 파일 구조
|
||||
|
||||
```
|
||||
frontend/components/control/
|
||||
├── ControlMode.tsx ← 제어 모드 오버레이 (캔버스 위에)
|
||||
├── ControlToolbar.tsx ← 제어 모드 툴바 (읽기/편집 전환)
|
||||
├── FlowViewer.tsx ← 읽기 모드: 카드 흐름 시각화
|
||||
├── RuleBuilder.tsx ← 편집 모드: 규칙 빌더
|
||||
├── TableNode.tsx ← 테이블 노드 UI
|
||||
├── ControlNode.tsx ← 제어 노드 UI (16종)
|
||||
├── NodeConfigPopover.tsx ← 노드 설정 팝오버
|
||||
├── PortHandle.tsx ← I/O 포트 (드래그 연결)
|
||||
├── ConnectionLine.tsx ← SVG bezier 연결선
|
||||
├── ControlPalette.tsx ← 제어 모드 팔레트 (사이드바 교체)
|
||||
└── hooks/
|
||||
├── useControlMode.ts ← 제어 모드 상태 관리
|
||||
├── usePortDrag.ts ← 포트 연결 드래그 로직
|
||||
└── useFlowAnimation.ts ← 트리 확산 애니메이션
|
||||
```
|
||||
|
||||
### 7.2 SVG 연결선 렌더링
|
||||
|
||||
mockup의 bezier 곡선 방식 그대로:
|
||||
|
||||
```typescript
|
||||
// from 좌표 (x1,y1) → to 좌표 (x2,y2)
|
||||
const dx = x2 - x1;
|
||||
const d = `M${x1},${y1} C${x1+dx*0.5},${y1} ${x1+dx*0.5},${y2} ${x2},${y2}`;
|
||||
// SVG <path d={d} class="ctrl-line-auto" marker-end="url(#arr-auto)" />
|
||||
```
|
||||
|
||||
선이 그려지는 애니메이션: `stroke-dashoffset` transition
|
||||
|
||||
---
|
||||
|
||||
## 8. 스타일
|
||||
|
||||
제어 모드 전용 스타일은 mockup의 `css/07-control-mode.css` + `css/08-rule-builder.css` 참조.
|
||||
|
||||
핵심:
|
||||
- 캔버스 격자: 시안 톤 (`rgba(0,206,201,.22)`)
|
||||
- 테이블 노드: 시안 보더 + 글래스 배경
|
||||
- 연결선: 점선 + 펄스 애니메이션 (`stroke-dasharray: 6 3`)
|
||||
- 뱃지: 글래스 + 시안/보라/앰버 보더
|
||||
|
||||
---
|
||||
|
||||
## 9. 참고 파일
|
||||
|
||||
| 파일 | 용도 |
|
||||
|---|---|
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/js/06-control-mode.js` | **흐름 시각화 (진실의 원천)** — 996줄, enterControlMode, showCardFlow, buildCtrlTree, calcFlowPositions |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/js/07-rule-builder.js` | **규칙 빌더 (진실의 원천)** — 752줄, 16종 노드, 포트 드래그, 연결 관리, 설정 팝오버 |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/css/07-control-mode.css` | 제어 모드 스타일 |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/css/08-rule-builder.css` | 규칙 빌더 스타일 |
|
||||
| `notes/gbpark/2026-04-09-invyone-architecture.md` (Section 9 Phase 3) | 제어 플로우 로드맵 |
|
||||
| `notes/gbpark/2026-04-08-lowcode-platform-spec.md` | 비즈니스 룰 정의 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 완료 기준
|
||||
|
||||
1. **제어 모드 진입/탈출**: ⚡ 버튼으로 토글, 캔버스 시각이 변함
|
||||
2. **카드 클릭 → 흐름 표시**: 소스 테이블 + 관련 테이블 + 비즈니스 룰이 트리로 표시
|
||||
3. **트리 확산 애니메이션**: 선 → 노드 순서로 연쇄 등장
|
||||
4. **4종 연결선**: 소스(핑크), 자동(보라), 조건(앰버), FK(시안) 구분
|
||||
5. **팔레트에서 노드 드래그앤드롭**: 테이블 노드 + 제어 노드 캔버스에 배치
|
||||
6. **포트 연결**: output → input 드래그로 연결, SVG bezier 곡선
|
||||
7. **노드 설정 팝오버**: 타입별 설정 폼이 동작
|
||||
8. **규칙 저장/로드**: DB에 저장하고 다시 열면 복원
|
||||
9. **빈 영역 클릭 → 흐름 닫기**
|
||||
10. **다크/라이트 모드 지원**
|
||||
|
||||
---
|
||||
|
||||
## 11. 다음 단계 연결
|
||||
|
||||
Phase 6 (자동생성/프리셋):
|
||||
- 테이블 선택 → FieldConfig 기반으로 Template 자동 생성
|
||||
- 프리셋 3종 (basic/split/tabs) 자동 배치
|
||||
- 이 시점에서 모든 기능이 갖춰져 있으므로, 자동생성이 올바른 Template JSON을 생성할 수 있음
|
||||
@@ -0,0 +1,368 @@
|
||||
# Phase 5 구현 작업기록 — 제어 모드 (비즈니스 룰 / 데이터 흐름)
|
||||
|
||||
> **작업일**: 2026-04-10
|
||||
> **설계서**: `notes/gbpark/2026-04-10-phase5-control-mode.md`
|
||||
> **상태**: 구현 완료 + 빌드 검증 통과 + DB 테이블 생성 완료
|
||||
|
||||
---
|
||||
|
||||
## 1. 생성/수정된 파일 (22개)
|
||||
|
||||
### 1.1 백엔드 (6개)
|
||||
|
||||
| 파일 | 상태 | 역할 |
|
||||
|---|---|---|
|
||||
| `backend-spring/src/main/resources/mapper/meta.xml` | 수정 | `getMetaRelations` 쿼리 추가 (table_relationships) |
|
||||
| `backend-spring/src/main/java/com/erp/service/MetaService.java` | 수정 | `getMetaRelations()` 메서드 추가 |
|
||||
| `backend-spring/src/main/java/com/erp/controller/MetaController.java` | 수정 | `GET /api/meta/tables/{tableName}/relations` 엔드포인트 |
|
||||
| `backend-spring/src/main/resources/mapper/businessRule.xml` | 신규 | 비즈니스 룰 CRUD 7쿼리 (namespace=`businessRule`) |
|
||||
| `backend-spring/src/main/java/com/erp/service/BusinessRuleService.java` | 신규 | 비즈니스 룰 서비스 (JSONB 파싱/직렬화) |
|
||||
| `backend-spring/src/main/java/com/erp/controller/BusinessRuleController.java` | 신규 | 룰 CRUD + 토글 엔드포인트 6개 |
|
||||
|
||||
### 1.2 프론트엔드 (16개)
|
||||
|
||||
| 파일 | 역할 |
|
||||
|---|---|
|
||||
| `frontend/lib/api/meta.ts` | `getMetaRelations()` 추가 |
|
||||
| `frontend/lib/api/businessRule.ts` | 비즈니스 룰 CRUD API 클라이언트 (신규) |
|
||||
| `frontend/styles/control-mode.css` | 제어 모드 + 규칙 빌더 CSS (~350줄, mockup 포팅) |
|
||||
| `frontend/components/control/hooks/useControlMode.ts` | Zustand 상태관리 + `CTRL_NODE_TYPES` 16종 |
|
||||
| `frontend/components/control/hooks/useFlowAnimation.ts` | BFS 체인 + 위치 계산 + 애니메이션 타이밍 |
|
||||
| `frontend/components/control/hooks/usePortDrag.ts` | 포트 연결 드래그 로직 |
|
||||
| `frontend/components/control/TableNode.tsx` | 테이블 노드 UI (컬럼 목록 + 드래그) |
|
||||
| `frontend/components/control/ConnectionLine.tsx` | SVG bezier 연결선 4종 + 마커 + 뱃지 |
|
||||
| `frontend/components/control/FlowViewer.tsx` | 읽기 모드: 카드 클릭 → 흐름 시각화 |
|
||||
| `frontend/components/control/ControlNode.tsx` | 제어 노드 16종 (I/O 포트 포함) |
|
||||
| `frontend/components/control/PortHandle.tsx` | I/O 포트 핸들 (드래그 연결 시작/끝) |
|
||||
| `frontend/components/control/NodeConfigPopover.tsx` | 노드 설정 팝오버 (타입별 폼) |
|
||||
| `frontend/components/control/ControlPalette.tsx` | 제어 팔레트 (사이드바 교체) |
|
||||
| `frontend/components/control/RuleBuilder.tsx` | 편집 모드: 규칙 빌더 (드래그앤드롭) |
|
||||
| `frontend/components/control/ControlToolbar.tsx` | 읽기/편집 모드 전환 + 저장 버튼 |
|
||||
| `frontend/components/control/ControlMode.tsx` | 제어 모드 오버레이 메인 컴포넌트 |
|
||||
|
||||
### 1.3 기존 파일 수정 (3개)
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
|---|---|
|
||||
| `frontend/components/dash/DashboardToolbar.tsx` | ⚡ 제어 모드 토글 버튼 추가 (제어 진입 시 편집 모드 자동 해제) |
|
||||
| `frontend/components/dash/DashboardLayout.tsx` | `ControlMode` 오버레이 통합 + 제어 편집 시 사이드바→팔레트 교체 |
|
||||
| `frontend/components/dash/DashboardCanvas.tsx` | `forwardRef` + `control-mode` CSS 클래스 + 제어 모드 시 드래그 비활성화 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 백엔드 상세
|
||||
|
||||
### 2.1 Meta Relations API (2소스 책임 분리)
|
||||
|
||||
```
|
||||
GET /api/meta/tables/{tableName}/relations → table_relationships 기반
|
||||
```
|
||||
|
||||
**★ Phase 1과 소스 분리:**
|
||||
- Phase 1 `getMetaFields()` → `table_type_columns` (필드 레벨 entity 참조 → FieldConfig.ref)
|
||||
- Phase 5 `getMetaRelations()` → `table_relationships` (테이블 간 업무 관계 → 제어 모드 흐름)
|
||||
- 제어 모드에서 두 소스 필요하면 **서비스 레이어에서 읽기 시점에만 합침**
|
||||
|
||||
### 2.2 BusinessRule CRUD (덕일 스타일 3레이어)
|
||||
|
||||
| 엔드포인트 | 메서드 | 설명 |
|
||||
|---|---|---|
|
||||
| `/api/dashboards/{id}/rules` | GET | 대시보드별 룰 목록 (페이지네이션) |
|
||||
| `/api/dashboards/{id}/rules` | POST | 룰 생성 (`rule_` + UUID 12자) |
|
||||
| `/api/rules/{id}` | GET | 룰 상세 (JSONB → Object 파싱) |
|
||||
| `/api/rules/{id}` | PUT | 룰 수정 (Object → JSON 직렬화) |
|
||||
| `/api/rules/{id}` | DELETE | 룰 소프트 삭제 (IS_ACTIVE='D') |
|
||||
| `/api/rules/{id}/toggle` | PUT | 활성/비활성 토글 |
|
||||
|
||||
**businessRule.xml 쿼리 7개:**
|
||||
`getBusinessRuleList`, `getBusinessRuleListCnt`, `getBusinessRuleInfo`, `insertBusinessRule`, `updateBusinessRule`, `deleteBusinessRule`, `toggleBusinessRule`
|
||||
|
||||
**JSONB 처리**: `nodes`/`connections` → `ObjectMapper`로 파싱/직렬화 + `#{nodes}::jsonb` 캐스팅
|
||||
|
||||
---
|
||||
|
||||
## 3. 프론트엔드 상세
|
||||
|
||||
### 3.1 useControlMode — Zustand 상태관리 (~160줄)
|
||||
|
||||
| 상태 | 타입 | 설명 |
|
||||
|---|---|---|
|
||||
| `active` | boolean | 제어 모드 ON/OFF |
|
||||
| `mode` | 'view' \| 'edit' | 읽기/편집 모드 |
|
||||
| `activeFlowCardId` | string \| null | 흐름 표시 중인 카드 |
|
||||
| `flowEdges` | Record[] | BFS 결과 엣지 배열 |
|
||||
| `tablePositions` | Record | 테이블 노드 위치 |
|
||||
| `ruleNodes` | Record[] | 규칙 빌더 노드 |
|
||||
| `ruleConnections` | Record[] | 규칙 빌더 연결 |
|
||||
| `configNodeId` | string \| null | 설정 팝오버 대상 노드 |
|
||||
|
||||
**CTRL_NODE_TYPES 16종 (mockup 그대로):**
|
||||
|
||||
| 카테고리 | 노드 | 아이콘 | 특수 포트 |
|
||||
|---|---|---|---|
|
||||
| 트리거 | 타이머 | ⏱ | — |
|
||||
| 조건 | 조건분기 | ◇ | Yes/No |
|
||||
| 조건 | 데이터 검증 | ✔ | Pass/Fail |
|
||||
| 액션 | 상태 변경 | 🔄 | — |
|
||||
| 액션 | 자동 등록 | 📝 | — |
|
||||
| 액션 | 계산/수식 | 🧮 | — |
|
||||
| 액션 | 삭제/보관 | 🗑 | — |
|
||||
| 액션 | 문서 생성 | 📄 | — |
|
||||
| 흐름 | 승인/결재 | ✋ | Approved/Rejected |
|
||||
| 흐름 | 대기/지연 | ⏳ | — |
|
||||
| 흐름 | 반복 | 🔁 | Each/Done |
|
||||
| 흐름 | 병렬 실행 | 🔀 | — |
|
||||
| 흐름 | 병합/합류 | ⤵ | — |
|
||||
| 연동 | 외부 호출 | 🌐 | — |
|
||||
| 연동 | 알림 발송 | 📨 | — |
|
||||
| 기록 | 로그 기록 | 📜 | — |
|
||||
|
||||
### 3.2 FlowViewer — 읽기 모드 (카드 클릭 → 흐름 시각화)
|
||||
|
||||
**흐름:**
|
||||
1. 캔버스에서 카드 클릭
|
||||
2. `getMetaRelations(sourceTable)` → 업무 관계 조회
|
||||
3. BFS로 도달 가능한 전체 체인 계산 (depth 무제한)
|
||||
4. `calcFlowPositions()` — 카드 우측에 depth별 트리 배치 (colGap=270~350, rowGap=240)
|
||||
5. `calcAnimationTimings()` — depth별 지연 (STEP=500ms, NODE_D=350ms)
|
||||
6. **트리 확산 애니메이션**: 선 → 노드 순서로 연쇄 등장
|
||||
|
||||
**시각 효과:**
|
||||
- 선택된 카드: opacity 1 + 좌측 고정 + 시안 보더
|
||||
- 나머지 카드: opacity 0.08
|
||||
- 테이블 노드: scale(0.3) → scale(1) 트랜지션
|
||||
- 연결선: `stroke-dashoffset` draw 애니메이션 → pulse 복원
|
||||
- 빈 영역 클릭: 흐름 닫기 (모든 카드 0.5로 복원)
|
||||
- 노드 드래그: 실시간 위치 업데이트 + 선 재그리기
|
||||
|
||||
### 3.3 RuleBuilder — 편집 모드 (규칙 빌더)
|
||||
|
||||
**흐름:**
|
||||
1. 사이드바 → 제어 팔레트 교체 (DB 테이블 + 제어 노드 16종)
|
||||
2. 팔레트에서 캔버스로 드래그앤드롭 → 노드 생성
|
||||
3. 노드 헤더 드래그 → 이동
|
||||
4. output 포트 mousedown → bezier 임시선 → input 포트 mouseup → 연결 생성
|
||||
5. 연결 중간 hover → 삭제 뱃지 (✕)
|
||||
6. 노드 body 클릭 → 설정 팝오버 (타입별 폼)
|
||||
7. "규칙 저장" → `insertBusinessRule()` / `updateBusinessRule()`
|
||||
|
||||
**포트 연결 규칙:**
|
||||
- 같은 노드끼리 연결 금지
|
||||
- 중복 연결 금지
|
||||
- 드래그 중 모든 input 포트 pulse 애니메이션
|
||||
|
||||
### 3.4 SVG 연결선 4종
|
||||
|
||||
| 타입 | 색상 | CSS 클래스 | 마커 | 용도 |
|
||||
|---|---|---|---|---|
|
||||
| 소스 | 핑크 #fd79a8 | `ctrl-line-tpl` | arr-src | 카드 → 소스 테이블 |
|
||||
| 자동실행 | 보라 #6c5ce7 | `ctrl-line-auto` | arr-auto | 테이블 → 테이블 자동 등록 |
|
||||
| 조건분기 | 앰버 #fdcb6e | `ctrl-line-cond` | arr-cond | 조건 충족 시 실행 |
|
||||
| FK | 시안 #00cec9 | `ctrl-line` | arr-fk | 외래키 관계 |
|
||||
|
||||
**라이트 모드 보정**: 시안→#00a89e, 보라→#5b4acf, 앰버→#d4a017, 핑크→#e0559e
|
||||
|
||||
### 3.5 연결선 위 뱃지
|
||||
|
||||
- 일반: 글래스 배경 + 시안 보더 + 라벨 텍스트
|
||||
- 조건분기: 확장형 (`cb-head` + `cb-cond` + `cb-paths` Yes/No)
|
||||
- 소스: 핑크 보더
|
||||
- 자동실행: 보라 보더
|
||||
|
||||
### 3.6 NodeConfigPopover — 타입별 설정 폼
|
||||
|
||||
| 노드 타입 | 설정 항목 |
|
||||
|---|---|
|
||||
| 조건분기 | 필드, 연산자(=, ≠, >, <, 기한경과, 포함), 값 |
|
||||
| 상태 변경 | 대상 테이블, 변경 필드, 변경값 |
|
||||
| 자동 등록 | 대상 테이블 |
|
||||
| 타이머 | 기준 필드, 경과량, 단위(일/시간/주) |
|
||||
| 알림 발송 | 채널(이메일/SMS/푸시/Slack), 수신자, 메시지 |
|
||||
| 승인/결재 | 승인자, 승인 조건 |
|
||||
| 계산/수식 | 대상 테이블, 결과 필드, 수식 |
|
||||
| 외부 호출 | URL, 메서드(POST/GET/PUT/DELETE) |
|
||||
| 데이터 검증 | 대상 필드, 검증 규칙 |
|
||||
| 로그 기록 | 내용 |
|
||||
|
||||
### 3.7 Dashboard 통합
|
||||
|
||||
**DashboardToolbar:**
|
||||
- ⚡ 버튼 추가 (제어 모드 진입 시 편집 모드 자동 해제)
|
||||
- 제어 모드 활성 중: 편집/템플릿추가/저장 버튼 숨김
|
||||
|
||||
**DashboardLayout:**
|
||||
- `ControlMode` 오버레이 (ControlToolbar + FlowViewer/RuleBuilder)
|
||||
- 제어 편집 모드: 사이드바 → `ControlPalette` 교체
|
||||
|
||||
**DashboardCanvas:**
|
||||
- `forwardRef`로 부모에서 캔버스 DOM 참조
|
||||
- `control-mode` CSS 클래스 (시안 격자 배경)
|
||||
- 제어 모드 시 카드 드래그/리사이즈 비활성화
|
||||
|
||||
---
|
||||
|
||||
## 4. DB 변경
|
||||
|
||||
### 4.1 business_rules 테이블 (✅ 생성 완료)
|
||||
|
||||
```sql
|
||||
CREATE TABLE business_rules (
|
||||
rule_id VARCHAR(50) PRIMARY KEY,
|
||||
dashboard_id VARCHAR(50),
|
||||
name VARCHAR(200),
|
||||
description TEXT,
|
||||
nodes JSONB NOT NULL,
|
||||
connections JSONB NOT NULL,
|
||||
is_enabled BOOLEAN DEFAULT TRUE,
|
||||
company_code VARCHAR(20) NOT NULL,
|
||||
created_by VARCHAR(50),
|
||||
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by VARCHAR(50),
|
||||
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active VARCHAR(1) DEFAULT 'Y'
|
||||
);
|
||||
CREATE INDEX idx_brules_dashboard ON business_rules(dashboard_id);
|
||||
```
|
||||
|
||||
**실행 대상**: `211.115.91.141:11134/test_dev` (2026-04-10 실행 완료)
|
||||
★ DB FK 제약조건 안 걸음 (설계서 명시 — 앱 레벨 관리)
|
||||
|
||||
---
|
||||
|
||||
## 5. 덕일 스타일 / 프로젝트 컨벤션 준수
|
||||
|
||||
| 규칙 | 준수 |
|
||||
|---|---|
|
||||
| 3레이어 (Controller → Service → XML) | ✅ |
|
||||
| Mapper Interface 금지 | ✅ — sqlSession 직접 호출 |
|
||||
| Map<String, Object> 사용, DTO 금지 | ✅ — ApiResponse만 예외 |
|
||||
| BaseService 상속 | ✅ |
|
||||
| @Autowired CommonService | ✅ |
|
||||
| XML 파일명: 소문자, Mapper 안 붙임 | ✅ — `businessRule.xml` |
|
||||
| XML namespace: 파일명과 동일 | ✅ — `namespace="businessRule"` |
|
||||
| SQL: UPPER_SNAKE | ✅ |
|
||||
| SELECT 쉼표: 앞에 | ✅ |
|
||||
| #{파라미터}: snake_case | ✅ |
|
||||
| OGNL test: 바깥 작은따옴표 | ✅ |
|
||||
| 프론트 타입: Record<string, any> | ✅ — FieldConfig만 예외 |
|
||||
| v5/ctrl CSS 변수 사용, 즉흥 값 금지 | ✅ |
|
||||
| 컴팩트 폰트 사이즈 (0.42~0.68rem) | ✅ |
|
||||
| mockup 진실의 원천 | ✅ — 06-control-mode.js, 07-rule-builder.js |
|
||||
|
||||
---
|
||||
|
||||
## 6. CSS 토큰 사용 목록
|
||||
|
||||
| 용도 | 토큰 |
|
||||
|---|---|
|
||||
| 시안 | `--ctrl-cyan` (#00cec9), `--ctrl-cyan-glow` |
|
||||
| 보라 | `--ctrl-primary` (#6c5ce7) |
|
||||
| 앰버 | `--ctrl-amber` (#fdcb6e) |
|
||||
| 핑크 | `--ctrl-pink` (#fd79a8) |
|
||||
| 그린 | `--ctrl-green` (#55efc4) |
|
||||
| 레드 | `--ctrl-red` (#ff4757) |
|
||||
| 유리 배경 | `--ctrl-glass`, `--ctrl-glass-strong` |
|
||||
| 유리 보더 | `--ctrl-glass-border` |
|
||||
| 텍스트 | `var(--v5-text)`, `var(--v5-text-sec)`, `var(--v5-text-muted)` |
|
||||
| 보더 | `var(--v5-border)` |
|
||||
| 서피스 | `var(--v5-surface)`, `var(--v5-surface-hover)` |
|
||||
| 블러 | `backdrop-filter: blur(20px) saturate(1.4)` (v5 글래스 패턴) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 검증 결과
|
||||
|
||||
| 검증 항목 | 결과 |
|
||||
|---|---|
|
||||
| `./gradlew compileJava` | BUILD SUCCESSFUL |
|
||||
| `./gradlew bootJar` | BUILD SUCCESSFUL |
|
||||
| `npx tsc --noEmit` (Phase 5 파일) | 에러 0개 |
|
||||
| DB 테이블 생성 (test_dev) | CREATE TABLE + INDEX 성공 |
|
||||
| 기존 레거시 에러 | 2827개 (Phase 5 무관, 변동 없음) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 완료 기준 대비 상태
|
||||
|
||||
| # | 기준 | 상태 |
|
||||
|---|---|---|
|
||||
| 1 | ⚡ 버튼으로 제어 모드 토글 | ✅ |
|
||||
| 2 | 캔버스 시각 변화 (시안 격자, 카드 반투명) | ✅ |
|
||||
| 3 | 카드 클릭 → 흐름 표시 (소스 테이블 + 관련 테이블 + 비즈니스 룰 트리) | ✅ |
|
||||
| 4 | 트리 확산 애니메이션 (선→노드 순차 등장) | ✅ |
|
||||
| 5 | 4종 연결선 (소스/자동/조건/FK) | ✅ |
|
||||
| 6 | 팔레트에서 노드 드래그앤드롭 | ✅ |
|
||||
| 7 | 포트 연결 (output→input 드래그, bezier 곡선) | ✅ |
|
||||
| 8 | 노드 설정 팝오버 (타입별 폼) | ✅ |
|
||||
| 9 | 규칙 저장/로드 (DB JSONB) | ✅ |
|
||||
| 10 | 빈 영역 클릭 → 흐름 닫기 | ✅ |
|
||||
| 11 | 다크/라이트 모드 | ✅ (라이트 보정 CSS 포함) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 접속 경로
|
||||
|
||||
- **대시보드 (제어 모드 포함)**: `/dash` → ⚡ 버튼 클릭
|
||||
- 개발자 빌더: `/admin/builder` (Phase 3)
|
||||
- 테스트 페이지: `/test-fc` (Phase 2)
|
||||
|
||||
---
|
||||
|
||||
## 10. Phase 5 범위 밖 (다음에 해야 할 것)
|
||||
|
||||
| 항목 | 비고 |
|
||||
|---|---|
|
||||
| `table_relationships` 테이블에 실제 데이터 INSERT | 현재 빈 테이블이면 흐름이 소스→1개만 표시 |
|
||||
| 비즈니스 룰 실행 엔진 (트리거/조건 평가/액션 실행) | 현재는 시각 편집만 |
|
||||
| 규칙 로드 UI (기존 규칙 목록에서 선택 → 복원) | 현재는 새 규칙 생성만 |
|
||||
| computed 수식 파서 (AST 기반 안전한 파서) | Phase 5+ |
|
||||
| DataPort 이벤트 버스 (카드 간 실시간 데이터 전달) | Phase 5+ |
|
||||
| 자동생성/프리셋 (Phase 6) | 모든 기능 갖춰진 후 마지막 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 파일별 코드 요약
|
||||
|
||||
### businessRule.xml (107줄)
|
||||
```
|
||||
7개 쿼리: getBusinessRuleList, getBusinessRuleListCnt, getBusinessRuleInfo,
|
||||
insertBusinessRule, updateBusinessRule, deleteBusinessRule, toggleBusinessRule
|
||||
common include: companyCodeFilter, dynamicOrderBy, pagination
|
||||
```
|
||||
|
||||
### BusinessRuleService.java (~95줄)
|
||||
```
|
||||
퍼블릭: getBusinessRuleList, getBusinessRuleInfo, insertBusinessRule,
|
||||
updateBusinessRule, deleteBusinessRule, toggleBusinessRule
|
||||
프라이빗: parseJsonField, stringifyJsonField (JSONB 유틸)
|
||||
```
|
||||
|
||||
### BusinessRuleController.java (~100줄)
|
||||
```
|
||||
6개 엔드포인트: GET/POST rules + GET/PUT/DELETE/toggle rule
|
||||
```
|
||||
|
||||
### useControlMode.ts (~160줄)
|
||||
```
|
||||
Zustand store: active, mode, flowEdges, ruleNodes, ruleConnections
|
||||
+ CTRL_NODE_TYPES 16종 정의 + genNodeId/genConnId 헬퍼
|
||||
```
|
||||
|
||||
### FlowViewer.tsx (~180줄)
|
||||
```
|
||||
카드 클릭 이벤트 → getMetaRelations → BFS → 위치 계산 → 순차 reveal
|
||||
TableNode + ConnectionSvg + FlowLine + FlowBadge 렌더
|
||||
```
|
||||
|
||||
### RuleBuilder.tsx (~180줄)
|
||||
```
|
||||
캔버스 드래그앤드롭 → 노드 생성 (테이블/제어)
|
||||
포트 연결 SVG + 삭제 뱃지 + NodeConfigPopover
|
||||
```
|
||||
|
||||
### control-mode.css (~350줄)
|
||||
```
|
||||
--ctrl-* 변수 체계, 연결선 4종 + pulse 애니메이션,
|
||||
테이블 노드, 제어 노드, I/O 포트, 설정 팝오버, 팔레트
|
||||
다크/라이트 모드 보정
|
||||
```
|
||||
Reference in New Issue
Block a user