This commit is contained in:
@@ -15,7 +15,7 @@ public class DashboardController {
|
||||
|
||||
private final DashboardService dashboardService;
|
||||
|
||||
// ═══ 대시보드 CRUD ═══
|
||||
// ═══ 대시보드 CRUD (MENU_INFO 단일 본체) ═══
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getDashboardList(
|
||||
@@ -33,11 +33,11 @@ public class DashboardController {
|
||||
return ResponseEntity.ok(ApiResponse.success(dashboardService.getDashboardList(params)));
|
||||
}
|
||||
|
||||
@GetMapping("/{dashboardId}")
|
||||
@GetMapping("/{objid}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getDashboardInfo(
|
||||
@PathVariable String dashboardId) {
|
||||
@PathVariable String objid) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("dashboard_id", dashboardId);
|
||||
params.put("objid", objid);
|
||||
Map<String, Object> result = dashboardService.getDashboardInfo(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.ok(ApiResponse.error("대시보드를 찾을 수 없습니다"));
|
||||
@@ -55,23 +55,23 @@ public class DashboardController {
|
||||
return ResponseEntity.ok(ApiResponse.success(dashboardService.insertDashboard(body)));
|
||||
}
|
||||
|
||||
@PutMapping("/{dashboardId}")
|
||||
@PutMapping("/{objid}")
|
||||
public ResponseEntity<ApiResponse<Void>> updateDashboard(
|
||||
@PathVariable String dashboardId,
|
||||
@PathVariable String objid,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("dashboard_id", dashboardId);
|
||||
body.put("objid", objid);
|
||||
body.put("user_id", userId);
|
||||
dashboardService.updateDashboard(body);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "수정 완료"));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{dashboardId}")
|
||||
@DeleteMapping("/{objid}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteDashboard(
|
||||
@PathVariable String dashboardId,
|
||||
@PathVariable String objid,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("dashboard_id", dashboardId);
|
||||
params.put("objid", objid);
|
||||
params.put("user_id", userId);
|
||||
dashboardService.deleteDashboard(params);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "삭제 완료"));
|
||||
@@ -79,36 +79,36 @@ public class DashboardController {
|
||||
|
||||
// ═══ 카드 CRUD ═══
|
||||
|
||||
@GetMapping("/{dashboardId}/cards")
|
||||
@GetMapping("/{objid}/cards")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getDashboardCards(
|
||||
@PathVariable String dashboardId) {
|
||||
@PathVariable String objid) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("dashboard_id", dashboardId);
|
||||
params.put("menu_objid", objid);
|
||||
return ResponseEntity.ok(ApiResponse.success(dashboardService.getDashboardCardList(params)));
|
||||
}
|
||||
|
||||
@PostMapping("/{dashboardId}/cards")
|
||||
@PostMapping("/{objid}/cards")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertDashboardCard(
|
||||
@PathVariable String dashboardId,
|
||||
@PathVariable String objid,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("dashboard_id", dashboardId);
|
||||
body.put("menu_objid", objid);
|
||||
return ResponseEntity.ok(ApiResponse.success(dashboardService.insertDashboardCard(body)));
|
||||
}
|
||||
|
||||
@PutMapping("/{dashboardId}/cards/{cardId}")
|
||||
@PutMapping("/{objid}/cards/{cardId}")
|
||||
public ResponseEntity<ApiResponse<Void>> updateDashboardCard(
|
||||
@PathVariable String dashboardId,
|
||||
@PathVariable String objid,
|
||||
@PathVariable String cardId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("dashboard_id", dashboardId);
|
||||
body.put("menu_objid", objid);
|
||||
body.put("card_id", cardId);
|
||||
dashboardService.updateDashboardCard(body);
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{dashboardId}/cards/{cardId}")
|
||||
@DeleteMapping("/{objid}/cards/{cardId}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteDashboardCard(
|
||||
@PathVariable String dashboardId,
|
||||
@PathVariable String objid,
|
||||
@PathVariable String cardId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("card_id", cardId);
|
||||
@@ -116,23 +116,11 @@ public class DashboardController {
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
@PutMapping("/{dashboardId}/cards/batch")
|
||||
@PutMapping("/{objid}/cards/batch")
|
||||
public ResponseEntity<ApiResponse<Void>> updateCardPositions(
|
||||
@PathVariable String dashboardId,
|
||||
@PathVariable String objid,
|
||||
@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,
|
||||
@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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ public class DashboardService extends BaseService {
|
||||
|
||||
private static final String NS = "dashboard.";
|
||||
|
||||
// ═══ 대시보드 CRUD ═══
|
||||
// ═══ 대시보드 CRUD (MENU_INFO 단일 본체) ═══
|
||||
|
||||
public Map<String, Object> getDashboardList(Map<String, Object> params) {
|
||||
commonService.applyPagination(params);
|
||||
@@ -32,17 +32,32 @@ public class DashboardService extends BaseService {
|
||||
|
||||
@Transactional
|
||||
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);
|
||||
String objid = String.valueOf(System.currentTimeMillis());
|
||||
params.put("objid", objid);
|
||||
params.put("writer", params.get("user_id"));
|
||||
|
||||
// 개인 대시보드 여부: is_personal=true 이면 USER_ID 채움, 아니면 NULL (회사 공용)
|
||||
Object isPersonal = params.get("is_personal");
|
||||
boolean personal = isPersonal != null
|
||||
&& (Boolean.TRUE.equals(isPersonal) || "true".equalsIgnoreCase(String.valueOf(isPersonal)));
|
||||
if (!personal) {
|
||||
params.put("user_id", null);
|
||||
}
|
||||
|
||||
// 회사별 자동 시퀀스 = URL. COMPANY_CODE 내 /숫자 형 URL 중 최대값 + 1
|
||||
Integer nextSeq = sqlSession.selectOne(NS + "getNextMenuSeq", params);
|
||||
if (nextSeq == null) nextSeq = 1;
|
||||
String menuUrl = "/" + nextSeq;
|
||||
params.put("seq", String.valueOf(nextSeq));
|
||||
params.put("menu_url", menuUrl);
|
||||
|
||||
sqlSession.insert(NS + "insertDashboard", params);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("dashboard_id", dashboardId);
|
||||
result.put("objid", objid);
|
||||
result.put("dashboard_id", objid); // 프론트 하위호환
|
||||
result.put("menu_url", menuUrl);
|
||||
result.put("seq", nextSeq);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -97,10 +112,4 @@ public class DashboardService extends BaseService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ 사이드바 메뉴 ═══
|
||||
|
||||
public List<Map<String, Object>> getSidebarMenu(Map<String, Object> params) {
|
||||
return sqlSession.selectList(NS + "getSidebarMenu", params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,6 +333,7 @@
|
||||
, LANG_KEY
|
||||
, LANG_KEY_DESC
|
||||
, MENU_ICON
|
||||
, USER_ID
|
||||
) VALUES (
|
||||
#{objid}
|
||||
, #{menu_type}
|
||||
@@ -348,6 +349,7 @@
|
||||
, #{lang_key}
|
||||
, #{lang_key_desc}
|
||||
, #{menu_icon}
|
||||
, #{user_id}
|
||||
)
|
||||
</insert>
|
||||
|
||||
@@ -382,6 +384,7 @@
|
||||
WHERE OBJID = #{menu_id}
|
||||
</update>
|
||||
|
||||
|
||||
<!-- ================================================================
|
||||
사용자 관리
|
||||
================================================================ -->
|
||||
|
||||
@@ -2,117 +2,134 @@
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="dashboard">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
대시보드 = 사용자 메뉴 (MENU_INFO 단일 본체)
|
||||
- MENU_TYPE='1' AND MENU_URL ~ '^/\d+$' (회사별 시퀀스 숫자 URL) 인 row 가 대시보드
|
||||
- 개인 대시보드: USER_ID = 로그인 사용자
|
||||
- 공용 대시보드: USER_ID IS NULL (같은 COMPANY_CODE 공유)
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- ═══ 대시보드 CRUD ═══ -->
|
||||
|
||||
<select id="getDashboardList" parameterType="map" resultType="map">
|
||||
SELECT DASHBOARD_ID
|
||||
, NAME
|
||||
, ICON
|
||||
, DISPLAY_ORDER
|
||||
SELECT OBJID
|
||||
, OBJID AS DASHBOARD_ID
|
||||
, MENU_NAME_KOR AS NAME
|
||||
, MENU_URL
|
||||
, MENU_ICON AS ICON
|
||||
, SEQ AS DISPLAY_ORDER
|
||||
, COMPANY_CODE
|
||||
, USER_ID
|
||||
, IS_ACTIVE
|
||||
, CREATED_BY
|
||||
, STATUS
|
||||
, WRITER AS CREATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_BY
|
||||
, UPDATED_DATE
|
||||
FROM DASHBOARDS
|
||||
WHERE IS_ACTIVE = 'Y'
|
||||
FROM MENU_INFO
|
||||
WHERE MENU_TYPE = '1'
|
||||
AND MENU_URL ~ '^/\d+$'
|
||||
AND STATUS = 'active'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
AND (USER_ID = #{user_id} OR USER_ID IS NULL)
|
||||
<if test='keyword != null and keyword != ""'>
|
||||
AND NAME LIKE CONCAT('%', #{keyword}, '%')
|
||||
AND MENU_NAME_KOR LIKE CONCAT('%', #{keyword}, '%')
|
||||
</if>
|
||||
ORDER BY DISPLAY_ORDER ASC, CREATED_DATE ASC
|
||||
ORDER BY CAST(COALESCE(NULLIF(SEQ, ''), '0') AS INTEGER) ASC, CREATED_DATE ASC
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getDashboardListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM DASHBOARDS
|
||||
WHERE IS_ACTIVE = 'Y'
|
||||
FROM MENU_INFO
|
||||
WHERE MENU_TYPE = '1'
|
||||
AND MENU_URL ~ '^/\d+$'
|
||||
AND STATUS = 'active'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
AND (USER_ID = #{user_id} OR USER_ID IS NULL)
|
||||
<if test='keyword != null and keyword != ""'>
|
||||
AND NAME LIKE CONCAT('%', #{keyword}, '%')
|
||||
AND MENU_NAME_KOR LIKE CONCAT('%', #{keyword}, '%')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getDashboardInfo" parameterType="map" resultType="map">
|
||||
SELECT DASHBOARD_ID
|
||||
, NAME
|
||||
, ICON
|
||||
, DISPLAY_ORDER
|
||||
SELECT OBJID
|
||||
, OBJID AS DASHBOARD_ID
|
||||
, MENU_NAME_KOR AS NAME
|
||||
, MENU_URL
|
||||
, MENU_ICON AS ICON
|
||||
, SEQ AS DISPLAY_ORDER
|
||||
, COMPANY_CODE
|
||||
, USER_ID
|
||||
, IS_ACTIVE
|
||||
, CREATED_BY
|
||||
, STATUS
|
||||
, WRITER AS CREATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_BY
|
||||
, UPDATED_DATE
|
||||
FROM DASHBOARDS
|
||||
WHERE DASHBOARD_ID = #{dashboard_id}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
FROM MENU_INFO
|
||||
WHERE OBJID = #{objid}
|
||||
AND MENU_TYPE = '1'
|
||||
AND STATUS = 'active'
|
||||
</select>
|
||||
|
||||
<!-- 회사별 다음 메뉴 시퀀스 (URL 식별자로 사용) -->
|
||||
<select id="getNextMenuSeq" parameterType="map" resultType="int">
|
||||
SELECT COALESCE(MAX(CAST(NULLIF(SEQ, '') AS INTEGER)), 0) + 1
|
||||
FROM MENU_INFO
|
||||
WHERE MENU_TYPE = '1'
|
||||
AND COMPANY_CODE = #{company_code}
|
||||
AND MENU_URL ~ '^/\d+$'
|
||||
</select>
|
||||
|
||||
<insert id="insertDashboard" parameterType="map">
|
||||
INSERT INTO DASHBOARDS (
|
||||
DASHBOARD_ID
|
||||
, NAME
|
||||
, ICON
|
||||
, DISPLAY_ORDER
|
||||
, COMPANY_CODE
|
||||
, USER_ID
|
||||
, IS_ACTIVE
|
||||
, CREATED_BY
|
||||
INSERT INTO MENU_INFO (
|
||||
OBJID
|
||||
, MENU_TYPE
|
||||
, PARENT_OBJ_ID
|
||||
, MENU_NAME_KOR
|
||||
, MENU_URL
|
||||
, SEQ
|
||||
, WRITER
|
||||
, CREATED_DATE
|
||||
, UPDATED_BY
|
||||
, UPDATED_DATE
|
||||
, STATUS
|
||||
, COMPANY_CODE
|
||||
, MENU_ICON
|
||||
, USER_ID
|
||||
) VALUES (
|
||||
#{dashboard_id}
|
||||
#{objid}
|
||||
, '1'
|
||||
, '0'
|
||||
, #{name}
|
||||
, #{icon}
|
||||
, #{display_order}
|
||||
, #{menu_url}
|
||||
, #{seq}
|
||||
, #{writer}
|
||||
, NOW()
|
||||
, 'active'
|
||||
, #{company_code}
|
||||
, COALESCE(#{icon}, '📋')
|
||||
, #{user_id}
|
||||
, 'Y'
|
||||
, #{user_id}
|
||||
, CURRENT_TIMESTAMP
|
||||
, #{user_id}
|
||||
, CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateDashboard" parameterType="map">
|
||||
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 MENU_INFO
|
||||
SET
|
||||
<if test='name != null'>MENU_NAME_KOR = #{name},</if>
|
||||
<if test='icon != null'>MENU_ICON = #{icon},</if>
|
||||
<if test='display_order != null'>SEQ = #{display_order},</if>
|
||||
OBJID = OBJID
|
||||
WHERE OBJID = #{objid}
|
||||
AND MENU_TYPE = '1'
|
||||
</update>
|
||||
|
||||
<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 MENU_INFO
|
||||
SET STATUS = 'inactive'
|
||||
WHERE OBJID = #{objid}
|
||||
AND MENU_TYPE = '1'
|
||||
</update>
|
||||
|
||||
<!-- ═══ 대시보드 카드 ═══ -->
|
||||
|
||||
<select id="getDashboardCardList" parameterType="map" resultType="map">
|
||||
SELECT DC.CARD_ID
|
||||
, DC.DASHBOARD_ID
|
||||
, DC.MENU_OBJID
|
||||
, DC.MENU_OBJID AS DASHBOARD_ID
|
||||
, DC.TEMPLATE_ID
|
||||
, DC.POSITION_X
|
||||
, DC.POSITION_Y
|
||||
@@ -130,7 +147,7 @@
|
||||
, 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}
|
||||
WHERE DC.MENU_OBJID = #{menu_objid}
|
||||
AND DC.IS_ACTIVE = 'Y'
|
||||
ORDER BY DC.DISPLAY_ORDER ASC, DC.CREATED_DATE ASC
|
||||
</select>
|
||||
@@ -138,7 +155,7 @@
|
||||
<insert id="insertDashboardCard" parameterType="map">
|
||||
INSERT INTO DASHBOARD_CARDS (
|
||||
CARD_ID
|
||||
, DASHBOARD_ID
|
||||
, MENU_OBJID
|
||||
, TEMPLATE_ID
|
||||
, POSITION_X
|
||||
, POSITION_Y
|
||||
@@ -151,7 +168,7 @@
|
||||
, UPDATED_DATE
|
||||
) VALUES (
|
||||
#{card_id}
|
||||
, #{dashboard_id}
|
||||
, #{menu_objid}
|
||||
, #{template_id}
|
||||
, #{position_x}
|
||||
, #{position_y}
|
||||
@@ -209,18 +226,4 @@
|
||||
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"/>
|
||||
AND (USER_ID = #{user_id} OR USER_ID IS NULL)
|
||||
ORDER BY DISPLAY_ORDER ASC, CREATED_DATE ASC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -31,10 +31,8 @@ services:
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: dev
|
||||
SERVER_PORT: 8081
|
||||
# test_dev DB (211.115.91.141:11134)
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://211.115.91.141:11134/test_dev
|
||||
SPRING_DATASOURCE_USERNAME: postgres
|
||||
SPRING_DATASOURCE_PASSWORD: vexplor0909!!
|
||||
# DB 연결 정보는 backend-spring/src/main/resources/application.yml 사용 (단일 소스).
|
||||
# env 로 override 하고 싶으면 여기에 SPRING_DATASOURCE_URL/USERNAME/PASSWORD 를 다시 추가.
|
||||
# JWT_SECRET 은 docker/dev/.env 에서 주입 (이 파일은 git 추적, .env 는 gitignored + syncthing 동기화)
|
||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET 환경변수 필요. docker/dev/.env 파일 확인}
|
||||
JWT_EXPIRATION: ${JWT_EXPIRATION:-86400000}
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
.inv-login {
|
||||
--bg:#fafaff; --bg-subtle:#f3f2fa; --surface:rgba(255,255,255,0.55); --surface-solid:#ffffff;
|
||||
--surface-hover:rgba(255,255,255,0.7); --text:#0f0e1a; --text-sec:#6b6a80; --text-muted:#9998ad;
|
||||
--primary:#6c5ce7; --primary-light:#a29bfe; --primary-glow:rgba(108,92,231,0.25);
|
||||
--cyan:#00cec9; --cyan-glow:rgba(0,206,201,0.2); --pink:#fd79a8; --pink-glow:rgba(253,121,168,0.15);
|
||||
--primary:#6c5ce7; --primary-light:#a29bfe; --primary-glow:rgba(var(--v5-primary-rgb),0.25);
|
||||
--cyan:#00cec9; --cyan-glow:rgba(var(--v5-cyan-rgb),0.2); --pink:#fd79a8; --pink-glow:rgba(var(--v5-pink-rgb),0.15);
|
||||
--red:#ff4757; --green:#00b894; --amber:#fdcb6e;
|
||||
--border:rgba(108,92,231,0.12); --border-subtle:rgba(0,0,0,0.05);
|
||||
--border:rgba(var(--v5-primary-rgb),0.12); --border-subtle:rgba(0,0,0,0.05);
|
||||
--glass:rgba(255,255,255,0.45); --glass-strong:rgba(255,255,255,0.65);
|
||||
--glass-border:rgba(108,92,231,0.12);
|
||||
--glow-sm:0 0 20px rgba(108,92,231,0.12); --glow-md:0 0 40px rgba(108,92,231,0.2);
|
||||
--glow-lg:0 0 80px rgba(108,92,231,0.25);
|
||||
--glass-border:rgba(var(--v5-primary-rgb),0.12);
|
||||
--glow-sm:0 0 20px rgba(var(--v5-primary-rgb),0.12); --glow-md:0 0 40px rgba(var(--v5-primary-rgb),0.2);
|
||||
--glow-lg:0 0 80px rgba(var(--v5-primary-rgb),0.25);
|
||||
|
||||
position:fixed;inset:0;z-index:1;
|
||||
font-family:'Inter',system-ui,sans-serif;
|
||||
@@ -24,14 +24,14 @@
|
||||
.inv-login.dark {
|
||||
--bg:#06050e; --bg-subtle:#0c0b18; --surface:rgba(17,16,42,0.5); --surface-solid:#11102a;
|
||||
--surface-hover:rgba(25,24,64,0.6); --text:#eae8f4; --text-sec:#8d8ba8; --text-muted:#5a587a;
|
||||
--primary:#a29bfe; --primary-light:#c8c4ff; --primary-glow:rgba(162,155,254,0.25);
|
||||
--cyan:#55efc4; --cyan-glow:rgba(85,239,196,0.15); --pink:#fd79a8; --red:#ff6b6b;
|
||||
--primary:#a29bfe; --primary-light:#c8c4ff; --primary-glow:rgba(var(--v5-primary-rgb),0.25);
|
||||
--cyan:#55efc4; --cyan-glow:rgba(var(--v5-cyan-rgb),0.15); --pink:#fd79a8; --red:#ff6b6b;
|
||||
--green:#55efc4; --amber:#ffeaa7;
|
||||
--border:rgba(162,155,254,0.1); --border-subtle:rgba(255,255,255,0.04);
|
||||
--border:rgba(var(--v5-primary-rgb),0.1); --border-subtle:rgba(255,255,255,0.04);
|
||||
--glass:rgba(17,16,42,0.45); --glass-strong:rgba(17,16,42,0.65);
|
||||
--glass-border:rgba(162,155,254,0.12);
|
||||
--glow-sm:0 0 20px rgba(162,155,254,0.1); --glow-md:0 0 40px rgba(162,155,254,0.18);
|
||||
--glow-lg:0 0 80px rgba(162,155,254,0.22);
|
||||
--glass-border:rgba(var(--v5-primary-rgb),0.12);
|
||||
--glow-sm:0 0 20px rgba(var(--v5-primary-rgb),0.1); --glow-md:0 0 40px rgba(var(--v5-primary-rgb),0.18);
|
||||
--glow-lg:0 0 80px rgba(var(--v5-primary-rgb),0.22);
|
||||
}
|
||||
|
||||
/* ===== COSMIC BACKGROUND ===== */
|
||||
@@ -50,7 +50,7 @@
|
||||
.inv-login .neb-1{width:700px;height:700px;top:-20%;right:-15%;background:radial-gradient(circle,var(--primary-glow),transparent 70%);animation-duration:18s;}
|
||||
.inv-login .neb-2{width:600px;height:600px;bottom:-25%;left:-10%;background:radial-gradient(circle,var(--cyan-glow),transparent 70%);animation-duration:14s;animation-delay:-4s;}
|
||||
.inv-login .neb-3{width:450px;height:450px;top:35%;left:40%;background:radial-gradient(circle,var(--pink-glow),transparent 70%);animation-duration:12s;animation-delay:-8s;}
|
||||
.inv-login .neb-4{width:350px;height:350px;top:60%;right:25%;background:radial-gradient(circle,rgba(108,92,231,0.08),transparent 70%);animation-duration:20s;animation-delay:-2s;}
|
||||
.inv-login .neb-4{width:350px;height:350px;top:60%;right:25%;background:radial-gradient(circle,rgba(var(--v5-primary-rgb),0.08),transparent 70%);animation-duration:20s;animation-delay:-2s;}
|
||||
@keyframes inv-drift{0%{transform:translate(0,0) scale(1)}100%{transform:translate(30px,-25px) scale(1.1)}}
|
||||
|
||||
.inv-login:not(.dark) .cosmos{background:linear-gradient(180deg,#e8e4ff 0%,#f0edff 30%,#fafaff 60%,#f5f0ff 100%);}
|
||||
@@ -62,7 +62,7 @@
|
||||
.inv-login:not(.dark) .neb-3{width:800px;height:350px;top:auto;bottom:5%;left:30%;
|
||||
background:radial-gradient(ellipse,rgba(255,255,255,0.8),rgba(240,220,255,0.3),transparent 70%);animation-duration:22s;}
|
||||
.inv-login:not(.dark) .neb-4{width:600px;height:600px;top:-10%;right:20%;bottom:auto;
|
||||
background:radial-gradient(circle,rgba(108,92,231,0.08),rgba(0,206,201,0.04),transparent 70%);}
|
||||
background:radial-gradient(circle,rgba(var(--v5-primary-rgb),0.08),rgba(var(--v5-cyan-rgb),0.04),transparent 70%);}
|
||||
|
||||
.inv-login .shooting-star{position:absolute;width:80px;height:1px;
|
||||
background:linear-gradient(90deg,rgba(255,255,255,0.6),transparent);
|
||||
@@ -100,17 +100,17 @@
|
||||
animation:inv-cardIn 1s cubic-bezier(.16,1,.3,1) .3s both;position:relative;z-index:5;
|
||||
}
|
||||
.inv-login:not(.dark) .login-card{background:rgba(255,255,255,0.82);}
|
||||
.inv-login.dark .login-card{box-shadow:0 12px 48px rgba(0,0,0,0.6),var(--glow-md),inset 0 0 0 1px rgba(162,155,254,0.06);}
|
||||
.inv-login.dark .login-card{box-shadow:0 12px 48px rgba(0,0,0,0.6),var(--glow-md),inset 0 0 0 1px rgba(var(--v5-primary-rgb),0.06);}
|
||||
@keyframes inv-cardIn{from{opacity:0;transform:translateY(40px) scale(.96)}to{opacity:1;transform:none}}
|
||||
|
||||
/* Orbital decoration */
|
||||
.inv-login .login-orbit{width:80px;height:80px;margin:0 auto 1rem;position:relative;animation:inv-logoIn 1.2s cubic-bezier(.16,1,.3,1) .4s both;}
|
||||
.inv-login .orbit-core{position:absolute;inset:22px;border-radius:50%;
|
||||
background:linear-gradient(135deg,var(--primary),var(--cyan));
|
||||
box-shadow:0 0 30px var(--primary-glow),0 0 60px rgba(0,206,201,0.15);
|
||||
box-shadow:0 0 30px var(--primary-glow),0 0 60px rgba(var(--v5-cyan-rgb),0.15);
|
||||
animation:inv-corePulse 3s ease-in-out infinite;}
|
||||
@keyframes inv-corePulse{0%,100%{box-shadow:0 0 30px var(--primary-glow),0 0 60px rgba(0,206,201,0.15)}
|
||||
50%{box-shadow:0 0 40px var(--primary-glow),0 0 80px rgba(0,206,201,0.25)}}
|
||||
@keyframes inv-corePulse{0%,100%{box-shadow:0 0 30px var(--primary-glow),0 0 60px rgba(var(--v5-cyan-rgb),0.15)}
|
||||
50%{box-shadow:0 0 40px var(--primary-glow),0 0 80px rgba(var(--v5-cyan-rgb),0.25)}}
|
||||
.inv-login .orbit-ring{position:absolute;inset:0;border-radius:50%;border:1.5px solid transparent;
|
||||
border-top-color:var(--primary);border-right-color:var(--cyan);
|
||||
animation:inv-orbitSpin 4s linear infinite;opacity:.6;}
|
||||
@@ -124,7 +124,7 @@
|
||||
|
||||
/* Fail shake */
|
||||
@keyframes inv-denied{
|
||||
0%{transform:scale(1.1);box-shadow:0 0 60px rgba(255,71,87,0.4),0 0 0 2px rgba(255,71,87,0.5),inset 0 0 30px rgba(255,71,87,0.05)}
|
||||
0%{transform:scale(1.1);box-shadow:0 0 60px rgba(var(--v5-red-rgb),0.4),0 0 0 2px rgba(var(--v5-red-rgb),0.5),inset 0 0 30px rgba(var(--v5-red-rgb),0.05)}
|
||||
18%{transform:scale(1) translateX(-6px)}
|
||||
36%{transform:scale(1) translateX(5px)}
|
||||
52%{transform:scale(1) translateX(-4px)}
|
||||
@@ -166,7 +166,7 @@
|
||||
background-color:#ffffff !important;
|
||||
backdrop-filter:none !important;
|
||||
-webkit-backdrop-filter:none !important;
|
||||
border-color:rgba(108,92,231,0.18) !important;
|
||||
border-color:rgba(var(--v5-primary-rgb),0.18) !important;
|
||||
color:#0f0e1a !important;
|
||||
}
|
||||
.inv-login:not(.dark) input.fi:-webkit-autofill,
|
||||
@@ -177,7 +177,7 @@
|
||||
}
|
||||
.inv-login .fi::placeholder{color:var(--text-muted);font-weight:400;}
|
||||
.inv-login .fi:focus{border-color:var(--primary);box-shadow:0 0 0 4px var(--primary-glow),var(--glow-sm);}
|
||||
.inv-login .fi.error{border-color:rgba(255,71,87,0.4) !important;box-shadow:0 0 12px rgba(255,71,87,0.12) !important;}
|
||||
.inv-login .fi.error{border-color:rgba(var(--v5-red-rgb),0.4) !important;box-shadow:0 0 12px rgba(var(--v5-red-rgb),0.12) !important;}
|
||||
.inv-login .pw-w .fi{padding-right:2.8rem;}
|
||||
.inv-login .pw-w{position:relative;display:flex;align-items:center;}
|
||||
.inv-login .pw-b{position:absolute;right:14px;top:50%;transform:translateY(-50%);background:none;border:none;
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function LoginPage() {
|
||||
useEffect(() => {
|
||||
const co = cosmosRef.current;
|
||||
if (!co) return;
|
||||
const cs = ["rgba(162,155,254,.8)", "rgba(85,239,196,.7)", "rgba(253,121,168,.7)"];
|
||||
const cs = ["rgba(var(--v5-primary-rgb),.8)", "rgba(var(--v5-cyan-rgb),.7)", "rgba(var(--v5-pink-rgb),.7)"];
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const s = document.createElement("div");
|
||||
s.className = "star" + (Math.random() > 0.83 ? " c" : "");
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
// INVYONE 대시보드(=사용자 메뉴) 뷰
|
||||
// - URL: /{seq} (회사별 자동 시퀀스, 메뉴 그 자체)
|
||||
// - menuSeq 로 사용자 대시보드 목록에서 매칭 → OBJID 획득 → DashboardLayout 렌더
|
||||
|
||||
import { use, useEffect, useState } from "react";
|
||||
import { DashboardLayout } from "@/components/dash/DashboardLayout";
|
||||
import { getDashboardList } from "@/lib/api/dashMenu";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ menuSeq: string }>;
|
||||
}
|
||||
|
||||
export default function MenuSeqPage({ params }: PageProps) {
|
||||
const { menuSeq } = use(params);
|
||||
const isNumeric = /^\d+$/.test(menuSeq);
|
||||
|
||||
const [objid, setObjid] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [missing, setMissing] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNumeric) {
|
||||
setLoading(false);
|
||||
setMissing(true);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
console.log("[MenuSeq] 조회 시작:", menuSeq);
|
||||
const result = await getDashboardList();
|
||||
console.log("[MenuSeq] getDashboardList 응답:", result);
|
||||
const list: Record<string, any>[] = result?.list ?? [];
|
||||
const wanted = `/${menuSeq}`;
|
||||
const match = list.find((d) => (d.menu_url ?? d.MENU_URL) === wanted);
|
||||
console.log("[MenuSeq] 매칭:", { wanted, matched: match, listCount: list.length });
|
||||
if (match) {
|
||||
setObjid(String(match.objid ?? match.OBJID));
|
||||
} else {
|
||||
setMissing(true);
|
||||
setErrorMsg(`'/${menuSeq}' 에 해당하는 대시보드를 목록(${list.length}개)에서 찾지 못했습니다.`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("[MenuSeq] 조회 실패:", err);
|
||||
setMissing(true);
|
||||
setErrorMsg(`대시보드 목록 조회 실패: ${err?.response?.status ?? ""} ${err?.message ?? err}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [menuSeq, isNumeric]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground">
|
||||
대시보드 로드 중...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (missing || !objid) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<div className="text-4xl">📋</div>
|
||||
<div className="text-base font-semibold text-foreground">
|
||||
대시보드를 찾을 수 없습니다
|
||||
</div>
|
||||
<div className="max-w-md text-xs text-muted-foreground">
|
||||
경로: <code className="rounded bg-muted px-1.5 py-0.5">/{menuSeq}</code>
|
||||
{errorMsg ? <div className="mt-2">{errorMsg}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <DashboardLayout dashboardId={objid} />;
|
||||
}
|
||||
@@ -1,37 +1,75 @@
|
||||
"use client";
|
||||
|
||||
// INVYONE 빌더 진입 화면
|
||||
// - 템플릿 목록 + 새 템플릿 생성
|
||||
// - URL 에 ?id=xxx 가 있으면 빌더로 바로 진입
|
||||
// - 없으면 템플릿 선택/생성 UI 표시
|
||||
// INVYONE 스튜디오 진입 페이지 (templates 테이블 기반)
|
||||
// - 템플릿 목록 + 새 템플릿 생성 → templates 테이블 CRUD
|
||||
// - URL ?id=<template_id> 로 바로 진입
|
||||
// - ScreenDesigner 는 template_id 를 통해 templates API 로 저장/로드
|
||||
import { Suspense, useState, useEffect, useCallback } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import ScreenDesigner from "@/components/screen/ScreenDesigner";
|
||||
import type { ScreenDefinition } from "@/types/screen";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { getTemplateList, deleteTemplate } from "@/lib/api/template";
|
||||
import { createTemplate } from "@/lib/utils/templateAdapter";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Plus, LayoutTemplate, Search, FileText } from "lucide-react";
|
||||
import { Plus, LayoutTemplate, Search, FileText, Trash2 } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const CATEGORIES: { id: string; label: string }[] = [
|
||||
{ id: "", label: "전체" },
|
||||
{ id: "sales", label: "영업/CRM" },
|
||||
{ id: "production", label: "생산/공정" },
|
||||
{ id: "hr", label: "인사/급여" },
|
||||
{ id: "inventory", label: "재고/물류" },
|
||||
{ id: "finance", label: "재무/회계" },
|
||||
{ id: "admin", label: "관리자" },
|
||||
{ id: "custom", label: "기타" },
|
||||
];
|
||||
|
||||
function templateToScreenDef(t: Record<string, any>): ScreenDefinition {
|
||||
const templateId = t.template_id ?? t.TEMPLATE_ID;
|
||||
return {
|
||||
// ScreenDesigner 가 screen_id 를 옵셔널하게 참조하도록 undefined 허용
|
||||
screen_id: undefined as any,
|
||||
screen_name: t.name ?? t.NAME ?? "",
|
||||
screen_code: "",
|
||||
table_name: t.primary_table ?? t.PRIMARY_TABLE ?? "",
|
||||
company_code: (t.company_code ?? t.COMPANY_CODE ?? "") as any,
|
||||
description: t.description ?? t.DESCRIPTION ?? "",
|
||||
is_active: "Y" as any,
|
||||
created_date: new Date(),
|
||||
updated_date: new Date(),
|
||||
template_id: templateId,
|
||||
is_template_mode: true,
|
||||
template_category: t.category ?? t.CATEGORY ?? "",
|
||||
template_status: (t.status ?? t.STATUS) as any,
|
||||
};
|
||||
}
|
||||
|
||||
function TemplateGallery({
|
||||
templates,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onDelete,
|
||||
loading,
|
||||
}: {
|
||||
templates: ScreenDefinition[];
|
||||
onSelect: (t: ScreenDefinition) => void;
|
||||
templates: Record<string, any>[];
|
||||
onSelect: (t: Record<string, any>) => void;
|
||||
onCreate: () => void;
|
||||
onDelete: (t: Record<string, any>) => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeCategory, setActiveCategory] = useState("");
|
||||
|
||||
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 q = query.toLowerCase();
|
||||
return (
|
||||
!q ||
|
||||
(t.screen_name || "").toLowerCase().includes(q) ||
|
||||
(t.screen_code || "").toLowerCase().includes(q)
|
||||
);
|
||||
const matchQ = !q || name.includes(q) || desc.includes(q);
|
||||
const matchCat = !activeCategory || cat === activeCategory;
|
||||
return matchQ && matchCat;
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -42,7 +80,7 @@ function TemplateGallery({
|
||||
<div>
|
||||
<h1 className="mb-1 text-2xl font-bold text-foreground">템플릿</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
기존 템플릿을 열거나 새로 만들어보세요
|
||||
INVYONE 스튜디오 — 기존 템플릿을 열거나 새로 만드세요
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -50,14 +88,17 @@ function TemplateGallery({
|
||||
onClick={onCreate}
|
||||
className="flex items-center gap-1.5 rounded-md border border-primary/30 bg-primary/10 px-4 py-2 text-sm font-semibold text-primary transition-all hover:bg-primary/15 hover:border-primary/50"
|
||||
>
|
||||
<Plus size={14} />
|
||||
새 템플릿
|
||||
<Plus size={14} />새 템플릿
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="mb-6 relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={14} />
|
||||
{/* 검색 + 카테고리 */}
|
||||
<div className="mb-6 flex items-center gap-3 flex-wrap">
|
||||
<div className="relative max-w-md flex-1 min-w-[200px]">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
size={14}
|
||||
/>
|
||||
<Input
|
||||
placeholder="템플릿 검색..."
|
||||
value={query}
|
||||
@@ -65,6 +106,23 @@ function TemplateGallery({
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
className={
|
||||
activeCategory === cat.id
|
||||
? "rounded-md border border-primary/40 bg-primary/15 px-3 py-1.5 text-xs font-semibold text-primary"
|
||||
: "rounded-md border border-border/40 bg-card px-3 py-1.5 text-xs text-muted-foreground hover:border-primary/30 hover:text-foreground"
|
||||
}
|
||||
>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 로딩 */}
|
||||
{loading && (
|
||||
@@ -76,7 +134,10 @@ function TemplateGallery({
|
||||
{/* 빈 상태 */}
|
||||
{!loading && templates.length === 0 && (
|
||||
<div className="py-20 text-center">
|
||||
<LayoutTemplate className="mx-auto mb-4 text-muted-foreground/30" size={48} />
|
||||
<LayoutTemplate
|
||||
className="mx-auto mb-4 text-muted-foreground/30"
|
||||
size={48}
|
||||
/>
|
||||
<p className="mb-2 text-sm font-medium text-foreground">
|
||||
아직 템플릿이 없습니다
|
||||
</p>
|
||||
@@ -88,38 +149,66 @@ function TemplateGallery({
|
||||
onClick={onCreate}
|
||||
className="rounded-md border border-primary/30 bg-primary/10 px-4 py-2 text-sm font-semibold text-primary hover:bg-primary/15"
|
||||
>
|
||||
<Plus size={14} className="inline mr-1" />
|
||||
새 템플릿 만들기
|
||||
<Plus size={14} className="inline mr-1" />새 템플릿 만들기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 템플릿 그리드 */}
|
||||
{/* 그리드 */}
|
||||
{!loading && filtered.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{filtered.map((t) => (
|
||||
{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="group relative flex flex-col items-start rounded-lg border border-border/40 bg-card p-4 text-left transition-all hover:border-primary/30 hover:shadow-md"
|
||||
>
|
||||
<button
|
||||
key={t.screen_id}
|
||||
type="button"
|
||||
onClick={() => onSelect(t)}
|
||||
className="group flex flex-col items-start rounded-lg border border-border/40 bg-card p-4 text-left transition-all hover:border-primary/30 hover:shadow-md"
|
||||
className="flex w-full flex-col items-start text-left"
|
||||
>
|
||||
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<FileText size={18} />
|
||||
</div>
|
||||
<h3 className="mb-1 text-sm font-semibold text-foreground line-clamp-1">
|
||||
{t.screen_name}
|
||||
{name}
|
||||
</h3>
|
||||
{t.screen_code && (
|
||||
<p className="mb-2 font-mono text-[0.6rem] text-muted-foreground/60">
|
||||
{t.screen_code}
|
||||
<p className="mb-2 text-[0.62rem] text-muted-foreground line-clamp-2">
|
||||
{desc || "설명 없음"}
|
||||
</p>
|
||||
<div className="mt-auto flex items-center gap-1 flex-wrap">
|
||||
{cat && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[0.6rem] text-primary">
|
||||
{cat}
|
||||
</span>
|
||||
)}
|
||||
<p className="text-[0.62rem] text-muted-foreground line-clamp-2">
|
||||
{(t as any).description || "설명 없음"}
|
||||
</p>
|
||||
{table && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[0.6rem] text-muted-foreground font-mono">
|
||||
{table}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(t);
|
||||
}}
|
||||
className="absolute right-2 top-2 rounded-md p-1 text-muted-foreground/60 opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-500 group-hover:opacity-100"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -140,9 +229,10 @@ function CreateTemplateModal({
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (name: string) => Promise<void>;
|
||||
onCreate: (name: string, category: string) => Promise<void>;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [category, setCategory] = useState("custom");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
if (!open) return null;
|
||||
@@ -152,8 +242,9 @@ function CreateTemplateModal({
|
||||
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-2xl">
|
||||
<h2 className="mb-1 text-lg font-bold text-foreground">새 템플릿</h2>
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
템플릿 이름을 입력하세요. 나중에 각 컴포넌트에서 테이블을 선택합니다.
|
||||
템플릿 이름과 카테고리를 입력하세요.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="예: 사원 관리"
|
||||
@@ -162,14 +253,26 @@ function CreateTemplateModal({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && name.trim() && !creating) {
|
||||
setCreating(true);
|
||||
onCreate(name.trim()).finally(() => setCreating(false));
|
||||
onCreate(name.trim(), category).finally(() => setCreating(false));
|
||||
} else if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
className="mb-4 h-10"
|
||||
className="h-10"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{CATEGORIES.filter((c) => c.id).map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -184,7 +287,7 @@ function CreateTemplateModal({
|
||||
onClick={async () => {
|
||||
setCreating(true);
|
||||
try {
|
||||
await onCreate(name.trim());
|
||||
await onCreate(name.trim(), category);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
@@ -202,27 +305,22 @@ function CreateTemplateModal({
|
||||
function BuilderInner() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const screenIdParam = searchParams.get("id");
|
||||
const { companyCode } = useAuth();
|
||||
const templateIdParam = searchParams.get("id");
|
||||
useAuth();
|
||||
|
||||
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
||||
const [allTemplates, setAllTemplates] = useState<ScreenDefinition[]>([]);
|
||||
const [templates, setTemplates] = useState<Record<string, any>[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
const fetchTemplates = useCallback(async (): Promise<Record<string, any>[]> => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
const result: any = await screenApi.getScreens({
|
||||
page: 1,
|
||||
size: 1000,
|
||||
searchTerm: "",
|
||||
excludePop: true,
|
||||
});
|
||||
const list: ScreenDefinition[] = result?.data ?? [];
|
||||
setAllTemplates(list);
|
||||
const result: Record<string, any> = await getTemplateList();
|
||||
const list: Record<string, any>[] = result?.list ?? result?.data ?? [];
|
||||
setTemplates(list);
|
||||
return list;
|
||||
} catch (err: any) {
|
||||
console.error("[BuilderPage] 템플릿 로드 실패:", err);
|
||||
@@ -237,52 +335,77 @@ function BuilderInner() {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const list = await fetchTemplates();
|
||||
if (screenIdParam) {
|
||||
const id = parseInt(screenIdParam, 10);
|
||||
const target = list.find((s: any) => s.screen_id === id);
|
||||
if (target) setSelectedScreen(target);
|
||||
if (templateIdParam) {
|
||||
const target = list.find(
|
||||
(t) => (t.template_id ?? t.TEMPLATE_ID) === templateIdParam,
|
||||
);
|
||||
if (target) setSelectedScreen(templateToScreenDef(target));
|
||||
}
|
||||
})();
|
||||
}, [screenIdParam, fetchTemplates]);
|
||||
}, [templateIdParam, fetchTemplates]);
|
||||
|
||||
const handleSelectTemplate = useCallback(
|
||||
(t: ScreenDefinition) => {
|
||||
setSelectedScreen(t);
|
||||
router.replace(`/admin/builder?id=${t.screen_id}`);
|
||||
(t: Record<string, any>) => {
|
||||
const def = templateToScreenDef(t);
|
||||
setSelectedScreen(def);
|
||||
router.replace(`/admin/builder?id=${def.template_id}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const handleCreateTemplate = useCallback(
|
||||
async (name: string) => {
|
||||
async (name: string, category: string) => {
|
||||
try {
|
||||
// 회사 코드 가져오기 (없으면 빈 문자열)
|
||||
const code = companyCode || "DEFAULT";
|
||||
// 템플릿 생성 — table_name은 빈 상태로 시작 (각 컴포넌트가 자기 테이블 선택)
|
||||
const newScreen = await screenApi.createScreen({
|
||||
screen_name: name,
|
||||
table_name: "", // INVYONE: 컴포넌트별로 선택 (레거시 호환용 빈 값)
|
||||
company_code: code as any,
|
||||
} as any);
|
||||
const created = await createTemplate({
|
||||
name,
|
||||
category,
|
||||
description: "",
|
||||
primaryTable: "",
|
||||
});
|
||||
const templateId = created.template_id ?? created.TEMPLATE_ID;
|
||||
setShowCreate(false);
|
||||
setSelectedScreen(newScreen);
|
||||
router.replace(`/admin/builder?id=${newScreen.screen_id}`);
|
||||
const def = templateToScreenDef({
|
||||
template_id: templateId,
|
||||
name,
|
||||
category,
|
||||
description: "",
|
||||
primary_table: "",
|
||||
status: "draft",
|
||||
});
|
||||
setSelectedScreen(def);
|
||||
router.replace(`/admin/builder?id=${templateId}`);
|
||||
toast.success(`"${name}" 템플릿을 만들었습니다`);
|
||||
} catch (err: any) {
|
||||
console.error("[BuilderPage] 템플릿 생성 실패:", err);
|
||||
alert(`템플릿 생성 실패: ${err?.message ?? "알 수 없는 오류"}`);
|
||||
toast.error(`템플릿 생성 실패: ${err?.message ?? "알 수 없는 오류"}`);
|
||||
}
|
||||
},
|
||||
[companyCode, router],
|
||||
[router],
|
||||
);
|
||||
|
||||
const handleDeleteTemplate = useCallback(
|
||||
async (t: Record<string, any>) => {
|
||||
const tid = t.template_id ?? t.TEMPLATE_ID;
|
||||
const name = t.name ?? t.NAME ?? "템플릿";
|
||||
if (!confirm(`"${name}" 템플릿을 삭제합니다.`)) return;
|
||||
try {
|
||||
await deleteTemplate(tid);
|
||||
toast.info(`"${name}" 을(를) 삭제했습니다`);
|
||||
await fetchTemplates();
|
||||
} catch (err: any) {
|
||||
console.error("[BuilderPage] 템플릿 삭제 실패:", err);
|
||||
toast.error("삭제 실패");
|
||||
}
|
||||
},
|
||||
[fetchTemplates],
|
||||
);
|
||||
|
||||
const handleBackToList = useCallback(() => {
|
||||
setSelectedScreen(null);
|
||||
router.replace("/admin/builder");
|
||||
// 목록 새로고침
|
||||
fetchTemplates();
|
||||
}, [fetchTemplates, router]);
|
||||
|
||||
// 에러 상태
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-2 text-slate-500">
|
||||
@@ -298,14 +421,14 @@ function BuilderInner() {
|
||||
);
|
||||
}
|
||||
|
||||
// 템플릿 선택 안 되어 있으면 갤러리 표시
|
||||
if (!selectedScreen) {
|
||||
return (
|
||||
<>
|
||||
<TemplateGallery
|
||||
templates={allTemplates}
|
||||
templates={templates}
|
||||
onSelect={handleSelectTemplate}
|
||||
onCreate={() => setShowCreate(true)}
|
||||
onDelete={handleDeleteTemplate}
|
||||
loading={loading}
|
||||
/>
|
||||
<CreateTemplateModal
|
||||
@@ -317,7 +440,6 @@ function BuilderInner() {
|
||||
);
|
||||
}
|
||||
|
||||
// 템플릿 선택됨 → 빌더 표시
|
||||
return (
|
||||
<div className="ide-builder h-[calc(100vh-4rem)] w-full overflow-hidden bg-background">
|
||||
<ScreenDesigner
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
// 템플릿 반응 미리보기 페이지
|
||||
// ============================================================================
|
||||
// 빌더(ScreenDesigner)에서 "반응 미리보기" 버튼으로 새 창으로 열림.
|
||||
// URL: /admin/template-preview?id=<templateId>
|
||||
// 템플릿을 서버에서 로드 → Full/Half/Quarter 토글 안에서 TemplateRenderer 로 렌더.
|
||||
// ============================================================================
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { getTemplateInfo } from "@/lib/api/template";
|
||||
import type { Template } from "@/types/invyone-component";
|
||||
import { TemplateResponsivePreview } from "@/components/dash/TemplateResponsivePreview";
|
||||
|
||||
function PreviewInner() {
|
||||
const searchParams = useSearchParams();
|
||||
const templateId = searchParams?.get("id") ?? "";
|
||||
const [template, setTemplate] = useState<Template | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!templateId) {
|
||||
setError("템플릿 ID가 없습니다");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancel = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await getTemplateInfo(templateId);
|
||||
if (cancel) return;
|
||||
if (!data) {
|
||||
setError("템플릿을 찾을 수 없습니다");
|
||||
} else {
|
||||
setTemplate(data as unknown as Template);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!cancel) setError(e?.message ?? "로드 실패");
|
||||
} finally {
|
||||
if (!cancel) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancel = true;
|
||||
};
|
||||
}, [templateId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center text-sm text-muted-foreground">
|
||||
템플릿 로드 중...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error || !template) {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center text-sm text-destructive">
|
||||
{error ?? "템플릿이 비어있습니다"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full bg-background">
|
||||
<TemplateResponsivePreview template={template} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TemplatePreviewPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<PreviewInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, use } from "react";
|
||||
import { DashboardViewer } from "@/components/dashboard/DashboardViewer";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
|
||||
interface DashboardViewPageProps {
|
||||
params: Promise<{
|
||||
dashboardId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 뷰어 페이지
|
||||
* - 저장된 대시보드를 읽기 전용으로 표시
|
||||
* - 실시간 데이터 업데이트
|
||||
* - 전체화면 모드 지원
|
||||
*/
|
||||
export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
||||
const resolvedParams = use(params);
|
||||
const [dashboard, setDashboard] = useState<{
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
elements: DashboardElement[];
|
||||
settings?: {
|
||||
backgroundColor?: string;
|
||||
resolution?: string;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
} | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadDashboard = React.useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 실제 API 호출 시도
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
|
||||
try {
|
||||
const dashboardData = await dashboardApi.getDashboard(resolvedParams.dashboardId);
|
||||
setDashboard({
|
||||
...dashboardData,
|
||||
elements: dashboardData.elements || [],
|
||||
});
|
||||
} catch (apiError) {
|
||||
console.warn("API 호출 실패, 로컬 스토리지 확인:", apiError);
|
||||
|
||||
// API 실패 시 로컬 스토리지에서 찾기
|
||||
const savedDashboards = JSON.parse(localStorage.getItem("savedDashboards") || "[]");
|
||||
const savedDashboard = savedDashboards.find((d: { id: string }) => d.id === resolvedParams.dashboardId);
|
||||
|
||||
if (savedDashboard) {
|
||||
setDashboard(savedDashboard);
|
||||
} else {
|
||||
// 로컬에도 없으면 샘플 데이터 사용
|
||||
const sampleDashboard = generateSampleDashboard(resolvedParams.dashboardId);
|
||||
setDashboard(sampleDashboard);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError("대시보드를 불러오는 중 오류가 발생했습니다.");
|
||||
console.error("Dashboard loading error:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [resolvedParams.dashboardId]);
|
||||
|
||||
// 대시보드 데이터 로딩
|
||||
useEffect(() => {
|
||||
loadDashboard();
|
||||
}, [loadDashboard]);
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-4 border-ring border-t-transparent" />
|
||||
<div className="text-lg font-medium text-foreground">대시보드 로딩 중...</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">잠시만 기다려주세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error || !dashboard) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 text-6xl">😞</div>
|
||||
<div className="mb-2 text-xl font-medium text-foreground">{error || "대시보드를 찾을 수 없습니다"}</div>
|
||||
<div className="mb-4 text-sm text-muted-foreground">대시보드 ID: {resolvedParams.dashboardId}</div>
|
||||
<button onClick={loadDashboard} className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90">
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen">
|
||||
{/* 대시보드 헤더 - 보기 모드에서는 숨김 */}
|
||||
{/* <div className="border-b border-border bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{dashboard.title}</h1>
|
||||
{dashboard.description && <p className="mt-1 text-sm text-muted-foreground">{dashboard.description}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 새로고침 버튼 *\/}
|
||||
<button
|
||||
onClick={loadDashboard}
|
||||
className="rounded-lg border border-input px-3 py-2 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="새로고침"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
|
||||
{/* 전체화면 버튼 *\/}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
document.documentElement.requestFullscreen();
|
||||
}
|
||||
}}
|
||||
className="rounded-lg border border-input px-3 py-2 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="전체화면"
|
||||
>
|
||||
⛶
|
||||
</button>
|
||||
|
||||
{/* 편집 버튼 *\/}
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push(`/admin/screenMng/dashboardList?load=${resolvedParams.dashboardId}`);
|
||||
}}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-white hover:bg-primary"
|
||||
>
|
||||
편집
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메타 정보 *\/}
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>생성: {new Date(dashboard.createdAt).toLocaleString()}</span>
|
||||
<span>수정: {new Date(dashboard.updatedAt).toLocaleString()}</span>
|
||||
<span>요소: {dashboard.elements.length}개</span>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* 대시보드 뷰어 */}
|
||||
<DashboardViewer
|
||||
elements={dashboard.elements}
|
||||
dashboardId={dashboard.id}
|
||||
dashboardTitle={dashboard.title}
|
||||
backgroundColor={dashboard.settings?.backgroundColor}
|
||||
resolution={dashboard.settings?.resolution}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 샘플 대시보드 생성 함수
|
||||
*/
|
||||
function generateSampleDashboard(dashboardId: string): {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
elements: DashboardElement[];
|
||||
settings?: {
|
||||
backgroundColor?: string;
|
||||
resolution?: string;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
} {
|
||||
const dashboards: Record<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
elements: DashboardElement[];
|
||||
settings?: {
|
||||
backgroundColor?: string;
|
||||
resolution?: string;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
> = {
|
||||
"sales-overview": {
|
||||
id: "sales-overview",
|
||||
title: "📊 매출 현황 대시보드",
|
||||
description: "월별 매출 추이 및 상품별 판매 현황을 한눈에 확인할 수 있습니다.",
|
||||
elements: [
|
||||
{
|
||||
id: "chart-1",
|
||||
type: "chart",
|
||||
subtype: "bar",
|
||||
position: { x: 20, y: 20 },
|
||||
size: { width: 400, height: 300 },
|
||||
title: "📊 월별 매출 추이",
|
||||
content: "월별 매출 데이터",
|
||||
dataSource: {
|
||||
type: "database",
|
||||
query: "SELECT month, sales FROM monthly_sales",
|
||||
refreshInterval: 30000,
|
||||
},
|
||||
chartConfig: {
|
||||
xAxis: "month",
|
||||
yAxis: "sales",
|
||||
title: "월별 매출 추이",
|
||||
colors: ["#3B82F6", "#EF4444", "#10B981"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "chart-2",
|
||||
type: "chart",
|
||||
subtype: "pie",
|
||||
position: { x: 450, y: 20 },
|
||||
size: { width: 350, height: 300 },
|
||||
title: "🥧 상품별 판매 비율",
|
||||
content: "상품별 판매 데이터",
|
||||
dataSource: {
|
||||
type: "database",
|
||||
query: "SELECT product_name, total_sold FROM product_sales",
|
||||
refreshInterval: 60000,
|
||||
},
|
||||
chartConfig: {
|
||||
xAxis: "product_name",
|
||||
yAxis: "total_sold",
|
||||
title: "상품별 판매 비율",
|
||||
colors: ["#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "chart-3",
|
||||
type: "chart",
|
||||
subtype: "line",
|
||||
position: { x: 20, y: 350 },
|
||||
size: { width: 780, height: 250 },
|
||||
title: "📈 사용자 가입 추이",
|
||||
content: "사용자 가입 데이터",
|
||||
dataSource: {
|
||||
type: "database",
|
||||
query: "SELECT week, new_users FROM user_growth",
|
||||
refreshInterval: 300000,
|
||||
},
|
||||
chartConfig: {
|
||||
xAxis: "week",
|
||||
yAxis: "new_users",
|
||||
title: "주간 신규 사용자 가입 추이",
|
||||
colors: ["#10B981"],
|
||||
},
|
||||
},
|
||||
],
|
||||
createdAt: "2024-09-30T10:00:00Z",
|
||||
updatedAt: "2024-09-30T14:30:00Z",
|
||||
},
|
||||
"user-analytics": {
|
||||
id: "user-analytics",
|
||||
title: "👥 사용자 분석 대시보드",
|
||||
description: "사용자 행동 패턴 및 가입 추이 분석",
|
||||
elements: [
|
||||
{
|
||||
id: "chart-4",
|
||||
type: "chart",
|
||||
subtype: "line",
|
||||
position: { x: 20, y: 20 },
|
||||
size: { width: 500, height: 300 },
|
||||
title: "📈 일일 활성 사용자",
|
||||
content: "사용자 활동 데이터",
|
||||
dataSource: {
|
||||
type: "database",
|
||||
query: "SELECT date, active_users FROM daily_active_users",
|
||||
refreshInterval: 60000,
|
||||
},
|
||||
chartConfig: {
|
||||
xAxis: "date",
|
||||
yAxis: "active_users",
|
||||
title: "일일 활성 사용자 추이",
|
||||
},
|
||||
},
|
||||
],
|
||||
createdAt: "2024-09-29T15:00:00Z",
|
||||
updatedAt: "2024-09-30T09:15:00Z",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
dashboards[dashboardId] || {
|
||||
id: dashboardId,
|
||||
title: `대시보드 ${dashboardId}`,
|
||||
description: "샘플 대시보드입니다.",
|
||||
elements: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Dashboard {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
thumbnail?: string;
|
||||
elementsCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 목록 페이지
|
||||
* - 저장된 대시보드들의 목록 표시
|
||||
* - 새 대시보드 생성 링크
|
||||
* - 대시보드 미리보기 및 관리
|
||||
*/
|
||||
export default function DashboardListPage() {
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// 대시보드 목록 로딩
|
||||
useEffect(() => {
|
||||
loadDashboards();
|
||||
}, []);
|
||||
|
||||
const loadDashboards = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// 실제 API 호출 시도
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
|
||||
try {
|
||||
const result = await dashboardApi.getDashboards({ page: 1, limit: 50 });
|
||||
|
||||
// API에서 가져온 대시보드들을 Dashboard 형식으로 변환
|
||||
const apiDashboards: Dashboard[] = result.dashboards.map((dashboard: any) => ({
|
||||
id: dashboard.id,
|
||||
title: dashboard.title,
|
||||
description: dashboard.description,
|
||||
elementsCount: dashboard.elementsCount || dashboard.elements?.length || 0,
|
||||
createdAt: dashboard.createdAt,
|
||||
updatedAt: dashboard.updatedAt,
|
||||
isPublic: dashboard.isPublic,
|
||||
creatorName: dashboard.creatorName,
|
||||
}));
|
||||
|
||||
setDashboards(apiDashboards);
|
||||
} catch (apiError) {
|
||||
console.warn("API 호출 실패, 로컬 스토리지 및 샘플 데이터 사용:", apiError);
|
||||
|
||||
// API 실패 시 로컬 스토리지 + 샘플 데이터 사용
|
||||
const savedDashboards = JSON.parse(localStorage.getItem("savedDashboards") || "[]");
|
||||
|
||||
// 샘플 대시보드들
|
||||
const sampleDashboards: Dashboard[] = [
|
||||
{
|
||||
id: "sales-overview",
|
||||
title: "📊 매출 현황 대시보드",
|
||||
description: "월별 매출 추이 및 상품별 판매 현황을 한눈에 확인할 수 있습니다.",
|
||||
elementsCount: 3,
|
||||
createdAt: "2024-09-30T10:00:00Z",
|
||||
updatedAt: "2024-09-30T14:30:00Z",
|
||||
isPublic: true,
|
||||
},
|
||||
{
|
||||
id: "user-analytics",
|
||||
title: "👥 사용자 분석 대시보드",
|
||||
description: "사용자 행동 패턴 및 가입 추이 분석",
|
||||
elementsCount: 1,
|
||||
createdAt: "2024-09-29T15:00:00Z",
|
||||
updatedAt: "2024-09-30T09:15:00Z",
|
||||
isPublic: false,
|
||||
},
|
||||
{
|
||||
id: "inventory-status",
|
||||
title: "📦 재고 현황 대시보드",
|
||||
description: "실시간 재고 현황 및 입출고 내역",
|
||||
elementsCount: 4,
|
||||
createdAt: "2024-09-28T11:30:00Z",
|
||||
updatedAt: "2024-09-29T16:45:00Z",
|
||||
isPublic: true,
|
||||
},
|
||||
];
|
||||
|
||||
// 저장된 대시보드를 Dashboard 형식으로 변환
|
||||
const userDashboards: Dashboard[] = savedDashboards.map((dashboard: any) => ({
|
||||
id: dashboard.id,
|
||||
title: dashboard.title,
|
||||
description: dashboard.description,
|
||||
elementsCount: dashboard.elements?.length || 0,
|
||||
createdAt: dashboard.createdAt,
|
||||
updatedAt: dashboard.updatedAt,
|
||||
isPublic: false, // 사용자가 만든 대시보드는 기본적으로 비공개
|
||||
}));
|
||||
|
||||
// 사용자 대시보드를 맨 앞에 배치
|
||||
setDashboards([...userDashboards, ...sampleDashboards]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Dashboard loading error:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 검색 필터링
|
||||
const filteredDashboards = dashboards.filter(
|
||||
(dashboard) =>
|
||||
dashboard.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
dashboard.description?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b border-border bg-card">
|
||||
<div className="mx-auto max-w-7xl px-6 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">📊 대시보드</h1>
|
||||
<p className="mt-1 text-muted-foreground">데이터를 시각화하고 인사이트를 얻어보세요</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/admin/screenMng/dashboardList"
|
||||
className="rounded-lg bg-primary px-6 py-3 font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
➕ 새 대시보드 만들기
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 검색 바 */}
|
||||
<div className="mt-6">
|
||||
<div className="relative max-w-md">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="대시보드 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full rounded-lg border border-input bg-background py-2 pr-4 pl-10 text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
<div className="absolute top-2.5 left-3 text-muted-foreground">🔍</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<div className="mx-auto max-w-7xl px-6 py-8">
|
||||
{isLoading ? (
|
||||
// 로딩 상태
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="rounded-lg border border-border bg-card p-6 shadow-sm">
|
||||
<div className="animate-pulse">
|
||||
<div className="mb-3 h-4 w-3/4 rounded bg-muted"></div>
|
||||
<div className="mb-2 h-3 w-full rounded bg-muted"></div>
|
||||
<div className="mb-4 h-3 w-2/3 rounded bg-muted"></div>
|
||||
<div className="mb-4 h-32 rounded bg-muted"></div>
|
||||
<div className="flex justify-between">
|
||||
<div className="h-3 w-1/4 rounded bg-muted"></div>
|
||||
<div className="h-3 w-1/4 rounded bg-muted"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filteredDashboards.length === 0 ? (
|
||||
// 빈 상태
|
||||
<div className="py-12 text-center">
|
||||
<div className="mb-4 text-6xl">📊</div>
|
||||
<h3 className="mb-2 text-xl font-medium text-foreground">
|
||||
{searchTerm ? "검색 결과가 없습니다" : "아직 대시보드가 없습니다"}
|
||||
</h3>
|
||||
<p className="mb-6 text-muted-foreground">
|
||||
{searchTerm ? "다른 검색어로 시도해보세요" : "첫 번째 대시보드를 만들어보세요"}
|
||||
</p>
|
||||
{!searchTerm && (
|
||||
<Link
|
||||
href="/admin/screenMng/dashboardList"
|
||||
className="inline-flex items-center rounded-lg bg-primary px-6 py-3 font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
➕ 대시보드 만들기
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// 대시보드 그리드
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredDashboards.map((dashboard) => (
|
||||
<DashboardCard key={dashboard.id} dashboard={dashboard} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DashboardCardProps {
|
||||
dashboard: Dashboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 대시보드 카드 컴포넌트
|
||||
*/
|
||||
function DashboardCard({ dashboard }: DashboardCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card shadow-sm transition-shadow hover:shadow-md">
|
||||
{/* 썸네일 영역 */}
|
||||
<div className="flex h-48 items-center justify-center rounded-t-lg bg-gradient-to-br from-primary/10 to-primary/20">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📊</div>
|
||||
<div className="text-sm text-muted-foreground">{dashboard.elementsCount}개 요소</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카드 내용 */}
|
||||
<div className="p-6">
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<h3 className="line-clamp-1 text-lg font-semibold text-foreground">{dashboard.title}</h3>
|
||||
{dashboard.isPublic ? (
|
||||
<span className="rounded-full bg-success/10 px-2 py-1 text-xs text-success">공개</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground">비공개</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dashboard.description && <p className="mb-4 line-clamp-2 text-sm text-muted-foreground">{dashboard.description}</p>}
|
||||
|
||||
{/* 메타 정보 */}
|
||||
<div className="mb-4 text-xs text-muted-foreground">
|
||||
<div>생성: {new Date(dashboard.createdAt).toLocaleDateString()}</div>
|
||||
<div>수정: {new Date(dashboard.updatedAt).toLocaleDateString()}</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼들 */}
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href={`/dashboard/${dashboard.id}`}
|
||||
className="flex-1 rounded-lg bg-primary px-4 py-2 text-center text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
보기
|
||||
</Link>
|
||||
<Link
|
||||
href={`/admin/screenMng/dashboardList?load=${dashboard.id}`}
|
||||
className="rounded-lg border border-input bg-background px-4 py-2 text-sm text-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
편집
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
// 복사 기능 구현
|
||||
console.log("Dashboard copy:", dashboard.id);
|
||||
}}
|
||||
className="rounded-lg border border-input bg-background px-4 py-2 text-sm text-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
title="복사"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -871,3 +871,6 @@ select {
|
||||
--reveal-radius: var(--reveal-max, 1500px);
|
||||
}
|
||||
}
|
||||
|
||||
/* (컬러 테마 변경은 View Transitions 미사용 — colorTransition.ts 참고.
|
||||
화면 cross-fade 가 깜빡임으로 느껴진다는 피드백으로 즉시 swap + 요소별 entrance 재생만 사용.) */
|
||||
|
||||
@@ -40,6 +40,13 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="ko" className="h-full" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `try{var c=localStorage.getItem('v5-color-theme');if(c&&c!=='purple')document.documentElement.setAttribute('data-color',c);}catch(e){}`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className={`${inter.variable} ${jetbrainsMono.variable} bg-background h-full font-sans antialiased`}>
|
||||
<div id="root" className="h-full">
|
||||
<ThemeProvider>
|
||||
|
||||
@@ -89,7 +89,7 @@ export function ControlToolbar({ dashboardId }: ControlToolbarProps) {
|
||||
<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))',
|
||||
border: '1px solid var(--ctrl-glass-border, rgba(var(--v5-primary-rgb),.12))',
|
||||
borderRadius: 8, padding: '.3rem', minWidth: 200, zIndex: 100,
|
||||
backdropFilter: 'blur(20px) saturate(1.4)',
|
||||
}}>
|
||||
@@ -104,7 +104,7 @@ export function ControlToolbar({ dashboardId }: ControlToolbarProps) {
|
||||
style={{
|
||||
display: 'block', width: '100%', textAlign: 'left',
|
||||
padding: '.3rem .5rem', borderRadius: 6, border: 'none',
|
||||
background: isActive ? 'rgba(0,206,201,.12)' : 'transparent',
|
||||
background: isActive ? 'rgba(var(--v5-cyan-rgb),.12)' : 'transparent',
|
||||
color: isActive ? 'var(--ctrl-cyan)' : 'var(--v5-text-sec)',
|
||||
fontSize: '.55rem', cursor: 'pointer',
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface CreateDashboardModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: { name: string; icon: string; is_personal: boolean }) => Promise<void> | void;
|
||||
defaultName?: string;
|
||||
defaultIcon?: string;
|
||||
submitting?: boolean;
|
||||
}
|
||||
|
||||
const ICON_PRESETS = ['📋', '📊', '📈', '📉', '📦', '🚚', '🏭', '🧭', '🗺️', '🔧', '⚙️', '📁'];
|
||||
|
||||
export function CreateDashboardModal({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
defaultName = '',
|
||||
defaultIcon = '📋',
|
||||
submitting = false,
|
||||
}: CreateDashboardModalProps) {
|
||||
const [name, setName] = useState(defaultName);
|
||||
const [icon, setIcon] = useState(defaultIcon);
|
||||
const [isPersonal, setIsPersonal] = useState(false);
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(defaultName);
|
||||
setIcon(defaultIcon);
|
||||
setIsPersonal(false);
|
||||
setTimeout(() => nameRef.current?.focus(), 30);
|
||||
}
|
||||
}, [open, defaultName, defaultIcon]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || submitting) return;
|
||||
await onSubmit({ name: trimmed, icon, is_personal: isPersonal });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
className="w-[420px] max-w-[92vw] rounded-xl border border-[var(--v5-glass-border)] bg-[var(--v5-glass)] p-5 shadow-[var(--v5-glow-md)]"
|
||||
style={{ backdropFilter: 'blur(20px) saturate(1.4)' }}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-[0.95rem] font-bold text-foreground">새 대시보드 만들기</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
이름
|
||||
</label>
|
||||
<input
|
||||
ref={nameRef}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="예: 수주 관리"
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-[var(--v5-primary)]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
아이콘
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ICON_PRESETS.map((i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setIcon(i)}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-md border text-base transition-colors ${
|
||||
icon === i
|
||||
? 'border-[var(--v5-primary)] bg-[var(--v5-primary)]/10'
|
||||
: 'border-border hover:bg-accent'
|
||||
}`}
|
||||
aria-label={`아이콘 ${i}`}
|
||||
>
|
||||
{i}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
공유 범위
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex cursor-pointer items-start gap-2 rounded-md border border-border p-2.5 hover:bg-accent/40">
|
||||
<input
|
||||
type="radio"
|
||||
name="scope"
|
||||
checked={!isPersonal}
|
||||
onChange={() => setIsPersonal(false)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-foreground">회사 전체 공용</div>
|
||||
<div className="text-xs text-muted-foreground">같은 회사 사용자 모두가 볼 수 있습니다 (기본)</div>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-start gap-2 rounded-md border border-border p-2.5 hover:bg-accent/40">
|
||||
<input
|
||||
type="radio"
|
||||
name="scope"
|
||||
checked={isPersonal}
|
||||
onChange={() => setIsPersonal(true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-foreground">나만 보기 (개인 대시보드)</div>
|
||||
<div className="text-xs text-muted-foreground">내 사이드바에만 표시됩니다</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
className="rounded-md border border-border bg-background px-3 py-1.5 text-sm hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !name.trim()}
|
||||
className="rounded-md bg-[var(--v5-primary)] px-3 py-1.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? '생성 중...' : '만들기'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useRef, useCallback, useEffect, forwardRef } from 'react';
|
||||
import { useDashboardStore } from '@/stores/dashboardStore';
|
||||
import { deleteDashboardCard } from '@/lib/api/dashMenu';
|
||||
import { toast } from 'sonner';
|
||||
import { DashboardCard } from './DashboardCard';
|
||||
import { DashboardEmpty } from './DashboardEmpty';
|
||||
|
||||
@@ -22,6 +24,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||
const editMode = useDashboardStore((s) => s.editMode);
|
||||
const updateCard = useDashboardStore((s) => s.updateCard);
|
||||
const removeCard = useDashboardStore((s) => s.removeCard);
|
||||
const activeDashboardId = useDashboardStore((s) => s.activeDashboardId);
|
||||
const internalRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = (externalRef as React.RefObject<HTMLDivElement | null>) ?? internalRef;
|
||||
const dragRef = useRef<{
|
||||
@@ -143,10 +146,22 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||
updateCard(cardId, { is_collapsed: !wasCollapsed });
|
||||
}, [cards, updateCard]);
|
||||
|
||||
const handleRemove = useCallback((cardId: string) => {
|
||||
const handleRemove = useCallback(async (cardId: string) => {
|
||||
if (!confirm('이 카드를 삭제하시겠습니까?')) return;
|
||||
if (!activeDashboardId) {
|
||||
toast.error('활성 대시보드가 없습니다');
|
||||
return;
|
||||
}
|
||||
// 낙관적 UI: 먼저 로컬에서 제거 후 서버 호출. 실패 시 알림.
|
||||
removeCard(cardId);
|
||||
}, [removeCard]);
|
||||
try {
|
||||
await deleteDashboardCard(activeDashboardId, cardId);
|
||||
toast.success('카드 삭제됨');
|
||||
} catch (err) {
|
||||
console.error('[Dashboard] 카드 삭제 실패', err);
|
||||
toast.error('카드 삭제 실패 — 새로고침 후 다시 시도해주세요');
|
||||
}
|
||||
}, [activeDashboardId, removeCard]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -208,6 +208,15 @@ export function DashboardCard({
|
||||
setFormRow(null);
|
||||
}, []);
|
||||
|
||||
// 폼 row 패치 핸들러 (등록/수정 뷰 input 바인딩용)
|
||||
const handleFormRowChange = useCallback((patch: Record<string, any>) => {
|
||||
setFormRow((prev) => ({ ...(prev ?? {}), ...patch }));
|
||||
}, []);
|
||||
|
||||
const handleFormSubmit = useCallback(() => {
|
||||
if (formRow) handleSubmitForm(formRow);
|
||||
}, [formRow, handleSubmitForm]);
|
||||
|
||||
// TemplateRenderer 로 전달할 공유 context
|
||||
const renderContext: TemplateRenderContext = useMemo(
|
||||
() => ({
|
||||
@@ -224,6 +233,10 @@ export function DashboardCard({
|
||||
onAdd: handleAdd,
|
||||
onEdit: handleEdit,
|
||||
onDelete: handleDelete,
|
||||
formRow: formRow ?? undefined,
|
||||
onFormRowChange: handleFormRowChange,
|
||||
onFormSubmit: handleFormSubmit,
|
||||
onFormCancel: closeForm,
|
||||
}),
|
||||
[
|
||||
fields,
|
||||
@@ -239,9 +252,26 @@ export function DashboardCard({
|
||||
handleAdd,
|
||||
handleEdit,
|
||||
handleDelete,
|
||||
formRow,
|
||||
handleFormRowChange,
|
||||
handleFormSubmit,
|
||||
closeForm,
|
||||
],
|
||||
);
|
||||
|
||||
// 등록/수정 뷰가 스튜디오에서 구성되어 있는지 체크 (있으면 TemplateRenderer로 렌더)
|
||||
const hasCustomCreateView = useMemo(() => {
|
||||
const cv = (template?.views as any)?.create;
|
||||
if (Array.isArray(cv)) return cv.length > 0;
|
||||
return Array.isArray(cv?.components) && cv.components.length > 0;
|
||||
}, [template]);
|
||||
|
||||
const hasCustomEditView = useMemo(() => {
|
||||
const ev = (template?.views as any)?.edit;
|
||||
if (Array.isArray(ev)) return ev.length > 0;
|
||||
return Array.isArray(ev?.components) && ev.components.length > 0;
|
||||
}, [template]);
|
||||
|
||||
return (
|
||||
<div className={`dash-card${isCollapsed ? ' collapsed' : ''}`}>
|
||||
<div className="dash-card-head">
|
||||
@@ -330,13 +360,17 @@ export function DashboardCard({
|
||||
|
||||
<div className="dash-resize-handle" data-resize="true" />
|
||||
|
||||
{formMode && formRow && (
|
||||
{formMode && formRow && template && (
|
||||
<FormOverlay
|
||||
title={`${templateName} ${formMode === 'create' ? '등록' : '수정'}`}
|
||||
fields={fields}
|
||||
initialRow={formRow}
|
||||
onSubmit={handleSubmitForm}
|
||||
onClose={closeForm}
|
||||
template={template}
|
||||
view={formMode}
|
||||
hasCustomView={formMode === 'create' ? hasCustomCreateView : hasCustomEditView}
|
||||
context={renderContext}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -349,9 +383,23 @@ interface FormOverlayProps {
|
||||
initialRow: Record<string, any>;
|
||||
onSubmit: (row: Record<string, any>) => void;
|
||||
onClose: () => void;
|
||||
template: Template;
|
||||
view: 'create' | 'edit';
|
||||
hasCustomView: boolean;
|
||||
context: TemplateRenderContext;
|
||||
}
|
||||
|
||||
function FormOverlay({ title, fields, initialRow, onSubmit, onClose }: FormOverlayProps) {
|
||||
function FormOverlay({
|
||||
title,
|
||||
fields,
|
||||
initialRow,
|
||||
onSubmit,
|
||||
onClose,
|
||||
template,
|
||||
view,
|
||||
hasCustomView,
|
||||
context,
|
||||
}: FormOverlayProps) {
|
||||
return (
|
||||
<div
|
||||
className="dash-form-overlay"
|
||||
@@ -367,12 +415,18 @@ function FormOverlay({ title, fields, initialRow, onSubmit, onClose }: FormOverl
|
||||
</button>
|
||||
</div>
|
||||
<div className="dash-form-body">
|
||||
{hasCustomView ? (
|
||||
// 스튜디오에서 구성한 등록/수정 뷰 → TemplateRenderer
|
||||
<TemplateRenderer template={template} context={context} view={view} />
|
||||
) : (
|
||||
// 기본 FcForm fallback (스튜디오에서 뷰 구성 안 한 경우)
|
||||
<FcForm
|
||||
fields={fields}
|
||||
loadRow={initialRow}
|
||||
onSubmit={onSubmit}
|
||||
config={{ columns: 2 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
interface DashboardEmptyProps {
|
||||
dashboardName: string;
|
||||
onOpenLibrary: () => void;
|
||||
@@ -7,14 +9,35 @@ interface DashboardEmptyProps {
|
||||
|
||||
export function DashboardEmpty({ dashboardName, onOpenLibrary }: DashboardEmptyProps) {
|
||||
return (
|
||||
<div className="dash-empty">
|
||||
<div className="dash-empty-icon">📋</div>
|
||||
<div
|
||||
className="dash-empty"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onOpenLibrary}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onOpenLibrary();
|
||||
}
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="dash-empty-icon" style={{ color: 'var(--v5-primary)' }}>
|
||||
<Plus size={38} />
|
||||
</div>
|
||||
<div className="dash-empty-title">{dashboardName}</div>
|
||||
<div className="dash-empty-desc">
|
||||
아직 템플릿이 없습니다. <b>+ 템플릿 추가</b> 버튼으로 첫 카드를 배치하세요.
|
||||
이제 템플릿을 추가합니다. 이 영역을 클릭하거나 상단 <b>템플릿 추가</b> 버튼을 눌러주세요.
|
||||
</div>
|
||||
<button className="dash-empty-btn" onClick={onOpenLibrary}>
|
||||
+ 템플릿 추가
|
||||
<button
|
||||
className="dash-empty-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenLibrary();
|
||||
}}
|
||||
>
|
||||
<Plus size={14} style={{ marginRight: 4, verticalAlign: '-2px' }} />
|
||||
템플릿 추가
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,12 +5,10 @@ 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';
|
||||
@@ -20,10 +18,17 @@ import { CardSettingsPanel } from './CardSettingsPanel';
|
||||
import { ControlMode } from '@/components/control/ControlMode';
|
||||
import { ControlPalette } from '@/components/control/ControlPalette';
|
||||
import { useControlMode } from '@/components/control/hooks/useControlMode';
|
||||
import { useMenu } from '@/contexts/MenuContext';
|
||||
import { toast } from 'sonner';
|
||||
import '@/styles/dashboard.css';
|
||||
|
||||
export function DashboardLayout() {
|
||||
interface DashboardLayoutProps {
|
||||
/** 단일 대시보드 모드: AppLayout 사이드바 메뉴가 대시보드 목록 역할을 하므로
|
||||
* 자체 DashboardSidebar 를 숨기고 지정된 dashboardId 만 로드한다. */
|
||||
dashboardId?: string;
|
||||
}
|
||||
|
||||
export function DashboardLayout({ dashboardId: singleDashboardId }: DashboardLayoutProps = {}) {
|
||||
const {
|
||||
dashboards,
|
||||
activeDashboardId,
|
||||
@@ -34,12 +39,17 @@ export function DashboardLayout() {
|
||||
setCards,
|
||||
addCard,
|
||||
setEditMode,
|
||||
openCreate,
|
||||
libOpen,
|
||||
openLib,
|
||||
closeLib,
|
||||
} = useDashboardStore();
|
||||
|
||||
const controlActive = useControlMode((s) => s.active);
|
||||
const controlMode = useControlMode((s) => s.mode);
|
||||
const { refreshMenus } = useMenu();
|
||||
const isSingleMode = !!singleDashboardId;
|
||||
|
||||
const [libOpen, setLibOpen] = useState(false);
|
||||
const [settingsCardId, setSettingsCardId] = useState<string | null>(null);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
@@ -47,13 +57,16 @@ export function DashboardLayout() {
|
||||
// 대시보드 목록 로드
|
||||
const loadDashboards = useCallback(async () => {
|
||||
try {
|
||||
const result = await getDashboardList({ limit: 100 });
|
||||
const result = await getDashboardList();
|
||||
const list: Record<string, any>[] = result?.list ?? [];
|
||||
setDashboards(list);
|
||||
|
||||
// 첫 번째 대시보드 자동 선택
|
||||
if (list.length > 0 && !activeDashboardId) {
|
||||
const firstId = list[0].dashboard_id ?? list[0].DASHBOARD_ID;
|
||||
// 단일 모드에서는 URL 의 dashboardId 를 활성화
|
||||
if (isSingleMode && singleDashboardId) {
|
||||
setActiveDashboard(singleDashboardId);
|
||||
} else if (list.length > 0 && !activeDashboardId) {
|
||||
// 멀티 모드에서는 첫 번째 대시보드 자동 선택
|
||||
const firstId = list[0].objid ?? list[0].OBJID ?? list[0].dashboard_id ?? list[0].DASHBOARD_ID;
|
||||
setActiveDashboard(firstId);
|
||||
}
|
||||
setInitialized(true);
|
||||
@@ -61,10 +74,17 @@ export function DashboardLayout() {
|
||||
console.error('[Dashboard] Load failed:', err);
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [setDashboards, setActiveDashboard, activeDashboardId]);
|
||||
}, [setDashboards, setActiveDashboard, activeDashboardId, isSingleMode, singleDashboardId]);
|
||||
|
||||
useEffect(() => { loadDashboards(); }, []);
|
||||
|
||||
// 단일 모드에서 URL id 가 바뀌면 활성 전환
|
||||
useEffect(() => {
|
||||
if (isSingleMode && singleDashboardId && singleDashboardId !== activeDashboardId) {
|
||||
setActiveDashboard(singleDashboardId);
|
||||
}
|
||||
}, [isSingleMode, singleDashboardId, activeDashboardId, setActiveDashboard]);
|
||||
|
||||
// 대시보드 전환 시 카드 로드
|
||||
const loadCards = useCallback(async (dashId: string) => {
|
||||
try {
|
||||
@@ -84,35 +104,23 @@ export function DashboardLayout() {
|
||||
}, [activeDashboardId, loadCards, setEditMode]);
|
||||
|
||||
// 활성 대시보드 정보
|
||||
const activeDash = dashboards.find(
|
||||
(d) => (d.dashboard_id ?? d.DASHBOARD_ID) === activeDashboardId
|
||||
);
|
||||
const dashKey = (d: Record<string, any>) =>
|
||||
d.objid ?? d.OBJID ?? d.dashboard_id ?? d.DASHBOARD_ID;
|
||||
const activeDash = dashboards.find((d) => dashKey(d) === 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('대시보드 생성 실패');
|
||||
}
|
||||
};
|
||||
// 대시보드 CRUD — 생성 모달은 AppLayout(전역 헤더) 이 소유. 여기서는 모달만 열어준다.
|
||||
const handleAddDashboard = () => openCreate();
|
||||
|
||||
const handleRenameDashboard = async (id: string) => {
|
||||
const dash = dashboards.find((d) => (d.dashboard_id ?? d.DASHBOARD_ID) === id);
|
||||
const dash = dashboards.find((d) => dashKey(d) === id);
|
||||
if (!dash) return;
|
||||
const newName = prompt('새 이름:', dash.name ?? dash.NAME ?? '');
|
||||
if (!newName?.trim()) return;
|
||||
try {
|
||||
await updateDashboard(id, { name: newName.trim() });
|
||||
await loadDashboards();
|
||||
try { await refreshMenus(); } catch { /* refresh 실패 무시 */ }
|
||||
toast.success('이름 변경됨');
|
||||
} catch (err) {
|
||||
toast.error('이름 변경 실패');
|
||||
@@ -120,15 +128,16 @@ export function DashboardLayout() {
|
||||
};
|
||||
|
||||
const handleDeleteDashboard = async (id: string) => {
|
||||
if (dashboards.length <= 1) {
|
||||
if (!isSingleMode && dashboards.length <= 1) {
|
||||
toast.warning('마지막 대시보드는 삭제할 수 없습니다');
|
||||
return;
|
||||
}
|
||||
const dash = dashboards.find((d) => (d.dashboard_id ?? d.DASHBOARD_ID) === id);
|
||||
const dash = dashboards.find((d) => dashKey(d) === id);
|
||||
if (!confirm(`"${dash?.name ?? dash?.NAME}" 을 삭제합니다.`)) return;
|
||||
try {
|
||||
await deleteDashboard(id);
|
||||
await loadDashboards();
|
||||
try { await refreshMenus(); } catch { /* refresh 실패 무시 */ }
|
||||
toast.info('대시보드 삭제됨');
|
||||
} catch (err) {
|
||||
toast.error('삭제 실패');
|
||||
@@ -140,32 +149,40 @@ export function DashboardLayout() {
|
||||
setActiveDashboard(id);
|
||||
};
|
||||
|
||||
// 템플릿 추가 (라이브러리 → 카드)
|
||||
// 템플릿 추가 (라이브러리 → 카드) — 화면 정중앙 배치 + 기존 카드 수만큼 stagger
|
||||
const handleSelectTemplate = async (template: Record<string, any>) => {
|
||||
if (!activeDashboardId) return;
|
||||
const templateId = template.template_id ?? template.TEMPLATE_ID;
|
||||
try {
|
||||
const cv = canvasRef.current;
|
||||
const cw = cv?.clientWidth ?? 1000;
|
||||
const ch = cv?.clientHeight ?? 600;
|
||||
const w = Math.min(700, Math.max(320, cw - 40));
|
||||
const h = Math.min(450, Math.max(240, ch - 40));
|
||||
const stagger = (cards.length % 8) * 28;
|
||||
const x = Math.max(16, Math.round((cw - w) / 2) + stagger);
|
||||
const y = Math.max(16, Math.round((ch - h) / 2) + stagger);
|
||||
|
||||
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,
|
||||
position_x: x,
|
||||
position_y: y,
|
||||
width: w,
|
||||
height: h,
|
||||
});
|
||||
// 새 카드를 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,
|
||||
position_x: x,
|
||||
position_y: y,
|
||||
width: w,
|
||||
height: h,
|
||||
is_collapsed: false,
|
||||
});
|
||||
setLibOpen(false);
|
||||
closeLib();
|
||||
if (!editMode) setEditMode(true);
|
||||
toast.success(`${template.name ?? template.NAME} 카드를 추가했습니다`);
|
||||
} catch (err) {
|
||||
@@ -207,27 +224,28 @@ export function DashboardLayout() {
|
||||
|
||||
return (
|
||||
<div className="dash-shell">
|
||||
{/* 사이드바 — 제어 편집 모드에서는 팔레트로 교체 */}
|
||||
{/* 사이드바 — 단일 모드에선 AppLayout 메뉴가 대시보드 목록 역할이므로 자체 사이드바 숨김.
|
||||
제어 편집 모드에서만 팔레트로 오버레이. */}
|
||||
{controlActive && controlMode === 'edit' ? (
|
||||
<div className="dash-side">
|
||||
<div className="dash-side-sec" style={{ color: 'var(--ctrl-cyan)' }}>제어 팔레트</div>
|
||||
<ControlPalette onDropTable={() => {}} onDropControl={() => {}} />
|
||||
</div>
|
||||
) : (
|
||||
) : !isSingleMode ? (
|
||||
<DashboardSidebar
|
||||
onAddDashboard={handleAddDashboard}
|
||||
onRenameDashboard={handleRenameDashboard}
|
||||
onDeleteDashboard={handleDeleteDashboard}
|
||||
onSwitchDashboard={handleSwitchDashboard}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
<div className="dash-content">
|
||||
{activeDashboardId ? (
|
||||
<>
|
||||
<DashboardToolbar
|
||||
dashboardName={dashName}
|
||||
cardCount={cards.length}
|
||||
onOpenLibrary={() => setLibOpen(true)}
|
||||
onOpenLibrary={() => openLib()}
|
||||
onSaveLayout={handleSaveLayout}
|
||||
/>
|
||||
{/* 제어 모드 툴바 + 오버레이 */}
|
||||
@@ -236,7 +254,7 @@ export function DashboardLayout() {
|
||||
<DashboardCanvas
|
||||
ref={canvasRef}
|
||||
dashboardName={dashName}
|
||||
onOpenLibrary={() => setLibOpen(true)}
|
||||
onOpenLibrary={() => openLib()}
|
||||
onOpenSettings={(id) => setSettingsCardId(id)}
|
||||
controlMode={controlActive}
|
||||
/>
|
||||
@@ -272,7 +290,7 @@ export function DashboardLayout() {
|
||||
|
||||
<TemplateLibraryModal
|
||||
open={libOpen}
|
||||
onClose={() => setLibOpen(false)}
|
||||
onClose={() => closeLib()}
|
||||
onSelectTemplate={handleSelectTemplate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function TemplateLibraryModal({ open, onClose, onSelectTemplate }: Templa
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getTemplateList({ status: 'published', limit: 100 });
|
||||
const result = await getTemplateList();
|
||||
setTemplates(result?.list ?? []);
|
||||
} catch (err) {
|
||||
console.error('[TemplateLibrary] Load failed:', err);
|
||||
|
||||
@@ -1,29 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { FcTable, FcSearch, FcButton } from '@/components/fc';
|
||||
import type {
|
||||
Template,
|
||||
TemplateComponent,
|
||||
FieldConfig,
|
||||
ButtonConfig,
|
||||
} from '@/types/invyone-component';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { Template, FieldConfig } from '@/types/invyone-component';
|
||||
import { ComponentRegistry } from '@/lib/registry/ComponentRegistry';
|
||||
import {
|
||||
FULL_WIDTH_IDS,
|
||||
detectResponsiveGroups,
|
||||
resolveResponsivePolicy,
|
||||
} from '@/lib/registry/responsive-policy';
|
||||
// side-effect: 컴포넌트 레지스트리 등록
|
||||
import '@/lib/registry/components';
|
||||
|
||||
/**
|
||||
* Template.views.list.components 를 세로 스택(flex-column) 으로 렌더하는
|
||||
* 런타임 컴포넌트.
|
||||
*
|
||||
* ── 레이아웃 원칙 ──────────────────────────────────────────────
|
||||
* 카드 내부는 항상 자동 레이아웃: 컴포넌트를 `order` 로 정렬하고, 같은
|
||||
* `row` 키를 가진 연속 블록은 flex-row 로 묶는다. 각 블록은 `flex-1
|
||||
* min-w-0` 로 가로폭을 분배받고, 행은 `flex-wrap` 이라 카드 폭이 좁아
|
||||
* 지면 자동으로 줄바꿈된다. px 좌표는 일절 사용하지 않는다.
|
||||
*
|
||||
* 레퍼런스: `frontend/app/test-card-responsive/page.tsx` (Phase 1 반응형
|
||||
* 검증 구현).
|
||||
*
|
||||
* 공유 상태(data, selectedRow, searchParams 등) 는 DashboardCard 에서
|
||||
* 관리하고 `context` 로 전달받는다.
|
||||
*/
|
||||
// DB 의 V2 포맷 저장본을 해석해서 ComponentRegistry 로 렌더.
|
||||
// 레이아웃은 절대좌표(scale)가 아니라 카드 폭 기반 @container 반응형.
|
||||
// 같은 행(y 좌표 비슷) 블록은 flex-row, 카드 폭 좁으면 세로 스택.
|
||||
// 인접 동종 단일 컴포넌트는 런타임에 가상 그룹으로 묶여 정책 적용됨.
|
||||
|
||||
const LEGACY_TO_UNIFIED: Record<string, string> = {
|
||||
'v2-divider-line': 'divider',
|
||||
'divider-line': 'divider',
|
||||
'v2-split-line': 'divider',
|
||||
'v2-text-display': 'title',
|
||||
'text-display': 'title',
|
||||
'v2-button-primary': 'button',
|
||||
'button-primary': 'button',
|
||||
'v2-table-search-widget': 'search',
|
||||
'table-search-widget': 'search',
|
||||
'v2-input': 'input',
|
||||
'v2-select': 'input',
|
||||
'v2-date': 'input',
|
||||
'text-input': 'input',
|
||||
'number-input': 'input',
|
||||
'date-input': 'input',
|
||||
'select-basic': 'input',
|
||||
'checkbox-basic': 'input',
|
||||
'textarea-basic': 'input',
|
||||
'slider-basic': 'input',
|
||||
'radio-basic': 'input',
|
||||
'toggle-switch': 'input',
|
||||
'v2-aggregation-widget': 'stats',
|
||||
'aggregation-widget': 'stats',
|
||||
'v2-status-count': 'stats',
|
||||
'v2-card-display': 'stats',
|
||||
'card-display': 'stats',
|
||||
'v2-table-list': 'table',
|
||||
'table-list': 'table',
|
||||
'v2-table-grouped': 'table',
|
||||
'v2-pivot-grid': 'table',
|
||||
'v2-tabs-widget': 'container',
|
||||
'v2-section-card': 'container',
|
||||
'v2-section-paper': 'container',
|
||||
'v2-repeat-container': 'container',
|
||||
'v2-repeater': 'container',
|
||||
'section-card': 'container',
|
||||
'section-paper': 'container',
|
||||
'accordion-basic': 'container',
|
||||
};
|
||||
|
||||
export type ViewKey = 'list' | 'create' | 'edit';
|
||||
|
||||
export interface TemplateRenderContext {
|
||||
fields: FieldConfig[];
|
||||
@@ -39,215 +74,707 @@ export interface TemplateRenderContext {
|
||||
onAdd: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
formRow?: Record<string, any>;
|
||||
onFormRowChange?: (patch: Record<string, any>) => void;
|
||||
onFormSubmit?: () => void;
|
||||
onFormCancel?: () => void;
|
||||
}
|
||||
|
||||
interface TemplateRendererProps {
|
||||
template: Template;
|
||||
context: TemplateRenderContext;
|
||||
view?: ViewKey;
|
||||
}
|
||||
|
||||
export function TemplateRenderer({ template, context }: TemplateRendererProps) {
|
||||
const rawComponents = (template.views as any)?.list?.components ?? [];
|
||||
|
||||
if (!Array.isArray(rawComponents) || rawComponents.length === 0) {
|
||||
return <EmptyTemplate />;
|
||||
interface Block {
|
||||
id: string;
|
||||
componentId: string;
|
||||
rawCid: string;
|
||||
config: Record<string, any>;
|
||||
pos: { left: number; top: number; width: number; height: number };
|
||||
}
|
||||
|
||||
const normalized = normalizeBlocks(rawComponents as any[]);
|
||||
const rows = groupByRow(normalized);
|
||||
function extractIdFromUrl(url?: string): string {
|
||||
if (!url || typeof url !== 'string') return '';
|
||||
const parts = url.split('/').filter(Boolean);
|
||||
return parts[parts.length - 1] ?? '';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col gap-2 overflow-auto p-2">
|
||||
{rows.map((row, i) => (
|
||||
<div key={i} className="flex w-full flex-row flex-wrap gap-2">
|
||||
{row.map((block) => (
|
||||
<div key={block.id} className="min-w-0 flex-1">
|
||||
<ComponentSwitch block={block} context={context} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
// 스튜디오 저장본의 style 에 박혀있는 고정 width/height 는 반응형 방해 — 모두 제거.
|
||||
// wrapper div 가 block 크기를 담당하고, 내부 컴포넌트는 100%/100% 로 채움.
|
||||
const FIXED_SIZE_KEYS = new Set([
|
||||
'width',
|
||||
'height',
|
||||
'minWidth',
|
||||
'minHeight',
|
||||
'maxWidth',
|
||||
'maxHeight',
|
||||
'min-width',
|
||||
'min-height',
|
||||
'max-width',
|
||||
'max-height',
|
||||
]);
|
||||
|
||||
function sanitizeStyleObject(v: any): Record<string, any> {
|
||||
if (!v || typeof v !== 'object' || Array.isArray(v)) return {};
|
||||
const out: Record<string, any> = {};
|
||||
for (const [k, val] of Object.entries(v)) {
|
||||
if (/^\d+$/.test(k)) continue; // "0","1".. indexed 잔재 제거
|
||||
if (Array.isArray(val)) continue;
|
||||
if (FIXED_SIZE_KEYS.has(k)) continue; // 고정 크기 제거
|
||||
out[k] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickViewsObj(template: any): any {
|
||||
const v = template?.views ?? template?.VIEWS;
|
||||
if (!v) return null;
|
||||
if (typeof v === 'string') {
|
||||
try { return JSON.parse(v); } catch { return null; }
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function extractComponents(viewData: any): any[] {
|
||||
if (!viewData) return [];
|
||||
if (Array.isArray(viewData)) return viewData;
|
||||
if (Array.isArray(viewData.components)) return viewData.components;
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeBlocks(raw: any[]): Block[] {
|
||||
return raw
|
||||
.filter((c) => c && typeof c === 'object')
|
||||
.map((c, i) => {
|
||||
const id = String(c.id ?? `blk_${i}`);
|
||||
const overrides =
|
||||
c.overrides && typeof c.overrides === 'object' && !Array.isArray(c.overrides)
|
||||
? c.overrides
|
||||
: null;
|
||||
const rawCid = String(
|
||||
c.componentId ?? c.componentType ?? c.type ?? extractIdFromUrl(c.url) ?? overrides?.type ?? '',
|
||||
);
|
||||
const componentId = LEGACY_TO_UNIFIED[rawCid] ?? rawCid;
|
||||
const configSrc =
|
||||
(c.config && typeof c.config === 'object' && !Array.isArray(c.config) ? c.config : null) ??
|
||||
(c.componentConfig && typeof c.componentConfig === 'object' && !Array.isArray(c.componentConfig)
|
||||
? c.componentConfig
|
||||
: null) ??
|
||||
overrides ??
|
||||
{};
|
||||
const safeStyle = sanitizeStyleObject(configSrc.style);
|
||||
const config = { ...configSrc, style: safeStyle };
|
||||
const p = c.position ?? {};
|
||||
const s = c.size ?? {};
|
||||
const pos = {
|
||||
left: Number(p.left ?? p.x ?? 0) || 0,
|
||||
top: Number(p.top ?? p.y ?? 0) || 0,
|
||||
width: Number(p.width ?? s.width ?? 200) || 200,
|
||||
height: Number(p.height ?? s.height ?? 100) || 100,
|
||||
};
|
||||
return { id, componentId, rawCid, config, pos };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 구 포맷(FreePosition 기반) 으로 저장된 블록을 최소 호환 처리:
|
||||
* `order` 가 없으면 배열 인덱스를 그대로 부여해 최소한 순서만 유지한다.
|
||||
* 구 데이터는 빌더에서 새로 저장하는 순간 order 포맷으로 이관된다.
|
||||
*/
|
||||
function normalizeBlocks(raw: any[]): TemplateComponent[] {
|
||||
return raw.map((c, i) => ({
|
||||
...c,
|
||||
order: typeof c?.order === 'number' ? c.order : i,
|
||||
row: typeof c?.row === 'number' ? c.row : undefined,
|
||||
config: c?.config ?? {},
|
||||
})) as TemplateComponent[];
|
||||
}
|
||||
// top 기준 band clustering.
|
||||
// 이전 overlap 기반 판정은 mixed-height (키 큰 카드 + 키 작은 버튼) 에서
|
||||
// 버튼이 카드 밴드 안에 박혀있을 때 같은 row 로 오판정 → "다음 줄" 의도를 깬다.
|
||||
// 새 규칙:
|
||||
// - 블록을 top 오름차순 정렬
|
||||
// - 현재 row 의 최소 top (curTopMin) 과 새 블록 top 의 차이가
|
||||
// tolerance 이내면 같은 row. 초과하면 다음 row.
|
||||
// - tolerance = max( min(curMaxH, b.height) * 0.5, 16px )
|
||||
// → 디자이너에서 픽셀 스냅된 약간의 top 차이는 흡수,
|
||||
// 작은 블록(버튼)이 큰 블록(카드) 하단에 붙어있으면 다음 row 로 분리.
|
||||
// FULL_WIDTH(테이블/컨테이너 등)는 단독 행.
|
||||
const ROW_TOP_TOLERANCE_RATIO = 0.5;
|
||||
const ROW_TOP_TOLERANCE_MIN_PX = 16;
|
||||
|
||||
/**
|
||||
* `order` 로 정렬한 뒤 `row` 키가 같은 연속 블록을 같은 행으로 묶는다.
|
||||
* undefined row 는 항상 단독 행으로 취급한다.
|
||||
*/
|
||||
function groupByRow(blocks: TemplateComponent[]): TemplateComponent[][] {
|
||||
const sorted = [...blocks].sort((a, b) => a.order - b.order);
|
||||
const result: TemplateComponent[][] = [];
|
||||
let current: TemplateComponent[] = [];
|
||||
let currentKey: number | undefined = undefined;
|
||||
function groupIntoRows(blocks: Block[]): Block[][] {
|
||||
if (blocks.length === 0) return [];
|
||||
const sorted = [...blocks].sort(
|
||||
(a, b) => a.pos.top - b.pos.top || a.pos.left - b.pos.left,
|
||||
);
|
||||
const rows: Block[][] = [];
|
||||
let current: Block[] = [];
|
||||
let curTopMin = Infinity;
|
||||
let curMaxH = 0;
|
||||
|
||||
const flush = () => {
|
||||
if (current.length > 0) {
|
||||
result.push(current);
|
||||
if (current.length) rows.push(current);
|
||||
current = [];
|
||||
currentKey = undefined;
|
||||
}
|
||||
curTopMin = Infinity;
|
||||
curMaxH = 0;
|
||||
};
|
||||
|
||||
for (const block of sorted) {
|
||||
if (block.row === undefined) {
|
||||
for (const b of sorted) {
|
||||
if (FULL_WIDTH_IDS.has(b.componentId)) {
|
||||
flush();
|
||||
result.push([block]);
|
||||
rows.push([b]);
|
||||
continue;
|
||||
}
|
||||
if (currentKey === block.row) {
|
||||
current.push(block);
|
||||
let sameRow = false;
|
||||
if (current.length > 0) {
|
||||
const topDiff = b.pos.top - curTopMin;
|
||||
const smallerH = Math.min(curMaxH, b.pos.height);
|
||||
const tolerance = Math.max(
|
||||
smallerH * ROW_TOP_TOLERANCE_RATIO,
|
||||
ROW_TOP_TOLERANCE_MIN_PX,
|
||||
);
|
||||
sameRow = topDiff < tolerance;
|
||||
}
|
||||
if (sameRow) {
|
||||
current.push(b);
|
||||
if (b.pos.top < curTopMin) curTopMin = b.pos.top;
|
||||
if (b.pos.height > curMaxH) curMaxH = b.pos.height;
|
||||
} else {
|
||||
flush();
|
||||
current = [block];
|
||||
currentKey = block.row;
|
||||
current = [b];
|
||||
curTopMin = b.pos.top;
|
||||
curMaxH = b.pos.height;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return result;
|
||||
return rows.map((row) => [...row].sort((a, b) => a.pos.left - b.pos.left));
|
||||
}
|
||||
|
||||
function EmptyTemplate() {
|
||||
export function TemplateRenderer({
|
||||
template,
|
||||
context,
|
||||
view = 'list',
|
||||
}: TemplateRendererProps) {
|
||||
const viewsObj = useMemo(() => pickViewsObj(template), [template]);
|
||||
const viewData = viewsObj?.[view];
|
||||
const blocks = useMemo(
|
||||
() => normalizeBlocks(extractComponents(viewData)),
|
||||
[viewData],
|
||||
);
|
||||
const rows = useMemo(() => groupIntoRows(blocks), [blocks]);
|
||||
const canvasWidth = useMemo(() => {
|
||||
const sr = viewsObj?.screenResolution ?? viewsObj?.designSize;
|
||||
if (sr?.width) return Number(sr.width) || 1920;
|
||||
// fallback: blocks 의 최대 right
|
||||
let maxR = 0;
|
||||
for (const b of blocks) {
|
||||
const r = b.pos.left + b.pos.width;
|
||||
if (r > maxR) maxR = r;
|
||||
}
|
||||
return maxR > 0 ? maxR : 1920;
|
||||
}, [viewsObj, blocks]);
|
||||
const canvasHeight = useMemo(() => {
|
||||
const sr = viewsObj?.screenResolution ?? viewsObj?.designSize;
|
||||
if (sr?.height) return Number(sr.height) || 1080;
|
||||
let maxB = 0;
|
||||
for (const b of blocks) {
|
||||
const bb = b.pos.top + b.pos.height;
|
||||
if (bb > maxB) maxB = bb;
|
||||
}
|
||||
return maxB > 0 ? maxB : 1080;
|
||||
}, [viewsObj, blocks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (blocks.length > 0 && !blocks[0].rawCid) {
|
||||
console.warn('[TemplateRenderer] empty componentId', {
|
||||
templateKeys: Object.keys(template ?? {}),
|
||||
firstBlockRaw: extractComponents(viewData)[0],
|
||||
});
|
||||
}
|
||||
}, [blocks, template, viewData]);
|
||||
|
||||
// ── 디버그 덤프: URL 에 ?debug=1 또는 localStorage.INVYONE_DEBUG 일 때 활성화
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
const enabled = qs.has('debug') || window.localStorage?.getItem('INVYONE_DEBUG') === '1';
|
||||
if (!enabled) return;
|
||||
if (!blocks.length) return;
|
||||
|
||||
const dump = () => {
|
||||
const wrapperEl = document.querySelector('.dash-tpl-wrapper') as HTMLElement | null;
|
||||
const wrapperCS = wrapperEl ? getComputedStyle(wrapperEl) : null;
|
||||
const wrapperWidth = wrapperEl?.clientWidth ?? null;
|
||||
const wrapperHeight = wrapperEl?.clientHeight ?? null;
|
||||
const rowEls = wrapperEl
|
||||
? Array.from(wrapperEl.querySelectorAll(':scope > .dash-tpl-row, :scope > .dash-tpl-full')) as HTMLElement[]
|
||||
: [];
|
||||
const rowComputed = rowEls.map((el, i) => {
|
||||
const cs = getComputedStyle(el);
|
||||
return {
|
||||
idx: i,
|
||||
className: el.className,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
flexDirection: cs.flexDirection,
|
||||
flexWrap: cs.flexWrap,
|
||||
alignItems: cs.alignItems,
|
||||
children: Array.from(el.children).map((c) => {
|
||||
const ccs = getComputedStyle(c as HTMLElement);
|
||||
return {
|
||||
class: (c as HTMLElement).className,
|
||||
clientWidth: (c as HTMLElement).clientWidth,
|
||||
clientHeight: (c as HTMLElement).clientHeight,
|
||||
display: ccs.display,
|
||||
flex: ccs.flex,
|
||||
width: ccs.width,
|
||||
marginLeft: ccs.marginLeft,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
/* eslint-disable no-console */
|
||||
console.groupCollapsed(
|
||||
'%c[INVYONE 반응엔진 덤프]',
|
||||
'color: #6c5ce7; font-weight: bold',
|
||||
`view=${view} blocks=${blocks.length} rows=${rows.length}`,
|
||||
);
|
||||
console.log('canvas (저장/추정):', { canvasWidth, canvasHeight });
|
||||
console.log('wrapper (DOM):', {
|
||||
clientWidth: wrapperWidth,
|
||||
clientHeight: wrapperHeight,
|
||||
containerType: wrapperCS?.containerType,
|
||||
containerName: wrapperCS?.containerName,
|
||||
narrowExpected: wrapperWidth != null ? wrapperWidth <= 800 : null,
|
||||
});
|
||||
console.table(
|
||||
blocks.map((b) => ({
|
||||
id: b.id,
|
||||
rawCid: b.rawCid,
|
||||
unifiedId: b.componentId,
|
||||
x: b.pos.left,
|
||||
y: b.pos.top,
|
||||
w: b.pos.width,
|
||||
h: b.pos.height,
|
||||
})),
|
||||
);
|
||||
console.log(
|
||||
'groupIntoRows (y 겹침 기준):',
|
||||
rows.map((row, i) => ({
|
||||
row: i,
|
||||
count: row.length,
|
||||
blocks: row.map((b) => `${b.componentId}#${b.id}`),
|
||||
yRange: [
|
||||
Math.min(...row.map((b) => b.pos.top)),
|
||||
Math.max(...row.map((b) => b.pos.top + b.pos.height)),
|
||||
],
|
||||
})),
|
||||
);
|
||||
const groupTrace = rows.map((row, i) => {
|
||||
const gs = detectResponsiveGroups(row);
|
||||
const rowTop = Math.min(...row.map((b) => b.pos.top));
|
||||
const rowBottom = Math.max(
|
||||
...row.map((b) => b.pos.top + b.pos.height),
|
||||
);
|
||||
return {
|
||||
row: i,
|
||||
rowHeight: rowBottom - rowTop,
|
||||
groups: gs.map((g) => {
|
||||
const policy = resolveResponsivePolicy({
|
||||
componentId: g.componentId,
|
||||
pos: g.bbox,
|
||||
canvasWidth,
|
||||
canvasHeight: rowBottom - rowTop,
|
||||
groupSize: g.blocks.length,
|
||||
config: g.blocks[0].config.responsive,
|
||||
});
|
||||
return {
|
||||
componentId: g.componentId,
|
||||
virtual: g.virtual,
|
||||
blockIds: g.blocks.map((b) => b.id),
|
||||
bbox: g.bbox,
|
||||
flexBasisPct:
|
||||
Math.round((g.bbox.width / canvasWidth) * 1000) / 10 + '%',
|
||||
appliedMode: policy.appliedMode,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
console.log('detectResponsiveGroups + appliedMode:', groupTrace);
|
||||
console.log('row/그룹 DOM computed:', rowComputed);
|
||||
console.groupEnd();
|
||||
/* eslint-enable no-console */
|
||||
};
|
||||
|
||||
// 초기 + 크기 변화 때 재덤프
|
||||
const id = window.setTimeout(dump, 50);
|
||||
let ro: ResizeObserver | null = null;
|
||||
const wrapperEl = document.querySelector('.dash-tpl-wrapper') as HTMLElement | null;
|
||||
if (wrapperEl && 'ResizeObserver' in window) {
|
||||
ro = new ResizeObserver(() => {
|
||||
window.clearTimeout((dump as any)._t);
|
||||
(dump as any)._t = window.setTimeout(dump, 80);
|
||||
});
|
||||
ro.observe(wrapperEl);
|
||||
}
|
||||
return () => {
|
||||
window.clearTimeout(id);
|
||||
ro?.disconnect();
|
||||
};
|
||||
}, [blocks, rows, canvasWidth, canvasHeight, view]);
|
||||
|
||||
if (!blocks.length) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 py-8 text-center">
|
||||
<div className="text-4xl opacity-40">📋</div>
|
||||
<div className="text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
템플릿이 비어있습니다
|
||||
{view === 'list' ? '템플릿이 비어있습니다' : '이 뷰가 비어있습니다'}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 opacity-80 dark:text-slate-400">
|
||||
빌더에서 컴포넌트를 배치한 뒤 게시하세요
|
||||
스튜디오에서 컴포넌트를 배치하세요
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComponentSwitch({
|
||||
block,
|
||||
context,
|
||||
}: {
|
||||
block: TemplateComponent;
|
||||
context: TemplateRenderContext;
|
||||
}) {
|
||||
const componentId = block.componentId;
|
||||
const config = (block.config ?? {}) as Record<string, any>;
|
||||
|
||||
switch (componentId) {
|
||||
case 'v2-table-list':
|
||||
return (
|
||||
<FcTable
|
||||
fields={context.fields}
|
||||
data={context.data}
|
||||
loading={context.loading}
|
||||
onRowSelect={context.onRowSelect}
|
||||
config={config}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'v2-table-search-widget':
|
||||
return (
|
||||
<FcSearch
|
||||
fields={context.fields}
|
||||
onSearch={context.onSearch}
|
||||
config={config}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'v2-button-primary': {
|
||||
const buttonConfig: ButtonConfig = {
|
||||
text: config.text ?? '버튼',
|
||||
actionType: config.actionType ?? 'save',
|
||||
variant: config.variant ?? 'default',
|
||||
confirm: config.confirm,
|
||||
};
|
||||
const handler = resolveActionHandler(buttonConfig.actionType, context);
|
||||
const needsRow =
|
||||
buttonConfig.actionType === 'edit' ||
|
||||
buttonConfig.actionType === 'delete';
|
||||
const disabled = needsRow && !context.selectedRow;
|
||||
return (
|
||||
<div className="flex w-full items-center">
|
||||
<FcButton config={buttonConfig} onClick={handler} disabled={disabled} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'v2-text-display':
|
||||
return (
|
||||
<div
|
||||
className="flex w-full items-center px-2 text-slate-700 dark:text-slate-200"
|
||||
className="dash-tpl-wrapper"
|
||||
style={{ containerType: 'inline-size' }}
|
||||
>
|
||||
<style>{`
|
||||
.dash-tpl-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
box-sizing: border-box;
|
||||
container-type: inline-size;
|
||||
container-name: tpl;
|
||||
}
|
||||
/* FULL_WIDTH 단독 row (table/container/accordion 등 main content):
|
||||
wrapper 의 남은 세로 공간을 먹어서 카드 크기에 맞게 확장. 카드가
|
||||
좁거나 상단 row 가 커지면 자연 축소 + 내부 자체 overflow 로 처리. */
|
||||
.dash-tpl-full {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
/* 일반 row: flex-row nowrap. 자기 원본 세로 크기 유지 (flex: 0 0 auto).
|
||||
stats reflow 등 내부가 세로로 커지면 row 도 자연 성장(basis auto). */
|
||||
.dash-tpl-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dash-tpl-spacer {
|
||||
min-width: 0;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
align-self: stretch;
|
||||
}
|
||||
.dash-tpl-group { box-sizing: border-box; min-width: 0; }
|
||||
.dash-tpl-item { box-sizing: border-box; min-width: 0; }
|
||||
.dash-tpl-spacer {
|
||||
align-self: stretch;
|
||||
min-width: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 버튼 compact 단계: 카드 폭이 좁아지면 버튼의 padding/font 를
|
||||
단계적으로 축소. 자동 세로 스택은 하지 않는다 — row 는 항상 horizontal.
|
||||
(stats 는 reflow 가 내부 열 수 조정으로 세로 확장 → row 도 자연 성장) */
|
||||
@container tpl (max-width: 900px) {
|
||||
.rp-fixed[data-comp="button"] button,
|
||||
.rp-fixed[data-comp="button"] .v5-btn {
|
||||
padding-left: 10px !important;
|
||||
padding-right: 10px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
@container tpl (max-width: 600px) {
|
||||
.rp-fixed[data-comp="button"] button,
|
||||
.rp-fixed[data-comp="button"] .v5-btn {
|
||||
padding-left: 6px !important;
|
||||
padding-right: 6px !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
}
|
||||
@container tpl (max-width: 420px) {
|
||||
.rp-fixed[data-comp="button"] button,
|
||||
.rp-fixed[data-comp="button"] .v5-btn {
|
||||
padding-left: 4px !important;
|
||||
padding-right: 4px !important;
|
||||
font-size: 10px !important;
|
||||
}
|
||||
}
|
||||
/* stats reflow 는 카드 좁아지면 자동 열 감소 (auto-fit minmax). 별도 강제 없음. */
|
||||
/* very narrow: 더 좁으면 reflow 도 1열 */
|
||||
@container tpl (max-width: 420px) {
|
||||
.rp-reflow { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
`}</style>
|
||||
{rows.map((row, ri) => {
|
||||
// FULL_WIDTH 단독 row — scroll 정책
|
||||
if (row.length === 1 && FULL_WIDTH_IDS.has(row[0].componentId)) {
|
||||
const b = row[0];
|
||||
const policy = resolveResponsivePolicy({
|
||||
componentId: b.componentId,
|
||||
pos: b.pos,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
groupSize: 1,
|
||||
config: b.config.responsive,
|
||||
});
|
||||
return (
|
||||
<div key={ri} className="dash-tpl-full">
|
||||
<div
|
||||
className={`dash-tpl-group ${policy.wrapperClassName}`}
|
||||
style={{
|
||||
fontSize: config.fontSize ?? '0.85rem',
|
||||
fontWeight: config.fontWeight ?? 600,
|
||||
textAlign: (config.align ?? 'left') as any,
|
||||
justifyContent:
|
||||
config.align === 'center'
|
||||
? 'center'
|
||||
: config.align === 'right'
|
||||
? 'flex-end'
|
||||
: 'flex-start',
|
||||
...policy.wrapperStyle,
|
||||
width: '100%',
|
||||
// 세로는 .dash-tpl-full (flex:1) 에 의해 부모가 결정하므로
|
||||
// height:100% 로 받아 확장. 최소 원본 높이 보장은 minHeight.
|
||||
flex: '1 1 auto',
|
||||
minHeight: `${Math.min(b.pos.height, 240)}px`,
|
||||
height: 'auto',
|
||||
}}
|
||||
>
|
||||
{config.text ?? ''}
|
||||
<div style={{ ...policy.innerStyle, height: '100%', flex: '1 1 auto', minHeight: 0 }}>
|
||||
<BlockRenderer block={b} context={context} view={view} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 row — 그룹 탐지 + 그룹 사이 spacer 삽입으로 원본 x 좌표 보존.
|
||||
// spacer 는 flex-shrink 가 커서 공간 부족 시 먼저 줄어들고, 그룹은
|
||||
// 자기 폭을 최대한 유지. 카드가 넓으면 spacer 가 남는 공간을 흡수해서
|
||||
// 각 그룹이 디자인 시점 위치를 유지.
|
||||
const rowTop = Math.min(...row.map((b) => b.pos.top));
|
||||
const rowBottom = Math.max(
|
||||
...row.map((b) => b.pos.top + b.pos.height),
|
||||
);
|
||||
const rowHeight = rowBottom - rowTop;
|
||||
const groups = detectResponsiveGroups(row);
|
||||
|
||||
type Cell =
|
||||
| { kind: 'spacer'; widthPct: number; key: string }
|
||||
| {
|
||||
kind: 'group';
|
||||
group: (typeof groups)[number];
|
||||
key: string;
|
||||
};
|
||||
const cells: Cell[] = [];
|
||||
let prevRight = 0;
|
||||
groups.forEach((group, gi) => {
|
||||
const gapPx = group.bbox.left - prevRight;
|
||||
const gapPct = (gapPx / canvasWidth) * 100;
|
||||
if (gapPct > 0.5) {
|
||||
cells.push({
|
||||
kind: 'spacer',
|
||||
widthPct: gapPct,
|
||||
key: `sp-${gi}`,
|
||||
});
|
||||
}
|
||||
cells.push({ kind: 'group', group, key: `g-${gi}` });
|
||||
prevRight = group.bbox.left + group.bbox.width;
|
||||
});
|
||||
const trailingPct = ((canvasWidth - prevRight) / canvasWidth) * 100;
|
||||
if (trailingPct > 0.5) {
|
||||
cells.push({
|
||||
kind: 'spacer',
|
||||
widthPct: trailingPct,
|
||||
key: 'sp-tail',
|
||||
});
|
||||
}
|
||||
|
||||
case 'v2-aggregation-widget': {
|
||||
const label = config.label ?? 'KPI';
|
||||
const value =
|
||||
config.value ??
|
||||
(typeof context.totalCount === 'number' ? context.totalCount : '—');
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-1 rounded border border-slate-200 bg-slate-50 p-2 dark:border-slate-700 dark:bg-slate-900">
|
||||
<div className="text-[10px] uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-xl font-bold text-indigo-600 dark:text-indigo-300">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
<div
|
||||
key={ri}
|
||||
className="dash-tpl-row"
|
||||
style={{ minHeight: `${rowHeight}px` }}
|
||||
>
|
||||
{cells.map((cell) => {
|
||||
if (cell.kind === 'spacer') {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-1 rounded border border-dashed border-slate-300 bg-slate-50 p-2 text-center dark:border-slate-700 dark:bg-slate-900">
|
||||
<div className="font-mono text-[11px] text-slate-600 dark:text-slate-300">
|
||||
{componentId}
|
||||
</div>
|
||||
<div className="text-[9px] text-slate-500 opacity-80 dark:text-slate-400">
|
||||
Phase 2.2 에서 지원
|
||||
<div
|
||||
key={cell.key}
|
||||
className="dash-tpl-spacer"
|
||||
style={{ flex: `0 5 ${cell.widthPct}%` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const group = cell.group;
|
||||
const cfg = group.blocks[0].config.responsive;
|
||||
const groupPolicy = resolveResponsivePolicy({
|
||||
componentId: group.componentId,
|
||||
pos: group.bbox,
|
||||
canvasWidth,
|
||||
canvasHeight: rowHeight,
|
||||
groupSize: group.blocks.length,
|
||||
config: cfg,
|
||||
});
|
||||
const flexBasisPct = (group.bbox.width / canvasWidth) * 100;
|
||||
const cellKey = cell.key;
|
||||
|
||||
// fixed 단일 — 원본 크기 유지
|
||||
// 버튼은 예외적으로 shrink 허용 (compact 단계: 패딩/폰트는 CSS
|
||||
// 컨테이너 쿼리가 축소. 박스 자체도 최소 48px 까지 shrink).
|
||||
// 다른 fixed 타입(input/title/divider)은 완전 고정.
|
||||
// ★ groupPolicy.wrapperStyle 의 anchor 기반 정렬(marginLeft:auto 등)을
|
||||
// 반드시 반영해야 우측 앵커 블록이 row 오른쪽 끝으로 붙는다.
|
||||
if (
|
||||
groupPolicy.appliedMode === 'fixed' &&
|
||||
group.blocks.length === 1
|
||||
) {
|
||||
const b = group.blocks[0];
|
||||
const isButton = b.componentId === 'button';
|
||||
const itemWrapperStyle: CSSProperties = {
|
||||
...groupPolicy.wrapperStyle,
|
||||
flex: isButton ? '0 1 auto' : '0 0 auto',
|
||||
width: `${b.pos.width}px`,
|
||||
height: `${b.pos.height}px`,
|
||||
minWidth: isButton ? 48 : undefined,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={cellKey}
|
||||
className={`rp-fixed ${groupPolicy.wrapperClassName}`}
|
||||
data-comp={b.componentId}
|
||||
style={itemWrapperStyle}
|
||||
>
|
||||
<div style={{ width: '100%', height: '100%' }}>
|
||||
<BlockRenderer
|
||||
block={b}
|
||||
context={context}
|
||||
view={view}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 그룹 모드별 flex 정책 — shrink 값으로 축소 우선순위 제어.
|
||||
// spacer(shrink 10) → reflow/scroll(0.5) → wrap(0.3) → fixed(0) 순.
|
||||
// 공간 부족 시 spacer 가 먼저 사라지고 그룹은 자기 폭을 유지.
|
||||
// wrap : 원본 폭을 basis 로, 아주 약하게만 shrink (0.3)
|
||||
// reflow : basis 비례 %, 중간 shrink (0.5), 내부 auto-fit 재배치
|
||||
// scroll : basis 비례 %, 중간 shrink (0.5), 내부 자체 overflow
|
||||
let flexShorthand: string;
|
||||
if (groupPolicy.appliedMode === 'wrap') {
|
||||
flexShorthand = `0 0.3 ${group.bbox.width}px`;
|
||||
} else {
|
||||
flexShorthand = `1 0.5 ${flexBasisPct}%`;
|
||||
}
|
||||
|
||||
function resolveActionHandler(
|
||||
actionType: string | undefined,
|
||||
context: TemplateRenderContext,
|
||||
): (() => void) | undefined {
|
||||
switch (actionType) {
|
||||
case 'add':
|
||||
return context.onAdd;
|
||||
case 'edit':
|
||||
return context.onEdit;
|
||||
case 'delete':
|
||||
return context.onDelete;
|
||||
default:
|
||||
return undefined;
|
||||
const groupBoxStyle: CSSProperties = {
|
||||
...groupPolicy.wrapperStyle,
|
||||
flex: flexShorthand,
|
||||
minWidth: 0,
|
||||
minHeight: `${group.bbox.height}px`,
|
||||
};
|
||||
delete (groupBoxStyle as any).width;
|
||||
delete (groupBoxStyle as any).position;
|
||||
delete (groupBoxStyle as any).top;
|
||||
delete (groupBoxStyle as any).left;
|
||||
delete (groupBoxStyle as any).right;
|
||||
delete (groupBoxStyle as any).bottom;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cellKey}
|
||||
className={`dash-tpl-group ${groupPolicy.wrapperClassName}`}
|
||||
style={groupBoxStyle}
|
||||
>
|
||||
{group.blocks.map((b) => {
|
||||
const itemStyle: CSSProperties = {
|
||||
...groupPolicy.innerStyle,
|
||||
};
|
||||
if (groupPolicy.appliedMode === 'wrap') {
|
||||
// 원본 px 크기를 최대폭으로, 부모가 더 좁으면 같이 줄어듦.
|
||||
// 부모 박스가 단일 버튼 폭보다 작아도 max-width 100% 로
|
||||
// 튀어나가지 않아 잘림 방지.
|
||||
itemStyle.width = `${b.pos.width}px`;
|
||||
itemStyle.height = `${b.pos.height}px`;
|
||||
itemStyle.maxWidth = '100%';
|
||||
itemStyle.flex = '0 1 auto';
|
||||
} else if (groupPolicy.appliedMode === 'reflow') {
|
||||
itemStyle.minHeight = `${b.pos.height}px`;
|
||||
} else if (groupPolicy.appliedMode === 'scroll') {
|
||||
itemStyle.width = '100%';
|
||||
itemStyle.height = '100%';
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
className={`dash-tpl-item ${groupPolicy.innerClassName}`}
|
||||
style={itemStyle}
|
||||
>
|
||||
<BlockRenderer
|
||||
block={b}
|
||||
context={context}
|
||||
view={view}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockRenderer({
|
||||
block,
|
||||
context,
|
||||
view,
|
||||
}: {
|
||||
block: Block;
|
||||
context: TemplateRenderContext;
|
||||
view: ViewKey;
|
||||
}) {
|
||||
const def = ComponentRegistry.getComponent(block.componentId);
|
||||
if (!def?.component) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 p-2 text-center text-[10px] text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
|
||||
<div>
|
||||
<div className="font-mono text-[11px] font-bold">
|
||||
{block.rawCid || '(empty)'}
|
||||
</div>
|
||||
<div className="opacity-70">미등록</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const Cmp = def.component as React.ComponentType<any>;
|
||||
return (
|
||||
<Cmp
|
||||
component={{
|
||||
id: block.id,
|
||||
componentType: block.componentId,
|
||||
position: { x: block.pos.left, y: block.pos.top, z: 1 },
|
||||
size: { width: block.pos.width, height: block.pos.height },
|
||||
componentConfig: block.config,
|
||||
component_config: block.config,
|
||||
style: {},
|
||||
}}
|
||||
componentConfig={block.config}
|
||||
config={block.config}
|
||||
isDesignMode={false}
|
||||
isPreview={true}
|
||||
size={{ width: block.pos.width, height: block.pos.height }}
|
||||
position={{ x: block.pos.left, y: block.pos.top, z: 1 }}
|
||||
formData={context.formRow}
|
||||
onFormDataChange={(fieldName: string, value: any) =>
|
||||
context.onFormRowChange?.({ [fieldName]: value })
|
||||
}
|
||||
view={view}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* TemplateResponsivePreview
|
||||
* ============================================================================
|
||||
* 템플릿 한 개를 실제 대시보드 카드 크기와 동일한 컨테이너(Full/Half/Quarter)
|
||||
* 안에서 미리 렌더링하는 패널. 화면 디자이너에서 편집 → "반응 미리보기" 로
|
||||
* 열어서 카드가 축소될 때 각 컴포넌트가 정책대로 반응하는지 즉시 확인한다.
|
||||
*
|
||||
* 관련 문서:
|
||||
* notes/gbpark/2026-04-19-template-responsive-policy.md
|
||||
* lib/registry/responsive-policy.ts
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { Template } from '@/types/invyone-component';
|
||||
import { TemplateRenderer, type TemplateRenderContext } from './TemplateRenderer';
|
||||
|
||||
export type PreviewSize = 'full' | 'half' | 'quarter';
|
||||
|
||||
const SIZE_PRESETS: Record<PreviewSize, { width: number; label: string }> = {
|
||||
full: { width: 1200, label: '1/1 (Full)' },
|
||||
half: { width: 600, label: '1/2 (Half)' },
|
||||
quarter: { width: 300, label: '1/4 (Quarter)' },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
template: Template;
|
||||
/** 미리보기용 mock context — 실제 데이터 바인딩은 빌더에서 전달 */
|
||||
context?: Partial<TemplateRenderContext>;
|
||||
/** 기본 프리셋 (기본 full) */
|
||||
initialSize?: PreviewSize;
|
||||
}
|
||||
|
||||
const DEFAULT_CONTEXT: TemplateRenderContext = {
|
||||
fields: [],
|
||||
data: [],
|
||||
loading: false,
|
||||
selectedRow: null,
|
||||
totalCount: 0,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
onSearch: () => {},
|
||||
onRowSelect: () => {},
|
||||
onPageChange: () => {},
|
||||
onAdd: () => {},
|
||||
onEdit: () => {},
|
||||
onDelete: () => {},
|
||||
};
|
||||
|
||||
export function TemplateResponsivePreview({
|
||||
template,
|
||||
context,
|
||||
initialSize = 'full',
|
||||
}: Props) {
|
||||
const [size, setSize] = useState<PreviewSize>(initialSize);
|
||||
|
||||
const mergedContext = useMemo<TemplateRenderContext>(
|
||||
() => ({ ...DEFAULT_CONTEXT, ...(context ?? {}) }),
|
||||
[context],
|
||||
);
|
||||
|
||||
const preset = SIZE_PRESETS[size];
|
||||
|
||||
return (
|
||||
<div className="tpl-rp-root">
|
||||
<style>{`
|
||||
.tpl-rp-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.tpl-rp-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tpl-rp-btn {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card));
|
||||
color: hsl(var(--foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.tpl-rp-btn:hover {
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
.tpl-rp-btn.active {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground, var(--card)));
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
.tpl-rp-info {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.tpl-rp-stage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
overflow: auto;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
.tpl-rp-card {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.06);
|
||||
overflow: hidden;
|
||||
transition: width 0.25s ease;
|
||||
height: 640px;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="tpl-rp-toolbar">
|
||||
{(Object.keys(SIZE_PRESETS) as PreviewSize[]).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`tpl-rp-btn ${size === key ? 'active' : ''}`}
|
||||
onClick={() => setSize(key)}
|
||||
>
|
||||
{SIZE_PRESETS[key].label}
|
||||
</button>
|
||||
))}
|
||||
<span className="tpl-rp-info">
|
||||
카드 폭 <strong>{preset.width}px</strong> — 템플릿:{' '}
|
||||
{template.name ?? template.templateId}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="tpl-rp-stage">
|
||||
<div className="tpl-rp-card" style={{ width: preset.width }}>
|
||||
<TemplateRenderer template={template} context={mergedContext} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -129,7 +129,7 @@ function MiniTopology({ topology }: { topology: FlowSummary["topology"] }) {
|
||||
<path
|
||||
key={`e-${i}`}
|
||||
d={`M${sx} ${sy}Q${mx} ${my} ${tx} ${ty}`}
|
||||
stroke="rgba(108,92,231,0.25)"
|
||||
stroke="rgba(var(--v5-primary-rgb),0.25)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -53,11 +53,6 @@ function ScreenCodeResolver({ screenCode }: { screenCode: string }) {
|
||||
return <ScreenViewPageWrapper screenIdProp={screenId} />;
|
||||
}
|
||||
|
||||
const DashboardViewPage = dynamic(
|
||||
() => import("@/app/(main)/dashboard/[dashboardId]/page"),
|
||||
{ ssr: false, loading: LoadingFallback },
|
||||
);
|
||||
|
||||
const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
// 관리자 메인
|
||||
"/admin": dynamic(() => import("@/app/(main)/admin/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -306,12 +301,8 @@ export function AdminPageRenderer({ url }: AdminPageRendererProps) {
|
||||
return <ScreenCodeResolver screenCode={screenCodeMatch[1]} />;
|
||||
}
|
||||
|
||||
// 대시보드 할당: /dashboard/[id]
|
||||
const dashboardMatch = cleanUrl.match(/^\/dashboard\/([^/]+)$/);
|
||||
if (dashboardMatch) {
|
||||
console.log("[AdminPageRenderer] → /dashboard/[id] 매칭:", dashboardMatch[1]);
|
||||
return <DashboardViewPage params={Promise.resolve({ dashboardId: dashboardMatch[1] })} />;
|
||||
}
|
||||
// 대시보드(=사용자 메뉴) 는 탭이 아닌 페이지 라우트(/{seq})로 직접 이동하므로
|
||||
// AdminPageRenderer 에서는 처리하지 않음.
|
||||
|
||||
// URL 직접 입력: 레지스트리 매칭
|
||||
const PageComponent = useMemo(() => {
|
||||
|
||||
@@ -20,7 +20,12 @@ import {
|
||||
Building2,
|
||||
FileCheck,
|
||||
Monitor,
|
||||
Plus,
|
||||
Edit3,
|
||||
} from "lucide-react";
|
||||
import { useDashboardStore } from "@/stores/dashboardStore";
|
||||
import { insertDashboard } from "@/lib/api/dashMenu";
|
||||
import { CreateDashboardModal } from "@/components/dash/CreateDashboardModal";
|
||||
import { useMenu } from "@/contexts/MenuContext";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
@@ -29,13 +34,13 @@ import { menuScreenApi } from "@/lib/api/screen";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { toast } from "sonner";
|
||||
import { ProfileModal } from "./ProfileModal";
|
||||
import { SettingsModal } from "./SettingsModal";
|
||||
import { Logo } from "./Logo";
|
||||
import { SideMenu } from "./SideMenu";
|
||||
import { TabBar } from "./TabBar";
|
||||
import { TabContent } from "./TabContent";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { CosmicBackground } from "./CosmicBackground";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -246,8 +251,35 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
|
||||
const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const { theme, setTheme: rawSetTheme } = useTheme();
|
||||
|
||||
// 대시보드 생성/편집 모드 / 템플릿 추가 (전역 헤더 버튼)
|
||||
const dashEditMode = useDashboardStore((s) => s.editMode);
|
||||
const toggleDashEditMode = useDashboardStore((s) => s.toggleEditMode);
|
||||
const dashCreateOpen = useDashboardStore((s) => s.createOpen);
|
||||
const openDashCreate = useDashboardStore((s) => s.openCreate);
|
||||
const closeDashCreate = useDashboardStore((s) => s.closeCreate);
|
||||
const dashActiveId = useDashboardStore((s) => s.activeDashboardId);
|
||||
const openDashLib = useDashboardStore((s) => s.openLib);
|
||||
const [dashCreateSubmitting, setDashCreateSubmitting] = useState(false);
|
||||
|
||||
const handleDashCreateSubmit = useCallback(async (payload: { name: string; icon: string; is_personal: boolean }) => {
|
||||
setDashCreateSubmitting(true);
|
||||
try {
|
||||
const result = await insertDashboard(payload);
|
||||
try { await refreshMenus(); } catch { /* ignore */ }
|
||||
const newUrl = result?.menu_url ?? result?.MENU_URL;
|
||||
closeDashCreate();
|
||||
toast.success(`"${payload.name}" 대시보드를 만들었습니다`);
|
||||
if (newUrl) router.push(newUrl);
|
||||
} catch (err) {
|
||||
toast.error("대시보드 생성 실패");
|
||||
} finally {
|
||||
setDashCreateSubmitting(false);
|
||||
}
|
||||
}, [refreshMenus, closeDashCreate, router]);
|
||||
|
||||
// 테마 전환 — 클릭 위치에서 원형으로 새 테마가 reveal (View Transitions API)
|
||||
const setNextTheme = useCallback((t: "light" | "dark", e?: React.MouseEvent) => {
|
||||
if (theme === t) return;
|
||||
@@ -435,10 +467,11 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 대시보드 할당 (/dashboard/xxx) → admin 탭으로 렌더링 (AdminPageRenderer가 처리)
|
||||
if (menu.url && menu.url.startsWith("/dashboard/")) {
|
||||
console.log("[handleMenuClick] → 대시보드 탭:", menu.url);
|
||||
openTab({ type: "admin", title: menuName, admin_url: menu.url });
|
||||
// 3) 대시보드 (사용자 메뉴) → 자동 부여된 /숫자 URL 로 이동
|
||||
// - 회사별 시퀀스가 URL 그 자체. "/{seq}" 형태만 대시보드로 인식
|
||||
if (menu.url && /^\/\d+$/.test(menu.url)) {
|
||||
console.log("[handleMenuClick] → 대시보드 페이지:", menu.url);
|
||||
router.push(menu.url);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
@@ -777,9 +810,6 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Cosmic background */}
|
||||
<CosmicBackground />
|
||||
|
||||
{/* Theme fade overlay */}
|
||||
<div className="v5-theme-fade" id="v5-theme-fade" />
|
||||
|
||||
@@ -804,6 +834,34 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
{/* mode transition 헤더 glow 라인 — 평소엔 opacity 0, mode change 시에만 flash */}
|
||||
<div className="v5-hdr-glow" />
|
||||
<div className="v5-hdr-r">
|
||||
{/* 대시보드 생성 + 템플릿 추가 + 편집 모드 (Light/Dark 토글 왼쪽) */}
|
||||
<button
|
||||
className="v5-dash-btn"
|
||||
onClick={openDashCreate}
|
||||
title="새 대시보드 만들기"
|
||||
>
|
||||
<Plus size={13} />
|
||||
<span>대시보드</span>
|
||||
</button>
|
||||
<button
|
||||
className="v5-dash-btn"
|
||||
onClick={openDashLib}
|
||||
disabled={!dashActiveId}
|
||||
title={dashActiveId ? "템플릿 라이브러리에서 카드 추가" : "대시보드를 먼저 선택하세요"}
|
||||
>
|
||||
<Plus size={13} />
|
||||
<span>템플릿 추가</span>
|
||||
</button>
|
||||
<button
|
||||
className={`v5-dash-btn${dashEditMode ? " on" : ""}`}
|
||||
onClick={toggleDashEditMode}
|
||||
disabled={!dashActiveId}
|
||||
title={dashActiveId ? (dashEditMode ? "편집 모드 끄기" : "편집 모드 켜기") : "대시보드 화면에서만 사용할 수 있습니다"}
|
||||
>
|
||||
<Edit3 size={13} />
|
||||
<span>{dashEditMode ? "편집 중" : "편집"}</span>
|
||||
</button>
|
||||
|
||||
{/* Theme pill */}
|
||||
<div className="v5-pill">
|
||||
<button className={theme !== "dark" ? "on" : ""} onClick={(e) => setNextTheme("light", e)}>Light</button>
|
||||
@@ -862,6 +920,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>내 정보</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSettingsOpen(true)}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>설정</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
|
||||
<FileCheck className="mr-2 h-4 w-4" />
|
||||
<span>결재함</span>
|
||||
@@ -938,9 +1000,11 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
{/* ★ INVYONE 신규 페이지는 children 직접 렌더, 기존 VEX 페이지는 탭 시스템 */}
|
||||
<main className="v5-content flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{pathname && (
|
||||
pathname.startsWith('/dashboard') ||
|
||||
pathname.startsWith('/dash') ||
|
||||
pathname.startsWith('/admin/builder') ||
|
||||
pathname.startsWith('/test-fc')
|
||||
pathname.startsWith('/test-fc') ||
|
||||
/^\/\d+$/.test(pathname) // /숫자 = 신규 대시보드(메뉴 시퀀스) 페이지
|
||||
) ? (
|
||||
// ★ flex 컨테이너로 만들어서 안쪽 dash-shell이 height:100% 잘 먹도록
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
@@ -996,6 +1060,17 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<SettingsModal open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
|
||||
{/* 전역 대시보드 생성 모달 — 헤더 "대시보드" 버튼에서 열림 */}
|
||||
<CreateDashboardModal
|
||||
open={dashCreateOpen}
|
||||
onClose={closeDashCreate}
|
||||
onSubmit={handleDashCreateSubmit}
|
||||
defaultName="새 대시보드"
|
||||
submitting={dashCreateSubmitting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export function CosmicBackground() {
|
||||
if (!co) return;
|
||||
|
||||
// Stars
|
||||
const starColors = ["rgba(162,155,254,.8)", "rgba(85,239,196,.7)", "rgba(253,121,168,.7)"];
|
||||
const starColors = ["rgba(var(--v5-primary-rgb),.8)", "rgba(var(--v5-cyan-rgb),.7)", "rgba(var(--v5-pink-rgb),.7)"];
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const s = document.createElement("div");
|
||||
s.className = "star" + (Math.random() > 0.83 ? " c" : "");
|
||||
|
||||
@@ -1,22 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import { LayoutGrid, Plus } from "lucide-react";
|
||||
import { useDashboardStore } from "@/stores/dashboardStore";
|
||||
|
||||
export function EmptyDashboard() {
|
||||
const activeDashboardId = useDashboardStore((s) => s.activeDashboardId);
|
||||
const openLib = useDashboardStore((s) => s.openLib);
|
||||
|
||||
const hasDashboard = !!activeDashboardId;
|
||||
const handleCenterClick = () => {
|
||||
if (hasDashboard) openLib();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-white">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
|
||||
<LayoutGrid className="h-10 w-10 text-muted-foreground" />
|
||||
<div
|
||||
className={`flex h-full items-center justify-center bg-white dark:bg-transparent ${hasDashboard ? "cursor-pointer" : ""}`}
|
||||
onClick={handleCenterClick}
|
||||
role={hasDashboard ? "button" : undefined}
|
||||
tabIndex={hasDashboard ? 0 : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (hasDashboard && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
openLib();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 text-center transition-transform hover:scale-[1.02]">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-[var(--v5-primary)]/10 text-[var(--v5-primary)] ring-1 ring-[var(--v5-primary)]/30">
|
||||
{hasDashboard ? <Plus className="h-10 w-10" /> : <LayoutGrid className="h-10 w-10 text-muted-foreground" />}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
열린 탭이 없습니다
|
||||
{hasDashboard ? "이제 템플릿을 추가합니다" : "열린 탭이 없습니다"}
|
||||
</h2>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
왼쪽 사이드바에서 메뉴를 클릭하거나 드래그하여 탭을 추가하세요.
|
||||
{hasDashboard
|
||||
? "이 영역을 클릭하거나 상단의 '템플릿 추가' 버튼을 눌러 첫 카드를 배치하세요."
|
||||
: "왼쪽 사이드바에서 메뉴를 클릭하거나 드래그하여 탭을 추가하세요."}
|
||||
</p>
|
||||
</div>
|
||||
{hasDashboard && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openLib();
|
||||
}}
|
||||
className="mt-1 inline-flex items-center gap-1.5 rounded-md bg-[var(--v5-primary)] px-3 py-1.5 text-[0.7rem] font-semibold text-white hover:opacity-90"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
템플릿 추가
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Check, Moon, Sun } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useColorTheme, COLOR_THEMES, type ColorTheme } from "@/hooks/useColorTheme";
|
||||
import { animatedThemeChange } from "@/lib/themeTransition";
|
||||
import { animatedColorChange } from "@/lib/colorTransition";
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
const { color, setColor } = useColorTheme();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
|
||||
const handleModeClick = (next: "light" | "dark", e: React.MouseEvent) => {
|
||||
if (next === theme) return;
|
||||
animatedThemeChange(next, setTheme, { x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
const handleColorClick = (id: ColorTheme, e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (id === color) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
const meta = COLOR_THEMES.find((c) => c.id === id);
|
||||
const swatchColor = (isDark ? meta?.dark : meta?.light) ?? "#6c5ce7";
|
||||
animatedColorChange(id, () => setColor(id), { x: cx, y: cy, color: swatchColor });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>설정</DialogTitle>
|
||||
<DialogDescription>화면 테마와 색상을 변경합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* === 모드 (라이트/다크) === */}
|
||||
<div className="settings-section">
|
||||
<div className="settings-label">화면 모드</div>
|
||||
<div className="settings-mode-row">
|
||||
<button
|
||||
type="button"
|
||||
className={`settings-mode-btn ${!isDark ? "on" : ""}`}
|
||||
onClick={(e) => handleModeClick("light", e)}
|
||||
>
|
||||
<Sun size={14} />
|
||||
<span>라이트</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`settings-mode-btn ${isDark ? "on" : ""}`}
|
||||
onClick={(e) => handleModeClick("dark", e)}
|
||||
>
|
||||
<Moon size={14} />
|
||||
<span>다크</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* === 색상 테마 === */}
|
||||
<div className="settings-section">
|
||||
<div className="settings-label">색상 테마</div>
|
||||
<div className="settings-color-grid">
|
||||
{COLOR_THEMES.map((c) => {
|
||||
const swatch = isDark ? c.dark : c.light;
|
||||
const isActive = color === c.id;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className={`settings-color-swatch ${isActive ? "on" : ""}`}
|
||||
onClick={(e) => handleColorClick(c.id as ColorTheme, e)}
|
||||
title={c.label}
|
||||
aria-label={c.label}
|
||||
>
|
||||
<span className="swatch-circle" style={{ background: swatch }}>
|
||||
{isActive && <Check size={12} strokeWidth={3} />}
|
||||
</span>
|
||||
<span className="swatch-label">{c.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -582,12 +582,24 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||
(componentStyle.labelPosition === "left" || componentStyle.labelPosition === "right")
|
||||
);
|
||||
const needsStripBorder = isV2HorizLabel || isButtonComponent;
|
||||
// component.style 에 스튜디오 저장 잔재(indexed object {0:"c",1:"a",..}, 배열 값) 가 있을 수 있어
|
||||
// React 가 CSSStyleDeclaration 에 할당 시 런타임 에러 발생. 숫자 키/배열 값 제거한다.
|
||||
const sanitizeStyleKeys = (v: any): Record<string, any> => {
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) return {};
|
||||
const out: Record<string, any> = {};
|
||||
for (const [k, val] of Object.entries(v)) {
|
||||
if (/^\d+$/.test(k)) continue;
|
||||
if (Array.isArray(val)) continue;
|
||||
out[k] = val;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const safeComponentStyle = needsStripBorder
|
||||
? (() => {
|
||||
const { borderWidth, borderColor, borderStyle, border, ...rest } = componentStyle as any;
|
||||
const { borderWidth, borderColor, borderStyle, border, ...rest } = sanitizeStyleKeys(componentStyle);
|
||||
return rest;
|
||||
})()
|
||||
: componentStyle;
|
||||
: sanitizeStyleKeys(componentStyle);
|
||||
|
||||
const baseStyle = isDesignMode ? {
|
||||
// 디자인 모드: 기존 절대 좌표 방식 그대로 유지
|
||||
|
||||
@@ -99,6 +99,7 @@ import { initializeComponents } from "@/lib/registry/components";
|
||||
import { ScreenFileAPI } from "@/lib/api/screenFile";
|
||||
import { safeMigrateLayout, needsMigration } from "@/lib/utils/widthToColumnSpan";
|
||||
import { convertV2ToLegacy, convertLegacyToV2, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
||||
import { saveTemplate, loadTemplateAsLayout } from "@/lib/utils/templateAdapter";
|
||||
|
||||
// V2 API 사용 플래그 (true: V2, false: 기존)
|
||||
const USE_V2_API = true;
|
||||
@@ -1589,6 +1590,79 @@ export default function ScreenDesigner({
|
||||
|
||||
// 화면 레이아웃 로드
|
||||
useEffect(() => {
|
||||
// ═══ INVYONE 스튜디오 (templates 모드) ═══
|
||||
if (selectedScreen?.template_id) {
|
||||
const loadTemplateLayout = async () => {
|
||||
try {
|
||||
const result = await loadTemplateAsLayout(selectedScreen.template_id!);
|
||||
if (!result) {
|
||||
// 템플릿 정보를 찾지 못함 — 빈 레이아웃 시작
|
||||
const defaultResolution =
|
||||
SCREEN_RESOLUTIONS.find((r) => r.name === "Full HD (1920×1080)") ||
|
||||
SCREEN_RESOLUTIONS[0];
|
||||
setScreenResolution(defaultResolution);
|
||||
const emptyLayout = {
|
||||
components: [],
|
||||
gridSettings: {
|
||||
columns: 12,
|
||||
gap: 16,
|
||||
padding: 0,
|
||||
snapToGrid: true,
|
||||
showGrid: false,
|
||||
gridColor: "#d1d5db",
|
||||
gridOpacity: 0.5,
|
||||
},
|
||||
screenResolution: defaultResolution,
|
||||
} as any;
|
||||
setLayout(emptyLayout);
|
||||
setHistory([emptyLayout]);
|
||||
setHistoryIndex(0);
|
||||
viewLayoutsRef.current.list = [];
|
||||
viewLayoutsRef.current.create = [];
|
||||
viewLayoutsRef.current.edit = [];
|
||||
setActiveView("list");
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedLayout: any = {
|
||||
...result.layout,
|
||||
gridSettings: {
|
||||
columns: result.layout.gridSettings?.columns ?? 12,
|
||||
gap: result.layout.gridSettings?.gap ?? 16,
|
||||
padding: 0,
|
||||
snapToGrid: result.layout.gridSettings?.snapToGrid ?? true,
|
||||
showGrid: result.layout.gridSettings?.showGrid ?? false,
|
||||
gridColor: result.layout.gridSettings?.gridColor ?? "#d1d5db",
|
||||
gridOpacity: result.layout.gridSettings?.gridOpacity ?? 0.5,
|
||||
},
|
||||
};
|
||||
|
||||
if (result.screenResolution) {
|
||||
setScreenResolution(result.screenResolution as any);
|
||||
} else {
|
||||
const defaultResolution =
|
||||
SCREEN_RESOLUTIONS.find((r) => r.name === "Full HD (1920×1080)") ||
|
||||
SCREEN_RESOLUTIONS[0];
|
||||
setScreenResolution(defaultResolution);
|
||||
}
|
||||
|
||||
setLayout(loadedLayout);
|
||||
setHistory([loadedLayout]);
|
||||
setHistoryIndex(0);
|
||||
|
||||
viewLayoutsRef.current.list = loadedLayout.components ?? [];
|
||||
viewLayoutsRef.current.create = result.viewLayouts.create ?? [];
|
||||
viewLayoutsRef.current.edit = result.viewLayouts.edit ?? [];
|
||||
setActiveView("list");
|
||||
} catch (error) {
|
||||
console.error("[ScreenDesigner] 템플릿 로드 실패:", error);
|
||||
toast.error("템플릿을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
loadTemplateLayout();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedScreen?.screen_id) {
|
||||
// 현재 화면 ID를 전역 변수로 설정 (파일 업로드 시 사용)
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -1731,7 +1805,7 @@ export default function ScreenDesigner({
|
||||
};
|
||||
loadLayout();
|
||||
}
|
||||
}, [selectedScreen?.screen_id]);
|
||||
}, [selectedScreen?.screen_id, selectedScreen?.template_id]);
|
||||
|
||||
// 스페이스바 키 이벤트 처리 (Pan 모드) + 전역 마우스 이벤트
|
||||
useEffect(() => {
|
||||
@@ -2116,8 +2190,8 @@ export default function ScreenDesigner({
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!selectedScreen?.screen_id) {
|
||||
console.error("❌ 저장 실패: selectedScreen 또는 screenId가 없습니다.", selectedScreen);
|
||||
if (!selectedScreen?.screen_id && !selectedScreen?.template_id) {
|
||||
console.error("❌ 저장 실패: selectedScreen/template_id 모두 없습니다.", selectedScreen);
|
||||
toast.error("화면 정보가 없습니다.");
|
||||
return;
|
||||
}
|
||||
@@ -2188,22 +2262,43 @@ export default function ScreenDesigner({
|
||||
(v2Layout as any).views = v2Views;
|
||||
}
|
||||
|
||||
// ═══ INVYONE 스튜디오 (templates 모드) ═══
|
||||
if (selectedScreen.template_id) {
|
||||
await saveTemplate({
|
||||
templateId: selectedScreen.template_id,
|
||||
name: selectedScreen.screen_name ?? "",
|
||||
category: selectedScreen.template_category,
|
||||
description: selectedScreen.description,
|
||||
primaryTable: currentMainTableName ?? selectedScreen.table_name ?? "",
|
||||
layout: layoutWithResolution,
|
||||
v2Views: {
|
||||
create: v2Views.create,
|
||||
edit: v2Views.edit,
|
||||
},
|
||||
});
|
||||
toast.success("템플릿이 저장되었습니다.");
|
||||
if (onScreenUpdate && currentMainTableName) {
|
||||
onScreenUpdate({ tableName: currentMainTableName });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (USE_POP_API) {
|
||||
await screenApi.saveLayoutPop(selectedScreen.screen_id, v2Layout);
|
||||
await screenApi.saveLayoutPop(selectedScreen.screen_id!, v2Layout);
|
||||
} else if (USE_V2_API) {
|
||||
const currentLayerId = activeLayerIdRef.current || 1;
|
||||
await screenApi.saveLayoutV2(selectedScreen.screen_id, {
|
||||
await screenApi.saveLayoutV2(selectedScreen.screen_id!, {
|
||||
...v2Layout,
|
||||
layerId: currentLayerId,
|
||||
mainTableName: currentMainTableName,
|
||||
});
|
||||
} else {
|
||||
await screenApi.saveLayout(selectedScreen.screen_id, layoutWithResolution);
|
||||
await screenApi.saveLayout(selectedScreen.screen_id!, layoutWithResolution);
|
||||
}
|
||||
|
||||
// 테이블이 변경된 경우 전용 API로 명시적으로 업데이트
|
||||
if (currentMainTableName && currentMainTableName !== selectedScreen.table_name) {
|
||||
await screenApi.updateScreenTableName(selectedScreen.screen_id, currentMainTableName);
|
||||
await screenApi.updateScreenTableName(selectedScreen.screen_id!, currentMainTableName);
|
||||
}
|
||||
|
||||
toast.success("화면이 저장되었습니다.");
|
||||
@@ -2234,8 +2329,24 @@ export default function ScreenDesigner({
|
||||
window.open(previewUrl, "_blank", "width=800,height=900");
|
||||
}, [selectedScreen, defaultDevicePreview]);
|
||||
|
||||
// 반응 미리보기 (템플릿 모드 전용) — 저장 후 Full/Half/Quarter 토글로 정책 확인
|
||||
const handleResponsivePreview = useCallback(() => {
|
||||
const tid = selectedScreen?.template_id;
|
||||
if (!tid) {
|
||||
toast.error("템플릿 ID가 없습니다. 먼저 저장해주세요.");
|
||||
return;
|
||||
}
|
||||
const previewUrl = `/admin/template-preview?id=${encodeURIComponent(String(tid))}`;
|
||||
window.open(previewUrl, "_blank", "width=1400,height=900");
|
||||
}, [selectedScreen]);
|
||||
|
||||
// 다국어 자동 생성 핸들러
|
||||
const handleGenerateMultilang = useCallback(async () => {
|
||||
// 템플릿 모드에서는 다국어 자동 생성 미지원 (screens 기반 기능)
|
||||
if (selectedScreen?.template_id) {
|
||||
toast.info("템플릿 모드에서는 다국어 자동 생성이 지원되지 않습니다.");
|
||||
return;
|
||||
}
|
||||
if (!selectedScreen?.screen_id) {
|
||||
toast.error("화면 정보가 없습니다.");
|
||||
return;
|
||||
@@ -6065,7 +6176,9 @@ export default function ScreenDesigner({
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
// 레이아웃 저장 실행
|
||||
if (layout.components.length > 0 && selectedScreen?.screen_id) {
|
||||
const hasTarget =
|
||||
!!selectedScreen?.screen_id || !!selectedScreen?.template_id;
|
||||
if (layout.components.length > 0 && hasTarget) {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const currentMainTableName = tables.length > 0 ? tables[0].tableName : null;
|
||||
@@ -6075,23 +6188,57 @@ export default function ScreenDesigner({
|
||||
screenResolution: screenResolution,
|
||||
mainTableName: currentMainTableName,
|
||||
};
|
||||
// V2/POP API 사용 여부에 따라 분기
|
||||
|
||||
// ═══ INVYONE 스튜디오 (templates 모드) ═══
|
||||
if (selectedScreen?.template_id) {
|
||||
const createComps = viewLayoutsRef.current.create || [];
|
||||
const editComps = viewLayoutsRef.current.edit || [];
|
||||
const v2Views: Record<string, any[]> = {};
|
||||
if (createComps.length > 0) {
|
||||
v2Views.create = convertLegacyToV2({
|
||||
...layout,
|
||||
components: createComps,
|
||||
}).components;
|
||||
}
|
||||
if (editComps.length > 0) {
|
||||
v2Views.edit = convertLegacyToV2({
|
||||
...layout,
|
||||
components: editComps,
|
||||
}).components;
|
||||
}
|
||||
await saveTemplate({
|
||||
templateId: selectedScreen.template_id,
|
||||
name: selectedScreen.screen_name ?? "",
|
||||
category: selectedScreen.template_category,
|
||||
description: selectedScreen.description,
|
||||
primaryTable: currentMainTableName ?? selectedScreen.table_name ?? "",
|
||||
layout: layoutWithResolution,
|
||||
v2Views,
|
||||
});
|
||||
toast.success("템플릿이 저장되었습니다.");
|
||||
if (onScreenUpdate && currentMainTableName) {
|
||||
onScreenUpdate({ tableName: currentMainTableName });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// V2/POP API 사용 여부에 따라 분기 (기존 screens)
|
||||
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
||||
if (USE_POP_API) {
|
||||
await screenApi.saveLayoutPop(selectedScreen.screen_id, v2Layout);
|
||||
await screenApi.saveLayoutPop(selectedScreen!.screen_id!, v2Layout);
|
||||
} else if (USE_V2_API) {
|
||||
const currentLayerId = activeLayerIdRef.current || 1;
|
||||
await screenApi.saveLayoutV2(selectedScreen.screen_id, {
|
||||
await screenApi.saveLayoutV2(selectedScreen!.screen_id!, {
|
||||
...v2Layout,
|
||||
layerId: currentLayerId,
|
||||
mainTableName: currentMainTableName,
|
||||
});
|
||||
} else {
|
||||
await screenApi.saveLayout(selectedScreen.screen_id, layoutWithResolution);
|
||||
await screenApi.saveLayout(selectedScreen!.screen_id!, layoutWithResolution);
|
||||
}
|
||||
|
||||
if (currentMainTableName && currentMainTableName !== selectedScreen.table_name) {
|
||||
await screenApi.updateScreenTableName(selectedScreen.screen_id, currentMainTableName);
|
||||
if (currentMainTableName && currentMainTableName !== selectedScreen!.table_name) {
|
||||
await screenApi.updateScreenTableName(selectedScreen!.screen_id!, currentMainTableName);
|
||||
}
|
||||
|
||||
toast.success("레이아웃이 저장되었습니다.");
|
||||
@@ -6453,7 +6600,7 @@ export default function ScreenDesigner({
|
||||
onBack={onBackToList}
|
||||
onSave={handleSave}
|
||||
isSaving={isSaving}
|
||||
onPreview={isPop ? handlePopPreview : undefined}
|
||||
onPreview={isPop ? handlePopPreview : handleResponsivePreview}
|
||||
onGenerateMultilang={handleGenerateMultilang}
|
||||
isGeneratingMultilang={isGeneratingMultilang}
|
||||
onOpenMultilangSettings={() => setShowMultilangSettingsModal(true)}
|
||||
|
||||
@@ -206,7 +206,7 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
||||
</button>
|
||||
)}
|
||||
{onPreview && (
|
||||
<button type="button" className="sq-btn" onClick={onPreview} title="POP 미리보기">
|
||||
<button type="button" className="sq-btn" onClick={onPreview} title="미리보기">
|
||||
<Eye size={13} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
export type ColorTheme = "purple" | "blue" | "green" | "orange" | "pink" | "cyan";
|
||||
|
||||
export const COLOR_THEME_STORAGE_KEY = "v5-color-theme";
|
||||
export const DEFAULT_COLOR_THEME: ColorTheme = "purple";
|
||||
|
||||
export const COLOR_THEMES: { id: ColorTheme; label: string; light: string; dark: string }[] = [
|
||||
{ id: "purple", label: "보라", light: "#6c5ce7", dark: "#a29bfe" },
|
||||
{ id: "blue", label: "블루", light: "#3b82f6", dark: "#93c5fd" },
|
||||
{ id: "green", label: "그린", light: "#10b981", dark: "#6ee7b7" },
|
||||
{ id: "orange", label: "오렌지", light: "#f97316", dark: "#fdba74" },
|
||||
{ id: "pink", label: "핑크", light: "#ec4899", dark: "#f472b6" },
|
||||
{ id: "cyan", label: "시안", light: "#0891b2", dark: "#7dd3fc" },
|
||||
];
|
||||
|
||||
export function useColorTheme() {
|
||||
const [color, setColorState] = useState<ColorTheme>(DEFAULT_COLOR_THEME);
|
||||
|
||||
useEffect(() => {
|
||||
const current = (document.documentElement.getAttribute("data-color") as ColorTheme) || DEFAULT_COLOR_THEME;
|
||||
setColorState(current);
|
||||
}, []);
|
||||
|
||||
const setColor = useCallback((next: ColorTheme) => {
|
||||
if (next === DEFAULT_COLOR_THEME) {
|
||||
document.documentElement.removeAttribute("data-color");
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-color", next);
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(COLOR_THEME_STORAGE_KEY, next);
|
||||
} catch {}
|
||||
setColorState(next);
|
||||
}, []);
|
||||
|
||||
return { color, setColor };
|
||||
}
|
||||
@@ -49,9 +49,3 @@ export async function updateCardPositionsBatch(dashboardId: string, cards: Recor
|
||||
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,45 @@
|
||||
/**
|
||||
* 컬러 테마 전환 — 화면 cross-fade 없이 즉시 색 swap + 색 들어간 요소들이 자기 자리에서
|
||||
* entrance 재생.
|
||||
*
|
||||
* View Transitions API 의 화면 cross-fade 가 깜빡임으로 느껴진다는 피드백 — 호출 제거.
|
||||
* 색은 즉시 바뀌고, .vt-color-changing 클래스가 잠깐 붙는 동안 색깔 사용 요소들의 keyframe
|
||||
* (v5-color-refresh 계열) 만 재생되어 "색이 바뀌었다" 는 인상을 준다.
|
||||
*/
|
||||
|
||||
type ApplyColor = (color: string) => void;
|
||||
|
||||
interface AnimatedColorOrigin {
|
||||
x: number;
|
||||
y: number;
|
||||
/** 새 색상의 hex/rgb 문자열 — 클릭 burst 에 사용 */
|
||||
color: string;
|
||||
}
|
||||
|
||||
const REFRESH_DURATION_MS = 700;
|
||||
|
||||
export function animatedColorChange(
|
||||
next: string,
|
||||
applyColor: ApplyColor,
|
||||
origin?: AnimatedColorOrigin,
|
||||
): void {
|
||||
if (origin) spawnColorBurst(origin.x, origin.y, origin.color);
|
||||
|
||||
const root = document.documentElement;
|
||||
root.classList.add("vt-color-changing");
|
||||
applyColor(next);
|
||||
|
||||
window.setTimeout(() => {
|
||||
root.classList.remove("vt-color-changing");
|
||||
}, REFRESH_DURATION_MS);
|
||||
}
|
||||
|
||||
function spawnColorBurst(x: number, y: number, color: string) {
|
||||
const burst = document.createElement("div");
|
||||
burst.className = "v5-color-burst";
|
||||
burst.style.left = `${x}px`;
|
||||
burst.style.top = `${y}px`;
|
||||
burst.style.setProperty("--burst-color", color);
|
||||
document.body.appendChild(burst);
|
||||
window.setTimeout(() => burst.remove(), 800);
|
||||
}
|
||||
@@ -683,7 +683,7 @@ export const AccordionBasicComponent: React.FC<AccordionBasicComponentProps> = (
|
||||
display: "block",
|
||||
}}
|
||||
className={className}
|
||||
{...domProps}
|
||||
{...filterDOMProps(domProps)}
|
||||
>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import { ButtonConfig, ButtonVariant } from "./types";
|
||||
|
||||
/**
|
||||
@@ -106,7 +107,12 @@ export const ButtonComponent: React.FC<ButtonComponentProps> = ({
|
||||
...fromProps,
|
||||
} as ButtonConfig;
|
||||
|
||||
const text = componentConfig.text ?? "버튼";
|
||||
// ★ text/icon 은 문자열이어야 함. object 가 들어오면 React child 에러 → 방어
|
||||
const rawText = componentConfig.text;
|
||||
const text: string =
|
||||
typeof rawText === "string" || typeof rawText === "number"
|
||||
? String(rawText)
|
||||
: "버튼";
|
||||
const variant: ButtonVariant = componentConfig.variant ?? "primary";
|
||||
// ★ componentConfig.size 는 가끔 {width, height} 객체가 섞여 올 수 있어 방어
|
||||
const sizeKey: NonNullable<ButtonConfig["size"]> =
|
||||
@@ -115,7 +121,8 @@ export const ButtonComponent: React.FC<ButtonComponentProps> = ({
|
||||
? (componentConfig.size as NonNullable<ButtonConfig["size"]>)
|
||||
: "md";
|
||||
const disabled = componentConfig.disabled ?? false;
|
||||
const icon = componentConfig.icon;
|
||||
const rawIcon = componentConfig.icon;
|
||||
const icon: string | null = typeof rawIcon === "string" ? rawIcon : null;
|
||||
const iconPosition = componentConfig.iconPosition ?? "left";
|
||||
|
||||
const variantStyle = VARIANT_PRESETS[variant] ?? VARIANT_PRESETS.primary;
|
||||
@@ -243,7 +250,7 @@ export const ButtonComponent: React.FC<ButtonComponentProps> = ({
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
disabled={disabled}
|
||||
{...domProps}
|
||||
{...filterDOMProps(domProps)}
|
||||
>
|
||||
{icon && iconPosition === "left" && (
|
||||
<span aria-hidden>{/* TODO: lucide icon lookup (Phase F) */}{icon}</span>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { ContainerConfig, ContainerType, ContainerTab } from "./types";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
/**
|
||||
* Container — 통합 레이아웃 컨테이너 컴포넌트
|
||||
@@ -283,7 +284,7 @@ export const ContainerComponent: React.FC<ContainerComponentProps> = ({
|
||||
|
||||
return (
|
||||
<div style={containerStyle} className={className}
|
||||
onClick={handleClick} onDragStart={onDragStart} onDragEnd={onDragEnd} {...domProps}>
|
||||
onClick={handleClick} onDragStart={onDragStart} onDragEnd={onDragEnd} {...filterDOMProps(domProps)}>
|
||||
{renderBody()}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { DividerLineConfig } from "./types";
|
||||
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
export interface DividerLineComponentProps extends ComponentRendererProps {
|
||||
config?: DividerLineConfig;
|
||||
@@ -111,7 +112,7 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
|
||||
} = props as any;
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
<div style={componentStyle} className={className} {...filterDOMProps(domProps)}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { DividerConfig } from "./types";
|
||||
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
/**
|
||||
* Divider — 통합 구분선 컴포넌트
|
||||
@@ -190,7 +191,7 @@ export const DividerComponent: React.FC<DividerComponentProps> = ({
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
{...domProps}
|
||||
{...filterDOMProps(domProps)}
|
||||
>
|
||||
{renderLabel()}
|
||||
<div
|
||||
@@ -230,7 +231,7 @@ export const DividerComponent: React.FC<DividerComponentProps> = ({
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
{...domProps}
|
||||
{...filterDOMProps(domProps)}
|
||||
>
|
||||
{renderLabel()}
|
||||
<div
|
||||
@@ -253,7 +254,7 @@ export const DividerComponent: React.FC<DividerComponentProps> = ({
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
{...domProps}
|
||||
{...filterDOMProps(domProps)}
|
||||
>
|
||||
{renderLabel()}
|
||||
<div
|
||||
|
||||
@@ -331,7 +331,7 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
|
||||
|
||||
if (!isDesignMode && !targetTable) {
|
||||
return (
|
||||
<div style={componentStyle} onClick={handleClick} {...domProps}>
|
||||
<div style={componentStyle} onClick={handleClick} {...filterDOMProps(domProps)}>
|
||||
<div className="flex items-center justify-center rounded-md border border-dashed border-border p-4 text-xs text-muted-foreground">
|
||||
대상 테이블이 설정되지 않았습니다.
|
||||
</div>
|
||||
@@ -341,7 +341,7 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={componentStyle} onClick={handleClick} {...domProps}>
|
||||
<div style={componentStyle} onClick={handleClick} {...filterDOMProps(domProps)}>
|
||||
<div className="flex items-center justify-center gap-2 p-3 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
결재 정보 로딩 중...
|
||||
@@ -352,7 +352,7 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={componentStyle} onClick={handleClick} {...domProps}>
|
||||
<div style={componentStyle} onClick={handleClick} {...filterDOMProps(domProps)}>
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
@@ -363,7 +363,7 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
|
||||
if (!stepData) {
|
||||
if (!isDesignMode && !targetRecordId) {
|
||||
return (
|
||||
<div style={componentStyle} onClick={handleClick} {...domProps}>
|
||||
<div style={componentStyle} onClick={handleClick} {...filterDOMProps(domProps)}>
|
||||
<div className="flex items-center gap-2 rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground">
|
||||
<FileCheck className="h-3.5 w-3.5" />
|
||||
레코드를 선택하면 결재 현황이 표시됩니다.
|
||||
@@ -372,7 +372,7 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={componentStyle} onClick={handleClick} {...domProps}>
|
||||
<div style={componentStyle} onClick={handleClick} {...filterDOMProps(domProps)}>
|
||||
<div className="flex items-center gap-2 rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground">
|
||||
<FileCheck className="h-3.5 w-3.5" />
|
||||
결재 요청 내역이 없습니다.
|
||||
@@ -386,7 +386,7 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
|
||||
const urgency = request.urgency;
|
||||
|
||||
return (
|
||||
<div style={componentStyle} onClick={handleClick} {...domProps}>
|
||||
<div style={componentStyle} onClick={handleClick} {...filterDOMProps(domProps)}>
|
||||
<div className="rounded-md border border-border bg-card">
|
||||
{/* 헤더 - 요약 */}
|
||||
<button
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { ImageDisplayConfig } from "./types";
|
||||
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
export interface ImageDisplayComponentProps extends ComponentRendererProps {
|
||||
config?: ImageDisplayConfig;
|
||||
@@ -78,7 +79,7 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
<div style={componentStyle} className={className} {...filterDOMProps(domProps)}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && (component.style?.labelDisplay ?? true) && (
|
||||
<label
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { InputConfig, InputFieldType } from "./types";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
/**
|
||||
* Input — 통합 필드 입력 컴포넌트
|
||||
@@ -394,7 +395,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
{...domProps}
|
||||
{...filterDOMProps(domProps)}
|
||||
>
|
||||
{label && (
|
||||
<label
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { SearchConfig } from "./types";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
/**
|
||||
* Search — 통합 검색 필터 컴포넌트
|
||||
@@ -201,7 +202,7 @@ export const SearchComponent: React.FC<SearchComponentProps> = ({
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
{...domProps}
|
||||
{...filterDOMProps(domProps)}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { SliderBasicConfig } from "./types";
|
||||
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
export interface SliderBasicComponentProps extends ComponentRendererProps {
|
||||
config?: SliderBasicConfig;
|
||||
@@ -78,7 +79,7 @@ export const SliderBasicComponent: React.FC<SliderBasicComponentProps> = ({
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
<div style={componentStyle} className={className} {...filterDOMProps(domProps)}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import { StatsConfig, StatsItem, StatsOrientation, StatsStyle } from "./types";
|
||||
|
||||
/**
|
||||
@@ -57,9 +58,23 @@ export const StatsComponent: React.FC<StatsComponentProps> = ({
|
||||
];
|
||||
const orientation: StatsOrientation = componentConfig.orientation ?? "horizontal";
|
||||
const statsStyle: StatsStyle = componentConfig.style ?? "card";
|
||||
const columns = componentConfig.columns ?? Math.min(items.length || 1, 4);
|
||||
// columns 미지정 시 undefined → auto-fit 이 폭에 맞춰 자동 결정 (5개면 5열 등).
|
||||
// columns 지정 시에만 최대 컬럼 수 상한으로 동작.
|
||||
const columns: number | undefined = componentConfig.columns;
|
||||
const title = componentConfig.title;
|
||||
|
||||
// componentConfig.style 이 "card" 같은 문자열인 경우, 또는 spread 잔재로 숫자 키가
|
||||
// 생긴 경우({0:"c",1:"a",...})에 대한 방어. CSS 로 쓸 수 없는 키/값은 제거.
|
||||
const asStyleObject = (v: any): React.CSSProperties => {
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) return {};
|
||||
const out: Record<string, any> = {};
|
||||
for (const [k, val] of Object.entries(v)) {
|
||||
if (/^\d+$/.test(k)) continue; // 숫자 키(indexed object 잔재) 제거
|
||||
if (Array.isArray(val)) continue; // 배열 값 제거
|
||||
out[k] = val;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const containerStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
@@ -68,8 +83,8 @@ export const StatsComponent: React.FC<StatsComponentProps> = ({
|
||||
gap: "8px",
|
||||
padding: "8px",
|
||||
boxSizing: "border-box",
|
||||
...(component as any).style,
|
||||
...style,
|
||||
...asStyleObject((component as any).style),
|
||||
...asStyleObject(style),
|
||||
};
|
||||
|
||||
if (isDesignMode && isSelected) {
|
||||
@@ -77,23 +92,30 @@ export const StatsComponent: React.FC<StatsComponentProps> = ({
|
||||
containerStyle.outlineOffset = "2px";
|
||||
}
|
||||
|
||||
// 반응 정책: columns 는 최대 컬럼 수 상한으로만 동작. 실제 배치는 auto-fit
|
||||
// minmax 로 처리해, 카드 폭이 줄어들면 자동으로 열 수가 감소하고 각 카드의
|
||||
// 최소 폭(180px)은 유지된다. (notes/2026-04-19-template-responsive-policy.md §4.3)
|
||||
const minItemWidth = 180;
|
||||
const itemsContainerStyle: React.CSSProperties = (() => {
|
||||
if (orientation === "grid") {
|
||||
if (orientation === "vertical") {
|
||||
return {
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
};
|
||||
}
|
||||
// horizontal / grid → auto-fit 재배치
|
||||
const gridTemplateColumns = columns
|
||||
? `repeat(auto-fit, minmax(max(${minItemWidth}px, calc((100% - ${(columns - 1) * 8}px) / ${columns})), 1fr))`
|
||||
: `repeat(auto-fit, minmax(${minItemWidth}px, 1fr))`;
|
||||
return {
|
||||
display: "flex",
|
||||
flexDirection: orientation === "vertical" ? "column" : "row",
|
||||
display: "grid",
|
||||
gridTemplateColumns,
|
||||
gap: "8px",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
flexWrap: "wrap",
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -313,7 +335,7 @@ export const StatsComponent: React.FC<StatsComponentProps> = ({
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
{...domProps}
|
||||
{...filterDOMProps(domProps)}
|
||||
>
|
||||
{title && (
|
||||
<div
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
// 🆕 RelatedDataButtons 전역 레지스트리 타입 선언
|
||||
declare global {
|
||||
@@ -5247,7 +5248,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
// 카드 모드
|
||||
if (tableConfig.displayMode === "card" && !isDesignMode) {
|
||||
return (
|
||||
<div {...domProps}>
|
||||
<div {...filterDOMProps(domProps)}>
|
||||
{loading ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<span className="text-muted-foreground text-sm">로딩 중...</span>
|
||||
@@ -5281,7 +5282,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
// SingleTableWithSticky 모드
|
||||
if (tableConfig.stickyHeader && !isDesignMode) {
|
||||
return (
|
||||
<div {...domProps}>
|
||||
<div {...filterDOMProps(domProps)}>
|
||||
{/* 필터 헤더는 TableSearchWidget으로 이동 */}
|
||||
|
||||
{/* 그룹 표시 배지 */}
|
||||
@@ -5344,7 +5345,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
// 일반 테이블 모드 (네이티브 HTML 테이블)
|
||||
return (
|
||||
<>
|
||||
<div {...domProps}>
|
||||
<div {...filterDOMProps(domProps)}>
|
||||
{/* 필터 헤더는 TableSearchWidget으로 이동 */}
|
||||
|
||||
{/* 🆕 DevExpress 스타일 기능 툴바 */}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import {
|
||||
TableConfig,
|
||||
TableDisplayMode,
|
||||
@@ -382,7 +383,7 @@ export const TableComponent: React.FC<TableComponentProps> = ({
|
||||
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
{...domProps}
|
||||
{...filterDOMProps(domProps)}
|
||||
>
|
||||
{renderToolbar()}
|
||||
{renderBody()}
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { TestInputConfig } from "./types";
|
||||
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
export interface TestInputComponentProps extends ComponentRendererProps {
|
||||
config?: TestInputConfig;
|
||||
@@ -73,7 +74,7 @@ export const TestInputComponent: React.FC<TestInputComponentProps> = ({
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
<div style={componentStyle} className={className} {...filterDOMProps(domProps)}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
|
||||
@@ -84,7 +84,7 @@ export const TextDisplayComponent: React.FC<TextDisplayComponentProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
<div style={componentStyle} className={className} {...filterDOMProps(domProps)}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && (component.style?.labelDisplay ?? true) && (
|
||||
<label
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { TitleConfig, TitleVariant } from "./types";
|
||||
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
/**
|
||||
* Title — 통합 제목/텍스트 컴포넌트
|
||||
@@ -210,7 +211,7 @@ export const TitleComponent: React.FC<TitleComponentProps> = ({
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
{...domProps}
|
||||
{...filterDOMProps(domProps)}
|
||||
>
|
||||
{textElement}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { ToggleSwitchConfig } from "./types";
|
||||
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
export interface ToggleSwitchComponentProps extends ComponentRendererProps {
|
||||
config?: ToggleSwitchConfig;
|
||||
@@ -77,7 +78,7 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
<div style={componentStyle} className={className} {...filterDOMProps(domProps)}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { DividerLineConfig } from "./types";
|
||||
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
export interface DividerLineComponentProps extends ComponentRendererProps {
|
||||
config?: DividerLineConfig;
|
||||
@@ -110,7 +111,7 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
|
||||
} = props as any;
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
<div style={componentStyle} className={className} {...filterDOMProps(domProps)}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { SplitLineConfig } from "./types";
|
||||
import { setCanvasSplit, resetCanvasSplit } from "./canvasSplitStore";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
export interface SplitLineComponentProps extends ComponentRendererProps {
|
||||
config?: SplitLineConfig;
|
||||
@@ -218,7 +219,7 @@ export const SplitLineComponent: React.FC<SplitLineComponentProps> = ({
|
||||
}}
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
{...domProps}
|
||||
{...filterDOMProps(domProps)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -265,7 +266,7 @@ export const SplitLineComponent: React.FC<SplitLineComponentProps> = ({
|
||||
}}
|
||||
className={className}
|
||||
onMouseDown={handleMouseDown}
|
||||
{...domProps}
|
||||
{...filterDOMProps(domProps)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -13,6 +13,7 @@ import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
|
||||
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
|
||||
import { useTabId } from "@/contexts/TabIdContext";
|
||||
import { formatNumber as centralFormatNumber, formatCurrency as centralFormatCurrency } from "@/lib/formatting";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
// 🖼️ 테이블 셀 이미지 썸네일 컴포넌트
|
||||
// objid인 경우 인증된 API로 blob URL 생성, 경로인 경우 직접 URL 사용
|
||||
@@ -5556,7 +5557,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
// 카드 모드
|
||||
if (tableConfig.displayMode === "card" && !isDesignMode) {
|
||||
return (
|
||||
<div {...domProps}>
|
||||
<div {...filterDOMProps(domProps)}>
|
||||
{loading ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<span className="text-muted-foreground text-sm">로딩 중...</span>
|
||||
@@ -5590,7 +5591,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
// SingleTableWithSticky 모드
|
||||
if (tableConfig.stickyHeader && !isDesignMode) {
|
||||
return (
|
||||
<div {...domProps}>
|
||||
<div {...filterDOMProps(domProps)}>
|
||||
{/* 필터 헤더는 TableSearchWidget으로 이동 */}
|
||||
|
||||
{/* 그룹 표시 배지 */}
|
||||
@@ -5662,7 +5663,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
// 일반 테이블 모드 (네이티브 HTML 테이블)
|
||||
return (
|
||||
<>
|
||||
<div {...domProps}>
|
||||
<div {...filterDOMProps(domProps)}>
|
||||
{/* 필터 헤더는 TableSearchWidget으로 이동 */}
|
||||
|
||||
{/* 🆕 DevExpress 스타일 기능 툴바 */}
|
||||
|
||||
@@ -87,7 +87,7 @@ export const TextDisplayComponent: React.FC<TextDisplayComponentProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
<div style={componentStyle} className={className} {...filterDOMProps(domProps)}>
|
||||
{/* v2-text-display는 텍스트 표시 전용이므로 별도 라벨 불필요 */}
|
||||
<div style={textStyle} onClick={handleClick} onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
||||
{componentConfig.text || "텍스트를 입력하세요"}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* 반응 정책 엔진
|
||||
* ============================================================================
|
||||
*
|
||||
* INVYONE 템플릿은 FHD 기준으로 자유배치되지만, 실제 표시 영역(대시보드 카드
|
||||
* 크기)은 1/1, 1/2, 1/4 로 줄어들 수 있다. 이때 전체를 일괄 비율 축소하는
|
||||
* 대신, 컴포넌트 성격별로 다르게 반응해야 한다.
|
||||
*
|
||||
* 정책 정의: notes/gbpark/2026-04-19-template-responsive-policy.md
|
||||
* 타입 정의: types/invyone-component.ts (ResponsiveConfig, ResponsiveMode)
|
||||
*
|
||||
* 핵심:
|
||||
* 1. 컴포넌트는 4가지 모드로 분류: fixed / scroll / reflow / wrap
|
||||
* 2. 인접한 동종 단일 컴포넌트는 런타임에 가상 그룹으로 묶임
|
||||
* - 버튼 여러 개 연속 → button-group (wrap)
|
||||
* - stats 여러 개 연속 → stats-group (reflow)
|
||||
* 3. 각 정책은 CSS 인라인 스타일과 래퍼 className 을 반환
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
import type {
|
||||
ResponsiveAnchor,
|
||||
ResponsiveConfig,
|
||||
ResponsiveMode,
|
||||
} from '@/types/invyone-component';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. componentId → 기본 반응 모드 매핑
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 단일 컴포넌트의 기본 반응 모드.
|
||||
* TemplateRenderer 의 LEGACY_TO_UNIFIED 결과값 기준(통합된 핵심 타입).
|
||||
*/
|
||||
const DEFAULT_MODE_BY_ID: Record<string, ResponsiveMode> = {
|
||||
// 데이터 영역 — 스크롤
|
||||
table: 'scroll',
|
||||
search: 'scroll',
|
||||
container: 'scroll',
|
||||
'split-panel': 'scroll',
|
||||
accordion: 'scroll',
|
||||
tabs: 'scroll',
|
||||
|
||||
// 카드 그룹 — 재배치
|
||||
stats: 'reflow',
|
||||
form: 'reflow',
|
||||
|
||||
// 버튼 그룹 — 줄바꿈
|
||||
'button-bar': 'wrap',
|
||||
|
||||
// 단일 — 고정
|
||||
button: 'fixed',
|
||||
input: 'fixed',
|
||||
title: 'fixed',
|
||||
divider: 'fixed',
|
||||
pagination: 'fixed',
|
||||
};
|
||||
|
||||
/** 여러 개 연속 배치 시 그룹 모드로 승격되는 단일 타입. */
|
||||
const GROUP_PROMOTION: Record<string, ResponsiveMode> = {
|
||||
button: 'wrap', // 버튼 두 개 이상 → 버튼 그룹 wrap
|
||||
input: 'wrap',
|
||||
stats: 'reflow',
|
||||
title: 'wrap',
|
||||
};
|
||||
|
||||
/** 카드 폭 전체를 단독 점유하는 풀 타입 (같은 row 허용 안 됨). */
|
||||
export const FULL_WIDTH_IDS: ReadonlySet<string> = new Set([
|
||||
'table',
|
||||
'container',
|
||||
'search',
|
||||
'split-panel',
|
||||
'accordion',
|
||||
'tabs',
|
||||
]);
|
||||
|
||||
export function getDefaultResponsiveMode(
|
||||
componentId: string,
|
||||
groupSize = 1,
|
||||
): ResponsiveMode {
|
||||
if (groupSize > 1 && GROUP_PROMOTION[componentId]) {
|
||||
return GROUP_PROMOTION[componentId];
|
||||
}
|
||||
return DEFAULT_MODE_BY_ID[componentId] ?? 'fixed';
|
||||
}
|
||||
|
||||
export function getDefaultMinItemWidth(componentId: string): number {
|
||||
switch (componentId) {
|
||||
case 'stats':
|
||||
return 180;
|
||||
case 'form':
|
||||
return 240;
|
||||
case 'button':
|
||||
case 'button-bar':
|
||||
return 96;
|
||||
case 'input':
|
||||
return 160;
|
||||
default:
|
||||
return 160;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 2. anchor 자동 결정 (fixed 모드)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 디자인 시점 좌표를 기준으로 축소 시 붙일 기준점을 결정.
|
||||
* 캔버스를 9등분(좌/중/우 × 상/중/하) 한다.
|
||||
*/
|
||||
export function resolveAnchor(
|
||||
pos: { left: number; top: number; width: number; height: number },
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
): ResponsiveAnchor {
|
||||
const centerX = pos.left + pos.width / 2;
|
||||
const centerY = pos.top + pos.height / 2;
|
||||
const isRight = centerX > canvasWidth * 0.6;
|
||||
const isLeft = centerX < canvasWidth * 0.4;
|
||||
const isBottom = centerY > canvasHeight * 0.6;
|
||||
|
||||
if (isRight) return isBottom ? 'bottom-right' : 'top-right';
|
||||
if (isLeft) return isBottom ? 'bottom-left' : 'top-left';
|
||||
return 'center';
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 3. 모드별 CSS 생성
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ResolvedPolicy {
|
||||
/** 래퍼 요소에 적용할 인라인 스타일 */
|
||||
wrapperStyle: CSSProperties;
|
||||
/** 래퍼 요소에 추가할 className */
|
||||
wrapperClassName: string;
|
||||
/** 자식(실제 컴포넌트)에 적용할 스타일 */
|
||||
innerStyle: CSSProperties;
|
||||
/** 자식에 적용할 className */
|
||||
innerClassName: string;
|
||||
/** 실제 적용된 모드 (디버그/QA 용) */
|
||||
appliedMode: ResponsiveMode;
|
||||
}
|
||||
|
||||
interface ResolveContext {
|
||||
componentId: string;
|
||||
pos: { left: number; top: number; width: number; height: number };
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
/** 인접 동종이 묶인 그룹 크기 (단독 = 1) */
|
||||
groupSize?: number;
|
||||
/** 명시적 ResponsiveConfig (미지정 시 기본값 사용) */
|
||||
config?: ResponsiveConfig;
|
||||
}
|
||||
|
||||
export function resolveResponsivePolicy(ctx: ResolveContext): ResolvedPolicy {
|
||||
const groupSize = ctx.groupSize ?? 1;
|
||||
const mode =
|
||||
ctx.config?.mode ?? getDefaultResponsiveMode(ctx.componentId, groupSize);
|
||||
|
||||
switch (mode) {
|
||||
case 'scroll':
|
||||
return resolveScroll(ctx);
|
||||
case 'reflow':
|
||||
return resolveReflow(ctx);
|
||||
case 'wrap':
|
||||
return resolveWrap(ctx);
|
||||
case 'fixed':
|
||||
default:
|
||||
return resolveFixed(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveScroll(ctx: ResolveContext): ResolvedPolicy {
|
||||
// 테이블/컨테이너: wrapper 는 카드 폭에 맞춰 100% 로 줄어들고 자기는 clip.
|
||||
// 가로 스크롤은 TableComponent(또는 다른 container 컴포넌트)가 **자체**
|
||||
// overflow:auto 로 처리하므로, wrapper 는 추가 스크롤을 걸지 않는다. 이중
|
||||
// 스크롤바가 뜨거나 테이블이 카드 경계 밖으로 튀어나가는 문제를 방지.
|
||||
const innerStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
};
|
||||
if (ctx.config?.minWidth) {
|
||||
innerStyle.minWidth = `${ctx.config.minWidth}px`;
|
||||
}
|
||||
return {
|
||||
wrapperStyle: {
|
||||
overflow: 'hidden',
|
||||
minHeight: ctx.config?.minHeight,
|
||||
},
|
||||
wrapperClassName: 'rp-scroll',
|
||||
innerStyle,
|
||||
innerClassName: '',
|
||||
appliedMode: 'scroll',
|
||||
};
|
||||
}
|
||||
|
||||
function resolveReflow(ctx: ResolveContext): ResolvedPolicy {
|
||||
const minItemWidth =
|
||||
ctx.config?.minItemWidth ?? getDefaultMinItemWidth(ctx.componentId);
|
||||
const maxColumns = ctx.config?.maxColumns;
|
||||
const gap = ctx.config?.gap ?? 8;
|
||||
|
||||
const gridTemplateColumns = maxColumns
|
||||
? `repeat(auto-fit, minmax(max(${minItemWidth}px, calc((100% - ${
|
||||
gap * (maxColumns - 1)
|
||||
}px) / ${maxColumns})), 1fr))`
|
||||
: `repeat(auto-fit, minmax(${minItemWidth}px, 1fr))`;
|
||||
|
||||
return {
|
||||
wrapperStyle: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns,
|
||||
gap: `${gap}px`,
|
||||
minHeight: ctx.config?.minHeight,
|
||||
},
|
||||
wrapperClassName: 'rp-reflow',
|
||||
innerStyle: { minWidth: 0 },
|
||||
innerClassName: '',
|
||||
appliedMode: 'reflow',
|
||||
};
|
||||
}
|
||||
|
||||
function resolveWrap(ctx: ResolveContext): ResolvedPolicy {
|
||||
const gap = ctx.config?.gap ?? 8;
|
||||
return {
|
||||
wrapperStyle: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: `${gap}px`,
|
||||
alignItems: 'flex-start',
|
||||
alignContent: 'flex-start',
|
||||
},
|
||||
wrapperClassName: 'rp-wrap',
|
||||
innerStyle: {
|
||||
flex: '0 0 auto',
|
||||
minWidth: ctx.config?.minWidth,
|
||||
minHeight: ctx.config?.minHeight,
|
||||
},
|
||||
innerClassName: '',
|
||||
appliedMode: 'wrap',
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFixed(ctx: ResolveContext): ResolvedPolicy {
|
||||
// flex row 컨텍스트 안에서 동작. 원본 크기 유지 + flex-basis 로 자리 배정.
|
||||
// anchor 는 row 내부에서 좌/우 정렬 힌트로만 사용 (justify-self 기반).
|
||||
const anchor =
|
||||
ctx.config?.anchor ??
|
||||
resolveAnchor(ctx.pos, ctx.canvasWidth, ctx.canvasHeight);
|
||||
|
||||
const wrapperStyle: CSSProperties = {
|
||||
width: `${ctx.pos.width}px`,
|
||||
height: `${ctx.pos.height}px`,
|
||||
flex: '0 0 auto',
|
||||
};
|
||||
|
||||
// 우측 앵커면 flex 에서 오른쪽 정렬되게 힌트 — 실제 정렬은 부모 spacer 로 처리
|
||||
if (anchor === 'top-right' || anchor === 'bottom-right') {
|
||||
wrapperStyle.marginLeft = 'auto';
|
||||
}
|
||||
|
||||
return {
|
||||
wrapperStyle,
|
||||
wrapperClassName: `rp-fixed rp-anchor-${anchor}`,
|
||||
innerStyle: { width: '100%', height: '100%' },
|
||||
innerClassName: '',
|
||||
appliedMode: 'fixed',
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 4. 그룹 탐지
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GroupableBlock {
|
||||
id: string;
|
||||
componentId: string;
|
||||
pos: { left: number; top: number; width: number; height: number };
|
||||
}
|
||||
|
||||
export interface ResponsiveGroup<T extends GroupableBlock = GroupableBlock> {
|
||||
/** 그룹을 대표하는 컴포넌트 ID ('button', 'stats', 'title' 등) */
|
||||
componentId: string;
|
||||
/** 런타임 가상 그룹 여부 (ButtonBar 처럼 자체 그룹 컴포넌트면 false) */
|
||||
virtual: boolean;
|
||||
/** 그룹 멤버 */
|
||||
blocks: T[];
|
||||
/** 그룹 전체의 bounding box (pos) */
|
||||
bbox: { left: number; top: number; width: number; height: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 row 안에서 연속된 동일 componentId 블록을 가상 그룹으로 묶는다.
|
||||
* 단, 블록 사이 수평 간격이 크면 (디자이너가 의도적으로 떨어뜨린 배치)
|
||||
* 같은 id 여도 별개 그룹으로 분리한다. 예: "중앙 버튼" + "우측 액션 버튼".
|
||||
*
|
||||
* 그룹 판정 기준 (AND):
|
||||
* 1. 같은 componentId
|
||||
* 2. gap <= max(직전블록폭 × 0.5, 32px) — 시각적으로 인접한 것만 묶음
|
||||
*
|
||||
* FULL_WIDTH 는 그룹 대상이 아니며, 항상 단독 그룹으로 유지된다.
|
||||
*/
|
||||
export function detectResponsiveGroups<T extends GroupableBlock>(
|
||||
rowBlocks: T[],
|
||||
): ResponsiveGroup<T>[] {
|
||||
if (rowBlocks.length === 0) return [];
|
||||
const groups: ResponsiveGroup<T>[] = [];
|
||||
const sorted = [...rowBlocks].sort((a, b) => a.pos.left - b.pos.left);
|
||||
|
||||
let current: T[] = [];
|
||||
let currentId: string | null = null;
|
||||
|
||||
const flush = () => {
|
||||
if (current.length === 0) return;
|
||||
groups.push(makeGroup(current, currentId!));
|
||||
current = [];
|
||||
currentId = null;
|
||||
};
|
||||
|
||||
for (const b of sorted) {
|
||||
if (FULL_WIDTH_IDS.has(b.componentId)) {
|
||||
flush();
|
||||
groups.push(makeGroup([b], b.componentId));
|
||||
continue;
|
||||
}
|
||||
if (currentId === null || b.componentId !== currentId) {
|
||||
flush();
|
||||
currentId = b.componentId;
|
||||
current = [b];
|
||||
continue;
|
||||
}
|
||||
// 같은 componentId: gap 검사
|
||||
const prev = current[current.length - 1];
|
||||
const gap = b.pos.left - (prev.pos.left + prev.pos.width);
|
||||
const adjacencyThreshold = Math.max(prev.pos.width * 0.5, 32);
|
||||
if (gap > adjacencyThreshold) {
|
||||
// 같은 id 지만 시각적으로 떨어져 있음 → 별개 그룹
|
||||
flush();
|
||||
currentId = b.componentId;
|
||||
current = [b];
|
||||
} else {
|
||||
current.push(b);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return groups;
|
||||
}
|
||||
|
||||
function makeGroup<T extends GroupableBlock>(
|
||||
blocks: T[],
|
||||
componentId: string,
|
||||
): ResponsiveGroup<T> {
|
||||
const left = Math.min(...blocks.map((b) => b.pos.left));
|
||||
const top = Math.min(...blocks.map((b) => b.pos.top));
|
||||
const right = Math.max(...blocks.map((b) => b.pos.left + b.pos.width));
|
||||
const bottom = Math.max(...blocks.map((b) => b.pos.top + b.pos.height));
|
||||
return {
|
||||
componentId,
|
||||
virtual: blocks.length > 1,
|
||||
blocks,
|
||||
bbox: { left, top, width: right - left, height: bottom - top },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* INVYONE 스튜디오 ↔ templates 테이블 어댑터
|
||||
*
|
||||
* ScreenDesigner 의 layout 객체는 screens 테이블(layout-v2 JSON)을 기준으로 설계되어 있음.
|
||||
* INVYONE 스튜디오는 templates 테이블(fields/views/connections 3-jsonb 분리) 을 사용하므로
|
||||
* 두 포맷 사이 왕복 변환을 이 어댑터가 담당한다.
|
||||
*
|
||||
* - saveTemplate: 현재 layout + 3뷰 캐시 → templates.views JSON 으로 저장
|
||||
* - loadTemplateAsLayout: templates.views JSON → ScreenDesigner 가 기대하는 layout + 3뷰 캐시
|
||||
*
|
||||
* 규칙: Record<string, any> 원칙 유지 (invyone-component.ts 의 확정 타입 외 금지).
|
||||
*/
|
||||
|
||||
import {
|
||||
getTemplateInfo,
|
||||
insertTemplate,
|
||||
updateTemplate,
|
||||
} from "@/lib/api/template";
|
||||
import { convertLegacyToV2, convertV2ToLegacy } from "./layoutV2Converter";
|
||||
|
||||
export interface TemplateSavePayload {
|
||||
templateId: string;
|
||||
name: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
primaryTable?: string;
|
||||
/** 현재 list 뷰 layout (components, gridSettings, screenResolution 포함) */
|
||||
layout: Record<string, any>;
|
||||
/** v2 포맷 컴포넌트 배열 — 등록/수정 뷰 */
|
||||
v2Views?: {
|
||||
create?: Record<string, any>[];
|
||||
edit?: Record<string, any>[];
|
||||
};
|
||||
/** 템플릿 수준 필드 규격 (아직 미사용, 확장용) */
|
||||
fields?: Record<string, any>[];
|
||||
/** DataPort 연결 (아직 미사용, 확장용) */
|
||||
connections?: Record<string, any>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 layout + 3뷰 캐시를 templates.views JSON 으로 직렬화해서 updateTemplate 호출.
|
||||
* 등록/수정/수정용 뷰는 v2 포맷으로, list 뷰는 convertLegacyToV2 를 거친 후 함께 묶어서 저장.
|
||||
*/
|
||||
export async function saveTemplate(p: TemplateSavePayload): Promise<void> {
|
||||
const v2Layout = convertLegacyToV2(p.layout as any);
|
||||
const viewsJson = {
|
||||
list: (v2Layout as any)?.components ?? [],
|
||||
create: p.v2Views?.create ?? [],
|
||||
edit: p.v2Views?.edit ?? [],
|
||||
gridSettings: p.layout.gridSettings,
|
||||
screenResolution: p.layout.screenResolution,
|
||||
mainTableName: p.primaryTable,
|
||||
};
|
||||
const payload: Record<string, any> = {
|
||||
name: p.name,
|
||||
category: p.category ?? "custom",
|
||||
description: p.description ?? "",
|
||||
primary_table: p.primaryTable ?? "",
|
||||
fields: JSON.stringify(p.fields ?? []),
|
||||
views: JSON.stringify(viewsJson),
|
||||
connections: JSON.stringify(p.connections ?? []),
|
||||
};
|
||||
await updateTemplate(p.templateId, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 신규 template 레코드 생성 — 빈 views/fields/connections 로 시작.
|
||||
* 반환된 template_id 는 URL 쿼리 등에 사용.
|
||||
*/
|
||||
export async function createTemplate(data: {
|
||||
name: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
primaryTable?: string;
|
||||
}): Promise<Record<string, any>> {
|
||||
const payload: Record<string, any> = {
|
||||
name: data.name,
|
||||
category: data.category ?? "custom",
|
||||
description: data.description ?? "",
|
||||
primary_table: data.primaryTable ?? "",
|
||||
fields: JSON.stringify([]),
|
||||
views: JSON.stringify({ list: [], create: [], edit: [] }),
|
||||
connections: JSON.stringify([]),
|
||||
};
|
||||
const res = await insertTemplate(payload);
|
||||
return res ?? {};
|
||||
}
|
||||
|
||||
export interface LoadedTemplate {
|
||||
templateInfo: Record<string, any>;
|
||||
/** ScreenDesigner 의 layout 상태에 그대로 setLayout 으로 주입 가능 */
|
||||
layout: Record<string, any>;
|
||||
/** viewLayoutsRef.current.create / edit 에 주입할 legacy 컴포넌트 배열 */
|
||||
viewLayouts: {
|
||||
create: any[];
|
||||
edit: any[];
|
||||
};
|
||||
primaryTable: string;
|
||||
screenResolution?: Record<string, any>;
|
||||
}
|
||||
|
||||
function parseJsonMaybe(raw: any): any {
|
||||
if (raw == null) return null;
|
||||
if (typeof raw === "object") return raw;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* templates.views JSON → ScreenDesigner layout + 3뷰 캐시로 역직렬화.
|
||||
* 목록/등록/수정 3뷰가 모두 저장되어 있으면 각각 Legacy 포맷으로 변환.
|
||||
*/
|
||||
export async function loadTemplateAsLayout(
|
||||
templateId: string,
|
||||
): Promise<LoadedTemplate | null> {
|
||||
const template = await getTemplateInfo(templateId);
|
||||
if (!template) return null;
|
||||
|
||||
const viewsObj =
|
||||
parseJsonMaybe(template.views) ?? parseJsonMaybe(template.VIEWS) ?? {};
|
||||
|
||||
const listV2 = Array.isArray(viewsObj.list) ? viewsObj.list : [];
|
||||
const createV2 = Array.isArray(viewsObj.create) ? viewsObj.create : [];
|
||||
const editV2 = Array.isArray(viewsObj.edit) ? viewsObj.edit : [];
|
||||
|
||||
const legacyListLayout = convertV2ToLegacy({
|
||||
components: listV2,
|
||||
gridSettings: viewsObj.gridSettings,
|
||||
screenResolution: viewsObj.screenResolution,
|
||||
} as any) ?? { components: [] };
|
||||
|
||||
const createLegacy = createV2.length
|
||||
? convertV2ToLegacy({ components: createV2 } as any)?.components ?? []
|
||||
: [];
|
||||
const editLegacy = editV2.length
|
||||
? convertV2ToLegacy({ components: editV2 } as any)?.components ?? []
|
||||
: [];
|
||||
|
||||
return {
|
||||
templateInfo: template,
|
||||
layout: {
|
||||
...legacyListLayout,
|
||||
gridSettings: viewsObj.gridSettings ?? (legacyListLayout as any).gridSettings,
|
||||
screenResolution:
|
||||
viewsObj.screenResolution ?? (legacyListLayout as any).screenResolution,
|
||||
},
|
||||
viewLayouts: {
|
||||
create: createLegacy,
|
||||
edit: editLegacy,
|
||||
},
|
||||
primaryTable:
|
||||
template.primary_table ?? template.PRIMARY_TABLE ?? viewsObj.mainTableName ?? "",
|
||||
screenResolution: viewsObj.screenResolution,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ interface DashboardState {
|
||||
cards: Record<string, any>[];
|
||||
editMode: boolean;
|
||||
loading: boolean;
|
||||
createOpen: boolean;
|
||||
|
||||
setDashboards: (dashboards: Record<string, any>[]) => void;
|
||||
setActiveDashboard: (id: string | null) => void;
|
||||
@@ -20,6 +21,11 @@ interface DashboardState {
|
||||
addDashboard: (dashboard: Record<string, any>) => void;
|
||||
updateDashboardInList: (id: string, updates: Record<string, any>) => void;
|
||||
removeDashboard: (id: string) => void;
|
||||
openCreate: () => void;
|
||||
closeCreate: () => void;
|
||||
libOpen: boolean;
|
||||
openLib: () => void;
|
||||
closeLib: () => void;
|
||||
}
|
||||
|
||||
export const useDashboardStore = create<DashboardState>()(
|
||||
@@ -30,6 +36,8 @@ export const useDashboardStore = create<DashboardState>()(
|
||||
cards: [],
|
||||
editMode: false,
|
||||
loading: false,
|
||||
createOpen: false,
|
||||
libOpen: false,
|
||||
|
||||
setDashboards: (dashboards) => set({ dashboards }),
|
||||
|
||||
@@ -80,6 +88,11 @@ export const useDashboardStore = create<DashboardState>()(
|
||||
: s.activeDashboardId,
|
||||
};
|
||||
}),
|
||||
|
||||
openCreate: () => set({ createOpen: true }),
|
||||
closeCreate: () => set({ createOpen: false }),
|
||||
openLib: () => set({ libOpen: true }),
|
||||
closeLib: () => set({ libOpen: false }),
|
||||
}),
|
||||
{ name: 'dashboard-store' }
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
/* ── 제어 모드 변수 ── */
|
||||
:root {
|
||||
--ctrl-cyan: #00cec9;
|
||||
--ctrl-cyan-glow: rgba(0, 206, 201, .3);
|
||||
--ctrl-cyan-glow: rgba(var(--v5-cyan-rgb), .3);
|
||||
--ctrl-primary: #6c5ce7;
|
||||
--ctrl-amber: #fdcb6e;
|
||||
--ctrl-pink: #fd79a8;
|
||||
@@ -15,7 +15,7 @@
|
||||
--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);
|
||||
--ctrl-glass-border: rgba(var(--v5-cyan-rgb), .25);
|
||||
}
|
||||
.dark {
|
||||
--ctrl-glass: rgba(255, 255, 255, .04);
|
||||
@@ -24,12 +24,12 @@
|
||||
|
||||
/* ═══ 제어 모드 캔버스 배경 ═══ */
|
||||
.dash-canvas.control-mode {
|
||||
background-image: radial-gradient(circle at 0.5px 0.5px, rgba(0,206,201,.22) 0.5px, transparent 0);
|
||||
background-image: radial-gradient(circle at 0.5px 0.5px, rgba(var(--v5-cyan-rgb),.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);
|
||||
background-image: radial-gradient(circle at 0.5px 0.5px, rgba(var(--v5-cyan-rgb),.18) 0.5px, transparent 0);
|
||||
}
|
||||
|
||||
/* 카드 축소 + 클릭 가능 */
|
||||
@@ -40,16 +40,16 @@
|
||||
.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);
|
||||
box-shadow: 0 0 30px rgba(var(--v5-cyan-rgb),.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;
|
||||
box-shadow: 0 0 20px rgba(var(--v5-cyan-rgb),.3) !important; font-weight: 700;
|
||||
}
|
||||
.dash-btn.control-on:hover { box-shadow: 0 0 30px rgba(0,206,201,.45) !important; }
|
||||
.dash-btn.control-on:hover { box-shadow: 0 0 30px rgba(var(--v5-cyan-rgb),.45) !important; }
|
||||
|
||||
/* ═══ SVG 오버레이 ═══ */
|
||||
.ctrl-svg {
|
||||
@@ -80,20 +80,20 @@ html:not(.dark) .ctrl-line-tpl { stroke: #e0559e; stroke-width: 3; opacity: .6;
|
||||
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);
|
||||
border: 1px solid rgba(var(--v5-cyan-rgb),.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);
|
||||
transition: all .25s; box-shadow: 0 4px 16px rgba(var(--v5-cyan-rgb),.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);
|
||||
box-shadow: 0 8px 24px rgba(var(--v5-cyan-rgb),.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.auto { border-color: rgba(var(--v5-primary-rgb),.35); color: var(--ctrl-primary);
|
||||
box-shadow: 0 4px 16px rgba(var(--v5-primary-rgb),.12); }
|
||||
.ctrl-badge.tpl-link { border-color: rgba(var(--v5-pink-rgb),.35); color: var(--ctrl-pink);
|
||||
box-shadow: 0 4px 16px rgba(var(--v5-pink-rgb),.12); font-size: .5rem; }
|
||||
|
||||
/* 조건분기 뱃지 */
|
||||
.ctrl-badge.cond {
|
||||
@@ -132,30 +132,30 @@ html:not(.dark) .ctrl-badge { background: rgba(255,255,255,.92);
|
||||
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);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.08), 0 0 20px rgba(var(--v5-cyan-rgb),.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); }
|
||||
.dark .tbl-node { box-shadow: 0 8px 24px rgba(0,0,0,.5), 0 0 20px rgba(var(--v5-cyan-rgb),.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);
|
||||
box-shadow: 0 12px 32px rgba(0,0,0,.12), 0 0 30px rgba(var(--v5-cyan-rgb),.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;
|
||||
background: linear-gradient(135deg, rgba(var(--v5-cyan-rgb),.12), rgba(var(--v5-cyan-rgb),.04));
|
||||
border-bottom: 1px solid rgba(var(--v5-cyan-rgb),.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;
|
||||
font-size: .7rem; background: rgba(var(--v5-cyan-rgb),.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;
|
||||
background: rgba(var(--v5-cyan-rgb),.1); color: var(--ctrl-cyan); font-weight: 700;
|
||||
}
|
||||
|
||||
.tbl-node-cols { padding: .35rem 0; max-height: 160px; overflow-y: auto; }
|
||||
@@ -167,19 +167,19 @@ html:not(.dark) .ctrl-badge { background: rgba(255,255,255,.92);
|
||||
|
||||
.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;
|
||||
border: 2px solid rgba(var(--v5-cyan-rgb),.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);
|
||||
box-shadow: 0 0 8px rgba(var(--v5-cyan-rgb),.5); transform: scale(1.3);
|
||||
}
|
||||
.tbl-port.pk { border-color: var(--ctrl-primary); background: rgba(108,92,231,.15); }
|
||||
.tbl-port.pk { border-color: var(--ctrl-primary); background: rgba(var(--v5-primary-rgb),.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.pk { color: var(--ctrl-primary); background: rgba(var(--v5-primary-rgb),.1); }
|
||||
.tbl-col-mark.fk { color: var(--ctrl-amber); background: rgba(253,203,110,.1); }
|
||||
|
||||
/* ═══ 제어 노드 (액션/조건/타이머) ═══ */
|
||||
@@ -218,7 +218,7 @@ html:not(.dark) .ctrl-badge { background: rgba(255,255,255,.92);
|
||||
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);
|
||||
background: rgba(var(--v5-red-rgb),.1); border-color: rgba(var(--v5-red-rgb),.3); color: var(--ctrl-red);
|
||||
}
|
||||
|
||||
.ctrl-an-body {
|
||||
@@ -255,12 +255,12 @@ html:not(.dark) .ctrl-badge { background: rgba(255,255,255,.92);
|
||||
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:hover { box-shadow: 0 0 10px rgba(var(--v5-cyan-rgb),.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;
|
||||
box-shadow: 0 0 14px rgba(var(--v5-cyan-rgb),.6) !important;
|
||||
transform: translateY(-50%) scale(1.5) !important;
|
||||
}
|
||||
.ctrl-io-port.port-hover.tbl-io { transform: scale(1.5) !important; }
|
||||
@@ -268,8 +268,8 @@ html:not(.dark) .ctrl-badge { background: rgba(255,255,255,.92);
|
||||
/* 드래그 중 모든 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); }
|
||||
0%, 100% { box-shadow: 0 0 4px rgba(var(--v5-cyan-rgb),.3); }
|
||||
50% { box-shadow: 0 0 14px rgba(var(--v5-cyan-rgb),.6); }
|
||||
}
|
||||
|
||||
/* ═══ 규칙 연결선 ═══ */
|
||||
@@ -290,14 +290,14 @@ html:not(.dark) .ctrl-badge { background: rgba(255,255,255,.92);
|
||||
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);
|
||||
border: 1.5px solid rgba(var(--v5-red-rgb),.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);
|
||||
background: rgba(var(--v5-red-rgb),.15); border-color: rgba(var(--v5-red-rgb),.5); color: var(--ctrl-red);
|
||||
box-shadow: 0 2px 12px rgba(var(--v5-red-rgb),.2);
|
||||
}
|
||||
|
||||
/* ═══ 설정 팝오버 ═══ */
|
||||
@@ -306,8 +306,8 @@ html:not(.dark) .ctrl-badge { background: rgba(255,255,255,.92);
|
||||
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);
|
||||
border: 1px solid rgba(var(--v5-primary-rgb),.3); border-radius: 12px;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,.15), 0 0 24px rgba(var(--v5-primary-rgb),.1);
|
||||
z-index: 50; padding: .7rem;
|
||||
opacity: 0; transform: translateX(-8px); transition: opacity .2s, transform .2s;
|
||||
}
|
||||
@@ -355,7 +355,7 @@ html:not(.dark) .ctrl-badge { background: rgba(255,255,255,.92);
|
||||
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);
|
||||
background: rgba(var(--v5-cyan-rgb),.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; }
|
||||
@@ -376,7 +376,7 @@ html:not(.dark) .ctrl-badge { background: rgba(255,255,255,.92);
|
||||
font-weight: 600; cursor: pointer; transition: all .2s;
|
||||
}
|
||||
.ctrl-mode-btn.on {
|
||||
background: rgba(0,206,201,.12); border-color: var(--ctrl-cyan);
|
||||
background: rgba(var(--v5-cyan-rgb),.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)); }
|
||||
@@ -386,5 +386,5 @@ html:not(.dark) .ctrl-action-node {
|
||||
}
|
||||
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);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.1); border-color: rgba(var(--v5-primary-rgb),.2);
|
||||
}
|
||||
|
||||
@@ -45,13 +45,13 @@
|
||||
.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));
|
||||
background: linear-gradient(135deg, rgba(var(--v5-primary-rgb),.12), rgba(var(--v5-primary-rgb),.05));
|
||||
color: var(--v5-primary); font-weight: 600;
|
||||
border: 1px solid rgba(108,92,231,.15); box-shadow: var(--v5-glow-sm);
|
||||
border: 1px solid rgba(var(--v5-primary-rgb),.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);
|
||||
background: linear-gradient(135deg, rgba(var(--v5-primary-rgb),.14), rgba(var(--v5-primary-rgb),.05));
|
||||
border-color: rgba(var(--v5-primary-rgb),.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;
|
||||
@@ -67,7 +67,7 @@
|
||||
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-si-act.danger:hover { background: rgba(var(--v5-red-rgb),.12); color: var(--v5-red); }
|
||||
|
||||
/* 새 대시보드 추가 버튼 */
|
||||
.dash-add-btn {
|
||||
@@ -78,7 +78,7 @@
|
||||
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); }
|
||||
background: rgba(var(--v5-primary-rgb),.04); }
|
||||
|
||||
/* ── 콘텐츠 영역 ── */
|
||||
.dash-content {
|
||||
@@ -136,11 +136,11 @@
|
||||
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;
|
||||
background-image: radial-gradient(circle at 0.5px 0.5px, rgba(var(--v5-primary-rgb),.25) 0.5px, transparent 0);
|
||||
outline: 1px dashed rgba(var(--v5-primary-rgb),.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);
|
||||
background-image: radial-gradient(circle at 0.5px 0.5px, rgba(var(--v5-primary-rgb),.3) 0.5px, transparent 0);
|
||||
}
|
||||
|
||||
/* ── 카드 ── */
|
||||
@@ -150,9 +150,9 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255,255,255,0.92);
|
||||
border: 1px solid var(--v5-glass-border, rgba(108,92,231,.2));
|
||||
border: 1px solid var(--v5-glass-border, rgba(var(--v5-primary-rgb),.2));
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.12), 0 0 20px rgba(108,92,231,.15);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.12), 0 0 20px rgba(var(--v5-primary-rgb),.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -161,20 +161,20 @@
|
||||
}
|
||||
.dark .dash-card {
|
||||
background: rgba(28, 26, 56, 0.92);
|
||||
border-color: rgba(162,155,254,.2);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 0 20px rgba(162,155,254,.15);
|
||||
border-color: rgba(var(--v5-primary-rgb),.2);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 0 20px rgba(var(--v5-primary-rgb),.15);
|
||||
}
|
||||
.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);
|
||||
.dash-card:hover { border-color: rgba(var(--v5-primary-rgb),.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); }
|
||||
border-style: solid; border-color: rgba(var(--v5-primary-rgb),.3); }
|
||||
.dash-card.dragging {
|
||||
box-shadow: 0 24px 60px rgba(108,92,231,.35), var(--v5-glow-lg, var(--v5-glow-md));
|
||||
box-shadow: 0 24px 60px rgba(var(--v5-primary-rgb),.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));
|
||||
box-shadow: 0 24px 60px rgba(var(--v5-cyan-rgb),.3), var(--v5-glow-lg, var(--v5-glow-md));
|
||||
border-color: var(--v5-cyan); z-index: 50;
|
||||
}
|
||||
|
||||
@@ -189,15 +189,15 @@
|
||||
.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);
|
||||
background: linear-gradient(135deg, rgba(var(--v5-primary-rgb),.15), rgba(var(--v5-cyan-rgb),.1));
|
||||
border: 1px solid rgba(var(--v5-primary-rgb),.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);
|
||||
background: rgba(var(--v5-primary-rgb),.08); border: 1px solid rgba(var(--v5-primary-rgb),.18);
|
||||
text-transform: uppercase; letter-spacing: .05em;
|
||||
}
|
||||
.dash-card-head-r { display: flex; align-items: center; gap: .3rem; }
|
||||
@@ -207,7 +207,7 @@
|
||||
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-btn.danger:hover { background: rgba(var(--v5-red-rgb),.12); color: var(--v5-red); }
|
||||
|
||||
/* 카드 본문 — container query 활성화 */
|
||||
.dash-card-body {
|
||||
@@ -404,7 +404,7 @@
|
||||
}
|
||||
.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));
|
||||
background: linear-gradient(135deg, rgba(var(--v5-primary-rgb),.12), rgba(var(--v5-primary-rgb),.04));
|
||||
color: var(--v5-primary); font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -422,14 +422,14 @@
|
||||
.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);
|
||||
background: linear-gradient(135deg, rgba(var(--v5-primary-rgb),.15), rgba(var(--v5-cyan-rgb),.08));
|
||||
border: 1px solid rgba(var(--v5-primary-rgb),.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;
|
||||
background: rgba(var(--v5-primary-rgb),.08); color: var(--v5-primary); font-weight: 600;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
.dash-crud-btn:hover:not(:disabled) {
|
||||
border-color: var(--v5-primary);
|
||||
color: var(--v5-primary);
|
||||
background: rgba(108,92,231,.08);
|
||||
background: rgba(var(--v5-primary-rgb),.08);
|
||||
}
|
||||
.dash-crud-btn:disabled {
|
||||
opacity: .4;
|
||||
@@ -537,7 +537,7 @@
|
||||
}
|
||||
.dash-crud-btn.danger:hover:not(:disabled) {
|
||||
border-color: var(--v5-red);
|
||||
background: rgba(255,71,87,.08);
|
||||
background: rgba(var(--v5-red-rgb),.08);
|
||||
}
|
||||
.dash-crud-note {
|
||||
margin-left: auto;
|
||||
|
||||
@@ -192,14 +192,14 @@ html:not(.dark) .dev-canvas {
|
||||
container-type: inline-size;
|
||||
container-name: card;
|
||||
background-image:
|
||||
linear-gradient(to right, var(--grid-line, rgba(108,92,231,.08)) 1px, transparent 1px);
|
||||
linear-gradient(to right, var(--grid-line, rgba(var(--v5-primary-rgb),.08)) 1px, transparent 1px);
|
||||
background-size: calc((100% - 32px) / 12 + 8px) 100%;
|
||||
background-position: 16px 0;
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.dev-canvas-grid.dragging {
|
||||
background-image:
|
||||
linear-gradient(to right, var(--grid-line-hover, rgba(108,92,231,.2)) 1px, transparent 1px);
|
||||
linear-gradient(to right, var(--grid-line-hover, rgba(var(--v5-primary-rgb),.2)) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
/* 빌더 팝업 프레임 안의 grid */
|
||||
|
||||
+284
-88
@@ -5,20 +5,28 @@
|
||||
|
||||
/* ===== V5 CSS Variables ===== */
|
||||
:root {
|
||||
/* RGB triplets for dynamic color theming (used as rgba(var(--v5-primary-rgb), .X)) */
|
||||
--v5-primary-rgb:108,92,231;
|
||||
--v5-cyan-rgb:0,206,201;
|
||||
--v5-pink-rgb:253,121,168;
|
||||
--v5-red-rgb:255,71,87;
|
||||
--v5-green-rgb:0,184,148;
|
||||
--v5-amber-rgb:253,203,110;
|
||||
|
||||
--v5-bg:#fafaff; --v5-bg-subtle:#f3f2fa;
|
||||
--v5-surface:rgba(255,255,255,0.55); --v5-surface-solid:#ffffff;
|
||||
--v5-surface-hover:rgba(255,255,255,0.7);
|
||||
--v5-text:#0f0e1a; --v5-text-sec:#6b6a80; --v5-text-muted:#9998ad;
|
||||
--v5-primary:#6c5ce7; --v5-primary-light:#a29bfe; --v5-primary-glow:rgba(108,92,231,0.25);
|
||||
--v5-cyan:#00cec9; --v5-cyan-glow:rgba(0,206,201,0.2);
|
||||
--v5-pink:#fd79a8; --v5-pink-glow:rgba(253,121,168,0.15);
|
||||
--v5-red:#ff4757; --v5-green:#00b894; --v5-amber:#fdcb6e;
|
||||
--v5-border:rgba(108,92,231,0.12); --v5-border-subtle:rgba(0,0,0,0.05);
|
||||
--v5-primary:rgb(var(--v5-primary-rgb)); --v5-primary-light:#a29bfe; --v5-primary-glow:rgba(var(--v5-primary-rgb),0.25);
|
||||
--v5-cyan:rgb(var(--v5-cyan-rgb)); --v5-cyan-glow:rgba(var(--v5-cyan-rgb),0.2);
|
||||
--v5-pink:rgb(var(--v5-pink-rgb)); --v5-pink-glow:rgba(var(--v5-pink-rgb),0.15);
|
||||
--v5-red:rgb(var(--v5-red-rgb)); --v5-green:rgb(var(--v5-green-rgb)); --v5-amber:rgb(var(--v5-amber-rgb));
|
||||
--v5-border:rgba(var(--v5-primary-rgb),0.12); --v5-border-subtle:rgba(0,0,0,0.05);
|
||||
--v5-glass:rgba(255,255,255,0.45); --v5-glass-strong:rgba(255,255,255,0.65);
|
||||
--v5-glass-border:rgba(108,92,231,0.12);
|
||||
--v5-glow-sm:0 0 20px rgba(108,92,231,0.12);
|
||||
--v5-glow-md:0 0 40px rgba(108,92,231,0.2);
|
||||
--v5-glow-lg:0 0 80px rgba(108,92,231,0.25);
|
||||
--v5-glass-border:rgba(var(--v5-primary-rgb),0.12);
|
||||
--v5-glow-sm:0 0 20px rgba(var(--v5-primary-rgb),0.12);
|
||||
--v5-glow-md:0 0 40px rgba(var(--v5-primary-rgb),0.2);
|
||||
--v5-glow-lg:0 0 80px rgba(var(--v5-primary-rgb),0.25);
|
||||
--v5-sidebar-w:220px;
|
||||
|
||||
/* ===== Template Grid System (2026-04-10) =====
|
||||
@@ -31,31 +39,74 @@
|
||||
--grid-gap-wide:.55rem;
|
||||
--card-narrow-max:520px;
|
||||
--card-normal-max:900px;
|
||||
--grid-line:rgba(108,92,231,.08);
|
||||
--grid-line-hover:rgba(108,92,231,.2);
|
||||
--grid-drop-preview:rgba(108,92,231,.15);
|
||||
--grid-drop-preview-border:rgba(108,92,231,.5);
|
||||
--grid-line:rgba(var(--v5-primary-rgb),.08);
|
||||
--grid-line-hover:rgba(var(--v5-primary-rgb),.2);
|
||||
--grid-drop-preview:rgba(var(--v5-primary-rgb),.15);
|
||||
--grid-drop-preview-border:rgba(var(--v5-primary-rgb),.5);
|
||||
}
|
||||
.dark {
|
||||
/* Dark-mode RGB triplets — overridden by data-color presets when active */
|
||||
--v5-primary-rgb:162,155,254;
|
||||
--v5-cyan-rgb:85,239,196;
|
||||
--v5-pink-rgb:253,121,168;
|
||||
--v5-red-rgb:255,107,107;
|
||||
--v5-green-rgb:85,239,196;
|
||||
--v5-amber-rgb:255,234,167;
|
||||
|
||||
--v5-bg:#06050e; --v5-bg-subtle:#0c0b18;
|
||||
--v5-surface:rgba(17,16,42,0.5); --v5-surface-solid:#11102a;
|
||||
--v5-surface-hover:rgba(25,24,64,0.6);
|
||||
--v5-text:#eae8f4; --v5-text-sec:#8d8ba8; --v5-text-muted:#5a587a;
|
||||
--v5-primary:#a29bfe; --v5-primary-light:#c8c4ff; --v5-primary-glow:rgba(162,155,254,0.25);
|
||||
--v5-cyan:#55efc4; --v5-cyan-glow:rgba(85,239,196,0.15);
|
||||
--v5-pink:#fd79a8; --v5-red:#ff6b6b; --v5-green:#55efc4; --v5-amber:#ffeaa7;
|
||||
--v5-border:rgba(162,155,254,0.1); --v5-border-subtle:rgba(255,255,255,0.04);
|
||||
--v5-primary:rgb(var(--v5-primary-rgb)); --v5-primary-light:#c8c4ff; --v5-primary-glow:rgba(var(--v5-primary-rgb),0.25);
|
||||
--v5-cyan:rgb(var(--v5-cyan-rgb)); --v5-cyan-glow:rgba(var(--v5-cyan-rgb),0.15);
|
||||
--v5-pink:rgb(var(--v5-pink-rgb)); --v5-red:rgb(var(--v5-red-rgb)); --v5-green:rgb(var(--v5-green-rgb)); --v5-amber:rgb(var(--v5-amber-rgb));
|
||||
--v5-border:rgba(var(--v5-primary-rgb),0.1); --v5-border-subtle:rgba(255,255,255,0.04);
|
||||
--v5-glass:rgba(17,16,42,0.45); --v5-glass-strong:rgba(17,16,42,0.65);
|
||||
--v5-glass-border:rgba(162,155,254,0.12);
|
||||
--v5-glow-sm:0 0 20px rgba(162,155,254,0.1);
|
||||
--v5-glow-md:0 0 40px rgba(162,155,254,0.18);
|
||||
--v5-glow-lg:0 0 80px rgba(162,155,254,0.22);
|
||||
--grid-line:rgba(162,155,254,.1);
|
||||
--grid-line-hover:rgba(162,155,254,.25);
|
||||
--grid-drop-preview:rgba(162,155,254,.15);
|
||||
--grid-drop-preview-border:rgba(162,155,254,.5);
|
||||
--v5-glass-border:rgba(var(--v5-primary-rgb),0.12);
|
||||
--v5-glow-sm:0 0 20px rgba(var(--v5-primary-rgb),0.1);
|
||||
--v5-glow-md:0 0 40px rgba(var(--v5-primary-rgb),0.18);
|
||||
--v5-glow-lg:0 0 80px rgba(var(--v5-primary-rgb),0.22);
|
||||
--grid-line:rgba(var(--v5-primary-rgb),.1);
|
||||
--grid-line-hover:rgba(var(--v5-primary-rgb),.25);
|
||||
--grid-drop-preview:rgba(var(--v5-primary-rgb),.15);
|
||||
--grid-drop-preview-border:rgba(var(--v5-primary-rgb),.5);
|
||||
}
|
||||
|
||||
/* ===== COLOR THEME PRESETS =====
|
||||
Apply via <html data-color="purple|blue|green|orange|pink|cyan">.
|
||||
Each preset defines RGB triplets for light & dark modes.
|
||||
Default (no data-color) = purple — already defined in :root / .dark above. */
|
||||
|
||||
/* --- BLUE --- */
|
||||
html[data-color="blue"]{
|
||||
--v5-primary-rgb:59,130,246; --v5-cyan-rgb:14,165,233; --v5-pink-rgb:99,102,241;}
|
||||
html.dark[data-color="blue"]{
|
||||
--v5-primary-rgb:147,197,253; --v5-cyan-rgb:125,211,252; --v5-pink-rgb:129,140,248;}
|
||||
|
||||
/* --- GREEN --- */
|
||||
html[data-color="green"]{
|
||||
--v5-primary-rgb:16,185,129; --v5-cyan-rgb:20,184,166; --v5-pink-rgb:132,204,22;}
|
||||
html.dark[data-color="green"]{
|
||||
--v5-primary-rgb:110,231,183; --v5-cyan-rgb:94,234,212; --v5-pink-rgb:190,242,100;}
|
||||
|
||||
/* --- ORANGE --- */
|
||||
html[data-color="orange"]{
|
||||
--v5-primary-rgb:249,115,22; --v5-cyan-rgb:6,182,212; --v5-pink-rgb:251,146,60;}
|
||||
html.dark[data-color="orange"]{
|
||||
--v5-primary-rgb:253,186,116; --v5-cyan-rgb:103,232,249; --v5-pink-rgb:252,165,165;}
|
||||
|
||||
/* --- PINK --- */
|
||||
html[data-color="pink"]{
|
||||
--v5-primary-rgb:236,72,153; --v5-cyan-rgb:168,85,247; --v5-pink-rgb:244,114,182;}
|
||||
html.dark[data-color="pink"]{
|
||||
--v5-primary-rgb:244,114,182; --v5-cyan-rgb:192,132,252; --v5-pink-rgb:249,168,212;}
|
||||
|
||||
/* --- CYAN --- */
|
||||
html[data-color="cyan"]{
|
||||
--v5-primary-rgb:8,145,178; --v5-cyan-rgb:14,165,233; --v5-pink-rgb:6,182,212;}
|
||||
html.dark[data-color="cyan"]{
|
||||
--v5-primary-rgb:125,211,252; --v5-cyan-rgb:103,232,249; --v5-pink-rgb:165,243,252;}
|
||||
|
||||
/* ===== COSMIC BACKGROUND ===== */
|
||||
.v5-cosmos{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
|
||||
.v5-cosmos .star{position:absolute;width:2px;height:2px;background:white;border-radius:50%;
|
||||
@@ -70,19 +121,19 @@ html:not(.dark) .v5-cosmos .particle{display:none;}
|
||||
.v5-cosmos .neb-1{width:700px;height:700px;top:-20%;right:-15%;background:radial-gradient(circle,var(--v5-primary-glow),transparent 70%);animation-duration:18s;}
|
||||
.v5-cosmos .neb-2{width:600px;height:600px;bottom:-25%;left:-10%;background:radial-gradient(circle,var(--v5-cyan-glow),transparent 70%);animation-duration:14s;animation-delay:-4s;}
|
||||
.v5-cosmos .neb-3{width:450px;height:450px;top:35%;left:40%;background:radial-gradient(circle,var(--v5-pink-glow),transparent 70%);animation-duration:12s;animation-delay:-8s;}
|
||||
.v5-cosmos .neb-4{width:350px;height:350px;top:60%;right:25%;background:radial-gradient(circle,rgba(108,92,231,0.08),transparent 70%);animation-duration:20s;animation-delay:-2s;}
|
||||
.v5-cosmos .neb-4{width:350px;height:350px;top:60%;right:25%;background:radial-gradient(circle,rgba(var(--v5-primary-rgb),0.08),transparent 70%);animation-duration:20s;animation-delay:-2s;}
|
||||
@keyframes v5-drift{0%{transform:translate(0,0) scale(1)}100%{transform:translate(30px,-25px) scale(1.1)}}
|
||||
|
||||
html:not(.dark) .v5-cosmos{background:linear-gradient(180deg,#e8e4ff 0%,#f0edff 30%,#fafaff 60%,#f5f0ff 100%);}
|
||||
html:not(.dark) .v5-cosmos{background:linear-gradient(180deg,#dcd4f0 0%,#e2dbf3 30%,#e6e0f5 60%,#dbd3ee 100%);}
|
||||
html:not(.dark) .v5-cosmos .neb{filter:blur(100px);}
|
||||
html:not(.dark) .v5-cosmos .neb-1{width:1200px;height:500px;top:auto;bottom:-10%;right:-15%;border-radius:50%;
|
||||
background:radial-gradient(ellipse,rgba(255,255,255,0.9),rgba(230,225,255,0.5),transparent 70%);animation-duration:25s;}
|
||||
background:radial-gradient(ellipse,rgba(240,233,255,0.55),rgba(220,210,250,0.35),transparent 70%);animation-duration:25s;}
|
||||
html:not(.dark) .v5-cosmos .neb-2{width:1000px;height:400px;top:auto;bottom:-5%;left:-10%;
|
||||
background:radial-gradient(ellipse,rgba(255,255,255,0.85),rgba(200,240,255,0.4),transparent 70%);animation-duration:20s;}
|
||||
background:radial-gradient(ellipse,rgba(238,232,250,0.5),rgba(200,220,250,0.3),transparent 70%);animation-duration:20s;}
|
||||
html:not(.dark) .v5-cosmos .neb-3{width:800px;height:350px;top:auto;bottom:5%;left:30%;
|
||||
background:radial-gradient(ellipse,rgba(255,255,255,0.8),rgba(240,220,255,0.3),transparent 70%);animation-duration:22s;}
|
||||
background:radial-gradient(ellipse,rgba(245,238,255,0.45),rgba(230,210,250,0.25),transparent 70%);animation-duration:22s;}
|
||||
html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bottom:auto;
|
||||
background:radial-gradient(circle,rgba(108,92,231,0.08),rgba(0,206,201,0.04),transparent 70%);}
|
||||
background:radial-gradient(circle,rgba(var(--v5-primary-rgb),0.1),rgba(var(--v5-cyan-rgb),0.05),transparent 70%);}
|
||||
|
||||
.v5-cosmos .shooting-star{position:absolute;width:80px;height:1px;
|
||||
background:linear-gradient(90deg,rgba(255,255,255,0.6),transparent);
|
||||
@@ -101,6 +152,11 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
.v5-hdr{height:50px;display:flex;align-items:center;justify-content:space-between;padding:0 1.25rem;
|
||||
background:var(--v5-glass);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border-bottom:1px solid var(--v5-glass-border);position:relative;z-index:20;flex-shrink:0;}
|
||||
/* Light mode: 헤더가 본문 위에 떠있는 입체감 + 더 명확한 흰 톤 */
|
||||
html:not(.dark) .v5-hdr{
|
||||
background:linear-gradient(180deg,rgba(255,255,255,0.9),rgba(252,250,255,0.78));
|
||||
border-bottom-color:rgba(var(--v5-primary-rgb),0.18);
|
||||
box-shadow:0 1px 0 rgba(255,255,255,0.6) inset, 0 4px 20px rgba(var(--v5-primary-rgb),0.06);}
|
||||
.v5-hdr-l{display:flex;align-items:center;gap:1rem;}
|
||||
.v5-hdr-logo{font-size:1.05rem;font-weight:900;letter-spacing:-.03em;
|
||||
background:linear-gradient(135deg,var(--v5-primary),var(--v5-cyan));-webkit-background-clip:text;
|
||||
@@ -117,13 +173,23 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
transition:all .3s cubic-bezier(.4,0,.2,1);}
|
||||
.v5-pill button.on{background:var(--v5-primary);color:white;box-shadow:var(--v5-glow-sm);}
|
||||
|
||||
/* 대시보드 생성 + 편집 모드 (헤더, Light/Dark 토글 왼쪽) */
|
||||
.v5-dash-btn{display:inline-flex;align-items:center;gap:.3rem;padding:.32rem .7rem;
|
||||
border:1px solid var(--v5-glass-border);background:var(--v5-surface);backdrop-filter:blur(8px);
|
||||
border-radius:10px;color:var(--v5-text-muted);cursor:pointer;font-size:.62rem;font-weight:600;
|
||||
font-family:inherit;transition:all .2s cubic-bezier(.4,0,.2,1);}
|
||||
.v5-dash-btn:hover:not(:disabled){border-color:var(--v5-primary);color:var(--v5-primary);box-shadow:var(--v5-glow-sm);}
|
||||
.v5-dash-btn:disabled{opacity:.4;cursor:not-allowed;}
|
||||
.v5-dash-btn.on{background:var(--v5-primary);border-color:var(--v5-primary);color:white;box-shadow:var(--v5-glow-sm);}
|
||||
.v5-dash-btn.on:hover{color:white;}
|
||||
|
||||
/* Bell */
|
||||
.v5-bell{position:relative;width:32px;height:32px;border-radius:10px;border:1px solid var(--v5-glass-border);
|
||||
background:var(--v5-surface);backdrop-filter:blur(8px);color:var(--v5-text-muted);cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;transition:all .2s;}
|
||||
.v5-bell:hover{border-color:var(--v5-primary);color:var(--v5-primary);box-shadow:var(--v5-glow-sm);}
|
||||
.v5-bell-dot{position:absolute;top:5px;right:5px;width:7px;height:7px;background:var(--v5-red);border-radius:50%;animation:v5-pdot 2s infinite;}
|
||||
@keyframes v5-pdot{0%,100%{box-shadow:0 0 0 0 rgba(255,71,87,.4)}50%{box-shadow:0 0 0 5px rgba(255,71,87,0)}}
|
||||
@keyframes v5-pdot{0%,100%{box-shadow:0 0 0 0 rgba(var(--v5-red-rgb),.4)}50%{box-shadow:0 0 0 5px rgba(var(--v5-red-rgb),0)}}
|
||||
|
||||
/* Admin button */
|
||||
.v5-admin-btn{position:relative;width:32px;height:32px;border-radius:10px;border:1px solid var(--v5-glass-border);
|
||||
@@ -138,7 +204,7 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
.v5-admin-btn .ic-home{display:none;}
|
||||
.v5-admin-mode .v5-admin-btn .ic-gear{display:none;}
|
||||
.v5-admin-mode .v5-admin-btn .ic-home{display:block;}
|
||||
.v5-admin-mode .v5-admin-btn{border-color:var(--v5-cyan);color:var(--v5-cyan);background:rgba(0,206,201,.08);box-shadow:0 0 15px var(--v5-cyan-glow);}
|
||||
.v5-admin-mode .v5-admin-btn{border-color:var(--v5-cyan);color:var(--v5-cyan);background:rgba(var(--v5-cyan-rgb),.08);box-shadow:0 0 15px var(--v5-cyan-glow);}
|
||||
.v5-admin-mode .v5-admin-btn:hover{color:var(--v5-cyan);border-color:var(--v5-cyan);}
|
||||
.v5-admin-mode .v5-admin-btn .v5-admin-label{color:var(--v5-cyan);}
|
||||
|
||||
@@ -172,7 +238,7 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
.v5-avatar-dd .av-item:hover .av-ic{opacity:1;}
|
||||
.v5-avatar-dd .av-divider{height:1px;background:var(--v5-border-subtle);margin:.3rem .6rem;}
|
||||
.v5-avatar-dd .av-item.danger{color:var(--v5-red);}
|
||||
.v5-avatar-dd .av-item.danger:hover{background:rgba(255,71,87,.08);}
|
||||
.v5-avatar-dd .av-item.danger:hover{background:rgba(var(--v5-red-rgb),.08);}
|
||||
|
||||
/* Notification panel */
|
||||
.v5-noti-panel{position:absolute;top:calc(100% + 10px);right:0;width:300px;max-height:400px;
|
||||
@@ -192,12 +258,12 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
.v5-noti-list{overflow-y:auto;max-height:330px;padding:.35rem;}
|
||||
.v5-noti-item{display:flex;gap:.55rem;padding:.55rem .5rem;border-radius:10px;cursor:pointer;transition:all .15s;}
|
||||
.v5-noti-item:hover{background:var(--v5-surface-hover);}
|
||||
.v5-noti-item.unread{background:linear-gradient(135deg,rgba(108,92,231,.05),rgba(108,92,231,.02));}
|
||||
.dark .v5-noti-item.unread{background:linear-gradient(135deg,rgba(162,155,254,.06),rgba(162,155,254,.02));}
|
||||
.v5-noti-item.unread{background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.05),rgba(var(--v5-primary-rgb),.02));}
|
||||
.dark .v5-noti-item.unread{background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.06),rgba(var(--v5-primary-rgb),.02));}
|
||||
.v5-noti-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;margin-top:.3rem;}
|
||||
.v5-noti-dot.info{background:var(--v5-primary);}
|
||||
.v5-noti-dot.warn{background:var(--v5-amber);}
|
||||
.v5-noti-dot.err{background:var(--v5-red);box-shadow:0 0 6px rgba(255,71,87,.4);}
|
||||
.v5-noti-dot.err{background:var(--v5-red);box-shadow:0 0 6px rgba(var(--v5-red-rgb),.4);}
|
||||
.v5-noti-dot.ok{background:var(--v5-green);}
|
||||
.v5-noti-body{flex:1;min-width:0;}
|
||||
.v5-noti-msg{font-size:.7rem;font-weight:500;color:var(--v5-text);line-height:1.35;}
|
||||
@@ -212,11 +278,11 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
|
||||
/* Admin badge — display:none 대신 opacity/transform 으로 hidden 해서 zoom-in/out 애니메이션 가능 */
|
||||
.v5-admin-badge{display:flex;align-items:center;gap:.4rem;padding:.2rem .6rem;border-radius:999px;
|
||||
background:linear-gradient(135deg,rgba(108,92,231,.12),rgba(0,206,201,.08));
|
||||
border:1px solid rgba(108,92,231,.2);font-size:.58rem;font-weight:700;color:var(--v5-primary);
|
||||
background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.12),rgba(var(--v5-cyan-rgb),.08));
|
||||
border:1px solid rgba(var(--v5-primary-rgb),.2);font-size:.58rem;font-weight:700;color:var(--v5-primary);
|
||||
opacity:0;transform:scale(0) rotate(-30deg);pointer-events:none;}
|
||||
.dark .v5-admin-badge{background:linear-gradient(135deg,rgba(162,155,254,.12),rgba(85,239,196,.08));
|
||||
border-color:rgba(162,155,254,.2);color:var(--v5-primary-light);}
|
||||
.dark .v5-admin-badge{background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.12),rgba(var(--v5-cyan-rgb),.08));
|
||||
border-color:rgba(var(--v5-primary-rgb),.2);color:var(--v5-primary-light);}
|
||||
.v5-admin-mode .v5-admin-badge{opacity:1;transform:scale(1) rotate(0);pointer-events:auto;}
|
||||
/* badge zoom — 모드 진입 시 bouncy in, 이탈 시 quick out */
|
||||
.v5-admin-badge.mode-zoom-in{animation:v5-badge-zoom-in .55s cubic-bezier(.34,1.56,.64,1) both;}
|
||||
@@ -243,7 +309,7 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
.v5-tab-x{width:14px;height:14px;border-radius:3px;border:none;background:transparent;color:var(--v5-text-muted);
|
||||
font-size:.6rem;cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:0;transition:all .15s;}
|
||||
.v5-tab:hover .v5-tab-x{opacity:1;}
|
||||
.v5-tab-x:hover{background:rgba(255,71,87,.15);color:var(--v5-red);}
|
||||
.v5-tab-x:hover{background:rgba(var(--v5-red-rgb),.15);color:var(--v5-red);}
|
||||
|
||||
/* Tab collapse — 탭 바에 통합된 좌측 핸들 (떠있는 박스 느낌 제거) */
|
||||
.v5-tab-toggle{width:32px;height:36px;border:none;background:transparent;color:var(--v5-text-muted);cursor:pointer;
|
||||
@@ -284,12 +350,12 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
color:var(--v5-text-sec);cursor:pointer;transition:all .15s;gap:.4rem;}
|
||||
.v5-tab-dropdown .td-item:hover{background:var(--v5-surface-hover);color:var(--v5-text);transform:translateX(2px);}
|
||||
.v5-tab-dropdown .td-item.on{color:var(--v5-primary);font-weight:600;
|
||||
background:linear-gradient(135deg,rgba(108,92,231,.1),rgba(108,92,231,.04));}
|
||||
background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.1),rgba(var(--v5-primary-rgb),.04));}
|
||||
.v5-tab-dropdown .td-close{width:16px;height:16px;border-radius:4px;border:none;
|
||||
background:transparent;color:var(--v5-text-muted);cursor:pointer;display:flex;
|
||||
align-items:center;justify-content:center;font-size:.55rem;opacity:0;transition:all .15s;flex-shrink:0;}
|
||||
.v5-tab-dropdown .td-item:hover .td-close{opacity:1;}
|
||||
.v5-tab-dropdown .td-close:hover{background:rgba(255,71,87,.12);color:var(--v5-red);}
|
||||
.v5-tab-dropdown .td-close:hover{background:rgba(var(--v5-red-rgb),.12);color:var(--v5-red);}
|
||||
.v5-tab-dropdown .td-head{display:flex;align-items:center;justify-content:space-between;
|
||||
padding:.3rem .65rem .5rem;border-bottom:1px solid var(--v5-border-subtle);margin-bottom:.25rem;}
|
||||
.v5-tab-dropdown .td-title{font-size:.6rem;font-weight:700;color:var(--v5-text-muted);text-transform:uppercase;letter-spacing:.08em;}
|
||||
@@ -299,9 +365,17 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
|
||||
/* ===== GLASS SIDEBAR ===== */
|
||||
.v5-body{display:flex;flex:1;overflow:hidden;position:relative;z-index:5;}
|
||||
/* Light mode: 본문 영역에 옅은 라벤더 톤 깔아 헤더/사이드바 흰색과 분리 */
|
||||
html:not(.dark) .v5-body{
|
||||
background:linear-gradient(135deg,#e8e1f5 0%,#ede6f7 35%,#e6dff3 70%,#e0d8ee 100%);}
|
||||
.v5-side{width:var(--v5-sidebar-w);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;}
|
||||
/* Light mode: 사이드바가 본문에서 분리되어 떠있는 입체감 */
|
||||
html:not(.dark) .v5-side{
|
||||
background:linear-gradient(180deg,rgba(254,253,255,0.85),rgba(250,248,254,0.7));
|
||||
border-right-color:rgba(var(--v5-primary-rgb),0.16);
|
||||
box-shadow:1px 0 0 rgba(255,255,255,0.5) inset, 4px 0 20px rgba(var(--v5-primary-rgb),0.05);}
|
||||
|
||||
.v5-side-sec{font-size:.55rem;font-weight:700;text-transform:uppercase;letter-spacing:.12em;
|
||||
color:var(--v5-text-muted);padding:1rem .65rem .35rem;
|
||||
@@ -313,10 +387,10 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
position:relative;overflow:hidden;height:auto;}
|
||||
.v5-si .ic{width:16px;height:16px;display:flex;align-items:center;justify-content:center;opacity:.65;flex-shrink:0;}
|
||||
.v5-si:hover{background:var(--v5-surface-hover);color:var(--v5-text);transform:translateX(2px);}
|
||||
.v5-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);}
|
||||
.v5-si.on{background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.12),rgba(var(--v5-primary-rgb),.05));
|
||||
color:var(--v5-primary);font-weight:600;border:1px solid rgba(var(--v5-primary-rgb),.15);box-shadow:var(--v5-glow-sm);}
|
||||
.v5-si.on .ic{opacity:1;}
|
||||
.dark .v5-si.on{background:linear-gradient(135deg,rgba(162,155,254,.14),rgba(162,155,254,.05));border-color:rgba(162,155,254,.15);}
|
||||
.dark .v5-si.on{background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.14),rgba(var(--v5-primary-rgb),.05));border-color:rgba(var(--v5-primary-rgb),.15);}
|
||||
.v5-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 .3s cubic-bezier(.16,1,.3,1);}
|
||||
.v5-si:hover::before{transform:scaleY(.5);opacity:.4;}
|
||||
@@ -411,8 +485,8 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
animation:v5-catIn .4s cubic-bezier(.16,1,.3,1) both;}
|
||||
.v5-side.collapsed .v5-side-cat:first-child{margin-top:0;}
|
||||
.v5-side.collapsed .v5-side-cat:hover{background:var(--v5-surface-hover);color:var(--v5-primary);transform:scale(1.05);}
|
||||
.v5-side.collapsed .v5-side-cat.open{background:linear-gradient(135deg,rgba(108,92,231,.1),rgba(108,92,231,.04));color:var(--v5-primary);}
|
||||
.dark .v5-side.collapsed .v5-side-cat.open{background:linear-gradient(135deg,rgba(162,155,254,.1),rgba(162,155,254,.04));}
|
||||
.v5-side.collapsed .v5-side-cat.open{background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.1),rgba(var(--v5-primary-rgb),.04));color:var(--v5-primary);}
|
||||
.dark .v5-side.collapsed .v5-side-cat.open{background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.1),rgba(var(--v5-primary-rgb),.04));}
|
||||
.v5-side-cat .cat-label{font-size:.48rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;
|
||||
margin-top:.15rem;text-align:center;line-height:1;}
|
||||
.v5-side.collapsed .v5-side-group:nth-child(1) .v5-side-cat{animation-delay:.06s;}
|
||||
@@ -487,17 +561,17 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
cursor:pointer;transition:all .15s;}
|
||||
.v5-side-flyout .fly-item:hover{background:var(--v5-surface-hover);color:var(--v5-text);transform:translateX(2px);}
|
||||
.v5-side-flyout .fly-item.on{color:var(--v5-primary);font-weight:600;
|
||||
background:linear-gradient(135deg,rgba(108,92,231,.1),rgba(108,92,231,.04));}
|
||||
background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.1),rgba(var(--v5-primary-rgb),.04));}
|
||||
.v5-side-flyout .fly-item .ic{width:14px;height:14px;display:flex;align-items:center;justify-content:center;opacity:.6;flex-shrink:0;}
|
||||
.v5-side-flyout .fly-item.on .ic{opacity:1;}
|
||||
|
||||
/* Admin sidebar accent */
|
||||
.v5-admin-side .v5-si.on{background:linear-gradient(135deg,rgba(0,206,201,.12),rgba(0,206,201,.05));
|
||||
color:var(--v5-cyan);border-color:rgba(0,206,201,.2);}
|
||||
.v5-admin-side .v5-si.on{background:linear-gradient(135deg,rgba(var(--v5-cyan-rgb),.12),rgba(var(--v5-cyan-rgb),.05));
|
||||
color:var(--v5-cyan);border-color:rgba(var(--v5-cyan-rgb),.2);}
|
||||
.v5-admin-side .v5-si.on .ic{opacity:1;}
|
||||
.v5-admin-side .v5-si::before{background:var(--v5-cyan);}
|
||||
.dark .v5-admin-side .v5-si.on{background:linear-gradient(135deg,rgba(85,239,196,.12),rgba(85,239,196,.05));
|
||||
border-color:rgba(85,239,196,.15);}
|
||||
.dark .v5-admin-side .v5-si.on{background:linear-gradient(135deg,rgba(var(--v5-cyan-rgb),.12),rgba(var(--v5-cyan-rgb),.05));
|
||||
border-color:rgba(var(--v5-cyan-rgb),.15);}
|
||||
|
||||
/* ===== MODE TRANSITION ===== */
|
||||
.v5-mode-fade{position:fixed;inset:0;z-index:9998;pointer-events:none;opacity:0;
|
||||
@@ -644,9 +718,9 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
--v5-mm-text-sec:#4e4c5e;
|
||||
--v5-mm-text-muted:#8a8899;
|
||||
--v5-mm-accent:#6c5ce7;
|
||||
--v5-mm-accent-soft:rgba(108,92,231,.08);
|
||||
--v5-mm-accent-line:rgba(108,92,231,.22);
|
||||
--v5-mm-ring:rgba(108,92,231,.14);
|
||||
--v5-mm-accent-soft:rgba(var(--v5-primary-rgb),.08);
|
||||
--v5-mm-accent-line:rgba(var(--v5-primary-rgb),.22);
|
||||
--v5-mm-ring:rgba(var(--v5-primary-rgb),.14);
|
||||
--v5-mm-green:#0a9975;
|
||||
--v5-mm-red:#dc2626;
|
||||
--v5-mm-amber:#c87d18;
|
||||
@@ -703,10 +777,10 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
.v5-mm-scope-list{padding:.3rem .6rem 1rem;display:flex;flex-direction:column;gap:.5rem;flex:1;overflow-y:auto;}
|
||||
.v5-mm-scope{display:flex;flex-direction:column;gap:.5rem;padding:.85rem;border-radius:10px;
|
||||
background:var(--v5-mm-panel);border:1px solid var(--v5-mm-border);cursor:pointer;transition:all .15s;}
|
||||
.v5-mm-scope:hover{border-color:rgba(108,92,231,.2);}
|
||||
.dark .v5-mm-scope:hover{border-color:rgba(162,155,254,.2);}
|
||||
.v5-mm-scope.on{background:rgba(108,92,231,.08);border-color:var(--v5-mm-accent);}
|
||||
.dark .v5-mm-scope.on{background:rgba(162,155,254,.08);}
|
||||
.v5-mm-scope:hover{border-color:rgba(var(--v5-primary-rgb),.2);}
|
||||
.dark .v5-mm-scope:hover{border-color:rgba(var(--v5-primary-rgb),.2);}
|
||||
.v5-mm-scope.on{background:rgba(var(--v5-primary-rgb),.08);border-color:var(--v5-mm-accent);}
|
||||
.dark .v5-mm-scope.on{background:rgba(var(--v5-primary-rgb),.08);}
|
||||
.v5-mm-scope-top{display:flex;align-items:center;justify-content:space-between;}
|
||||
.v5-mm-scope-ico{width:30px;height:30px;border-radius:8px;display:flex;align-items:center;justify-content:center;
|
||||
background:var(--v5-mm-sunk);color:var(--v5-mm-text-sec);border:1px solid var(--v5-mm-border-2);}
|
||||
@@ -725,8 +799,8 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
.v5-mm-tree-srch input{width:100%;height:28px;padding:0 .55rem 0 1.85rem;border:1px solid var(--v5-mm-border);border-radius:6px;
|
||||
background:var(--v5-mm-sunk);color:var(--v5-mm-text);font-size:.68rem;font-family:inherit;outline:none;transition:all .12s;}
|
||||
.v5-mm-tree-srch input:focus{border-color:var(--v5-mm-accent);background:var(--v5-mm-panel);
|
||||
box-shadow:0 0 0 2px rgba(108,92,231,.14);}
|
||||
.dark .v5-mm-tree-srch input:focus{box-shadow:0 0 0 2px rgba(162,155,254,.2);}
|
||||
box-shadow:0 0 0 2px rgba(var(--v5-primary-rgb),.14);}
|
||||
.dark .v5-mm-tree-srch input:focus{box-shadow:0 0 0 2px rgba(var(--v5-primary-rgb),.2);}
|
||||
|
||||
.v5-mm-tree{padding:.15rem .4rem 1rem;flex:1;overflow-y:auto;display:flex;flex-direction:column;}
|
||||
|
||||
@@ -735,8 +809,8 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
cursor:pointer;transition:all .12s;position:relative;user-select:none;
|
||||
background-clip:padding-box;}
|
||||
.v5-mm-node:hover{background:var(--v5-mm-sunk);}
|
||||
.v5-mm-node.on{background:rgba(108,92,231,.08);}
|
||||
.dark .v5-mm-node.on{background:rgba(162,155,254,.09);}
|
||||
.v5-mm-node.on{background:rgba(var(--v5-primary-rgb),.08);}
|
||||
.dark .v5-mm-node.on{background:rgba(var(--v5-primary-rgb),.09);}
|
||||
.v5-mm-node.on .v5-mm-node-name{color:var(--v5-mm-accent);font-weight:600;}
|
||||
|
||||
.v5-mm-caret{width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;
|
||||
@@ -763,16 +837,16 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
.v5-mm-cnt{font-size:.68rem;font-weight:700;color:var(--v5-mm-text-muted);font-variant-numeric:tabular-nums;flex-shrink:0;
|
||||
padding:.15rem .5rem;border-radius:5px;background:var(--v5-mm-sunk);border:1px solid var(--v5-mm-border-2);}
|
||||
.v5-mm-node.on .v5-mm-cnt{color:var(--v5-mm-accent);background:var(--v5-mm-panel);
|
||||
border-color:rgba(108,92,231,.2);}
|
||||
border-color:rgba(var(--v5-primary-rgb),.2);}
|
||||
|
||||
/* L1: 크게 강조 — margin 대신 padding을 써서 드래그 hit area를 여백까지 확장 */
|
||||
.v5-mm-node.l1{padding:1.2rem 1rem .95rem;border-radius:11px;background:var(--v5-mm-panel);
|
||||
border:1px solid var(--v5-mm-border);margin:0 .15rem .15rem;gap:.75rem;
|
||||
background-clip:padding-box;}
|
||||
.v5-mm-node.l1:hover{border-color:rgba(108,92,231,.2);background:rgba(108,92,231,.05);}
|
||||
.dark .v5-mm-node.l1:hover{border-color:rgba(162,155,254,.2);background:rgba(162,155,254,.05);}
|
||||
.v5-mm-node.l1.on{border-color:var(--v5-mm-accent);background:rgba(108,92,231,.08);}
|
||||
.dark .v5-mm-node.l1.on{background:rgba(162,155,254,.08);}
|
||||
.v5-mm-node.l1:hover{border-color:rgba(var(--v5-primary-rgb),.2);background:rgba(var(--v5-primary-rgb),.05);}
|
||||
.dark .v5-mm-node.l1:hover{border-color:rgba(var(--v5-primary-rgb),.2);background:rgba(var(--v5-primary-rgb),.05);}
|
||||
.v5-mm-node.l1.on{border-color:var(--v5-mm-accent);background:rgba(var(--v5-primary-rgb),.08);}
|
||||
.dark .v5-mm-node.l1.on{background:rgba(var(--v5-primary-rgb),.08);}
|
||||
.v5-mm-node.l1 .v5-mm-caret{width:18px;height:18px;}
|
||||
.v5-mm-node.l1 .v5-mm-caret svg{width:11px;height:11px;}
|
||||
.v5-mm-node.l1 .v5-mm-node-ico{width:38px;height:38px;border-radius:10px;}
|
||||
@@ -817,15 +891,15 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
/* 선택 상태 진입 애니메이션 */
|
||||
.v5-mm-node.on{animation:v5-mm-selectPulse .5s cubic-bezier(.16,1,.3,1);}
|
||||
@keyframes v5-mm-selectPulse{
|
||||
0%{box-shadow:inset 0 0 0 0 rgba(108,92,231,0);}
|
||||
50%{box-shadow:inset 0 0 0 2px rgba(108,92,231,.3);}
|
||||
100%{box-shadow:inset 0 0 0 0 rgba(108,92,231,0);}
|
||||
0%{box-shadow:inset 0 0 0 0 rgba(var(--v5-primary-rgb),0);}
|
||||
50%{box-shadow:inset 0 0 0 2px rgba(var(--v5-primary-rgb),.3);}
|
||||
100%{box-shadow:inset 0 0 0 0 rgba(var(--v5-primary-rgb),0);}
|
||||
}
|
||||
.dark .v5-mm-node.on{animation-name:v5-mm-selectPulseDark;}
|
||||
@keyframes v5-mm-selectPulseDark{
|
||||
0%{box-shadow:inset 0 0 0 0 rgba(162,155,254,0);}
|
||||
50%{box-shadow:inset 0 0 0 2px rgba(162,155,254,.35);}
|
||||
100%{box-shadow:inset 0 0 0 0 rgba(162,155,254,0);}
|
||||
0%{box-shadow:inset 0 0 0 0 rgba(var(--v5-primary-rgb),0);}
|
||||
50%{box-shadow:inset 0 0 0 2px rgba(var(--v5-primary-rgb),.35);}
|
||||
100%{box-shadow:inset 0 0 0 0 rgba(var(--v5-primary-rgb),0);}
|
||||
}
|
||||
|
||||
/* Drag & drop — box-shadow 인셋 방식 (connector ::before/::after 충돌 방지) */
|
||||
@@ -948,8 +1022,8 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
.v5-mm-sv-hero{padding-bottom:1.2rem;margin-bottom:.6rem;border-bottom:1px solid var(--v5-mm-border);}
|
||||
.v5-mm-sv-hero-top{display:flex;align-items:center;gap:.8rem;}
|
||||
.v5-mm-sv-hero-ico{width:42px;height:42px;border-radius:11px;display:flex;align-items:center;justify-content:center;
|
||||
background:var(--v5-mm-accent);color:white;flex-shrink:0;box-shadow:0 2px 8px -2px rgba(108,92,231,.35);}
|
||||
.dark .v5-mm-sv-hero-ico{color:var(--v5-mm-bg);box-shadow:0 2px 10px -2px rgba(162,155,254,.35);}
|
||||
background:var(--v5-mm-accent);color:white;flex-shrink:0;box-shadow:0 2px 8px -2px rgba(var(--v5-primary-rgb),.35);}
|
||||
.dark .v5-mm-sv-hero-ico{color:var(--v5-mm-bg);box-shadow:0 2px 10px -2px rgba(var(--v5-primary-rgb),.35);}
|
||||
.v5-mm-sv-hero-ico svg{width:20px;height:20px;}
|
||||
.v5-mm-sv-hero-info{flex:1;min-width:0;}
|
||||
.v5-mm-sv-hero-path{font-size:.7rem;color:var(--v5-mm-text-muted);margin-bottom:.2rem;}
|
||||
@@ -964,9 +1038,9 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
background:var(--v5-mm-sunk);color:var(--v5-mm-text-muted);border:1px solid var(--v5-mm-border);
|
||||
font-family:'JetBrains Mono',monospace;}
|
||||
.v5-mm-chip.on{color:var(--v5-mm-green);background:rgba(0,184,148,.08);border-color:rgba(0,184,148,.22);}
|
||||
.dark .v5-mm-chip.on{background:rgba(85,239,196,.08);border-color:rgba(85,239,196,.22);}
|
||||
.v5-mm-chip.scope{color:var(--v5-cyan);background:rgba(0,206,201,.08);border-color:rgba(0,206,201,.24);}
|
||||
.dark .v5-mm-chip.scope{background:rgba(85,239,196,.06);border-color:rgba(85,239,196,.22);}
|
||||
.dark .v5-mm-chip.on{background:rgba(var(--v5-cyan-rgb),.08);border-color:rgba(var(--v5-cyan-rgb),.22);}
|
||||
.v5-mm-chip.scope{color:var(--v5-cyan);background:rgba(var(--v5-cyan-rgb),.08);border-color:rgba(var(--v5-cyan-rgb),.24);}
|
||||
.dark .v5-mm-chip.scope{background:rgba(var(--v5-cyan-rgb),.06);border-color:rgba(var(--v5-cyan-rgb),.22);}
|
||||
|
||||
.v5-mm-sv-grid{display:grid;grid-template-columns:clamp(180px,18vw,260px) minmax(0,1fr);
|
||||
gap:clamp(1rem,2vw,2rem);padding:clamp(1rem,1.6vw,1.5rem) 0;
|
||||
@@ -985,8 +1059,8 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo
|
||||
.v5-mm-inp{width:100%;height:36px;padding:0 .85rem;border:1px solid var(--v5-mm-border);border-radius:8px;
|
||||
background:var(--v5-mm-panel);color:var(--v5-mm-text);font-size:.78rem;font-family:inherit;outline:none;transition:all .15s;}
|
||||
.dark .v5-mm-inp{background:var(--v5-mm-sunk);}
|
||||
.v5-mm-inp:focus{border-color:var(--v5-mm-accent);box-shadow:0 0 0 3px rgba(108,92,231,.14);}
|
||||
.dark .v5-mm-inp:focus{box-shadow:0 0 0 3px rgba(162,155,254,.2);}
|
||||
.v5-mm-inp:focus{border-color:var(--v5-mm-accent);box-shadow:0 0 0 3px rgba(var(--v5-primary-rgb),.14);}
|
||||
.dark .v5-mm-inp:focus{box-shadow:0 0 0 3px rgba(var(--v5-primary-rgb),.2);}
|
||||
.v5-mm-inp[readonly]{background:var(--v5-mm-sunk);color:var(--v5-mm-text-muted);}
|
||||
textarea.v5-mm-inp{height:auto;min-height:72px;padding:.55rem .85rem;resize:vertical;line-height:1.55;}
|
||||
select.v5-mm-inp{padding-right:2rem;appearance:none;-webkit-appearance:none;
|
||||
@@ -1025,13 +1099,13 @@ select.v5-mm-inp{padding-right:2rem;appearance:none;-webkit-appearance:none;
|
||||
.v5-mm-btn:hover{border-color:var(--v5-mm-text-muted);color:var(--v5-mm-text);}
|
||||
.v5-mm-btn svg{width:13px;height:13px;}
|
||||
.v5-mm-btn.primary{background:var(--v5-mm-accent);color:white;border-color:var(--v5-mm-accent);
|
||||
box-shadow:0 2px 6px -1px rgba(108,92,231,.35),inset 0 1px 0 rgba(255,255,255,.22);}
|
||||
box-shadow:0 2px 6px -1px rgba(var(--v5-primary-rgb),.35),inset 0 1px 0 rgba(255,255,255,.22);}
|
||||
.v5-mm-btn.primary:hover{background:#5c4ed4;border-color:#5c4ed4;transform:translateY(-1px);
|
||||
box-shadow:0 4px 12px -2px rgba(108,92,231,.4);}
|
||||
box-shadow:0 4px 12px -2px rgba(var(--v5-primary-rgb),.4);}
|
||||
.dark .v5-mm-btn.primary{color:var(--v5-mm-bg);}
|
||||
.dark .v5-mm-btn.primary:hover{background:#b8b2ff;border-color:#b8b2ff;color:var(--v5-mm-bg);}
|
||||
.v5-mm-btn.danger{color:var(--v5-mm-red);}
|
||||
.v5-mm-btn.danger:hover{border-color:var(--v5-mm-red);background:rgba(255,71,87,.05);}
|
||||
.v5-mm-btn.danger:hover{border-color:var(--v5-mm-red);background:rgba(var(--v5-red-rgb),.05);}
|
||||
.v5-mm-btn.sm{height:28px;padding:0 .7rem;font-size:.66rem;}
|
||||
|
||||
/* Overview stats */
|
||||
@@ -1051,9 +1125,9 @@ select.v5-mm-inp{padding-right:2rem;appearance:none;-webkit-appearance:none;
|
||||
.v5-mm-act-ico{width:24px;height:24px;border-radius:6px;display:flex;align-items:center;justify-content:center;
|
||||
background:var(--v5-mm-sunk);color:var(--v5-mm-text-sec);border:1px solid var(--v5-mm-border-2);flex-shrink:0;}
|
||||
.v5-mm-act-ico svg{width:12px;height:12px;}
|
||||
.v5-mm-act-ico.edit{color:var(--v5-mm-accent);background:rgba(108,92,231,.08);border-color:rgba(108,92,231,.18);}
|
||||
.v5-mm-act-ico.edit{color:var(--v5-mm-accent);background:rgba(var(--v5-primary-rgb),.08);border-color:rgba(var(--v5-primary-rgb),.18);}
|
||||
.v5-mm-act-ico.add{color:var(--v5-mm-green);background:rgba(0,184,148,.08);border-color:rgba(0,184,148,.2);}
|
||||
.v5-mm-act-ico.del{color:var(--v5-mm-red);background:rgba(255,71,87,.08);border-color:rgba(255,71,87,.2);}
|
||||
.v5-mm-act-ico.del{color:var(--v5-mm-red);background:rgba(var(--v5-red-rgb),.08);border-color:rgba(var(--v5-red-rgb),.2);}
|
||||
.v5-mm-act-body{flex:1;min-width:0;}
|
||||
.v5-mm-act-title{color:var(--v5-mm-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.v5-mm-act-title b{font-weight:600;}
|
||||
@@ -1097,3 +1171,125 @@ select.v5-mm-inp{padding-right:2rem;appearance:none;-webkit-appearance:none;
|
||||
.v5-mm-sv-ft{flex-direction:column;align-items:stretch;gap:.5rem;}
|
||||
.v5-mm-sv-ft > div{justify-content:space-between;}
|
||||
}
|
||||
|
||||
/* ===== SETTINGS MODAL ===== */
|
||||
.settings-section{display:flex;flex-direction:column;gap:.55rem;padding:.5rem 0;}
|
||||
.settings-section + .settings-section{border-top:1px solid var(--v5-border-subtle);padding-top:1rem;}
|
||||
.settings-label{font-size:.62rem;font-weight:700;color:var(--v5-text-muted);
|
||||
text-transform:uppercase;letter-spacing:.08em;}
|
||||
|
||||
/* 모드 토글 (라이트/다크) */
|
||||
.settings-mode-row{display:flex;gap:.45rem;}
|
||||
.settings-mode-btn{flex:1;display:flex;align-items:center;justify-content:center;gap:.4rem;
|
||||
padding:.55rem .8rem;border-radius:10px;border:1px solid var(--v5-glass-border);
|
||||
background:var(--v5-surface);backdrop-filter:blur(8px);color:var(--v5-text-sec);cursor:pointer;
|
||||
font-size:.72rem;font-weight:500;font-family:inherit;
|
||||
transition:all .2s cubic-bezier(.4,0,.2,1);}
|
||||
.settings-mode-btn:hover{color:var(--v5-text);border-color:var(--v5-primary);}
|
||||
.settings-mode-btn.on{background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.12),rgba(var(--v5-primary-rgb),.05));
|
||||
color:var(--v5-primary);border-color:rgba(var(--v5-primary-rgb),.35);
|
||||
box-shadow:var(--v5-glow-sm);font-weight:600;}
|
||||
|
||||
/* 색상 스와치 그리드 */
|
||||
.settings-color-grid{display:grid;grid-template-columns:repeat(6,1fr);gap:.5rem;}
|
||||
.settings-color-swatch{display:flex;flex-direction:column;align-items:center;gap:.35rem;
|
||||
padding:.55rem .35rem;border-radius:10px;border:1px solid transparent;
|
||||
background:transparent;cursor:pointer;font-family:inherit;
|
||||
transition:all .2s cubic-bezier(.4,0,.2,1);}
|
||||
.settings-color-swatch:hover{background:var(--v5-surface-hover);border-color:var(--v5-glass-border);}
|
||||
.settings-color-swatch.on{background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.1),rgba(var(--v5-primary-rgb),.04));
|
||||
border-color:rgba(var(--v5-primary-rgb),.35);box-shadow:var(--v5-glow-sm);}
|
||||
.swatch-circle{width:34px;height:34px;border-radius:50%;display:flex;align-items:center;justify-content:center;
|
||||
color:white;box-shadow:0 2px 8px rgba(0,0,0,.15),inset 0 1px 0 rgba(255,255,255,.25);
|
||||
transition:transform .2s cubic-bezier(.16,1,.3,1);}
|
||||
.settings-color-swatch:hover .swatch-circle{transform:scale(1.08);}
|
||||
.settings-color-swatch.on .swatch-circle{transform:scale(1.05);box-shadow:0 4px 14px rgba(0,0,0,.2),inset 0 1px 0 rgba(255,255,255,.3);}
|
||||
.swatch-label{font-size:.6rem;font-weight:500;color:var(--v5-text-sec);}
|
||||
.settings-color-swatch.on .swatch-label{color:var(--v5-primary);font-weight:600;}
|
||||
|
||||
@media(max-width:480px){
|
||||
.settings-color-grid{grid-template-columns:repeat(3,1fr);}
|
||||
}
|
||||
|
||||
/* ===== 컬러 테마 변경 — 절제된 클릭 피드백 =====
|
||||
화면으로 퍼지는 큰 ring/glow 는 과하므로 스와치 주변 ~80px 안에서만 짧게 끝남.
|
||||
메인 효과는 View Transitions cross-fade(전체 색 부드러운 보간) 가 담당. */
|
||||
.v5-color-burst{
|
||||
position:fixed;pointer-events:none;z-index:9999;
|
||||
width:0;height:0;border-radius:50%;
|
||||
transform:translate(-50%,-50%);
|
||||
--burst-color:rgb(var(--v5-primary-rgb));
|
||||
}
|
||||
.v5-color-burst::before,
|
||||
.v5-color-burst::after{
|
||||
content:'';position:absolute;left:50%;top:50%;border-radius:50%;
|
||||
transform:translate(-50%,-50%);
|
||||
}
|
||||
/* 작은 글로우 — 스와치 바로 주변만 살짝 빛남 */
|
||||
.v5-color-burst::before{
|
||||
width:80px;height:80px;
|
||||
background:radial-gradient(circle,var(--burst-color) 0%,transparent 70%);
|
||||
opacity:0;
|
||||
animation:v5-burst-glow .65s cubic-bezier(.16,1,.3,1) forwards;
|
||||
}
|
||||
/* inner pulse — 짧은 반짝임 */
|
||||
.v5-color-burst::after{
|
||||
width:16px;height:16px;
|
||||
background:var(--burst-color);
|
||||
box-shadow:0 0 12px var(--burst-color);
|
||||
opacity:.85;
|
||||
animation:v5-burst-pulse .45s cubic-bezier(.16,1,.3,1) forwards;
|
||||
}
|
||||
@keyframes v5-burst-glow{
|
||||
0% {transform:translate(-50%,-50%) scale(.3);opacity:.55;}
|
||||
100% {transform:translate(-50%,-50%) scale(1.4);opacity:0;}
|
||||
}
|
||||
@keyframes v5-burst-pulse{
|
||||
0% {transform:translate(-50%,-50%) scale(.4);opacity:.85;}
|
||||
100% {transform:translate(-50%,-50%) scale(1.8);opacity:0;}
|
||||
}
|
||||
|
||||
/* 선택된 스와치 — 클릭 직후 1회 부드러운 bounce (무한 펄스 X) */
|
||||
@keyframes v5-swatch-bounce{
|
||||
0% {transform:scale(1.05);}
|
||||
35% {transform:scale(1.22);}
|
||||
70% {transform:scale(1.0);}
|
||||
100% {transform:scale(1.05);}
|
||||
}
|
||||
.settings-color-swatch.on .swatch-circle{
|
||||
animation:v5-swatch-bounce .55s cubic-bezier(.34,1.4,.64,1) both;}
|
||||
|
||||
/* ===== 컬러 변경 시 — 색깔 들어간 요소들이 자기 자리에서 entrance 재생 =====
|
||||
글로벌 화면 효과 X. primary/cyan 색을 쓰는 요소들이 각자 짧게 fade+scale 로 다시 나타남.
|
||||
클릭 위치에서 가까운 요소부터 시간차(stagger) 로 일어나도록 자연스럽게 정렬되진 않지만
|
||||
동시 재생만으로도 "색깔 부분이 다시 그려진" 인상이 충분히 남.
|
||||
v5-color-burst 는 클릭한 스와치 자체의 클릭 피드백으로 유지. */
|
||||
|
||||
/* 살짝 scale 만 — opacity 변동 안 줘서 깜빡임 없음. 색은 즉시 swap, 요소만 살짝 "맥동" */
|
||||
@keyframes v5-color-refresh{
|
||||
0% {transform:scale(.96);}
|
||||
60% {transform:scale(1.03);}
|
||||
100% {transform:scale(1);}
|
||||
}
|
||||
/* transform 부적절한 텍스트/배지 — 미세 saturate 펄스로 색이 한번 진해졌다 정상 */
|
||||
@keyframes v5-color-refresh-sat{
|
||||
0% {filter:saturate(1);}
|
||||
40% {filter:saturate(1.6);}
|
||||
100% {filter:saturate(1);}
|
||||
}
|
||||
|
||||
html.vt-color-changing .v5-hdr-logo,
|
||||
html.vt-color-changing .v5-avatar,
|
||||
html.vt-color-changing .v5-admin-badge,
|
||||
html.vt-color-changing .v5-noti-dot.info,
|
||||
html.vt-color-changing .swatch-circle{
|
||||
animation:v5-color-refresh-sat .6s cubic-bezier(.4,0,.2,1) both;}
|
||||
|
||||
html.vt-color-changing .v5-pill button.on,
|
||||
html.vt-color-changing .v5-si.on,
|
||||
html.vt-color-changing .v5-tab.on,
|
||||
html.vt-color-changing .settings-mode-btn.on,
|
||||
html.vt-color-changing .settings-color-swatch.on,
|
||||
html.vt-color-changing .v5-bell,
|
||||
html.vt-color-changing .v5-admin-btn{
|
||||
animation:v5-color-refresh .55s cubic-bezier(.34,1.4,.64,1) both;}
|
||||
|
||||
@@ -707,6 +707,54 @@ export interface ViewTrigger {
|
||||
action: 'open-modal' | 'navigate';
|
||||
}
|
||||
|
||||
/**
|
||||
* 반응 정책 모드. 카드/대시보드 영역 축소 시 컴포넌트가 어떻게 반응할지 결정.
|
||||
* 자세한 정의: notes/gbpark/2026-04-19-template-responsive-policy.md
|
||||
*
|
||||
* - fixed : 크기 유지, 위치만 조정 (단일 버튼/입력/타이틀/구분선)
|
||||
* - scroll : 내용 크기 유지, 넘치면 가로 스크롤 (테이블/그리드/검색/컨테이너)
|
||||
* - reflow : 폭 부족 시 열 수 재배치 (stats 카드 그룹 5→3→2→1열)
|
||||
* - wrap : 요소 크기 유지, 행 단위 줄바꿈 (버튼 그룹/태그 그룹)
|
||||
*/
|
||||
export type ResponsiveMode = 'fixed' | 'scroll' | 'reflow' | 'wrap';
|
||||
|
||||
/** fixed 모드에서 축소 시 붙일 기준점. */
|
||||
export type ResponsiveAnchor =
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right'
|
||||
| 'center';
|
||||
|
||||
/**
|
||||
* 컴포넌트의 반응 정책.
|
||||
*
|
||||
* 명시하지 않으면 componentId 기반 기본값이 런타임에 주입된다.
|
||||
* (예: componentId === 'table' → { mode: 'scroll' })
|
||||
*
|
||||
* 인접한 동종 단일 컴포넌트들이 같은 row 에 있으면 렌더러가 가상 그룹으로 묶어
|
||||
* 그룹 정책을 적용한다 — 버튼 여러 개는 wrap, stats 여러 개는 reflow.
|
||||
*/
|
||||
export interface ResponsiveConfig {
|
||||
/** 반응 모드 (미지정 시 componentId 기반 기본값) */
|
||||
mode?: ResponsiveMode;
|
||||
/** 최소 폭 (px) — 이 이하로는 줄지 않음 */
|
||||
minWidth?: number;
|
||||
/** 최소 높이 (px) */
|
||||
minHeight?: number;
|
||||
/** 컨테이너 overflow 정책 */
|
||||
overflowX?: 'hidden' | 'auto' | 'scroll';
|
||||
overflowY?: 'hidden' | 'auto' | 'scroll';
|
||||
/** fixed 모드: 축소 시 붙일 기준점 (미지정 시 디자인 시점 위치로 자동 결정) */
|
||||
anchor?: ResponsiveAnchor;
|
||||
/** reflow 모드: grid auto-fit 의 minmax 최소값 (px) */
|
||||
minItemWidth?: number;
|
||||
/** reflow 모드: 최대 컬럼 수 */
|
||||
maxColumns?: number;
|
||||
/** reflow/wrap 모드: 아이템 간격 (px) */
|
||||
gap?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Template 안에 배치된 컴포넌트 하나.
|
||||
*
|
||||
@@ -759,6 +807,9 @@ export interface TemplateComponent {
|
||||
/** 이 컴포넌트가 다른 뷰를 여는지 여부 (예: 등록 버튼 → create 뷰) */
|
||||
viewTrigger?: ViewTrigger;
|
||||
|
||||
/** 반응 정책 (미지정 시 componentId 기반 기본값 주입) */
|
||||
responsive?: ResponsiveConfig;
|
||||
|
||||
/** 그룹핑용 — 여러 컴포넌트를 하나의 group 으로 묶을 때 부모 id */
|
||||
parentId?: string;
|
||||
|
||||
|
||||
@@ -793,6 +793,16 @@ export interface ScreenDefinition {
|
||||
fields?: FieldConfig[];
|
||||
/** 컴포넌트 간 DataPort 연결 목록 (런타임에 DataPortBus 브리지로 변환) */
|
||||
connections?: Connection[];
|
||||
|
||||
// ─── INVYONE 스튜디오 (Templates 모드) ───
|
||||
/** 템플릿 모드일 때 저장/로드 대상 TEMPLATE_ID. 세팅 시 screens 테이블이 아닌 templates 테이블로 입출력 */
|
||||
template_id?: string;
|
||||
/** 템플릿 모드 여부 — true 면 ScreenDesigner 가 templates API 로 동작 */
|
||||
is_template_mode?: boolean;
|
||||
/** 템플릿 카테고리 (예: sales/production/hr/inventory/finance/admin) */
|
||||
template_category?: string;
|
||||
/** 템플릿 게시 상태 (draft|published) */
|
||||
template_status?: "draft" | "published";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# 2026-04-19 대시보드 런타임 버그 수정 + 헤더 버튼 추가
|
||||
|
||||
## 배경
|
||||
사용자 보고: "대시보드 메뉴 클릭해도 아무것도 안 뜬다" / "이전 메뉴(DTG 이력관리, 물류 통합관제, 카테고리) 가 지웠는데도 사이드바에 남아있다" / "404 가 뜬다".
|
||||
|
||||
## 원인 진단 타임라인
|
||||
|
||||
### 1) 도커 백엔드가 엉뚱한 DB 를 바라보고 있었음
|
||||
`application.yml` 은 `183.99.177.40:5432/vexplor` 로 커밋됨(`4d39d70f1`).
|
||||
하지만 `docker/dev/docker-compose.invyone.yml` 의 env 에 **옛 주소 `211.115.91.141:11134/test_dev`** 가 박혀있었고, Spring 은 env 를 yml 보다 우선하므로 컨테이너가 18 시간 동안 옛 DB 바라봄.
|
||||
|
||||
"지워진 줄 알았던 DTG/물류/카테고리 메뉴" 는 **옛 DB 의 메뉴 데이터**가 그대로 뜨던 것. invyone 신 DB 에는 없는 데이터.
|
||||
|
||||
**조치**:
|
||||
- compose 에서 `SPRING_DATASOURCE_URL/USERNAME/PASSWORD` env 3줄 **제거** → yml 이 단일 소스
|
||||
- `docker compose -f docker/dev/docker-compose.invyone.yml up -d` 로 컨테이너 recreate (단순 restart 아님 — env 변경 반영 안 됨)
|
||||
|
||||
### 2) 대시보드 조회 쿼리가 옛 URL 포맷을 기대
|
||||
`insertDashboard` 는 `MENU_URL = "/3"` 식의 **숫자 URL** 로 저장.
|
||||
하지만 `getDashboardList` / `getDashboardListCnt` 의 WHERE 조건은 **`MENU_URL LIKE '/dashboard/%'`** (옛 포맷).
|
||||
→ 조회 결과 0 건 → `[menuSeq]/page.tsx` 가 매칭 실패 → `notFound()` → 404
|
||||
|
||||
**조치** (`backend-spring/src/main/resources/mapper/dashboard.xml`):
|
||||
- 조건 3군데 `LIKE '/dashboard/%'` → `~ '^/\d+$'` 로 교체
|
||||
- 주석도 같이 업데이트
|
||||
|
||||
### 3) Spring 이 소스 아닌 build 산출물에서 XML 로드
|
||||
위 조치 후 재시작해도 여전히 쿼리가 안 바뀜.
|
||||
이유: bootRun 은 XML 을 `/app/build/resources/main/mapper/dashboard.xml` 에서 읽음. `src/main/resources/` 는 classpath 에 없음.
|
||||
Syncthing 이 `src` 를 전파해도 `build/resources/main/` 은 옛날 그대로.
|
||||
|
||||
**조치**:
|
||||
```bash
|
||||
docker exec invyone-backend-spring sh -c 'cd /app && ./gradlew processResources --quiet'
|
||||
docker restart invyone-backend-spring
|
||||
```
|
||||
|
||||
### 4) getDashboardList SELECT 에 MENU_URL 컬럼 누락
|
||||
조건은 고쳤는데 여전히 404. 프론트 디버그 화면 출력: `'/3' 에 해당하는 대시보드를 목록(5개)에서 찾지 못했습니다.`
|
||||
즉 API 가 5 건 돌려주지만 **menu_url 필드가 응답에 없었음**. SELECT 절에서 빠짐.
|
||||
|
||||
**조치**: `getDashboardList`, `getDashboardInfo` SELECT 에 `MENU_URL` 추가 → processResources → restart.
|
||||
|
||||
### 5) AppLayout 이 `/숫자` 경로를 탭 시스템으로 보냄
|
||||
AppLayout body 가 `pathname.startsWith('/dashboard')` 등만 children 직접 렌더, 나머지는 `<TabContent />` 렌더.
|
||||
`/3` 은 이 조건에 안 걸려서 TabContent 가 떴고, 탭 `"screen" | "admin"` 타입만 지원하므로 "열린 탭이 없습니다" 화면.
|
||||
|
||||
**조치**: AppLayout 의 조건에 `/^\/\d+$/.test(pathname)` 추가.
|
||||
|
||||
### 6) 클라이언트 컴포넌트에서 notFound() 는 디버깅이 어려움
|
||||
`notFound()` 가 호출되면 단순히 전역 not-found 페이지가 뜨고, 왜 그런지 알 길이 없음. 네트워크 탭 없이는 API 실패인지 매칭 실패인지 구분 불가.
|
||||
|
||||
**조치**: `[menuSeq]/page.tsx` 에서 `notFound()` 호출 제거. 대신 **inline 에러 메시지 페이지**로 렌더하고 사유(응답 카운트, HTTP 상태 코드) 를 화면에 직접 표시. 디버그 편의 + 사용자도 상황 알 수 있음.
|
||||
|
||||
## 새 기능 — 헤더 버튼 3개 (Light/Dark 토글 왼쪽)
|
||||
|
||||
### 전역 상태 (`frontend/stores/dashboardStore.ts`)
|
||||
```ts
|
||||
createOpen, openCreate, closeCreate // 대시보드 생성 모달
|
||||
libOpen, openLib, closeLib // 템플릿 라이브러리 모달
|
||||
```
|
||||
|
||||
### 버튼 (`AppLayout.tsx`)
|
||||
- **대시보드** (`+`) — `openCreate()` → 전역 CreateDashboardModal
|
||||
- **템플릿 추가** (`+`) — `openLib()`. activeDashboardId 없으면 disabled
|
||||
- **편집** (edit3) — `toggleEditMode()`. activeDashboardId 없으면 disabled. on 상태 때 primary 배경
|
||||
|
||||
### 스타일 (`styles/v5-layout.css`)
|
||||
`.v5-dash-btn` 클래스 추가 — v5 톤(glass + primary on 상태).
|
||||
|
||||
### 템플릿 배치 위치
|
||||
`handleSelectTemplate` 에서 canvas 정중앙 좌표 계산 + 기존 카드 수 `% 8 * 28px` stagger.
|
||||
|
||||
### 빈 상태 UI
|
||||
- `layout/EmptyDashboard.tsx` (탭 0 개) — "이제 템플릿을 추가합니다" + 중앙 클릭/Enter → openLib + 버튼
|
||||
- `dash/DashboardEmpty.tsx` (대시보드에 카드 0 개) — 동일 톤
|
||||
|
||||
## ButtonComponent 런타임 방어
|
||||
템플릿 렌더 시 `Objects are not valid as a React child (found: object with keys {name, size, type})` 발생.
|
||||
`componentConfig.text`, `componentConfig.icon` 이 object 로 들어올 수 있음. `typeof === 'string'` 아니면 각각 `"버튼"` / `null` 로 폴백.
|
||||
|
||||
## DB 정리 (사용자 승인 후 hard DELETE)
|
||||
테스트 대시보드 5 개 (`테스트용`, `테스트`, `123213`, `새 대시보드ㅎㅇ`, `새 대시보드ㅈㅈ`) 완전 삭제.
|
||||
|
||||
트랜잭션 순서:
|
||||
1. `business_rules WHERE dashboard_id IN (...)` (0 건)
|
||||
2. `dashboard_elements WHERE dashboard_id IN (...)` (0 건)
|
||||
3. `dashboard_cards WHERE menu_objid IN (...)` (7 건) ← `dashboard_id` 아님, **`menu_objid`**
|
||||
4. `MENU_INFO WHERE OBJID IN (...) AND MENU_TYPE='1'` (5 건)
|
||||
|
||||
남은 대시보드: 0 개.
|
||||
|
||||
## 수정된 파일
|
||||
|
||||
### backend-spring
|
||||
- `src/main/resources/mapper/dashboard.xml` — 조회 조건 + SELECT 절
|
||||
|
||||
### frontend
|
||||
- `stores/dashboardStore.ts` — createOpen / libOpen 전역 state
|
||||
- `components/layout/AppLayout.tsx` — 헤더 3 버튼 + `/숫자` 라우팅 조건 + 전역 CreateDashboardModal
|
||||
- `components/layout/EmptyDashboard.tsx` — 메시지 + 클릭 핸들러
|
||||
- `components/dash/DashboardLayout.tsx` — 로컬 libOpen → store. 템플릿 배치 중앙
|
||||
- `components/dash/DashboardEmpty.tsx` — 메시지 통일
|
||||
- `app/(main)/[menuSeq]/page.tsx` — notFound → inline 에러
|
||||
- `lib/registry/components/button/ButtonComponent.tsx` — text/icon 방어
|
||||
- `styles/v5-layout.css` — `.v5-dash-btn` 추가
|
||||
|
||||
### docker
|
||||
- `docker/dev/docker-compose.invyone.yml` — DATASOURCE env 3 줄 제거
|
||||
|
||||
## 다음 번에 기억할 함정
|
||||
|
||||
1. **XML 수정 → processResources → restart** 순서 필수 (src 수정만으론 안 됨)
|
||||
2. **docker compose env 가 yml 을 덮어씀** — 이상 증상 시 러닝 컨테이너 env 확인
|
||||
3. **클라이언트 컴포넌트에서 notFound() 남발 금지** — inline 에러 렌더가 디버깅 훨씬 편함
|
||||
4. **dashboard_cards 는 `menu_objid` 컬럼** (dashboard_id 아님)
|
||||
@@ -0,0 +1,309 @@
|
||||
# 메뉴 = 대시보드 통합 설계 (MENU_INFO 단일 본체화)
|
||||
|
||||
> 2026-04-19 / gbpark
|
||||
> INVYONE 규격 "메뉴 = 대시보드"를 실제 DB 스키마/로직 레벨까지 일치시킴.
|
||||
> 이중 테이블(DASHBOARDS + MENU_INFO) + MENU_DESC 문자열 태그 sync 방식 → 완전 폐기.
|
||||
|
||||
---
|
||||
|
||||
## 1. 현재 상태 (제거 대상)
|
||||
|
||||
### 1-1. 이중 테이블
|
||||
|
||||
**DASHBOARDS** (신규, 본체 취급)
|
||||
```
|
||||
DASHBOARD_ID VARCHAR PK -- "dash_xxx"
|
||||
NAME VARCHAR
|
||||
ICON VARCHAR
|
||||
DISPLAY_ORDER INT
|
||||
COMPANY_CODE VARCHAR
|
||||
USER_ID VARCHAR -- 개인화용 (nullable=공용)
|
||||
IS_ACTIVE CHAR(1) -- 'Y'/'D'
|
||||
CREATED_BY, CREATED_DATE, UPDATED_BY, UPDATED_DATE
|
||||
```
|
||||
|
||||
**MENU_INFO** (기존 VEX, 사이드바 렌더용)
|
||||
```
|
||||
OBJID VARCHAR PK (System.currentTimeMillis() 문자열)
|
||||
PARENT_OBJ_ID BIGINT/VARCHAR
|
||||
MENU_NAME_KOR VARCHAR
|
||||
MENU_URL VARCHAR -- '/dashboard/{DASHBOARD_ID}' 패턴
|
||||
MENU_DESC VARCHAR -- 'dashboard:{DASHBOARD_ID}' 태그 악용 중
|
||||
MENU_TYPE VARCHAR -- '0'=admin / '1'=user
|
||||
SEQ, WRITER, CREATED_DATE, STATUS, COMPANY_CODE, LANG_KEY, MENU_ICON
|
||||
+ REL_MENU_AUTH (권한), MULTI_LANG_KEY_MASTER (다국어)
|
||||
```
|
||||
|
||||
**차이 필드**: `USER_ID` 단 하나만 DASHBOARDS 고유. 나머지는 전부 MENU_INFO에 대응 존재.
|
||||
|
||||
### 1-2. 문자열 태그 sync (백엔드)
|
||||
|
||||
`backend-spring/src/main/java/com/erp/service/DashboardService.java`
|
||||
- L20: `MENU_DESC_PREFIX = "dashboard:"`
|
||||
- L35~73: `insertDashboard` — DASHBOARDS INSERT → MENU_INFO INSERT(태그 부여)
|
||||
- L75~95: `updateDashboard` — DASHBOARDS UPDATE → `updateMenuByDesc`로 메뉴 sync
|
||||
- L97~114: `deleteDashboard` — DASHBOARDS soft delete → `deleteMenuByDesc`로 메뉴 sync
|
||||
|
||||
`backend-spring/src/main/resources/mapper/admin.xml`
|
||||
- L385~416: 대시보드 역참조 쿼리 3개
|
||||
- `selectMenuByDesc` / `updateMenuByDesc` / `deleteMenuByDesc`
|
||||
|
||||
### 1-3. 중복 엔드포인트
|
||||
|
||||
- `/api/dashboards` (DASHBOARDS 기반)
|
||||
- `/api/menus/user` (MENU_INFO 기반, 사이드바 실제 사용)
|
||||
- `/api/dashboards/sidebar/menu` (안 쓰이는데 만들어둠)
|
||||
|
||||
### 1-4. 프론트 dashboard_id 참조 (15파일)
|
||||
```
|
||||
app/(main)/dashboard/[dashboardId]/page.tsx
|
||||
app/(main)/dashboard/page.tsx
|
||||
app/(main)/admin/screenMng/dashboardList/[id]/page.tsx
|
||||
components/dash/DashboardLayout.tsx
|
||||
components/dash/DashboardSidebar.tsx
|
||||
components/dashboard/DashboardViewer.tsx
|
||||
components/control/ControlMode.tsx, ControlToolbar.tsx, FlowViewer.tsx
|
||||
components/admin/dashboard/MenuAssignmentModal.tsx
|
||||
components/admin/MenuFormModal.tsx
|
||||
components/layout/AdminPageRenderer.tsx
|
||||
lib/api/dashMenu.ts, businessRule.ts
|
||||
stores/dashboardStore.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 통합 설계 (최종 구조)
|
||||
|
||||
### 2-1. 스키마
|
||||
|
||||
**MENU_INFO** (단일 본체, USER_ID 1컬럼만 추가)
|
||||
```sql
|
||||
ALTER TABLE MENU_INFO ADD COLUMN USER_ID VARCHAR(64);
|
||||
```
|
||||
- **회사 공용 대시보드 (기본값)**: `USER_ID IS NULL` — COMPANY_CODE로 구분, 같은 회사 사용자 모두 공유
|
||||
- **개인 대시보드 (옵션)**: `USER_ID = <로그인 사용자 ID>` — 본인만 노출
|
||||
|
||||
> UI 측면: 대시보드 생성 시 "공유 범위" 선택이 **필수**. 기본 선택은 "회사 전체 공용".
|
||||
> - `insertDashboard({ name, icon, is_personal: boolean })`
|
||||
> - `is_personal=false` → USER_ID=NULL / `is_personal=true` → USER_ID=로그인 사용자
|
||||
|
||||
**DASHBOARD_CARDS** (FK를 OBJID로)
|
||||
```sql
|
||||
ALTER TABLE DASHBOARD_CARDS ADD COLUMN MENU_OBJID VARCHAR(50);
|
||||
-- 데이터 이관 후
|
||||
ALTER TABLE DASHBOARD_CARDS DROP COLUMN DASHBOARD_ID;
|
||||
ALTER TABLE DASHBOARD_CARDS ALTER COLUMN MENU_OBJID SET NOT NULL;
|
||||
```
|
||||
- 테이블명은 유지 (`DASHBOARD_CARDS`). MENU 아래 카드들이라는 의미상 자연스러움
|
||||
- FK만 `DASHBOARD_ID` → `MENU_OBJID`로 교체
|
||||
|
||||
**DASHBOARDS** — DROP (전체 폐기)
|
||||
|
||||
**MENU_INFO.MENU_DESC** — `'dashboard:...'` 태그 전부 NULL 처리 (본체 합침 = 태그 불필요)
|
||||
|
||||
### 2-2. 대시보드 정체성
|
||||
|
||||
- 대시보드 = MENU_INFO row 중 `MENU_TYPE='1'` + `MENU_URL='/dashboard/<OBJID>'`
|
||||
- OBJID가 곧 대시보드 식별자 (더 이상 별도 dashboard_id 없음)
|
||||
- 라우트: `/dashboard/12345...` (MENU_INFO.OBJID)
|
||||
- 대시보드 아닌 사용자 메뉴(외부링크 등)는 `MENU_URL='https://...'`로 공존
|
||||
|
||||
### 2-3. API 일원화
|
||||
|
||||
- `GET /api/dashboards` — 내부적으로 `SELECT FROM MENU_INFO WHERE MENU_TYPE='1' AND MENU_URL LIKE '/dashboard/%'`
|
||||
- `POST /api/dashboards` — MENU_INFO INSERT만 수행
|
||||
- `PUT /api/dashboards/{objid}` — MENU_INFO UPDATE
|
||||
- `DELETE /api/dashboards/{objid}` — MENU_INFO DELETE (또는 STATUS)
|
||||
- `GET /api/dashboards/{objid}/cards`, `POST`, `PUT`, `DELETE` — DASHBOARD_CARDS.MENU_OBJID 기준
|
||||
- `/api/dashboards/sidebar/menu` — **제거**. 사이드바는 `/api/menus/user` 하나만 사용
|
||||
|
||||
---
|
||||
|
||||
## 3. 마이그레이션 DDL/DML (승인 후 실행)
|
||||
|
||||
```sql
|
||||
-- ═══ 0. 백업 ═══
|
||||
CREATE TABLE DASHBOARDS_BAK_20260419 AS SELECT * FROM DASHBOARDS;
|
||||
CREATE TABLE DASHBOARD_CARDS_BAK_20260419 AS SELECT * FROM DASHBOARD_CARDS;
|
||||
CREATE TABLE MENU_INFO_BAK_20260419 AS
|
||||
SELECT * FROM MENU_INFO WHERE MENU_DESC LIKE 'dashboard:%';
|
||||
|
||||
-- ═══ 1. MENU_INFO에 USER_ID 컬럼 추가 ═══
|
||||
ALTER TABLE MENU_INFO ADD COLUMN USER_ID VARCHAR(64);
|
||||
|
||||
-- ═══ 2. DASHBOARDS 중 대응되는 MENU_INFO가 없는 것만 보충 INSERT ═══
|
||||
-- (sync 로직 덕에 대부분은 이미 매칭 row 존재)
|
||||
INSERT INTO MENU_INFO (
|
||||
OBJID, MENU_TYPE, PARENT_OBJ_ID, MENU_NAME_KOR, MENU_URL,
|
||||
MENU_DESC, SEQ, WRITER, CREATED_DATE, STATUS, COMPANY_CODE,
|
||||
MENU_ICON, USER_ID
|
||||
)
|
||||
SELECT
|
||||
(EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT + ROW_NUMBER() OVER (ORDER BY D.CREATED_DATE),
|
||||
'1', '0', D.NAME, '/dashboard/' || D.DASHBOARD_ID,
|
||||
NULL, D.DISPLAY_ORDER, D.CREATED_BY, D.CREATED_DATE,
|
||||
CASE WHEN D.IS_ACTIVE='Y' THEN 'active' ELSE 'inactive' END,
|
||||
D.COMPANY_CODE, D.ICON, D.USER_ID
|
||||
FROM DASHBOARDS D
|
||||
WHERE D.IS_ACTIVE='Y'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM MENU_INFO M WHERE M.MENU_DESC = 'dashboard:' || D.DASHBOARD_ID
|
||||
);
|
||||
|
||||
-- ═══ 3. 기존 MENU_INFO에 USER_ID 채우기 (DASHBOARDS에 저장돼있던 값) ═══
|
||||
UPDATE MENU_INFO M
|
||||
SET USER_ID = D.USER_ID
|
||||
FROM DASHBOARDS D
|
||||
WHERE M.MENU_DESC = 'dashboard:' || D.DASHBOARD_ID;
|
||||
|
||||
-- ═══ 4. DASHBOARD_CARDS에 MENU_OBJID 컬럼 추가 & 매핑 ═══
|
||||
ALTER TABLE DASHBOARD_CARDS ADD COLUMN MENU_OBJID BIGINT;
|
||||
|
||||
-- MENU_INFO.MENU_URL 패턴으로 매핑 (가장 확실)
|
||||
UPDATE DASHBOARD_CARDS C
|
||||
SET MENU_OBJID = M.OBJID
|
||||
FROM MENU_INFO M
|
||||
WHERE M.MENU_URL = '/dashboard/' || C.DASHBOARD_ID;
|
||||
|
||||
-- (fallback: 태그 매핑)
|
||||
UPDATE DASHBOARD_CARDS C
|
||||
SET MENU_OBJID = M.OBJID
|
||||
FROM MENU_INFO M
|
||||
WHERE C.MENU_OBJID IS NULL
|
||||
AND M.MENU_DESC = 'dashboard:' || C.DASHBOARD_ID;
|
||||
|
||||
-- 매핑 실패 row 검증
|
||||
SELECT COUNT(*) FROM DASHBOARD_CARDS WHERE MENU_OBJID IS NULL AND IS_ACTIVE='Y';
|
||||
-- 0이 아니면 중단하고 조사
|
||||
|
||||
-- ═══ 5. MENU_URL 재생성 (DASHBOARD_ID → OBJID) ═══
|
||||
UPDATE MENU_INFO
|
||||
SET MENU_URL = '/dashboard/' || OBJID::TEXT
|
||||
WHERE MENU_URL LIKE '/dashboard/dash_%';
|
||||
|
||||
-- ═══ 6. 태그 제거 ═══
|
||||
UPDATE MENU_INFO SET MENU_DESC = NULL WHERE MENU_DESC LIKE 'dashboard:%';
|
||||
|
||||
-- ═══ 7. DASHBOARD_CARDS 제약 ═══
|
||||
ALTER TABLE DASHBOARD_CARDS DROP COLUMN DASHBOARD_ID;
|
||||
ALTER TABLE DASHBOARD_CARDS ALTER COLUMN MENU_OBJID SET NOT NULL;
|
||||
|
||||
-- ═══ 8. DASHBOARDS DROP ═══
|
||||
DROP TABLE DASHBOARDS;
|
||||
```
|
||||
|
||||
**롤백 전략**: `_BAK_20260419` 테이블들에서 복원 가능. 폐기는 1주일 모니터링 후.
|
||||
|
||||
---
|
||||
|
||||
## 4. 백엔드 변경 목록
|
||||
|
||||
### 4-1. `DashboardService.java`
|
||||
- `MENU_DESC_PREFIX` 상수 제거
|
||||
- `insertDashboard` — MENU_INFO INSERT만, dashboard_id 생성 로직 제거, OBJID 생성 + 반환
|
||||
- `updateDashboard` — `admin.updateMenu`로 통일
|
||||
- `deleteDashboard` — `admin.deleteMenu` 사용
|
||||
- `getDashboardList`/`getDashboardInfo` — MENU_INFO 기반 새 쿼리
|
||||
- `getSidebarMenu` 메서드 제거
|
||||
|
||||
### 4-2. `DashboardController.java`
|
||||
- `@PathVariable String dashboardId` → `@PathVariable Long menuObjid` (또는 String 유지하되 내부 Long 변환)
|
||||
- `/sidebar/menu` 엔드포인트 제거
|
||||
|
||||
### 4-3. `admin.xml`
|
||||
- `selectMenuByDesc`, `updateMenuByDesc`, `deleteMenuByDesc` 3개 쿼리 삭제
|
||||
- `insertMenu`에 `USER_ID` 컬럼 추가
|
||||
|
||||
### 4-4. `dashboard.xml`
|
||||
- DASHBOARDS 관련 CRUD 전부 MENU_INFO 기반으로 재작성
|
||||
- `getDashboardList`: `FROM MENU_INFO WHERE MENU_TYPE='1' AND MENU_URL LIKE '/dashboard/%'`
|
||||
- `getDashboardInfo`: `WHERE OBJID = #{objid}`
|
||||
- `insertDashboard`, `updateDashboard`, `deleteDashboard` — MENU_INFO 상대 쿼리
|
||||
- `DASHBOARD_CARDS` 쿼리: `DASHBOARD_ID` → `MENU_OBJID` 치환
|
||||
- `getSidebarMenu` 쿼리 삭제
|
||||
|
||||
---
|
||||
|
||||
## 5. 프론트엔드 변경 목록
|
||||
|
||||
### 5-1. `lib/api/dashMenu.ts`
|
||||
- 파라미터 `dashboardId` → `menuObjid` (변수명 유지해도 값은 OBJID)
|
||||
- `getSidebarMenu` 함수 제거
|
||||
- 엔드포인트 URL 그대로 (`/api/dashboards/...`)
|
||||
|
||||
### 5-2. `app/(main)/dashboard/[dashboardId]/page.tsx`
|
||||
- 라우트 param 이름은 `[dashboardId]` 유지해도 되나, 실제 값은 OBJID
|
||||
- 또는 `[menuObjid]`로 rename (가독성 ↑, diff 커짐)
|
||||
- **선택**: rename 하지 않음 (URL만 안 바뀌면 리다이렉트 영향 ↓)
|
||||
|
||||
### 5-3. `app/(main)/dashboard/page.tsx` (진입점)
|
||||
- 첫 `menu_url`에서 OBJID 추출해서 라우트
|
||||
- 또는 기존 getDashboardList 응답의 `objid` 필드 사용
|
||||
|
||||
### 5-4. 기타 15개 참조 파일
|
||||
- `dashboard_id` → `menu_objid` 변수명 치환 (프론트 내부 네이밍)
|
||||
- DB 응답 키도 백엔드와 맞춰 `menu_objid`로 통일
|
||||
|
||||
### 5-5. `contexts/MenuContext.tsx`
|
||||
- 변경 없음. 이미 `/api/menus/user`만 호출 중이라 통합의 수혜자
|
||||
|
||||
### 5-6. 대시보드 생성 모달 (신규)
|
||||
- 현재 `prompt()`로 이름만 받는 방식을 모달로 교체
|
||||
- 필드: 이름 / 아이콘 / **공유 범위 (회사 공용 ● / 나만 보기 ○)**
|
||||
- 사용 위치: `dashboard/page.tsx`, `dash/DashboardLayout.tsx` — 공통 `CreateDashboardModal` 컴포넌트로
|
||||
- 사이드바 구분 표시: 개인 대시보드는 이름 옆 작은 `👤` 또는 v5 토큰의 서브톤 텍스트 (세부는 구현 시 결정)
|
||||
|
||||
---
|
||||
|
||||
## 6. 실행 순서 (의존성)
|
||||
|
||||
```
|
||||
[1] 설계 문서 확정 ← 지금 여기
|
||||
↓
|
||||
[2] DB 현황 확인 (psql SELECT, 사용자 승인)
|
||||
↓
|
||||
[3] DDL 스크립트 최종 검토 + 사용자 승인
|
||||
↓
|
||||
[4] 백업 + 마이그레이션 실행
|
||||
↓
|
||||
[5] 백엔드 수정 + 빌드 성공 확인
|
||||
↓
|
||||
[6] 프론트엔드 수정 + 빌드 성공 확인
|
||||
↓
|
||||
[7] 도커 재기동 + 사이드바에서 대시보드 렌더 확인
|
||||
↓
|
||||
[8] 1주일 모니터링 후 _BAK 테이블 DROP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 리스크 / 고려사항
|
||||
|
||||
1. **OBJID URL 노출**
|
||||
- BIGINT 값이 URL에 그대로 보임 (e.g. `/dashboard/1713456789012`). VEX 관례와 일치
|
||||
- 사용자 메뉴 라우트 전반에서 이미 같은 패턴 사용 중이라 이질감 없음
|
||||
|
||||
2. **기존 즐겨찾기 북마크**
|
||||
- 내부 사용자라 영향 미미 (재로그인 시 MenuContext가 새로 로드)
|
||||
- 걱정되면 MENU_URL 저장 시 기존 dash_ id를 SLUG 필드로 보관 가능 (선택)
|
||||
|
||||
3. **템플릿-카드 관계**
|
||||
- 변경 없음. `DASHBOARD_CARDS.TEMPLATE_ID` → `TEMPLATES.TEMPLATE_ID` 그대로
|
||||
|
||||
4. **관리자 메뉴**
|
||||
- 영향 없음. `MENU_TYPE='0'`으로 분리 유지
|
||||
|
||||
5. **권한 (REL_MENU_AUTH)**
|
||||
- 자동 계승. 대시보드 생성 시 권한 row 별도 insert 필요하면 MenuService 기존 로직 따라감
|
||||
|
||||
---
|
||||
|
||||
## 8. 승인 체크리스트 (사용자)
|
||||
|
||||
- [ ] 설계 방향 OK (MENU_INFO 단일 본체)
|
||||
- [ ] MENU_INFO에 `USER_ID` 1컬럼 추가 OK
|
||||
- [ ] DASHBOARDS 테이블 DROP OK (백업 테이블 _BAK_20260419는 1주일 유지)
|
||||
- [ ] DASHBOARD_CARDS.DASHBOARD_ID → MENU_OBJID 전환 OK
|
||||
- [ ] `/api/dashboards/sidebar/menu` 엔드포인트 제거 OK
|
||||
- [ ] URL 패턴 `/dashboard/<OBJID>` OK (기존 `/dashboard/dash_xxx`는 마이그레이션 시 일괄 교체)
|
||||
- [ ] 현재 DB 데이터 확인을 위한 psql SELECT 권한 허용 OK
|
||||
@@ -0,0 +1,54 @@
|
||||
# 템플릿 → 대시보드 연결 작업 진행 현황 (2026-04-19)
|
||||
|
||||
스튜디오(ScreenDesigner)에서 만든 템플릿을 대시보드(= 사용자 메뉴) 카드로 배치하는 플로우 구축.
|
||||
|
||||
## 설계 결정
|
||||
|
||||
- **옵션 A 채택**: 기존 `screens` 테이블은 VEX 레거시 그대로 두고, 신규 `templates` 테이블을 사용
|
||||
- **대시보드 = 사용자 메뉴**: 대시보드 생성 시 `MENU_INFO` 에 자동 등록 (MENU_DESC='dashboard:{id}' 태그로 구분)
|
||||
- **단일 대시보드 모드**: 한 대시보드에 카드 여러 장, 각 카드 = 템플릿 1개
|
||||
|
||||
## 수정/신규 파일
|
||||
|
||||
### 프론트엔드
|
||||
|
||||
| 파일 | 상태 | 역할 |
|
||||
|---|---|---|
|
||||
| `lib/utils/templateAdapter.ts` | 신규 | 스튜디오 layout ↔ `templates.jsonb` (fields/views/connections) 변환. `saveTemplate`, `loadTemplateAsLayout`, `createTemplate` |
|
||||
| `components/screen/ScreenDesigner.tsx` | 수정 (4군데 분기 추가) | `selectedScreen.template_id` 가 있으면 screenApi 대신 templateAdapter 경로로 저장/로드 |
|
||||
| `app/(main)/admin/builder/page.tsx` | 재작성 | screenApi → `getTemplateList` / `insertTemplate`. `templateToScreenDef` 변환 추가 |
|
||||
| `components/dash/TemplateRenderer.tsx` | 신규 | 대시보드 카드 안에서 템플릿을 렌더. V2 포맷(url + overrides) 파싱 → ComponentRegistry 조회 → 컴포넌트 렌더. absolute 좌표 + @container 기반 반응형 |
|
||||
| `lib/registry/components/stats/StatsComponent.tsx` | 수정 | `asStyleObject` — indexed-object(예: `{0:"c",1:"a"}`) 방어 |
|
||||
| `components/screen/RealtimePreviewDynamic.tsx` | 수정 | `sanitizeStyleKeys` — 숫자 키 style 잔재 제거 (StatsComponent 런타임 에러 근본 원인이 이 파일이었음) |
|
||||
| 기타 21개 컴포넌트 | 수정 | `filterDOMProps` 적용해 DOM prop warning 제거 |
|
||||
|
||||
### 백엔드
|
||||
|
||||
| 파일 | 상태 | 역할 |
|
||||
|---|---|---|
|
||||
| `backend-spring/.../service/DashboardService.java` | 수정 | `insertDashboard` / `updateDashboard` / `deleteDashboard` 호출 시 `MENU_INFO` 행 자동 동기화 |
|
||||
| `backend-spring/.../mapper/admin.xml` | 수정 | `selectMenuByDesc`, `updateMenuByDesc`, `deleteMenuByDesc` 쿼리 추가 |
|
||||
|
||||
## 해결한 이슈
|
||||
|
||||
- **Registry 미초기화**: `(unknown) 미등록 컴포넌트` → `import '@/lib/registry/components'` side-effect import 추가
|
||||
- **V2 포맷 인식 실패**: DB 가 `componentId` 대신 `url: "@/lib/registry/components/table"` + `overrides: {...}` 로 저장 → `extractIdFromUrl` + `overrides` fallback 추가
|
||||
- **StatsComponent "Cannot set property 0..."**: 런타임 에러. 추적 결과 `RealtimePreviewDynamic` 에서 `component.style` 을 스프레드하다 숫자 키가 섞여 들어옴. `sanitizeStyleKeys` 로 제거
|
||||
- **DOM warning 폭발**: 10+ 컴포넌트가 모르는 prop 받음. Python 스크립트로 21개에 일괄 `filterDOMProps` 적용
|
||||
- **임의 제약 추가 금지**: `limit: 100/1000`, `status='published'` 등을 지시 없이 추가했다가 강한 피드백. 모두 제거. 메모리(`feedback_no_arbitrary_limits.md`) 등록
|
||||
|
||||
## 현재 TemplateRenderer 반응형 방식
|
||||
|
||||
시도 순서: scale transform → flex-flow → 비례 변환 → flex-basis+spacer → **small/large 블록 구분 (현재)**
|
||||
|
||||
- **small 블록** (`width < canvas의 25%`, 예: 버튼): 원본 px 유지. center_x 가 canvas 오른쪽이면 `right: N%` 기준, 왼쪽이면 `left: N%` 기준 정렬
|
||||
- **large 블록** (`width >= 25%`, 예: stats/table): 비율 `width: N%` 로 variance
|
||||
- **narrow 모드** (`@container (max-width: 640px)`): 전체 세로 스택 + width 100%
|
||||
- 컴포넌트 간 수직 위치는 `top: (pos.top - rowTop)px` 로 보존
|
||||
|
||||
## 참고 메모리
|
||||
|
||||
- `feedback_no_arbitrary_limits.md` — 지시 없이 리미트/필터/디폴트 넣지 말 것
|
||||
- `feedback_responsive_container_based.md` — 반응형은 @container 기준
|
||||
- `feedback_admin_vs_user_design.md` — 관리자(복잡 OK) vs 사용자(단순/통일)
|
||||
- `feedback_menu_is_dashboard.md` — 사용자 메뉴 = 대시보드
|
||||
@@ -0,0 +1,353 @@
|
||||
# 템플릿 반응 정책 구현 기록 (A+B 단계)
|
||||
|
||||
- 일자: 2026-04-19
|
||||
- 브랜치: `gbpark-node`
|
||||
- 근거 문서: [`2026-04-19-template-responsive-policy.md`](./2026-04-19-template-responsive-policy.md)
|
||||
- 요약: 정책 문서(4모드·그룹 단위 반응)를 실제 코드로 이식. 타입/정책 엔진/런타임 주입/렌더러/시뮬레이터까지 일괄 구현.
|
||||
|
||||
---
|
||||
|
||||
## 1. 왜 했나
|
||||
|
||||
기존 `TemplateRenderer`는 이미 `@container` 기반 반응형 일부가 들어가 있었지만, 로직이 다음과 같은 **이분법** 수준이었다.
|
||||
|
||||
- FULL_WIDTH (테이블/컨테이너) → 단독 row, 100% 폭
|
||||
- 작은 블록(canvas 25% 미만) → 원본 px + 좌/우 앵커
|
||||
- 큰 블록 → 가로 비례 축소
|
||||
|
||||
이 방식은 정책 문서가 지적한 문제를 그대로 남겼다.
|
||||
|
||||
- 버튼 여러 개가 "작은 블록 여러 개"로 흩어져 배치됨 → 좁아지면 겹치거나 어색함
|
||||
- KPI 카드 5개는 "큰 블록"으로 일괄 축소되어 각 셀이 찌그러짐
|
||||
- 컴포넌트 성격과 무관하게 같은 방식으로 반응
|
||||
|
||||
정책 문서가 요구한 **"컴포넌트 성격별 차등 반응 + 그룹 단위 적용"** 이 빠진 상태였다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위 결정
|
||||
|
||||
사용자 협의 결과 다음 방향으로 확정.
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| 어디까지 | **A+B 통합** — 정책 엔진 + 컴포넌트 내부 보강을 한 묶음으로. A만 하면 "타입만 생기고 화면은 그대로" 상태가 되어 체감 효과 없음. |
|
||||
| 오버라이드 UI (C) | **나중**. 스키마에만 `responsiveMode` 추가, 디자이너 UI는 지금 열지 않음. |
|
||||
| 기존 템플릿 | **런타임 기본값 주입**. DB 마이그레이션 스크립트 불필요. |
|
||||
| 1/2·1/4 시뮬레이터 | **디자이너 툴바 연동**. 대시보드에서만 보면 피드백 루프가 너무 느림. |
|
||||
| 반응 단위 | **그룹 단위** (핵심 보강 포인트). 개별 컴포넌트가 아니라 "버튼 두 개 = 버튼 그룹 wrap", "KPI 5개 = 카드 그룹 reflow". |
|
||||
|
||||
---
|
||||
|
||||
## 3. 작업 분할 (Task)
|
||||
|
||||
| # | 작업 | 상태 |
|
||||
|---|---|---|
|
||||
| 1 | `ResponsiveConfig` 타입 확장 | ✅ |
|
||||
| 2 | 반응 정책 엔진 모듈 작성 | ✅ |
|
||||
| 3 | `TemplateRenderer` 그룹 탐지 + 정책 적용 | ✅ |
|
||||
| 4 | 컴포넌트 내부 반응 구현 보강 (Stats auto-fit 등) | ✅ |
|
||||
| 5 | 반응 미리보기 패널 + 디자이너 툴바 연결 | ✅ |
|
||||
| 6 | 통합 검증 (타입체크 비교) | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 4. 변경/추가 파일
|
||||
|
||||
### 신규
|
||||
- `frontend/lib/registry/responsive-policy.ts` — **정책 엔진** (4모드별 CSS 생성 + 그룹 탐지 + 기본값 매핑)
|
||||
- `frontend/components/dash/TemplateResponsivePreview.tsx` — Full/Half/Quarter 토글 미리보기 패널
|
||||
- `frontend/app/(main)/admin/template-preview/page.tsx` — 새 창에서 열리는 미리보기 라우트
|
||||
|
||||
### 수정
|
||||
- `frontend/types/invyone-component.ts` — `ResponsiveMode`, `ResponsiveAnchor`, `ResponsiveConfig` 타입 추가. `TemplateComponent.responsive?` 필드 추가
|
||||
- `frontend/components/dash/TemplateRenderer.tsx` — 그룹 기반 정책 적용. 기존 `isSmall/FULL_WIDTH` 이분법 제거, `detectResponsiveGroups` + `resolveResponsivePolicy` 기반 재작성
|
||||
- `frontend/lib/registry/components/stats/StatsComponent.tsx` — orientation=grid/horizontal 기본값을 `repeat(auto-fit, minmax(180px, 1fr))` 기반 자동 재배치로 전환
|
||||
- `frontend/components/screen/ScreenDesigner.tsx` — `handleResponsivePreview` 핸들러 추가. POP 모드가 아니면 미리보기 버튼이 이 핸들러 호출
|
||||
- `frontend/components/screen/toolbar/SlimToolbar.tsx` — 버튼 title을 "POP 미리보기" → "미리보기" 로 일반화
|
||||
|
||||
---
|
||||
|
||||
## 5. 핵심 개념
|
||||
|
||||
### 5.1 네 개의 반응 모드
|
||||
|
||||
| 모드 | CSS 개념 | 누가 쓰나 |
|
||||
|---|---|---|
|
||||
| `fixed` | 원본 px + anchor(`left`/`right`·`top`/`bottom`) | 단일 button/input/title/divider/pagination |
|
||||
| `scroll` | `width:100%` + `min-width:디자인폭` + `overflow-x:auto` | table, search, container, split-panel, tabs, accordion |
|
||||
| `reflow` | `display:grid; grid-template-columns: repeat(auto-fit, minmax(X, 1fr))` | stats 그룹, form |
|
||||
| `wrap` | `display:flex; flex-wrap:wrap` | button-bar, 가상 button-group(2+) |
|
||||
|
||||
### 5.2 그룹 탐지 (★핵심)
|
||||
|
||||
같은 row 안에서 **연속된 동일 `componentId`** 블록을 런타임에 가상 그룹으로 묶는다.
|
||||
|
||||
```
|
||||
row: [button, button, input, stats, stats, stats]
|
||||
↓ detectResponsiveGroups
|
||||
groups: [
|
||||
{ componentId: 'button', blocks: [b1, b2], virtual: true }, // → wrap
|
||||
{ componentId: 'input', blocks: [i1], virtual: false }, // → fixed
|
||||
{ componentId: 'stats', blocks: [s1,s2,s3], virtual: true }, // → reflow
|
||||
]
|
||||
```
|
||||
|
||||
FULL_WIDTH 타입(table 등)은 그룹 대상이 아니며 항상 단독 그룹으로 유지된다.
|
||||
|
||||
### 5.3 단독 vs 그룹 승격 (`GROUP_PROMOTION`)
|
||||
|
||||
단일 컴포넌트의 기본 모드가 여러 개 배치 시 그룹 모드로 승격된다.
|
||||
|
||||
```ts
|
||||
// responsive-policy.ts
|
||||
const DEFAULT_MODE_BY_ID = {
|
||||
button: 'fixed', input: 'fixed', title: 'fixed', ...
|
||||
stats: 'reflow', table: 'scroll', 'button-bar': 'wrap', ...
|
||||
};
|
||||
|
||||
const GROUP_PROMOTION = {
|
||||
button: 'wrap', // 버튼 두 개 이상 → wrap
|
||||
input: 'wrap',
|
||||
stats: 'reflow',
|
||||
title: 'wrap',
|
||||
};
|
||||
|
||||
getDefaultResponsiveMode(componentId, groupSize);
|
||||
// groupSize>1 이고 GROUP_PROMOTION 에 있으면 승격 모드
|
||||
```
|
||||
|
||||
### 5.4 앵커 자동 결정 (fixed 모드)
|
||||
|
||||
디자인 시점 좌표를 기준으로 캔버스를 9등분해 앵커를 결정한다.
|
||||
|
||||
```
|
||||
centerX < 40% → left계열
|
||||
centerX > 60% → right계열
|
||||
그 외 → center
|
||||
|
||||
centerY > 60% → bottom계열
|
||||
그 외 → top계열
|
||||
```
|
||||
|
||||
축소 시 버튼이 "원본 디자인 위치에서 가장 가까운 모서리에 붙어서" 이동하도록.
|
||||
|
||||
### 5.5 런타임 기본값 주입
|
||||
|
||||
DB 저장본에 `responsive` 필드가 없어도 렌더 시점에 `componentId + groupSize`로 모드를 결정하므로 기존 템플릿은 **무수정으로** 새 정책을 따른다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 렌더 파이프라인
|
||||
|
||||
```
|
||||
DB views JSON
|
||||
↓ normalizeBlocks — 좌표/componentId/config 표준화
|
||||
blocks[]
|
||||
↓ groupIntoRows — y 좌표 겹침으로 row 분할, FULL_WIDTH 는 단독 row
|
||||
rows[][]
|
||||
↓ (각 row 마다)
|
||||
detectResponsiveGroups — 연속 동일 타입 → 가상 그룹
|
||||
groups[]
|
||||
↓ (각 group 마다)
|
||||
resolveResponsivePolicy — { wrapperStyle, wrapperClassName, innerStyle, appliedMode }
|
||||
↓
|
||||
DOM:
|
||||
.dash-tpl-row-abs (position:relative)
|
||||
.dash-tpl-group (position:absolute, bbox 기반)
|
||||
.dash-tpl-item (wrap/reflow: 자식 자동 배치 / scroll: minWidth 확보)
|
||||
<실제 컴포넌트>
|
||||
```
|
||||
|
||||
narrow(카드 폭 ≤ 640px)일 때는 `@container` 쿼리로 row 자체를 세로 스택, 모든 `position:absolute`를 해제, reflow는 1열로 수렴.
|
||||
|
||||
---
|
||||
|
||||
## 7. 기본값 매핑 (최종)
|
||||
|
||||
| componentId | 단독 모드 | 그룹 승격 모드 | `minItemWidth` |
|
||||
|---|---|---|---|
|
||||
| `table` | scroll | — | — |
|
||||
| `search` | scroll | — | — |
|
||||
| `container` | scroll | — | — |
|
||||
| `split-panel` | scroll | — | — |
|
||||
| `tabs` | scroll | — | — |
|
||||
| `accordion` | scroll | — | — |
|
||||
| `stats` | reflow | reflow | 180 |
|
||||
| `form` | reflow | — | 240 |
|
||||
| `button-bar` | wrap | — | 96 |
|
||||
| `button` | fixed | **wrap** | 96 |
|
||||
| `input` | fixed | **wrap** | 160 |
|
||||
| `title` | fixed | **wrap** | 160 |
|
||||
| `divider` | fixed | — | — |
|
||||
| `pagination` | fixed | — | — |
|
||||
|
||||
---
|
||||
|
||||
## 8. 컴포넌트 내부 보강
|
||||
|
||||
### Stats (`StatsComponent.tsx`)
|
||||
|
||||
변경 전 — `orientation='grid'` 일 때 `repeat(${columns}, 1fr)` 고정 컬럼 수.
|
||||
변경 후 — columns는 **상한**으로만 사용, 실제 배치는 `auto-fit + minmax` 기반.
|
||||
|
||||
```css
|
||||
/* columns 지정 시: 최대 columns 개 + 카드 당 최소 180px */
|
||||
repeat(auto-fit, minmax(max(180px, calc((100% - gap) / columns)), 1fr))
|
||||
|
||||
/* columns 미지정 시 */
|
||||
repeat(auto-fit, minmax(180px, 1fr))
|
||||
```
|
||||
|
||||
결과: FHD 5열 → 중간 3열 → 작은 2열 → 매우 작은 1열 자동 수렴 (정책 문서 §4.3 요구).
|
||||
|
||||
### Title, Button, Input, Table
|
||||
|
||||
개별 수정 없음. 바깥 정책 wrapper가 `width:100% height:100%` 를 보장하고 내부 컴포넌트가 이미 그에 맞춰 동작하므로 건드릴 필요가 없었다. Table은 정책 엔진의 scroll 모드가 `min-width: max(pos.width, 480)`를 걸어 자동으로 가로 스크롤이 생긴다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 미리보기 흐름
|
||||
|
||||
```
|
||||
빌더에서 저장
|
||||
↓
|
||||
SlimToolbar 👁 버튼 클릭 (POP 모드가 아니면 handleResponsivePreview 호출)
|
||||
↓
|
||||
window.open('/admin/template-preview?id=<templateId>', _blank)
|
||||
↓
|
||||
PreviewPage
|
||||
→ getTemplateInfo(templateId)
|
||||
→ TemplateResponsivePreview { template }
|
||||
→ Full/Half/Quarter 토글 (1200/600/300px)
|
||||
→ TemplateRenderer { template, mock context }
|
||||
```
|
||||
|
||||
장점
|
||||
- ScreenDesigner(8286줄) 내부 구조를 거의 안 건드림 — 핸들러 하나와 title 한 글자만 수정
|
||||
- 새 라우트 분리로 상태 오염 없음
|
||||
- 폭이 바뀔 때 `@container inline-size` + 정책 엔진이 자동 반응하므로 즉시 시각 확인 가능
|
||||
|
||||
단점 (→ C 단계에서 개선)
|
||||
- 편집 중 상태가 아닌 "저장된 상태" 기준 — 편집 후 저장해야 미리보기에 반영됨
|
||||
|
||||
---
|
||||
|
||||
## 10. 검증
|
||||
|
||||
타입체크 결과.
|
||||
|
||||
| 상태 | tsc 에러 수 |
|
||||
|---|---|
|
||||
| 변경 전 (stash) | 3399 |
|
||||
| 변경 후 | 3392 |
|
||||
|
||||
→ 내 변경으로 새로 생긴 에러 없음. 기존 에러 7건이 줄었음(디자인 해상도 필드 호환성 보강 부산물).
|
||||
|
||||
변경 파일 개별 체크에서 관련 에러 0건.
|
||||
|
||||
---
|
||||
|
||||
## 11. 남은 것 (C 단계, 이번 범위 밖)
|
||||
|
||||
1. **디자이너 속성 패널** 에 `responsiveMode` 오버라이드 UI
|
||||
2. **편집 중 상태 실시간 미리보기** — 현재는 저장 후 새 창. 좌/우 분할 또는 split 패널로 실시간 반영
|
||||
3. **정교한 튜닝** — Table 커스텀 minWidth, Form columns 재계산, Title 줄바꿈 시 높이 증가 등
|
||||
4. **Stats 커스텀 프리셋** — KPI 타입별 기본 `minItemWidth` 조정 (아이콘 있는 큰 카드 vs 숫자만 있는 컴팩트 카드)
|
||||
|
||||
---
|
||||
|
||||
## 12. 참고 위치
|
||||
|
||||
- 정책 정의: [`2026-04-19-template-responsive-policy.md`](./2026-04-19-template-responsive-policy.md)
|
||||
- 타입: `frontend/types/invyone-component.ts` (섹션 6 카드 엔진 v2 내부)
|
||||
- 정책 엔진: `frontend/lib/registry/responsive-policy.ts`
|
||||
- 렌더러: `frontend/components/dash/TemplateRenderer.tsx`
|
||||
- 미리보기 패널: `frontend/components/dash/TemplateResponsivePreview.tsx`
|
||||
- 미리보기 라우트: `frontend/app/(main)/admin/template-preview/page.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 13. 2차 개정 — 같은 날 오후 (실사용 검증 + 회귀 수정)
|
||||
|
||||
초기 A+B 구현 직후 실제 템플릿(stats 5개 + 버튼 2개 + 테이블)으로 Quarter 상태 검증 중 여러 회귀 발견. 최종 도달한 모델과 그 과정의 오답들을 기록.
|
||||
|
||||
### 13.1 최종 모델 (현재 코드 상태)
|
||||
|
||||
**그룹 탐지 (`detectResponsiveGroups`)**
|
||||
- 같은 row + 같은 `componentId` + **gap ≤ max(직전블록폭 × 0.5, 32px)** 일 때만 그룹
|
||||
- 떨어져 있는 같은 id 는 각자 **단독 fixed** (중앙 버튼 + 우측 버튼이 서로 다른 그룹)
|
||||
|
||||
**row 레이아웃 — 항상 horizontal**
|
||||
- row 는 `display: flex; flex-direction: row; flex-wrap: nowrap; align-items: flex-start`
|
||||
- 카드 폭이 좁다는 이유만으로 **`flex-direction: column` 전환을 하지 않는다** (이전 버전에서 임의로 넣었던 규칙. 정책 문서에 근거 없음)
|
||||
- row 자체는 `flex: 0 0 auto` — 자기 크기 유지, 내부 reflow 로 커지면 자연 성장
|
||||
|
||||
**그룹 사이 spacer** (원본 x 간격 보존)
|
||||
```tsx
|
||||
<div className="dash-tpl-spacer" style={{ flex: '0 5 <gap%>' }} />
|
||||
```
|
||||
- `flex-shrink: 5` — 공간 부족 시 **spacer 가 먼저 축소**되어 그룹들 원본 x 성격 최대한 유지
|
||||
- 그룹간 간격이 1% 미만이면 생성 안 함 (DOM 최소)
|
||||
|
||||
**그룹 flex 정책 (shrink 우선순위 차등)**
|
||||
|
||||
| 종류 | flex | 의미 |
|
||||
|---|---|---|
|
||||
| spacer | `0 5 X%` | 가장 먼저 shrink |
|
||||
| scroll / reflow 그룹 | `1 0.5 X%` | 중간 정도 shrink, 비례 성장 |
|
||||
| wrap 그룹 | `0 0.3 <width>px` | 가장 덜 shrink, 자연폭 기준 |
|
||||
| fixed 단일 (버튼 제외) | `0 0 auto` | 완전 고정 |
|
||||
| fixed 단일 (button) | `0 1 auto` + minWidth 48 | **shrink 허용** (compact 단계와 연동) |
|
||||
|
||||
**button 전용 compact 단계** (자동 세로 스택 대신)
|
||||
```css
|
||||
@container tpl (max-width: 900px) { /* padding 10px, font 12px */ }
|
||||
@container tpl (max-width: 600px) { /* padding 6px, font 11px */ }
|
||||
@container tpl (max-width: 420px) { /* padding 4px, font 10px */ }
|
||||
```
|
||||
- 버튼은 `fixed → compact fixed → overflow/wrap(optional)` 순서로 반응
|
||||
- 박스 자체 shrink + 내부 패딩/폰트 단계 축소 = 버튼이 사라지지 않고 자연스럽게 줄어듦
|
||||
- `data-comp="button"` 속성으로 CSS 타겟팅
|
||||
|
||||
**세로 반응 (카드 높이에 테이블이 따라옴)**
|
||||
- `.dash-tpl-full` (FULL_WIDTH 단독 row) → **`flex: 1 1 auto; min-height: 0`** — wrapper 남은 세로 공간 흡수
|
||||
- `.dash-tpl-row` (일반 row) → `flex: 0 0 auto` (자기 크기 유지)
|
||||
- table 자체는 자체 `overflow: auto` 로 내부 스크롤 처리 (정책 엔진의 wrapper 는 `overflow: hidden` + 100% 폭)
|
||||
- wrapper 전체 상하 스크롤은 최후 fallback (거의 발생 안 함)
|
||||
|
||||
**Stats `columns` 기본값**
|
||||
- 이전: `Math.min(items.length, 4)` — items 5개면 4열 강제 → 5번째가 wrap
|
||||
- 현재: `undefined` — `repeat(auto-fit, minmax(180px, 1fr))` 가 폭에 맞춰 자동 결정. 5개면 폭만 되면 5열 한 줄
|
||||
|
||||
### 13.2 폐기된 오답들 (재발 방지)
|
||||
|
||||
| 오답 | 왜 틀렸나 | 현재 해법 |
|
||||
|---|---|---|
|
||||
| `@container (max-width: 640px) { flex-direction: column }` | 정책 문서에 없는 규칙. row 안의 버튼들을 강제 세로 스택해서 의도 파괴 | **쿼리 전체 폐기**. row 는 항상 horizontal |
|
||||
| narrow 에서 `.rp-fixed { width: 100% !important }` | 원본 100px 버튼이 카드 전체 폭으로 확장 | 없음. fixed 는 항상 원본 px 기준 |
|
||||
| gap threshold 없는 같은-id 자동 그룹화 | 중앙 버튼 + 우측 버튼을 하나의 wrap 그룹으로 묶어 나란히 붙여버림 | gap 기반 분리 (§13.1) |
|
||||
| `min-height: 0 !important` row override | inline `min-height: rowHeight` 를 무효화해서 row 가 0 으로 붕괴 | 제거. inline minHeight + `height: auto` 조합 |
|
||||
| table wrapper 의 `overflow-x: auto` + `min-width: 원본폭` | 테이블 wrapper 가 카드 밖으로 튀어나감 | wrapper `overflow: hidden` + 테이블 자체 내부 스크롤에 위임 |
|
||||
|
||||
### 13.3 디버그 덤프
|
||||
|
||||
`?debug=1` 또는 `localStorage.INVYONE_DEBUG=1` 일 때 콘솔에 상세 덤프.
|
||||
- canvas / wrapper clientWidth·Height / narrowExpected
|
||||
- blocks 테이블 (rawCid, unifiedId, x/y/w/h)
|
||||
- groupIntoRows 결과
|
||||
- detectResponsiveGroups + appliedMode
|
||||
- row/그룹 DOM computed (flex-direction, flex, width, margin-left)
|
||||
- ResizeObserver 로 wrapper 크기 변경 시 자동 재덤프
|
||||
|
||||
### 13.4 같은 세션에서 수정한 다른 버그
|
||||
|
||||
**대시보드 카드 삭제 무효 (`DashboardCanvas.handleRemove`)**
|
||||
- 원인: zustand `removeCard` 만 호출하고 서버 API `deleteDashboardCard` 미호출 → 새로고침 시 DB 에서 재로드되어 카드 부활
|
||||
- 수정: `activeDashboardId` 조회 후 `deleteDashboardCard(dashboardId, cardId)` 호출 추가 (낙관적 UI)
|
||||
|
||||
### 13.5 현재 남은 것
|
||||
|
||||
- **디자이너 편집 중 실시간 반영 미리보기** (현재는 저장 후 새 창)
|
||||
- **responsiveMode 오버라이드 UI** (C 단계)
|
||||
- 매우 좁은 카드에서 table 내부 self-scroll 외 추가 compact 단계 (pagination, header 등)
|
||||
@@ -0,0 +1,232 @@
|
||||
# INVYONE 템플릿 반응 정책 정의
|
||||
|
||||
## 1. 문제 정의
|
||||
|
||||
현재 템플릿은 FHD 기준으로 화면 디자이너에서 제작된다.
|
||||
|
||||
하지만 실제 사용 환경에서는 다음과 같이 더 작은 영역에도 표시된다.
|
||||
|
||||
- 전체 화면
|
||||
- 화면의 1/2 영역
|
||||
- 화면의 1/4 영역
|
||||
|
||||
이때 모든 컴포넌트를 동일하게 축소하면 다음 문제가 발생한다.
|
||||
|
||||
- 테이블 컬럼이 읽기 어려울 정도로 줄어든다
|
||||
- 버튼이 비정상적으로 찌그러지거나 붙는다
|
||||
- KPI 카드와 같은 요약 컴포넌트는 가로 폭 부족 시 구조가 무너진다
|
||||
- 결과적으로 템플릿이 원래 의도한 화면 구조를 유지하지 못한다
|
||||
|
||||
이 문서의 목적은 자유배치를 유지하면서도, 표시 영역 축소 시 컴포넌트 성격에 맞는 반응 규칙을 정의하는 것이다.
|
||||
|
||||
## 2. 핵심 원칙
|
||||
|
||||
### 2.1 자유배치는 유지한다
|
||||
|
||||
- 화면 디자이너에서의 자유배치 개념은 유지한다
|
||||
- 템플릿은 여전히 FHD 기준으로 제작한다
|
||||
- 사용자는 원하는 위치에 원하는 화면 구성을 설계할 수 있어야 한다
|
||||
|
||||
### 2.2 전체 일괄 축소는 하지 않는다
|
||||
|
||||
- 화면이 줄어든다고 해서 전체 레이아웃을 비율로 일괄 축소하지 않는다
|
||||
- 모든 컴포넌트가 동일한 방식으로 반응해서는 안 된다
|
||||
|
||||
### 2.3 반응 규칙은 컴포넌트별로 다르게 적용한다
|
||||
|
||||
- 어떤 컴포넌트는 크기를 유지해야 한다
|
||||
- 어떤 컴포넌트는 위치만 따라 이동해야 한다
|
||||
- 어떤 컴포넌트는 줄바꿈 또는 열 재배치가 필요하다
|
||||
|
||||
즉, 반응형의 기준은 `화면 전체`가 아니라 `컴포넌트 타입별 정책`이다.
|
||||
|
||||
## 3. 템플릿 기준 해상도
|
||||
|
||||
- 기본 제작 기준: `1920 x 1080 (FHD)`
|
||||
- 템플릿 저장 시 기준 해상도 정보를 함께 가진다
|
||||
- 실제 렌더링 시에는 현재 표시 영역 크기와 기준 해상도를 비교해 각 컴포넌트 정책을 적용한다
|
||||
|
||||
## 4. 컴포넌트 반응 정책
|
||||
|
||||
## 4.1 테이블 / 리스트 / 그리드
|
||||
|
||||
### 목표
|
||||
|
||||
데이터 가독성을 우선한다.
|
||||
|
||||
### 정책
|
||||
|
||||
- 컬럼 폭을 억지로 줄이지 않는다
|
||||
- 텍스트가 깨질 정도의 축소를 허용하지 않는다
|
||||
- 표시 영역보다 내용이 넓으면 `가로 스크롤`을 허용한다
|
||||
- 필요 시 세로 높이만 부모 영역에 맞춰 조정할 수 있다
|
||||
|
||||
### 금지
|
||||
|
||||
- 컬럼이 의미 없이 좁아지는 자동 압축
|
||||
- 테이블 전체를 축소 비율로 찌그러뜨리는 처리
|
||||
|
||||
## 4.2 버튼 / 액션 그룹
|
||||
|
||||
### 목표
|
||||
|
||||
조작 가능성과 클릭 영역을 유지한다.
|
||||
|
||||
### 정책
|
||||
|
||||
- 버튼의 기본 높이와 클릭 가능한 최소 폭을 유지한다
|
||||
- 화면 폭이 줄어들면 버튼 그룹은 함께 이동할 수 있다
|
||||
- 버튼끼리 겹치지 않는 선에서 정렬 위치는 바뀔 수 있다
|
||||
- 필요한 경우 동일 그룹 안에서 줄바꿈을 허용할 수 있다
|
||||
|
||||
### 금지
|
||||
|
||||
- 버튼 텍스트가 읽기 어려울 정도로 가로 폭을 강제로 압축하는 것
|
||||
- 버튼 높이와 클릭 영역을 비정상적으로 줄이는 것
|
||||
|
||||
## 4.3 KPI 카드 / 통계 카드 / 요약 카드
|
||||
|
||||
### 목표
|
||||
|
||||
카드 자체의 정보 밀도는 유지하되, 폭 부족 시 구조를 재배치한다.
|
||||
|
||||
### 정책
|
||||
|
||||
- 카드 크기를 무한정 축소하지 않는다
|
||||
- 최소 카드 폭을 가진다
|
||||
- 폭이 부족하면 `열 수를 줄이고 행 수를 늘린다`
|
||||
- 예시:
|
||||
- FHD: 5열
|
||||
- 중간 폭: 3열
|
||||
- 작은 폭: 2열
|
||||
- 매우 작은 폭: 1열
|
||||
|
||||
### 허용
|
||||
|
||||
- 카드 그룹 내부의 줄바꿈
|
||||
- 카드 간 gap 재계산
|
||||
|
||||
### 금지
|
||||
|
||||
- 카드 글자와 수치가 읽기 어렵도록 비율 축소하는 것
|
||||
|
||||
## 4.4 라벨 / 텍스트 / 정보 블록
|
||||
|
||||
### 목표
|
||||
|
||||
텍스트 가독성을 유지한다.
|
||||
|
||||
### 정책
|
||||
|
||||
- 폰트를 화면 축소에 맞춰 기계적으로 줄이지 않는다
|
||||
- 텍스트 영역은 줄바꿈 가능
|
||||
- 너무 좁으면 높이 증가를 허용한다
|
||||
|
||||
## 4.5 컨테이너 / 섹션 영역
|
||||
|
||||
### 목표
|
||||
|
||||
자식 컴포넌트의 정책을 수용하는 부모 역할을 한다.
|
||||
|
||||
### 정책
|
||||
|
||||
- 컨테이너는 자식 배치 규칙을 존중한다
|
||||
- 컨테이너는 내부 overflow 정책을 가진다
|
||||
- 필요 시 `hidden`, `auto`, `scroll` 중 하나를 선택할 수 있어야 한다
|
||||
|
||||
## 5. 자유배치와 반응 정책의 관계
|
||||
|
||||
자유배치는 유지하지만, 자유배치의 의미는 다음처럼 해석한다.
|
||||
|
||||
- FHD 기준에서는 디자이너에서 배치한 위치를 최대한 그대로 보여준다
|
||||
- 더 작은 표시 영역에서는 각 컴포넌트 정책에 따라 다르게 반응한다
|
||||
- 즉, 자유배치는 `기준 배치값`이며, 축소 시 무조건 픽셀 단위 동일성을 보장하는 개념은 아니다
|
||||
|
||||
단, 이때도 다음 원칙은 유지한다.
|
||||
|
||||
- 컴포넌트의 성격을 무시한 일괄 축소는 하지 않는다
|
||||
- 사용자 의도가 크게 훼손되지 않는 범위에서만 재배치한다
|
||||
|
||||
## 6. 반응 정책 레벨
|
||||
|
||||
각 컴포넌트는 아래 중 하나의 반응 정책을 가진다.
|
||||
|
||||
### 6.1 Fixed
|
||||
|
||||
- 크기 유지
|
||||
- 위치만 조정 가능
|
||||
- 대표 예시: 버튼, 입력 필드 일부, 툴바 액션
|
||||
|
||||
### 6.2 Scroll
|
||||
|
||||
- 내용 크기 유지
|
||||
- 부모 영역보다 크면 스크롤 허용
|
||||
- 대표 예시: 테이블, 그리드, 코드 뷰, 데이터 목록
|
||||
|
||||
### 6.3 Reflow
|
||||
|
||||
- 폭 부족 시 줄바꿈, 열 수 재배치 허용
|
||||
- 대표 예시: KPI 카드, 요약 카드, 뱃지 그룹
|
||||
|
||||
### 6.4 Wrap
|
||||
|
||||
- 요소 크기는 유지하되 행 단위 줄바꿈 허용
|
||||
- 대표 예시: 버튼 그룹, 필터 태그 그룹
|
||||
|
||||
## 7. 최소 필요 속성
|
||||
|
||||
각 컴포넌트 또는 컴포넌트 그룹은 최소한 아래 속성을 가져야 한다.
|
||||
|
||||
- `responsiveMode`
|
||||
- `fixed`
|
||||
- `scroll`
|
||||
- `reflow`
|
||||
- `wrap`
|
||||
- `minWidth`
|
||||
- `minHeight`
|
||||
- `overflowX`
|
||||
- `overflowY`
|
||||
- `anchor`
|
||||
- `top-left`
|
||||
- `top-right`
|
||||
- `bottom-left`
|
||||
- `bottom-right`
|
||||
- `center`
|
||||
|
||||
카드 그룹과 같은 재배치 대상은 추가로 아래 속성이 필요하다.
|
||||
|
||||
- `minItemWidth`
|
||||
- `maxColumns`
|
||||
- `breakpoints`
|
||||
- `gap`
|
||||
|
||||
## 8. 예시 해석
|
||||
|
||||
예: 수주관리 템플릿
|
||||
|
||||
- 상단 KPI 5개: `reflow`
|
||||
- 우측 버튼 2개: `fixed` 또는 `wrap`
|
||||
- 메인 테이블: `scroll`
|
||||
- 하단 상세영역: `fixed` 또는 부모 컨테이너 기준 확장
|
||||
|
||||
이 경우 화면이 줄어들면:
|
||||
|
||||
- KPI는 5열에서 3열 또는 2열로 재배치된다
|
||||
- 버튼은 크기 유지, 필요 시 다음 줄로 넘어간다
|
||||
- 테이블은 가로 스크롤이 생긴다
|
||||
- 전체 화면은 무조건 비율 축소되지 않는다
|
||||
|
||||
## 9. 제품 관점 결론
|
||||
|
||||
INVYONE 템플릿은 `완전 고정 캔버스`도 아니고, `전체 일괄 반응형`도 아니다.
|
||||
|
||||
정확한 방향은 다음이다.
|
||||
|
||||
- `FHD 기준 자유배치`
|
||||
- `표시 영역 축소 시 컴포넌트별 차등 반응`
|
||||
- `읽기성과 조작성을 해치지 않는 범위에서만 유연성 허용`
|
||||
|
||||
즉 최종 모델은 다음 한 줄로 정리된다.
|
||||
|
||||
> 자유배치는 유지하되, 축소 대응은 화면 전체가 아니라 컴포넌트 정책별로 처리한다.
|
||||
|
||||
Reference in New Issue
Block a user