refactor(numbering-rule): NumberingRule → Input canonical 흡수 + 채번 관리 페이지 분리

- 옛 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 21:42:13 +09:00
parent 59f5cf22f0
commit a5bbd1eb7c
40 changed files with 3735 additions and 1247 deletions
@@ -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<String, Object> formData = body != null ? (Map<String, Object>) 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<String, Object> formData = body != null ? (Map<String, Object>) 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<String, Object> formData = body != null ? (Map<String, Object>) 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<ApiResponse<Void>> 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<ApiResponse<Void>> updateRuleSequence(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role,
@PathVariable String ruleId,
@RequestBody Map<String, Object> 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
// ================================================================
@@ -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<String, Object> 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<String, Object> 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;
}
@@ -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
</sql>
<sql id="partColumns">
@@ -42,7 +42,7 @@
<otherwise>AND (company_code = #{company_code} OR company_code = '*')</otherwise>
</choose>
ORDER BY CREATED_DATE DESC
ORDER BY created_at DESC
</select>
<select id="getRuleById" parameterType="map" resultType="map">
@@ -61,19 +61,19 @@
INSERT INTO numbering_rules (
rule_id, rule_name, description, separator, reset_period,
current_sequence, table_name, column_name, company_code,
category_column, category_value_id, created_by, CREATED_DATE, UPDATED_DATE
category_column, category_value_id, created_by, created_at, updated_at
) VALUES (
#{rule_id},
#{rule_name},
#{description, jdbcType=VARCHAR},
#{separator, jdbcType=VARCHAR},
#{reset_period, jdbcType=VARCHAR},
#{current_sequence, jdbcType=INTEGER},
#{current_sequence, jdbcType=VARCHAR},
#{table_name, jdbcType=VARCHAR},
#{column_name, jdbcType=VARCHAR},
#{company_code},
#{category_column, jdbcType=VARCHAR},
#{category_value_id, jdbcType=INTEGER},
#{category_value_id, jdbcType=VARCHAR},
#{created_by, jdbcType=VARCHAR},
NOW(), NOW()
)
@@ -89,8 +89,8 @@
table_name = COALESCE(#{table_name, jdbcType=VARCHAR}, table_name),
column_name = COALESCE(#{column_name, jdbcType=VARCHAR}, column_name),
category_column = COALESCE(#{category_column, jdbcType=VARCHAR}, category_column),
category_value_id = COALESCE(#{category_value_id, jdbcType=INTEGER}, category_value_id),
UPDATED_DATE = NOW()
category_value_id = COALESCE(#{category_value_id, jdbcType=VARCHAR}, category_value_id),
updated_at = NOW()
WHERE rule_id = #{rule_id}
AND (company_code = #{company_code} OR company_code = '*')
@@ -122,7 +122,7 @@
<insert id="insertRulePart" parameterType="map">
INSERT INTO numbering_rule_parts (
rule_id, part_order, part_type, generation_method,
auto_config, manual_config, company_code, CREATED_DATE
auto_config, manual_config, company_code, created_at
) VALUES (
#{rule_id},
#{order},
@@ -164,7 +164,17 @@
<update id="updateCurrentSequenceInRule" parameterType="map">
UPDATE numbering_rules
SET current_sequence = GREATEST(COALESCE(current_sequence, '0'), #{current_sequence}),
UPDATED_DATE = NOW()
updated_at = NOW()
WHERE rule_id = #{rule_id}
AND (company_code = #{company_code} OR company_code = '*')
</update>
<!-- admin 전용: GREATEST 없이 직접 SET. 임의 값 (0 포함) 으로 내릴 수 있음 -->
<update id="setCurrentSequenceInRule" parameterType="map">
UPDATE numbering_rules
SET current_sequence = #{current_sequence},
updated_at = NOW()
WHERE rule_id = #{rule_id}
AND (company_code = #{company_code} OR company_code = '*')
@@ -183,7 +193,7 @@
<otherwise>AND (company_code = #{company_code} OR company_code = '*')</otherwise>
</choose>
ORDER BY CREATED_DATE DESC
ORDER BY created_at DESC
</select>
<select id="getAvailableRulesForScreen" parameterType="map" resultType="map">
@@ -200,7 +210,7 @@
AND table_name = #{table_name}
</if>
ORDER BY CREATED_DATE DESC
ORDER BY created_at DESC
</select>
<select id="getRuleByColumn" parameterType="map" resultType="map">
@@ -218,8 +228,8 @@
r.category_value_id AS category_value_id,
cv.value_label AS category_value_label,
r.created_by AS created_by,
r.CREATED_DATE AS CREATED_DATE,
r.UPDATED_DATE AS UPDATED_DATE
r.created_at AS created_at,
r.updated_at AS updated_at
FROM numbering_rules r
@@ -247,8 +257,8 @@
r.category_value_id AS category_value_id,
cv.value_label AS category_value_label,
r.created_by AS created_by,
r.CREATED_DATE AS CREATED_DATE,
r.UPDATED_DATE AS UPDATED_DATE
r.created_at AS created_at,
r.updated_at AS updated_at
FROM numbering_rules r
@@ -259,7 +269,7 @@
AND (r.column_name IS NULL OR r.column_name = '')
AND r.category_value_id IS NULL
ORDER BY r.UPDATED_DATE DESC
ORDER BY r.updated_at DESC
LIMIT 1
</select>
@@ -280,7 +290,7 @@
WHERE (company_code = #{company_code} OR company_code = '*')
ORDER BY CREATED_DATE
ORDER BY created_at
</select>
<select id="getRulePartsForCopy" parameterType="map" resultType="map">
@@ -0,0 +1,275 @@
"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 { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import {
getNumberingRulesFromTest,
deleteNumberingRuleFromTest,
} 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";
/**
* 채번 관리 (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<NumberingRuleConfig[]>([]);
const [loading, setLoading] = useState(false);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const selectedRule = useMemo(
() => rules.find((r) => String(r.rule_id) === selectedRuleId) ?? null,
[rules, selectedRuleId],
);
const loadRules = useCallback(async () => {
setLoading(true);
try {
const resp = await getNumberingRulesFromTest();
if (resp.success && resp.data) {
setRules(resp.data);
} else {
setRules([]);
if (resp.error) showErrorToast("규칙 목록 조회 실패", resp.error);
}
} catch (e) {
setRules([]);
showErrorToast("규칙 목록 조회 실패", e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
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<string, NumberingRuleConfig[]> = {};
for (const r of filtered) {
const key = r.table_name || "(미지정)";
if (!groups[key]) groups[key] = [];
groups[key].push(r);
}
return Object.entries(groups).sort(([a], [b]) => a.localeCompare(b));
}, [rules, search]);
const handleDelete = async (ruleId: string) => {
if (!window.confirm("이 채번 규칙을 삭제하시겠습니까? 시퀀스 기록도 함께 삭제됩니다.")) return;
try {
const resp = await deleteNumberingRuleFromTest(ruleId);
if (resp.success) {
toast.success("규칙이 삭제되었습니다");
if (selectedRuleId === ruleId) setSelectedRuleId(null);
setRefreshKey((k) => k + 1);
} else {
showErrorToast("규칙 삭제 실패", resp.error);
}
} catch (e) {
showErrorToast("규칙 삭제 실패", e);
}
};
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between border-b border-border bg-card px-4 py-3">
<div className="flex items-center gap-2">
<Hash className="h-5 w-5 text-primary" />
<h1 className="text-base font-bold"> </h1>
<span className="text-xs text-muted-foreground">
({rules.length} )
</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setRefreshKey((k) => k + 1)}
disabled={loading}
className="h-8"
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
<span className="ml-1 text-xs"></span>
</Button>
<Button
size="sm"
variant="default"
onClick={() => setCreateOpen(true)}
className="h-8"
>
<Plus className="h-3.5 w-3.5" />
<span className="ml-1 text-xs"> </span>
</Button>
</div>
</div>
{/* 본문: 좌측 목록 + 우측 상세 */}
<div className="flex min-h-0 flex-1">
{/* 좌측: 규칙 목록 */}
<aside className="flex w-[300px] flex-shrink-0 flex-col border-r border-border bg-card">
<div className="border-b border-border p-3">
<div className="relative">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="규칙명 / 테이블 / 컬럼 검색"
className="h-8 pl-7 text-xs"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{loading && rules.length === 0 ? (
<div className="flex h-32 items-center justify-center text-xs text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> ...
</div>
) : groupedRules.length === 0 ? (
<div className="flex h-32 flex-col items-center justify-center gap-1.5 px-3 text-center text-xs text-muted-foreground">
<Hash className="h-6 w-6" />
{search ? "검색 결과가 없습니다" : "등록된 채번 규칙이 없습니다"}
</div>
) : (
groupedRules.map(([tableName, group]) => (
<div key={tableName}>
<div className="flex items-center gap-1.5 bg-muted/40 px-3 py-1.5">
<Table2 className="h-3 w-3 text-muted-foreground" />
<span className="truncate text-[10px] font-bold text-muted-foreground">
{tableName}
</span>
<span className="text-[10px] text-muted-foreground">({group.length})</span>
</div>
{group.map((rule) => {
const id = String(rule.rule_id);
const isSelected = selectedRuleId === id;
return (
<div
key={id}
className={cn(
"group flex items-center justify-between border-b border-border/30 px-3 py-2 transition-colors",
isSelected
? "border-l-[3px] border-l-primary bg-primary/5 pl-2.5"
: "hover:bg-accent",
)}
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => setSelectedRuleId(id)}
>
<Hash className="h-3 w-3 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className={cn("truncate text-xs", isSelected && "font-bold")}>
{rule.rule_name}
</div>
<div className="truncate text-[10px] text-muted-foreground">
{rule.column_name} · seq {rule.current_sequence ?? 0}
</div>
</div>
</button>
<button
type="button"
className="ml-1 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
handleDelete(id);
}}
title="규칙 삭제"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
);
})}
</div>
))
)}
</div>
</aside>
{/* 우측: 선택된 규칙 상세 */}
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
{!selectedRule ? (
<div className="flex flex-1 flex-col items-center justify-center text-center text-muted-foreground">
<Hash className="mb-3 h-10 w-10" />
<p className="mb-1 text-base font-medium"> </p>
<p className="text-xs">
+ 퀀
</p>
</div>
) : (
<>
{/* ① 규칙 디자이너 (상단 2/3) — initialConfig 로 정확한 rule 주입 (by-column 우회) */}
<div className="flex min-h-0 flex-[2_2_0%] border-b border-border">
<NumberingRuleDesigner
key={`designer-${selectedRule.rule_id}`}
initialConfig={selectedRule}
lockedColumn={
selectedRule.table_name && selectedRule.column_name
? {
tableName: selectedRule.table_name,
columnName: selectedRule.column_name,
}
: undefined
}
currentTableName={selectedRule.table_name}
onSave={() => setRefreshKey((k) => k + 1)}
/>
</div>
{/* ② 시퀀스 관리 (하단 1/3) */}
<div className="flex min-h-0 flex-1 overflow-y-auto">
<SequenceManagementPanel
key={`seq-${selectedRule.rule_id}-${refreshKey}`}
ruleId={String(selectedRule.rule_id)}
onChanged={() => setRefreshKey((k) => k + 1)}
/>
</div>
</>
)}
</main>
</div>
{/* 신규 규칙 생성 모달 — 컬럼 자유 선택 (lockedColumn 없음) */}
<NumberingRuleCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
tableName=""
onCreated={(rule) => {
if (rule.rule_id) setSelectedRuleId(String(rule.rule_id));
setRefreshKey((k) => k + 1);
}}
/>
</div>
);
}
+3 -1
View File
@@ -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 = (
@@ -76,6 +76,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
// 시스템 관리
"/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 }),
@@ -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<NumberingRuleCreateDialogProps> = ({
open,
onOpenChange,
tableName,
columnName,
onCreated,
}) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex h-[80vh] max-w-4xl flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="border-b border-border px-4 py-3">
<DialogTitle className="text-sm">
{tableName}
{columnName ? `.${columnName}` : ""}
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1">
<NumberingRuleDesigner
currentTableName={tableName}
lockedColumn={columnName ? { tableName, columnName } : undefined}
onSave={(rule) => {
onCreated?.(rule);
onOpenChange(false);
}}
/>
</div>
</DialogContent>
</Dialog>
);
};
@@ -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<NumberingRuleDesignerProps> = ({
@@ -48,9 +53,12 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
className = "",
currentTableName,
menuObjid,
lockedColumn,
}) => {
const [numberingColumns, setNumberingColumns] = useState<NumberingColumn[]>([]);
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<NumberingRuleConfig | null>(null);
const [selectedPartOrder, setSelectedPartOrder] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
@@ -62,6 +70,29 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
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<NumberingRuleDesignerProps> = ({
return (
<div className={cn("flex h-full", className)}>
{/* 좌측: 채번 컬럼 목록 (테이블별 그룹화) */}
{/* 좌측: 채번 컬럼 목록 (테이블별 그룹화) — lockedColumn 시 hide */}
{!lockedColumn && (
<div className="code-nav flex w-[240px] flex-shrink-0 flex-col border-r border-border">
<div className="code-nav-head flex flex-col gap-2 border-b border-border px-3 py-2.5">
<div className="flex items-center gap-2">
@@ -370,15 +402,20 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
)}
</div>
</div>
)}
{/* 우측: 미리보기 + 파이프라인 + 설정 + 저장 바 */}
<div className="code-main flex min-w-0 flex-1 flex-col overflow-hidden">
{!currentRule ? (
<div className="flex flex-1 flex-col items-center justify-center text-center">
<Hash className="mb-3 h-10 w-10 text-muted-foreground" />
<p className="mb-2 text-lg font-medium text-muted-foreground"> </p>
<p className="mb-2 text-lg font-medium text-muted-foreground">
{lockedColumn ? "채번 규칙을 불러오는 중..." : "컬럼을 선택하세요"}
</p>
<p className="text-sm text-muted-foreground">
{lockedColumn
? "잠시만 기다려 주세요"
: "좌측에서 채번 컬럼을 선택하면 규칙을 편집할 수 있습니다"}
</p>
</div>
) : (
@@ -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<SequenceManagementPanelProps> = ({
ruleId,
onChanged,
}) => {
const [rule, setRule] = useState<NumberingRuleConfig | null>(null);
const [loading, setLoading] = useState(false);
// mutation lock — 저장/초기화 동시 실행 방지 (race condition)
const [mutating, setMutating] = useState<null | "save" | "reset">(null);
const [draftSequence, setDraftSequence] = useState<string>("");
const [preview, setPreview] = useState<string>("");
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 (
<div className="flex items-center justify-center p-6">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (!rule) {
return (
<div className="p-4 text-sm text-muted-foreground">
.
</div>
);
}
const current = rule.current_sequence ?? 0;
const dirty = String(current) !== draftSequence;
return (
<div className="flex flex-col gap-4 p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-bold">퀀 </h3>
<p className="mt-0.5 text-xs text-muted-foreground">
{rule.rule_name} ({rule.table_name}.{rule.column_name})
</p>
</div>
</div>
<div className="grid grid-cols-[auto_1fr_auto] items-center gap-3 rounded-md border border-border bg-card p-3">
<label className="text-xs font-semibold"> 퀀</label>
<Input
type="number"
min={0}
value={draftSequence}
onChange={(e) => setDraftSequence(e.target.value)}
className="h-8 max-w-[160px] font-mono"
/>
<div className="flex gap-1.5">
<Button
size="sm"
variant="default"
disabled={!dirty || mutating !== null}
onClick={handleSave}
className="h-8"
>
{mutating === "save" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
<span className="ml-1 text-xs"></span>
</Button>
<Button
size="sm"
variant="outline"
disabled={mutating !== null}
onClick={handleReset}
className="h-8"
>
{mutating === "reset" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="h-3.5 w-3.5" />
)}
<span className="ml-1 text-xs"></span>
</Button>
</div>
</div>
<div className="flex flex-col gap-2 rounded-md border border-border bg-muted/30 p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold"> </span>
<Button
size="sm"
variant="ghost"
disabled={previewing}
onClick={handlePreview}
className="h-7"
>
{previewing ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Eye className="h-3 w-3" />
)}
<span className="ml-1 text-xs"></span>
</Button>
</div>
<div className="rounded bg-background p-2 font-mono text-sm">
{preview || <span className="text-xs text-muted-foreground">'조회' </span>}
</div>
<p className="text-[10px] text-muted-foreground">
퀀 . form 1 .
</p>
</div>
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-2.5 text-xs text-amber-900 dark:text-amber-200">
<p className="font-semibold"> </p>
<p className="mt-1 leading-relaxed">
퀀 .
.
</p>
</div>
</div>
);
};
@@ -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
@@ -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,
},
],
};
@@ -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<string, any>) {
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<string, any>,
kind: Kind,
@@ -298,22 +318,13 @@ function applyTriple(
ctx: { numberingTableName?: string },
): Record<string, any> {
const next: Record<string, any> = { ...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<InvFieldConfigPanelProps> = ({
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
const [loadingRules, setLoadingRules] = useState(false);
const [rulesRefreshKey, setRulesRefreshKey] = useState(0);
const numberingTableName = primaryTableName;
const [entityColumns, setEntityColumns] = useState<ColumnOption[]>([]);
@@ -542,7 +544,7 @@ export const InvFieldConfigPanel: React.FC<InvFieldConfigPanelProps> = ({
return () => {
cancelled = true;
};
}, [numberingTableName, currentKind, fieldType]);
}, [numberingTableName, currentKind, fieldType, rulesRefreshKey]);
// 엔티티 컬럼 로드
const loadEntityColumns = useCallback(
@@ -761,6 +763,7 @@ export const InvFieldConfigPanel: React.FC<InvFieldConfigPanelProps> = ({
numberingRules={numberingRules}
loadingRules={loadingRules}
numberingTableName={numberingTableName}
onRulesRefresh={() => setRulesRefreshKey((k) => k + 1)}
/>
</CPSection>
@@ -780,7 +783,7 @@ export const InvFieldConfigPanel: React.FC<InvFieldConfigPanelProps> = ({
{currentKind !== "auto" && (
<CPGroup title="고급 설정" defaultOpen={false}>
{isSelectGroup ? (
<SelectAdvancedOptions config={config} updateConfig={updateConfig} />
<SelectAdvancedOptions config={config} updateConfig={updateConfig} multi={fieldType === "multi"} />
) : (
<InputAdvancedOptions config={config} updateConfig={updateConfig} />
)}
@@ -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) {
<NumberingOptions
config={config}
numberingTableName={p.numberingTableName}
columnName={p.columnName}
loading={p.loadingRules}
rules={p.numberingRules}
onChange={onChange}
updateConfig={updateConfig}
onRulesRefresh={p.onRulesRefresh}
/>
);
if (kind === "auto" && type === "formula") return <FormulaOptions config={config} updateConfig={updateConfig} />;
@@ -1855,18 +1861,42 @@ function FileOptions({
function NumberingOptions({
config,
numberingTableName,
columnName,
loading,
rules,
onChange,
updateConfig,
onRulesRefresh,
}: {
config: Record<string, any>;
numberingTableName: string;
columnName?: string;
loading: boolean;
rules: NumberingRuleConfig[];
onChange: (config: Record<string, any>) => 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 (
<>
<CPRow label="대상 테이블">
@@ -1877,27 +1907,25 @@ function NumberingOptions({
)}
</CPRow>
{numberingTableName && (
<CPRow label="채번 규칙" required>
{loading ? (
<InlineLoader text="채번 규칙 로딩 중..." />
) : rules.length === 0 ? (
<Hint tone="warn"> </Hint>
) : (
<CPSelect
value={config.autoGeneration?.numberingRuleId || ""}
onChange={(v) =>
onChange({
...config,
autoGeneration: {
...config.autoGeneration,
type: "numbering_rule" as AutoGenerationType,
numberingRuleId: v,
tableName: numberingTableName,
},
})
<>
<CPRow
label="채번 규칙"
required
help={
!loading && rules.length === 0
? "이 테이블에 등록된 채번 규칙이 없어요. 아래 버튼으로 새로 만드세요."
: undefined
}
>
<option value=""> </option>
{loading ? (
<InlineLoader text="채번 규칙 로딩 중..." />
) : (
<CPSelect
value={config.autoGeneration?.options?.numberingRuleId || ""}
onChange={(v) => applyRuleId(v)}
disabled={rules.length === 0}
>
<option value="">{rules.length === 0 ? "등록된 규칙 없음" : "채번 규칙 선택"}</option>
{rules.map((rule) => (
<option key={rule.rule_id} value={String(rule.rule_id)}>
{rule.rule_name} ({rule.separator || "-"}
@@ -1907,10 +1935,45 @@ function NumberingOptions({
</CPSelect>
)}
</CPRow>
<CPRow label="">
<button
type="button"
onClick={() => setCreateOpen(true)}
style={{
alignSelf: "flex-start",
padding: "4px 10px",
fontSize: 11,
fontWeight: 600,
color: "hsl(var(--primary))",
background: "hsl(var(--primary) / 0.08)",
border: "1px solid hsl(var(--primary) / 0.3)",
borderRadius: 4,
cursor: "pointer",
}}
>
+
</button>
</CPRow>
</>
)}
<CPRow label="읽기 전용" help="채번 필드는 자동 생성되므로 읽기전용 권장">
<div style={{ display: "flex", alignItems: "center", minHeight: 20 }}>
<CPSwitch value={config.readonly !== false} onChange={(v) => updateConfig("readonly", v)} />
</div>
</CPRow>
{numberingTableName && (
<NumberingRuleCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
tableName={numberingTableName}
columnName={columnName}
onCreated={(rule) => {
// 저장 직후: 새 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<string, any>;
updateConfig: (k: string, v: any) => void;
multi?: boolean;
}) {
return (
<>
@@ -1931,16 +1996,13 @@ function SelectAdvancedOptions({
<CPSelect value={config.mode || "dropdown"} onChange={(v) => updateConfig("mode", v)}>
<option value="dropdown"></option>
<option value="combobox"> </option>
<option value="radio"> </option>
<option value="check"></option>
<option value="tag"> </option>
<option value="toggle"></option>
{!multi && <option value="radio"> </option>}
{multi && <option value="check"></option>}
{multi && <option value="tag"> </option>}
{!multi && <option value="toggle"></option>}
</CPSelect>
</CPRow>
<CPRow label="복수 선택" help="한 번에 여러 값을 선택">
<CPSwitch value={!!config.multiple} onChange={(v) => updateConfig("multiple", v)} />
</CPRow>
{config.multiple && (
{multi && (
<CPRow label="최대 개수">
<CPNumber
value={config.maxSelect ?? ""}
@@ -1,206 +0,0 @@
"use client";
/**
* V2NumberingRule 설정 패널
* 토스식 단계별 UX: 최대 규칙 수(카드선택) -> 카드 레이아웃(카드선택) -> 표시/동작(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<V2NumberingRuleConfigPanelProps> = ({
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 (
<div className="space-y-4">
{/* ─── 1단계: 최대 규칙 수 카드 선택 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="grid grid-cols-4 gap-2">
{MAX_RULES_CARDS.map((card) => {
const isSelected = (config.maxRules || 6) === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("maxRules", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-2.5 text-center transition-all min-h-[60px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<span className="text-xs font-medium">{card.label}</span>
<span className="text-[10px] text-muted-foreground mt-0.5">
{card.desc}
</span>
</button>
);
})}
</div>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
{/* ─── 2단계: 카드 레이아웃 카드 선택 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="grid grid-cols-2 gap-2">
{LAYOUT_CARDS.map((card) => {
const Icon = card.icon;
const isSelected = (config.cardLayout || "vertical") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("cardLayout", card.value)}
className={cn(
"flex items-center gap-2 rounded-lg border p-3 text-left transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<Icon className="h-5 w-5 shrink-0 text-muted-foreground" />
<div>
<span className="text-xs font-medium block">{card.label}</span>
<span className="text-[10px] text-muted-foreground block">
{card.desc}
</span>
</div>
</button>
);
})}
</div>
</div>
{/* ─── 3단계: 표시 설정 (Switch) ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.showPreview !== false}
onCheckedChange={(checked) => updateConfig("showPreview", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.showRuleList !== false}
onCheckedChange={(checked) => updateConfig("showRuleList", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.enableReorder !== false}
onCheckedChange={(checked) => updateConfig("enableReorder", checked)}
/>
</div>
</div>
</div>
{/* ─── 4단계: 고급 설정 (기본 접혀있음) ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.readonly || false}
onCheckedChange={(checked) => updateConfig("readonly", checked)}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2NumberingRuleConfigPanel.displayName = "V2NumberingRuleConfigPanel";
export default V2NumberingRuleConfigPanel;
@@ -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<HTMLDivElement>(null);
const searchInputRef = React.useRef<HTMLInputElement>(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 후보로 사용 (호환: <option value="">선택...</option>)
const firstEmpty = opts.find((o) => o.value === "");
const placeholderText =
@@ -497,15 +529,15 @@ export function CPSelect({
{showPlaceholder ? placeholderText : current?.label}
</span>
</button>
{open && (
{open && popPos && typeof document !== "undefined" && createPortal(
<div
ref={popRef}
style={{
position: "absolute",
top: "calc(100% + 2px)",
left: 0,
right: 0,
zIndex: 60,
position: "fixed",
top: popPos.top,
left: popPos.left,
width: popPos.width,
zIndex: 9999,
background: "var(--cp-surface)",
border: "1px solid var(--cp-border)",
borderRadius: 4,
@@ -587,7 +619,8 @@ export function CPSelect({
</div>
)}
</div>
</div>
</div>,
document.body
)}
</div>
);
+18
View File
@@ -172,6 +172,24 @@ export async function resetSequence(ruleId: string): Promise<ApiResponse<void>>
}
}
/**
* 현재 시퀀스 임의 값으로 수정 (admin)
* backend `PUT /api/numbering-rules/:ruleId/sequence` body={sequence}
*/
export async function updateRuleSequence(
ruleId: string,
newSequence: number,
): Promise<ApiResponse<void>> {
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 테이블 사용) ======
/**
@@ -415,42 +415,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
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와 비교
+2 -2
View File
@@ -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";
@@ -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<InputComponentProps> = ({
...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<InputComponentProps> = ({
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<InputComponentProps> = ({
? (props as any).value
: undefined;
// 의미있는 controlled 값 — 빈 문자열 / null 은 "값 없음" 으로 취급 → defaultValue fallback.
// (ScreenDesigner 가 디자인 모드에서 빈 value 를 넘기는 케이스 방어)
const hasControlled =
controlledValue !== undefined && controlledValue !== null && controlledValue !== "";
const [localValue, setLocalValue] = useState<unknown>(
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<InputComponentProps> = ({
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<InputComponentProps> = ({
} = 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<InputComponentProps> = ({
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<InputComponentProps> = ({
disabled={disabled}
readonly={readonly}
placeholder={placeholder}
className="border-0 bg-transparent rounded-none"
/>
);
case "datetime":
@@ -339,6 +440,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
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<InputComponentProps> = ({
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<InputComponentProps> = ({
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<InputComponentProps> = ({
/>
);
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 (
<select
<TagPicker
value={arrValue}
onChange={(v) => 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 (
<CheckboxListPicker
value={arrValue}
onChange={(v) => propagate(v)}
options={normalizedOptions}
maxSelect={(componentConfig as any).maxSelect}
disabled={disabled}
readonly={readonly}
className="border-0 bg-transparent rounded-none"
/>
);
}
// dropdown / combobox (기본) — MultiSelectPicker
return (
<MultiSelectPicker
value={arrValue}
onChange={(v) => 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 (
<RadioPicker
value={typeof value === "string" ? value : ""}
onChange={(e) => propagate(e.target.value)}
{...common}
>
<option value="">{placeholder || "선택하세요"}</option>
{options.map((opt, i) => {
if (typeof opt === "string") {
return (
<option key={i} value={opt}>
{opt}
</option>
onChange={(v) => propagate(v)}
options={normalizedOptions}
disabled={disabled}
readonly={readonly}
className="border-0 bg-transparent rounded-none"
/>
);
}
// dropdown / combobox (기본) — SingleSelectPicker
return (
<option key={i} value={opt.value}>
{opt.label}
</option>
);
})}
</select>
<SingleSelectPicker
value={typeof value === "string" ? value : ""}
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": {
// single.boolean 영역. mode=toggle (기본 권장) 이면 TogglePicker, 그 외 단일 체크박스.
const cbMode = (componentConfig as any).mode;
if (cbMode === "toggle") {
return (
<TogglePicker
value={value}
onChange={(v) => propagate(v)}
disabled={disabled}
readonly={readonly}
className="border-0 bg-transparent rounded-none"
/>
);
}
case "checkbox":
return (
<label
style={{
@@ -412,6 +591,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
alignItems: "center",
gap: "6px",
fontSize: "13px",
padding: "0 8px",
cursor: disabled ? "not-allowed" : "pointer",
}}
>
@@ -419,15 +599,16 @@ export const InputComponent: React.FC<InputComponentProps> = ({
type="checkbox"
checked={!!value}
onChange={(e) => propagate(e.target.checked)}
disabled={disabled || isDesignMode}
disabled={disabled}
readOnly={readonly}
/>
<span>{placeholder || label || "체크"}</span>
</label>
);
}
case "entity":
return (
<div style={{ display: "flex", gap: "4px" }}>
<div style={{ display: "flex", gap: "4px", width: "100%", height: "100%" }}>
<input
type="text"
value={typeof value === "string" ? value : ""}
@@ -441,10 +622,12 @@ export const InputComponent: React.FC<InputComponentProps> = ({
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<InputComponentProps> = ({
);
case "code":
return (
<input
type="text"
value={typeof value === "string" ? value : ""}
{...common}
readOnly
style={{
...baseInputStyle,
background: "hsl(var(--muted))",
fontFamily: "monospace",
color: "hsl(var(--muted-foreground))",
<NumberingPicker
value={value}
onChange={(v) => 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 (
<input
type="text"
type={inputHtmlType}
value={typeof value === "string" ? value : ""}
onChange={(e) => propagate(e.target.value)}
minLength={componentConfig.minLength}
@@ -491,6 +689,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
/>
);
}
}
};
return (
@@ -505,19 +704,24 @@ export const InputComponent: React.FC<InputComponentProps> = ({
{label && (
<label
style={{
position: "absolute",
top: "-18px",
left: 0,
fontSize: "11px",
fontWeight: 600,
color: "hsl(var(--foreground))",
display: "flex",
alignItems: "center",
gap: "3px",
whiteSpace: "nowrap",
pointerEvents: "none",
}}
>
{label}
{required && <span style={{ color: "hsl(var(--destructive))" }}>*</span>}
</label>
)}
{renderInput()}
<div style={inputSlotStyle}>{renderInput()}</div>
{helperText && (
<span
style={{
@@ -43,7 +43,7 @@ export const InputDefinition = createComponentDefinition({
web_type: "text",
component: InputWrapper,
default_config: DEFAULT_CONFIG,
default_size: { width: 240, height: 48 },
default_size: { width: 240, height: 30 },
config_panel: InvFieldConfigPanel,
icon: "Edit",
tags: ["입력", "input", "field", "text", "number", "date", "select"],
@@ -0,0 +1,414 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { previewNumberingCode } from "@/lib/api/numberingRule";
/**
* NumberingPicker picker
*
* InvField autonum . API
* , 릿 `____`
* (prefix span + input + suffix span) .
*
* V2Input.tsx numbering (state/effect/) . :
* - (preview API )
* - props.numberingRuleId (autoGen.options.numberingRuleId )
* - by-column API + getTableColumns fallback
*
* 관련: notes/gbpark/2026-05-08-input-canonical-migration.md §A.6
*/
export interface NumberingPickerProps {
value: unknown;
onChange: (v: string) => void;
tableName?: string;
columnName?: string;
formData?: Record<string, any>;
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<NumberingPickerProps> = ({
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<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [manualInputValue, setManualInputValue] = useState<string>("");
const ruleIdRef = useRef<string | null>(propRuleId ?? null);
const hasGeneratedRef = useRef<boolean>(false);
const lastCategoryValuesRef = useRef<string>("");
const userEditedRef = useRef<boolean>(false);
const hadManualPartRef = useRef<boolean>(false);
const templateRef = useRef<string>("");
const formDataRef = useRef<Record<string, any> | 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<string, string> = {};
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 (
<input
type="text"
value={displayValue}
readOnly
disabled={disabled || isGenerating}
placeholder={isGenerating ? "생성 중..." : placeholder || "자동채번"}
className={className}
style={{
width: "100%",
height: "100%",
padding: "5px 8px",
fontSize: "13px",
border: 0,
borderRadius: 0,
background: "hsl(var(--muted))",
color: "hsl(var(--muted-foreground))",
fontFamily: "monospace",
outline: "none",
boxSizing: "border-box",
...style,
}}
/>
);
}
// ____ 있는 경우: prefix + 편집 input + suffix
const parts = template.split("____");
const prefix = parts[0] || "";
const suffix = parts.length > 1 ? parts.slice(1).join("") : "";
return (
<div className={className} style={wrapperStyle}>
{prefix && (
<span
style={{
display: "flex",
alignItems: "center",
height: "100%",
padding: "0 8px",
fontSize: "13px",
fontFamily: "monospace",
color: "hsl(var(--muted-foreground))",
background: "hsl(var(--muted))",
whiteSpace: "nowrap",
}}
>
{prefix}
</span>
)}
<input
type="text"
value={manualInputValue}
onChange={(e) => {
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 && (
<span
style={{
display: "flex",
alignItems: "center",
height: "100%",
padding: "0 8px",
fontSize: "13px",
fontFamily: "monospace",
color: "hsl(var(--muted-foreground))",
background: "hsl(var(--muted))",
whiteSpace: "nowrap",
}}
>
{suffix}
</span>
)}
</div>
);
};
@@ -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 (
<div ref={ref} className={cn("flex h-full items-center gap-2", className)}>
<div ref={ref} className={cn("flex h-full items-center", className)}>
<RangeCalendarPopover
open={openStart}
onOpenChange={setOpenStart}
@@ -752,6 +754,7 @@ export const RangeDatePicker = forwardRef<
disabled={disabled}
readonly={readonly}
displayValue={value[0]}
className={className}
/>
<span className="text-muted-foreground">~</span>
<RangeCalendarPopover
@@ -763,6 +766,7 @@ export const RangeDatePicker = forwardRef<
disabled={disabled}
readonly={readonly}
displayValue={value[1]}
className={className}
/>
</div>
);
@@ -783,16 +787,20 @@ export const TimePicker = forwardRef<
}
>(({ value, onChange, disabled, readonly, className }, ref) => {
return (
<div className={cn("relative h-full", className)}>
<Clock className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
<div className="relative h-full w-full">
<Clock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<input
ref={ref}
type="time"
value={value || ""}
onChange={(e) => 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,
)}
/>
</div>
);
@@ -838,7 +846,7 @@ export const DateTimePicker = forwardRef<
);
return (
<div ref={ref} className={cn("flex h-full gap-2", className)}>
<div ref={ref} className={cn("flex h-full", className)}>
<div className="h-full flex-1">
<SingleDatePicker
value={datePart}
@@ -848,10 +856,12 @@ export const DateTimePicker = forwardRef<
maxDate={maxDate}
disabled={disabled}
readonly={readonly}
className={className}
/>
</div>
<div className="bg-border h-full w-px shrink-0 self-stretch" aria-hidden />
<div className="h-full w-1/3 min-w-[100px]">
<TimePicker value={timePart} onChange={handleTimeChange} disabled={disabled} readonly={readonly} />
<TimePicker value={timePart} onChange={handleTimeChange} disabled={disabled} readonly={readonly} className={className} />
</div>
</div>
);
@@ -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<HTMLDivElement, SingleSelectPickerProps>(
({ value, onChange, options, placeholder = "선택", searchable, allowClear, disabled, readonly, className }, ref) => {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const wrapperRef = useRef<HTMLDivElement>(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 (
<div
ref={wrapperRef}
className={cn("relative h-full w-full", (disabled || readonly) && "cursor-not-allowed", className)}
>
<div
ref={ref}
className={cn(
"flex h-full w-full cursor-pointer items-center gap-1 px-2 text-sm",
(disabled || readonly) && "cursor-not-allowed",
)}
onClick={() => {
if (!disabled && !readonly) setOpen((v) => !v);
}}
>
<span className={cn("flex-1 truncate", !selected && "text-muted-foreground")}>
{selected?.label || placeholder}
</span>
{allowClear && selected && !disabled && !readonly && (
<button
type="button"
className="text-muted-foreground hover:text-foreground text-xs"
onClick={handleClear}
>
</button>
)}
<ChevronDown className="text-muted-foreground h-4 w-4 shrink-0" />
</div>
{open && (
<div className="border-border bg-popover absolute top-full right-0 left-0 z-50 mt-1 max-h-64 overflow-auto rounded-md border shadow-md">
{searchable && (
<div className="border-border bg-popover sticky top-0 border-b p-2">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={(e) => 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
/>
</div>
</div>
)}
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs"> </div>
) : (
filteredOptions.map((opt) => (
<button
key={opt.value}
type="button"
disabled={opt.disabled}
className={cn(
"hover:bg-accent block w-full px-3 py-1.5 text-left text-sm",
value === opt.value && "bg-accent font-medium",
opt.disabled && "cursor-not-allowed opacity-50",
)}
onClick={() => !opt.disabled && handleSelect(opt.value)}
>
{opt.label}
</button>
))
)}
</div>
)}
</div>
);
},
);
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<HTMLDivElement, MultiSelectPickerProps>(
(
{ value, onChange, options, placeholder = "선택", searchable, allowClear, maxSelect, disabled, readonly, className },
ref,
) => {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const wrapperRef = useRef<HTMLDivElement>(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 (
<div
ref={wrapperRef}
className={cn("relative h-full w-full", (disabled || readonly) && "cursor-not-allowed", className)}
>
<div
ref={ref}
className={cn(
"flex h-full w-full cursor-pointer items-center gap-1 px-2 text-sm",
(disabled || readonly) && "cursor-not-allowed",
)}
onClick={() => {
if (!disabled && !readonly) setOpen((v) => !v);
}}
>
<span className={cn("flex-1 truncate", selectedOptions.length === 0 && "text-muted-foreground")}>
{displayLabel || placeholder}
</span>
{allowClear && selectedOptions.length > 0 && !disabled && !readonly && (
<button
type="button"
className="text-muted-foreground hover:text-foreground text-xs"
onClick={handleClearAll}
>
</button>
)}
<ChevronDown className="text-muted-foreground h-4 w-4 shrink-0" />
</div>
{open && (
<div className="border-border bg-popover absolute top-full right-0 left-0 z-50 mt-1 max-h-64 overflow-auto rounded-md border shadow-md">
{searchable && (
<div className="border-border bg-popover sticky top-0 border-b p-2">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={(e) => 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
/>
</div>
</div>
)}
{maxSelect && (
<div className="text-muted-foreground border-border border-b px-3 py-1 text-[10px]">
{selectedValues.length} / {maxSelect}
</div>
)}
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs"> </div>
) : (
filteredOptions.map((opt) => {
const checked = selectedValues.includes(opt.value);
const blocked = !checked && !!maxSelect && selectedValues.length >= maxSelect;
return (
<button
key={opt.value}
type="button"
disabled={opt.disabled || blocked}
className={cn(
"hover:bg-accent flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm",
checked && "bg-accent/60",
(opt.disabled || blocked) && "cursor-not-allowed opacity-50 hover:bg-transparent",
)}
onClick={() => handleToggle(opt.value)}
>
<span
className={cn(
"border-border flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm border",
checked && "border-primary bg-primary text-primary-foreground",
)}
>
{checked && <Check className="h-3 w-3" />}
</span>
<span className="flex-1 truncate">{opt.label}</span>
</button>
);
})
)}
</div>
)}
</div>
);
},
);
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<HTMLDivElement, RadioPickerProps>(
({ value, onChange, options, disabled, readonly, className }, ref) => {
return (
<div
ref={ref}
className={cn(
"flex h-full w-full flex-wrap content-start items-start gap-x-3 gap-y-1 px-2 py-1 text-sm",
(disabled || readonly) && "cursor-not-allowed",
className,
)}
>
{options.length === 0 ? (
<span className="text-muted-foreground text-xs"> </span>
) : (
options.map((opt) => {
const checked = value === opt.value;
const optDisabled = disabled || readonly || opt.disabled;
return (
<label
key={opt.value}
className={cn(
"inline-flex cursor-pointer items-center gap-1.5",
optDisabled && "cursor-not-allowed",
)}
>
<input
type="radio"
checked={checked}
disabled={optDisabled}
onChange={() => !optDisabled && onChange?.(opt.value)}
className="h-3.5 w-3.5 shrink-0"
/>
<span className="select-none">{opt.label}</span>
</label>
);
})
)}
</div>
);
},
);
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<HTMLDivElement, CheckboxListPickerProps>(
({ 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 (
<div
ref={ref}
className={cn(
"flex h-full w-full flex-wrap content-start items-start gap-x-3 gap-y-1 px-2 py-1 text-sm",
(disabled || readonly) && "cursor-not-allowed",
className,
)}
>
{options.length === 0 ? (
<span className="text-muted-foreground text-xs"> </span>
) : (
options.map((opt) => {
const checked = selectedValues.includes(opt.value);
const blocked = !checked && !!maxSelect && selectedValues.length >= maxSelect;
const optDisabled = disabled || readonly || opt.disabled || blocked;
return (
<label
key={opt.value}
className={cn(
"inline-flex cursor-pointer items-center gap-1.5",
optDisabled && "cursor-not-allowed",
)}
>
<input
type="checkbox"
checked={checked}
disabled={optDisabled}
onChange={() => !optDisabled && handleToggle(opt.value)}
className="h-3.5 w-3.5 shrink-0"
/>
<span className="select-none">{opt.label}</span>
</label>
);
})
)}
</div>
);
},
);
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<HTMLDivElement, TogglePickerProps>(
({ value, onChange, trueLabel = "예", falseLabel = "아니오", disabled, readonly, className }, ref) => {
const checked = isTruthy(value);
const lockClick = !!disabled || !!readonly;
return (
<div
ref={ref}
className={cn(
"flex h-full w-full items-center gap-2 px-2 text-sm",
lockClick && "cursor-not-allowed",
className,
)}
>
<button
type="button"
role="switch"
aria-checked={checked}
disabled={lockClick}
onClick={() => !lockClick && onChange?.(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors outline-none",
checked ? "bg-primary" : "bg-muted",
lockClick ? "cursor-not-allowed" : "cursor-pointer",
)}
>
<span
className={cn(
"bg-background inline-block h-4 w-4 transform rounded-full shadow transition-transform",
checked ? "translate-x-[18px]" : "translate-x-[2px]",
)}
/>
</button>
<span className="text-foreground select-none">{checked ? trueLabel : falseLabel}</span>
</div>
);
},
);
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<HTMLDivElement, TagPickerProps>(
(
{
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<HTMLInputElement>) => {
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 (
<div
ref={ref}
className={cn(
"flex h-full w-full flex-wrap items-center gap-1 px-2 py-1 text-sm",
lockEdit && "cursor-not-allowed",
className,
)}
>
{tags.map((tag, i) => (
<span
key={i}
className="bg-accent text-accent-foreground inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs"
>
<span>{tag}</span>
{!lockEdit && (
<button
type="button"
onClick={() => removeTag(i)}
className="text-muted-foreground hover:text-foreground leading-none"
aria-label={`${tag} 제거`}
>
</button>
)}
</span>
))}
{!lockEdit && (!maxSelect || tags.length < maxSelect) && (
<input
type="text"
value={input}
onChange={(e) => 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}
/>
)}
</div>
);
},
);
TagPicker.displayName = "TagPicker";
@@ -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<NumberingRuleWrapperProps> = ({
config,
onChange,
isPreview = false,
tableName,
menuObjid,
}) => {
console.log("📋 NumberingRuleWrapper: 테이블명 + menuObjid 전달", {
tableName,
menuObjid,
hasMenuObjid: !!menuObjid,
config
});
return (
<div className="h-full w-full">
<NumberingRuleDesigner
maxRules={config.maxRules || 6}
isPreview={isPreview}
className="h-full"
currentTableName={tableName} // 테이블명 전달
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
/>
</div>
);
};
export const NumberingRuleComponent = NumberingRuleWrapper;
@@ -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<NumberingRuleConfigPanelProps> = ({
config,
onChange,
}) => {
return (
<div className="space-y-4 p-4">
<div className="space-y-2">
<Label className="text-sm font-medium"> </Label>
<Input
type="number"
min={1}
max={10}
value={config.maxRules || 6}
onChange={(e) =>
onChange({ ...config, maxRules: parseInt(e.target.value) || 6 })
}
className="h-9"
/>
<p className="text-xs text-muted-foreground">
(1-10)
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium"> </Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
checked={config.readonly || false}
onCheckedChange={(checked) =>
onChange({ ...config, readonly: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium"> </Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
checked={config.showPreview !== false}
onCheckedChange={(checked) =>
onChange({ ...config, showPreview: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium"> </Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
checked={config.showRuleList !== false}
onCheckedChange={(checked) =>
onChange({ ...config, showRuleList: checked })
}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium"> </Label>
<Select
value={config.cardLayout || "vertical"}
onValueChange={(value: "vertical" | "horizontal") =>
onChange({ ...config, cardLayout: value })
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="vertical"></SelectItem>
<SelectItem value="horizontal"></SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
);
};
@@ -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 <NumberingRuleComponent {...(this.props as any)} renderer={this} />;
}
/**
*
*/
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
}
// 자동 등록 실행
NumberingRuleRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
NumberingRuleRenderer.enableHotReload();
}
@@ -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
<NumberingRuleDesigner
maxRules={6}
isPreview={false}
className="h-full"
/>
```
## 데이터베이스 구조
### 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
- **작성자**: 개발팀
@@ -1,15 +0,0 @@
/**
*
*/
import { NumberingRuleComponentConfig } from "./types";
export const defaultConfig: NumberingRuleComponentConfig = {
maxRules: 6,
readonly: false,
showPreview: true,
showRuleList: true,
enableReorder: false,
cardLayout: "vertical",
};
@@ -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";
@@ -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";
}
@@ -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<NumberingRuleWrapperProps> = ({
config,
onChange,
isPreview = false,
tableName,
menuObjid,
}) => {
console.log("📋 NumberingRuleWrapper: 테이블명 + menuObjid 전달", {
tableName,
menuObjid,
hasMenuObjid: !!menuObjid,
config
});
return (
<div className="h-full w-full">
<NumberingRuleDesigner
maxRules={config.maxRules || 6}
isPreview={isPreview}
className="h-full"
currentTableName={tableName} // 테이블명 전달
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
/>
</div>
);
};
export const NumberingRuleComponent = NumberingRuleWrapper;
@@ -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<NumberingRuleConfigPanelProps> = ({
config,
onChange,
}) => {
return (
<div className="space-y-4 p-4">
<div className="space-y-2">
<Label className="text-sm font-medium"> </Label>
<Input
type="number"
min={1}
max={10}
value={config.maxRules || 6}
onChange={(e) =>
onChange({ ...config, maxRules: parseInt(e.target.value) || 6 })
}
className="h-9"
/>
<p className="text-xs text-muted-foreground">
(1-10)
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium"> </Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
checked={config.readonly || false}
onCheckedChange={(checked) =>
onChange({ ...config, readonly: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium"> </Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
checked={config.showPreview !== false}
onCheckedChange={(checked) =>
onChange({ ...config, showPreview: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium"> </Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
checked={config.showRuleList !== false}
onCheckedChange={(checked) =>
onChange({ ...config, showRuleList: checked })
}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium"> </Label>
<Select
value={config.cardLayout || "vertical"}
onValueChange={(value: "vertical" | "horizontal") =>
onChange({ ...config, cardLayout: value })
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="vertical"></SelectItem>
<SelectItem value="horizontal"></SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
);
};
@@ -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 <NumberingRuleComponent {...(this.props as any)} />;
}
/**
*
*/
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
}
// 자동 등록 실행
NumberingRuleRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
NumberingRuleRenderer.enableHotReload();
}
@@ -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
<NumberingRuleDesigner
maxRules={6}
isPreview={false}
className="h-full"
/>
```
## 데이터베이스 구조
### 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
- **작성자**: 개발팀
@@ -1,15 +0,0 @@
/**
*
*/
import { NumberingRuleComponentConfig } from "./types";
export const defaultConfig: NumberingRuleComponentConfig = {
maxRules: 6,
readonly: false,
showPreview: true,
showRuleList: true,
enableReorder: false,
cardLayout: "vertical",
};
@@ -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";
@@ -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";
}
@@ -110,8 +110,7 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
"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"),
+44 -42
View File
@@ -17,40 +17,40 @@ export interface V2ComponentMapping {
* V2
*/
export const WEB_TYPE_V2_MAPPING: Record<string, V2ComponentMapping> = {
// 텍스트 입력 계열 → 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<string, V2ComponentMapping> = {
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<string, V2ComponentMapping> = {
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<string, V2ComponentMapping> = {
* ( )
*/
export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
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" },
};
}
@@ -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 `<input>` (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 `<select>` 대체)
- ✅ webTypeMapping `select / dropdown``input` (single dropdown)
### Phase B.2 — MultiSelectPicker
- ✅ `MultiSelectPicker` 신규 (select-pickers.tsx) — 체크박스 list + maxSelect 차단 + 라벨 join 트리거
- ✅ InputComponent select 분기에 multi 분기 추가 (`type=multi` 또는 `config.multiple` 시)
- ✅ value 정규화 (string / string[] 둘 다 받음)
### Phase B.4 — displayMode (mode) 통합
- ✅ 처음 시도: `displayMode` 신규 prop + UI — **중복 발견 후 폐기** (기존 `config.mode` 와 동일)
- ✅ 정정: 기존 `config.mode` 사용 (dropdown/combobox/radio/check/tag/toggle 6 가지)
- ✅ `RadioPicker` 신규 — single + mode=radio (라디오 button list)
- ✅ `CheckboxListPicker` 신규 — multi + mode=check (체크박스 list, maxSelect 차단)
- ✅ InputComponent select 분기 — mode 따라 4 picker 분기 (Single/Multi/Radio/CheckboxList)
- ✅ ConfigPanel "선택 방식" 옵션을 multi prop 따라 분기 — single: dropdown/combobox/radio/toggle, multi: dropdown/combobox/check/tag
- ✅ TYPE_VOLATILE_FIELDS 에 `maxSelect` 추가 (`mode` 는 기존)
### Phase B.4 추가 정리 — 외각 box + 복수 선택 토글
- ✅ picker 4개 wrapper 의 `opacity-50` 제거 (시각 흐릿 해소)
- ✅ InputComponent select 분기 disabled 에 `isDesignMode` 빼기 — 디자인 모드에서도 클릭 가능 (시각만)
- ✅ container border/bg 분기 — `mode === "radio" || "check"` 시 외각 box 제거 (자체 visual element 가 표시)
- ✅ 고급 설정의 "복수 선택" CPSwitch 제거 — brumb 의 `단일/다중` 이 진실의 원천 (단일/다중 = 저장 형태 차이, UX 토글 아님)
- ✅ "최대 개수" 노출 조건 — `multi prop` 으로 분기 (config.multiple 의존 X)
- ✅ input default_size 높이 48 → 30 (사용자 의도)
### Phase B.4 의 알려진 이슈 (이후 해결됨)
- ✅ ~~기본 선택값 (`config.defaultValue`) 동작 안 함~~ — 2026-05-11 해결 (BlockRenderer hijack 버그)
- ⚠️ dropdown 모드인데 박스 없음 사례 (사진 #32). 원인 가설: `config.mode` 잔재 ("radio"/"check"). 새 컴포넌트 끌어 놓으면 박스 정상
---
## 2026-05-11 진행 (이번 세션)
### Phase A.8 — 채번 admin 페이지 + 시퀀스 관리 (★ 별도 트랙)
**배경**: VEX 는 채번을 캔버스 컴포넌트로 처리 (잘못된 구조). 팀장 요구 — 별도 admin 페이지에서 채번 규칙 + 시퀀스 일원 관리. INVYONE 에는 그 기능 없음 → 신규 작성.
**작업 중 발견한 mismatch 들 (운영 전이라 모두 동시 fix)**:
1. **URL mismatch** — backend Controller `/api/numbering-rule` (단수) ↔ frontend `/numbering-rules` (복수)
- **fix**: backend `@RequestMapping` 복수로 변경
2. **응답 key mismatch** — backend `Map.of("code", ...)` ↔ frontend `data.generatedCode`
- **fix**: backend 4 endpoint (preview/test-preview/allocate/generate) 응답 key 모두 `generatedCode` 로 통일
3. **GREATEST 로직**`updateCurrentSequenceInRule` mapper 가 GREATEST 로 sequence 못 내림
- **fix**: admin 전용 `setCurrentSequenceInRule` SQL 신규 (GREATEST 없이 직접 SET). 기존 mapper 는 allocateCode 흐름용으로 유지
4. **두 테이블 ground truth 분리** — 실제 발번은 `numbering_rule_sequences` (prefix 별), `numbering_rules.current_sequence` 는 표시용
- **fix**: admin 의 reset/update 가 두 테이블 다 처리:
- `deleteSequencesByRuleId` (prefix sequences 비움)
- `setCurrentSequenceInRule` (numbering_rules 직접 set)
- 추가: `incrementSequenceForPrefix` 의 INSERT 분기에서 base 값을 `numbering_rules.current_sequence + 1` 로 변경 → admin set 값 N 이 다음 발번 N+1 로 정확히 반영
5. **권한 없음** — PUT /sequence + POST /reset 일반 사용자도 호출 가능
- **fix**: `@RequestAttribute("role")` 받아서 SUPER_ADMIN/ADMIN/COMPANY_ADMIN 만 허용. 403 으로 거부
6. **Designer wrong rule 위험**`NumberingRuleDesigner` lockedColumn 시 by-column 조회로 카테고리 분기 규칙에서 다른 rule 열림
- **fix**: 기존 `initialConfig` prop 활용. mount 시 setCurrentRule + setSelectedColumn 직접 set. lockedColumn effect 에 `if (initialConfig) return` 가드
7. **race condition** — SequenceManagementPanel 의 저장/리셋 동시 실행
- **fix**: 단일 `mutating: null | "save" | "reset"` state 로 통합. 양 버튼 disabled 가 `mutating !== null`
**신규 / 수정 파일**:
- ✅ `backend-spring/src/main/java/com/erp/controller/NumberingRuleController.java`
- `@RequestMapping` 단수 → 복수
- `PUT /{ruleId}/sequence` endpoint 신규 (권한 체크 포함)
- `POST /{ruleId}/reset` 에 권한 체크 추가
- preview/allocate/generate 응답 key 통일
- `isAdminRole` 헬퍼 추가
- ✅ `backend-spring/src/main/java/com/erp/service/NumberingRuleService.java`
- `updateRuleSequence` 메서드 신규 (두 테이블 처리)
- `resetSequence``setCurrentSequenceInRule` 사용으로 변경
- `incrementSequenceForPrefix` 의 INSERT 분기 base = numbering_rules.current_sequence + 1
- ✅ `backend-spring/src/main/resources/mapper/numberingRule.xml`
- `setCurrentSequenceInRule` SQL 신규 (admin 전용)
- ✅ `frontend/lib/api/numberingRule.ts`
- `updateRuleSequence(ruleId, newSequence)` 함수 신규
- ✅ `frontend/components/numbering-rule/SequenceManagementPanel.tsx` 신규
- 현재 시퀀스 + 직접 수정 + reset + 미리보기. mutation lock 적용
- ✅ `frontend/components/numbering-rule/NumberingRuleDesigner.tsx`
- `initialConfig` prop 활용 effect 추가 (by-column 우회)
- `selectedColumn` 초기 state 를 lockedColumn 으로
- ✅ `frontend/app/(main)/admin/systemMng/numberingRuleList/page.tsx` 신규
- 좌측 규칙 목록 + 우측 ① NumberingRuleDesigner + ② SequenceManagementPanel
- 신규 규칙 CTA → NumberingRuleCreateDialog
- ✅ `frontend/components/layout/AdminPageRenderer.tsx`
- `/admin/systemMng/numberingRuleList` 라우팅 등록
**남은 / 후속**:
- ✅ 사이드바 메뉴 DB INSERT (2026-05-11 완료)
- Flyway: `V017__register_numbering_rule_menu.sql` + `V018__assign_numbering_rule_menu_to_super_admin.sql`
- 즉시 운영 DB INSERT 실행: MENU_INFO row 등록 (OBJID=NUMBERING_RULE_LIST, SEQ=8, PARENT=시스템 관리 그룹)
- AUTHORITY_SUB_MENU 매핑: 운영 DB 에 SUPER_ADMIN AUTH_CODE 가 없어 INSERT 0 (다른 systemMng 메뉴도 매핑 없이 동작 중. 무해)
- ✅ v2-numbering-rule / numbering-rule 캔버스 컴포넌트 폐기 (2026-05-11 완료)
- `lib/registry/components/numbering-rule/` 폴더 통째 삭제
- `lib/registry/components/v2-numbering-rule/` 폴더 통째 삭제
- `components/v2/config-panels/V2NumberingRuleConfigPanel.tsx` 삭제
- `components/screen/templates/NumberingRuleTemplate.ts` 삭제 (dead code)
- `lib/registry/components/index.ts` 의 두 import 제거
- `lib/utils/getComponentConfigPanel.tsx` 의 두 import 제거
- `ComponentsPanel.tsx` 의 hidden list 에서 "v2-numbering-rule" / "numbering-rule" 제거
- ⏳ 운영 모드 동작 검증 (backend 재시작 필요 — URL mismatch + 응답 key + sequence 흐름 다 fix 됐는지 확인)
### Phase A.7 — 스튜디오 내 채번 규칙 생성 (CTA + Dialog)
사용자 요구: 운영에서 채번 규칙은 admin 페이지에서 사전 등록되어야 NumberingPicker 동작. 스튜디오에서도 새 규칙을 만들 수 있게 — InvFieldConfigPanel 의 채번 옵션에 "새 규칙 만들기" CTA + Modal.
- ✅ `NumberingRuleDesigner.tsx``lockedColumn?: { tableName, columnName }` prop 추가
- 좌측 컬럼 목록 UI hide (조건부 render)
- mount 시 자동 `handleSelectColumn` 호출
- selectedColumn 초기 state 를 lockedColumn 으로 (flash 방지)
- "컬럼을 선택하세요" → lockedColumn 시 "채번 규칙을 불러오는 중..." 로 분기
- ✅ `frontend/components/numbering-rule/NumberingRuleCreateDialog.tsx` 신규 — shadcn Dialog wrapper. NumberingRuleDesigner 를 lockedColumn 으로 띄움. onSave → onCreated callback + 자동 닫기
- ✅ `InvFieldConfigPanel.tsx` — NumberingOptions 에 "+ 새 규칙 만들기" 버튼 + Dialog 통합
- `rulesRefreshKey` state + numberingRules effect dep 추가
- 생성 후 새 ruleId 자동 선택 + rules 목록 refresh
- **canonical 위치 통일**: `autoGeneration.numberingRuleId` (옵션 밖) → `autoGeneration.options.numberingRuleId` (옵션 안)
- V2InputConfigPanel 도 옵션 안에 저장. autoGeneration.ts.generateValue 도 옵션 안 사용.
- InputComponent 의 NumberingPicker `numberingRuleId` prop 도 옵션 안에서 읽음 → 일관 ★
- 이전 옵션 밖 fallback 제거 (canonical 1안 원칙)
- ✅ Codex 검증 — 2 issue fix
- **MED**: applyRuleId 에 `enabled: true` + `options.numberingRuleId` 명시 (저장 시 무효 방지)
- **LOW**: selectedColumn 초기 state 를 lockedColumn 으로 (flash 방지)
- **OK** 항목 (3·5·6) 무수정
- **LOW** (4 토큰 컨벤션 raw hsl) — 기능 영향 없음, 후속 정리
### Phase A.6 — numbering API hook (완료)
- ✅ `frontend/lib/registry/components/input/numbering-picker.tsx` 신규
- V2Input.tsx:600~949, 1069~1144 의 채번 본체 추출 — useState/useRef + 3 useEffect (main / debounce / beforeFormSave) + 렌더 (readonly text vs prefix-input-suffix)
- `previewNumberingCode(ruleId, formData, manualInputValue)` API 사용
- ruleId 결정 흐름: `props.numberingRuleId` 우선 → 없으면 `by-column` API → fallback `getTableColumns.detailSettings.numberingRuleId`
- `____` 템플릿: 첫 생성 시 templateRef set + 부모 value, 카테고리 변경 시 manualInputValue 유지 + 새 조합값 onChange
- debounce (300ms) 디바운스로 manualInputValue 변경 시 suffix 동적 갱신
- beforeFormSave window listener — 저장 직전 조합값 formData 주입
- ✅ Codex 검증 (1차) — 6 issue 지적 → 전부 fix 적용
- cancellation flag (main + debounce) → race condition 방지
- `??``||` (tableName 폴백, 빈 문자열 통과 방지)
- `originalData || _originalData` 별칭 보강 (isEditMode)
- 카테고리 변경 + userEditedRef 시 onChange 호출 (parent value 누락 fix)
- propRuleId main effect dep 추가 + isDesignMode debounce dep 추가
- inputSlotStyle 에 `overflow: hidden + borderRadius: inherit` 이동 (NumberingPicker 모서리 처리)
- `_numberingRuleId` callback (EditModal/buttonActions 호환) — `onRuleIdResolved` prop 추가
- Legacy `inputType="numbering"` → "code" 매핑 추가 (V2 저장 데이터 호환)
- ✅ InputComponent.tsx — `case "code"` 분기 → NumberingPicker 호출. tableName 5경로 (props/config/component/overrides/screenInfo) + isEditMode 노출
- ✅ 타입체크 통과 (numbering-picker / InputComponent 에서 에러 0건)
### Phase B.4.4 — TogglePicker
- ✅ `TogglePicker` 신규 — boolean Y/N 토글 스위치 (truthy 자동 판정: bool/숫자/Y/N/true/false/1/0)
- ✅ InputComponent `case "checkbox"` 분기 — `mode=toggle` 시 TogglePicker, 아니면 기존 단일 체크박스
### Phase B.3 — TagPicker
- ✅ `TagPicker` 신규 — chip + 입력 (Enter / 구분자 추가, Backspace / ✕ 제거, maxSelect 차단, 중복 방지)
- ✅ InputComponent multi 분기 — `format=tags` 또는 `mode=tag` 시 TagPicker
### Phase B.4 추가 정리 (사용자 사진 검증 반영)
- ✅ Radio/Checkbox list 의 **외각 box 제거**`mode=radio||check` 시 container border/bg 없음 (자체 visual element 가 표시)
- ✅ **"복수 선택" CPSwitch 제거** (고급 설정) — brumb 의 `단일/다중` 이 진실의 원천. 단일/다중 = 저장 형태 차이 (값 cardinality), UX 토글 아님
- ✅ "최대 개수" 노출 조건 — `multi prop` 으로 분기 (config.multiple 의존 X)
- ✅ ConfigPanel "선택 방식" 옵션을 multi 따라 분기 — single: dropdown/combobox/radio/toggle, multi: dropdown/combobox/check/tag
- ✅ ~~`displayMode` 신규 prop~~ 폐기 — 기존 `config.mode` 와 중복이라 정리. mode 만 사용
- ✅ Radio/Checkbox list — flex-col → flex-wrap 으로 변경 (디자인/운영 모드 외형 일관, 박스 사이즈에 맞게 자동 wrap)
- ✅ picker 4개 wrapper 의 `opacity-50` 제거 (시각 흐릿 해소)
- ✅ InputComponent select 분기 disabled 에 `isDesignMode` 빼기 — 디자인 모드에서도 클릭 가능
### Phase B.5 — CPSelect Portal 도입
- ✅ `CPSelect` 의 dropdown 을 React Portal 로 — `position: fixed` + `getBoundingClientRect()` 좌표 + scroll/resize 시 닫음
- ✅ ConfigPanel 의 `overflow:auto` 와 무관하게 dropdown 화면 끝까지 보임 (사진 #35 의 가려짐 해소)
### Phase B.6 — defaultValue 버그 (중요 발견)
**증상**: 사용자가 ConfigPanel 에 "기본 선택값: 저는 김민호" 설정해도 운영 (form-popup 수정 모달) 에서 default 안 적용. InputComponent props 의 `componentConfig.defaultValue: undefined`.
**진단 단계** (긴 디버그 흐름):
1. `[Input debug]` console.log 추가 → 스튜디오 OK, 운영 undefined 확인
2. `[saveTemplate]` 디버그 → edit 뷰 `overrides.defaultValue = "저는 김민호"` 저장 정상
3. `[loadTemplate]` 디버그 → editLegacy `componentConfig.defaultValue = "저는 김민호"` load 정상
4. `[Input render path]` 디버그 → `pageUrl: "/form-popup"` 확인, rawComponentConfig.defaultValue = undefined
5. → form-popup 페이지가 **BlockRenderer** 사용. 거기서 stripping
**진짜 원인**: `frontend/components/dash/BlockRenderer.tsx:54-57`
```ts
// BEFORE (버그)
const runtimeConfig =
resolvedColumnName != null
? { ...block.config, defaultValue: resolvedValue } // ← 무조건 덮어씀
: block.config;
```
→ columnName 있는 컴포넌트는 form data 의 그 컬럼 값 (`context.formRow?.[columnName]`) 으로 **defaultValue 를 무조건 hijack**. 신규/빈 row 시 `resolvedValue = undefined` → 사용자 설정 defaultValue 가 undefined 로 덮임.
**Fix**:
```ts
// AFTER
const runtimeConfig =
resolvedColumnName != null && resolvedValue !== undefined && resolvedValue !== null
? { ...block.config, defaultValue: resolvedValue }
: block.config; // ← formRow 값 없으면 사용자 설정 defaultValue 보존
```
### 잘못 들어간 경로 (rollback 완료)
- ❌ ~~GPT 진단 — frontend `saveLayoutV2` 의 payload shape 와 backend `body.get("layout_data")` 불일치~~
- 처음 GPT 가 `saveLayoutV2` payload 를 `layout_data` 키로 감싸야 한다 제안
- 사용자 짚음: "그러면 옛 컴포넌트는 어떻게 저장됐냐" — 정확. 모순.
- **저장 자체는 정상**이었음. INVYONE 스튜디오는 `saveTemplate` 사용 (template_id 가 있을 때), saveLayoutV2 는 별개 경로
- rollback 완료. 정상 흐름 복원
### 임시 디버그 (잔존 — 정리 가능)
- `templateAdapter.ts:76``[saveTemplate] payload` 일반 진단용 로그 — 유지 (low-noise)
- 그 외 [Input debug] / [Save layout debug] / [loadTemplate] / [Input render path] 등 모두 제거 완료
---
## 3. 진행 중 / 남은 작업
### Phase A 잔여
- ✅ ~~**A.6** numbering API hook~~ (2026-05-11 완료 — NumberingPicker 신규 + InputComponent 통합)
### Phase B 진행
- ✅ ~~**B.2** MultiSelectPicker — multi / maxSelect~~ (완료)
- ✅ ~~**B.3** TagPicker — tags (tagbox)~~ (완료)
- ✅ ~~**B.4** radio / checkbox / toggle~~ (완료)
- ⏳ **B.4.5** SwapPicker — multi + mode=swap (양쪽 list 간 이동, 큰 작업)
- ⏳ **B.5** option loader — api / code / category / distinct / 계층 (apiClient 의존)
- ⏳ webTypeMapping 의 multi / checkbox / radio / boolean / code / category 매핑도 점진 input
### 디버그 (해결)
- ✅ ~~`config.defaultValue` 동작 안 함~~ — BlockRenderer hijack 버그 (2026-05-11 해결)
### Phase C — entity
- ⏳ entity 검색팝업 + 다른 컬럼 auto-fill (V2Select.tsx:1007~)
- ⏳ 현재 InputComponent entity 분기는 placeholder 버튼만
### Phase D — V2 폐기
- ⏳ webTypeMapping 의 select 계열 (multi/category/entity/code/checkbox/radio/boolean) → input (B 단계 후)
- ⏳ webTypeMapping 의 file/image/img → input (file 통합 후)
- ⏳ `ScreenSettingModal.tsx:2052-2066` 의 옛 컴포넌트 생성 경로 → input
- ⏳ DB 마이그 — `screens` 테이블의 `layout` JSON 안 componentType 변경 (사용자 승인 필요)
- ⏳ `V2Input.tsx` / `V2Select.tsx` 파일 삭제
- ⏳ `registerV2Components.ts` 의 v2-input / v2-select 등록 제거
- ⏳ `V2InputConfigPanel.tsx` (832줄) 삭제 — 이전 세션의 폐기 보류
### Phase E — 옛 6개 폐기
- ⏳ date-input / text-input / number-input / select-basic / checkbox-basic / textarea-basic 의 캔버스 컴포넌트 (`DateInputComponent.tsx` 등) 폐기
- ⏳ 6 폴더 자체 삭제 (ScreenSettingModal 의 생성 경로 검증 후)
- ⏳ 부수 효과 — `DateInputComponent.tsx:214` 의 옛 TS 에러 자연 해소
---
## 4. 위험 영역 / 주의사항
1. **V2Input / V2Select 폐기 전 고유 기능 이식 검증 필수**
- V2Input: numbering API · mask · password · slider · color picker
- V2Select: radio/check/toggle/swap mode · entity FK 검색·auto-fill · option loader (api/code/category/distinct/계층)
2. **DB 마이그 (Phase D 4단계)**
- 화면 데이터는 `screens` 테이블의 `layout` JSON 안에 통째 저장 (별도 component 테이블 없음 — `screen_components` 같은 이름 X)
- JSON 안 `componentType` / `componentConfig` 변경 SQL 필요 (jsonb_set 또는 string replace)
- 운영 단계 아니라 데이터 마이그 부담 작음 — 단 UPDATE 사용자 승인 필요 (메모리)
3. **dev reload 캐시**
- webTypeMapping.ts 변경은 새로 끌어 놓는 컴포넌트만 반영
- 캐시 안 잡히면 hard reload (Cmd+Shift+R)
4. **canonical pattern — TYPE_VOLATILE_FIELDS**
- 새 type 추가 시 잔재 필드도 같이 등록할 것 (안 그러면 분기 간 잔재 → 옛 컴포넌트 분기 트리거 위험)
---
## 5. 관련 파일 맵
```
[Canonical (목표)]
frontend/components/v2/config-panels/InvFieldConfigPanel.tsx ← 단일 ConfigPanel (brumb)
frontend/lib/registry/components/input/InputComponent.tsx ← 단일 캔버스 컴포넌트
frontend/lib/registry/components/input/pickers.tsx ← date/datetime/time/daterange picker
frontend/lib/registry/components/input/select-pickers.tsx ← select picker (B.1 신규)
frontend/lib/registry/components/input/index.ts ← input 등록 (config_panel: InvFieldConfigPanel)
[라우팅]
frontend/lib/utils/webTypeMapping.ts ← web_type → componentType
frontend/lib/utils/getComponentConfigPanel.tsx ← componentId → ConfigPanel
frontend/lib/registry/DynamicComponentRenderer.tsx ← 캔버스 렌더 dispatch (fieldType swap 제거됨)
frontend/components/screen/panels/V2PropertiesPanel.tsx ← properties 패널 라우팅
[폐기 예정]
frontend/components/v2/V2Input.tsx ← 1286줄
frontend/components/v2/V2Select.tsx ← 1350줄
frontend/lib/registry/components/{date,text,number}-input/ ← 자체 캔버스 컴포넌트 폐기
frontend/lib/registry/components/{select,checkbox,textarea}-basic/
frontend/components/v2/config-panels/V2InputConfigPanel.tsx ← 832줄 (이전 세션 폐기 보류)
[유틸 / API]
frontend/lib/utils/autoGeneration.ts ← AutoGenerationUtils.generateValue (A.5 hook 연결됨)
frontend/lib/api/numberingRule.ts ← previewNumberingCode (A.6 이식 예정)
```
---
## 6. 핵심 의사결정 기록
| 결정 | 이유 |
|---|---|
| Canonical = InvFieldConfigPanel + InputComponent | brumb (kind/type/format) 풍부, FieldConfig spec 정합 |
| (가)/(다) 하이브리드 거부 → (나) 통합 추진 | canonical 1안 원칙 / 운영 단계 아님 |
| webTypeMapping → input 점진 변경 | V2Select 고유 기능 이식 후 매핑 변경 (안전) |
| native `<select>` → 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줄 유지)
File diff suppressed because it is too large Load Diff