488 lines
14 KiB
TypeScript
488 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { ChevronLeft, ChevronRight, Plus, Trash2 } from "lucide-react";
|
|
import type {
|
|
PopSearchConfig,
|
|
SearchInputType,
|
|
DatePresetOption,
|
|
} from "./types";
|
|
import { SEARCH_INPUT_TYPE_LABELS, DATE_PRESET_LABELS } from "./types";
|
|
|
|
// ========================================
|
|
// 기본값
|
|
// ========================================
|
|
|
|
const DEFAULT_CONFIG: PopSearchConfig = {
|
|
inputType: "text",
|
|
fieldName: "",
|
|
placeholder: "검색어 입력",
|
|
debounceMs: 500,
|
|
triggerOnEnter: true,
|
|
labelPosition: "top",
|
|
labelText: "",
|
|
labelVisible: true,
|
|
};
|
|
|
|
// ========================================
|
|
// 설정 패널 메인
|
|
// ========================================
|
|
|
|
interface ConfigPanelProps {
|
|
config: PopSearchConfig | undefined;
|
|
onUpdate: (config: PopSearchConfig) => void;
|
|
}
|
|
|
|
export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
|
|
const [step, setStep] = useState(0);
|
|
const cfg = { ...DEFAULT_CONFIG, ...(config || {}) };
|
|
|
|
const update = (partial: Partial<PopSearchConfig>) => {
|
|
onUpdate({ ...cfg, ...partial });
|
|
};
|
|
|
|
const STEPS = ["기본 설정", "상세 설정"];
|
|
|
|
return (
|
|
<div className="space-y-3 overflow-y-auto pr-1 pb-32">
|
|
{/* Stepper 헤더 */}
|
|
<div className="flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-2">
|
|
{STEPS.map((s, i) => (
|
|
<button
|
|
key={s}
|
|
type="button"
|
|
onClick={() => setStep(i)}
|
|
className={cn(
|
|
"flex items-center gap-1 rounded px-2 py-1 text-[10px] font-medium transition-colors",
|
|
step === i
|
|
? "bg-primary text-primary-foreground"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-background text-[8px] font-bold text-foreground">
|
|
{i + 1}
|
|
</span>
|
|
{s}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* STEP 1: 기본 설정 */}
|
|
{step === 0 && (
|
|
<StepBasicSettings cfg={cfg} update={update} />
|
|
)}
|
|
|
|
{/* STEP 2: 타입별 상세 설정 */}
|
|
{step === 1 && (
|
|
<StepDetailSettings cfg={cfg} update={update} />
|
|
)}
|
|
|
|
{/* 이전/다음 버튼 */}
|
|
<div className="flex justify-between pt-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 text-[10px]"
|
|
disabled={step === 0}
|
|
onClick={() => setStep(step - 1)}
|
|
>
|
|
<ChevronLeft className="mr-1 h-3 w-3" />
|
|
이전
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 text-[10px]"
|
|
disabled={step === STEPS.length - 1}
|
|
onClick={() => setStep(step + 1)}
|
|
>
|
|
다음
|
|
<ChevronRight className="ml-1 h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// STEP 1: 기본 설정
|
|
// ========================================
|
|
|
|
interface StepProps {
|
|
cfg: PopSearchConfig;
|
|
update: (partial: Partial<PopSearchConfig>) => void;
|
|
}
|
|
|
|
function StepBasicSettings({ cfg, update }: StepProps) {
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* 입력 타입 */}
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">입력 타입</Label>
|
|
<Select
|
|
value={cfg.inputType}
|
|
onValueChange={(v) => update({ inputType: v as SearchInputType })}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(SEARCH_INPUT_TYPE_LABELS).map(([key, label]) => (
|
|
<SelectItem key={key} value={key} className="text-xs">
|
|
{label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 필드명 */}
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">
|
|
필드명 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
value={cfg.fieldName}
|
|
onChange={(e) => update({ fieldName: e.target.value })}
|
|
placeholder="예: supplier_code"
|
|
className="h-8 text-xs"
|
|
/>
|
|
<p className="text-[9px] text-muted-foreground">
|
|
filter_changed 이벤트에서 이 이름으로 값이 전달됩니다
|
|
</p>
|
|
</div>
|
|
|
|
{/* 플레이스홀더 */}
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">플레이스홀더</Label>
|
|
<Input
|
|
value={cfg.placeholder || ""}
|
|
onChange={(e) => update({ placeholder: e.target.value })}
|
|
placeholder="입력 힌트 텍스트"
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
{/* 라벨 표시 */}
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="labelVisible"
|
|
checked={cfg.labelVisible !== false}
|
|
onCheckedChange={(checked) =>
|
|
update({ labelVisible: Boolean(checked) })
|
|
}
|
|
/>
|
|
<Label htmlFor="labelVisible" className="text-[10px]">
|
|
라벨 표시
|
|
</Label>
|
|
</div>
|
|
|
|
{/* 라벨 텍스트 + 위치 (라벨 표시 ON일 때만) */}
|
|
{cfg.labelVisible !== false && (
|
|
<>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">라벨 텍스트</Label>
|
|
<Input
|
|
value={cfg.labelText || ""}
|
|
onChange={(e) => update({ labelText: e.target.value })}
|
|
placeholder="예: 거래처명"
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">라벨 위치</Label>
|
|
<Select
|
|
value={cfg.labelPosition || "top"}
|
|
onValueChange={(v) =>
|
|
update({ labelPosition: v as "top" | "left" })
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="top" className="text-xs">위 (기본)</SelectItem>
|
|
<SelectItem value="left" className="text-xs">왼쪽</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// STEP 2: 타입별 상세 설정
|
|
// ========================================
|
|
|
|
function StepDetailSettings({ cfg, update }: StepProps) {
|
|
switch (cfg.inputType) {
|
|
case "text":
|
|
case "number":
|
|
return <TextDetailSettings cfg={cfg} update={update} />;
|
|
case "select":
|
|
return <SelectDetailSettings cfg={cfg} update={update} />;
|
|
case "date-preset":
|
|
return <DatePresetDetailSettings cfg={cfg} update={update} />;
|
|
case "modal-table":
|
|
case "modal-card":
|
|
case "modal-icon-grid":
|
|
return <ModalDetailSettings cfg={cfg} update={update} />;
|
|
case "toggle":
|
|
return (
|
|
<div className="rounded-lg bg-muted/50 p-3">
|
|
<p className="text-[10px] text-muted-foreground">
|
|
토글은 추가 설정이 없습니다. ON/OFF 값이 바로 전달됩니다.
|
|
</p>
|
|
</div>
|
|
);
|
|
default:
|
|
return (
|
|
<div className="rounded-lg bg-muted/50 p-3">
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{cfg.inputType} 타입의 상세 설정은 후속 구현 예정입니다.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// text/number 상세 설정
|
|
// ========================================
|
|
|
|
function TextDetailSettings({ cfg, update }: StepProps) {
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* 디바운스 */}
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">디바운스 (ms)</Label>
|
|
<Input
|
|
type="number"
|
|
value={cfg.debounceMs ?? 500}
|
|
onChange={(e) =>
|
|
update({ debounceMs: Math.max(0, Number(e.target.value)) })
|
|
}
|
|
min={0}
|
|
max={5000}
|
|
step={100}
|
|
className="h-8 text-xs"
|
|
/>
|
|
<p className="text-[9px] text-muted-foreground">
|
|
입력 후 대기 시간. 0이면 즉시 발행 (권장: 300~500)
|
|
</p>
|
|
</div>
|
|
|
|
{/* Enter 발행 */}
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="triggerOnEnter"
|
|
checked={cfg.triggerOnEnter !== false}
|
|
onCheckedChange={(checked) =>
|
|
update({ triggerOnEnter: Boolean(checked) })
|
|
}
|
|
/>
|
|
<Label htmlFor="triggerOnEnter" className="text-[10px]">
|
|
Enter 키로 즉시 발행
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// select 상세 설정: 정적 옵션 편집
|
|
// ========================================
|
|
|
|
function SelectDetailSettings({ cfg, update }: StepProps) {
|
|
const options = cfg.options || [];
|
|
|
|
const addOption = () => {
|
|
update({
|
|
options: [
|
|
...options,
|
|
{ value: `opt_${options.length + 1}`, label: `옵션 ${options.length + 1}` },
|
|
],
|
|
});
|
|
};
|
|
|
|
const removeOption = (index: number) => {
|
|
update({ options: options.filter((_, i) => i !== index) });
|
|
};
|
|
|
|
const updateOption = (index: number, field: "value" | "label", val: string) => {
|
|
const next = options.map((opt, i) =>
|
|
i === index ? { ...opt, [field]: val } : opt
|
|
);
|
|
update({ options: next });
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<Label className="text-[10px]">옵션 목록</Label>
|
|
|
|
{options.length === 0 && (
|
|
<p className="text-[9px] text-muted-foreground">
|
|
옵션이 없습니다. 아래 버튼으로 추가하세요.
|
|
</p>
|
|
)}
|
|
|
|
{options.map((opt, i) => (
|
|
<div key={i} className="flex items-center gap-1">
|
|
<Input
|
|
value={opt.value}
|
|
onChange={(e) => updateOption(i, "value", e.target.value)}
|
|
placeholder="값"
|
|
className="h-7 flex-1 text-[10px]"
|
|
/>
|
|
<Input
|
|
value={opt.label}
|
|
onChange={(e) => updateOption(i, "label", e.target.value)}
|
|
placeholder="라벨"
|
|
className="h-7 flex-1 text-[10px]"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeOption(i)}
|
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 w-full text-[10px]"
|
|
onClick={addOption}
|
|
>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
옵션 추가
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// date-preset 상세 설정: 프리셋 선택
|
|
// ========================================
|
|
|
|
function DatePresetDetailSettings({ cfg, update }: StepProps) {
|
|
const ALL_PRESETS: DatePresetOption[] = [
|
|
"today",
|
|
"this-week",
|
|
"this-month",
|
|
"custom",
|
|
];
|
|
const activePresets = cfg.datePresets || ["today", "this-week", "this-month"];
|
|
|
|
const togglePreset = (preset: DatePresetOption) => {
|
|
const next = activePresets.includes(preset)
|
|
? activePresets.filter((p) => p !== preset)
|
|
: [...activePresets, preset];
|
|
update({ datePresets: next.length > 0 ? next : ["today"] });
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<Label className="text-[10px]">활성화할 프리셋</Label>
|
|
|
|
{ALL_PRESETS.map((preset) => (
|
|
<div key={preset} className="flex items-center gap-2">
|
|
<Checkbox
|
|
id={`preset_${preset}`}
|
|
checked={activePresets.includes(preset)}
|
|
onCheckedChange={() => togglePreset(preset)}
|
|
/>
|
|
<Label htmlFor={`preset_${preset}`} className="text-[10px]">
|
|
{DATE_PRESET_LABELS[preset]}
|
|
</Label>
|
|
</div>
|
|
))}
|
|
|
|
{activePresets.includes("custom") && (
|
|
<p className="text-[9px] text-muted-foreground">
|
|
"직접" 선택 시 날짜 입력 UI가 표시됩니다 (후속 구현)
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// modal-* 상세 설정
|
|
// ========================================
|
|
|
|
function ModalDetailSettings({ cfg, update }: StepProps) {
|
|
const mc = cfg.modalConfig || { modalCanvasId: "", displayField: "", valueField: "" };
|
|
|
|
const updateModal = (partial: Partial<typeof mc>) => {
|
|
update({ modalConfig: { ...mc, ...partial } });
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="rounded-lg border border-dashed border-muted-foreground/30 p-3">
|
|
<p className="text-[10px] text-muted-foreground">
|
|
모달 캔버스 연동은 모달 시스템 구현 후 활성화됩니다.
|
|
현재는 설정값만 저장됩니다.
|
|
</p>
|
|
</div>
|
|
|
|
{/* 모달 캔버스 ID */}
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">모달 캔버스 ID</Label>
|
|
<Input
|
|
value={mc.modalCanvasId}
|
|
onChange={(e) => updateModal({ modalCanvasId: e.target.value })}
|
|
placeholder="예: modal-supplier"
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
{/* 표시 필드 */}
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">표시 필드</Label>
|
|
<Input
|
|
value={mc.displayField}
|
|
onChange={(e) => updateModal({ displayField: e.target.value })}
|
|
placeholder="예: supplier_name"
|
|
className="h-8 text-xs"
|
|
/>
|
|
<p className="text-[9px] text-muted-foreground">
|
|
선택 후 입력란에 표시할 필드명
|
|
</p>
|
|
</div>
|
|
|
|
{/* 값 필드 */}
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">값 필드</Label>
|
|
<Input
|
|
value={mc.valueField}
|
|
onChange={(e) => updateModal({ valueField: e.target.value })}
|
|
placeholder="예: supplier_code"
|
|
className="h-8 text-xs"
|
|
/>
|
|
<p className="text-[9px] text-muted-foreground">
|
|
필터 값으로 사용할 필드명
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|