From a5bbd1eb7c9c2a14ee6e38d3b5d9d9f95618c5bb Mon Sep 17 00:00:00 2001 From: gbpark Date: Mon, 11 May 2026 21:42:13 +0900 Subject: [PATCH 01/27] =?UTF-8?q?refactor(numbering-rule):=20NumberingRule?= =?UTF-8?q?=20=E2=86=92=20Input=20canonical=20=ED=9D=A1=EC=88=98=20+=20?= =?UTF-8?q?=EC=B1=84=EB=B2=88=20=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 옛 registry/numbering-rule, registry/v2-numbering-rule, V2NumberingRuleConfigPanel, NumberingRuleTemplate 폐기 — InvFieldConfigPanel + InputComponent 로 통합 - input 에 numbering-picker / select-pickers 추가, autonum 타입 흡수 - 채번 관리 전용 admin 페이지(systemMng/numberingRuleList) + CreateDialog + SequenceManagementPanel 신설 - backend NumberingRule controller/service/mapper 갱신 (시퀀스 관리 엔드포인트) - input canonical 진행 노트 + 채번 관리 mockup 추가 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controller/NumberingRuleController.java | 57 +- .../com/erp/service/NumberingRuleService.java | 58 +- .../main/resources/mapper/numberingRule.xml | 46 +- .../systemMng/numberingRuleList/page.tsx | 275 +++++ frontend/components/dash/BlockRenderer.tsx | 4 +- .../components/layout/AdminPageRenderer.tsx | 1 + .../NumberingRuleCreateDialog.tsx | 56 + .../numbering-rule/NumberingRuleDesigner.tsx | 45 +- .../SequenceManagementPanel.tsx | 232 ++++ .../screen/panels/ComponentsPanel.tsx | 4 +- .../screen/templates/NumberingRuleTemplate.ts | 81 -- .../v2/config-panels/InvFieldConfigPanel.tsx | 192 ++-- .../V2NumberingRuleConfigPanel.tsx | 206 ---- .../config-panels/_shared/cp/CPPrimitives.tsx | 47 +- frontend/lib/api/numberingRule.ts | 18 + .../lib/registry/DynamicComponentRenderer.tsx | 40 +- frontend/lib/registry/components/index.ts | 4 +- .../components/input/InputComponent.tsx | 310 ++++- .../lib/registry/components/input/index.ts | 2 +- .../components/input/numbering-picker.tsx | 414 +++++++ .../lib/registry/components/input/pickers.tsx | 26 +- .../components/input/select-pickers.tsx | 610 ++++++++++ .../numbering-rule/NumberingRuleComponent.tsx | 42 - .../NumberingRuleConfigPanel.tsx | 105 -- .../numbering-rule/NumberingRuleRenderer.tsx | 33 - .../components/numbering-rule/README.md | 102 -- .../components/numbering-rule/config.ts | 15 - .../components/numbering-rule/index.ts | 42 - .../components/numbering-rule/types.ts | 15 - .../NumberingRuleComponent.tsx | 42 - .../NumberingRuleConfigPanel.tsx | 105 -- .../NumberingRuleRenderer.tsx | 33 - .../components/v2-numbering-rule/README.md | 102 -- .../components/v2-numbering-rule/config.ts | 15 - .../components/v2-numbering-rule/index.ts | 42 - .../components/v2-numbering-rule/types.ts | 15 - .../lib/utils/getComponentConfigPanel.tsx | 3 +- frontend/lib/utils/webTypeMapping.ts | 86 +- .../2026-05-08-input-canonical-migration.md | 454 ++++++++ .../2026-05-11-numbering-rule-mockup.html | 1003 +++++++++++++++++ 40 files changed, 3735 insertions(+), 1247 deletions(-) create mode 100644 frontend/app/(main)/admin/systemMng/numberingRuleList/page.tsx create mode 100644 frontend/components/numbering-rule/NumberingRuleCreateDialog.tsx create mode 100644 frontend/components/numbering-rule/SequenceManagementPanel.tsx delete mode 100644 frontend/components/screen/templates/NumberingRuleTemplate.ts delete mode 100644 frontend/components/v2/config-panels/V2NumberingRuleConfigPanel.tsx create mode 100644 frontend/lib/registry/components/input/numbering-picker.tsx create mode 100644 frontend/lib/registry/components/input/select-pickers.tsx delete mode 100644 frontend/lib/registry/components/numbering-rule/NumberingRuleComponent.tsx delete mode 100644 frontend/lib/registry/components/numbering-rule/NumberingRuleConfigPanel.tsx delete mode 100644 frontend/lib/registry/components/numbering-rule/NumberingRuleRenderer.tsx delete mode 100644 frontend/lib/registry/components/numbering-rule/README.md delete mode 100644 frontend/lib/registry/components/numbering-rule/config.ts delete mode 100644 frontend/lib/registry/components/numbering-rule/index.ts delete mode 100644 frontend/lib/registry/components/numbering-rule/types.ts delete mode 100644 frontend/lib/registry/components/v2-numbering-rule/NumberingRuleComponent.tsx delete mode 100644 frontend/lib/registry/components/v2-numbering-rule/NumberingRuleConfigPanel.tsx delete mode 100644 frontend/lib/registry/components/v2-numbering-rule/NumberingRuleRenderer.tsx delete mode 100644 frontend/lib/registry/components/v2-numbering-rule/README.md delete mode 100644 frontend/lib/registry/components/v2-numbering-rule/config.ts delete mode 100644 frontend/lib/registry/components/v2-numbering-rule/index.ts delete mode 100644 frontend/lib/registry/components/v2-numbering-rule/types.ts create mode 100644 notes/gbpark/2026-05-08-input-canonical-migration.md create mode 100644 notes/gbpark/2026-05-11-numbering-rule-mockup.html diff --git a/backend-spring/src/main/java/com/erp/controller/NumberingRuleController.java b/backend-spring/src/main/java/com/erp/controller/NumberingRuleController.java index 7822b18d..10c35f30 100644 --- a/backend-spring/src/main/java/com/erp/controller/NumberingRuleController.java +++ b/backend-spring/src/main/java/com/erp/controller/NumberingRuleController.java @@ -11,7 +11,7 @@ import java.util.List; import java.util.Map; @RestController -@RequestMapping("/api/numbering-rule") +@RequestMapping("/api/numbering-rules") @RequiredArgsConstructor @Slf4j public class NumberingRuleController { @@ -136,7 +136,7 @@ public class NumberingRuleController { Map formData = body != null ? (Map) body.get("form_data") : null; String manualInputValue = body != null ? (String) body.get("manual_input_value") : null; String code = numberingRuleService.previewCode(ruleId, companyCode, formData, manualInputValue); - return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "미리보기 생성이 완료되었습니다.")); + return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "미리보기 생성이 완료되었습니다.")); } // ================================================================ @@ -202,7 +202,7 @@ public class NumberingRuleController { Map formData = body != null ? (Map) body.get("form_data") : null; String manualInputValue = body != null ? (String) body.get("manual_input_value") : null; String code = numberingRuleService.previewCode(ruleId, companyCode, formData, manualInputValue); - return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "미리보기 생성이 완료되었습니다.")); + return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "미리보기 생성이 완료되었습니다.")); } /** POST /{ruleId}/allocate → 코드 할당 (순번 증가) */ @@ -215,7 +215,7 @@ public class NumberingRuleController { Map formData = body != null ? (Map) body.get("form_data") : null; String userInputCode = body != null ? (String) body.get("user_input_code") : null; String code = numberingRuleService.allocateCode(ruleId, companyCode, formData, userInputCode); - return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "코드 할당이 완료되었습니다.")); + return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "코드 할당이 완료되었습니다.")); } /** POST /{ruleId}/generate (deprecated) → allocateCode 위임 */ @@ -224,18 +224,63 @@ public class NumberingRuleController { @RequestAttribute("company_code") String companyCode, @PathVariable String ruleId) { String code = numberingRuleService.generateCode(ruleId, companyCode); - return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "코드 생성이 완료되었습니다.")); + return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "코드 생성이 완료되었습니다.")); } - /** POST /{ruleId}/reset → 순번 초기화 */ + /** admin 권한 (SUPER_ADMIN / ADMIN / COMPANY_ADMIN) 만 시퀀스 직접 조작 가능 */ + private boolean isAdminRole(String role) { + return "SUPER_ADMIN".equals(role) + || "ADMIN".equals(role) + || "COMPANY_ADMIN".equals(role); + } + + /** POST /{ruleId}/reset → 순번 초기화 (admin 전용) */ @PostMapping("/{ruleId}/reset") public ResponseEntity> resetSequence( @RequestAttribute("company_code") String companyCode, + @RequestAttribute("role") String role, @PathVariable String ruleId) { + if (!isAdminRole(role)) { + return ResponseEntity.status(403) + .body(ApiResponse.error("관리자 권한이 필요합니다.")); + } numberingRuleService.resetSequence(ruleId, companyCode); return ResponseEntity.ok(ApiResponse.success(null, "시퀀스가 초기화되었습니다.")); } + /** PUT /{ruleId}/sequence → 현재 시퀀스 임의 값으로 수정 (admin 전용) */ + @PutMapping("/{ruleId}/sequence") + public ResponseEntity> updateRuleSequence( + @RequestAttribute("company_code") String companyCode, + @RequestAttribute("role") String role, + @PathVariable String ruleId, + @RequestBody Map body) { + if (!isAdminRole(role)) { + return ResponseEntity.status(403) + .body(ApiResponse.error("관리자 권한이 필요합니다.")); + } + Object seqObj = body.get("sequence"); + if (seqObj == null) { + return ResponseEntity.badRequest() + .body(ApiResponse.error("sequence 값이 필요합니다.")); + } + Integer newSequence; + try { + newSequence = (seqObj instanceof Number) + ? ((Number) seqObj).intValue() + : Integer.parseInt(seqObj.toString()); + } catch (NumberFormatException e) { + return ResponseEntity.badRequest() + .body(ApiResponse.error("sequence 는 정수여야 합니다.")); + } + if (newSequence < 0) { + return ResponseEntity.badRequest() + .body(ApiResponse.error("sequence 는 0 이상이어야 합니다.")); + } + numberingRuleService.updateRuleSequence(ruleId, newSequence, companyCode); + return ResponseEntity.ok(ApiResponse.success(null, "시퀀스가 수정되었습니다.")); + } + // ================================================================ // ■ Admin // ================================================================ diff --git a/backend-spring/src/main/java/com/erp/service/NumberingRuleService.java b/backend-spring/src/main/java/com/erp/service/NumberingRuleService.java index ac895d28..5f3c7187 100644 --- a/backend-spring/src/main/java/com/erp/service/NumberingRuleService.java +++ b/backend-spring/src/main/java/com/erp/service/NumberingRuleService.java @@ -189,7 +189,14 @@ public class NumberingRuleService extends BaseService { return allocateCode(ruleId, companyCode, null, null); } - /** POST /:ruleId/reset → 순번 초기화 */ + /** + * POST /:ruleId/reset → 순번 초기화 (admin) + * + * 두 테이블 다 처리: + * 1. numbering_rule_sequences (prefix 별 발번 카운터, 실제 ground truth) 전체 DELETE → 다음 발번 1 부터 + * 2. numbering_rules.current_sequence (표시용) 직접 0 으로 set + * - admin 전용 SQL `setCurrentSequenceInRule` 사용 (GREATEST 없음) + */ @Transactional public void resetSequence(String ruleId, String companyCode) { Map params = new HashMap<>(); @@ -197,10 +204,32 @@ public class NumberingRuleService extends BaseService { params.put("company_code", companyCode); params.put("current_sequence", 0); sqlSession.delete(NS + "deleteSequencesByRuleId", params); - sqlSession.update(NS + "updateCurrentSequenceInRule", params); + sqlSession.update(NS + "setCurrentSequenceInRule", params); log.info("시퀀스 초기화 완료: ruleId={}, companyCode={}", ruleId, companyCode); } + /** + * PUT /:ruleId/sequence → 현재 시퀀스 임의 값으로 수정 (admin) + * + * admin 이 "지금 카운터를 N 으로 set" 의도. 다음 발번은 N+1 부터. + * 두 테이블 다 처리: + * 1. numbering_rule_sequences (prefix 별 실제 카운터) 전체 DELETE + * → 다음 allocate 시 새 row 가 INSERT (current_sequence=1) 되거나 + * 또는 admin set 값을 기반으로 시작하도록 별도 처리 필요할 수 있음 + * - 운영 전 단계라 historical sequence 폐기 안전 + * 2. numbering_rules.current_sequence 를 newSequence 로 set + */ + @Transactional + public void updateRuleSequence(String ruleId, Integer newSequence, String companyCode) { + Map params = new HashMap<>(); + params.put("rule_id", ruleId); + params.put("company_code", companyCode); + params.put("current_sequence", newSequence); + sqlSession.delete(NS + "deleteSequencesByRuleId", params); + sqlSession.update(NS + "setCurrentSequenceInRule", params); + log.info("시퀀스 수정 완료: ruleId={}, newSequence={}, companyCode={}", ruleId, newSequence, companyCode); + } + // ================================================================ // ■ Available Rules // ================================================================ @@ -426,12 +455,31 @@ public class NumberingRuleService extends BaseService { return seq == null ? 0L : ((Number) seq).longValue(); } - /** 순번 증가 UPSERT – ON CONFLICT DO UPDATE RETURNING */ + /** + * 순번 증가 UPSERT – ON CONFLICT DO UPDATE RETURNING. + * + * INSERT 분기의 base 값: + * - 동일 prefix 의 row 가 없을 때 (첫 발번 / admin reset 후 / 새 카테고리 등) + * `numbering_rules.current_sequence + 1` 부터 시작. + * - 의미: admin 이 sequence 를 N 으로 set 하고 historical sequences 를 비웠을 때, + * 다음 발번이 N+1 부터 정확히 시작되도록. + * - numbering_rules row 가 없는 비정상 케이스는 0+1=1. + */ private long incrementSequenceForPrefix(String ruleId, String companyCode, String prefixKey) { String sql = """ INSERT INTO numbering_rule_sequences (rule_id, company_code, prefix_key, current_sequence, last_allocated_at) - VALUES (?, ?, ?, 1, NOW()) + VALUES ( + ?, ?, ?, + COALESCE(( + SELECT current_sequence + FROM numbering_rules + WHERE rule_id = ? + AND (company_code = ? OR company_code = '*') + LIMIT 1 + ), 0) + 1, + NOW() + ) ON CONFLICT (rule_id, company_code, prefix_key) DO UPDATE SET current_sequence = numbering_rule_sequences.current_sequence + 1, @@ -439,7 +487,7 @@ public class NumberingRuleService extends BaseService { RETURNING current_sequence """; Long newSeq = jdbcTemplate.queryForObject(sql, Long.class, - ruleId, companyCode, prefixKey); + ruleId, companyCode, prefixKey, ruleId, companyCode); return newSeq != null ? newSeq : 1L; } diff --git a/backend-spring/src/main/resources/mapper/numberingRule.xml b/backend-spring/src/main/resources/mapper/numberingRule.xml index 8d961b5c..77be31b9 100644 --- a/backend-spring/src/main/resources/mapper/numberingRule.xml +++ b/backend-spring/src/main/resources/mapper/numberingRule.xml @@ -16,8 +16,8 @@ category_column AS category_column, category_value_id AS category_value_id, created_by AS created_by, - CREATED_DATE AS CREATED_DATE, - UPDATED_DATE AS UPDATED_DATE + created_at AS created_at, + updated_at AS updated_at @@ -42,7 +42,7 @@ AND (company_code = #{company_code} OR company_code = '*') - ORDER BY CREATED_DATE DESC + ORDER BY created_at DESC @@ -280,7 +290,7 @@ WHERE (company_code = #{company_code} OR company_code = '*') - ORDER BY CREATED_DATE + ORDER BY created_at setSearch(e.target.value)} + placeholder="규칙명 / 테이블 / 컬럼 검색" + className="h-8 pl-7 text-xs" + /> + + +
+ {loading && rules.length === 0 ? ( +
+ 로딩 중... +
+ ) : groupedRules.length === 0 ? ( +
+ + {search ? "검색 결과가 없습니다" : "등록된 채번 규칙이 없습니다"} +
+ ) : ( + groupedRules.map(([tableName, group]) => ( +
+
+ + + {tableName} + + ({group.length}) +
+ {group.map((rule) => { + const id = String(rule.rule_id); + const isSelected = selectedRuleId === id; + return ( +
+ + +
+ ); + })} +
+ )) + )} +
+ + + {/* 우측: 선택된 규칙 상세 */} +
+ {!selectedRule ? ( +
+ +

규칙을 선택하세요

+

+ 좌측에서 규칙을 선택하면 디자인 + 시퀀스 관리 화면이 표시됩니다 +

+
+ ) : ( + <> + {/* ① 규칙 디자이너 (상단 2/3) — initialConfig 로 정확한 rule 주입 (by-column 우회) */} +
+ setRefreshKey((k) => k + 1)} + /> +
+ {/* ② 시퀀스 관리 (하단 1/3) */} +
+ setRefreshKey((k) => k + 1)} + /> +
+ + )} +
+ + + {/* 신규 규칙 생성 모달 — 컬럼 자유 선택 (lockedColumn 없음) */} + { + if (rule.rule_id) setSelectedRuleId(String(rule.rule_id)); + setRefreshKey((k) => k + 1); + }} + /> + + ); +} diff --git a/frontend/components/dash/BlockRenderer.tsx b/frontend/components/dash/BlockRenderer.tsx index 6747ff16..00ab8620 100644 --- a/frontend/components/dash/BlockRenderer.tsx +++ b/frontend/components/dash/BlockRenderer.tsx @@ -51,8 +51,10 @@ export function BlockRenderer({ resolvedColumnName != null ? context.formRow?.[resolvedColumnName] : undefined; + // 사용자가 ConfigPanel 에 설정한 defaultValue 를 form data 값이 없을 때 보존. + // formRow[col] 가 의미있는 값일 때만 defaultValue 로 hijack — undefined/null 은 무시. const runtimeConfig = - resolvedColumnName != null + resolvedColumnName != null && resolvedValue !== undefined && resolvedValue !== null ? { ...block.config, defaultValue: resolvedValue } : block.config; const handleFormValueChange = ( diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index d859fddd..8cb7904e 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -76,6 +76,7 @@ const ADMIN_PAGE_REGISTRY: Record> = { // 시스템 관리 "/admin/systemMng/commonCodeList": dynamic(() => import("@/app/(main)/admin/systemMng/commonCodeList/page"), { ssr: false, loading: LoadingFallback }), "/admin/systemMng/tableMngList": dynamic(() => import("@/app/(main)/admin/systemMng/tableMngList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/systemMng/numberingRuleList": dynamic(() => import("@/app/(main)/admin/systemMng/numberingRuleList/page"), { ssr: false, loading: LoadingFallback }), "/admin/systemMng/i18nList": dynamic(() => import("@/app/(main)/admin/systemMng/i18nList/page"), { ssr: false, loading: LoadingFallback }), "/admin/systemMng/collection-managementList": dynamic(() => import("@/app/(main)/admin/systemMng/collection-managementList/page"), { ssr: false, loading: LoadingFallback }), "/admin/systemMng/cascading-managementList": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }), diff --git a/frontend/components/numbering-rule/NumberingRuleCreateDialog.tsx b/frontend/components/numbering-rule/NumberingRuleCreateDialog.tsx new file mode 100644 index 00000000..7f5fe8aa --- /dev/null +++ b/frontend/components/numbering-rule/NumberingRuleCreateDialog.tsx @@ -0,0 +1,56 @@ +"use client"; + +import React from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { NumberingRuleDesigner } from "./NumberingRuleDesigner"; +import { NumberingRuleConfig } from "@/types/numbering-rule"; + +/** + * NumberingRuleCreateDialog — 스튜디오 InvFieldConfigPanel 에서 + * "새 규칙 만들기" CTA 로 호출되는 모달. + * + * NumberingRuleDesigner 의 좌측 컬럼 목록을 hide (`lockedColumn`) 하고 + * 우측 디자인/저장 영역만 노출. 저장 완료 시 부모 (InvFieldConfigPanel) 가 + * 새 ruleId 를 받아 rules 목록 refresh + 자동 선택. + */ +export interface NumberingRuleCreateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + tableName: string; + columnName?: string; + /** + * 저장 완료 시 호출. 부모 측에서 rules refresh + numberingRuleId 자동 선택. + */ + onCreated?: (rule: NumberingRuleConfig) => void; +} + +export const NumberingRuleCreateDialog: React.FC = ({ + open, + onOpenChange, + tableName, + columnName, + onCreated, +}) => { + return ( + + + + + 새 채번 규칙 만들기 — {tableName} + {columnName ? `.${columnName}` : ""} + + +
+ { + onCreated?.(rule); + onOpenChange(false); + }} + /> +
+
+
+ ); +}; diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx index 58fda3bb..3f93a50a 100644 --- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -37,6 +37,11 @@ interface NumberingRuleDesignerProps { className?: string; currentTableName?: string; menuObjid?: number; + /** + * 컬럼이 사전에 결정된 컨텍스트 (스튜디오 InvFieldConfigPanel 모달 등) 에서 사용. + * 좌측 컬럼 목록 UI 를 hide 하고 mount 시 자동 선택. + */ + lockedColumn?: { tableName: string; columnName: string }; } export const NumberingRuleDesigner: React.FC = ({ @@ -48,9 +53,12 @@ export const NumberingRuleDesigner: React.FC = ({ className = "", currentTableName, menuObjid, + lockedColumn, }) => { const [numberingColumns, setNumberingColumns] = useState([]); - const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null); + const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>( + lockedColumn ?? null, + ); const [currentRule, setCurrentRule] = useState(null); const [selectedPartOrder, setSelectedPartOrder] = useState(null); const [loading, setLoading] = useState(false); @@ -62,6 +70,29 @@ export const NumberingRuleDesigner: React.FC = ({ loadNumberingColumns(); }, []); + // initialConfig 주어지면 by-column 조회 없이 그 rule 그대로 사용 (admin 페이지). + // ★ 카테고리 별 분기 규칙이 있을 때 by-column 으로 wrong rule 열림 방지. + useEffect(() => { + if (initialConfig) { + setCurrentRule(JSON.parse(JSON.stringify(initialConfig))); + setSelectedColumn({ + tableName: initialConfig.table_name || "", + columnName: initialConfig.column_name || "", + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialConfig?.rule_id]); + + // lockedColumn 컨텍스트 — mount 시 자동 컬럼 선택 (좌측 목록 hide 시 필수) + // initialConfig 가 우선 → 있을 때는 by-column 조회 skip + useEffect(() => { + if (initialConfig) return; + if (lockedColumn) { + handleSelectColumn(lockedColumn.tableName, lockedColumn.columnName); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lockedColumn?.tableName, lockedColumn?.columnName]); + const loadNumberingColumns = async () => { setLoading(true); try { @@ -299,7 +330,8 @@ export const NumberingRuleDesigner: React.FC = ({ return (
- {/* 좌측: 채번 컬럼 목록 (테이블별 그룹화) */} + {/* 좌측: 채번 컬럼 목록 (테이블별 그룹화) — lockedColumn 시 hide */} + {!lockedColumn && (
@@ -370,15 +402,20 @@ export const NumberingRuleDesigner: React.FC = ({ )}
+ )} {/* 우측: 미리보기 + 파이프라인 + 설정 + 저장 바 */}
{!currentRule ? (
-

컬럼을 선택하세요

+

+ {lockedColumn ? "채번 규칙을 불러오는 중..." : "컬럼을 선택하세요"} +

- 좌측에서 채번 컬럼을 선택하면 규칙을 편집할 수 있습니다 + {lockedColumn + ? "잠시만 기다려 주세요" + : "좌측에서 채번 컬럼을 선택하면 규칙을 편집할 수 있습니다"}

) : ( diff --git a/frontend/components/numbering-rule/SequenceManagementPanel.tsx b/frontend/components/numbering-rule/SequenceManagementPanel.tsx new file mode 100644 index 00000000..eb2c02cf --- /dev/null +++ b/frontend/components/numbering-rule/SequenceManagementPanel.tsx @@ -0,0 +1,232 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; +import { + getNumberingRuleById, + resetSequence, + updateRuleSequence, + previewNumberingCode, +} from "@/lib/api/numberingRule"; +import { NumberingRuleConfig } from "@/types/numbering-rule"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Loader2, RotateCcw, Save, Eye } from "lucide-react"; + +/** + * SequenceManagementPanel + * + * 채번 규칙의 현재 시퀀스를 직접 관리하는 패널: + * - current_sequence 표시 + * - 임의 값으로 수정 (PUT /sequence) + * - Reset 버튼 (POST /reset) + * - 다음 코드 미리보기 (POST /preview) + * + * admin/systemMng/numberingRuleList 에서 선택된 규칙 옆에 표시. + */ +export interface SequenceManagementPanelProps { + ruleId: string; + /** 시퀀스 수정 후 부모에 알림 (목록 refresh 등) */ + onChanged?: () => void; +} + +export const SequenceManagementPanel: React.FC = ({ + ruleId, + onChanged, +}) => { + const [rule, setRule] = useState(null); + const [loading, setLoading] = useState(false); + // mutation lock — 저장/초기화 동시 실행 방지 (race condition) + const [mutating, setMutating] = useState(null); + const [draftSequence, setDraftSequence] = useState(""); + const [preview, setPreview] = useState(""); + const [previewing, setPreviewing] = useState(false); + + const loadRule = useCallback(async () => { + if (!ruleId) return; + setLoading(true); + try { + const resp = await getNumberingRuleById(ruleId); + if (resp.success && resp.data) { + setRule(resp.data); + setDraftSequence(String(resp.data.current_sequence ?? 0)); + } + } finally { + setLoading(false); + } + }, [ruleId]); + + useEffect(() => { + loadRule(); + }, [loadRule]); + + const handleSave = async () => { + if (mutating) return; + const newSeq = parseInt(draftSequence, 10); + if (isNaN(newSeq) || newSeq < 0) { + toast.error("0 이상의 정수를 입력하세요"); + return; + } + if (rule?.current_sequence === newSeq) { + toast.info("현재 시퀀스와 동일합니다"); + return; + } + setMutating("save"); + try { + const resp = await updateRuleSequence(ruleId, newSeq); + if (resp.success) { + toast.success(`시퀀스가 ${newSeq} 로 수정되었습니다. 다음 발번부터 ${newSeq + 1} 부터 시작합니다.`); + await loadRule(); + onChanged?.(); + } else { + showErrorToast("시퀀스 수정 실패", resp.error); + } + } catch (e) { + showErrorToast("시퀀스 수정 실패", e); + } finally { + setMutating(null); + } + }; + + const handleReset = async () => { + if (mutating) return; + if (!window.confirm("시퀀스를 0 으로 초기화 하시겠습니까? 이후 발번부터는 1 부터 시작합니다.")) return; + setMutating("reset"); + try { + const resp = await resetSequence(ruleId); + if (resp.success) { + toast.success("시퀀스가 초기화되었습니다"); + await loadRule(); + onChanged?.(); + } else { + showErrorToast("시퀀스 초기화 실패", resp.error); + } + } catch (e) { + showErrorToast("시퀀스 초기화 실패", e); + } finally { + setMutating(null); + } + }; + + const handlePreview = async () => { + setPreviewing(true); + try { + const resp = await previewNumberingCode(ruleId); + if (resp.success && resp.data?.generatedCode) { + setPreview(resp.data.generatedCode); + } else { + setPreview(""); + showErrorToast("미리보기 실패", resp.error); + } + } catch (e) { + setPreview(""); + showErrorToast("미리보기 실패", e); + } finally { + setPreviewing(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!rule) { + return ( +
+ 규칙을 불러올 수 없습니다. +
+ ); + } + + const current = rule.current_sequence ?? 0; + const dirty = String(current) !== draftSequence; + + return ( +
+
+
+

시퀀스 관리

+

+ {rule.rule_name} ({rule.table_name}.{rule.column_name}) +

+
+
+ +
+ + setDraftSequence(e.target.value)} + className="h-8 max-w-[160px] font-mono" + /> +
+ + +
+
+ +
+
+ 다음 코드 미리보기 + +
+
+ {preview || '조회' 를 눌러 미리보기} +
+

+ ※ 미리보기는 시퀀스를 증가시키지 않습니다. 실제 발번은 form 저장 시점에 1 증가합니다. +

+
+ +
+

⚠ 주의

+

+ 시퀀스를 수동으로 수정하면 이미 발번된 코드와 중복될 수 있습니다. 운영 중인 규칙은 신중히 + 변경하세요. +

+
+
+ ); +}; diff --git a/frontend/components/screen/panels/ComponentsPanel.tsx b/frontend/components/screen/panels/ComponentsPanel.tsx index 14a22e0d..4675d8ae 100644 --- a/frontend/components/screen/panels/ComponentsPanel.tsx +++ b/frontend/components/screen/panels/ComponentsPanel.tsx @@ -161,7 +161,7 @@ export function ComponentsPanel({ "v2-category-manager", // → input (type='select', 추후 category 특화) "v2-file-upload", // → input (type='file') "v2-media", // → input (type='file') - "v2-numbering-rule", // → input (type='code') + // v2-numbering-rule: 폐기 (2026-05-11). admin 페이지 /admin/systemMng/numberingRuleList 로 대체 "v2-location-swap-selector", // → input (type='entity') // 아래 legacy 들은 이미 상단 "기본 입력 컴포넌트" 섹션에서 hidden: // text-input, number-input, date-input, textarea-basic, image-widget, @@ -192,7 +192,7 @@ export function ComponentsPanel({ // accordion-basic, conditional-container, section-card, section-paper, // tabs, repeat-container, repeat-screen-modal, repeater-field-group, // screen-split-panel 는 기존 상단에서 이미 숨김 - "numbering-rule", + // numbering-rule: 폐기 (2026-05-11) "split-panel-layout2", // → table (displayMode='split') Phase E 통합 "section-paper", // → v2-section-paper "section-card", // → v2-section-card diff --git a/frontend/components/screen/templates/NumberingRuleTemplate.ts b/frontend/components/screen/templates/NumberingRuleTemplate.ts deleted file mode 100644 index d2b8f907..00000000 --- a/frontend/components/screen/templates/NumberingRuleTemplate.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * 채번 규칙 템플릿 - * 화면관리 시스템에 등록하여 드래그앤드롭으로 사용 - */ - -import { Hash } from "lucide-react"; - -export const getDefaultNumberingRuleConfig = () => ({ - template_code: "numbering-rule-designer", - template_name: "코드 채번 규칙", - template_name_eng: "Numbering Rule Designer", - description: "코드 자동 채번 규칙을 설정하는 컴포넌트", - category: "admin" as const, - icon_name: "hash", - default_size: { - width: 1200, - height: 800, - }, - layout_config: { - components: [ - { - type: "numbering-rule" as const, - label: "채번 규칙 설정", - position: { x: 0, y: 0 }, - size: { width: 1200, height: 800 }, - ruleConfig: { - ruleId: "new-rule", - ruleName: "새 채번 규칙", - parts: [], - separator: "-", - resetPeriod: "none", - currentSequence: 1, - }, - maxRules: 6, - style: { - padding: "16px", - backgroundColor: "#ffffff", - }, - }, - ], - }, -}); - -/** - * 템플릿 패널에서 사용할 컴포넌트 정보 - */ -export const numberingRuleTemplate = { - id: "numbering-rule", - name: "채번 규칙", - description: "코드 자동 채번 규칙 설정", - category: "admin" as const, - icon: Hash, - default_size: { width: 1200, height: 800 }, - components: [ - { - type: "numbering-rule" as const, - widgetType: undefined, - label: "채번 규칙 설정", - position: { x: 0, y: 0 }, - size: { width: 1200, height: 800 }, - style: { - padding: "16px", - backgroundColor: "#ffffff", - }, - ruleConfig: { - ruleId: "new-rule", - ruleName: "새 채번 규칙", - parts: [], - separator: "-", - resetPeriod: "none", - currentSequence: 1, - }, - maxRules: 6, - }, - ], -}; - - - - - diff --git a/frontend/components/v2/config-panels/InvFieldConfigPanel.tsx b/frontend/components/v2/config-panels/InvFieldConfigPanel.tsx index cce94854..60b70a30 100644 --- a/frontend/components/v2/config-panels/InvFieldConfigPanel.tsx +++ b/frontend/components/v2/config-panels/InvFieldConfigPanel.tsx @@ -33,6 +33,7 @@ import { AutoGenerationType } from "@/types/screen"; import { AutoGenerationUtils } from "@/lib/utils/autoGeneration"; import { getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule"; import { NumberingRuleConfig } from "@/types/numbering-rule"; +import { NumberingRuleCreateDialog } from "@/components/numbering-rule/NumberingRuleCreateDialog"; import type { V2SelectFilter } from "@/types/v2-components"; import { @@ -289,7 +290,26 @@ function resolveTriple( return { kind: "input", type: "text", format: "free" }; } -// 새 (kind, type, format) 으로 config 재작성. 기존 fieldType/source/autoGeneration 도 같이 갱신. +// 모든 type 분기 사이 공유될 수 있는 잔재 필드. +// 분기 진입 시 일괄 reset 후 자기 분기에 필요한 필드만 set → 분기 간 잔재 0. +const TYPE_VOLATILE_FIELDS = [ + "fieldType", "inputType", "source", "multiple", + "step", "min", "max", "rows", "tags", "accept", + "unit", "thousands", "mode", "mask", + "autoGeneration", "computed", "readonly", + // type 별 옵션 (select options / text length / date 옵션) + "options", "minLength", "maxLength", + "dateFormat", "minDate", "maxDate", "showToday", "maxRangeDays", + // multi 의 maxSelect (mode 는 이미 위 라인에 포함) + "maxSelect", +] as const; + +function clearVolatileFields(next: Record) { + for (const f of TYPE_VOLATILE_FIELDS) delete next[f]; +} + +// 새 (kind, type, format) 으로 config 재작성. 사용자 입력 필드 (label/placeholder/helperText/ +// defaultValue/required/editable/disabled 등) 는 보존, type 별 잔재 필드는 일괄 reset. function applyTriple( prev: Record, kind: Kind, @@ -298,22 +318,13 @@ function applyTriple( ctx: { numberingTableName?: string }, ): Record { const next: Record = { ...prev, kind, type, format }; - - // 자동 kind 가 아니면 autoGeneration 정리 - if (kind !== "auto") { - if (next.autoGeneration?.type === "numbering_rule" || next.autoGeneration?.type === "formula") { - next.autoGeneration = { ...next.autoGeneration, enabled: false }; - } - } + clearVolatileFields(next); // ★ 분기 진입 전 잔재 일괄 정리 if (kind === "input") { if (type === "text") { next.fieldType = "text"; next.inputType = format === "free" ? "text" : format; - next.source = undefined; - next.multiple = false; - // free 는 줄 수 옵션, 나머지는 단일 라인 - if (format !== "free") next.rows = undefined; + // free 는 줄 수 옵션, 나머지는 단일 라인 (clearVolatileFields 로 이미 reset 됨) if (format === "email") next.inputType = "email"; if (format === "phone") next.inputType = "tel"; if (format === "url") next.inputType = "url"; @@ -321,24 +332,20 @@ function applyTriple( } if (type === "number") { next.fieldType = "number"; - next.source = undefined; - next.multiple = false; next.step = format === "decimal" ? 0.01 : 1; - next.unit = format === "percent" ? "%" : next.unit; + next.unit = format === "percent" ? "%" : undefined; next.inputType = format === "decimal" ? "decimal" : format === "percent" ? "percentage" : "number"; return next; } if (type === "money") { next.fieldType = "number"; - next.source = undefined; next.thousands = true; - next.unit = format === "krw" ? "원" : (next.currency || "USD"); + next.unit = format === "krw" ? "원" : (prev.currency || "USD"); next.inputType = "currency"; return next; } if (type === "date") { next.fieldType = "date"; - next.source = undefined; next.inputType = format === "range" ? "daterange" : format; return next; } @@ -362,7 +369,6 @@ function applyTriple( next.source = "category"; } else if (format === "boolean") { next.fieldType = "checkbox"; - next.source = undefined; next.multiple = false; } return next; @@ -372,7 +378,6 @@ function applyTriple( if (type === "autonum") { next.fieldType = "numbering"; next.inputType = "numbering"; - next.source = undefined; next.autoGeneration = { ...prev.autoGeneration, enabled: true, @@ -384,15 +389,13 @@ function applyTriple( } if (type === "formula") { next.fieldType = "text"; - next.source = undefined; - next.computed = next.computed || ""; + next.computed = prev.computed || ""; next.autoGeneration = { enabled: true, type: "formula" }; next.readonly = true; return next; } if (type === "audit") { next.fieldType = "datetime"; - next.source = undefined; next.autoGeneration = { enabled: true, type: "current_time" }; next.readonly = true; return next; @@ -401,8 +404,6 @@ function applyTriple( if (kind === "attach" && type === "file") { next.fieldType = "file"; - next.source = undefined; - next.multiple = false; next.accept = format === "image" ? "image/*" : format === "doc" ? ".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx" : "*/*"; return next; @@ -444,6 +445,7 @@ export const InvFieldConfigPanel: React.FC = ({ const [numberingRules, setNumberingRules] = useState([]); const [loadingRules, setLoadingRules] = useState(false); + const [rulesRefreshKey, setRulesRefreshKey] = useState(0); const numberingTableName = primaryTableName; const [entityColumns, setEntityColumns] = useState([]); @@ -542,7 +544,7 @@ export const InvFieldConfigPanel: React.FC = ({ return () => { cancelled = true; }; - }, [numberingTableName, currentKind, fieldType]); + }, [numberingTableName, currentKind, fieldType, rulesRefreshKey]); // 엔티티 컬럼 로드 const loadEntityColumns = useCallback( @@ -761,6 +763,7 @@ export const InvFieldConfigPanel: React.FC = ({ numberingRules={numberingRules} loadingRules={loadingRules} numberingTableName={numberingTableName} + onRulesRefresh={() => setRulesRefreshKey((k) => k + 1)} /> @@ -780,7 +783,7 @@ export const InvFieldConfigPanel: React.FC = ({ {currentKind !== "auto" && ( {isSelectGroup ? ( - + ) : ( )} @@ -868,6 +871,7 @@ type FormatBodyProps = { numberingRules: NumberingRuleConfig[]; loadingRules: boolean; numberingTableName: string; + onRulesRefresh: () => void; }; function FormatBody(p: FormatBodyProps) { @@ -914,10 +918,12 @@ function FormatBody(p: FormatBodyProps) { ); if (kind === "auto" && type === "formula") return ; @@ -1855,18 +1861,42 @@ function FileOptions({ function NumberingOptions({ config, numberingTableName, + columnName, loading, rules, onChange, updateConfig, + onRulesRefresh, }: { config: Record; numberingTableName: string; + columnName?: string; loading: boolean; rules: NumberingRuleConfig[]; onChange: (config: Record) => void; updateConfig: (k: string, v: any) => void; + onRulesRefresh: () => void; }) { + const [createOpen, setCreateOpen] = useState(false); + + const applyRuleId = (ruleId: string) => { + // canonical 저장 위치: autoGeneration.options.numberingRuleId + // (autoGeneration.ts.generateValue 의 numbering_rule case + InputComponent 의 NumberingPicker prop 과 일치) + onChange({ + ...config, + autoGeneration: { + ...config.autoGeneration, + enabled: true, + type: "numbering_rule" as AutoGenerationType, + tableName: numberingTableName, + options: { + ...(config.autoGeneration?.options ?? {}), + numberingRuleId: ruleId, + }, + }, + }); + }; + return ( <> @@ -1877,40 +1907,73 @@ function NumberingOptions({ )} {numberingTableName && ( - - {loading ? ( - - ) : rules.length === 0 ? ( - 이 테이블에 등록된 채번 규칙이 없어요 - ) : ( - - onChange({ - ...config, - autoGeneration: { - ...config.autoGeneration, - type: "numbering_rule" as AutoGenerationType, - numberingRuleId: v, - tableName: numberingTableName, - }, - }) - } + <> + + {loading ? ( + + ) : ( + applyRuleId(v)} + disabled={rules.length === 0} + > + + {rules.map((rule) => ( + + ))} + + )} + + + + + )} - updateConfig("readonly", v)} /> +
+ updateConfig("readonly", v)} /> +
+ {numberingTableName && ( + { + // 저장 직후: 새 ruleId 자동 선택 + 목록 refresh + if (rule.rule_id) applyRuleId(String(rule.rule_id)); + onRulesRefresh(); + }} + /> + )} ); } @@ -1921,9 +1984,11 @@ function NumberingOptions({ function SelectAdvancedOptions({ config, updateConfig, + multi = false, }: { config: Record; updateConfig: (k: string, v: any) => void; + multi?: boolean; }) { return ( <> @@ -1931,16 +1996,13 @@ function SelectAdvancedOptions({ updateConfig("mode", v)}> - - - - + {!multi && } + {multi && } + {multi && } + {!multi && }
- - updateConfig("multiple", v)} /> - - {config.multiple && ( + {multi && ( 카드 레이아웃(카드선택) -> 표시/동작(Switch) -> 고급(접힘) - */ - -import React, { useState } from "react"; -import { Input } from "@/components/ui/input"; -import { Switch } from "@/components/ui/switch"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { Settings, ChevronDown, LayoutList, LayoutGrid } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { NumberingRuleComponentConfig } from "@/lib/registry/components/v2-numbering-rule/types"; - -const MAX_RULES_CARDS = [ - { value: 3, label: "3개", desc: "간단한 코드" }, - { value: 6, label: "6개", desc: "기본 (권장)" }, - { value: 8, label: "8개", desc: "복잡한 코드" }, - { value: 10, label: "10개", desc: "최대" }, -] as const; - -const LAYOUT_CARDS = [ - { value: "vertical", label: "세로", desc: "위에서 아래로", icon: LayoutList }, - { value: "horizontal", label: "가로", desc: "왼쪽에서 오른쪽으로", icon: LayoutGrid }, -] as const; - -interface V2NumberingRuleConfigPanelProps { - config: NumberingRuleComponentConfig; - onChange: (config: NumberingRuleComponentConfig) => void; -} - -export const V2NumberingRuleConfigPanel: React.FC = ({ - config, - onChange, -}) => { - const [advancedOpen, setAdvancedOpen] = useState(false); - - const updateConfig = (field: keyof NumberingRuleComponentConfig, value: any) => { - const newConfig = { ...config, [field]: value }; - onChange(newConfig); - - if (typeof window !== "undefined") { - window.dispatchEvent( - new CustomEvent("componentConfigChanged", { - detail: { config: newConfig }, - }) - ); - } - }; - - return ( -
- {/* ─── 1단계: 최대 규칙 수 카드 선택 ─── */} -
-

최대 파트 수

-
- {MAX_RULES_CARDS.map((card) => { - const isSelected = (config.maxRules || 6) === card.value; - return ( - - ); - })} -
-

- 하나의 채번 규칙에 추가할 수 있는 최대 파트 개수에요 -

-
- - {/* ─── 2단계: 카드 레이아웃 카드 선택 ─── */} -
-

파트 배치 방향

-
- {LAYOUT_CARDS.map((card) => { - const Icon = card.icon; - const isSelected = (config.cardLayout || "vertical") === card.value; - return ( - - ); - })} -
-
- - {/* ─── 3단계: 표시 설정 (Switch) ─── */} -
-

표시 설정

-
-
-
-

미리보기 표시

-

- 코드 미리보기를 항상 보여줘요 -

-
- updateConfig("showPreview", checked)} - /> -
-
-
-

규칙 목록 표시

-

- 저장된 규칙 목록을 보여줘요 -

-
- updateConfig("showRuleList", checked)} - /> -
-
-
-

파트 순서 변경

-

- 파트 드래그로 순서를 바꿀 수 있어요 -

-
- updateConfig("enableReorder", checked)} - /> -
-
-
- - {/* ─── 4단계: 고급 설정 (기본 접혀있음) ─── */} - - - - - -
-
-
-

읽기 전용

-

- 편집 기능을 비활성화해요 -

-
- updateConfig("readonly", checked)} - /> -
-
-
-
-
- ); -}; - -V2NumberingRuleConfigPanel.displayName = "V2NumberingRuleConfigPanel"; - -export default V2NumberingRuleConfigPanel; diff --git a/frontend/components/v2/config-panels/_shared/cp/CPPrimitives.tsx b/frontend/components/v2/config-panels/_shared/cp/CPPrimitives.tsx index 7f43885e..0143ae1f 100644 --- a/frontend/components/v2/config-panels/_shared/cp/CPPrimitives.tsx +++ b/frontend/components/v2/config-panels/_shared/cp/CPPrimitives.tsx @@ -6,6 +6,7 @@ * - v5 토큰만 사용. 라이트/다크 모두 자동 대응 */ import React from "react"; +import { createPortal } from "react-dom"; import { CP_ICONS } from "./icons"; const CP_FONT = "var(--v5-font-sans)"; @@ -380,6 +381,37 @@ export function CPSelect({ const listRef = React.useRef(null); const searchInputRef = React.useRef(null); + // popover 좌표 (Portal — overflow:hidden/auto 컨테이너 안에서 잘리지 않도록) + const [popPos, setPopPos] = React.useState<{ top: number; left: number; width: number } | null>(null); + React.useLayoutEffect(() => { + if (!open || !wrapRef.current) { + setPopPos(null); + return; + } + const rect = wrapRef.current.getBoundingClientRect(); + setPopPos({ top: rect.bottom + 2, left: rect.left, width: rect.width }); + }, [open]); + React.useEffect(() => { + if (!open) return; + // scroll/resize 시 popup 위치를 trigger 에 맞춰 재계산 (이전에는 무조건 close → dropdown 안 스크롤 불가능) + // trigger 가 viewport 밖으로 나가면 그때만 close. + const updatePos = () => { + if (!wrapRef.current) return; + const rect = wrapRef.current.getBoundingClientRect(); + if (rect.bottom < 0 || rect.top > window.innerHeight) { + setOpen(false); + return; + } + setPopPos({ top: rect.bottom + 2, left: rect.left, width: rect.width }); + }; + window.addEventListener("scroll", updatePos, true); + window.addEventListener("resize", updatePos); + return () => { + window.removeEventListener("scroll", updatePos, true); + window.removeEventListener("resize", updatePos); + }; + }, [open]); + // 첫 옵션이 빈값이면 placeholder 후보로 사용 (호환: ) const firstEmpty = opts.find((o) => o.value === ""); const placeholderText = @@ -497,15 +529,15 @@ export function CPSelect({ {showPlaceholder ? placeholderText : current?.label} - {open && ( + {open && popPos && typeof document !== "undefined" && createPortal(
)}
-
+
, + document.body )}
); diff --git a/frontend/lib/api/numberingRule.ts b/frontend/lib/api/numberingRule.ts index 01f0a321..b7145329 100644 --- a/frontend/lib/api/numberingRule.ts +++ b/frontend/lib/api/numberingRule.ts @@ -172,6 +172,24 @@ export async function resetSequence(ruleId: string): Promise> } } +/** + * 현재 시퀀스 임의 값으로 수정 (admin) + * backend `PUT /api/numbering-rules/:ruleId/sequence` body={sequence} + */ +export async function updateRuleSequence( + ruleId: string, + newSequence: number, +): Promise> { + try { + const response = await apiClient.put(`/numbering-rules/${ruleId}/sequence`, { + sequence: newSequence, + }); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "시퀀스 수정 실패" }; + } +} + // ====== 테스트용 API (numbering_rules 테이블 사용) ====== /** diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 8d04528b..3380a0ad 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -415,42 +415,10 @@ export const DynamicComponentRenderer: React.FC = const mappedComponentType = mapToV2ComponentType(rawComponentType); - // fieldType 기반 동적 컴포넌트 전환 (사용자 설정 > DB input_type > 기본값) - const componentType = (() => { - const configFieldType = (component as any).componentConfig?.fieldType; - const fieldName = (component as any).columnName || (component as any).componentConfig?.fieldKey || (component as any).componentConfig?.columnName; - const isEntityJoin = fieldName?.includes("."); - const baseCol = isEntityJoin ? undefined : fieldName; - const rawDbType = baseCol && screenTableName - ? (columnMetaCache[screenTableName]?.[baseCol]?.input_type || columnMetaCache[screenTableName]?.[baseCol]?.inputType) - : undefined; - const dbInputType = rawDbType === "direct" || rawDbType === "auto" ? undefined : rawDbType; - - // 디버그 (division, unit 필드만) - 문제 확인 후 제거 - if (baseCol && (baseCol === "division" || baseCol === "unit")) { - const result = configFieldType - ? (["text","number","password","textarea","slider","color","numbering"].includes(configFieldType) ? "v2-input" : "v2-select") - : dbInputType - ? (["text","number","password","textarea","slider","color","numbering"].includes(dbInputType) ? "v2-input" : "v2-select") - : mappedComponentType; - const skipCat = dbInputType && !["category", "entity", "select"].includes(dbInputType); - console.log(`[DCR] ${baseCol}: dbInputType=${dbInputType}, RESULT=${result}, skipCat=${skipCat}`); - } - - // 사용자가 V2FieldConfigPanel에서 명시적으로 설정한 fieldType 최우선 - if (configFieldType) { - if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(configFieldType)) return "v2-input"; - if (["select", "category", "entity"].includes(configFieldType)) return "v2-select"; - } - - // componentConfig.fieldType 없으면 DB input_type 참조 (초기 로드 시) - if (dbInputType) { - if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(dbInputType)) return "v2-input"; - if (["select", "category", "entity"].includes(dbInputType)) return "v2-select"; - } - - return mappedComponentType; - })(); + // ★ canonical 라우팅: fieldType / dbInputType → v2-input/v2-select 강제 swap 제거됨. + // InvField (kind/type/format) 모델이 진실의 원천. mappedComponentType 그대로 사용. + // (이전 분기는 brumb 변경 시 InputComponent → V2Input swap 의 원인이었음) + const componentType = mappedComponentType; // 🆕 조건부 렌더링 체크 (conditionalConfig) // componentConfig 또는 overrides에서 conditionalConfig를 가져와서 formData와 비교 diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 5d41daee..a8e74e0b 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -71,7 +71,7 @@ import "./text-display/TextDisplayRenderer"; import "./divider-line/DividerLineRenderer"; import "./table-list/TableListRenderer"; import "./split-panel-layout/SplitPanelLayoutRenderer"; -import "./numbering-rule/NumberingRuleRenderer"; +// numbering-rule 캔버스 컴포넌트는 폐기 (2026-05-11). admin 페이지 /admin/systemMng/numberingRuleList 로 대체. import "./table-search-widget"; import "./repeat-screen-modal/RepeatScreenModalRenderer"; import "./section-paper/SectionPaperRenderer"; @@ -89,7 +89,7 @@ import "./v2-repeater/V2RepeaterRenderer"; import "./v2-button-primary/ButtonPrimaryRenderer"; import "./v2-split-panel-layout/SplitPanelLayoutRenderer"; import "./v2-aggregation-widget/AggregationWidgetRenderer"; -import "./v2-numbering-rule/NumberingRuleRenderer"; +// v2-numbering-rule 캔버스 컴포넌트는 폐기 (2026-05-11). admin 페이지 /admin/systemMng/numberingRuleList 로 대체. import "./v2-table-list/TableListRenderer"; import "./v2-text-display/TextDisplayRenderer"; import "./v2-divider-line/DividerLineRenderer"; diff --git a/frontend/lib/registry/components/input/InputComponent.tsx b/frontend/lib/registry/components/input/InputComponent.tsx index 527c75d2..34199795 100644 --- a/frontend/lib/registry/components/input/InputComponent.tsx +++ b/frontend/lib/registry/components/input/InputComponent.tsx @@ -5,6 +5,9 @@ import { ComponentRendererProps } from "@/types/component"; import { InputConfig, InputFieldType } from "./types"; import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { SingleDatePicker, DateTimePicker, TimePicker, RangeDatePicker } from "./pickers"; +import { SingleSelectPicker, MultiSelectPicker, RadioPicker, CheckboxListPicker, TogglePicker, TagPicker } from "./select-pickers"; +import { NumberingPicker } from "./numbering-picker"; +import { AutoGenerationUtils } from "@/lib/utils/autoGeneration"; /** * Input — 통합 필드 입력 컴포넌트 @@ -85,16 +88,38 @@ export const InputComponent: React.FC = ({ ...fromProps, } as InputConfig; - // DB 저장본의 type 은 'v2-input'/'v2-select' 같은 컴포넌트 ID 라서 - // VALID_TYPES 와 직접 매칭 안 됨. inputType (DB input_type 매핑) → web_type 폴백 순으로 InputFieldType 추정. + // type 결정 — InvField canonical (kind/type/format) 우선, 옛 inputType/web_type 폴백. const rawType: any = componentConfig.type; const inputType: any = (componentConfig as any).inputType; const webType: any = (componentConfig as any).web_type; + const fmt: any = (componentConfig as any).format; const type: InputFieldType = (() => { + // ─── InvField (canonical) type 매핑 ───────────────────────── + // InvField 의 type union 은 text/number/money/date/single/multi/autonum/formula/audit/file. + // InputFieldType 과 다른 일부는 매핑. + if (rawType === "money") return "number"; // money → number (currency 표시) + if (rawType === "single" || rawType === "multi") { + if (fmt === "boolean") return "checkbox"; + return "select"; + } + if (rawType === "autonum") return "code"; + if (rawType === "formula") return "text"; // computed readonly + if (rawType === "audit") return "datetime"; // current_time autoGen + if (rawType === "date") { + // InvField 의 date 는 format=date|datetime|time|range 로 sub 분기 + if (fmt === "datetime") return "datetime"; + if (fmt === "time") return "time"; + if (fmt === "range") return "daterange"; + return "date"; + } + // text/number/file/checkbox 등 InputFieldType 와 동일한 키는 그대로 if (isValidType(rawType)) return rawType; + + // ─── 옛 inputType / web_type 폴백 (점진 폐기 영역) ─────────── if (isValidType(inputType)) return inputType; - // v2-select 는 entity 든 code 든 dropdown 이든 전부 native select 로 통일 if (rawType === "v2-select") return "select"; + // V2-era 의 inputType="numbering" → code (autonum 의 옛 키) + if (inputType === "numbering" || webType === "numbering") return "code"; if ( inputType === "select" || inputType === "code" || @@ -146,6 +171,18 @@ export const InputComponent: React.FC = ({ const columnName: string | undefined = (component as any).columnName ?? (component as any).column_name; + // tableName / isEditMode — autonum (code) 채번 hook 용 + // `||` 사용: `??` 는 빈 문자열을 통과시켜 뒤 폴백을 막음. + const tableName: string | undefined = + (props as any).tableName || + (componentConfig as any).tableName || + (component as any).tableName || + (component as any).overrides?.tableName || + (props as any).screenInfo?.tableName; + // originalData 와 _originalData (V2-era 별칭) 둘 다 고려 + const originalData = (props as any).originalData || (props as any)._originalData; + const isEditMode = !!originalData && Object.keys(originalData).length > 0; + const controlledValue = formDataProp && columnName ? formDataProp[columnName] @@ -153,21 +190,39 @@ export const InputComponent: React.FC = ({ ? (props as any).value : undefined; + // 의미있는 controlled 값 — 빈 문자열 / null 은 "값 없음" 으로 취급 → defaultValue fallback. + // (ScreenDesigner 가 디자인 모드에서 빈 value 를 넘기는 케이스 방어) + const hasControlled = + controlledValue !== undefined && controlledValue !== null && controlledValue !== ""; + const [localValue, setLocalValue] = useState( - controlledValue !== undefined ? controlledValue : (componentConfig.defaultValue ?? ""), + hasControlled ? controlledValue : (componentConfig.defaultValue ?? ""), ); useEffect(() => { - if (controlledValue !== undefined) { + if (hasControlled) { setLocalValue((prev: unknown) => (prev === controlledValue ? prev : controlledValue)); - } else if (componentConfig.defaultValue !== undefined) { + } else if (componentConfig.defaultValue !== undefined && componentConfig.defaultValue !== null) { setLocalValue((prev: unknown) => - prev === componentConfig.defaultValue ? prev : (componentConfig.defaultValue ?? ""), + prev === componentConfig.defaultValue ? prev : componentConfig.defaultValue, ); } - }, [controlledValue, componentConfig.defaultValue]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasControlled, controlledValue, componentConfig.defaultValue]); + + // value 우선순위: + // 1) 운영 모드의 controlledValue (formData / props.value) + // 2) componentConfig.defaultValue (사용자가 ConfigPanel 에 설정한 기본값) + // 3) localValue (사용자 직접 입력 / 자동생성 결과) + const dvDefined = + componentConfig.defaultValue !== undefined && + componentConfig.defaultValue !== null && + componentConfig.defaultValue !== ""; + const value: unknown = hasControlled + ? controlledValue + : (dvDefined ? componentConfig.defaultValue : localValue); + - const value: unknown = controlledValue !== undefined ? controlledValue : localValue; const propagate = (v: unknown) => { setLocalValue(v); @@ -175,6 +230,28 @@ export const InputComponent: React.FC = ({ if (typeof onChangeProp === "function") onChangeProp(v); }; + // ─── 자동생성 hook ────────────────────────────────────────── + // 디자인 모드 X / 자동생성 enabled / 값 비어있을 때만 trigger. + // numbering_rule 은 별도 API hook (Phase A.6) — 여기선 skip. + const autoGen = (componentConfig as any).autoGeneration; + const autoGenEnabled = !!autoGen?.enabled; + const autoGenType = autoGen?.type; + useEffect(() => { + if (isDesignMode) return; + if (!autoGenEnabled || !autoGenType || autoGenType === "none") return; + if (autoGenType === "numbering_rule") return; + const isEmpty = value === undefined || value === null || value === ""; + if (!isEmpty) return; + + let cancelled = false; + AutoGenerationUtils.generateValue(autoGen, columnName, formDataProp).then((generated) => { + if (cancelled || generated == null) return; + propagate(generated); + }); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDesignMode, autoGenEnabled, autoGenType]); + // ─── DOM props filter (React warning 방지) ──────────────────────────── /* eslint-disable @typescript-eslint/no-unused-vars */ const { @@ -259,14 +336,21 @@ export const InputComponent: React.FC = ({ } = props as any; /* eslint-enable @typescript-eslint/no-unused-vars */ + // container 자체가 input box 역할 — border + radius + background. + // 자식 element 는 자체 border 없이 transparent 로 가득 채움 (이중 박스 방지). + // 단 radio/check 같은 list 형태는 외각 box 자체 불필요 (자체 visual element 가 표시). + const selectMode = (componentConfig as any).mode; + const isListLikeMode = type === "select" && (selectMode === "radio" || selectMode === "check"); const containerStyle: React.CSSProperties = { width: "100%", height: "100%", display: "flex", - flexDirection: "column", - gap: "2px", - padding: "4px 6px", + alignItems: "center", boxSizing: "border-box", + border: isListLikeMode ? "none" : "1px solid hsl(var(--border))", + borderRadius: isListLikeMode ? 0 : "4px", + background: isListLikeMode ? "transparent" : (disabled ? "hsl(var(--muted))" : "hsl(var(--card))"), + position: "relative", ...(component as any).style, ...style, }; @@ -276,13 +360,29 @@ export const InputComponent: React.FC = ({ containerStyle.outlineOffset = "2px"; } + // 위젯 슬롯 — type 별 element 가 들어갈 통일 wrapper. 박스 가득. + // overflow:hidden + borderRadius:inherit — numbering picker 의 prefix/suffix muted span + // 이 container 의 라운드 모서리 안에서 깨끗하게 잘리도록. label 은 absolute top:-18 + // 로 container 바깥에 그려져서 영향 없음. + const inputSlotStyle: React.CSSProperties = { + flex: 1, + display: "flex", + width: "100%", + height: "100%", + minHeight: 0, + overflow: "hidden", + borderRadius: "inherit", + }; + + // 통일 element style — border/radius 없이 transparent. container 의 border 가 box 역할. const baseInputStyle: React.CSSProperties = { width: "100%", + height: "100%", padding: "5px 8px", fontSize: "13px", - border: "1px solid hsl(var(--border))", - borderRadius: "4px", - background: disabled ? "hsl(var(--muted))" : "hsl(var(--card))", + border: 0, + borderRadius: 0, + background: "transparent", color: "hsl(var(--foreground))", outline: "none", boxSizing: "border-box", @@ -327,6 +427,7 @@ export const InputComponent: React.FC = ({ disabled={disabled} readonly={readonly} placeholder={placeholder} + className="border-0 bg-transparent rounded-none" /> ); case "datetime": @@ -339,6 +440,7 @@ export const InputComponent: React.FC = ({ maxDate={componentConfig.maxDate} disabled={disabled} readonly={readonly} + className="border-0 bg-transparent rounded-none" /> ); case "time": @@ -348,6 +450,7 @@ export const InputComponent: React.FC = ({ onChange={(v) => propagate(v)} disabled={disabled} readonly={readonly} + className="border-0 bg-transparent rounded-none" /> ); case "daterange": @@ -364,6 +467,7 @@ export const InputComponent: React.FC = ({ maxDate={componentConfig.maxDate} disabled={disabled} readonly={readonly} + className="border-0 bg-transparent rounded-none" /> ); case "textarea": @@ -379,32 +483,107 @@ export const InputComponent: React.FC = ({ /> ); case "select": { - const options = componentConfig.options ?? []; + const rawOptions = componentConfig.options ?? []; + const normalizedOptions = rawOptions.map((opt: any) => + typeof opt === "string" + ? { value: opt, label: opt } + : { value: String(opt.value ?? ""), label: String(opt.label ?? opt.value ?? "") }, + ); + const isMulti = rawType === "multi" || !!(componentConfig as any).multiple; + // 기존 옵션 — config.mode (dropdown|combobox|radio|check|tag|toggle) + const mode = (componentConfig as any).mode || "dropdown"; + const searchable = mode === "combobox" || !!(componentConfig as any).searchable; + + // multi 분기 + if (isMulti) { + const arrValue = Array.isArray(value) ? (value as string[]) : value ? [String(value)] : []; + // format=tags 또는 mode=tag → TagPicker (chip + 입력) + const fmt = (componentConfig as any).format; + if (fmt === "tags" || mode === "tag") { + return ( + propagate(v)} + placeholder={placeholder || "태그 입력 후 Enter"} + maxSelect={(componentConfig as any).maxSelect} + disabled={disabled} + readonly={readonly} + className="border-0 bg-transparent rounded-none" + /> + ); + } + if (mode === "check") { + return ( + propagate(v)} + options={normalizedOptions} + maxSelect={(componentConfig as any).maxSelect} + disabled={disabled} + readonly={readonly} + className="border-0 bg-transparent rounded-none" + /> + ); + } + // dropdown / combobox (기본) — MultiSelectPicker + return ( + propagate(v)} + options={normalizedOptions} + placeholder={placeholder || "선택"} + searchable={searchable} + allowClear={!!(componentConfig as any).allowClear} + maxSelect={(componentConfig as any).maxSelect} + disabled={disabled} + readonly={readonly} + className="border-0 bg-transparent rounded-none" + /> + ); + } + + // single 분기 + if (mode === "radio") { + return ( + propagate(v)} + options={normalizedOptions} + disabled={disabled} + readonly={readonly} + className="border-0 bg-transparent rounded-none" + /> + ); + } + // dropdown / combobox (기본) — SingleSelectPicker return ( - + onChange={(v) => propagate(v)} + options={normalizedOptions} + placeholder={placeholder || "선택"} + searchable={searchable} + allowClear={!!(componentConfig as any).allowClear} + disabled={disabled || isDesignMode} + readonly={readonly} + className="border-0 bg-transparent rounded-none" + /> ); } - case "checkbox": + case "checkbox": { + // single.boolean 영역. mode=toggle (기본 권장) 이면 TogglePicker, 그 외 단일 체크박스. + const cbMode = (componentConfig as any).mode; + if (cbMode === "toggle") { + return ( + propagate(v)} + disabled={disabled} + readonly={readonly} + className="border-0 bg-transparent rounded-none" + /> + ); + } return ( ); + } case "entity": return ( -
+
= ({ style={{ padding: "5px 10px", fontSize: "12px", + height: "100%", border: "1px solid hsl(var(--border))", background: "hsl(var(--muted))", borderRadius: "4px", cursor: disabled || isDesignMode ? "not-allowed" : "pointer", + flexShrink: 0, }} disabled={disabled || isDesignMode} > @@ -464,25 +647,40 @@ export const InputComponent: React.FC = ({ ); case "code": return ( - propagate(v)} + tableName={tableName} + columnName={columnName} + formData={formDataProp} + numberingRuleId={autoGen?.options?.numberingRuleId} + isEditMode={isEditMode} + isDesignMode={isDesignMode} + disabled={disabled} + readonly={readonly} + placeholder={placeholder} + className="border-0 bg-transparent rounded-none" + onRuleIdResolved={(ruleId) => { + // EditModal / buttonActions 의 `${columnName}_numberingRuleId` 메타 키 호환 + if (typeof onFormDataChangeProp === "function" && columnName) { + onFormDataChangeProp(`${columnName}_numberingRuleId`, ruleId); + } }} - placeholder={placeholder || "자동채번"} /> ); case "text": - default: + default: { + // InvField format 별 native input type 분기 (password/email/tel/url) + const f = (componentConfig as any).format; + const inputHtmlType = + f === "password" ? "password" : + f === "email" ? "email" : + f === "phone" || f === "tel" ? "tel" : + f === "url" ? "url" : + "text"; return ( propagate(e.target.value)} minLength={componentConfig.minLength} @@ -490,6 +688,7 @@ export const InputComponent: React.FC = ({ {...common} /> ); + } } }; @@ -505,19 +704,24 @@ export const InputComponent: React.FC = ({ {label && ( )} - {renderInput()} +
{renderInput()}
{helperText && ( void; + tableName?: string; + columnName?: string; + formData?: Record; + numberingRuleId?: string; + isEditMode?: boolean; + isDesignMode?: boolean; + disabled?: boolean; + readonly?: boolean; + placeholder?: string; + className?: string; + style?: React.CSSProperties; + /** + * ruleId 가 결정 (props 또는 by-column 조회) 되면 1회 호출. + * EditModal / buttonActions 의 `${columnName}_numberingRuleId` 메타 키 호환용. + */ + onRuleIdResolved?: (ruleId: string) => void; +} + +export const NumberingPicker: React.FC = ({ + value, + onChange, + tableName, + columnName, + formData, + numberingRuleId: propRuleId, + isEditMode = false, + isDesignMode = false, + disabled = false, + readonly = false, + placeholder, + className, + style, + onRuleIdResolved, +}) => { + // ─── state / refs ────────────────────────────────────────── + const [autoGeneratedValue, setAutoGeneratedValue] = useState(null); + const [isGenerating, setIsGenerating] = useState(false); + const [manualInputValue, setManualInputValue] = useState(""); + + const ruleIdRef = useRef(propRuleId ?? null); + const hasGeneratedRef = useRef(false); + const lastCategoryValuesRef = useRef(""); + const userEditedRef = useRef(false); + const hadManualPartRef = useRef(false); + const templateRef = useRef(""); + const formDataRef = useRef | undefined>(formData); + + // 최신 formData 추적 (effect closure stale 방지) + useEffect(() => { + formDataRef.current = formData; + }, [formData]); + + // props.numberingRuleId 변경 시 ref 갱신 + 외부 알림 (autoGen 설정 변경 대응) + useEffect(() => { + if (propRuleId && propRuleId !== ruleIdRef.current) { + ruleIdRef.current = propRuleId; + hasGeneratedRef.current = false; + onRuleIdResolved?.(propRuleId); + } else if (propRuleId && ruleIdRef.current === propRuleId) { + // 첫 mount 시 useRef 초기화에서 set 된 케이스 + onRuleIdResolved?.(propRuleId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [propRuleId]); + + // formData 의 다른 string 필드 변화 추적 (카테고리 기반 채번 재생성용) + const categoryValuesForNumbering = useMemo(() => { + if (!formData) return ""; + const categoryFields: Record = {}; + for (const [key, val] of Object.entries(formData)) { + if (key === columnName) continue; + if (typeof val === "string" && val) { + categoryFields[key] = val; + } + } + return JSON.stringify(categoryFields); + }, [formData, columnName]); + + // ─── main effect: ruleId 조회 + preview ──────────────────── + useEffect(() => { + if (isDesignMode) return; + if (isEditMode) return; // 수정 모드는 기존 값 유지 + + let cancelled = false; + + const run = async () => { + if (isGenerating) return; + + const categoryChanged = categoryValuesForNumbering !== lastCategoryValuesRef.current; + if (userEditedRef.current && !categoryChanged) return; + if (hasGeneratedRef.current && !categoryChanged) return; + + // 첫 생성 시 값이 이미 있고 카테고리 변경이 아니면 스킵 + if (!categoryChanged && value !== undefined && value !== null && value !== "") return; + + setIsGenerating(true); + try { + // ruleId 결정 (없을 때만 by-column → fallback 흐름) + if (!ruleIdRef.current) { + if (!tableName || !columnName) { + console.warn("NumberingPicker: tableName/columnName 없음", { tableName, columnName }); + return; + } + // by-column API + try { + const { apiClient } = await import("@/lib/api/client"); + const ruleResp = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`); + if (cancelled) return; + if (ruleResp.data?.success && ruleResp.data?.data?.ruleId) { + ruleIdRef.current = ruleResp.data.data.ruleId; + onRuleIdResolved?.(ruleResp.data.data.ruleId); + } + } catch { + // detailSettings fallback + try { + const { getTableColumns } = await import("@/lib/api/tableManagement"); + const colsResp = await getTableColumns(tableName); + if (cancelled) return; + if (colsResp.success && colsResp.data) { + const cols = colsResp.data.columns || colsResp.data; + const target = cols.find((c: { column_name: string }) => c.column_name === columnName); + if (target?.detail_settings) { + const parsed = + typeof target.detail_settings === "string" + ? JSON.parse(target.detail_settings) + : target.detail_settings; + ruleIdRef.current = parsed.numberingRuleId || null; + if (ruleIdRef.current) onRuleIdResolved?.(ruleIdRef.current); + } + } + } catch { + /* ignore */ + } + } + } + + const ruleId = ruleIdRef.current; + if (!ruleId) { + console.warn("NumberingPicker: 채번 규칙 없음. 옵션설정 > 채번설정에서 생성 필요", { + tableName, + columnName, + }); + return; + } + + const resp = await previewNumberingCode(ruleId, formDataRef.current, manualInputValue || undefined); + if (cancelled) return; + + if (resp.success && resp.data?.generatedCode) { + const code = resp.data.generatedCode; + hasGeneratedRef.current = true; + lastCategoryValuesRef.current = categoryValuesForNumbering; + + if (code.includes("____")) { + hadManualPartRef.current = true; + const oldTemplate = templateRef.current; + templateRef.current = code; + + if (!userEditedRef.current) { + // 첫 생성 — 템플릿 그대로 표시 + setAutoGeneratedValue(code); + onChange(code); + } else if (oldTemplate !== code) { + // 카테고리 변경으로 템플릿 갱신 + 기존 manualInputValue 유지 → 새 조합값 부모에 반영 + const parts = code.split("____"); + const newPrefix = parts[0] || ""; + const newSuffix = parts.length > 1 ? parts.slice(1).join("") : ""; + const newCombined = newPrefix + manualInputValue + newSuffix; + setAutoGeneratedValue(newCombined); + onChange(newCombined); + } + } else { + hadManualPartRef.current = false; + templateRef.current = ""; + setAutoGeneratedValue(code); + onChange(code); + userEditedRef.current = false; + } + } else { + console.warn("NumberingPicker: 채번 코드 생성 실패", resp); + } + } catch (err) { + if (!cancelled) console.error("NumberingPicker: 자동생성 오류", err); + } finally { + if (!cancelled) setIsGenerating(false); + } + }; + + run(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tableName, columnName, isDesignMode, isEditMode, categoryValuesForNumbering, propRuleId]); + + // ─── 디바운스: manualInputValue 변경 시 suffix 동적 갱신 ──── + useEffect(() => { + if (isDesignMode) return; + if (!templateRef.current.includes("____")) return; + if (!ruleIdRef.current) return; + if (!userEditedRef.current) return; + + let cancelled = false; + + const timer = setTimeout(async () => { + try { + const resp = await previewNumberingCode( + ruleIdRef.current!, + formDataRef.current, + manualInputValue || undefined, + ); + if (cancelled) return; + if (resp.success && resp.data?.generatedCode) { + const newTemplate = resp.data.generatedCode; + if (newTemplate.includes("____")) { + templateRef.current = newTemplate; + const parts = newTemplate.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.length > 1 ? parts.slice(1).join("") : ""; + const combined = prefix + manualInputValue + suffix; + setAutoGeneratedValue(combined); + onChange(combined); + } + } + } catch { + /* 미리보기 실패 시 기존 suffix 유지 */ + } + }, 300); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [manualInputValue, isDesignMode]); + + // ─── beforeFormSave 리스너: 저장 직전 조합값 주입 ────────── + useEffect(() => { + if (isDesignMode || !columnName) return; + + const handler = (event: Event) => { + const customEvent = event as CustomEvent; + const template = templateRef.current; + if (!template || !template.includes("____")) return; + + const parts = template.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.length > 1 ? parts.slice(1).join("") : ""; + const combined = prefix + manualInputValue + suffix; + + if (customEvent.detail?.formData && columnName) { + customEvent.detail.formData[columnName] = combined; + } + }; + + window.addEventListener("beforeFormSave", handler); + return () => window.removeEventListener("beforeFormSave", handler); + }, [columnName, manualInputValue, isDesignMode]); + + // ─── 렌더 ────────────────────────────────────────────────── + const displayValue = + autoGeneratedValue !== null + ? autoGeneratedValue + : typeof value === "string" + ? value + : ""; + + const template = templateRef.current; + const canEdit = hadManualPartRef.current && template && !readonly && !disabled; + + // 박스 외관은 부모 (InputComponent) 가 담당. 여기선 슬롯 내부만. + // 부모의 borderRadius 안에 prefix/suffix muted span 이 깨끗하게 잘리도록 overflow/inherit. + const wrapperStyle: React.CSSProperties = { + width: "100%", + height: "100%", + display: "flex", + alignItems: "center", + overflow: "hidden", + borderRadius: "inherit", + ...style, + }; + + // ____ 없는 경우: readonly 표시 + if (!canEdit) { + return ( + + ); + } + + // ____ 있는 경우: prefix + 편집 input + suffix + const parts = template.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.length > 1 ? parts.slice(1).join("") : ""; + + return ( +
+ {prefix && ( + + {prefix} + + )} + { + const newInput = e.target.value; + setManualInputValue(newInput); + userEditedRef.current = true; + + const newValue = prefix + newInput + suffix; + setAutoGeneratedValue(newValue); + onChange(newValue); + + // 외부 listener (다른 컴포넌트가 채번 변경 감지) 호환 + if (typeof window !== "undefined" && columnName) { + window.dispatchEvent( + new CustomEvent("numberingValueChanged", { + detail: { columnName, value: newValue }, + }), + ); + } + }} + placeholder="입력" + disabled={disabled || isGenerating} + style={{ + flex: 1, + minWidth: 60, + height: "100%", + padding: "0 8px", + fontSize: "13px", + fontFamily: "monospace", + border: 0, + borderRadius: 0, + background: "transparent", + color: "hsl(var(--foreground))", + outline: "none", + boxSizing: "border-box", + }} + /> + {suffix && ( + + {suffix} + + )} +
+ ); +}; diff --git a/frontend/lib/registry/components/input/pickers.tsx b/frontend/lib/registry/components/input/pickers.tsx index ede192af..869062d2 100644 --- a/frontend/lib/registry/components/input/pickers.tsx +++ b/frontend/lib/registry/components/input/pickers.tsx @@ -441,7 +441,8 @@ const RangeCalendarPopover: React.FC<{ disabled?: boolean; readonly?: boolean; displayValue?: string; -}> = ({ open, onOpenChange, selectedDate, onSelect, label, disabled, readonly, displayValue }) => { + className?: string; +}> = ({ open, onOpenChange, selectedDate, onSelect, label, disabled, readonly, displayValue, className }) => { const [currentMonth, setCurrentMonth] = useState(new Date()); const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); @@ -500,6 +501,7 @@ const RangeCalendarPopover: React.FC<{ "border-input bg-background flex h-full flex-1 cursor-pointer items-center rounded-md border px-3", (disabled || readonly) && "cursor-not-allowed opacity-50", !displayValue && !isTyping && "text-muted-foreground", + className, )} onClick={() => { if (!disabled && !readonly) onOpenChange(true); @@ -742,7 +744,7 @@ export const RangeDatePicker = forwardRef< ); return ( -
+
~
); @@ -783,16 +787,20 @@ export const TimePicker = forwardRef< } >(({ value, onChange, disabled, readonly, className }, ref) => { return ( -
- - + + onChange?.(e.target.value)} disabled={disabled} readOnly={readonly} - className="h-full pl-10" + className={cn( + "h-full w-full bg-transparent pl-10 pr-2 text-sm outline-none border-0", + (disabled || readonly) && "cursor-not-allowed opacity-50", + className, + )} />
); @@ -838,7 +846,7 @@ export const DateTimePicker = forwardRef< ); return ( -
+
+
- +
); diff --git a/frontend/lib/registry/components/input/select-pickers.tsx b/frontend/lib/registry/components/input/select-pickers.tsx new file mode 100644 index 00000000..4edb1ba8 --- /dev/null +++ b/frontend/lib/registry/components/input/select-pickers.tsx @@ -0,0 +1,610 @@ +"use client"; + +/** + * Input 컴포넌트의 select 계열 picker 묶음. + * + * 단계적 이식 (V2Select.tsx 1350줄 → 핵심만 InvField canonical 모델로): + * - SingleSelectPicker → InvField choice.single.list (드롭다운, 단일) + * - MultiSelectPicker → InvField choice.multi.list (TODO Phase B.2) + * - TagPicker → InvField choice.multi.tags (TODO Phase B.3) + * + * 외형 통일: outer wrapper transparent + container border 가 box 역할. + * 모든 picker 는 className prop 으로 border/bg override 가능. + */ + +import React, { useState, useRef, useEffect } from "react"; +import { ChevronDown, Search, Check } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface SelectOption { + value: string; + label: string; + disabled?: boolean; +} + +interface SingleSelectPickerProps { + value?: string; + onChange?: (value: string) => void; + options: SelectOption[]; + placeholder?: string; + searchable?: boolean; + allowClear?: boolean; + disabled?: boolean; + readonly?: boolean; + className?: string; +} + +export const SingleSelectPicker = React.forwardRef( + ({ value, onChange, options, placeholder = "선택", searchable, allowClear, disabled, readonly, className }, ref) => { + const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const wrapperRef = useRef(null); + + const selected = options.find((o) => o.value === value); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (!wrapperRef.current?.contains(e.target as Node)) { + setOpen(false); + setSearchQuery(""); + } + }; + const escHandler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setOpen(false); + setSearchQuery(""); + } + }; + document.addEventListener("mousedown", handler); + document.addEventListener("keydown", escHandler); + return () => { + document.removeEventListener("mousedown", handler); + document.removeEventListener("keydown", escHandler); + }; + }, [open]); + + const filteredOptions = + searchable && searchQuery + ? options.filter((o) => o.label.toLowerCase().includes(searchQuery.toLowerCase())) + : options; + + const handleSelect = (v: string) => { + onChange?.(v); + setOpen(false); + setSearchQuery(""); + }; + + const handleClear = (e: React.MouseEvent) => { + e.stopPropagation(); + onChange?.(""); + }; + + return ( +
+
{ + if (!disabled && !readonly) setOpen((v) => !v); + }} + > + + {selected?.label || placeholder} + + {allowClear && selected && !disabled && !readonly && ( + + )} + +
+ {open && ( +
+ {searchable && ( +
+
+ + setSearchQuery(e.target.value)} + placeholder="검색" + className="border-border bg-background h-7 w-full rounded border pr-2 pl-7 text-xs outline-none" + autoFocus + /> +
+
+ )} + {filteredOptions.length === 0 ? ( +
옵션 없음
+ ) : ( + filteredOptions.map((opt) => ( + + )) + )} +
+ )} +
+ ); + }, +); +SingleSelectPicker.displayName = "SingleSelectPicker"; + +// ───────────────────────────────────────────────────────── +// MultiSelectPicker — InvField choice.multi.list (다중 선택, 라벨 join 트리거) +// ───────────────────────────────────────────────────────── + +interface MultiSelectPickerProps { + value?: string[]; + onChange?: (values: string[]) => void; + options: SelectOption[]; + placeholder?: string; + searchable?: boolean; + allowClear?: boolean; + maxSelect?: number; + disabled?: boolean; + readonly?: boolean; + className?: string; +} + +export const MultiSelectPicker = React.forwardRef( + ( + { value, onChange, options, placeholder = "선택", searchable, allowClear, maxSelect, disabled, readonly, className }, + ref, + ) => { + const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const wrapperRef = useRef(null); + + // value 정규화 — undefined/string 방어 + const selectedValues: string[] = Array.isArray(value) ? value : value ? [String(value)] : []; + const selectedOptions = options.filter((o) => selectedValues.includes(o.value)); + const displayLabel = selectedOptions.length === 0 ? "" : selectedOptions.map((o) => o.label).join(", "); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (!wrapperRef.current?.contains(e.target as Node)) { + setOpen(false); + setSearchQuery(""); + } + }; + const escHandler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setOpen(false); + setSearchQuery(""); + } + }; + document.addEventListener("mousedown", handler); + document.addEventListener("keydown", escHandler); + return () => { + document.removeEventListener("mousedown", handler); + document.removeEventListener("keydown", escHandler); + }; + }, [open]); + + const filteredOptions = + searchable && searchQuery + ? options.filter((o) => o.label.toLowerCase().includes(searchQuery.toLowerCase())) + : options; + + const handleToggle = (v: string) => { + if (disabled || readonly) return; + if (selectedValues.includes(v)) { + onChange?.(selectedValues.filter((x) => x !== v)); + } else { + if (maxSelect && selectedValues.length >= maxSelect) return; // 차단 + onChange?.([...selectedValues, v]); + } + }; + + const handleClearAll = (e: React.MouseEvent) => { + e.stopPropagation(); + onChange?.([]); + }; + + return ( +
+
{ + if (!disabled && !readonly) setOpen((v) => !v); + }} + > + + {displayLabel || placeholder} + + {allowClear && selectedOptions.length > 0 && !disabled && !readonly && ( + + )} + +
+ {open && ( +
+ {searchable && ( +
+
+ + setSearchQuery(e.target.value)} + placeholder="검색" + className="border-border bg-background h-7 w-full rounded border pr-2 pl-7 text-xs outline-none" + autoFocus + /> +
+
+ )} + {maxSelect && ( +
+ {selectedValues.length} / {maxSelect} +
+ )} + {filteredOptions.length === 0 ? ( +
옵션 없음
+ ) : ( + filteredOptions.map((opt) => { + const checked = selectedValues.includes(opt.value); + const blocked = !checked && !!maxSelect && selectedValues.length >= maxSelect; + return ( + + ); + }) + )} +
+ )} +
+ ); + }, +); +MultiSelectPicker.displayName = "MultiSelectPicker"; + +// ───────────────────────────────────────────────────────── +// RadioPicker — single 의 displayMode=radio (옵션을 라디오 button list) +// ───────────────────────────────────────────────────────── + +interface RadioPickerProps { + value?: string; + onChange?: (value: string) => void; + options: SelectOption[]; + disabled?: boolean; + readonly?: boolean; + className?: string; +} + +export const RadioPicker = React.forwardRef( + ({ value, onChange, options, disabled, readonly, className }, ref) => { + return ( +
+ {options.length === 0 ? ( + 옵션 없음 + ) : ( + options.map((opt) => { + const checked = value === opt.value; + const optDisabled = disabled || readonly || opt.disabled; + return ( + + ); + }) + )} +
+ ); + }, +); +RadioPicker.displayName = "RadioPicker"; + +// ───────────────────────────────────────────────────────── +// CheckboxListPicker — multi 의 displayMode=checkbox (옵션을 체크박스 list) +// ───────────────────────────────────────────────────────── + +interface CheckboxListPickerProps { + value?: string[]; + onChange?: (values: string[]) => void; + options: SelectOption[]; + maxSelect?: number; + disabled?: boolean; + readonly?: boolean; + className?: string; +} + +export const CheckboxListPicker = React.forwardRef( + ({ value, onChange, options, maxSelect, disabled, readonly, className }, ref) => { + const selectedValues: string[] = Array.isArray(value) ? value : value ? [String(value)] : []; + + const handleToggle = (v: string) => { + if (disabled || readonly) return; + if (selectedValues.includes(v)) { + onChange?.(selectedValues.filter((x) => x !== v)); + } else { + if (maxSelect && selectedValues.length >= maxSelect) return; + onChange?.([...selectedValues, v]); + } + }; + + return ( +
+ {options.length === 0 ? ( + 옵션 없음 + ) : ( + options.map((opt) => { + const checked = selectedValues.includes(opt.value); + const blocked = !checked && !!maxSelect && selectedValues.length >= maxSelect; + const optDisabled = disabled || readonly || opt.disabled || blocked; + return ( + + ); + }) + )} +
+ ); + }, +); +CheckboxListPicker.displayName = "CheckboxListPicker"; + +// ───────────────────────────────────────────────────────── +// TogglePicker — single.boolean 의 mode=toggle (Y/N 토글 스위치) +// value: boolean | "Y"/"N" | "true"/"false" | 1/0 모두 받음 +// ───────────────────────────────────────────────────────── + +interface TogglePickerProps { + value?: unknown; + onChange?: (value: boolean) => void; + trueLabel?: string; + falseLabel?: string; + disabled?: boolean; + readonly?: boolean; + className?: string; +} + +function isTruthy(v: unknown): boolean { + if (typeof v === "boolean") return v; + if (typeof v === "number") return v !== 0; + if (typeof v === "string") { + const s = v.toLowerCase(); + return s === "true" || s === "y" || s === "yes" || s === "1"; + } + return false; +} + +export const TogglePicker = React.forwardRef( + ({ value, onChange, trueLabel = "예", falseLabel = "아니오", disabled, readonly, className }, ref) => { + const checked = isTruthy(value); + const lockClick = !!disabled || !!readonly; + + return ( +
+ + {checked ? trueLabel : falseLabel} +
+ ); + }, +); +TogglePicker.displayName = "TogglePicker"; + +// ───────────────────────────────────────────────────────── +// TagPicker — multi.tags (chip 입력. Enter / 구분자 로 추가, Backspace / ✕ 로 제거) +// ───────────────────────────────────────────────────────── + +interface TagPickerProps { + value?: string[]; + onChange?: (values: string[]) => void; + placeholder?: string; + maxSelect?: number; + /** 자동 분리 키. ',' 또는 ' ' 등 */ + separator?: string; + disabled?: boolean; + readonly?: boolean; + className?: string; +} + +export const TagPicker = React.forwardRef( + ( + { + value, + onChange, + placeholder = "태그 입력 후 Enter", + maxSelect, + separator, + disabled, + readonly, + className, + }, + ref, + ) => { + const [input, setInput] = useState(""); + const tags: string[] = Array.isArray(value) ? value : []; + const lockEdit = !!disabled || !!readonly; + + const addTag = (tag: string) => { + const trimmed = tag.trim(); + if (!trimmed) return; + if (tags.includes(trimmed)) return; + if (maxSelect && tags.length >= maxSelect) return; + onChange?.([...tags, trimmed]); + }; + + const removeTag = (idx: number) => { + onChange?.(tags.filter((_, i) => i !== idx)); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || (separator && e.key === separator)) { + e.preventDefault(); + if (input.trim()) { + addTag(input); + setInput(""); + } + } else if (e.key === "Backspace" && input === "" && tags.length > 0) { + removeTag(tags.length - 1); + } + }; + + return ( +
+ {tags.map((tag, i) => ( + + {tag} + {!lockEdit && ( + + )} + + ))} + {!lockEdit && (!maxSelect || tags.length < maxSelect) && ( + setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={tags.length === 0 ? placeholder : ""} + className="min-w-[80px] flex-1 bg-transparent text-sm outline-none" + disabled={lockEdit} + /> + )} +
+ ); + }, +); +TagPicker.displayName = "TagPicker"; diff --git a/frontend/lib/registry/components/numbering-rule/NumberingRuleComponent.tsx b/frontend/lib/registry/components/numbering-rule/NumberingRuleComponent.tsx deleted file mode 100644 index 6f1048f9..00000000 --- a/frontend/lib/registry/components/numbering-rule/NumberingRuleComponent.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import React from "react"; -import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner"; -import { NumberingRuleComponentConfig } from "./types"; - -interface NumberingRuleWrapperProps { - config: NumberingRuleComponentConfig; - onChange?: (config: NumberingRuleComponentConfig) => void; - isPreview?: boolean; - tableName?: string; // 현재 화면의 테이블명 - menuObjid?: number; // 🆕 메뉴 OBJID -} - -export const NumberingRuleWrapper: React.FC = ({ - config, - onChange, - isPreview = false, - tableName, - menuObjid, -}) => { - console.log("📋 NumberingRuleWrapper: 테이블명 + menuObjid 전달", { - tableName, - menuObjid, - hasMenuObjid: !!menuObjid, - config - }); - - return ( -
- -
- ); -}; - -export const NumberingRuleComponent = NumberingRuleWrapper; diff --git a/frontend/lib/registry/components/numbering-rule/NumberingRuleConfigPanel.tsx b/frontend/lib/registry/components/numbering-rule/NumberingRuleConfigPanel.tsx deleted file mode 100644 index 332d4055..00000000 --- a/frontend/lib/registry/components/numbering-rule/NumberingRuleConfigPanel.tsx +++ /dev/null @@ -1,105 +0,0 @@ -"use client"; - -import React from "react"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { Switch } from "@/components/ui/switch"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { NumberingRuleComponentConfig } from "./types"; - -interface NumberingRuleConfigPanelProps { - config: NumberingRuleComponentConfig; - onChange: (config: NumberingRuleComponentConfig) => void; -} - -export const NumberingRuleConfigPanel: React.FC = ({ - config, - onChange, -}) => { - return ( -
-
- - - onChange({ ...config, maxRules: parseInt(e.target.value) || 6 }) - } - className="h-9" - /> -

- 한 규칙에 추가할 수 있는 최대 파트 개수 (1-10) -

-
- -
-
- -

- 편집 기능을 비활성화합니다 -

-
- - onChange({ ...config, readonly: checked }) - } - /> -
- -
-
- -

- 코드 미리보기를 항상 표시합니다 -

-
- - onChange({ ...config, showPreview: checked }) - } - /> -
- -
-
- -

- 저장된 규칙 목록을 표시합니다 -

-
- - onChange({ ...config, showRuleList: checked }) - } - /> -
- -
- - -

- 규칙 파트 카드의 배치 방향 -

-
-
- ); -}; diff --git a/frontend/lib/registry/components/numbering-rule/NumberingRuleRenderer.tsx b/frontend/lib/registry/components/numbering-rule/NumberingRuleRenderer.tsx deleted file mode 100644 index 988815f5..00000000 --- a/frontend/lib/registry/components/numbering-rule/NumberingRuleRenderer.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import React from "react"; -import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; -import { NumberingRuleDefinition } from "./index"; -import { NumberingRuleComponent } from "./NumberingRuleComponent"; - -/** - * 채번 규칙 렌더러 - * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 - */ -export class NumberingRuleRenderer extends AutoRegisteringComponentRenderer { - static componentDefinition = NumberingRuleDefinition; - - render(): React.ReactElement { - return ; - } - - /** - * 채번 규칙 컴포넌트 특화 메서드 - */ - protected handleValueChange = (value: any) => { - this.updateComponent({ value }); - }; -} - -// 자동 등록 실행 -NumberingRuleRenderer.registerSelf(); - -// Hot Reload 지원 (개발 모드) -if (process.env.NODE_ENV === "development") { - NumberingRuleRenderer.enableHotReload(); -} diff --git a/frontend/lib/registry/components/numbering-rule/README.md b/frontend/lib/registry/components/numbering-rule/README.md deleted file mode 100644 index 5d04d894..00000000 --- a/frontend/lib/registry/components/numbering-rule/README.md +++ /dev/null @@ -1,102 +0,0 @@ -# 코드 채번 규칙 컴포넌트 - -## 개요 - -시스템에서 자동으로 코드를 생성하는 규칙을 설정하고 관리하는 관리자 전용 컴포넌트입니다. - -## 주요 기능 - -- **좌우 분할 레이아웃**: 좌측에서 규칙 목록, 우측에서 편집 -- **동적 파트 시스템**: 최대 6개의 파트를 자유롭게 조합 -- **실시간 미리보기**: 설정 즉시 생성될 코드 확인 -- **다양한 파트 유형**: 접두사, 순번, 날짜, 연도, 월, 커스텀 - -## 생성 코드 예시 - -- 제품 코드: `PROD-20251104-0001` -- 프로젝트 코드: `PRJ-2025-001` -- 거래처 코드: `CUST-A-0001` - -## 파트 유형 - -### 1. 접두사 (prefix) -고정된 문자열을 코드 앞에 추가합니다. -- 예: `PROD`, `PRJ`, `CUST` - -### 2. 순번 (sequence) -자동으로 증가하는 번호를 생성합니다. -- 자릿수 설정 가능 (1-10) -- 시작 번호 설정 가능 -- 예: `0001`, `00001` - -### 3. 날짜 (date) -현재 날짜를 다양한 형식으로 추가합니다. -- YYYY: 2025 -- YYYYMMDD: 20251104 -- YYMMDD: 251104 - -### 4. 연도 (year) -현재 연도를 추가합니다. -- YYYY: 2025 -- YY: 25 - -### 5. 월 (month) -현재 월을 2자리로 추가합니다. -- 예: 01, 02, ..., 12 - -### 6. 사용자 정의 (custom) -원하는 값을 직접 입력합니다. - -## 생성 방식 - -### 자동 생성 (auto) -시스템이 자동으로 값을 생성합니다. - -### 직접 입력 (manual) -사용자가 값을 직접 입력합니다. - -## 설정 옵션 - -| 옵션 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| `maxRules` | number | 6 | 최대 파트 개수 | -| `readonly` | boolean | false | 읽기 전용 모드 | -| `showPreview` | boolean | true | 미리보기 표시 | -| `showRuleList` | boolean | true | 규칙 목록 표시 | -| `cardLayout` | "vertical" \| "horizontal" | "vertical" | 카드 배치 방향 | - -## 사용 예시 - -```typescript - -``` - -## 데이터베이스 구조 - -### numbering_rules (마스터 테이블) -- 규칙 ID, 규칙명, 구분자 -- 초기화 주기, 현재 시퀀스 -- 적용 대상 테이블/컬럼 - -### numbering_rule_parts (파트 테이블) -- 파트 순서, 파트 유형 -- 생성 방식, 설정 (JSONB) - -## API 엔드포인트 - -- `GET /api/numbering-rules` - 규칙 목록 조회 -- `POST /api/numbering-rules` - 규칙 생성 -- `PUT /api/numbering-rules/:ruleId` - 규칙 수정 -- `DELETE /api/numbering-rules/:ruleId` - 규칙 삭제 -- `POST /api/numbering-rules/:ruleId/generate` - 코드 생성 - -## 버전 정보 - -- **버전**: 1.0.0 -- **작성일**: 2025-11-04 -- **작성자**: 개발팀 - diff --git a/frontend/lib/registry/components/numbering-rule/config.ts b/frontend/lib/registry/components/numbering-rule/config.ts deleted file mode 100644 index 87e5c996..00000000 --- a/frontend/lib/registry/components/numbering-rule/config.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * 채번 규칙 컴포넌트 기본 설정 - */ - -import { NumberingRuleComponentConfig } from "./types"; - -export const defaultConfig: NumberingRuleComponentConfig = { - maxRules: 6, - readonly: false, - showPreview: true, - showRuleList: true, - enableReorder: false, - cardLayout: "vertical", -}; - diff --git a/frontend/lib/registry/components/numbering-rule/index.ts b/frontend/lib/registry/components/numbering-rule/index.ts deleted file mode 100644 index cb88af9f..00000000 --- a/frontend/lib/registry/components/numbering-rule/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import React from "react"; -import { createComponentDefinition } from "../../utils/createComponentDefinition"; -import { ComponentCategory } from "@/types/component"; -import { NumberingRuleWrapper } from "./NumberingRuleComponent"; -import { NumberingRuleConfigPanel } from "./NumberingRuleConfigPanel"; -import { defaultConfig } from "./config"; - -/** - * 채번 규칙 컴포넌트 정의 - * 코드 자동 채번 규칙을 설정하고 관리하는 관리자 전용 컴포넌트 - */ -export const NumberingRuleDefinition = createComponentDefinition({ - id: "numbering-rule", - name: "코드 채번 규칙", - name_eng: "Numbering Rule Component", - description: "코드 자동 채번 규칙을 설정하고 관리하는 컴포넌트", - category: ComponentCategory.DISPLAY, - web_type: "component", - component: NumberingRuleWrapper, - default_config: defaultConfig, - default_size: { - width: 1200, - height: 800, - }, - config_panel: NumberingRuleConfigPanel, - icon: "Hash", - tags: ["코드", "채번", "규칙", "표시", "자동생성"], - version: "1.0.0", - author: "개발팀", - documentation: "코드 자동 채번 규칙을 설정합니다. 접두사, 날짜, 순번 등을 조합하여 고유한 코드를 생성할 수 있습니다.", - // hidden: true, // v2-numbering-rule 사용으로 패널에서 숨김 -}); - -// 타입 내보내기 -export type { NumberingRuleComponentConfig } from "./types"; - -// 컴포넌트 내보내기 -export { NumberingRuleComponent } from "./NumberingRuleComponent"; -export { NumberingRuleRenderer } from "./NumberingRuleRenderer"; - diff --git a/frontend/lib/registry/components/numbering-rule/types.ts b/frontend/lib/registry/components/numbering-rule/types.ts deleted file mode 100644 index 43def2cb..00000000 --- a/frontend/lib/registry/components/numbering-rule/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * 채번 규칙 컴포넌트 타입 정의 - */ - -import { NumberingRuleConfig } from "@/types/numbering-rule"; - -export interface NumberingRuleComponentConfig { - ruleConfig?: NumberingRuleConfig; - maxRules?: number; - readonly?: boolean; - showPreview?: boolean; - showRuleList?: boolean; - enableReorder?: boolean; - cardLayout?: "vertical" | "horizontal"; -} diff --git a/frontend/lib/registry/components/v2-numbering-rule/NumberingRuleComponent.tsx b/frontend/lib/registry/components/v2-numbering-rule/NumberingRuleComponent.tsx deleted file mode 100644 index 6f1048f9..00000000 --- a/frontend/lib/registry/components/v2-numbering-rule/NumberingRuleComponent.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import React from "react"; -import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner"; -import { NumberingRuleComponentConfig } from "./types"; - -interface NumberingRuleWrapperProps { - config: NumberingRuleComponentConfig; - onChange?: (config: NumberingRuleComponentConfig) => void; - isPreview?: boolean; - tableName?: string; // 현재 화면의 테이블명 - menuObjid?: number; // 🆕 메뉴 OBJID -} - -export const NumberingRuleWrapper: React.FC = ({ - config, - onChange, - isPreview = false, - tableName, - menuObjid, -}) => { - console.log("📋 NumberingRuleWrapper: 테이블명 + menuObjid 전달", { - tableName, - menuObjid, - hasMenuObjid: !!menuObjid, - config - }); - - return ( -
- -
- ); -}; - -export const NumberingRuleComponent = NumberingRuleWrapper; diff --git a/frontend/lib/registry/components/v2-numbering-rule/NumberingRuleConfigPanel.tsx b/frontend/lib/registry/components/v2-numbering-rule/NumberingRuleConfigPanel.tsx deleted file mode 100644 index 332d4055..00000000 --- a/frontend/lib/registry/components/v2-numbering-rule/NumberingRuleConfigPanel.tsx +++ /dev/null @@ -1,105 +0,0 @@ -"use client"; - -import React from "react"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { Switch } from "@/components/ui/switch"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { NumberingRuleComponentConfig } from "./types"; - -interface NumberingRuleConfigPanelProps { - config: NumberingRuleComponentConfig; - onChange: (config: NumberingRuleComponentConfig) => void; -} - -export const NumberingRuleConfigPanel: React.FC = ({ - config, - onChange, -}) => { - return ( -
-
- - - onChange({ ...config, maxRules: parseInt(e.target.value) || 6 }) - } - className="h-9" - /> -

- 한 규칙에 추가할 수 있는 최대 파트 개수 (1-10) -

-
- -
-
- -

- 편집 기능을 비활성화합니다 -

-
- - onChange({ ...config, readonly: checked }) - } - /> -
- -
-
- -

- 코드 미리보기를 항상 표시합니다 -

-
- - onChange({ ...config, showPreview: checked }) - } - /> -
- -
-
- -

- 저장된 규칙 목록을 표시합니다 -

-
- - onChange({ ...config, showRuleList: checked }) - } - /> -
- -
- - -

- 규칙 파트 카드의 배치 방향 -

-
-
- ); -}; diff --git a/frontend/lib/registry/components/v2-numbering-rule/NumberingRuleRenderer.tsx b/frontend/lib/registry/components/v2-numbering-rule/NumberingRuleRenderer.tsx deleted file mode 100644 index 21964818..00000000 --- a/frontend/lib/registry/components/v2-numbering-rule/NumberingRuleRenderer.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import React from "react"; -import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; -import { V2NumberingRuleDefinition } from "./index"; -import { NumberingRuleComponent } from "./NumberingRuleComponent"; - -/** - * 채번 규칙 렌더러 - * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 - */ -export class NumberingRuleRenderer extends AutoRegisteringComponentRenderer { - static componentDefinition = V2NumberingRuleDefinition; - - render(): React.ReactElement { - return ; - } - - /** - * 채번 규칙 컴포넌트 특화 메서드 - */ - protected handleValueChange = (value: any) => { - this.updateComponent({ value }); - }; -} - -// 자동 등록 실행 -NumberingRuleRenderer.registerSelf(); - -// Hot Reload 지원 (개발 모드) -if (process.env.NODE_ENV === "development") { - NumberingRuleRenderer.enableHotReload(); -} diff --git a/frontend/lib/registry/components/v2-numbering-rule/README.md b/frontend/lib/registry/components/v2-numbering-rule/README.md deleted file mode 100644 index 5d04d894..00000000 --- a/frontend/lib/registry/components/v2-numbering-rule/README.md +++ /dev/null @@ -1,102 +0,0 @@ -# 코드 채번 규칙 컴포넌트 - -## 개요 - -시스템에서 자동으로 코드를 생성하는 규칙을 설정하고 관리하는 관리자 전용 컴포넌트입니다. - -## 주요 기능 - -- **좌우 분할 레이아웃**: 좌측에서 규칙 목록, 우측에서 편집 -- **동적 파트 시스템**: 최대 6개의 파트를 자유롭게 조합 -- **실시간 미리보기**: 설정 즉시 생성될 코드 확인 -- **다양한 파트 유형**: 접두사, 순번, 날짜, 연도, 월, 커스텀 - -## 생성 코드 예시 - -- 제품 코드: `PROD-20251104-0001` -- 프로젝트 코드: `PRJ-2025-001` -- 거래처 코드: `CUST-A-0001` - -## 파트 유형 - -### 1. 접두사 (prefix) -고정된 문자열을 코드 앞에 추가합니다. -- 예: `PROD`, `PRJ`, `CUST` - -### 2. 순번 (sequence) -자동으로 증가하는 번호를 생성합니다. -- 자릿수 설정 가능 (1-10) -- 시작 번호 설정 가능 -- 예: `0001`, `00001` - -### 3. 날짜 (date) -현재 날짜를 다양한 형식으로 추가합니다. -- YYYY: 2025 -- YYYYMMDD: 20251104 -- YYMMDD: 251104 - -### 4. 연도 (year) -현재 연도를 추가합니다. -- YYYY: 2025 -- YY: 25 - -### 5. 월 (month) -현재 월을 2자리로 추가합니다. -- 예: 01, 02, ..., 12 - -### 6. 사용자 정의 (custom) -원하는 값을 직접 입력합니다. - -## 생성 방식 - -### 자동 생성 (auto) -시스템이 자동으로 값을 생성합니다. - -### 직접 입력 (manual) -사용자가 값을 직접 입력합니다. - -## 설정 옵션 - -| 옵션 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| `maxRules` | number | 6 | 최대 파트 개수 | -| `readonly` | boolean | false | 읽기 전용 모드 | -| `showPreview` | boolean | true | 미리보기 표시 | -| `showRuleList` | boolean | true | 규칙 목록 표시 | -| `cardLayout` | "vertical" \| "horizontal" | "vertical" | 카드 배치 방향 | - -## 사용 예시 - -```typescript - -``` - -## 데이터베이스 구조 - -### numbering_rules (마스터 테이블) -- 규칙 ID, 규칙명, 구분자 -- 초기화 주기, 현재 시퀀스 -- 적용 대상 테이블/컬럼 - -### numbering_rule_parts (파트 테이블) -- 파트 순서, 파트 유형 -- 생성 방식, 설정 (JSONB) - -## API 엔드포인트 - -- `GET /api/numbering-rules` - 규칙 목록 조회 -- `POST /api/numbering-rules` - 규칙 생성 -- `PUT /api/numbering-rules/:ruleId` - 규칙 수정 -- `DELETE /api/numbering-rules/:ruleId` - 규칙 삭제 -- `POST /api/numbering-rules/:ruleId/generate` - 코드 생성 - -## 버전 정보 - -- **버전**: 1.0.0 -- **작성일**: 2025-11-04 -- **작성자**: 개발팀 - diff --git a/frontend/lib/registry/components/v2-numbering-rule/config.ts b/frontend/lib/registry/components/v2-numbering-rule/config.ts deleted file mode 100644 index 87e5c996..00000000 --- a/frontend/lib/registry/components/v2-numbering-rule/config.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * 채번 규칙 컴포넌트 기본 설정 - */ - -import { NumberingRuleComponentConfig } from "./types"; - -export const defaultConfig: NumberingRuleComponentConfig = { - maxRules: 6, - readonly: false, - showPreview: true, - showRuleList: true, - enableReorder: false, - cardLayout: "vertical", -}; - diff --git a/frontend/lib/registry/components/v2-numbering-rule/index.ts b/frontend/lib/registry/components/v2-numbering-rule/index.ts deleted file mode 100644 index 286c3b44..00000000 --- a/frontend/lib/registry/components/v2-numbering-rule/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import React from "react"; -import { createComponentDefinition } from "../../utils/createComponentDefinition"; -import { ComponentCategory } from "@/types/component"; -import { NumberingRuleWrapper } from "./NumberingRuleComponent"; -import { V2NumberingRuleConfigPanel } from "@/components/v2/config-panels/V2NumberingRuleConfigPanel"; -import { defaultConfig } from "./config"; - -/** - * 채번 규칙 컴포넌트 정의 - * 코드 자동 채번 규칙을 설정하고 관리하는 관리자 전용 컴포넌트 - */ -export const V2NumberingRuleDefinition = createComponentDefinition({ - id: "v2-numbering-rule", - hidden: true, // Phase E: 통합 컴포넌트로 대체됨 - name: "코드 채번 규칙", - name_eng: "Numbering Rule Component", - description: "코드 자동 채번 규칙을 설정하고 관리하는 컴포넌트", - category: ComponentCategory.DISPLAY, - web_type: "component", - component: NumberingRuleWrapper, - default_config: defaultConfig, - default_size: { - width: 1200, - height: 800, - }, - config_panel: V2NumberingRuleConfigPanel, - icon: "Hash", - tags: ["코드", "채번", "규칙", "표시", "자동생성"], - version: "1.0.0", - author: "개발팀", - documentation: "코드 자동 채번 규칙을 설정합니다. 접두사, 날짜, 순번 등을 조합하여 고유한 코드를 생성할 수 있습니다.", -}); - -// 타입 내보내기 -export type { NumberingRuleComponentConfig } from "./types"; - -// 컴포넌트 내보내기 -export { NumberingRuleComponent } from "./NumberingRuleComponent"; -export { NumberingRuleRenderer } from "./NumberingRuleRenderer"; - diff --git a/frontend/lib/registry/components/v2-numbering-rule/types.ts b/frontend/lib/registry/components/v2-numbering-rule/types.ts deleted file mode 100644 index 43def2cb..00000000 --- a/frontend/lib/registry/components/v2-numbering-rule/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * 채번 규칙 컴포넌트 타입 정의 - */ - -import { NumberingRuleConfig } from "@/types/numbering-rule"; - -export interface NumberingRuleComponentConfig { - ruleConfig?: NumberingRuleConfig; - maxRules?: number; - readonly?: boolean; - showPreview?: boolean; - showRuleList?: boolean; - enableReorder?: boolean; - cardLayout?: "vertical" | "horizontal"; -} diff --git a/frontend/lib/utils/getComponentConfigPanel.tsx b/frontend/lib/utils/getComponentConfigPanel.tsx index 170c1e2b..aaf05278 100644 --- a/frontend/lib/utils/getComponentConfigPanel.tsx +++ b/frontend/lib/utils/getComponentConfigPanel.tsx @@ -110,8 +110,7 @@ const CONFIG_PANEL_MAP: Record Promise> = { "v2-rack-structure": () => import("@/lib/registry/components/domain/v2-rack-structure/RackStructureConfigPanel"), "aggregation-widget": () => import("@/lib/registry/components/aggregation-widget/AggregationWidgetConfigPanel"), "v2-aggregation-widget": () => import("@/lib/registry/components/v2-aggregation-widget/AggregationWidgetConfigPanel"), - "numbering-rule": () => import("@/lib/registry/components/numbering-rule/NumberingRuleConfigPanel"), - "v2-numbering-rule": () => import("@/lib/registry/components/v2-numbering-rule/NumberingRuleConfigPanel"), + // numbering-rule / v2-numbering-rule: 폐기 (2026-05-11). admin 페이지 /admin/systemMng/numberingRuleList 로 대체 "category-manager": () => import("@/lib/registry/components/category-manager/CategoryManagerConfigPanel"), "universal-form-modal": () => import("@/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel"), "v2-process-work-standard": () => import("@/lib/registry/components/domain/v2-process-work-standard/ProcessWorkStandardConfigPanel"), diff --git a/frontend/lib/utils/webTypeMapping.ts b/frontend/lib/utils/webTypeMapping.ts index 795a0577..e6ce00cd 100644 --- a/frontend/lib/utils/webTypeMapping.ts +++ b/frontend/lib/utils/webTypeMapping.ts @@ -17,40 +17,40 @@ export interface V2ComponentMapping { * 웹타입 → V2 컴포넌트 매핑 테이블 */ export const WEB_TYPE_V2_MAPPING: Record = { - // 텍스트 입력 계열 → V2Input + // 텍스트 입력 계열 → InputComponent (InvField canonical) text: { - componentType: "v2-input", - config: { inputType: "text", format: "none" }, + componentType: "input", + config: { kind: "input", type: "text", format: "free" }, }, email: { - componentType: "v2-input", - config: { inputType: "text", format: "email" }, + componentType: "input", + config: { kind: "input", type: "text", format: "email" }, }, password: { - componentType: "v2-input", - config: { inputType: "password" }, + componentType: "input", + config: { kind: "input", type: "text", format: "password" }, }, tel: { - componentType: "v2-input", - config: { inputType: "text", format: "tel" }, + componentType: "input", + config: { kind: "input", type: "text", format: "phone" }, }, url: { - componentType: "v2-input", - config: { inputType: "text", format: "url" }, + componentType: "input", + config: { kind: "input", type: "text", format: "url" }, }, textarea: { - componentType: "v2-input", - config: { inputType: "textarea", rows: 3 }, + componentType: "input", + config: { kind: "input", type: "textarea", format: "free", rows: 3 }, }, - // 숫자 입력 → V2Input + // 숫자 입력 → InputComponent number: { - componentType: "v2-input", - config: { inputType: "number" }, + componentType: "input", + config: { kind: "input", type: "number", format: "int" }, }, decimal: { - componentType: "v2-input", - config: { inputType: "number", step: 0.01 }, + componentType: "input", + config: { kind: "input", type: "number", format: "decimal", step: 0.01 }, }, // 날짜/시간 → InputComponent (type=date/datetime/time/daterange) @@ -71,14 +71,14 @@ export const WEB_TYPE_V2_MAPPING: Record = { config: { type: "daterange", dateFormat: "YYYY-MM-DD" }, }, - // 선택 입력 → V2Select + // 선택 입력 (single dropdown) → InputComponent (InvField canonical) select: { - componentType: "v2-select", - config: { mode: "dropdown", source: "static", options: [] }, + componentType: "input", + config: { kind: "choice", type: "single", format: "list", options: [] }, }, dropdown: { - componentType: "v2-select", - config: { mode: "dropdown", source: "static", options: [] }, + componentType: "input", + config: { kind: "choice", type: "single", format: "list", options: [] }, }, radio: { componentType: "v2-select", @@ -131,10 +131,10 @@ export const WEB_TYPE_V2_MAPPING: Record = { config: {}, }, - // 라벨/텍스트 표시 → V2Input (readonly) + // 라벨/텍스트 표시 → InputComponent (readonly) label: { - componentType: "v2-input", - config: { inputType: "text", readonly: true }, + componentType: "input", + config: { kind: "input", type: "text", format: "free", readonly: true }, }, }; @@ -142,31 +142,34 @@ export const WEB_TYPE_V2_MAPPING: Record = { * 레거시 매핑 테이블 (하위 호환성) */ export const WEB_TYPE_COMPONENT_MAPPING: Record = { - text: "v2-input", - email: "v2-input", - password: "v2-input", - tel: "v2-input", - url: "v2-input", - number: "v2-input", - decimal: "v2-input", - textarea: "v2-input", + text: "input", + email: "input", + password: "input", + tel: "input", + url: "input", + number: "input", + decimal: "input", + textarea: "input", date: "input", datetime: "input", time: "input", daterange: "input", - select: "v2-select", - dropdown: "v2-select", + // select 계열은 Phase B (select-pickers 모듈 도입 후) 에서 input 통합 + // single dropdown → input (Phase B.1 완료). multi/category/entity 는 Phase B.2~ + select: "input", + dropdown: "input", checkbox: "v2-select", radio: "v2-select", boolean: "v2-select", code: "v2-select", entity: "v2-select", category: "v2-select", + // file 은 Phase B 후 input 통합 file: "v2-file-upload", image: "v2-file-upload", img: "v2-file-upload", button: "button-primary", - label: "v2-input", + label: "input", }; /** @@ -176,11 +179,10 @@ export function getComponentIdFromWebType(webType: string): string { const mapping = WEB_TYPE_V2_MAPPING[webType]; if (!mapping) { - console.warn(`웹타입 "${webType}"에 대한 V2 매핑을 찾을 수 없습니다. 기본값 'v2-input' 사용`); - return "v2-input"; + console.warn(`웹타입 "${webType}"에 대한 매핑을 찾을 수 없습니다. 기본값 'input' 사용`); + return "input"; } - console.log(`웹타입 "${webType}" → V2 컴포넌트 "${mapping.componentType}" 매핑`); return mapping.componentType; } @@ -206,8 +208,8 @@ export function getV2MappingFromWebType(webType: string): V2ComponentMapping { if (!mapping) { return { - componentType: "v2-input", - config: { inputType: "text" }, + componentType: "input", + config: { kind: "input", type: "text", format: "free" }, }; } diff --git a/notes/gbpark/2026-05-08-input-canonical-migration.md b/notes/gbpark/2026-05-08-input-canonical-migration.md new file mode 100644 index 00000000..b57389c3 --- /dev/null +++ b/notes/gbpark/2026-05-08-input-canonical-migration.md @@ -0,0 +1,454 @@ +# INVYONE Input 통합 — Canonical 마이그레이션 진행 노트 + +날짜: 2026-05-07 ~ 2026-05-11 +작업자: gbpark +컨텍스트: 인비원스튜디오의 입력 컴포넌트 (input / v2-input / v2-select / 옛 6개) 가 분산되어 외형·모델 들쭉날쭉. **canonical 1안 (InvFieldConfigPanel + InputComponent)** 으로 통합 중. + +--- + +## 0. 핵심 원칙 + +- INVYONE = VEX 의 2세대 리뉴얼. **운영 단계 아님** → 옛 키 fallback / 호환 부담 X. 깨끗한 canonical 1안. +- 옛것이 남아있으면 통합 아님. **1 패널 1 컴포넌트**. +- GPT-5.5 (codex:rescue) 와 단계마다 교차 검증. + +## 1. 큰 그림 (목표 형태) + +``` +[8 통합 컴포넌트] = [8 단일 ConfigPanel + 단일 캔버스 컴포넌트] + input → InvFieldConfigPanel + InputComponent (text/number/money/date/single/multi/autonum/formula/audit/file) + table → InvTableConfigPanel + search · button · title · divider · stats · container + +[옛 V2 / 옛 6개 컴포넌트] → 기능 이식 후 폐기 + V2Input.tsx (1286줄) + V2Select.tsx (1350줄) + date-input / text-input / number-input / select-basic / checkbox-basic / textarea-basic 의 자체 캔버스 컴포넌트 6개 +``` + +--- + +## 2. 완료 작업 + +### Phase 1 — ConfigPanel 통합 (이전 세션 + 일부 본 세션) + +- ✅ `InvFieldConfigPanel` 의 brumb (4 kinds × 10 types × 32 formats) 그대로 canonical +- ✅ `resolveTriple` 에 옛 6개 컴포넌트 ID → triple default 분기 추가 +- ✅ 옛 6개 `index.ts` — config_panel: InvFieldConfigPanel + default_config 에 `{kind, type, format}` triple 추가 +- ✅ 신규 `input/index.ts` — config_panel: InvFieldConfigPanel + default_config triple +- ✅ `CONFIG_PANEL_MAP["input"]` → InvFieldConfigPanel + +### Phase 2 — applyTriple canonical cleanup + +- ✅ `TYPE_VOLATILE_FIELDS` 상수 + `clearVolatileFields` 함수 +- ✅ text/number/money/date/choice 분기 자기 필드만 set, 잔재 일괄 reset +- ✅ formula `next.computed = prev.computed || ""` 패치 (분기 순환 시 사용자 수식 보존) +- ✅ auto/attach 분기 redundant `next.source = undefined` 제거 + +### Phase 3 — 캔버스 라우팅 통일 + +- ✅ `DynamicComponentRenderer.tsx:418-453` 의 fieldType / dbInputType → v2-input/v2-select **강제 swap 분기 통째 제거** +- ✅ `webTypeMapping.ts` 의 text 계열 9개 (text/email/password/tel/url/textarea/number/decimal/label) → `input` +- ✅ 기본 fallback 도 `v2-input` → `input` + +### Phase 4 — InputComponent 외형 통일 + +- ✅ container 가 input box 역할 (border + radius + bg, padding 0) +- ✅ `baseInputStyle` 에서 자체 border 제거 + transparent (이중 박스 해소) +- ✅ `inputSlotStyle` (flex:1, width/height 100%) wrapper — 모든 type 의 위젯 박스 가득 +- ✅ label position: absolute, top: -18 (박스 바깥 위 — V2 스타일) +- ✅ entity outer div / button height 100% + flexShrink:0 +- ✅ text 분기에서 format 별 native input type 분기 (password/email/tel/url) + +### Phase 5 — pickers 통일 (date 계열) + +- ✅ `SingleDatePicker` / `DateTimePicker` / `TimePicker` / `RangeDatePicker` className prop 전파 → 자체 border 제거 +- ✅ `DateTimePicker` 의 sub-picker (SingleDatePicker + TimePicker) 에 className 전파 +- ✅ `DateTimePicker` gap-2 → 0 + 가운데 1px divider +- ✅ `TimePicker` 의 shadcn `Input` → raw `` (default class 회피) +- ✅ `RangeCalendarPopover` className prop 받음 + RangeDatePicker 가 sub 에 전달 +- ✅ `RangeDatePicker` gap-2 → 0 + +### Phase A.5 — 자동생성 hook + +- ✅ `InputComponent` 에 useEffect — `autoGeneration.enabled` 시 `AutoGenerationUtils.generateValue` 호출 +- ✅ 조건: 디자인 모드 X + 값 비어있을 때만 trigger +- ✅ 처리 type: uuid / current_user / current_time / sequence / random_string / random_number / company_code / department +- ✅ `numbering_rule` 은 별도 (Phase A.6 에서) + +### Phase B.1 — select-pickers 모듈 시작 + +- ✅ `frontend/lib/registry/components/input/select-pickers.tsx` 신규 +- ✅ `SingleSelectPicker` — Custom Popover dropdown + 검색 + allowClear + 외부 클릭 닫기 + ESC +- ✅ InputComponent select 분기 → SingleSelectPicker 사용 (native `` → SingleSelectPicker | OS 기본 dropdown 통일 어려움 + V2Select 풍부 기능 흡수 | +| `TYPE_VOLATILE_FIELDS` reset 패턴 | 명시 cleanup 보다 유지보수성 + 새 type 자동 일관 | +| label position: absolute (박스 바깥) | V2 스타일 / 사용자 의도 | +| 옛 데이터 마이그 X | 운영 단계 아님 (사용자 명시) | + +--- + +## 7. 다음 세션 진입점 + +### 우선순위 + +1. **B.4.5 — SwapPicker** (큰) + - multi + mode=swap + - 양쪽 list (선택 가능 / 선택됨) + 이동 버튼 + - V2Select.tsx:530~ 참고 + +2. **B.5 — option loader** (중) + - api / code / category / distinct / 계층 + - V2Select 의 option 로딩 로직 이식 + - apiClient 의존 + +3. **C — entity 검색팝업 + auto-fill** (큰) + - V2Select.tsx:1007~ 의 entity FK 검색 + - 다른 컬럼 auto-fill (조인) + - 현재 InputComponent entity 분기 = placeholder 버튼만 + +4. **D — V2 폐기** (위 1~3 완료 후) + - webTypeMapping 의 select 계열 (multi/category/entity/code/checkbox/radio/boolean) → input + - webTypeMapping 의 file/image/img → input (file 통합 후) + - `ScreenSettingModal.tsx:2052-2066` 의 옛 컴포넌트 생성 경로 → input + - DB 마이그 — `templates.views` JSON 안 componentType / url 변경 (사용자 승인 필요) + - `V2Input.tsx` / `V2Select.tsx` 파일 삭제 + - `registerV2Components.ts` 의 v2-input / v2-select 등록 제거 + - `V2InputConfigPanel.tsx` (832줄) 삭제 + +5. **E — 옛 6개 폐기** + - date-input / text-input / number-input / select-basic / checkbox-basic / textarea-basic 의 캔버스 컴포넌트 (`DateInputComponent.tsx` 등) 폐기 + - 6 폴더 자체 삭제 (ScreenSettingModal 의 생성 경로 검증 후) + +### 새 세션 진입 전 준비 + +- ✅ defaultValue 동작 검증 완료 — form-popup 수정 모달에서 default 적용 됨 +- 이전에 한 변경들은 form-popup 의 BlockRenderer hijack 으로 동작 검증이 어려웠음. 이제 가능 +- ⏳ Phase B.4.5 / B.5 / C 중 어디부터 진행할지 결정 — 사용자 의도 (사진 확인 가능한 것 우선) 따라 + +### 핵심 사실 (새 세션이 알아야 할 것) + +1. **INVYONE 스튜디오 = templates 모드** (`template_id` 가 있는 경우 `saveTemplate` 호출, `saveLayoutV2` 아님) +2. **운영 모드 form-popup** (`/form-popup?templateId=...`) 가 `getTemplateInfo` → PopupTemplateRenderer → BlockRenderer → InputComponent 경로 +3. **edit 뷰 컴포넌트** 가 운영에서 보임 (수정 모달) +4. **BlockRenderer.tsx:54-57** 의 runtimeConfig 가 columnName 기반 defaultValue hijack — fix 적용됨 +5. 임시 console.log 모두 제거됨 (saveTemplate payload 의 일반 진단 로그만 1줄 유지) diff --git a/notes/gbpark/2026-05-11-numbering-rule-mockup.html b/notes/gbpark/2026-05-11-numbering-rule-mockup.html new file mode 100644 index 00000000..931a7069 --- /dev/null +++ b/notes/gbpark/2026-05-11-numbering-rule-mockup.html @@ -0,0 +1,1003 @@ + + + + +채번 관리 — 테이블 아코디언 + + + + +
+ + +
+ 채번 관리 + / 관리자 / 시스템관리 + + + PUSAN ERP + + +
+ + +
+ + +
+
+ + +
+ 활성만 22 + 충돌 1 + 비활성 2 +
+ + + · + 8 테이블 / 24 규칙 +
+
+ + +
+ + +
+
+ + +
+ SO_HEADER + 수주 헤더 · last allocate 14:32 +
+ 규칙 4 + + + + + + + + + + + 오늘 + 81 건+18% + + 14:32 + + +
+
+
+
+
+
+
+ SO_NO·RULE-SO-001 +
+
수주번호 자동발번
+
SO-2026-05-1235
+
+ seq 1234 1235 + 월별 +
+
+
+
+
+
+ 73건 +18% +
+
+
+ +
+
+ SO_SUB_CD·RULE-SO-002 +
+
수주 부속코드
+
SUB-088
+
+ seq 87 88 + 없음 +
+
+
+
+
+
+ 8건 ±0% +
+
+
+ +
+
+ SO_EXT_REF·충돌 1건 +
+
수주 외부참조
+
EXT-2026-05-1202
+
+ seq 1201 1202 + 월별 +
+
+
+
+
+
+ 14:18 ⚠ +
+
+
+ +
+
+ SO_RT_NO·비활성 +
+
수주반품번호
+
RT-20260511-013
+
+ seq 12 + 일별 +
+
+
+
+
+
+
+ + +
+
+ + +
+ PO_HEADER + 발주 헤더 · last allocate 14:31 +
+ 규칙 3 + + + + + + + + 오늘 + 5 건+5% + + 14:31 + + +
+
+
+
+
+
+
PO_NO
+
발주번호 자동발번
+
PO-2026-046
+
+ seq 45 46 + 연도별 +
+
+
+
PO_EXT_REF
+
발주 외부참조
+
PE-2026-0024
+
+ seq 23 24 + 연도별 +
+
+ +
+
+
+
+
+ + +
+
+ + +
+ SHIPMENT + 출고 · last allocate 14:30 +
+ 규칙 2 + + + + + + + + 오늘 + 15 건+3% + + 14:30 + + +
+
+
+
+
+
+
SHIP_NO
+
출고번호
+
SH-008902
+
+ seq 8,901 8,902 + 없음 +
+
+
+
SHIP_LOT_NO
+
출고 LOT 번호
+
LOT-20260511-04
+
+ seq 3 4 + 일별 +
+
+
+
+
+
+
+ + +
+
+ + +
+ QUOTE + 견적 · last allocate 13:48 +
+ 규칙 3 + + + + + + + + 오늘 + 3 건±0% + + 13:48 + + +
+
+
+ + +
+
+ + +
+ INVOICE + 세금계산서 · last allocate 14:29 +
+ 규칙 8 + + + + + + + + 오늘 + 62 건+12% + + 14:29 + + +
+
+
+ + +
+
+ + +
+ MFG_ORDER + 생산오더 · 모두 비활성 +
+ 규칙 4 + + + + + + + + 오늘 + 0 건 + + + + +
+
+
+ + +
+ +
+ +
+ +
+ 8 테이블 / 24 규칙 · 활성 22 · 충돌 1 · 비활성 2 + last sync · 2026-05-11 16:32 +
+ +
+ + + + + + + From 90035dd5c63036c6f9070b37344fb4eaf26696ab Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 12 May 2026 12:06:28 +0900 Subject: [PATCH 02/27] =?UTF-8?q?feat(numbering):=20=EC=B1=84=EB=B2=88=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=86=B5?= =?UTF-8?q?=EC=A7=9C=20=EC=9E=91=EC=97=85=EB=8C=80=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 채번 = 독립 자원 (컬럼과 N:M 연결) 모델로 페이지 통째 리뉴얼. 카드 박스 폐기, 좌측 sidebar + 우측 통짜 main + ⌘K 명령 팔레트 구조. - frontend/styles/v5-layout.css: v5-nrm-* 섹션 추가 (메뉴관리 v5-mm-* 동일 패턴) - numberingRuleList/page.tsx: PageHead 안 쓰고 메뉴관리 스타일 단순 헤더 + 사이드바 (검색·필터·섹션·list) + Hero·파이프라인·2-col split·sticky save bar + ⌘K 팔레트 (검색·프리셋·기존 채번) - 기존 NumberingRuleDesigner/SequenceManagementPanel/CreateDialog 의 로직을 page.tsx 내부 sub-component (PipelineBlock·PartInspector·UsageList·CommandPalette) 로 재구성 - mockup HTML 산출물 notes/gbpark/ 보관 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../systemMng/numberingRuleList/page.tsx | 1428 ++++++++++-- frontend/styles/v5-layout.css | 282 +++ .../2026-05-12-numbering-rule-mockup-v2.html | 1954 ++++++++++++++++ .../2026-05-12-numbering-workbench.html | 1963 +++++++++++++++++ 4 files changed, 5433 insertions(+), 194 deletions(-) create mode 100644 notes/gbpark/2026-05-12-numbering-rule-mockup-v2.html create mode 100644 notes/gbpark/2026-05-12-numbering-workbench.html diff --git a/frontend/app/(main)/admin/systemMng/numberingRuleList/page.tsx b/frontend/app/(main)/admin/systemMng/numberingRuleList/page.tsx index a419e7e7..15a6b6c7 100644 --- a/frontend/app/(main)/admin/systemMng/numberingRuleList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/numberingRuleList/page.tsx @@ -1,44 +1,81 @@ "use client"; -import { useState, useEffect, useMemo, useCallback } from "react"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { Search, Hash, Plus, RefreshCw, Trash2, Loader2, Table2 } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { toast } from "sonner"; +import { + Search, Hash, Plus, RefreshCw, Trash2, Save, RotateCcw, Loader2, + Table2, Copy, Download, Share2, AlertTriangle, ArrowRight, Edit3, + Calendar, Type, Hash as HashIcon, Link2, Code, Minus, Layers, + CheckCircle, Eye, Upload, LayoutGrid, X, Sparkles, +} from "lucide-react"; import { showErrorToast } from "@/lib/utils/toastUtils"; import { getNumberingRulesFromTest, + getNumberingRuleById, deleteNumberingRuleFromTest, + resetSequence, + updateRuleSequence, + saveNumberingRuleToTest, } from "@/lib/api/numberingRule"; -import { NumberingRuleConfig } from "@/types/numbering-rule"; -import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner"; -import { SequenceManagementPanel } from "@/components/numbering-rule/SequenceManagementPanel"; -import { NumberingRuleCreateDialog } from "@/components/numbering-rule/NumberingRuleCreateDialog"; +import { + NumberingRuleConfig, + NumberingRulePart, + CodePartType, + DATE_FORMAT_OPTIONS, + RESET_PERIOD_OPTIONS, +} from "@/types/numbering-rule"; + +/* =================================================================== + 채번 관리 — 통짜 작업대 (좌측 list + 우측 디테일 + ⌘K 팔레트) + 디자인: notes/gbpark/2026-05-12-numbering-workbench.html + 공통 CSS: frontend/styles/v5-layout.css (v5-nrm-* 클래스) + =================================================================== */ + +type ResetPeriod = "none" | "daily" | "monthly" | "yearly"; +type SideFilter = "all" | "active" | "warn" | "unused"; + +const PART_TONE_BY_TYPE: Record = { + text: "text", // prefix + date: "date", + sequence: "sequence", + number: "number", + category: "category", + reference: "reference", +}; + +const PART_LABEL_BY_TYPE: Record = { + text: "TEXT", + date: "DATE", + sequence: "SEQ", + number: "NUM", + category: "CAT", + reference: "REF", +}; + +const RESET_LABEL: Record = { + none: "없음", + daily: "일", + monthly: "월", + yearly: "년", +}; -/** - * 채번 관리 (admin) - * - * VEX 에서 캔버스 컴포넌트로 처리하던 채번 관리를 별도 admin 페이지로 분리. - * - 좌측: 회사별 채번 규칙 목록 (테이블별 그룹화 + 검색) - * - 우측 ① : NumberingRuleDesigner (규칙 파트/구분자/리셋주기) - * - 우측 ② : SequenceManagementPanel (current_sequence 직접 수정 / reset / 미리보기) - * - * 관련 노트: notes/gbpark/2026-05-08-input-canonical-migration.md §A.8 - */ export default function NumberingRuleManagementPage() { const [rules, setRules] = useState([]); const [loading, setLoading] = useState(false); const [selectedRuleId, setSelectedRuleId] = useState(null); const [search, setSearch] = useState(""); - const [createOpen, setCreateOpen] = useState(false); + const [filter, setFilter] = useState("all"); + const [selectedPartOrder, setSelectedPartOrder] = useState(null); + const [cmdkOpen, setCmdkOpen] = useState(false); const [refreshKey, setRefreshKey] = useState(0); - const selectedRule = useMemo( - () => rules.find((r) => String(r.rule_id) === selectedRuleId) ?? null, - [rules, selectedRuleId], - ); + // selected rule (편집 중 상태) + const [editingRule, setEditingRule] = useState(null); + const [dirty, setDirty] = useState(false); + const [draftSequence, setDraftSequence] = useState(""); + const [mutating, setMutating] = useState(false); + // 회사별 채번 목록 로드 const loadRules = useCallback(async () => { setLoading(true); try { @@ -61,215 +98,1218 @@ export default function NumberingRuleManagementPage() { loadRules(); }, [loadRules, refreshKey]); - // 테이블별 그룹화 + 검색 - const groupedRules = useMemo(() => { - const term = search.trim().toLowerCase(); - const filtered = term - ? rules.filter( - (r) => - (r.rule_name || "").toLowerCase().includes(term) || - (r.table_name || "").toLowerCase().includes(term) || - (r.column_name || "").toLowerCase().includes(term), - ) - : rules; - const groups: Record = {}; - for (const r of filtered) { - const key = r.table_name || "(미지정)"; - if (!groups[key]) groups[key] = []; - groups[key].push(r); + // 선택된 채번 상세 로드 + useEffect(() => { + if (!selectedRuleId) { + setEditingRule(null); + setDirty(false); + setSelectedPartOrder(null); + return; } - return Object.entries(groups).sort(([a], [b]) => a.localeCompare(b)); - }, [rules, search]); + const fromList = rules.find((r) => String(r.rule_id) === selectedRuleId); + if (fromList) { + const cloned = structuredClone(fromList); + setEditingRule(cloned); + setDraftSequence(String(cloned.current_sequence ?? 0)); + setDirty(false); + setSelectedPartOrder(cloned.parts?.[0]?.order ?? null); + } + }, [selectedRuleId, rules]); - const handleDelete = async (ruleId: string) => { - if (!window.confirm("이 채번 규칙을 삭제하시겠습니까? 시퀀스 기록도 함께 삭제됩니다.")) return; + // ⌘K 단축키 + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + setCmdkOpen((v) => !v); + } + if (e.key === "Escape") setCmdkOpen(false); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + + // 필터/검색 + const visibleRules = useMemo(() => { + const term = search.trim().toLowerCase(); + return rules.filter((r) => { + if (filter === "active" && (r.parts?.length ?? 0) === 0) return false; + if (filter === "unused" && !!r.table_name && !!r.column_name) return false; + if (term) { + const hay = `${r.rule_name ?? ""} ${r.table_name ?? ""} ${r.column_name ?? ""} ${r.description ?? ""}`.toLowerCase(); + if (!hay.includes(term)) return false; + } + return true; + }); + }, [rules, search, filter]); + + // 사이드바 섹션 분류 + const groupedRules = useMemo(() => { + const live: NumberingRuleConfig[] = []; + const warn: NumberingRuleConfig[] = []; + const others: NumberingRuleConfig[] = []; + const unused: NumberingRuleConfig[] = []; + visibleRules.forEach((r) => { + const hasUsage = !!r.table_name && !!r.column_name; + if (!hasUsage) { + unused.push(r); + } else { + others.push(r); + } + }); + // live = 최근 발번 상위 3개 (실제론 마지막 발번 시각으로 정렬해야 하나 데이터 없음 → updated_at 사용) + const sortedByUpdate = [...others].sort((a, b) => + (b.updated_at ?? "").localeCompare(a.updated_at ?? "") + ); + return { + live: sortedByUpdate.slice(0, 3), + warn, + others: sortedByUpdate.slice(3), + unused, + }; + }, [visibleRules]); + + // 통계 + const stats = useMemo(() => { + const total = rules.length; + const active = rules.filter((r) => (r.parts?.length ?? 0) > 0).length; + const linked = rules.filter((r) => !!r.table_name && !!r.column_name).length; + const unused = rules.filter((r) => !r.table_name || !r.column_name).length; + return { total, active, warn: 0, unused, linked }; + }, [rules]); + + // 액션 + const handleSelect = (id: string) => { + if (dirty && !confirm("저장하지 않은 변경이 있습니다. 무시하고 이동할까요?")) return; + setSelectedRuleId(id); + }; + + const handleSave = async () => { + if (!editingRule || !dirty) return; + setMutating(true); try { - const resp = await deleteNumberingRuleFromTest(ruleId); + const resp = await saveNumberingRuleToTest(editingRule); if (resp.success) { - toast.success("규칙이 삭제되었습니다"); - if (selectedRuleId === ruleId) setSelectedRuleId(null); + toast.success("채번 규칙이 저장되었습니다"); + setDirty(false); setRefreshKey((k) => k + 1); } else { - showErrorToast("규칙 삭제 실패", resp.error); + showErrorToast("저장 실패", resp.error); } } catch (e) { - showErrorToast("규칙 삭제 실패", e); + showErrorToast("저장 실패", e); + } finally { + setMutating(false); } }; + const handleRevert = () => { + if (!selectedRuleId) return; + const fromList = rules.find((r) => String(r.rule_id) === selectedRuleId); + if (fromList) { + setEditingRule(structuredClone(fromList)); + setDraftSequence(String(fromList.current_sequence ?? 0)); + setDirty(false); + } + }; + + const handleDelete = async () => { + if (!editingRule) return; + if (!confirm(`"${editingRule.rule_name}" 규칙을 삭제하시겠습니까?`)) return; + try { + const resp = await deleteNumberingRuleFromTest(String(editingRule.rule_id)); + if (resp.success) { + toast.success("규칙이 삭제되었습니다"); + setSelectedRuleId(null); + setRefreshKey((k) => k + 1); + } else { + showErrorToast("삭제 실패", resp.error); + } + } catch (e) { + showErrorToast("삭제 실패", e); + } + }; + + const handleResetSequence = async () => { + if (!editingRule?.rule_id) return; + if (!confirm("시퀀스를 0 으로 초기화 하시겠습니까?")) return; + setMutating(true); + try { + const resp = await resetSequence(String(editingRule.rule_id)); + if (resp.success) { + toast.success("시퀀스가 초기화되었습니다"); + setDraftSequence("0"); + setRefreshKey((k) => k + 1); + } else { + showErrorToast("초기화 실패", resp.error); + } + } catch (e) { + showErrorToast("초기화 실패", e); + } finally { + setMutating(false); + } + }; + + const handleApplySequence = async () => { + if (!editingRule?.rule_id) return; + const newSeq = parseInt(draftSequence, 10); + if (isNaN(newSeq) || newSeq < 0) { + toast.error("0 이상의 정수를 입력하세요"); + return; + } + if ((editingRule.current_sequence ?? 0) === newSeq) { + toast.info("현재 시퀀스와 동일합니다"); + return; + } + setMutating(true); + try { + const resp = await updateRuleSequence(String(editingRule.rule_id), newSeq); + if (resp.success) { + toast.success(`시퀀스 ${newSeq} 적용됨`); + setRefreshKey((k) => k + 1); + } else { + showErrorToast("적용 실패", resp.error); + } + } catch (e) { + showErrorToast("적용 실패", e); + } finally { + setMutating(false); + } + }; + + const updatePart = (order: number, patch: Partial) => { + if (!editingRule) return; + setEditingRule({ + ...editingRule, + parts: editingRule.parts.map((p) => (p.order === order ? { ...p, ...patch } : p)), + }); + setDirty(true); + }; + + const updatePartAutoConfig = (order: number, patch: Partial>) => { + if (!editingRule) return; + setEditingRule({ + ...editingRule, + parts: editingRule.parts.map((p) => + p.order === order ? { ...p, auto_config: { ...p.auto_config, ...patch } } : p + ), + }); + setDirty(true); + }; + + const addPart = (type: CodePartType) => { + if (!editingRule) return; + const order = (editingRule.parts?.length ?? 0) + 1; + const newPart: NumberingRulePart = { + id: `part-${Date.now()}`, + order, + part_type: type, + generation_method: "auto", + auto_config: + type === "sequence" + ? { sequence_length: 4, start_from: 1 } + : type === "date" + ? { date_format: "YYYY" } + : type === "text" + ? { text_value: "PFX" } + : type === "number" + ? { number_length: 3, number_value: 1 } + : {}, + }; + setEditingRule({ ...editingRule, parts: [...editingRule.parts, newPart] }); + setSelectedPartOrder(order); + setDirty(true); + }; + + const removePart = (order: number) => { + if (!editingRule) return; + const filtered = editingRule.parts.filter((p) => p.order !== order); + const reordered = filtered.map((p, i) => ({ ...p, order: i + 1 })); + setEditingRule({ ...editingRule, parts: reordered }); + if (selectedPartOrder === order) setSelectedPartOrder(reordered[0]?.order ?? null); + setDirty(true); + }; + + const selectedPart = useMemo( + () => editingRule?.parts.find((p) => p.order === selectedPartOrder) ?? null, + [editingRule, selectedPartOrder] + ); + + // 다음 발번 코드 미리보기 + const previewCode = useMemo(() => { + if (!editingRule) return ""; + const sep = editingRule.separator ?? "-"; + const seq = (editingRule.current_sequence ?? 0) + 1; + const today = new Date(); + return editingRule.parts + .sort((a, b) => a.order - b.order) + .map((p) => renderPartValue(p, today, seq)) + .join(sep); + }, [editingRule]); + + const currentCode = useMemo(() => { + if (!editingRule) return ""; + const sep = editingRule.separator ?? "-"; + const seq = editingRule.current_sequence ?? 0; + const today = new Date(); + return editingRule.parts + .sort((a, b) => a.order - b.order) + .map((p) => renderPartValue(p, today, seq)) + .join(sep); + }, [editingRule]); + return ( -
- {/* 헤더 */} -
-
- -

채번 관리

- - ({rules.length} 규칙) - -
-
- -
+
+ ⌘ K + + {loading ? : } + + +
- {/* 본문: 좌측 목록 + 우측 상세 */} -
- {/* 좌측: 규칙 목록 */} -
+ + +
+
+
+

+ 수주번호 + 사용 중 +

+
+ 생성 2026-03-12 by gbpark + 마지막 수정 2026-05-14 16:22 + 지금까지 142건 발번 +
+
+
+ + +
+
+ + +
+
+ 이 채번이 만드는 코드 + SO-YYYY-MM-#### +
+
+
+ 고정 + SO +
+ - +
+ 년도 + 2026 +
+ - +
+ + 05 +
+ - +
+ 순번 + 0142 +
+
+
+ 다음에 만들어질 코드: SO-2026-05-0143 + · 매월 1일에 순번 초기화 +
+
+ + +
+
+

코드를 이루는 조각

+ 4개 + 조각을 클릭해서 편집 · 사이에 마우스 올리면 추가 가능 +
+ +
+
+ +
+
+ +
+
1번고정text
+ SO + × +
+ - +
+
2번년도YYYY
+ 2026 + × +
+ - +
+
3번MM
+ 05 + × +
+ - +
+
4번순번4자리
+ 0143 + × +
+ +
+ +
+ 조각 종류: + + + + + +
+
+ + +
+
+
+ 2번 조각 + 날짜 설정 +
+ +
+
+
+ +
+ + + + + + +
+
+
+ +
+ + + + +
+ 매월 1일 00:00 에 순번이 1 부터 다시 시작 +
+
+
+
+ +
+
+ 저장하지 않은 변경 1건 있음 +
+
+ + +
+
+
+ + +
+
+ + + + diff --git a/notes/gbpark/2026-05-15-numbering-rule-clean.html b/notes/gbpark/2026-05-15-numbering-rule-clean.html new file mode 100644 index 00000000..332a39ca --- /dev/null +++ b/notes/gbpark/2026-05-15-numbering-rule-clean.html @@ -0,0 +1,1238 @@ + + + + +채번 관리 — 정갈 버전 (v5 clean) + + + + + +
+ + +
+
+

채번 관리

+ + 14 규칙 · 9 연결 · 5 미사용 + +
+
+ + + +
+
+ +
+ + + + + +
+ + +
+
+
+
+

수주번호

+ 사용 중 + NR-001 +
+
+ 생성 2026-03-12 + 수정 2026-05-14 16:22 + by gbpark +
+
+
+
+ + + +
+
+ + +
+
+ 현재 발번 + SO-2026-05-0142 + 시퀀스 142 +
+ + +
+ + +
+
+

코드 구성

+ 4 / 8 + 파트 클릭 → 아래 인스펙터에서 편집 +
+ +
+
+ +
+
+ 1 +
+ TEXT + SO +
+ × +
+ - +
+ 2 +
+ DATE · YYYY + 2026 +
+ × +
+ - +
+ 3 +
+ DATE · MM + 05 +
+ × +
+ - +
+ 4 +
+ SEQ · 4d + 0143 +
+ × +
+ +
+ +
+ + 파트: + + + + + + +
+ + +
+
+
+ #2 + DATE 파트 설정 +
+ +
+
+
+ +
+ + + + + + +
+
+
+ +
+ + + + +
+
+
+
+
+ + +
+ +
+
+

연결된 컬럼

+ 1 + 이 채번이 적용된 사용처 +
+
+
+
+ SALES_ORDER + · + ORDER_NO +
+
단일 연결
+
+
+ 발번 + 142 +
+ +
+ +
+ + +
+
+

시퀀스

+ 월별 리셋 +
+
+
+ 현재 + 142 +
+
+ 다음 + 143 +
+
+
+ + + +
+
+ + + 운영 중인 채번입니다. 시퀀스 수정은 기존 발번 코드와 충돌 가능. + numbering_rule_sequences 재시작됨. + +
+
+
+ + +
+
+ 저장되지 않은 변경 1건 +
+
+ + +
+
+ +
+
+
+ + + + From c530a67cee3dec7e29819c9e16733da24491165c Mon Sep 17 00:00:00 2001 From: johngreen Date: Fri, 15 May 2026 18:35:33 +0900 Subject: [PATCH 18/27] =?UTF-8?q?fix(=EB=A9=80=ED=8B=B0=ED=85=8C=EB=84=8C?= =?UTF-8?q?=EC=8B=9C):=20=ED=85=8C=EB=84=8C=ED=8A=B8=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=A9=94=EB=89=B4?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=86=94=EB=A3=A8=EC=85=98=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=EB=A9=94=EB=89=B4=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminController.getAdminMenus 에 Host 헤더 기반 is_management_host 추가 (기존엔 user-menus 만 필터, /admin/menus 는 미적용이라 관리자 모드 사이드바에서 노출됨) - admin.xml selectAdminMenuList anchor + recursive 양쪽에 IS_SOLUTION_ONLY 필터 추가 - StartupSchemaMigrator: ALTER 외에 UPDATE 추가, 프로비저닝된 테넌트 DB 의 회사관리/서브도메인관리/감사로그 메뉴 행을 부팅 시 IS_SOLUTION_ONLY=TRUE 로 마킹 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../java/com/erp/controller/AdminController.java | 6 +++++- .../com/erp/migration/StartupSchemaMigrator.java | 16 +++++++++++++++- .../src/main/resources/mapper/admin.xml | 6 ++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/backend-spring/src/main/java/com/erp/controller/AdminController.java b/backend-spring/src/main/java/com/erp/controller/AdminController.java index e24252c9..b4a14e78 100644 --- a/backend-spring/src/main/java/com/erp/controller/AdminController.java +++ b/backend-spring/src/main/java/com/erp/controller/AdminController.java @@ -32,13 +32,17 @@ public class AdminController { @RequestAttribute("company_code") String companyCode, @RequestAttribute("role") String role, @RequestAttribute("user_id") String userId, - @RequestParam Map params) { + @RequestParam Map params, + HttpServletRequest request) { params.put("company_code", companyCode); params.put("user_type", role); params.put("user_id", userId); params.putIfAbsent("user_lang", "ko"); params.put("is_management_screen", params.get("menu_type") == null || "true".equals(params.get("include_inactive"))); + // 관리 호스트(solution.invyone.com 등) 여부 — 테넌트 호스트이면 IS_SOLUTION_ONLY 메뉴를 SQL 단계에서 제외 + String host = request.getHeader("Host"); + params.put("is_management_host", !SuperAdminGuard.isTenantHost(host)); return ResponseEntity.ok(ApiResponse.success(adminService.getAdminMenuList(params), "관리자 메뉴 목록 조회 성공")); } diff --git a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java index b5035e32..87c743fe 100644 --- a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java +++ b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java @@ -209,7 +209,21 @@ public class StartupSchemaMigrator { // 솔루션 관리 호스트(solution.invyone.com 등) 에서만 노출되는 메뉴 플래그. // 테넌트 사이트에선 mapper SQL 단계에서 제외. 메타 DB 는 Flyway V023 으로도 적용되지만 // 프로비저닝된 테넌트 DB 는 부팅 때 동기화. - "ALTER TABLE MENU_INFO ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL" + "ALTER TABLE MENU_INFO ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL", + + // V023 데이터 동기화: 솔루션 전용 메뉴 마킹. + // 회사관리 / 회사 프로비저닝 / 감사로그는 관리 호스트에서만 노출돼야 함. + // 이미 TRUE 인 행은 그대로 두기 위해 false 인 행만 갱신. + """ + UPDATE MENU_INFO + SET IS_SOLUTION_ONLY = TRUE + WHERE IS_SOLUTION_ONLY = FALSE + AND MENU_URL IN ( + '/admin/sysMng/subdomainList', + '/admin/userMng/companyList', + '/admin/audit-log' + ) + """ ); @EventListener(ApplicationReadyEvent.class) diff --git a/backend-spring/src/main/resources/mapper/admin.xml b/backend-spring/src/main/resources/mapper/admin.xml index 38af0239..91a64af0 100644 --- a/backend-spring/src/main/resources/mapper/admin.xml +++ b/backend-spring/src/main/resources/mapper/admin.xml @@ -58,6 +58,9 @@ AND RMA.READ_YN = 'Y' ) + + AND MENU.IS_SOLUTION_ONLY = FALSE + UNION ALL @@ -105,6 +108,9 @@ AND RMA.READ_YN = 'Y' ) + + AND S.IS_SOLUTION_ONLY = FALSE + ) SELECT V.LEV From a0a4dc3bf5389f097d06ccca8ae4c7885e2932ab Mon Sep 17 00:00:00 2001 From: johngreen Date: Fri, 15 May 2026 19:26:38 +0900 Subject: [PATCH 19/27] =?UTF-8?q?fix(=ED=85=8C=EC=9D=B4=EB=B8=94=ED=83=80?= =?UTF-8?q?=EC=9E=85):=20TABLE=5FTYPE=5FCOLUMNS.CODE=5FCATEGORY=20?= =?UTF-8?q?=E2=86=92=20CODE=5FINFO=20DB=20rename=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5/15 common-code 재설계(commit 2348800e) 가 mapper SQL 6 군데 컬럼 참조를 CODE_INFO 로 바꾸면서 DB 컬럼 rename 마이그레이션을 빠뜨려, 모든 테넌트 사이트에서 테이블타입관리 > 테이블 클릭 시 GET /api/table-management/tables/{name}/columns 가 500 (column "code_info" does not exist) 을 반환. - StartupSchemaMigrator MIGRATIONS 에 V024 항목 추가 DO 블록으로 information_schema 확인 후 rename — 멱등. - RUN_089_MIGRATION.md 신설 (V023 IS_SOLUTION_ONLY 운영 가이드도 합본). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../erp/migration/StartupSchemaMigrator.java | 27 ++++ db/migrations/RUN_089_MIGRATION.md | 143 ++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 db/migrations/RUN_089_MIGRATION.md diff --git a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java index 87c743fe..e0a0c5bb 100644 --- a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java +++ b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java @@ -223,6 +223,33 @@ public class StartupSchemaMigrator { '/admin/userMng/companyList', '/admin/audit-log' ) + """, + + // V024 / RUN_089: TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO rename. + // 5/15 common-code 재설계(commit 2348800e) 가 mapper SQL 의 컬럼 참조명만 + // 바꾸고 DB rename 을 빠뜨려, 테이블타입관리 컬럼 조회 API 가 500 반환. + // PostgreSQL 은 RENAME COLUMN 에 IF EXISTS 가 없어서 DO 블록으로 멱등 처리: + // - CODE_CATEGORY 만 있는 기존 테넌트: rename 수행 + // - 이미 CODE_INFO 인 신규 테넌트: no-op + // - 둘 다 있거나 둘 다 없는 비정상 상태: no-op (방어적) + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'table_type_columns' + AND column_name = 'code_category' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'table_type_columns' + AND column_name = 'code_info' + ) THEN + ALTER TABLE TABLE_TYPE_COLUMNS + RENAME COLUMN CODE_CATEGORY TO CODE_INFO; + END IF; + END $$ """ ); diff --git a/db/migrations/RUN_089_MIGRATION.md b/db/migrations/RUN_089_MIGRATION.md new file mode 100644 index 00000000..b1c5c56e --- /dev/null +++ b/db/migrations/RUN_089_MIGRATION.md @@ -0,0 +1,143 @@ +# 089 마이그레이션 — IS_SOLUTION_ONLY 메뉴 플래그 + TABLE_TYPE_COLUMNS.CODE_CATEGORY rename + +작성일: 2026-05-15 +작성자: johngreen +관련: +- (V023) 멀티테넌시 메뉴 격리 — 5/15 fix (commit c530a67c) +- (V024) common-code 마스터-디테일 재설계 — 5/15 refactor (commit 2348800e) + +## 목적 + +V023 과 V024 두 건의 누락된 운영 문서를 합본 처리. +앱 부팅 시 `StartupSchemaMigrator` 가 idempotent 로 메타 DB + 활성 테넌트 DB 전부에 자동 적용한다. + +### V023 — MENU_INFO.IS_SOLUTION_ONLY 컬럼 (회상) + +테넌트 사이트(`*.invyone.com`)에서 솔루션 전용 관리자 메뉴(회사관리/회사 프로비저닝/감사로그)를 숨기기 위한 플래그. +- 메뉴 mapper SQL(`selectAdminMenuList`, `selectUserMenuList`)이 `is_management_host` 파라미터를 보고 `IS_SOLUTION_ONLY=TRUE` 행을 제외. +- 이미 부팅 마이그레이션으로는 적용 중이지만 RUN_*.md 운영 문서가 빠져있어 이번 089 에 합본. + +### V024 — TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO (★ 신규, 본 PR 의 핵심) + +5/15 의 commonCode 마스터-디테일 재설계(commit `2348800e`)가 mapper SQL 6 군데에서 +`CL.CODE_CATEGORY` → `CL.CODE_INFO` 로 컬럼 참조명을 바꿨지만, **DB 컬럼 rename SQL 을 빠뜨린 채 머지**됨. +그 결과 모든 테넌트 DB 의 `테이블 타입관리 > 테이블 클릭 > 컬럼 목록` API +(`GET /api/table-management/tables/{name}/columns`) 가 **500** 반환: + +``` +ERROR: column cl.code_info does not exist +``` + +본 089 마이그레이션이 `CODE_CATEGORY` → `CODE_INFO` 로 컬럼명을 안전하게 변경한다. + +## 스키마 + +### MENU_INFO (V023) + +| 컬럼 | 타입 | 제약 | 설명 | +|---|---|---|---| +| `IS_SOLUTION_ONLY` | BOOLEAN | NOT NULL DEFAULT FALSE | TRUE 인 메뉴는 솔루션 관리 호스트에서만 노출 | + +### TABLE_TYPE_COLUMNS (V024) + +| 변경 | 설명 | +|---|---| +| `CODE_CATEGORY` → `CODE_INFO` | 컬럼 RENAME (값/타입/제약 그대로) | + +## SQL + +```sql +-- ================================================================= +-- 089-V023: MENU_INFO.IS_SOLUTION_ONLY (idempotent) +-- ================================================================= + +ALTER TABLE MENU_INFO + ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL; + +UPDATE MENU_INFO + SET IS_SOLUTION_ONLY = TRUE + WHERE IS_SOLUTION_ONLY = FALSE + AND MENU_URL IN ( + '/admin/sysMng/subdomainList', + '/admin/userMng/companyList', + '/admin/audit-log' + ); + +-- ================================================================= +-- 089-V024: TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO (idempotent) +-- ================================================================= +-- PostgreSQL 은 RENAME COLUMN 에 IF EXISTS 가 없으므로 DO 블록으로 +-- 멱등성 보장 (이미 CODE_INFO 면 no-op, CODE_CATEGORY 만 존재할 때만 rename). + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'table_type_columns' + AND column_name = 'code_category' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'table_type_columns' + AND column_name = 'code_info' + ) THEN + ALTER TABLE TABLE_TYPE_COLUMNS + RENAME COLUMN CODE_CATEGORY TO CODE_INFO; + END IF; +END $$; +``` + +## 멱등성 + +- V023: `ADD COLUMN IF NOT EXISTS` + UPDATE `WHERE IS_SOLUTION_ONLY = FALSE` 로 중복 실행 안전. +- V024: DO 블록 안에서 information_schema 로 현재 상태 확인 후 분기. + - 신규 테넌트 DB (이미 CODE_INFO 면): no-op + - 기존 테넌트 DB (CODE_CATEGORY 만 있으면): rename 수행 + - 둘 다 있거나 둘 다 없으면: no-op (방어적) + +## 적용 방법 + +부팅 시 자동 적용 — 별도 작업 불필요. +`backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java` 의 MIGRATIONS 리스트에 +위 SQL 이 등록되어 있어서 앱이 시작할 때 모든 활성 테넌트 DB 에 idempotent 로 실행된다. + +수동 적용이 필요한 경우 (예: 새 환경 부트스트랩 전): +```bash +psql -h -U -d -f - <<'SQL' +-- 위 SQL 본문 붙여넣기 +SQL +``` + +## 검증 + +```sql +-- V023 +SELECT COLUMN_NAME FROM information_schema.columns + WHERE TABLE_NAME = 'menu_info' AND COLUMN_NAME = 'is_solution_only'; +-- → 1 row + +SELECT MENU_URL, IS_SOLUTION_ONLY FROM MENU_INFO + WHERE MENU_URL IN ('/admin/sysMng/subdomainList', '/admin/userMng/companyList', '/admin/audit-log'); +-- → 모두 IS_SOLUTION_ONLY = TRUE + +-- V024 +SELECT COLUMN_NAME FROM information_schema.columns + WHERE TABLE_NAME = 'table_type_columns' AND COLUMN_NAME IN ('code_category', 'code_info'); +-- → 1 row: code_info (code_category 는 존재하면 안 됨) +``` + +## 영향 범위 + +- 테이블 타입관리 페이지 컬럼 조회 500 에러 해소. +- common-code 재설계 후속 (mapper/Service/Frontend 는 이미 5/15 에 머지됨). +- 부팅 시점 1회 실행 — 런타임 트래픽에는 영향 없음. + +## 롤백 + +V024 rename 을 되돌리려면 mapper SQL 도 같이 되돌려야 하므로 일반적으로 권장하지 않음. +만약 필요하면: +```sql +ALTER TABLE TABLE_TYPE_COLUMNS RENAME COLUMN CODE_INFO TO CODE_CATEGORY; +``` ++ `mapper/tableManagement.xml`, `commonCode.xml`, FE `commonCode.ts` 등 5/15 변경분 revert. From 14832a28ab19f3f56cb6e684a25ca724ee5aad6c Mon Sep 17 00:00:00 2001 From: johngreen Date: Fri, 15 May 2026 23:39:30 +0900 Subject: [PATCH 20/27] =?UTF-8?q?fix(=ED=85=8C=EC=9D=B4=EB=B8=94=ED=83=80?= =?UTF-8?q?=EC=9E=85):=20TABLE=5FTYPE=5FCOLUMNS=20=EC=97=90=20ON=20CONFLIC?= =?UTF-8?q?T=20=EB=A7=A4=EC=B9=AD=EC=9A=A9=20UNIQUE=20INDEX=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20+=20=EC=A4=91=EB=B3=B5=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 테이블 타입관리의 모든 쓰기 API (UNIQUE/NOT NULL 토글, 컬럼 설정 저장, input-type upsert) 가 500 반환. 원인은 mapper SQL 의 ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) 가 매칭할 unique 제약/인덱스가 운영 DB 에 존재하지 않아 PG 가 "there is no unique or exclusion constraint matching the ON CONFLICT specification" 으로 거부. - StartupSchemaMigrator MIGRATIONS 에 V025 / RUN_090 (1) (2) 추가: (1) ROW_NUMBER 로 (table, column, company) 중복 행 정리 (운영 메타 DB 실측 2 그룹 / 4 row — 동일 데이터의 NULL updated_date 옛 row 제거. 테넌트 DB 들은 중복 0건). (2) UX_TABLE_TYPE_COLUMNS_TCC UNIQUE INDEX 생성 (IF NOT EXISTS — 멱등). - RUN_090_MIGRATION.md 신설. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../erp/migration/StartupSchemaMigrator.java | 31 +++++ db/migrations/RUN_090_MIGRATION.md | 109 ++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 db/migrations/RUN_090_MIGRATION.md diff --git a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java index e0a0c5bb..02b60156 100644 --- a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java +++ b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java @@ -250,7 +250,38 @@ public class StartupSchemaMigrator { RENAME COLUMN CODE_CATEGORY TO CODE_INFO; END IF; END $$ + """, + + // V025 / RUN_090 (1) TABLE_TYPE_COLUMNS 중복 행 정리. + // PK 가 id 단일 (varchar) 인데 (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) 에는 + // UNIQUE 가 없어서 같은 키로 row 가 여러 개 INSERT 된 이력이 있음. + // 메타 DB 실측: 35K rows 중 2 그룹 4 row 가 중복. 그 그룹들은 동일 데이터를 + // updated_date NULL 짜리 옛 row 와 2026-03-16 마지막 갱신 row 가 공존하는 형태. + // 가장 최근 (updated_date DESC NULLS LAST, id::bigint DESC) 행만 남기고 제거. + // 테넌트 DB 들은 실측상 중복 없음 → DELETE 0건. 멱등 (재실행해도 변화 없음). """ + DELETE FROM TABLE_TYPE_COLUMNS + WHERE id IN ( + SELECT id FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE + ORDER BY UPDATED_DATE DESC NULLS LAST, + id::bigint DESC + ) AS rn + FROM TABLE_TYPE_COLUMNS + ) r + WHERE r.rn > 1 + ) + """, + + // V025 / RUN_090 (2) ON CONFLICT 매칭용 UNIQUE INDEX 추가. + // mapper 의 upsertColumnSettings / upsertNullable / upsertUnique / + // upsertColumnInputType 모두 ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) + // 를 쓰는데 DB 엔 매칭 unique 제약이 없어서 모든 쓰기 API 가 500. + // 인덱스 형태로 등록하면 ON CONFLICT 가 인식하고 ADD CONSTRAINT 식의 + // IF NOT EXISTS 누락 문제도 회피. + "CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE)" ); @EventListener(ApplicationReadyEvent.class) diff --git a/db/migrations/RUN_090_MIGRATION.md b/db/migrations/RUN_090_MIGRATION.md new file mode 100644 index 00000000..aee77f8c --- /dev/null +++ b/db/migrations/RUN_090_MIGRATION.md @@ -0,0 +1,109 @@ +# 090 마이그레이션 — TABLE_TYPE_COLUMNS 중복 정리 + ON CONFLICT 용 UNIQUE INDEX + +작성일: 2026-05-15 +작성자: johngreen +관련 버그: 테이블 타입관리에서 모든 쓰기 API (UNIQUE 토글 / NOT NULL 토글 / 컬럼 설정 저장) 가 500 반환. + +## 증상 + +``` +PSQLException: ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification + mapper: tableManagement.upsertColumnSettings / upsertNullable / upsertUnique / upsertColumnInputType +``` + +## 원인 + +`TABLE_TYPE_COLUMNS` 의 PK 는 `id` 단일(varchar). 운영 DB 어디에도 +`(TABLE_NAME, COLUMN_NAME, COMPANY_CODE)` UNIQUE 제약/인덱스가 없음. +mapper 의 `INSERT … ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) DO UPDATE …` +구문이 매칭할 unique constraint 를 찾지 못해 즉시 BadSqlGrammar 로 500. + +RUN_044 가 company_code 컬럼을 추가했지만 함께 도입했어야 할 unique index 가 +빠진 채로 운영에 들어간 것으로 보이며, 그 후 mapper 가 ON CONFLICT 패턴으로 작성되면서 +실제로는 한 번도 정상 동작하지 못한 채로 잠복했던 정황 (운영 메타 DB 의 35,316 행 중 +중복 키 그룹 2개 = 추가 4 row 가 그 흔적). + +## 조치 + +### (1) 중복 행 정리 + +각 `(TABLE_NAME, COLUMN_NAME, COMPANY_CODE)` 그룹에서 +`updated_date DESC NULLS LAST, id::bigint DESC` 로 정렬해 첫 행만 유지, 나머지 DELETE. + +```sql +DELETE FROM TABLE_TYPE_COLUMNS + WHERE id IN ( + SELECT id FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE + ORDER BY UPDATED_DATE DESC NULLS LAST, + id::bigint DESC + ) AS rn + FROM TABLE_TYPE_COLUMNS + ) r + WHERE r.rn > 1 + ); +``` + +실측(2026-05-15) 중복: + +| DB | 중복 그룹 | 삭제될 row | +|---|---|---| +| meta `invyone` | 2 (`sales_order_mng.incoterms@COMPANY_16`, `sales_order_mng.payment_term@COMPANY_16`) | 2 | +| `siflex_invyone` | 0 | 0 | +| `test01_invyone` | 0 | 0 | +| `test02_invyone` | 0 | 0 | + +남는 행은 가장 최근에 갱신된 동일 키 row (column_label/input_type 모두 동일 — 옛 NULL updated_date row 가 제거 대상). + +### (2) UNIQUE INDEX 추가 + +```sql +CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC + ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE); +``` + +PostgreSQL 은 ON CONFLICT 가 인덱스도 인식하므로 mapper 의 모든 upsert SQL 이 +즉시 정상 동작. `IF NOT EXISTS` 로 멱등. + +## 적용 방법 + +부팅 시 자동 적용 — 별도 작업 불필요. `StartupSchemaMigrator.MIGRATIONS` 리스트에 +V025 / RUN_090 (1) (2) 항목으로 등록되어 있어서 앱이 시작할 때 메타 DB + 모든 활성 +테넌트 DB 에 차례로 실행된다. + +## 검증 + +```sql +-- 중복 없음 +SELECT COUNT(*) FROM ( + SELECT 1 FROM TABLE_TYPE_COLUMNS + GROUP BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE HAVING COUNT(*) > 1 +) d; +-- → 0 + +-- 인덱스 존재 +SELECT indexname FROM pg_indexes + WHERE tablename = 'table_type_columns' AND indexname = 'ux_table_type_columns_tcc'; +-- → 1 row +``` + +브라우저 검증: +1. 솔루션 또는 테넌트 사이트 > 시스템 관리 > 테이블 타입관리 > 거래처 클릭 +2. 어느 컬럼이든 `UQ` / `NN` 토글 클릭 → 200, 토스트 "UNIQUE/NOT NULL 제약이 설정되었습니다" +3. "컬럼 설정 저장" 버튼 클릭 → 200, 토스트 "모든 컬럼 설정을 성공적으로 저장했습니다" + +## 영향 범위 + +- 테이블 타입관리 페이지 쓰기 API 4종 (`unique`, `nullable`, `columns/settings`, `columns/{c}/input-type`) 정상화. +- 멱등 — 재실행 시 DELETE 0건, CREATE INDEX 도 IF NOT EXISTS 라 skip. +- 부팅 시점 1회 실행, 런타임 트래픽에는 영향 없음. + +## 롤백 + +```sql +DROP INDEX IF EXISTS UX_TABLE_TYPE_COLUMNS_TCC; +``` +DELETE 된 중복 row 는 정보 손실 없음 (남은 row 와 column_label/input_type 동일) 이라 +복구가 의미 없음. 그래도 굳이 되돌리려면 사전 백업 필요. From 752e4fb6446772ed6e7094bdff43fb6692754b89 Mon Sep 17 00:00:00 2001 From: johngreen Date: Fri, 15 May 2026 23:55:48 +0900 Subject: [PATCH 21/27] =?UTF-8?q?fix(=ED=85=8C=EC=9D=B4=EB=B8=94=ED=83=80?= =?UTF-8?q?=EC=9E=85):=20syncScreenLayoutsInputType=20SQL=20=E2=80=94=20SC?= =?UTF-8?q?REEN=5FLAYOUTS.PROPERTIES=20=EA=B0=80=20varchar=20=EB=9D=BC=20J?= =?UTF-8?q?SONB=20=EC=BA=90=EC=8A=A4=ED=8C=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영 DB 의 SCREEN_LAYOUTS.PROPERTIES 컬럼이 character varying 인데 mapper SQL 은 PROPERTIES->>'...' 와 JSONB_SET(PROPERTIES, ...) 를 그대로 사용해 PG 가 'operator does not exist: character varying ->> unknown' 으로 거부. 이로 인해 syncScreenLayouts 가 던지는 SQLException 이 try-catch 로 무시되긴 하지만 외부 @Transactional 이 이미 aborted 상태가 되어 후속 ensureTableInLabels (insertTableLabelIfNotExists) 가 'current transaction is aborted' 로 연쇄 실패 → 컬럼 설정 저장 500. - SL.PROPERTIES::JSONB 캐스팅 (WHERE / SET 양쪽) - JSONB_SET 결과를 ::TEXT 로 캐스팅해 varchar 컬럼에 안전 저장 - 운영 4 DB (invyone, siflex/test01/test02 _invyone) 전수 검증: invalid JSON row 0건 → 캐스팅 안전 mapper SQL 만 변경. DB 마이그레이션 불필요. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/resources/mapper/tableManagement.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend-spring/src/main/resources/mapper/tableManagement.xml b/backend-spring/src/main/resources/mapper/tableManagement.xml index 8b727d4e..4e576fa3 100644 --- a/backend-spring/src/main/resources/mapper/tableManagement.xml +++ b/backend-spring/src/main/resources/mapper/tableManagement.xml @@ -667,15 +667,15 @@ SET PROPERTIES = JSONB_SET( JSONB_SET( - SL.PROPERTIES, + SL.PROPERTIES::JSONB, '{widgetType}', TO_JSONB(#{component_id}::TEXT) ), '{componentType}', TO_JSONB(#{component_id}::TEXT) - ) + )::TEXT FROM SCREEN_DEFINITIONS SD WHERE SL.SCREEN_ID = SD.SCREEN_ID - AND SL.PROPERTIES->>'tableName' = #{table_name} - AND SL.PROPERTIES->>'columnName' = #{column_name} + AND SL.PROPERTIES::JSONB->>'tableName' = #{table_name} + AND SL.PROPERTIES::JSONB->>'columnName' = #{column_name} AND ((SD.COMPANY_CODE = #{company_code} OR SD.COMPANY_CODE = '*') OR #{company_code} = '*') From 90787d837f4ab09acb2724e1e7bb691b9bbef12b Mon Sep 17 00:00:00 2001 From: johngreen Date: Sat, 16 May 2026 13:29:13 +0900 Subject: [PATCH 22/27] =?UTF-8?q?fix(=ED=85=8C=EC=9D=B4=EB=B8=94=ED=83=80?= =?UTF-8?q?=EC=9E=85):=20USER=5FSELECTABLE=5FINPUT=5FTYPES=20=ED=99=94?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=EB=A6=AC=EC=8A=A4=ED=8A=B8=EC=97=90=20legacy?= =?UTF-8?q?=20input=5Ftype=207=EA=B0=9C=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5/15 common-code 재설계가 화이트리스트를 8종으로 좁히면서 운영 DB 의 옛 input_type 값들을 매핑/정리하는 마이그레이션을 빠뜨려, 컬럼 설정 저장 batch POST 가 한 row 라도 legacy 값(category/select/textarea/ checkbox/radio/datetime/boolean)을 포함하면 400 거부. 운영 메타 DB 실측: 화이트리스트 밖 row 1,207건 (category 886, select 149, textarea 102, checkbox 55, radio 12, datetime 2, boolean 1). 운영 데이터/UI 의미를 보존하기 위해 매핑이 아닌 화이트리스트 확장 (legacy 7종 추가)으로 회복. legacy 정리는 별도 PR 에서 점진적으로. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../java/com/erp/constants/InputTypeConstants.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/backend-spring/src/main/java/com/erp/constants/InputTypeConstants.java b/backend-spring/src/main/java/com/erp/constants/InputTypeConstants.java index 59955af4..8f90c325 100644 --- a/backend-spring/src/main/java/com/erp/constants/InputTypeConstants.java +++ b/backend-spring/src/main/java/com/erp/constants/InputTypeConstants.java @@ -5,9 +5,15 @@ import java.util.Set; public final class InputTypeConstants { private InputTypeConstants() {} - /** 사용자가 직접 선택 가능한 INPUT_TYPE 8종 (INSERT/UPDATE-type 검증용) */ + /** + * INSERT/UPDATE-type 검증용 허용 INPUT_TYPE. + * 신규 표준 8종 + 운영 DB 에 잔존하는 legacy 7종(category/select/textarea/checkbox/radio/datetime/boolean). + * 5/15 common-code 재설계가 화이트리스트를 8종으로 좁히면서도 옛 데이터/프론트 정리를 빠뜨려 + * 컬럼 설정 저장 batch 가 일괄 거부됐던 회귀 회복. legacy 정리는 별도 PR 로. + */ public static final Set USER_SELECTABLE_INPUT_TYPES = Set.of( "text", "number", "date", "code", "entity", - "numbering", "file", "image" + "numbering", "file", "image", + "category", "select", "textarea", "checkbox", "radio", "datetime", "boolean" ); } From d8877b243a91e0768f763ee95785f9d39a48beb5 Mon Sep 17 00:00:00 2001 From: johngreen Date: Sat, 16 May 2026 14:30:39 +0900 Subject: [PATCH 23/27] =?UTF-8?q?chore(=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=ED=83=80=EC=9E=85):=20legacy=20input=5Ftype=201,207=20row=20?= =?UTF-8?q?=ED=91=9C=EC=A4=80=208=EC=A2=85=EC=9C=BC=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20(V026=20/=20RUN=5F091)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5/15 common-code 재설계가 화이트리스트를 8종으로 좁히면서 빠뜨린 운영 DB 데이터 정리. 90787d83 의 화이트리스트 확장 fix 는 회복용 보호막이었고, 본 PR 은 데이터를 표준으로 통합하는 후속 정리. 매핑: category/select/radio/checkbox/boolean → code textarea → text datetime → date 영향: 메타 DB 1,207 row 갱신. 테넌트 DB 들은 비어있어 0 row. WHERE input_type IN (...) 으로 멱등 (재실행 시 0 row). 화이트리스트 축소는 운영 안정 확인 후 별도 PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../erp/migration/StartupSchemaMigrator.java | 27 ++++++- db/migrations/RUN_091_MIGRATION.md | 81 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 db/migrations/RUN_091_MIGRATION.md diff --git a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java index 02b60156..1655eece 100644 --- a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java +++ b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java @@ -281,7 +281,32 @@ public class StartupSchemaMigrator { // 를 쓰는데 DB 엔 매칭 unique 제약이 없어서 모든 쓰기 API 가 500. // 인덱스 형태로 등록하면 ON CONFLICT 가 인식하고 ADD CONSTRAINT 식의 // IF NOT EXISTS 누락 문제도 회피. - "CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE)" + "CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE)", + + // V026 / RUN_091: TABLE_TYPE_COLUMNS.INPUT_TYPE legacy → 표준 8종 정리. + // 5/15 common-code 재설계가 화이트리스트를 8종으로 좁혔지만 운영 DB 의 + // 옛 값(category 886, select 149, textarea 102, checkbox 55, radio 12, + // datetime 2, boolean 1) 을 정리하는 마이그레이션을 빠뜨림. + // 매핑: + // category / select / radio / checkbox / boolean → code (commonCode 통합 의도) + // textarea → text (single/multi line 구분 손실 — UI 동작 가벼움) + // datetime → date + // 메타 DB 1,207 row 갱신. 테넌트 DB 들은 비어있어 영향 0. + // WHERE 절로 멱등 (재실행 시 0 row). + """ + UPDATE TABLE_TYPE_COLUMNS + SET INPUT_TYPE = CASE INPUT_TYPE + WHEN 'category' THEN 'code' + WHEN 'select' THEN 'code' + WHEN 'radio' THEN 'code' + WHEN 'checkbox' THEN 'code' + WHEN 'boolean' THEN 'code' + WHEN 'textarea' THEN 'text' + WHEN 'datetime' THEN 'date' + END, + UPDATED_DATE = NOW() + WHERE INPUT_TYPE IN ('category','select','radio','checkbox','boolean','textarea','datetime') + """ ); @EventListener(ApplicationReadyEvent.class) diff --git a/db/migrations/RUN_091_MIGRATION.md b/db/migrations/RUN_091_MIGRATION.md new file mode 100644 index 00000000..2077c5db --- /dev/null +++ b/db/migrations/RUN_091_MIGRATION.md @@ -0,0 +1,81 @@ +# 091 마이그레이션 — TABLE_TYPE_COLUMNS.INPUT_TYPE legacy → 표준 8종 정리 + +작성일: 2026-05-16 +작성자: johngreen +관련: 5/15 common-code 재설계 (commit `2348800e`) 후속 데이터 마이그레이션. + +## 배경 + +5/15 PR 이 `InputTypeConstants.USER_SELECTABLE_INPUT_TYPES` 화이트리스트를 +표준 8종(`text/number/date/code/entity/numbering/file/image`) 으로 좁혔지만, +운영 DB 에 잔존하는 옛 input_type 값들을 정리하는 데이터 마이그레이션이 빠지고 +프론트엔드도 옛 값을 그대로 echo 했기 때문에 컬럼 설정 저장 batch 가 400 으로 거부됐다. + +긴급 회복은 `90787d83` 에서 화이트리스트에 legacy 7종을 다시 인정하는 방식으로 +끝냈고, 본 091 마이그레이션은 그 뒤로 **데이터를 표준으로 통합**하는 후속 정리. + +## 매핑 + +| Legacy | → | Standard | 사유 | +|---|---|---|---| +| `category` | → | `code` | commonCode 통합 의도와 일치 | +| `select` | → | `code` | 미리 정의된 코드 선택 = code 와 동등 | +| `radio` | → | `code` | enum 선택 | +| `checkbox` | → | `code` | enum/boolean → code 매핑 (표준에 boolean 없음) | +| `boolean` | → | `code` | 표준에 boolean 없음 — code 가 가장 근접 | +| `textarea` | → | `text` | single/multi line 구분 UI 손실 (가벼움) | +| `datetime` | → | `date` | 표준에 datetime 분리 없음 | + +## 영향 범위 (실측 2026-05-16) + +| DB | 갱신 row | +|---|---| +| meta `invyone` | 1,207 (category 886 + select 149 + textarea 102 + checkbox 55 + radio 12 + datetime 2 + boolean 1) | +| `siflex_invyone` | 0 (테이블 비어있음) | +| `test01_invyone` | 0 | +| `test02_invyone` | 0 | + +## SQL + +```sql +UPDATE TABLE_TYPE_COLUMNS + SET INPUT_TYPE = CASE INPUT_TYPE + WHEN 'category' THEN 'code' + WHEN 'select' THEN 'code' + WHEN 'radio' THEN 'code' + WHEN 'checkbox' THEN 'code' + WHEN 'boolean' THEN 'code' + WHEN 'textarea' THEN 'text' + WHEN 'datetime' THEN 'date' + END, + UPDATED_DATE = NOW() + WHERE INPUT_TYPE IN ('category','select','radio','checkbox','boolean','textarea','datetime'); +``` + +## 멱등성 + +`WHERE INPUT_TYPE IN (...)` 으로 두 번째 실행 시 매칭 row 0 → no-op. + +## 적용 방법 + +부팅 시 자동 적용. `StartupSchemaMigrator.MIGRATIONS` 리스트에 V026 / RUN_091 항목으로 +등록되어 있어서 backend 시작 시 메타 DB + 활성 테넌트 DB 전부에 idempotent 로 실행된다. + +## 검증 + +```sql +-- 화이트리스트 밖 row 0 이어야 함 +SELECT input_type, COUNT(*) FROM table_type_columns + WHERE input_type NOT IN ('text','number','date','code','entity','numbering','file','image') + GROUP BY 1; +-- → 0 rows +``` + +## 후속 cleanup (별도 PR 거리) + +본 마이그레이션이 모든 환경에 한 번 적용된 다음에는: +1. `InputTypeConstants.USER_SELECTABLE_INPUT_TYPES` 에서 legacy 7종 다시 제거. +2. 프론트엔드 input type 선택 UI 에서 legacy 옵션 제거 (이미 있을 수도). +3. mapper/Service 에서 legacy 값 참조 흔적 grep + 정리. + +이번 PR 은 데이터 정리만. 화이트리스트 축소는 운영 안정 확인 후. From 6b204806b66e044cfae06aab88c838b88f9947ac Mon Sep 17 00:00:00 2001 From: johngreen Date: Sat, 16 May 2026 15:34:41 +0900 Subject: [PATCH 24/27] =?UTF-8?q?fix(=EA=B3=B5=ED=86=B5=EC=BD=94=EB=93=9C)?= =?UTF-8?q?:=20=EA=B7=B8=EB=A3=B9=20=EC=BD=94=EB=93=9C=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20INSERT=20=EC=8B=9C=20=EC=B9=9C=EC=A0=88=ED=95=9C=20?= =?UTF-8?q?400=20=EB=A9=94=EC=8B=9C=EC=A7=80=20(500=20=E2=86=92=20IllegalA?= =?UTF-8?q?rgumentException)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존: 중복 그룹 코드 등록 시 PK violation 으로 500 + "서버 내부 오류" → 사용자가 왜 안 되는지 알 수 없음. 변경: insertCodeInfo 진입 시 getCodeInfoInfo 로 사전 체크. 이미 존재하면 IllegalArgumentException 으로 던져 GlobalExceptionHandler 가 자동으로 400 + "이미 존재하는 그룹 코드입니다: {코드}" 메시지로 응답. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/java/com/erp/service/CommonCodeService.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend-spring/src/main/java/com/erp/service/CommonCodeService.java b/backend-spring/src/main/java/com/erp/service/CommonCodeService.java index b0dc23e3..6430fbd6 100644 --- a/backend-spring/src/main/java/com/erp/service/CommonCodeService.java +++ b/backend-spring/src/main/java/com/erp/service/CommonCodeService.java @@ -53,6 +53,13 @@ public class CommonCodeService extends BaseService { @Transactional public Map insertCodeInfo(Map body, String companyCode, String userId) { + Object rawCodeInfo = body.get("code_info"); + String codeInfo = rawCodeInfo == null ? null : rawCodeInfo.toString().trim(); + if (codeInfo != null && !codeInfo.isEmpty() + && getCodeInfoInfo(codeInfo, companyCode) != null) { + throw new IllegalArgumentException("이미 존재하는 그룹 코드입니다: " + codeInfo); + } + Map params = new HashMap<>(); params.put("code_info", body.get("code_info")); params.put("code_name", body.get("code_name")); From 78c5e3e358880c07b7b2265d3304a64f6fb1a263 Mon Sep 17 00:00:00 2001 From: johngreen Date: Sat, 16 May 2026 20:59:53 +0900 Subject: [PATCH 25/27] =?UTF-8?q?fix(=ED=85=8C=EC=9D=B4=EB=B8=94=ED=83=80?= =?UTF-8?q?=EC=9E=85):=20=EC=A2=8C=EC=B8=A1=20=EC=9D=BC=EA=B4=84=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=98=81=EC=97=AD=20layout=20shift=20+=20=EC=9A=B0?= =?UTF-8?q?=EC=B8=A1=20=EB=94=94=ED=85=8C=EC=9D=BC=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?overlay/slide-in=20=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 UX 이슈 3가지: - 좌측 테이블 목록에서 체크박스 첫 선택 시 삭제 버튼이 등장하면서 헤더 영역 높이가 변해 아래 리스트가 살짝 밀림 (layout shift). - 우측 디테일 패널이 conditional render 라 컬럼 클릭 시 가운데 본문이 380px 만큼 좁아져 컬럼명/라벨이 truncate 되며 "찌그러진" 느낌. - 닫는 방법이 X 버튼뿐이라 토글 직관성 부족. 변경: - 좌측 헤더 영역에 min-h-9 고정 — 삭제 버튼 등장해도 높이 고정, 리스트 안 흔들림. - 우측 디테일 패널을 overlay 로 전환: absolute right-0 z-20 + shadow-2xl. transition-transform + translate-x-{0|full} 로 300ms ease-out slide-in/out. pointer-events-none 으로 닫혀있을 때 클릭 차단. - 가운데 본문 width 변동 0 — 컬럼 클릭해도 안 좁아짐. - 컬럼 토글: 같은 컬럼 재클릭 시 디테일 패널 닫힘. X 버튼/외부 트리거도 그대로 동작. invyone admin 다른 화면들과의 일관성보다 가운데 본문 공간 보존이 우선이라 overlay 패턴 채택. 다른 화면(screenMngList, deptMngList)은 detail 영역이 처음부터 펼쳐진 2-pane 구조라 별개. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/systemMng/tableMngList/page.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 0170d5e7..6412db8b 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -1385,8 +1385,8 @@ export default function TableManagementPage() {
- {/* 3패널 메인 */} -
+ {/* 메인 (우측 패널은 overlay 라 2패널 layout) */} +
{/* 좌측: 테이블 목록 (240px) */}
{/* 검색 */} @@ -1401,7 +1401,7 @@ export default function TableManagementPage() { />
{isSuperAdmin && ( -
+
setSelectedColumn((prev) => (prev === c ? null : c))} onColumnChange={(columnName, field, value) => { if (field === "is_unique") { const currentColumn = columns.find((c) => c.column_name === columnName); @@ -1690,10 +1690,14 @@ export default function TableManagementPage() { )}
- {/* 우측: 상세 패널 (selectedColumn 있을 때만) */} - {selectedColumn && ( -
- + c.column_name === selectedColumn) ?? null} tables={tables} referenceTableColumns={referenceTableColumns} @@ -1719,8 +1723,7 @@ export default function TableManagementPage() { codeInfoOptions={commonCodeOptions} referenceTableOptions={referenceTableOptions} /> -
- )} +
{/* DDL 모달 컴포넌트들 */} From d306ac2865dec70e110494cac24c2316307fbcd5 Mon Sep 17 00:00:00 2001 From: johngreen Date: Sat, 16 May 2026 22:42:45 +0900 Subject: [PATCH 26/27] =?UTF-8?q?fix(=ED=85=8C=EC=9D=B4=EB=B8=94=ED=83=80?= =?UTF-8?q?=EC=9E=85):=20dropdown=20key=20=EC=A4=91=EB=B3=B5=20+=20hook=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20+=20=ED=83=AD=EB=B0=94=20outline=20+=20?= =?UTF-8?q?=EC=A2=8C=EC=B8=A1=20list=20=ED=8F=B0=ED=8A=B8=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EC=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 오늘 시리즈 후속 UX 다듬기 + 회귀 fix: 1) ColumnDetailPanel: dropdown key 중복 방어 - codeInfoOptions 에 placeholder "none" + 데이터 "none" 중복 시 React 가 'two children with the same key, none' 으로 거부 → filter 로 사전 제거. - refTableOpts 도 referenceTableOptions/tables 어디서든 중복 들어오면 같은 증상 → Set 기반 dedupe. 2) ColumnDetailPanel: hook 순서 위반 수정 - 기존 'if (!column) return null' 이 useMemo(refTableOpts) 앞에 있어서 column null/존재 케이스마다 hook 호출 수가 달라짐 (Rules of Hooks 위반). overlay 패턴 도입 후 column null 케이스가 자주 들어오면서 드러남. - early return 을 모든 hook 뒤로 이동. 3) v5-layout.css 탭바: Chrome 식 outline 스타일 - 비활성 탭도 각자 outline 보이게 (border:1px solid var(--v5-border))로 카드처럼 분리. - 활성 탭은 border + surface-hover 배경 + 위쪽 primary 1px inset 강조선. - 위 모서리 rounded, margin-bottom:-1px 로 탭바 하단 border 와 seamless 연결. 4) 좌측 테이블 list 폰트 사이즈 축소 - 한글명 16px → 13px, 영문명 12px → 10.5px, 행 padding 7px → 6px. - 280px 좁은 패널에 맞는 컴팩트 비율로 v5 컨벤션 정렬. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/systemMng/tableMngList/page.tsx | 6 +++--- .../admin/table-type/ColumnDetailPanel.tsx | 21 +++++++++++++++---- frontend/styles/v5-layout.css | 10 ++++++--- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 6412db8b..144203bd 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -1458,7 +1458,7 @@ export default function TableManagementPage() { )}
{table.display_name || table.table_name}
-
+
{table.table_name}
diff --git a/frontend/components/admin/table-type/ColumnDetailPanel.tsx b/frontend/components/admin/table-type/ColumnDetailPanel.tsx index c49a1a22..8b365576 100644 --- a/frontend/components/admin/table-type/ColumnDetailPanel.tsx +++ b/frontend/components/admin/table-type/ColumnDetailPanel.tsx @@ -75,11 +75,9 @@ export function ColumnDetailPanel({ return n; }, [column]); - if (!column) return null; - const refTableOpts = useMemo(() => { const hasKorean = (s: string) => /[가-힣]/.test(s); - const raw = referenceTableOptions.length + const rawSource = referenceTableOptions.length ? [...referenceTableOptions] : [ { value: "none", label: "없음" }, @@ -92,6 +90,14 @@ export function ColumnDetailPanel({ })), ]; + // value 기준 dedupe — referenceTableOptions/tables 어디서든 중복 들어오면 React key 충돌 + const seen = new Set(); + const raw = rawSource.filter((o) => { + if (seen.has(o.value)) return false; + seen.add(o.value); + return true; + }); + const noneOpt = raw.find((o) => o.value === "none"); const rest = raw.filter((o) => o.value !== "none"); @@ -106,6 +112,10 @@ export function ColumnDetailPanel({ return noneOpt ? [noneOpt, ...rest] : rest; }, [referenceTableOptions, tables]); + // early return 은 반드시 모든 hook 호출 뒤에 (Rules of Hooks). + // overlay 패턴으로 항상 마운트되므로 column null 케이스가 정상적으로 들어옴. + if (!column) return null; + return (
{/* 헤더 */} @@ -372,7 +382,10 @@ export function ColumnDetailPanel({ - {[{ value: "none", label: "선택 안함" }, ...codeInfoOptions].map((opt) => ( + {[ + { value: "none", label: "선택 안함" }, + ...codeInfoOptions.filter((opt) => opt.value !== "none"), + ].map((opt) => ( {opt.label} diff --git a/frontend/styles/v5-layout.css b/frontend/styles/v5-layout.css index 46a693d3..3b1b0321 100644 --- a/frontend/styles/v5-layout.css +++ b/frontend/styles/v5-layout.css @@ -413,15 +413,19 @@ html:not(.dark) .v5-hdr{ @keyframes v5-bdPulse{0%,100%{box-shadow:0 0 4px var(--v5-primary-glow)}50%{box-shadow:0 0 12px var(--v5-primary-glow)}} /* ===== SOLID TABS ===== */ -.v5-tabs{height:36px;display:flex;align-items:stretch;padding:0 .5rem;gap:1px;overflow-x:auto; +.v5-tabs{height:36px;display:flex;align-items:stretch;padding:4px .5rem 0;gap:2px;overflow-x:auto; background:var(--v5-surface-solid); border-bottom:1px solid var(--v5-border);position:relative;z-index:15;flex-shrink:0; scrollbar-width:none;-ms-overflow-style:none;} .v5-tabs::-webkit-scrollbar{display:none;} +/* Chrome 식 outline 탭: 비활성도 카드처럼 각각 outline. 활성 탭은 본문과 seamless + primary 강조선 */ .v5-tab{display:flex;align-items:center;gap:.4rem;padding:0 .85rem;font-size:.7rem;font-weight:500; - color:var(--v5-text-muted);cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap;transition:all .25s;} + color:var(--v5-text-muted);cursor:pointer;white-space:nowrap;transition:color .15s,border-color .15s,background .15s; + border:1px solid var(--v5-border);border-radius:8px 8px 0 0;margin-bottom:-1px;} .v5-tab:hover{color:var(--v5-text-sec);background:var(--v5-surface-hover);} -.v5-tab.on{color:var(--v5-primary);font-weight:600;border-bottom-color:var(--v5-primary);background:var(--v5-surface);} +.v5-tab.on{color:var(--v5-primary);font-weight:600; + border-color:var(--v5-border);border-bottom-color:var(--v5-surface-hover); + background:var(--v5-surface-hover);box-shadow:0 -1px 0 var(--v5-primary) inset;} .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;} From 75f68834973ecdfd715849b51f47d10aeb9967be Mon Sep 17 00:00:00 2001 From: johngreen Date: Mon, 18 May 2026 08:06:07 +0900 Subject: [PATCH 27/27] =?UTF-8?q?fix(=ED=85=8C=EC=9D=B4=EB=B8=94=ED=83=80?= =?UTF-8?q?=EC=9E=85):=20=ED=97=A4=EB=8D=94=20=EC=9D=B8=EB=9D=BC=EC=9D=B8?= =?UTF-8?q?=20=ED=8E=B8=EC=A7=91=EC=9D=84=20=EB=AA=85=EC=8B=9C=EC=A0=81=20?= =?UTF-8?q?=EC=97=B0=ED=95=84=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=A7=84?= =?UTF-8?q?=EC=9E=85=EC=9C=BC=EB=A1=9C=20+=20=EC=BB=AC=EB=9F=BC=EB=AA=85?= =?UTF-8?q?=20=ED=8F=B0=ED=8A=B8=20=EC=B6=95=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) 테이블 헤더 (표시명/설명) 편집 진입 방식 변경 - 기존: 텍스트 div 자체가 role="button" + onClick 이라 무심코 클릭 시 input 으로 전환 - 변경: 텍스트는 단순 span, 옆에 작은 Pencil 아이콘 버튼 추가. 그 버튼 클릭해야 편집 모드 진입. - 연필 아이콘은 평소 muted-foreground/50 톤, hover 시 진해짐 (group-hover 의존 X — Tailwind variant 캐시 회피). - 편집 모드 동작 (Enter / Esc / blur 커밋) 은 그대로. 2) ColumnGrid: 컬럼 라벨 text-sm → text-xs (14px → 12px) - 가운데 본문 컬럼 행이 너무 커보이던 문제. 좌측 list 폰트(이전 commit) 와 비례 맞춤. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/systemMng/tableMngList/page.tsx | 69 +++++++++---------- .../admin/table-type/ColumnGrid.tsx | 2 +- 2 files changed, 34 insertions(+), 37 deletions(-) diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 144203bd..1054a84f 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -20,6 +20,7 @@ import { Check, ChevronsUpDown, Loader2, + Pencil, } from "lucide-react"; import { cn } from "@/lib/utils"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; @@ -1551,26 +1552,24 @@ export default function TableManagementPage() { className="h-7 -mx-2 px-2 text-[15px] font-bold tracking-tight" /> ) : ( -
{ - setEditingHeaderValue(tableLabel); - setEditingHeaderField("label"); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); +
+ + {tableLabel || ( + {selectedTable} + )} + +
)} {/* table_name (코드, 편집 불가) */} @@ -1596,26 +1595,24 @@ export default function TableManagementPage() { className="mt-1 h-7 -mx-2 px-2 text-xs" /> ) : ( -
{ - setEditingHeaderValue(tableDescription); - setEditingHeaderField("description"); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); +
+ + {tableDescription || ( + + 설명 추가 + )} + +
)}
diff --git a/frontend/components/admin/table-type/ColumnGrid.tsx b/frontend/components/admin/table-type/ColumnGrid.tsx index e1f94a82..2a17b716 100644 --- a/frontend/components/admin/table-type/ColumnGrid.tsx +++ b/frontend/components/admin/table-type/ColumnGrid.tsx @@ -144,7 +144,7 @@ export function ColumnGrid({ {/* 라벨 + 컬럼명 (한글라벨 (영어명) 동시 표시) */}
-
+
{column.display_name && column.display_name !== column.column_name ? `${column.display_name} (${column.column_name})` : column.column_name}