ERP-node/frontend/components/v2/config-panels/V2InputConfigPanel.tsx

622 lines
28 KiB
TypeScript
Raw Normal View History

2025-12-23 09:31:18 +09:00
"use client";
/**
* V2Input
* UX: 기본 -> -> ()
2025-12-23 09:31:18 +09:00
*/
import React, { useState, useEffect } from "react";
2025-12-23 09:31:18 +09:00
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Settings, ChevronDown, Loader2, Type, Hash, Lock, AlignLeft, SlidersHorizontal, Palette, ListOrdered } from "lucide-react";
import { cn } from "@/lib/utils";
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
import { getAvailableNumberingRules } from "@/lib/api/numberingRule";
import { NumberingRuleConfig } from "@/types/numbering-rule";
2025-12-23 09:31:18 +09:00
interface V2InputConfigPanelProps {
2025-12-23 09:31:18 +09:00
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
menuObjid?: number;
2025-12-23 09:31:18 +09:00
}
export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config, onChange, menuObjid }) => {
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
const [loadingRules, setLoadingRules] = useState(false);
const [parentMenus, setParentMenus] = useState<any[]>([]);
const [loadingMenus, setLoadingMenus] = useState(false);
const [selectedMenuObjid, setSelectedMenuObjid] = useState<number | undefined>(() => {
return config.autoGeneration?.selectedMenuObjid || menuObjid;
});
const [advancedOpen, setAdvancedOpen] = useState(false);
2025-12-23 09:31:18 +09:00
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
useEffect(() => {
const loadMenus = async () => {
setLoadingMenus(true);
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get("/admin/menus");
if (response.data.success && response.data.data) {
const allMenus = response.data.data;
const userMenus = allMenus.filter((menu: any) => {
const menuType = menu.menu_type || menu.menuType;
const level = menu.level || menu.lev || menu.LEVEL;
return menuType === '1' && (level === 2 || level === 3 || level === '2' || level === '3');
});
setParentMenus(userMenus);
}
} catch (error) {
console.error("부모 메뉴 로드 실패:", error);
} finally {
setLoadingMenus(false);
}
};
loadMenus();
}, []);
const inputType = config.inputType || config.type || "text";
useEffect(() => {
const loadRules = async () => {
const isNumbering = inputType === "numbering" || config.autoGeneration?.type === "numbering_rule";
if (!isNumbering) return;
if (!selectedMenuObjid) { setNumberingRules([]); return; }
setLoadingRules(true);
try {
const response = await getAvailableNumberingRules(selectedMenuObjid);
if (response.success && response.data) {
setNumberingRules(response.data);
}
} catch (error) {
console.error("채번 규칙 목록 로드 실패:", error);
setNumberingRules([]);
} finally {
setLoadingRules(false);
}
};
loadRules();
}, [selectedMenuObjid, config.autoGeneration?.type, inputType]);
2025-12-23 09:31:18 +09:00
return (
<div className="space-y-4">
{/* ─── 1단계: 입력 타입 선택 (카드 방식) ─── */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Type className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium"> </p>
</div>
<p className="text-[11px] text-muted-foreground"> </p>
2025-12-23 09:31:18 +09:00
</div>
<div className="grid grid-cols-2 gap-2">
{[
{ value: "text", icon: Type, label: "텍스트", desc: "일반 텍스트 입력" },
{ value: "number", icon: Hash, label: "숫자", desc: "숫자만 입력" },
{ value: "password", icon: Lock, label: "비밀번호", desc: "마스킹 처리" },
{ value: "textarea", icon: AlignLeft, label: "여러 줄", desc: "긴 텍스트 입력" },
{ value: "slider", icon: SlidersHorizontal, label: "슬라이더", desc: "범위 선택" },
{ value: "color", icon: Palette, label: "색상", desc: "색상 선택기" },
{ value: "numbering", icon: ListOrdered, label: "채번", desc: "자동 번호 생성" },
].map((item) => (
<button
key={item.value}
type="button"
onClick={() => {
if (item.value === "numbering") {
const autoMenuObjid = selectedMenuObjid || menuObjid;
onChange({
...config,
inputType: "numbering",
autoGeneration: {
...config.autoGeneration,
type: "numbering_rule" as AutoGenerationType,
selectedMenuObjid: autoMenuObjid,
},
readonly: config.readonly ?? true,
});
if (autoMenuObjid) setSelectedMenuObjid(autoMenuObjid);
} else {
updateConfig("inputType", item.value);
}
}}
className={cn(
"flex items-center gap-2 rounded-lg border p-2.5 text-left transition-all",
inputType === item.value
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/30 hover:bg-muted/30"
)}
>
<item.icon className={cn(
"h-4 w-4 shrink-0",
inputType === item.value ? "text-primary" : "text-muted-foreground"
)} />
<div className="min-w-0">
<span className={cn(
"text-xs font-medium block",
inputType === item.value ? "text-primary" : "text-foreground"
)}>{item.label}</span>
<span className="text-[10px] text-muted-foreground block truncate">{item.desc}</span>
</div>
</button>
))}
</div>
{/* ─── 채번 타입 전용 설정 ─── */}
{inputType === "numbering" && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<ListOrdered className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> </p>
{menuObjid && selectedMenuObjid === menuObjid ? (
<div className="rounded-md border bg-background p-2">
<p className="text-xs text-muted-foreground"> </p>
<div className="mt-1 flex items-center justify-between">
<p className="text-sm font-medium">
{parentMenus.find((m: any) => m.objid === menuObjid)?.menu_name_kor
|| parentMenus.find((m: any) => m.objid === menuObjid)?.translated_name
|| `메뉴 #${menuObjid}`}
</p>
<button
type="button"
onClick={() => setSelectedMenuObjid(undefined)}
className="text-[10px] text-muted-foreground hover:text-foreground"
>
</button>
</div>
</div>
) : loadingMenus ? (
<div className="text-muted-foreground flex items-center gap-2 text-xs py-1">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : (
<Select
value={selectedMenuObjid ? String(selectedMenuObjid) : ""}
onValueChange={(v) => {
const objid = Number(v);
setSelectedMenuObjid(objid);
onChange({
...config,
autoGeneration: {
...config.autoGeneration,
type: "numbering_rule" as AutoGenerationType,
selectedMenuObjid: objid,
},
});
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="메뉴를 선택해주세요" />
</SelectTrigger>
<SelectContent>
{parentMenus.map((menu: any) => (
<SelectItem key={menu.objid} value={String(menu.objid)}>
{menu.menu_name_kor || menu.translated_name || menu.menu_name || `메뉴 ${menu.objid}`}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{selectedMenuObjid && (
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> </p>
{loadingRules ? (
<div className="text-muted-foreground flex items-center gap-2 text-xs py-1">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : numberingRules.length > 0 ? (
<Select
value={config.autoGeneration?.numberingRuleId ? String(config.autoGeneration.numberingRuleId) : ""}
onValueChange={(v) => {
onChange({
...config,
autoGeneration: {
...config.autoGeneration,
type: "numbering_rule" as AutoGenerationType,
numberingRuleId: Number(v),
selectedMenuObjid,
},
});
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="채번 규칙 선택" />
</SelectTrigger>
<SelectContent>
{numberingRules.map((rule) => (
<SelectItem key={rule.ruleId} value={String(rule.ruleId)}>
{rule.ruleName} ({rule.separator || "-"}{"{번호}"})
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-xs text-muted-foreground"> </p>
)}
</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.readonly !== false}
onCheckedChange={(checked) => updateConfig("readonly", checked)}
/>
</div>
</div>
)}
{/* ─── 채번 타입이 아닌 경우: 기본 설정 ─── */}
{inputType !== "numbering" && (
<>
{/* 기본 설정 영역 */}
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
{/* 안내 텍스트 (placeholder) */}
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input
value={config.placeholder || ""}
onChange={(e) => updateConfig("placeholder", e.target.value)}
placeholder="입력 안내"
className="h-7 w-[160px] text-xs"
/>
2025-12-23 09:31:18 +09:00
</div>
{/* 입력 형식 - 텍스트 타입 전용 */}
{(inputType === "text" || !config.inputType) && (
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Select value={config.format || "none"} onValueChange={(value) => updateConfig("format", value)}>
<SelectTrigger className="h-7 w-[160px] text-xs">
<SelectValue placeholder="형식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
<SelectItem value="email"></SelectItem>
<SelectItem value="tel"></SelectItem>
<SelectItem value="url">URL</SelectItem>
<SelectItem value="currency"></SelectItem>
<SelectItem value="biz_no"></SelectItem>
</SelectContent>
</Select>
</div>
)}
2025-12-23 09:31:18 +09:00
{/* 입력 마스크 */}
<div className="flex items-center justify-between py-1">
<div>
<span className="text-xs text-muted-foreground"> </span>
<p className="text-[10px] text-muted-foreground mt-0.5"># = , A = , * = </p>
</div>
<Input
value={config.mask || ""}
onChange={(e) => updateConfig("mask", e.target.value)}
placeholder="###-####-####"
className="h-7 w-[160px] text-xs"
/>
</div>
{/* 숫자/슬라이더: 범위 설정 */}
{(inputType === "number" || inputType === "slider") && (
<div className="space-y-2 pt-1">
<p className="text-xs text-muted-foreground"> </p>
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Input
type="number"
value={config.min ?? ""}
onChange={(e) => updateConfig("min", e.target.value ? Number(e.target.value) : undefined)}
placeholder="0"
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Input
type="number"
value={config.max ?? ""}
onChange={(e) => updateConfig("max", e.target.value ? Number(e.target.value) : undefined)}
placeholder="100"
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Input
type="number"
value={config.step ?? ""}
onChange={(e) => updateConfig("step", e.target.value ? Number(e.target.value) : undefined)}
placeholder="1"
className="h-7 text-xs"
/>
</div>
</div>
</div>
)}
{/* 여러 줄 텍스트: 줄 수 */}
{inputType === "textarea" && (
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input
type="number"
value={config.rows || 3}
onChange={(e) => updateConfig("rows", parseInt(e.target.value) || 3)}
min={2}
max={20}
className="h-7 w-[160px] text-xs"
/>
</div>
)}
</div>
{/* ─── 고급 설정: 자동 생성 (Collapsible) ─── */}
<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.autoGeneration?.enabled || false}
onCheckedChange={(checked) => {
const currentConfig = config.autoGeneration || { type: "none", enabled: false };
updateConfig("autoGeneration", {
...currentConfig,
enabled: checked as boolean,
});
}}
/>
</div>
{config.autoGeneration?.enabled && (
<div className="space-y-3 ml-1 border-l-2 border-primary/20 pl-3">
{/* 자동 생성 타입 */}
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> </p>
<Select
value={config.autoGeneration?.type || "none"}
onValueChange={(value: AutoGenerationType) => {
const currentConfig = config.autoGeneration || { type: "none", enabled: false };
updateConfig("autoGeneration", {
...currentConfig,
type: value,
});
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="자동생성 타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
<SelectItem value="uuid">UUID </SelectItem>
<SelectItem value="current_user"> ID</SelectItem>
<SelectItem value="current_time"> </SelectItem>
<SelectItem value="sequence"> </SelectItem>
<SelectItem value="numbering_rule"> </SelectItem>
<SelectItem value="random_string"> </SelectItem>
<SelectItem value="random_number"> </SelectItem>
<SelectItem value="company_code"> </SelectItem>
<SelectItem value="department"> </SelectItem>
</SelectContent>
</Select>
</div>
{config.autoGeneration?.type && config.autoGeneration.type !== "none" && (
<p className="text-[11px] text-muted-foreground">
{AutoGenerationUtils.getTypeDescription(config.autoGeneration.type)}
</p>
)}
{/* 채번 규칙 선택 */}
{config.autoGeneration?.type === "numbering_rule" && (
<div className="space-y-3">
<div>
<p className="mb-1.5 text-xs text-muted-foreground">
<span className="text-destructive">*</span>
</p>
<Select
value={selectedMenuObjid?.toString() || ""}
onValueChange={(value) => {
const menuId = parseInt(value);
setSelectedMenuObjid(menuId);
updateConfig("autoGeneration", {
...config.autoGeneration,
selectedMenuObjid: menuId,
});
}}
disabled={loadingMenus}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder={loadingMenus ? "로딩 중..." : "메뉴 선택"} />
</SelectTrigger>
<SelectContent>
{parentMenus.length === 0 ? (
<SelectItem value="no-menus" disabled>
</SelectItem>
) : (
parentMenus.map((menu) => (
<SelectItem key={menu.objid} value={menu.objid.toString()}>
{menu.menu_name_kor}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{selectedMenuObjid ? (
<div>
<p className="mb-1.5 text-xs text-muted-foreground">
<span className="text-destructive">*</span>
</p>
{loadingRules ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : (
<Select
value={config.autoGeneration?.options?.numberingRuleId || ""}
onValueChange={(value) => {
updateConfig("autoGeneration", {
...config.autoGeneration,
options: {
...config.autoGeneration?.options,
numberingRuleId: value,
},
});
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="규칙 선택" />
</SelectTrigger>
<SelectContent>
{numberingRules.length === 0 ? (
<SelectItem value="no-rules" disabled>
</SelectItem>
) : (
numberingRules.map((rule) => (
<SelectItem key={rule.ruleId} value={rule.ruleId}>
{rule.ruleName}
</SelectItem>
))
)}
</SelectContent>
</Select>
)}
</div>
) : (
<div className="rounded-md border border-amber-200 bg-amber-50 p-2.5 text-xs text-amber-800">
</div>
)}
</div>
)}
{/* 랜덤/순차 옵션 */}
{config.autoGeneration?.type &&
["random_string", "random_number", "sequence"].includes(config.autoGeneration.type) && (
<div className="space-y-3">
{["random_string", "random_number"].includes(config.autoGeneration.type) && (
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"></span>
<Input
type="number"
min="1"
max="50"
value={config.autoGeneration?.options?.length || 8}
onChange={(e) => {
updateConfig("autoGeneration", {
...config.autoGeneration,
options: {
...config.autoGeneration?.options,
length: parseInt(e.target.value) || 8,
},
});
}}
className="h-7 w-[120px] text-xs"
/>
</div>
)}
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"></span>
<Input
value={config.autoGeneration?.options?.prefix || ""}
onChange={(e) => {
updateConfig("autoGeneration", {
...config.autoGeneration,
options: {
...config.autoGeneration?.options,
prefix: e.target.value,
},
});
}}
placeholder="예: INV-"
className="h-7 w-[120px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"></span>
<Input
value={config.autoGeneration?.options?.suffix || ""}
onChange={(e) => {
updateConfig("autoGeneration", {
...config.autoGeneration,
options: {
...config.autoGeneration?.options,
suffix: e.target.value,
},
});
}}
className="h-7 w-[120px] text-xs"
/>
</div>
<div>
<span className="text-xs text-muted-foreground"></span>
<div className="mt-1 rounded-md border bg-muted p-2 text-xs font-mono">
{AutoGenerationUtils.generatePreviewValue(config.autoGeneration)}
</div>
</div>
</div>
)}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</>
)}
2025-12-23 09:31:18 +09:00
</div>
);
};
V2InputConfigPanel.displayName = "V2InputConfigPanel";
2025-12-23 09:31:18 +09:00
export default V2InputConfigPanel;