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:
kjs
2025-11-18 16:12:47 +09:00
parent 967b76591b
commit e1a5befdf7
10 changed files with 1966 additions and 186 deletions

View File

@@ -15,6 +15,7 @@ import { screenApi } from "@/lib/api/screen";
import { ComponentData } from "@/types/screen";
import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
interface ScreenModalState {
isOpen: boolean;
@@ -394,60 +395,62 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</div>
</div>
) : screenData ? (
<div
className="relative bg-white mx-auto"
style={{
width: `${screenDimensions?.width || 800}px`,
height: `${screenDimensions?.height || 600}px`,
transformOrigin: "center center",
}}
>
{screenData.components.map((component) => {
// 화면 관리 해상도를 사용하는 경우 offset 조정 불필요
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
<TableOptionsProvider>
<div
className="relative bg-white mx-auto"
style={{
width: `${screenDimensions?.width || 800}px`,
height: `${screenDimensions?.height || 600}px`,
transformOrigin: "center center",
}}
>
{screenData.components.map((component) => {
// 화면 관리 해상도를 사용하는 경우 offset 조정 불필요
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
// offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시)
const adjustedComponent = (offsetX === 0 && offsetY === 0) ? component : {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
},
};
// offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시)
const adjustedComponent = (offsetX === 0 && offsetY === 0) ? component : {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
},
};
return (
<InteractiveScreenViewerDynamic
key={component.id}
component={adjustedComponent}
allComponents={screenData.components}
formData={formData}
onFormDataChange={(fieldName, value) => {
// console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`);
// console.log("📋 현재 formData:", formData);
setFormData((prev) => {
const newFormData = {
...prev,
[fieldName]: value,
};
// console.log("📝 ScreenModal 업데이트된 formData:", newFormData);
return newFormData;
});
}}
onRefresh={() => {
// 부모 화면의 테이블 새로고침 이벤트 발송
console.log("🔄 모달에서 부모 화면 테이블 새로고침 이벤트 발송");
window.dispatchEvent(new CustomEvent("refreshTable"));
}}
screenInfo={{
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
/>
);
})}
</div>
return (
<InteractiveScreenViewerDynamic
key={component.id}
component={adjustedComponent}
allComponents={screenData.components}
formData={formData}
onFormDataChange={(fieldName, value) => {
// console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`);
// console.log("📋 현재 formData:", formData);
setFormData((prev) => {
const newFormData = {
...prev,
[fieldName]: value,
};
// console.log("📝 ScreenModal 업데이트된 formData:", newFormData);
return newFormData;
});
}}
onRefresh={() => {
// 부모 화면의 테이블 새로고침 이벤트 발송
console.log("🔄 모달에서 부모 화면 테이블 새로고침 이벤트 발송");
window.dispatchEvent(new CustomEvent("refreshTable"));
}}
screenInfo={{
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
/>
);
})}
</div>
</TableOptionsProvider>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground"> .</p>

View File

@@ -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>

View File

@@ -74,6 +74,15 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
[dataRegistry, dataSourceId]
);
// 전체 dataRegistry를 사용 (모든 누적 데이터에 접근 가능)
console.log("📦 [SelectedItemsDetailInput] 사용 가능한 모든 데이터:", {
keys: Object.keys(dataRegistry),
counts: Object.entries(dataRegistry).map(([key, data]: [string, any]) => ({
table: key,
count: data.length,
})),
});
const updateItemData = useModalDataStore((state) => state.updateItemData);
// 🆕 새로운 데이터 구조: 품목별로 여러 개의 상세 데이터
@@ -138,39 +147,63 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
}
for (const field of codeFields) {
// 이미 codeCategory가 있으면 사용
let codeCategory = field.codeCategory;
// 🆕 codeCategory가 없으면 대상 테이블 컬럼에서 찾기
if (!codeCategory && targetTableColumns.length > 0) {
const columnMeta = targetTableColumns.find(
(col: any) => (col.columnName || col.column_name) === field.name
);
if (columnMeta) {
codeCategory = columnMeta.codeCategory || columnMeta.code_category;
console.log(`🔍 필드 "${field.name}"의 codeCategory를 메타데이터에서 찾음:`, codeCategory);
}
}
if (!codeCategory) {
console.warn(`⚠️ 필드 "${field.name}"의 codeCategory를 찾을 수 없습니다`);
// 이미 로드된 옵션이면 스킵
if (newOptions[field.name]) {
console.log(`⏭️ 이미 로드된 옵션 (${field.name})`);
continue;
}
// 이미 로드된 옵션이면 스킵
if (newOptions[codeCategory]) continue;
try {
const response = await commonCodeApi.options.getOptions(codeCategory);
if (response.success && response.data) {
newOptions[codeCategory] = response.data.map((opt) => ({
label: opt.label,
value: opt.value,
}));
console.log(`✅ 코드 옵션 로드 완료: ${codeCategory}`, newOptions[codeCategory]);
// 🆕 category 타입이면 table_column_category_values에서 로드
if (field.inputType === "category" && targetTable) {
console.log(`🔄 카테고리 옵션 로드 시도 (${targetTable}.${field.name})`);
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
const response = await getCategoryValues(targetTable, field.name, false);
console.log(`📥 getCategoryValues 응답:`, response);
if (response.success && response.data) {
newOptions[field.name] = response.data.map((item: any) => ({
label: item.value_label || item.valueLabel,
value: item.value_code || item.valueCode,
}));
console.log(`✅ 카테고리 옵션 로드 완료 (${field.name}):`, newOptions[field.name]);
} else {
console.error(`❌ 카테고리 옵션 로드 실패 (${field.name}):`, response.error || "응답 없음");
}
} else if (field.inputType === "code") {
// code 타입이면 기존대로 code_info에서 로드
// 이미 codeCategory가 있으면 사용
let codeCategory = field.codeCategory;
// 🆕 codeCategory가 없으면 대상 테이블 컬럼에서 찾기
if (!codeCategory && targetTableColumns.length > 0) {
const columnMeta = targetTableColumns.find(
(col: any) => (col.columnName || col.column_name) === field.name
);
if (columnMeta) {
codeCategory = columnMeta.codeCategory || columnMeta.code_category;
console.log(`🔍 필드 "${field.name}"의 codeCategory를 메타데이터에서 찾음:`, codeCategory);
}
}
if (!codeCategory) {
console.warn(`⚠️ 필드 "${field.name}"의 codeCategory를 찾을 수 없습니다`);
continue;
}
const response = await commonCodeApi.options.getOptions(codeCategory);
if (response.success && response.data) {
newOptions[field.name] = response.data.map((opt) => ({
label: opt.label,
value: opt.value,
}));
console.log(`✅ 코드 옵션 로드 완료 (${codeCategory}):`, newOptions[field.name]);
}
}
} catch (error) {
console.error(` 코드 옵션 로드 실패: ${codeCategory}`, error);
console.error(`❌ 옵션 로드 실패 (${field.name}):`, error);
}
}
@@ -262,6 +295,51 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
onClick?.();
};
// 🆕 실시간 단가 계산 함수 (설정 기반 + 카테고리 매핑)
const calculatePrice = useCallback((entry: GroupEntry): number => {
// 자동 계산 설정이 없으면 계산하지 않음
if (!componentConfig.autoCalculation) return 0;
const { inputFields, valueMapping } = componentConfig.autoCalculation;
// 기본 단가
const basePrice = parseFloat(entry[inputFields.basePrice] || "0");
if (basePrice === 0) return 0;
let price = basePrice;
// 1단계: 할인 적용
const discountTypeValue = entry[inputFields.discountType];
const discountValue = parseFloat(entry[inputFields.discountValue] || "0");
// 매핑을 통해 실제 연산 타입 결정
const discountOperation = valueMapping?.discountType?.[discountTypeValue] || "none";
if (discountOperation === "rate") {
price = price * (1 - discountValue / 100);
} else if (discountOperation === "amount") {
price = price - discountValue;
}
// 2단계: 반올림 적용
const roundingTypeValue = entry[inputFields.roundingType];
const roundingUnitValue = entry[inputFields.roundingUnit];
// 매핑을 통해 실제 연산 타입 결정
const roundingOperation = valueMapping?.roundingType?.[roundingTypeValue] || "none";
const unit = valueMapping?.roundingUnit?.[roundingUnitValue] || parseFloat(roundingUnitValue) || 1;
if (roundingOperation === "round") {
price = Math.round(price / unit) * unit;
} else if (roundingOperation === "floor") {
price = Math.floor(price / unit) * unit;
} else if (roundingOperation === "ceil") {
price = Math.ceil(price / unit) * unit;
}
return price;
}, [componentConfig.autoCalculation]);
// 🆕 그룹별 필드 변경 핸들러: itemId + groupId + entryId + fieldName
const handleFieldChange = useCallback((itemId: string, groupId: string, entryId: string, fieldName: string, value: any) => {
setItems((prevItems) => {
@@ -274,10 +352,38 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
if (existingEntryIndex >= 0) {
// 기존 entry 업데이트 (항상 이 경로로만 진입)
const updatedEntries = [...groupEntries];
updatedEntries[existingEntryIndex] = {
const updatedEntry = {
...updatedEntries[existingEntryIndex],
[fieldName]: value,
};
// 🆕 가격 관련 필드가 변경되면 자동 계산
if (componentConfig.autoCalculation) {
const { inputFields, targetField } = componentConfig.autoCalculation;
const priceRelatedFields = [
inputFields.basePrice,
inputFields.discountType,
inputFields.discountValue,
inputFields.roundingType,
inputFields.roundingUnit,
];
if (priceRelatedFields.includes(fieldName)) {
const calculatedPrice = calculatePrice(updatedEntry);
updatedEntry[targetField] = calculatedPrice;
console.log("💰 [자동 계산]", {
basePrice: updatedEntry[inputFields.basePrice],
discountType: updatedEntry[inputFields.discountType],
discountValue: updatedEntry[inputFields.discountValue],
roundingType: updatedEntry[inputFields.roundingType],
roundingUnit: updatedEntry[inputFields.roundingUnit],
calculatedPrice,
targetField,
});
}
}
updatedEntries[existingEntryIndex] = updatedEntry;
return {
...item,
fieldGroups: {
@@ -292,7 +398,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
}
});
});
}, []);
}, [calculatePrice]);
// 🆕 품목 제거 핸들러
const handleRemoveItem = (itemId: string) => {
@@ -303,7 +409,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const handleAddGroupEntry = (itemId: string, groupId: string) => {
const newEntryId = `entry-${Date.now()}`;
// 🔧 미리 빈 entry를 추가하여 리렌더링 방지
// 🔧 미리 빈 entry를 추가하여 리렌더링 방지 (autoFillFrom 처리)
setItems((prevItems) => {
return prevItems.map((item) => {
if (item.id !== itemId) return item;
@@ -311,6 +417,36 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const groupEntries = item.fieldGroups[groupId] || [];
const newEntry: GroupEntry = { id: newEntryId };
// 🆕 autoFillFrom 필드 자동 채우기 (tableName으로 직접 접근)
const groupFields = (componentConfig.additionalFields || []).filter(
(f) => f.groupId === groupId
);
groupFields.forEach((field) => {
if (!field.autoFillFrom) return;
// 데이터 소스 결정
let sourceData: any = null;
if (field.autoFillFromTable) {
// 특정 테이블에서 가져오기
const tableData = dataRegistry[field.autoFillFromTable];
if (tableData && tableData.length > 0) {
// 첫 번째 항목 사용 (또는 매칭 로직 추가 가능)
sourceData = tableData[0].originalData || tableData[0];
console.log(`✅ [autoFill] ${field.name}${field.autoFillFrom} (테이블: ${field.autoFillFromTable}):`, sourceData[field.autoFillFrom]);
}
} else {
// 주 데이터 소스 (item.originalData) 사용
sourceData = item.originalData;
console.log(`✅ [autoFill] ${field.name}${field.autoFillFrom} (주 소스):`, sourceData[field.autoFillFrom]);
}
if (sourceData && sourceData[field.autoFillFrom] !== undefined) {
newEntry[field.name] = sourceData[field.autoFillFrom];
}
});
return {
...item,
fieldGroups: {
@@ -377,6 +513,9 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const renderField = (field: AdditionalFieldDefinition, itemId: string, groupId: string, entryId: string, entry: GroupEntry) => {
const value = entry[field.name] || field.defaultValue || "";
// 🆕 계산된 필드는 읽기 전용 (자동 계산 설정 기반)
const isCalculatedField = componentConfig.autoCalculation?.targetField === field.name;
const commonProps = {
value: value || "",
disabled: componentConfig.disabled || componentConfig.readonly,
@@ -399,7 +538,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
type="text"
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
maxLength={field.validation?.maxLength}
className="h-8 text-xs sm:h-10 sm:text-sm"
className="h-10 text-sm"
/>
);
@@ -409,6 +548,30 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
case "bigint":
case "decimal":
case "numeric":
// 🆕 계산된 단가는 천 단위 구분 및 강조 표시
if (isCalculatedField) {
const numericValue = parseFloat(value) || 0;
const formattedValue = new Intl.NumberFormat("ko-KR").format(numericValue);
return (
<div className="relative">
<Input
value={formattedValue}
readOnly
disabled
className={cn(
"h-10 text-sm",
"bg-primary/10 border-primary/30 font-semibold text-primary",
"cursor-not-allowed"
)}
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-primary/70">
</div>
</div>
);
}
return (
<Input
{...commonProps}
@@ -416,7 +579,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
min={field.validation?.min}
max={field.validation?.max}
className="h-8 text-xs sm:h-10 sm:text-sm"
className="h-10 text-sm"
/>
);
@@ -428,7 +591,14 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
{...commonProps}
type="date"
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={(e) => {
// 날짜 선택기 강제 열기
const target = e.target as HTMLInputElement;
if (target && target.showPicker) {
target.showPicker();
}
}}
className="h-10 text-sm cursor-pointer"
/>
);
@@ -456,20 +626,16 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
// 🆕 추가 inputType들
case "code":
case "category":
// 🆕 codeCategory를 field.codeCategory 또는 codeOptions에서 찾기
// 🆕 옵션을 field.name 또는 field.codeCategory 키로 찾기
let categoryOptions = field.options; // 기본값
if (field.codeCategory && codeOptions[field.codeCategory]) {
// 1순위: 필드 이름으로 직접 찾기 (category 타입에서 사용)
if (codeOptions[field.name]) {
categoryOptions = codeOptions[field.name];
}
// 2순위: codeCategory로 찾기 (code 타입에서 사용)
else if (field.codeCategory && codeOptions[field.codeCategory]) {
categoryOptions = codeOptions[field.codeCategory];
} else {
// codeCategory가 없으면 모든 codeOptions에서 이 필드에 맞는 옵션 찾기
const matchedCategory = Object.keys(codeOptions).find((cat) => {
// 필드명과 매칭되는 카테고리 찾기 (예: currency_code → CURRENCY)
return field.name.toLowerCase().includes(cat.toLowerCase().replace('_', ''));
});
if (matchedCategory) {
categoryOptions = codeOptions[matchedCategory];
}
}
return (
@@ -478,7 +644,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
onValueChange={(val) => handleFieldChange(itemId, groupId, entryId, field.name, val)}
disabled={componentConfig.disabled || componentConfig.readonly}
>
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:text-sm">
<SelectTrigger size="default" className="w-full">
<SelectValue placeholder={field.placeholder || "선택하세요"} />
</SelectTrigger>
<SelectContent>
@@ -769,11 +935,11 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const isEditingThisEntry = isEditingThisGroup && editingDetailId === entry.id;
if (isEditingThisEntry) {
// 편집 모드: 입력 필드 표시
// 편집 모드: 입력 필드 표시 (가로 배치)
return (
<Card key={entry.id} className="border-dashed border-primary">
<CardContent className="p-3 space-y-2">
<div className="flex items-center justify-between mb-2">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-medium"> </span>
<Button
type="button"
@@ -790,15 +956,18 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
</Button>
</div>
{groupFields.map((field) => (
<div key={field.name} className="space-y-1">
<label className="text-xs font-medium">
{field.label}
{field.required && <span className="ml-1 text-destructive">*</span>}
</label>
{renderField(field, item.id, group.id, entry.id, entry)}
</div>
))}
{/* 🆕 가로 Grid 배치 (2~3열) */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{groupFields.map((field) => (
<div key={field.name} className="space-y-1">
<label className="text-xs font-medium">
{field.label}
{field.required && <span className="ml-1 text-destructive">*</span>}
</label>
{renderField(field, item.id, group.id, entry.id, entry)}
</div>
))}
</div>
</CardContent>
</Card>
);

View File

@@ -1,8 +1,9 @@
"use client";
import React, { useState, useMemo } from "react";
import React, { useState, useMemo, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -14,6 +15,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { getSecondLevelMenus, getCategoryColumns, getCategoryValues } from "@/lib/api/tableCategoryValue";
export interface SelectedItemsDetailInputConfigPanelProps {
config: SelectedItemsDetailInputConfig;
@@ -47,6 +49,7 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
// 🆕 필드 그룹 상태
const [localFieldGroups, setLocalFieldGroups] = useState<FieldGroup[]>(config.fieldGroups || []);
// 🆕 그룹별 펼침/접힘 상태
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
@@ -61,6 +64,77 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
const [tableSelectOpen, setTableSelectOpen] = useState(false);
const [tableSearchValue, setTableSearchValue] = useState("");
// 🆕 카테고리 매핑을 위한 상태
const [secondLevelMenus, setSecondLevelMenus] = useState<Array<{ menuObjid: number; menuName: string; parentMenuName: string }>>([]);
const [categoryColumns, setCategoryColumns] = useState<Record<string, Array<{ columnName: string; columnLabel: string }>>>({});
const [categoryValues, setCategoryValues] = useState<Record<string, Array<{ valueCode: string; valueLabel: string }>>>({});
// 2레벨 메뉴 목록 로드
useEffect(() => {
const loadMenus = async () => {
const response = await getSecondLevelMenus();
if (response.success && response.data) {
setSecondLevelMenus(response.data);
}
};
loadMenus();
}, []);
// 메뉴 선택 시 카테고리 목록 로드
const handleMenuSelect = async (menuObjid: number, fieldType: "discountType" | "roundingType" | "roundingUnit") => {
if (!config.targetTable) {
console.warn("⚠️ targetTable이 설정되지 않았습니다");
return;
}
console.log("🔍 카테고리 목록 로드 시작", { targetTable: config.targetTable, menuObjid, fieldType });
const response = await getCategoryColumns(config.targetTable);
console.log("📥 getCategoryColumns 응답:", response);
if (response.success && response.data) {
console.log("✅ 카테고리 컬럼 데이터:", response.data);
setCategoryColumns(prev => ({ ...prev, [fieldType]: response.data }));
} else {
console.error("❌ 카테고리 컬럼 로드 실패:", response);
}
// valueMapping 업데이트
handleChange("autoCalculation", {
...config.autoCalculation,
valueMapping: {
...config.autoCalculation.valueMapping,
_selectedMenus: {
...(config.autoCalculation.valueMapping as any)?._selectedMenus,
[fieldType]: menuObjid,
},
},
});
};
// 카테고리 선택 시 카테고리 값 목록 로드
const handleCategorySelect = async (columnName: string, menuObjid: number, fieldType: "discountType" | "roundingType" | "roundingUnit") => {
if (!config.targetTable) return;
const response = await getCategoryValues(config.targetTable, columnName, false, menuObjid);
if (response.success && response.data) {
setCategoryValues(prev => ({ ...prev, [fieldType]: response.data }));
}
// valueMapping 업데이트
handleChange("autoCalculation", {
...config.autoCalculation,
valueMapping: {
...config.autoCalculation.valueMapping,
_selectedCategories: {
...(config.autoCalculation.valueMapping as any)?._selectedCategories,
[fieldType]: columnName,
},
},
});
};
// 🆕 초기 로드 시 screenTableName을 targetTable로 자동 설정
React.useEffect(() => {
if (screenTableName && !config.targetTable) {
@@ -568,6 +642,85 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
</div>
</div>
{/* 🆕 원본 데이터 자동 채우기 */}
<div className="space-y-2">
<Label className="text-[10px] sm:text-xs"> ()</Label>
{/* 테이블명 입력 */}
<Input
value={field.autoFillFromTable || ""}
onChange={(e) => updateField(index, { autoFillFromTable: e.target.value })}
placeholder="비워두면 주 데이터 (예: item_price)"
className="h-6 w-full text-[10px] sm:h-7 sm:text-xs"
/>
<p className="text-[9px] text-gray-500 sm:text-[10px]">
</p>
{/* 필드 선택 */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-6 w-full justify-between text-[10px] sm:h-7 sm:text-xs"
>
{field.autoFillFrom
? sourceTableColumns.find(c => c.columnName === field.autoFillFrom)?.columnLabel || field.autoFillFrom
: "필드 선택 안 함"}
<ChevronsUpDown className="ml-1 h-2 w-2 shrink-0 opacity-50 sm:h-3 sm:w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[180px] p-0 sm:w-[200px]">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-6 text-[10px] sm:h-7 sm:text-xs" />
<CommandEmpty className="text-[10px] sm:text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[150px] overflow-auto sm:max-h-[200px]">
<CommandItem
value=""
onSelect={() => updateField(index, { autoFillFrom: undefined })}
className="text-[10px] sm:text-xs"
>
<Check
className={cn(
"mr-1 h-2 w-2 sm:mr-2 sm:h-3 sm:w-3",
!field.autoFillFrom ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
{sourceTableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={() => updateField(index, { autoFillFrom: column.columnName })}
className="text-[10px] sm:text-xs"
>
<Check
className={cn(
"mr-1 h-2 w-2 sm:mr-2 sm:h-3 sm:w-3",
field.autoFillFrom === column.columnName ? "opacity-100" : "opacity-0",
)}
/>
<div>
<div className="font-medium">{column.columnLabel}</div>
<div className="text-[9px] text-gray-500">{column.columnName}</div>
</div>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<p className="text-[9px] text-primary sm:text-[10px]">
{field.autoFillFromTable
? `"${field.autoFillFromTable}" 테이블에서 자동 채우기`
: "주 데이터 소스에서 자동 채우기 (수정 가능)"
}
</p>
</div>
{/* 🆕 필드 그룹 선택 */}
{localFieldGroups.length > 0 && (
<div className="space-y-1">
@@ -970,6 +1123,478 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
</p>
</div>
{/* 자동 계산 설정 */}
<div className="space-y-3 rounded-lg border p-3 sm:p-4">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold sm:text-sm"> </Label>
<Checkbox
id="enable-auto-calc"
checked={!!config.autoCalculation}
onCheckedChange={(checked) => {
if (checked) {
handleChange("autoCalculation", {
targetField: "calculated_price",
inputFields: {
basePrice: "current_unit_price",
discountType: "discount_type",
discountValue: "discount_value",
roundingType: "rounding_type",
roundingUnit: "rounding_unit_value",
},
calculationType: "price",
});
} else {
handleChange("autoCalculation", undefined);
}
}}
/>
</div>
{config.autoCalculation && (
<div className="space-y-2 border-t pt-2">
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs"> </Label>
<Input
value={config.autoCalculation.targetField || ""}
onChange={(e) => handleChange("autoCalculation", {
...config.autoCalculation,
targetField: e.target.value,
})}
placeholder="calculated_price"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs"> </Label>
<Input
value={config.autoCalculation.inputFields?.basePrice || ""}
onChange={(e) => handleChange("autoCalculation", {
...config.autoCalculation,
inputFields: {
...config.autoCalculation.inputFields,
basePrice: e.target.value,
},
})}
placeholder="current_unit_price"
className="h-7 text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs"> </Label>
<Input
value={config.autoCalculation.inputFields?.discountType || ""}
onChange={(e) => handleChange("autoCalculation", {
...config.autoCalculation,
inputFields: {
...config.autoCalculation.inputFields,
discountType: e.target.value,
},
})}
placeholder="discount_type"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs"></Label>
<Input
value={config.autoCalculation.inputFields?.discountValue || ""}
onChange={(e) => handleChange("autoCalculation", {
...config.autoCalculation,
inputFields: {
...config.autoCalculation.inputFields,
discountValue: e.target.value,
},
})}
placeholder="discount_value"
className="h-7 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs"> </Label>
<Input
value={config.autoCalculation.inputFields?.roundingType || ""}
onChange={(e) => handleChange("autoCalculation", {
...config.autoCalculation,
inputFields: {
...config.autoCalculation.inputFields,
roundingType: e.target.value,
},
})}
placeholder="rounding_type"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs"> </Label>
<Input
value={config.autoCalculation.inputFields?.roundingUnit || ""}
onChange={(e) => handleChange("autoCalculation", {
...config.autoCalculation,
inputFields: {
...config.autoCalculation.inputFields,
roundingUnit: e.target.value,
},
})}
placeholder="rounding_unit_value"
className="h-7 text-xs"
/>
</div>
</div>
<p className="text-[9px] text-amber-600 sm:text-[10px]">
💡
</p>
{/* 카테고리 값 매핑 */}
<div className="space-y-3 border-t pt-3 mt-3">
<Label className="text-[10px] font-semibold sm:text-xs"> </Label>
{/* 할인 방식 매핑 */}
<Collapsible>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="flex w-full items-center justify-between p-2 hover:bg-muted"
>
<span className="text-xs font-medium"> </span>
<ChevronDown className="h-4 w-4" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
{/* 1단계: 메뉴 선택 */}
<div className="space-y-1">
<Label className="text-[9px] sm:text-[10px]">1단계: 메뉴 </Label>
<Select
value={String((config.autoCalculation.valueMapping as any)?._selectedMenus?.discountType || "")}
onValueChange={(value) => handleMenuSelect(Number(value), "discountType")}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="2레벨 메뉴 선택" />
</SelectTrigger>
<SelectContent>
{secondLevelMenus.map((menu) => (
<SelectItem key={menu.menuObjid} value={String(menu.menuObjid)}>
{menu.parentMenuName} &gt; {menu.menuName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 2단계: 카테고리 선택 */}
{(config.autoCalculation.valueMapping as any)?._selectedMenus?.discountType && (
<div className="space-y-1">
<Label className="text-[9px] sm:text-[10px]">2단계: 카테고리 </Label>
<Select
value={(config.autoCalculation.valueMapping as any)?._selectedCategories?.discountType || ""}
onValueChange={(value) => handleCategorySelect(
value,
(config.autoCalculation.valueMapping as any)._selectedMenus.discountType,
"discountType"
)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{(categoryColumns.discountType || []).map((col: any) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 3단계: 값 매핑 */}
{(config.autoCalculation.valueMapping as any)?._selectedCategories?.discountType && (
<div className="space-y-1">
<Label className="text-[9px] sm:text-[10px]">3단계: 카테고리 </Label>
{["할인없음", "할인율(%)", "할인금액"].map((label, idx) => {
const operations = ["none", "rate", "amount"];
return (
<div key={label} className="flex items-center gap-2">
<span className="text-xs w-20">{label}</span>
<span className="text-xs text-muted-foreground"></span>
<Select
value={
Object.entries(config.autoCalculation.valueMapping?.discountType || {})
.find(([_, op]) => op === operations[idx])?.[0] || ""
}
onValueChange={(value) => {
const newMapping = { ...config.autoCalculation.valueMapping?.discountType };
Object.keys(newMapping).forEach(key => {
if (newMapping[key] === operations[idx]) delete newMapping[key];
});
if (value) {
newMapping[value] = operations[idx];
}
handleChange("autoCalculation", {
...config.autoCalculation,
valueMapping: {
...config.autoCalculation.valueMapping,
discountType: newMapping,
},
});
}}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="값 선택" />
</SelectTrigger>
<SelectContent>
{(categoryValues.discountType || []).map((val: any) => (
<SelectItem key={val.valueCode} value={val.valueCode}>
{val.valueLabel}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[10px] text-muted-foreground w-12">{operations[idx]}</span>
</div>
);
})}
</div>
)}
</CollapsibleContent>
</Collapsible>
{/* 반올림 방식 매핑 */}
<Collapsible>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="flex w-full items-center justify-between p-2 hover:bg-muted"
>
<span className="text-xs font-medium"> </span>
<ChevronDown className="h-4 w-4" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
{/* 1단계: 메뉴 선택 */}
<div className="space-y-1">
<Label className="text-[9px] sm:text-[10px]">1단계: 메뉴 </Label>
<Select
value={String((config.autoCalculation.valueMapping as any)?._selectedMenus?.roundingType || "")}
onValueChange={(value) => handleMenuSelect(Number(value), "roundingType")}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="2레벨 메뉴 선택" />
</SelectTrigger>
<SelectContent>
{secondLevelMenus.map((menu) => (
<SelectItem key={menu.menuObjid} value={String(menu.menuObjid)}>
{menu.parentMenuName} &gt; {menu.menuName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 2단계: 카테고리 선택 */}
{(config.autoCalculation.valueMapping as any)?._selectedMenus?.roundingType && (
<div className="space-y-1">
<Label className="text-[9px] sm:text-[10px]">2단계: 카테고리 </Label>
<Select
value={(config.autoCalculation.valueMapping as any)?._selectedCategories?.roundingType || ""}
onValueChange={(value) => handleCategorySelect(
value,
(config.autoCalculation.valueMapping as any)._selectedMenus.roundingType,
"roundingType"
)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{(categoryColumns.roundingType || []).map((col: any) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 3단계: 값 매핑 */}
{(config.autoCalculation.valueMapping as any)?._selectedCategories?.roundingType && (
<div className="space-y-1">
<Label className="text-[9px] sm:text-[10px]">3단계: 카테고리 </Label>
{["반올림없음", "반올림", "절삭", "올림"].map((label, idx) => {
const operations = ["none", "round", "floor", "ceil"];
return (
<div key={label} className="flex items-center gap-2">
<span className="text-xs w-20">{label}</span>
<span className="text-xs text-muted-foreground"></span>
<Select
value={
Object.entries(config.autoCalculation.valueMapping?.roundingType || {})
.find(([_, op]) => op === operations[idx])?.[0] || ""
}
onValueChange={(value) => {
const newMapping = { ...config.autoCalculation.valueMapping?.roundingType };
Object.keys(newMapping).forEach(key => {
if (newMapping[key] === operations[idx]) delete newMapping[key];
});
if (value) {
newMapping[value] = operations[idx];
}
handleChange("autoCalculation", {
...config.autoCalculation,
valueMapping: {
...config.autoCalculation.valueMapping,
roundingType: newMapping,
},
});
}}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="값 선택" />
</SelectTrigger>
<SelectContent>
{(categoryValues.roundingType || []).map((val: any) => (
<SelectItem key={val.valueCode} value={val.valueCode}>
{val.valueLabel}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[10px] text-muted-foreground w-12">{operations[idx]}</span>
</div>
);
})}
</div>
)}
</CollapsibleContent>
</Collapsible>
{/* 반올림 단위 매핑 */}
<Collapsible>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="flex w-full items-center justify-between p-2 hover:bg-muted"
>
<span className="text-xs font-medium"> </span>
<ChevronDown className="h-4 w-4" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
{/* 1단계: 메뉴 선택 */}
<div className="space-y-1">
<Label className="text-[9px] sm:text-[10px]">1단계: 메뉴 </Label>
<Select
value={String((config.autoCalculation.valueMapping as any)?._selectedMenus?.roundingUnit || "")}
onValueChange={(value) => handleMenuSelect(Number(value), "roundingUnit")}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="2레벨 메뉴 선택" />
</SelectTrigger>
<SelectContent>
{secondLevelMenus.map((menu) => (
<SelectItem key={menu.menuObjid} value={String(menu.menuObjid)}>
{menu.parentMenuName} &gt; {menu.menuName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 2단계: 카테고리 선택 */}
{(config.autoCalculation.valueMapping as any)?._selectedMenus?.roundingUnit && (
<div className="space-y-1">
<Label className="text-[9px] sm:text-[10px]">2단계: 카테고리 </Label>
<Select
value={(config.autoCalculation.valueMapping as any)?._selectedCategories?.roundingUnit || ""}
onValueChange={(value) => handleCategorySelect(
value,
(config.autoCalculation.valueMapping as any)._selectedMenus.roundingUnit,
"roundingUnit"
)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{(categoryColumns.roundingUnit || []).map((col: any) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 3단계: 값 매핑 */}
{(config.autoCalculation.valueMapping as any)?._selectedCategories?.roundingUnit && (
<div className="space-y-1">
<Label className="text-[9px] sm:text-[10px]">3단계: 카테고리 </Label>
{["1원", "10원", "100원", "1,000원"].map((label) => {
const unitValue = label === "1,000원" ? 1000 : parseInt(label);
return (
<div key={label} className="flex items-center gap-2">
<span className="text-xs w-20">{label}</span>
<span className="text-xs text-muted-foreground"></span>
<Select
value={
Object.entries(config.autoCalculation.valueMapping?.roundingUnit || {})
.find(([_, val]) => val === unitValue)?.[0] || ""
}
onValueChange={(value) => {
const newMapping = { ...config.autoCalculation.valueMapping?.roundingUnit };
Object.keys(newMapping).forEach(key => {
if (newMapping[key] === unitValue) delete newMapping[key];
});
if (value) {
newMapping[value] = unitValue;
}
handleChange("autoCalculation", {
...config.autoCalculation,
valueMapping: {
...config.autoCalculation.valueMapping,
roundingUnit: newMapping,
},
});
}}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="값 선택" />
</SelectTrigger>
<SelectContent>
{(categoryValues.roundingUnit || []).map((val: any) => (
<SelectItem key={val.valueCode} value={val.valueCode}>
{val.valueLabel}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[10px] text-muted-foreground w-12">{unitValue}</span>
</div>
);
})}
</div>
)}
</CollapsibleContent>
</Collapsible>
<p className="text-[9px] text-muted-foreground sm:text-[10px]">
💡 1단계: 메뉴 2단계: 카테고리 3단계:
</p>
</div>
</div>
)}
</div>
{/* 옵션 */}
<div className="space-y-2 rounded-lg border p-3 sm:p-4">
<div className="flex items-center space-x-2">

View File

@@ -22,6 +22,10 @@ export interface AdditionalFieldDefinition {
placeholder?: string;
/** 기본값 */
defaultValue?: any;
/** 🆕 원본 데이터에서 자동으로 값을 가져올 필드명 */
autoFillFrom?: string;
/** 🆕 자동 채우기할 데이터의 테이블명 (비워두면 주 데이터 소스 사용) */
autoFillFromTable?: string;
/** 선택 옵션 (type이 select일 때) */
options?: Array<{ label: string; value: string }>;
/** 필드 너비 (px 또는 %) */
@@ -54,6 +58,39 @@ export interface FieldGroup {
displayItems?: DisplayItem[];
}
/**
* 🆕 자동 계산 설정
*/
export interface AutoCalculationConfig {
/** 계산 대상 필드명 (예: calculated_price) */
targetField: string;
/** 계산에 사용할 입력 필드들 */
inputFields: {
basePrice: string; // 기본 단가 필드명
discountType: string; // 할인 방식 필드명
discountValue: string; // 할인값 필드명
roundingType: string; // 반올림 방식 필드명
roundingUnit: string; // 반올림 단위 필드명
};
/** 계산 함수 타입 */
calculationType: "price" | "custom";
/** 🆕 카테고리 값 → 연산 매핑 */
valueMapping?: {
/** 할인 방식 매핑 */
discountType?: {
[valueCode: string]: "none" | "rate" | "amount"; // 예: { "CATEGORY_544740": "rate" }
};
/** 반올림 방식 매핑 */
roundingType?: {
[valueCode: string]: "none" | "round" | "floor" | "ceil";
};
/** 반올림 단위 매핑 (숫자로 변환) */
roundingUnit?: {
[valueCode: string]: number; // 예: { "10": 10, "100": 100 }
};
};
}
/**
* SelectedItemsDetailInput 컴포넌트 설정 타입
*/
@@ -93,6 +130,12 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
*/
targetTable?: string;
/**
* 🆕 자동 계산 설정
* 특정 필드가 변경되면 다른 필드를 자동으로 계산
*/
autoCalculation?: AutoCalculationConfig;
/**
* 레이아웃 모드
* - grid: 테이블 형식 (기본)

View File

@@ -418,8 +418,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
setSelectedLeftItem(item);
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
loadRightData(item);
// 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
const leftTableName = componentConfig.leftPanel?.tableName;
if (leftTableName && !isDesignMode) {
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
useModalDataStore.getState().setData(leftTableName, [item]);
console.log(`✅ 분할 패널 좌측 선택: ${leftTableName}`, item);
});
}
},
[loadRightData],
[loadRightData, componentConfig.leftPanel?.tableName, isDesignMode],
);
// 우측 항목 확장/축소 토글

View File

@@ -41,6 +41,13 @@ export interface ButtonActionConfig {
// 모달/팝업 관련
modalTitle?: string;
modalTitleBlocks?: Array<{ // 🆕 블록 기반 제목 (우선순위 높음)
id: string;
type: "text" | "field";
value: string; // type=text: 텍스트 내용, type=field: 컬럼명
tableName?: string; // type=field일 때 테이블명
label?: string; // type=field일 때 표시용 라벨
}>;
modalDescription?: string;
modalSize?: "sm" | "md" | "lg" | "xl";
popupWidth?: number;
@@ -207,6 +214,20 @@ export class ButtonActionExecutor {
await new Promise(resolve => setTimeout(resolve, 100));
// 🆕 SelectedItemsDetailInput 배치 저장 처리 (fieldGroups 구조)
console.log("🔍 [handleSave] formData 구조 확인:", {
keys: Object.keys(context.formData),
values: Object.entries(context.formData).map(([key, value]) => ({
key,
isArray: Array.isArray(value),
length: Array.isArray(value) ? value.length : 0,
firstItem: Array.isArray(value) && value.length > 0 ? {
hasOriginalData: !!value[0]?.originalData,
hasFieldGroups: !!value[0]?.fieldGroups,
keys: Object.keys(value[0] || {})
} : null
}))
});
const selectedItemsKeys = Object.keys(context.formData).filter(key => {
const value = context.formData[key];
return Array.isArray(value) && value.length > 0 && value[0]?.originalData && value[0]?.fieldGroups;
@@ -215,6 +236,8 @@ export class ButtonActionExecutor {
if (selectedItemsKeys.length > 0) {
console.log("🔄 [handleSave] SelectedItemsDetailInput 배치 저장 감지:", selectedItemsKeys);
return await this.handleBatchSave(config, context, selectedItemsKeys);
} else {
console.log("⚠️ [handleSave] SelectedItemsDetailInput 데이터 감지 실패 - 일반 저장 진행");
}
// 폼 유효성 검사
@@ -830,11 +853,11 @@ export class ButtonActionExecutor {
dataSourceId: config.dataSourceId,
});
// 🆕 1. dataSourceId 자동 결정
// 🆕 1. 현재 화면의 TableList 또는 SplitPanelLayout 자동 감지
let dataSourceId = config.dataSourceId;
// dataSourceId가 없으면 같은 화면의 TableList 자동 감지
if (!dataSourceId && context.allComponents) {
// TableList 우선 감지
const tableListComponent = context.allComponents.find(
(comp: any) => comp.componentType === "table-list" && comp.componentConfig?.tableName
);
@@ -845,6 +868,19 @@ export class ButtonActionExecutor {
componentId: tableListComponent.id,
tableName: dataSourceId,
});
} else {
// TableList가 없으면 SplitPanelLayout의 좌측 패널 감지
const splitPanelComponent = context.allComponents.find(
(comp: any) => comp.componentType === "split-panel-layout" && comp.componentConfig?.leftPanel?.tableName
);
if (splitPanelComponent) {
dataSourceId = splitPanelComponent.componentConfig.leftPanel.tableName;
console.log("✨ 분할 패널 좌측 테이블 자동 감지:", {
componentId: splitPanelComponent.id,
tableName: dataSourceId,
});
}
}
}
@@ -853,21 +889,30 @@ export class ButtonActionExecutor {
dataSourceId = context.tableName || "default";
}
// 2. modalDataStore에서 데이터 확인
// 🆕 2. modalDataStore에서 현재 선택된 데이터 확인
try {
const { useModalDataStore } = await import("@/stores/modalDataStore");
const modalData = useModalDataStore.getState().dataRegistry[dataSourceId] || [];
const dataRegistry = useModalDataStore.getState().dataRegistry;
const modalData = dataRegistry[dataSourceId] || [];
console.log("📊 현재 화면 데이터 확인:", {
dataSourceId,
count: modalData.length,
allKeys: Object.keys(dataRegistry), // 🆕 전체 데이터 키 확인
});
if (modalData.length === 0) {
console.warn("⚠️ 전달할 데이터가 없습니다:", dataSourceId);
console.warn("⚠️ 선택된 데이터가 없습니다:", dataSourceId);
toast.warning("선택된 데이터가 없습니다. 먼저 항목을 선택해주세요.");
return false;
}
console.log("✅ 전달할 데이터:", {
dataSourceId,
count: modalData.length,
data: modalData,
console.log("✅ 모달 데이터 준비 완료:", {
currentData: { id: dataSourceId, count: modalData.length },
previousData: Object.entries(dataRegistry)
.filter(([key]) => key !== dataSourceId)
.map(([key, data]: [string, any]) => ({ id: key, count: data.length })),
});
} catch (error) {
console.error("❌ 데이터 확인 실패:", error);
@@ -875,7 +920,79 @@ export class ButtonActionExecutor {
return false;
}
// 3. 모달 열기 + URL 파라미터로 dataSourceId 전달
// 6. 동적 모달 제목 생성
const { useModalDataStore } = await import("@/stores/modalDataStore");
const dataRegistry = useModalDataStore.getState().dataRegistry;
let finalTitle = "데이터 입력";
// 🆕 블록 기반 제목 (우선순위 1)
if (config.modalTitleBlocks && config.modalTitleBlocks.length > 0) {
const titleParts: string[] = [];
config.modalTitleBlocks.forEach((block) => {
if (block.type === "text") {
// 텍스트 블록: 그대로 추가
titleParts.push(block.value);
} else if (block.type === "field") {
// 필드 블록: 데이터에서 값 가져오기
const tableName = block.tableName;
const columnName = block.value;
if (tableName && columnName) {
const tableData = dataRegistry[tableName];
if (tableData && tableData.length > 0) {
const firstItem = tableData[0].originalData || tableData[0];
const value = firstItem[columnName];
if (value !== undefined && value !== null) {
titleParts.push(String(value));
console.log(`✨ 동적 필드: ${tableName}.${columnName}${value}`);
} else {
// 데이터 없으면 라벨 표시
titleParts.push(block.label || columnName);
}
} else {
// 테이블 데이터 없으면 라벨 표시
titleParts.push(block.label || columnName);
}
}
}
});
finalTitle = titleParts.join("");
console.log("📋 블록 기반 제목 생성:", finalTitle);
}
// 기존 방식: {tableName.columnName} 패턴 (우선순위 2)
else if (config.modalTitle) {
finalTitle = config.modalTitle;
if (finalTitle.includes("{")) {
const matches = finalTitle.match(/\{([^}]+)\}/g);
if (matches) {
matches.forEach((match) => {
const path = match.slice(1, -1); // {item_info.item_name} → item_info.item_name
const [tableName, columnName] = path.split(".");
if (tableName && columnName) {
const tableData = dataRegistry[tableName];
if (tableData && tableData.length > 0) {
const firstItem = tableData[0].originalData || tableData[0];
const value = firstItem[columnName];
if (value !== undefined && value !== null) {
finalTitle = finalTitle.replace(match, String(value));
console.log(`✨ 동적 제목: ${match}${value}`);
}
}
}
});
}
}
}
// 7. 모달 열기 + URL 파라미터로 dataSourceId 전달
if (config.targetScreenId) {
// config에 modalDescription이 있으면 우선 사용
let description = config.modalDescription || "";
@@ -894,10 +1011,10 @@ export class ButtonActionExecutor {
const modalEvent = new CustomEvent("openScreenModal", {
detail: {
screenId: config.targetScreenId,
title: config.modalTitle || "데이터 입력",
title: finalTitle, // 🆕 동적 제목 사용
description: description,
size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large
urlParams: { dataSourceId }, // 🆕 URL 파라미터로 dataSourceId 전달
urlParams: { dataSourceId }, // 🆕 주 데이터 소스만 전달 (나머지는 modalDataStore에서 자동으로 찾음)
},
});

View File

@@ -85,8 +85,7 @@ export type ComponentType =
| "area"
| "layout"
| "flow"
| "component"
| "category-manager";
| "component";
/**
* 기본 위치 정보