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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,40 +1907,73 @@ 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
|
||||
}
|
||||
>
|
||||
{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 || "-"}
|
||||
{"{번호}"})
|
||||
</option>
|
||||
))}
|
||||
</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",
|
||||
}}
|
||||
>
|
||||
<option value="">채번 규칙 선택</option>
|
||||
{rules.map((rule) => (
|
||||
<option key={rule.rule_id} value={String(rule.rule_id)}>
|
||||
{rule.rule_name} ({rule.separator || "-"}
|
||||
{"{번호}"})
|
||||
</option>
|
||||
))}
|
||||
</CPSelect>
|
||||
)}
|
||||
</CPRow>
|
||||
+ 새 규칙 만들기
|
||||
</button>
|
||||
</CPRow>
|
||||
</>
|
||||
)}
|
||||
<CPRow label="읽기 전용" help="채번 필드는 자동 생성되므로 읽기전용 권장">
|
||||
<CPSwitch value={config.readonly !== false} onChange={(v) => updateConfig("readonly", v)} />
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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와 비교
|
||||
|
||||
@@ -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 (
|
||||
<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={(v) => propagate(v)}
|
||||
options={normalizedOptions}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
className="border-0 bg-transparent rounded-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
// dropdown / combobox (기본) — SingleSelectPicker
|
||||
return (
|
||||
<select
|
||||
<SingleSelectPicker
|
||||
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>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<option key={i} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
onChange={(v) => propagate(v)}
|
||||
options={normalizedOptions}
|
||||
placeholder={placeholder || "선택"}
|
||||
searchable={searchable}
|
||||
allowClear={!!(componentConfig as any).allowClear}
|
||||
disabled={disabled || isDesignMode}
|
||||
readonly={readonly}
|
||||
className="border-0 bg-transparent rounded-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "checkbox":
|
||||
case "checkbox": {
|
||||
// single.boolean 영역. mode=toggle (기본 권장) 이면 TogglePicker, 그 외 단일 체크박스.
|
||||
const cbMode = (componentConfig as any).mode;
|
||||
if (cbMode === "toggle") {
|
||||
return (
|
||||
<TogglePicker
|
||||
value={value}
|
||||
onChange={(v) => propagate(v)}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
className="border-0 bg-transparent rounded-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
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}
|
||||
@@ -490,6 +688,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user