Files
invyone/frontend/lib/registry/components/input/InvInputConfigPanel.tsx
T
DDD1542 5f945363b2 refactor: V2Date 일괄 폐기 + InvField type=date 통일
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>
2026-04-29 13:22:55 +09:00

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;