5f945363b2
V2DateConfigPanel(262줄) + V2Date 본체(1046줄) + V2DateConfig 타입 + lib/registry/components/v2-date/ + DynamicComponentRenderer 의 LEGACY alias 모두 삭제. 솔루션 정의 단계 정확한 1안. 데이터 모델 - FieldType / InputFieldType union 에 time, daterange 2종 추가하여 12종 확장 - canonical 키: dateFormat / minDate / maxDate / showToday / weekStart / dateDefault / rangePresets / maxRangeDays - 옛 v2-date 의 snake_case (min_date / max_date / show_today) 와 dateType / type / range / format 키 충돌 종결 런타임 (InputComponent + pickers.tsx) - V2Date 본체에 있던 SingleDatePicker / DateTimePicker / TimePicker / RangeDatePicker 4 picker 를 input/pickers.tsx 로 통합 - InputComponent 의 case "date"/"datetime" 의 native input 분기를 datepicker 로 교체하고 case "time"/"daterange" 신규 추가 - type 결정 로직에 inputType prop 인식 (DB input_type 매핑) → date/time 입력이 text 로 fallback 되던 silent breakage 해결 FC 계층 - FieldRenderer / CellRenderer / FcSearch 에 time / daterange 분기 추가 - TimeField, DateRangeField 신규 컴포넌트 - adapters.normalizeType allowed 배열 확장 ConfigPanel - InvFieldConfigPanel.DateOptions 에 showToday CPRow + CPSwitch 신규 - 옛 호환 코드 (showSeconds:ss 보정 / dateType-format 격상 등) 모두 제거 - InvInputConfigPanel.TYPE_OPTIONS / 날짜 옵션 분기에 time/daterange 추가 dead code 삭제 14곳 + 잔존 정리 - V2Date / V2DateConfigPanel / V2DateConfig / lib/registry/v2-date/ 폴더 - LEGACY_TO_UNIFIED / CONFIG_PANEL_MAP / CONFIG_PANEL_ALIAS / register / V2PropertiesPanel hardcoded require / config-panels barrel / hidden 목록 - componentConfig 스키마, templateMigrate, webTypeMapping, DynamicConfigPanel - withContainerQuery.css 의 v2-date 컨테이너 룰 - db/migrations/so_modal_layout(_kr).json 의 v2-date → input + type=date 37 files, +287 / -854. Codex GO 판정 기준 (2회 NO-GO 후 FC 계층 / inputType prop / FieldType union / 잔존 v2-date / console.log 모두 처리 후 GO). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
379 lines
13 KiB
TypeScript
379 lines
13 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* InvInputConfigPanel — 통합 "입력 필드" (id: input) cp 톤 설정 패널
|
|
*
|
|
* 흐름:
|
|
* ① 필드 타입 — CPVisualGrid 10칸 (text/number/date/datetime/select/entity/checkbox/textarea/file/code)
|
|
* ② 라벨 / 안내 — 라벨 + placeholder + 도움말
|
|
* ③ 타입별 옵션 — type 에 따라 조건부 (number min/max/step, text minLength/maxLength, textarea rows, select options, file accept/multiple, format)
|
|
* ▾ 옵션 — required / editable / disabled (FeatureChipGrid)
|
|
*
|
|
* Reference: notes/gbpark/2026-04-28-cp-panel-standard.md
|
|
*/
|
|
|
|
import React from "react";
|
|
import {
|
|
Type,
|
|
Hash,
|
|
Calendar,
|
|
CalendarClock,
|
|
ChevronDown,
|
|
Link2,
|
|
CheckSquare,
|
|
AlignLeft,
|
|
Paperclip,
|
|
Code2,
|
|
Plus,
|
|
X,
|
|
} from "lucide-react";
|
|
import {
|
|
CPSection,
|
|
CPRow,
|
|
CPGroup,
|
|
CPText,
|
|
CPNumber,
|
|
CPVisualGrid,
|
|
FeatureChipGrid,
|
|
Hint,
|
|
} from "@/components/v2/config-panels/_shared/cp";
|
|
import { RowDeleteBtn } from "../common/row-helpers";
|
|
import type { InputConfig, InputFieldType } from "./types";
|
|
|
|
export interface InvInputConfigPanelProps {
|
|
config?: InputConfig;
|
|
onChange?: (config: InputConfig) => void;
|
|
selectedComponent?: { id: string; config?: InputConfig; [k: string]: any };
|
|
}
|
|
|
|
const TYPE_OPTIONS: Array<{
|
|
value: InputFieldType;
|
|
label: string;
|
|
icon: React.ReactNode;
|
|
desc: string;
|
|
}> = [
|
|
{ value: "text", label: "문자열", icon: <Type size={16} />, desc: "일반 텍스트 한 줄" },
|
|
{ value: "number", label: "숫자", icon: <Hash size={16} />, desc: "정수 / 소수 입력" },
|
|
{ value: "date", label: "날짜", icon: <Calendar size={16} />, desc: "YYYY-MM-DD" },
|
|
{ value: "datetime", label: "날짜+시간", icon: <CalendarClock size={16} />, desc: "분/초까지" },
|
|
{ value: "time", label: "시간", icon: <CalendarClock size={16} />, desc: "HH:mm" },
|
|
{ value: "daterange", label: "기간", icon: <Calendar size={16} />, desc: "시작 ~ 끝" },
|
|
{ value: "select", label: "드롭다운", icon: <ChevronDown size={16} />, desc: "선택지 list" },
|
|
{ value: "entity", label: "엔티티", icon: <Link2 size={16} />, desc: "FK 참조 (팝업)" },
|
|
{ value: "checkbox", label: "체크박스", icon: <CheckSquare size={16} />, desc: "참/거짓" },
|
|
{ value: "textarea", label: "장문", icon: <AlignLeft size={16} />, desc: "여러 줄 텍스트" },
|
|
{ value: "file", label: "파일", icon: <Paperclip size={16} />, desc: "업로드" },
|
|
{ value: "code", label: "자동채번", icon: <Code2 size={16} />, desc: "readonly · 자동 생성" },
|
|
];
|
|
|
|
export const InvInputConfigPanel: React.FC<InvInputConfigPanelProps> = ({
|
|
config,
|
|
onChange,
|
|
selectedComponent,
|
|
}) => {
|
|
const current: InputConfig =
|
|
(config as InputConfig) || (selectedComponent?.config as InputConfig) || {};
|
|
|
|
const patch = (p: Partial<InputConfig>) => onChange?.({ ...current, ...p });
|
|
|
|
const type: InputFieldType = (current.type as InputFieldType) || "text";
|
|
|
|
const optionsArr: string[] = Array.isArray(current.options)
|
|
? current.options.map((o: any) => (typeof o === "string" ? o : o.label || o.value || ""))
|
|
: [];
|
|
|
|
const addOption = () => patch({ options: [...optionsArr, ""] as any });
|
|
const updateOption = (i: number, v: string) =>
|
|
patch({ options: optionsArr.map((o, idx) => (idx === i ? v : o)) as any });
|
|
const removeOption = (i: number) =>
|
|
patch({ options: optionsArr.filter((_, idx) => idx !== i) as any });
|
|
|
|
return (
|
|
<div style={{ fontFamily: "var(--v5-font-sans)", color: "var(--cp-text)", padding: "0 12px" }}>
|
|
{/* ── ① 필드 타입 ─────────────────────────── */}
|
|
<CPSection title="① 필드 타입" desc="입력 모드 (변형 결정)">
|
|
<CPVisualGrid
|
|
cols={5}
|
|
cardHeight={62}
|
|
value={type}
|
|
onChange={(v) => patch({ type: v as InputFieldType })}
|
|
options={TYPE_OPTIONS.map((o) => ({
|
|
value: o.value,
|
|
label: o.label,
|
|
preview: o.icon,
|
|
desc: o.desc,
|
|
}))}
|
|
/>
|
|
</CPSection>
|
|
|
|
{/* ── ② 라벨 / 안내 ─────────────────────────── */}
|
|
<CPSection title="② 라벨 / 안내">
|
|
<CPRow label="라벨" help="필드 위에 표시될 이름">
|
|
<CPText
|
|
value={current.label || ""}
|
|
onChange={(v) => patch({ label: v })}
|
|
placeholder="필드 라벨"
|
|
/>
|
|
</CPRow>
|
|
<CPRow label="placeholder" help="입력 전 안내 텍스트">
|
|
<CPText
|
|
value={current.placeholder || ""}
|
|
onChange={(v) => patch({ placeholder: v })}
|
|
placeholder="입력하세요"
|
|
/>
|
|
</CPRow>
|
|
<CPRow label="도움말" help="필드 하단 보조 안내">
|
|
<CPText
|
|
value={current.helperText || ""}
|
|
onChange={(v) => patch({ helperText: v || undefined })}
|
|
placeholder="하단 도움말"
|
|
/>
|
|
</CPRow>
|
|
</CPSection>
|
|
|
|
{/* ── ③ 타입별 옵션 (조건부) ─────────────────────────── */}
|
|
{type === "number" && (
|
|
<CPSection title="③ 숫자 옵션" desc="범위 / 증감">
|
|
<CPRow label="최소 (min)">
|
|
<CPNumber
|
|
value={current.min ?? undefined}
|
|
onChange={(v) => patch({ min: v })}
|
|
placeholder="제한 없음"
|
|
/>
|
|
</CPRow>
|
|
<CPRow label="최대 (max)">
|
|
<CPNumber
|
|
value={current.max ?? undefined}
|
|
onChange={(v) => patch({ max: v })}
|
|
placeholder="제한 없음"
|
|
/>
|
|
</CPRow>
|
|
<CPRow label="증감 단위 (step)" help="화살표/스피너 한 칸">
|
|
<CPNumber
|
|
value={current.step ?? undefined}
|
|
onChange={(v) => patch({ step: v })}
|
|
placeholder="1"
|
|
/>
|
|
</CPRow>
|
|
<CPRow label="포맷" help="예: #,##0 / 0.00">
|
|
<CPText
|
|
value={current.format || ""}
|
|
onChange={(v) => patch({ format: v || undefined })}
|
|
placeholder="#,##0"
|
|
/>
|
|
</CPRow>
|
|
</CPSection>
|
|
)}
|
|
|
|
{(type === "text" || type === "textarea") && (
|
|
<CPSection title="③ 문자열 옵션">
|
|
<CPRow label="최소 길이 (minLength)">
|
|
<CPNumber
|
|
value={current.minLength ?? undefined}
|
|
onChange={(v) => patch({ minLength: v })}
|
|
min={0}
|
|
placeholder="제한 없음"
|
|
/>
|
|
</CPRow>
|
|
<CPRow label="최대 길이 (maxLength)">
|
|
<CPNumber
|
|
value={current.maxLength ?? undefined}
|
|
onChange={(v) => patch({ maxLength: v })}
|
|
min={0}
|
|
placeholder="제한 없음"
|
|
/>
|
|
</CPRow>
|
|
{type === "textarea" && (
|
|
<CPRow label="줄 수 (rows)">
|
|
<CPNumber
|
|
value={current.rows ?? 3}
|
|
onChange={(v) => patch({ rows: v ?? 3 })}
|
|
min={1}
|
|
max={20}
|
|
/>
|
|
</CPRow>
|
|
)}
|
|
</CPSection>
|
|
)}
|
|
|
|
{(type === "date" || type === "datetime" || type === "time" || type === "daterange") && (
|
|
<CPSection title="③ 날짜 옵션">
|
|
<CPRow
|
|
label="포맷"
|
|
help={
|
|
type === "time"
|
|
? "예: HH:mm"
|
|
: type === "datetime"
|
|
? "예: YYYY-MM-DD HH:mm"
|
|
: "예: YYYY-MM-DD"
|
|
}
|
|
>
|
|
<CPText
|
|
value={(current as any).dateFormat || current.format || ""}
|
|
onChange={(v) => patch({ dateFormat: v || undefined } as any)}
|
|
placeholder={
|
|
type === "time"
|
|
? "HH:mm"
|
|
: type === "datetime"
|
|
? "YYYY-MM-DD HH:mm"
|
|
: "YYYY-MM-DD"
|
|
}
|
|
/>
|
|
</CPRow>
|
|
</CPSection>
|
|
)}
|
|
|
|
{type === "select" && (
|
|
<CPSection title="③ 선택지" desc={`${optionsArr.length}개`}>
|
|
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 5 }}>
|
|
<button
|
|
type="button"
|
|
onClick={addOption}
|
|
style={{
|
|
padding: "4px 10px",
|
|
fontSize: 10.5,
|
|
background: "var(--cp-bg-subtle)",
|
|
border: "1px solid var(--cp-border)",
|
|
borderRadius: 4,
|
|
cursor: "pointer",
|
|
color: "var(--cp-text)",
|
|
fontFamily: "var(--v5-font-sans)",
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
}}
|
|
>
|
|
<Plus size={10} /> 추가
|
|
</button>
|
|
</div>
|
|
{optionsArr.length === 0 ? (
|
|
<Hint>선택지가 없습니다. [+ 추가] 로 만드세요.</Hint>
|
|
) : (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
|
{optionsArr.map((opt, i) => (
|
|
<div
|
|
key={i}
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "16px 1fr 22px",
|
|
alignItems: "center",
|
|
columnGap: 6,
|
|
padding: "3px 6px",
|
|
background: "var(--cp-bg-subtle)",
|
|
border: "1px solid var(--cp-border-subtle)",
|
|
borderRadius: 4,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontSize: 9,
|
|
color: "var(--cp-text-muted)",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
textAlign: "right",
|
|
}}
|
|
>
|
|
{i + 1}
|
|
</span>
|
|
<input
|
|
type="text"
|
|
value={opt}
|
|
onChange={(e) => updateOption(i, e.target.value)}
|
|
placeholder={`옵션 ${i + 1}`}
|
|
style={{
|
|
height: 22,
|
|
padding: "0 6px",
|
|
fontSize: 11,
|
|
background: "var(--cp-surface)",
|
|
border: "1px solid var(--cp-border)",
|
|
borderRadius: 3,
|
|
color: "var(--cp-text)",
|
|
outline: "none",
|
|
fontFamily: "var(--v5-font-sans)",
|
|
}}
|
|
/>
|
|
<RowDeleteBtn onClick={() => removeOption(i)} hoverBg={false}>
|
|
<X size={10} />
|
|
</RowDeleteBtn>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CPSection>
|
|
)}
|
|
|
|
{type === "file" && (
|
|
<CPSection title="③ 파일 옵션">
|
|
<CPRow label="허용 확장자" help="예: image/* 또는 .pdf,.docx">
|
|
<CPText
|
|
value={current.accept || ""}
|
|
onChange={(v) => patch({ accept: v || undefined })}
|
|
placeholder="image/*"
|
|
/>
|
|
</CPRow>
|
|
<FeatureChipGrid
|
|
items={[
|
|
{
|
|
key: "multiple",
|
|
label: "다중 선택",
|
|
desc: "한 번에 여러 파일을 선택할 수 있어요.\nOFF 일 때는 한 파일씩 교체.",
|
|
},
|
|
]}
|
|
source={current as any}
|
|
onToggle={(k, v) => patch({ [k]: v } as Partial<InputConfig>)}
|
|
/>
|
|
</CPSection>
|
|
)}
|
|
|
|
{type === "entity" && (
|
|
<CPSection title="③ 엔티티 참조" desc="FK 참조 — 팝업에서 행 선택">
|
|
<Hint tone="warn">
|
|
FK 참조 picker UI 는 추후 추가 예정. 현재는 type 만 저장 (백엔드 키 보존).
|
|
</Hint>
|
|
</CPSection>
|
|
)}
|
|
|
|
{type === "code" && (
|
|
<CPSection title="③ 자동채번" desc="readonly · 저장 시 자동 생성">
|
|
<Hint>채번 규칙은 별도 설정에서 관리합니다.</Hint>
|
|
</CPSection>
|
|
)}
|
|
|
|
{type === "checkbox" && (
|
|
<CPSection title="③ 체크박스">
|
|
<Hint>체크박스는 추가 옵션이 없습니다 (참/거짓).</Hint>
|
|
</CPSection>
|
|
)}
|
|
|
|
{/* ── ▾ 옵션 ─────────────────────────── */}
|
|
<CPGroup title="옵션" defaultOpen>
|
|
<FeatureChipGrid
|
|
items={[
|
|
{
|
|
key: "required",
|
|
label: "필수",
|
|
desc: "비어 있으면 저장 시 검증 오류.\n라벨 우측에 빨간 * 표시.",
|
|
},
|
|
{
|
|
key: "editable",
|
|
label: "편집 가능",
|
|
default: true,
|
|
desc: "사용자가 값을 수정할 수 있어요.\nOFF = 화면에는 보이되 편집 불가 (readonly).",
|
|
},
|
|
{
|
|
key: "disabled",
|
|
label: "비활성화",
|
|
desc: "필드 자체를 회색 처리하고 입력/포커스 차단.\n조건부 표시와 함께 자주 쓰여요.",
|
|
},
|
|
]}
|
|
source={current as any}
|
|
onToggle={(k, v) => patch({ [k]: v } as Partial<InputConfig>)}
|
|
/>
|
|
</CPGroup>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
InvInputConfigPanel.displayName = "InvInputConfigPanel";
|
|
|
|
export default InvInputConfigPanel;
|