feat: 기간별 단가 설정 기능 구현 - 자동 계산 시스템
- 선택항목 상세입력 컴포넌트 확장 - 실시간 가격 계산 기능 추가 (할인율/할인금액, 반올림 방식) - 카테고리 값 기반 연산 매핑 시스템 - 3단계 드릴다운 방식 설정 UI (메뉴 → 카테고리 → 값 매핑) - 설정 가능한 계산 로직 - autoCalculation 설정으로 계산 필드명 동적 지정 - valueMapping으로 카테고리 코드와 연산 타입 매핑 - 할인 방식: none/rate/amount - 반올림 방식: none/round/floor/ceil - 반올림 단위: 1/10/100/1000 - UI 개선 - 입력 필드 가로 배치 (반응형 Grid) - 카테고리 타입 필드 옵션 로딩 개선 - 계산 결과 필드 자동 표시 및 읽기 전용 처리 - 날짜 입력 필드 네이티브 피커 지원 - API 연동 - 2레벨 메뉴 목록 조회 - 메뉴별 카테고리 컬럼 조회 - 카테고리별 값 목록 조회 - 문서화 - 기간별 단가 설정 가이드 작성
This commit is contained in:
@@ -8,7 +8,8 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, ChevronsUpDown, Search } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Check, ChevronsUpDown, Search, Plus, X, ChevronUp, ChevronDown, Type, Database } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
@@ -16,6 +17,15 @@ import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
|
||||
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
|
||||
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
|
||||
|
||||
// 🆕 제목 블록 타입
|
||||
interface TitleBlock {
|
||||
id: string;
|
||||
type: "text" | "field";
|
||||
value: string; // text: 텍스트 내용, field: 컬럼명
|
||||
tableName?: string; // field일 때 테이블명
|
||||
label?: string; // field일 때 표시용 라벨
|
||||
}
|
||||
|
||||
interface ButtonConfigPanelProps {
|
||||
component: ComponentData;
|
||||
onUpdateProperty: (path: string, value: any) => void;
|
||||
@@ -64,6 +74,15 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||
const [displayColumnOpen, setDisplayColumnOpen] = useState(false);
|
||||
const [displayColumnSearch, setDisplayColumnSearch] = useState("");
|
||||
|
||||
// 🆕 제목 블록 빌더 상태
|
||||
const [titleBlocks, setTitleBlocks] = useState<TitleBlock[]>([]);
|
||||
const [availableTables, setAvailableTables] = useState<Array<{ name: string; label: string }>>([]); // 시스템의 모든 테이블 목록
|
||||
const [tableColumnsMap, setTableColumnsMap] = useState<Record<string, Array<{ name: string; label: string }>>>({});
|
||||
const [blockTableSearches, setBlockTableSearches] = useState<Record<string, string>>({}); // 블록별 테이블 검색어
|
||||
const [blockColumnSearches, setBlockColumnSearches] = useState<Record<string, string>>({}); // 블록별 컬럼 검색어
|
||||
const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 테이블 Popover 열림 상태
|
||||
const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 컬럼 Popover 열림 상태
|
||||
|
||||
// 🎯 플로우 위젯이 화면에 있는지 확인
|
||||
const hasFlowWidget = useMemo(() => {
|
||||
const found = allComponents.some((comp: any) => {
|
||||
@@ -95,9 +114,150 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||
editModalDescription: String(latestAction.editModalDescription || ""),
|
||||
targetUrl: String(latestAction.targetUrl || ""),
|
||||
});
|
||||
|
||||
// 🆕 제목 블록 초기화
|
||||
if (latestAction.modalTitleBlocks && latestAction.modalTitleBlocks.length > 0) {
|
||||
setTitleBlocks(latestAction.modalTitleBlocks);
|
||||
} else {
|
||||
// 기본값: 빈 배열
|
||||
setTitleBlocks([]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [component.id]);
|
||||
|
||||
// 🆕 제목 블록 핸들러
|
||||
const addTextBlock = () => {
|
||||
const newBlock: TitleBlock = {
|
||||
id: `text-${Date.now()}`,
|
||||
type: "text",
|
||||
value: "",
|
||||
};
|
||||
const updatedBlocks = [...titleBlocks, newBlock];
|
||||
setTitleBlocks(updatedBlocks);
|
||||
onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks);
|
||||
};
|
||||
|
||||
const addFieldBlock = () => {
|
||||
const newBlock: TitleBlock = {
|
||||
id: `field-${Date.now()}`,
|
||||
type: "field",
|
||||
value: "",
|
||||
tableName: "",
|
||||
label: "",
|
||||
};
|
||||
const updatedBlocks = [...titleBlocks, newBlock];
|
||||
setTitleBlocks(updatedBlocks);
|
||||
onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks);
|
||||
};
|
||||
|
||||
const updateBlock = (id: string, updates: Partial<TitleBlock>) => {
|
||||
const updatedBlocks = titleBlocks.map((block) =>
|
||||
block.id === id ? { ...block, ...updates } : block
|
||||
);
|
||||
setTitleBlocks(updatedBlocks);
|
||||
onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks);
|
||||
};
|
||||
|
||||
const removeBlock = (id: string) => {
|
||||
const updatedBlocks = titleBlocks.filter((block) => block.id !== id);
|
||||
setTitleBlocks(updatedBlocks);
|
||||
onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks);
|
||||
};
|
||||
|
||||
const moveBlockUp = (id: string) => {
|
||||
const index = titleBlocks.findIndex((b) => b.id === id);
|
||||
if (index <= 0) return;
|
||||
const newBlocks = [...titleBlocks];
|
||||
[newBlocks[index - 1], newBlocks[index]] = [newBlocks[index], newBlocks[index - 1]];
|
||||
setTitleBlocks(newBlocks);
|
||||
onUpdateProperty("componentConfig.action.modalTitleBlocks", newBlocks);
|
||||
};
|
||||
|
||||
const moveBlockDown = (id: string) => {
|
||||
const index = titleBlocks.findIndex((b) => b.id === id);
|
||||
if (index < 0 || index >= titleBlocks.length - 1) return;
|
||||
const newBlocks = [...titleBlocks];
|
||||
[newBlocks[index], newBlocks[index + 1]] = [newBlocks[index + 1], newBlocks[index]];
|
||||
setTitleBlocks(newBlocks);
|
||||
onUpdateProperty("componentConfig.action.modalTitleBlocks", newBlocks);
|
||||
};
|
||||
|
||||
// 🆕 제목 미리보기 생성
|
||||
const generateTitlePreview = (): string => {
|
||||
if (titleBlocks.length === 0) return "(제목 없음)";
|
||||
return titleBlocks
|
||||
.map((block) => {
|
||||
if (block.type === "text") {
|
||||
return block.value || "(텍스트)";
|
||||
} else {
|
||||
return block.label || block.value || "(필드)";
|
||||
}
|
||||
})
|
||||
.join("");
|
||||
};
|
||||
|
||||
// 🆕 시스템의 모든 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const fetchAllTables = async () => {
|
||||
try {
|
||||
const response = await apiClient.get("/table-management/tables");
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
const tables = response.data.data.map((table: any) => ({
|
||||
name: table.tableName,
|
||||
label: table.displayName || table.tableName,
|
||||
}));
|
||||
setAvailableTables(tables);
|
||||
console.log(`✅ 전체 테이블 목록 로드 성공:`, tables.length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAllTables();
|
||||
}, []);
|
||||
|
||||
// 🆕 특정 테이블의 컬럼 로드
|
||||
const loadTableColumns = async (tableName: string) => {
|
||||
if (!tableName || tableColumnsMap[tableName]) return;
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||
console.log(`📥 테이블 ${tableName} 컬럼 응답:`, response.data);
|
||||
|
||||
if (response.data.success) {
|
||||
// data가 배열인지 확인
|
||||
let columnData = response.data.data;
|
||||
|
||||
// data.columns 형태일 수도 있음
|
||||
if (!Array.isArray(columnData) && columnData?.columns) {
|
||||
columnData = columnData.columns;
|
||||
}
|
||||
|
||||
// data.data 형태일 수도 있음
|
||||
if (!Array.isArray(columnData) && columnData?.data) {
|
||||
columnData = columnData.data;
|
||||
}
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => {
|
||||
const name = col.name || col.columnName;
|
||||
const label = col.displayName || col.label || col.columnLabel || name;
|
||||
console.log(` - 컬럼: ${name} → "${label}"`);
|
||||
return { name, label };
|
||||
});
|
||||
setTableColumnsMap((prev) => ({ ...prev, [tableName]: columns }));
|
||||
console.log(`✅ 테이블 ${tableName} 컬럼 로드 성공:`, columns.length, "개");
|
||||
} else {
|
||||
console.error("❌ 컬럼 데이터가 배열이 아닙니다:", columnData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준)
|
||||
useEffect(() => {
|
||||
const fetchScreens = async () => {
|
||||
@@ -431,25 +591,284 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-primary font-medium">
|
||||
✨ 비워두면 같은 화면의 TableList를 자동으로 감지합니다
|
||||
✨ 비워두면 현재 화면의 TableList를 자동으로 감지합니다
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
직접 지정하려면 테이블명을 입력하세요 (예: item_info)
|
||||
• 자동 감지: 현재 화면의 TableList 선택 데이터<br/>
|
||||
• 누적 전달: 이전 모달의 모든 데이터도 자동으로 함께 전달<br/>
|
||||
• 다음 화면에서 tableName으로 바로 사용 가능<br/>
|
||||
• 수동 설정: 필요시 직접 테이블명 입력 (예: item_info)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="modal-title-with-data">모달 제목</Label>
|
||||
<Input
|
||||
id="modal-title-with-data"
|
||||
placeholder="예: 상세 정보 입력"
|
||||
value={localInputs.modalTitle}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalInputs((prev) => ({ ...prev, modalTitle: newValue }));
|
||||
onUpdateProperty("componentConfig.action.modalTitle", newValue);
|
||||
}}
|
||||
/>
|
||||
{/* 🆕 블록 기반 제목 빌더 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>모달 제목 구성</Label>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addTextBlock}
|
||||
className="h-6 text-xs"
|
||||
>
|
||||
<Type className="mr-1 h-3 w-3" />
|
||||
텍스트 추가
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addFieldBlock}
|
||||
className="h-6 text-xs"
|
||||
>
|
||||
<Database className="mr-1 h-3 w-3" />
|
||||
필드 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 블록 목록 */}
|
||||
<div className="space-y-2">
|
||||
{titleBlocks.length === 0 ? (
|
||||
<div className="text-center py-4 text-xs text-muted-foreground border-2 border-dashed rounded">
|
||||
텍스트나 필드를 추가하여 제목을 구성하세요
|
||||
</div>
|
||||
) : (
|
||||
titleBlocks.map((block, index) => (
|
||||
<Card key={block.id} className="p-2">
|
||||
<div className="flex items-start gap-2">
|
||||
{/* 순서 변경 버튼 */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => moveBlockUp(block.id)}
|
||||
disabled={index === 0}
|
||||
className="h-5 w-5 p-0"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => moveBlockDown(block.id)}
|
||||
disabled={index === titleBlocks.length - 1}
|
||||
className="h-5 w-5 p-0"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 블록 타입 표시 */}
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{block.type === "text" ? (
|
||||
<Type className="h-4 w-4 text-blue-500" />
|
||||
) : (
|
||||
<Database className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 블록 설정 */}
|
||||
<div className="flex-1 space-y-2">
|
||||
{block.type === "text" ? (
|
||||
// 텍스트 블록
|
||||
<Input
|
||||
placeholder="텍스트 입력 (예: 품목 상세정보 - )"
|
||||
value={block.value}
|
||||
onChange={(e) => updateBlock(block.id, { value: e.target.value })}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
) : (
|
||||
// 필드 블록
|
||||
<>
|
||||
{/* 테이블 선택 - Combobox */}
|
||||
<Popover
|
||||
open={blockTablePopoverOpen[block.id] || false}
|
||||
onOpenChange={(open) => {
|
||||
setBlockTablePopoverOpen((prev) => ({ ...prev, [block.id]: open }));
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
>
|
||||
{block.tableName
|
||||
? (availableTables.find((t) => t.name === block.tableName)?.label || block.tableName)
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="테이블 검색 (라벨 또는 이름)..."
|
||||
className="h-7 text-xs"
|
||||
value={blockTableSearches[block.id] || ""}
|
||||
onValueChange={(value) => {
|
||||
setBlockTableSearches((prev) => ({ ...prev, [block.id]: value }));
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableTables
|
||||
.filter((table) => {
|
||||
const search = (blockTableSearches[block.id] || "").toLowerCase();
|
||||
if (!search) return true;
|
||||
return (
|
||||
table.label.toLowerCase().includes(search) ||
|
||||
table.name.toLowerCase().includes(search)
|
||||
);
|
||||
})
|
||||
.map((table) => (
|
||||
<CommandItem
|
||||
key={table.name}
|
||||
value={table.name}
|
||||
onSelect={() => {
|
||||
updateBlock(block.id, { tableName: table.name, value: "" });
|
||||
loadTableColumns(table.name);
|
||||
setBlockTableSearches((prev) => ({ ...prev, [block.id]: "" }));
|
||||
setBlockTablePopoverOpen((prev) => ({ ...prev, [block.id]: false }));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
block.tableName === table.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{table.label}</span>
|
||||
<span className="ml-2 text-[10px] text-muted-foreground">({table.name})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{block.tableName && (
|
||||
<>
|
||||
{/* 컬럼 선택 - Combobox (라벨명 표시) */}
|
||||
<Popover
|
||||
open={blockColumnPopoverOpen[block.id] || false}
|
||||
onOpenChange={(open) => {
|
||||
setBlockColumnPopoverOpen((prev) => ({ ...prev, [block.id]: open }));
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
>
|
||||
{block.value
|
||||
? (tableColumnsMap[block.tableName]?.find((c) => c.name === block.value)?.label || block.value)
|
||||
: "컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색 (라벨 또는 이름)..."
|
||||
className="h-7 text-xs"
|
||||
value={blockColumnSearches[block.id] || ""}
|
||||
onValueChange={(value) => {
|
||||
setBlockColumnSearches((prev) => ({ ...prev, [block.id]: value }));
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{(tableColumnsMap[block.tableName] || [])
|
||||
.filter((col) => {
|
||||
const search = (blockColumnSearches[block.id] || "").toLowerCase();
|
||||
if (!search) return true;
|
||||
return (
|
||||
col.label.toLowerCase().includes(search) ||
|
||||
col.name.toLowerCase().includes(search)
|
||||
);
|
||||
})
|
||||
.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={col.name}
|
||||
onSelect={() => {
|
||||
updateBlock(block.id, {
|
||||
value: col.name,
|
||||
label: col.label,
|
||||
});
|
||||
setBlockColumnSearches((prev) => ({ ...prev, [block.id]: "" }));
|
||||
setBlockColumnPopoverOpen((prev) => ({ ...prev, [block.id]: false }));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
block.value === col.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{col.label}</span>
|
||||
<span className="ml-2 text-[10px] text-muted-foreground">({col.name})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Input
|
||||
placeholder="표시 라벨 (예: 품목명)"
|
||||
value={block.label || ""}
|
||||
onChange={(e) => updateBlock(block.id, { label: e.target.value })}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeBlock(block.id)}
|
||||
className="h-7 w-7 p-0 text-red-500"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
{titleBlocks.length > 0 && (
|
||||
<div className="mt-2 p-2 bg-muted rounded text-xs">
|
||||
<span className="text-muted-foreground">미리보기: </span>
|
||||
<span className="font-medium">{generateTitlePreview()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
• 텍스트: 고정 텍스트 입력 (예: "품목 상세정보 - ")<br/>
|
||||
• 필드: 이전 화면 데이터로 자동 채워짐 (예: 품목명, 규격)<br/>
|
||||
• 순서 변경: ↑↓ 버튼으로 자유롭게 배치<br/>
|
||||
• 데이터가 없으면 "표시 라벨"이 대신 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user