feat: 부모 데이터 매핑 기능 구현 (선택항목 상세입력 컴포넌트)
- 여러 테이블(거래처, 품목 등)에서 데이터를 가져와 자동 매핑 가능 - 각 매핑마다 소스 테이블, 원본 필드, 저장 필드를 독립적으로 설정 - 검색 가능한 Combobox로 테이블 및 컬럼 선택 UX 개선 - 소스 테이블 선택 시 해당 테이블의 컬럼 자동 로드 - 라벨, 컬럼명, 데이터 타입으로 검색 가능 - 세로 레이아웃으로 가독성 향상 기술적 변경사항: - ParentDataMapping 인터페이스 추가 (sourceTable, sourceField, targetField) - buttonActions.ts의 handleBatchSave에서 소스 테이블 기반 데이터 소스 자동 판단 - tableManagementApi.getColumnList() 사용하여 테이블 컬럼 동적 로드 - Command + Popover 조합으로 검색 가능한 Select 구현 - 각 매핑별 독립적인 컬럼 상태 관리 (mappingSourceColumns)
This commit is contained in:
parent
b74cb94191
commit
f4e4ee13e2
|
|
@ -23,7 +23,8 @@ export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
|
|||
const result = await screenManagementService.getScreensByCompany(
|
||||
targetCompanyCode,
|
||||
parseInt(page as string),
|
||||
parseInt(size as string)
|
||||
parseInt(size as string),
|
||||
searchTerm as string // 검색어 전달
|
||||
);
|
||||
|
||||
res.json({
|
||||
|
|
|
|||
|
|
@ -98,7 +98,8 @@ export class ScreenManagementService {
|
|||
async getScreensByCompany(
|
||||
companyCode: string,
|
||||
page: number = 1,
|
||||
size: number = 20
|
||||
size: number = 20,
|
||||
searchTerm?: string // 검색어 추가
|
||||
): Promise<PaginatedResponse<ScreenDefinition>> {
|
||||
const offset = (page - 1) * size;
|
||||
|
||||
|
|
@ -111,6 +112,16 @@ export class ScreenManagementService {
|
|||
params.push(companyCode);
|
||||
}
|
||||
|
||||
// 검색어 필터링 추가 (화면명, 화면 코드, 테이블명 검색)
|
||||
if (searchTerm && searchTerm.trim() !== "") {
|
||||
whereConditions.push(`(
|
||||
screen_name ILIKE $${params.length + 1} OR
|
||||
screen_code ILIKE $${params.length + 1} OR
|
||||
table_name ILIKE $${params.length + 1}
|
||||
)`);
|
||||
params.push(`%${searchTerm.trim()}%`);
|
||||
}
|
||||
|
||||
const whereSQL = whereConditions.join(" AND ");
|
||||
|
||||
// 페이징 쿼리 (Raw Query)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -66,17 +67,31 @@ type DeletedScreenDefinition = ScreenDefinition & {
|
|||
};
|
||||
|
||||
export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScreen }: ScreenListProps) {
|
||||
const { user } = useAuth();
|
||||
const isSuperAdmin = user?.userType === "SUPER_ADMIN" || user?.companyCode === "*";
|
||||
|
||||
const [activeTab, setActiveTab] = useState("active");
|
||||
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||||
const [deletedScreens, setDeletedScreens] = useState<DeletedScreenDefinition[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(true); // 초기 로딩
|
||||
const [isSearching, setIsSearching] = useState(false); // 검색 중 로딩 (포커스 유지)
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||
const [selectedCompanyCode, setSelectedCompanyCode] = useState<string>("all");
|
||||
const [companies, setCompanies] = useState<any[]>([]);
|
||||
const [loadingCompanies, setLoadingCompanies] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isCopyOpen, setIsCopyOpen] = useState(false);
|
||||
const [screenToCopy, setScreenToCopy] = useState<ScreenDefinition | null>(null);
|
||||
|
||||
// 검색어 디바운스를 위한 타이머 ref
|
||||
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 첫 로딩 여부를 추적 (한 번만 true)
|
||||
const isFirstLoad = useRef(true);
|
||||
|
||||
// 삭제 관련 상태
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [screenToDelete, setScreenToDelete] = useState<ScreenDefinition | null>(null);
|
||||
|
|
@ -119,14 +134,75 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
|
||||
const [previewFormData, setPreviewFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// 화면 목록 로드 (실제 API)
|
||||
// 최고 관리자인 경우 회사 목록 로드
|
||||
useEffect(() => {
|
||||
if (isSuperAdmin) {
|
||||
loadCompanies();
|
||||
}
|
||||
}, [isSuperAdmin]);
|
||||
|
||||
const loadCompanies = async () => {
|
||||
try {
|
||||
setLoadingCompanies(true);
|
||||
const { apiClient } = await import("@/lib/api/client"); // named export
|
||||
const response = await apiClient.get("/admin/companies");
|
||||
const data = response.data.data || response.data || [];
|
||||
setCompanies(data.map((c: any) => ({
|
||||
companyCode: c.company_code || c.companyCode,
|
||||
companyName: c.company_name || c.companyName,
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error("회사 목록 조회 실패:", error);
|
||||
} finally {
|
||||
setLoadingCompanies(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 검색어 디바운스 처리 (150ms 지연 - 빠른 응답)
|
||||
useEffect(() => {
|
||||
// 이전 타이머 취소
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
// 새 타이머 설정
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
setDebouncedSearchTerm(searchTerm);
|
||||
}, 150);
|
||||
|
||||
// 클린업
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
};
|
||||
}, [searchTerm]);
|
||||
|
||||
// 화면 목록 로드 (실제 API) - debouncedSearchTerm 사용
|
||||
useEffect(() => {
|
||||
let abort = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// 첫 로딩인 경우에만 loading=true, 그 외에는 isSearching=true
|
||||
if (isFirstLoad.current) {
|
||||
setLoading(true);
|
||||
isFirstLoad.current = false; // 첫 로딩 완료 표시
|
||||
} else {
|
||||
setIsSearching(true);
|
||||
}
|
||||
|
||||
if (activeTab === "active") {
|
||||
const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm });
|
||||
const params: any = { page: currentPage, size: 20, searchTerm: debouncedSearchTerm };
|
||||
|
||||
// 최고 관리자이고 특정 회사를 선택한 경우
|
||||
if (isSuperAdmin && selectedCompanyCode !== "all") {
|
||||
params.companyCode = selectedCompanyCode;
|
||||
}
|
||||
|
||||
console.log("🔍 화면 목록 API 호출:", params); // 디버깅용
|
||||
const resp = await screenApi.getScreens(params);
|
||||
console.log("✅ 화면 목록 응답:", resp); // 디버깅용
|
||||
|
||||
if (abort) return;
|
||||
setScreens(resp.data || []);
|
||||
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
|
||||
|
|
@ -137,7 +213,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
|
||||
}
|
||||
} catch (e) {
|
||||
// console.error("화면 목록 조회 실패", e);
|
||||
console.error("화면 목록 조회 실패", e);
|
||||
if (activeTab === "active") {
|
||||
setScreens([]);
|
||||
} else {
|
||||
|
|
@ -145,28 +221,38 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
}
|
||||
setTotalPages(1);
|
||||
} finally {
|
||||
if (!abort) setLoading(false);
|
||||
if (!abort) {
|
||||
setLoading(false);
|
||||
setIsSearching(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => {
|
||||
abort = true;
|
||||
};
|
||||
}, [currentPage, searchTerm, activeTab]);
|
||||
}, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, isSuperAdmin]);
|
||||
|
||||
const filteredScreens = screens; // 서버 필터 기준 사용
|
||||
|
||||
// 화면 목록 다시 로드
|
||||
const reloadScreens = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm });
|
||||
setIsSearching(true);
|
||||
const params: any = { page: currentPage, size: 20, searchTerm: debouncedSearchTerm };
|
||||
|
||||
// 최고 관리자이고 특정 회사를 선택한 경우
|
||||
if (isSuperAdmin && selectedCompanyCode !== "all") {
|
||||
params.companyCode = selectedCompanyCode;
|
||||
}
|
||||
|
||||
const resp = await screenApi.getScreens(params);
|
||||
setScreens(resp.data || []);
|
||||
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
|
||||
} catch (e) {
|
||||
// console.error("화면 목록 조회 실패", e);
|
||||
console.error("화면 목록 조회 실패", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -405,18 +491,48 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
<div className="space-y-4">
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="w-full sm:w-[400px]">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="화면명, 코드, 테이블명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
disabled={activeTab === "trash"}
|
||||
/>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
{/* 최고 관리자 전용: 회사 필터 */}
|
||||
{isSuperAdmin && (
|
||||
<div className="w-full sm:w-[200px]">
|
||||
<Select value={selectedCompanyCode} onValueChange={setSelectedCompanyCode} disabled={activeTab === "trash"}>
|
||||
<SelectTrigger className="h-10 text-sm">
|
||||
<SelectValue placeholder="전체 회사" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 회사</SelectItem>
|
||||
{companies.map((company) => (
|
||||
<SelectItem key={company.companyCode} value={company.companyCode}>
|
||||
{company.companyName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="w-full sm:w-[400px]">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
key="screen-search-input" // 리렌더링 시에도 동일한 Input 유지
|
||||
placeholder="화면명, 코드, 테이블명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
disabled={activeTab === "trash"}
|
||||
/>
|
||||
{/* 검색 중 인디케이터 */}
|
||||
{isSearching && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
disabled={activeTab === "trash"}
|
||||
|
|
|
|||
|
|
@ -257,7 +257,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
|
||||
// 🆕 저장 요청 시에만 데이터 전달 (이벤트 리스너 방식)
|
||||
useEffect(() => {
|
||||
const handleSaveRequest = () => {
|
||||
const handleSaveRequest = (event: Event) => {
|
||||
// component.id를 문자열로 안전하게 변환
|
||||
const componentKey = String(component.id || "selected_items");
|
||||
|
||||
|
|
@ -269,17 +269,26 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
componentKey,
|
||||
});
|
||||
|
||||
if (items.length > 0 && onFormDataChange) {
|
||||
const dataToSave = { [componentKey]: items };
|
||||
console.log("📝 [SelectedItemsDetailInput] 저장 요청 시 데이터 전달:", {
|
||||
if (items.length > 0) {
|
||||
console.log("📝 [SelectedItemsDetailInput] 저장 데이터 준비:", {
|
||||
key: componentKey,
|
||||
itemsCount: items.length,
|
||||
fullData: dataToSave,
|
||||
firstItem: items[0],
|
||||
});
|
||||
onFormDataChange(dataToSave);
|
||||
|
||||
// ✅ CustomEvent의 detail에 데이터 첨부
|
||||
if (event instanceof CustomEvent && event.detail) {
|
||||
// context.formData에 직접 추가
|
||||
event.detail.formData[componentKey] = items;
|
||||
console.log("✅ [SelectedItemsDetailInput] context.formData에 데이터 직접 추가 완료");
|
||||
}
|
||||
|
||||
// 기존 onFormDataChange도 호출 (호환성)
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(componentKey, items);
|
||||
}
|
||||
} else {
|
||||
console.warn("⚠️ [SelectedItemsDetailInput] 저장 데이터 전달 실패:", {
|
||||
console.warn("⚠️ [SelectedItemsDetailInput] 저장할 데이터 없음:", {
|
||||
hasItems: items.length > 0,
|
||||
hasCallback: !!onFormDataChange,
|
||||
itemsLength: items.length,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Plus, X, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup, DisplayItem, DisplayItemType, EmptyBehavior, DisplayFieldFormat } from "./types";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -69,6 +69,36 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
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 }>>>({});
|
||||
|
||||
// 🆕 부모 데이터 매핑: 각 매핑별 소스 테이블 컬럼 상태
|
||||
const [mappingSourceColumns, setMappingSourceColumns] = useState<Record<number, Array<{ columnName: string; columnLabel?: string; dataType?: string }>>>({});
|
||||
|
||||
// 🆕 소스 테이블 선택 시 컬럼 로드
|
||||
const loadMappingSourceColumns = async (tableName: string, mappingIndex: number) => {
|
||||
try {
|
||||
console.log(`🔍 [매핑 ${mappingIndex}] 소스 테이블 컬럼 로드:`, tableName);
|
||||
|
||||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||
const response = await tableManagementApi.getColumnList(tableName);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const columns = response.data.columns || [];
|
||||
setMappingSourceColumns(prev => ({
|
||||
...prev,
|
||||
[mappingIndex]: columns.map((col: any) => ({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.displayName || col.columnLabel || col.columnName,
|
||||
dataType: col.dataType,
|
||||
}))
|
||||
}));
|
||||
console.log(`✅ [매핑 ${mappingIndex}] 컬럼 로드 성공:`, columns.length);
|
||||
} else {
|
||||
console.error(`❌ [매핑 ${mappingIndex}] 컬럼 로드 실패:`, response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ [매핑 ${mappingIndex}] 컬럼 로드 오류:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
// 2레벨 메뉴 목록 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -1741,6 +1771,294 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🆕 부모 데이터 매핑 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold sm:text-sm">부모 데이터 매핑</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
const newMapping = {
|
||||
sourceTable: "", // 사용자가 선택
|
||||
sourceField: "",
|
||||
targetField: "",
|
||||
defaultValue: undefined,
|
||||
};
|
||||
handleChange("parentDataMapping", [
|
||||
...(config.parentDataMapping || []),
|
||||
newMapping,
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-[9px] text-muted-foreground sm:text-[10px]">
|
||||
이전 화면(거래처 선택 등)에서 넘어온 데이터를 자동으로 매핑합니다.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(config.parentDataMapping || []).map((mapping, index) => (
|
||||
<Card key={index} className="p-3">
|
||||
<div className="space-y-2">
|
||||
{/* 소스 테이블 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px] sm:text-[10px]">소스 테이블</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs font-normal"
|
||||
>
|
||||
{mapping.sourceTable
|
||||
? allTables.find((t) => t.tableName === mapping.sourceTable)?.displayName ||
|
||||
mapping.sourceTable
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={(currentValue) => {
|
||||
const updated = [...(config.parentDataMapping || [])];
|
||||
updated[index] = {
|
||||
...updated[index],
|
||||
sourceTable: currentValue,
|
||||
sourceField: "", // 테이블 변경 시 필드 초기화
|
||||
};
|
||||
handleChange("parentDataMapping", updated);
|
||||
|
||||
// 테이블 선택 시 컬럼 로드
|
||||
if (currentValue) {
|
||||
loadMappingSourceColumns(currentValue, index);
|
||||
}
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
mapping.sourceTable === table.tableName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{table.displayName || table.tableName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-[8px] text-muted-foreground">
|
||||
품목, 거래처, 사용자 등 데이터를 가져올 테이블을 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 원본 필드 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px] sm:text-[10px]">원본 필드</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs font-normal"
|
||||
disabled={!mapping.sourceTable}
|
||||
>
|
||||
{mapping.sourceField
|
||||
? mappingSourceColumns[index]?.find((c) => c.columnName === mapping.sourceField)
|
||||
?.columnLabel || mapping.sourceField
|
||||
: "컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
{!mapping.sourceTable ? (
|
||||
<CommandEmpty className="text-xs">소스 테이블을 먼저 선택하세요</CommandEmpty>
|
||||
) : !mappingSourceColumns[index] || mappingSourceColumns[index].length === 0 ? (
|
||||
<CommandEmpty className="text-xs">컬럼 로딩 중...</CommandEmpty>
|
||||
) : (
|
||||
<>
|
||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{mappingSourceColumns[index].map((col) => {
|
||||
const searchValue = `${col.columnLabel || col.columnName} ${col.columnName} ${col.dataType || ""}`.toLowerCase();
|
||||
return (
|
||||
<CommandItem
|
||||
key={col.columnName}
|
||||
value={searchValue}
|
||||
onSelect={() => {
|
||||
const updated = [...(config.parentDataMapping || [])];
|
||||
updated[index] = { ...updated[index], sourceField: col.columnName };
|
||||
handleChange("parentDataMapping", updated);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
mapping.sourceField === col.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{col.columnLabel || col.columnName}</span>
|
||||
{col.dataType && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{col.dataType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 저장 필드 (현재 화면 테이블 컬럼) */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px] sm:text-[10px]">저장 필드 (현재 테이블)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs font-normal"
|
||||
disabled={targetTableColumns.length === 0}
|
||||
>
|
||||
{mapping.targetField
|
||||
? targetTableColumns.find((c) => c.columnName === mapping.targetField)?.columnLabel ||
|
||||
mapping.targetField
|
||||
: "저장 테이블 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
{targetTableColumns.length === 0 ? (
|
||||
<CommandEmpty className="text-xs">저장 테이블을 먼저 선택하세요</CommandEmpty>
|
||||
) : (
|
||||
<>
|
||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{targetTableColumns.map((col) => {
|
||||
const searchValue = `${col.columnLabel || col.columnName} ${col.columnName} ${col.dataType || ""}`.toLowerCase();
|
||||
return (
|
||||
<CommandItem
|
||||
key={col.columnName}
|
||||
value={searchValue}
|
||||
onSelect={() => {
|
||||
const updated = [...(config.parentDataMapping || [])];
|
||||
updated[index] = { ...updated[index], targetField: col.columnName };
|
||||
handleChange("parentDataMapping", updated);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
mapping.targetField === col.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{col.columnLabel || col.columnName}</span>
|
||||
{col.dataType && (
|
||||
<span className="text-[10px] text-muted-foreground">{col.dataType}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 기본값 (선택사항) */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px] sm:text-[10px]">기본값 (선택사항)</Label>
|
||||
<Input
|
||||
value={mapping.defaultValue || ""}
|
||||
onChange={(e) => {
|
||||
const updated = [...(config.parentDataMapping || [])];
|
||||
updated[index] = { ...updated[index], defaultValue: e.target.value };
|
||||
handleChange("parentDataMapping", updated);
|
||||
}}
|
||||
placeholder="값이 없을 때 사용할 기본값"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-full text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
const updated = (config.parentDataMapping || []).filter((_, i) => i !== index);
|
||||
handleChange("parentDataMapping", updated);
|
||||
}}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(config.parentDataMapping || []).length === 0 && (
|
||||
<p className="text-center text-[10px] text-muted-foreground py-4">
|
||||
매핑 설정이 없습니다. "추가" 버튼을 클릭하여 설정하세요.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 예시 */}
|
||||
<div className="rounded-lg bg-green-50 p-2 text-xs">
|
||||
<p className="mb-1 text-[10px] font-medium text-green-900">💡 예시</p>
|
||||
<div className="space-y-1 text-[9px] text-green-700">
|
||||
<p><strong>매핑 1: 거래처 ID</strong></p>
|
||||
<p className="ml-2">• 소스 테이블: <code className="bg-green-100 px-1">customer_mng</code></p>
|
||||
<p className="ml-2">• 원본 필드: <code className="bg-green-100 px-1">id</code> → 저장 필드: <code className="bg-green-100 px-1">customer_id</code></p>
|
||||
|
||||
<p className="mt-1"><strong>매핑 2: 품목 ID</strong></p>
|
||||
<p className="ml-2">• 소스 테이블: <code className="bg-green-100 px-1">item_info</code></p>
|
||||
<p className="ml-2">• 원본 필드: <code className="bg-green-100 px-1">id</code> → 저장 필드: <code className="bg-green-100 px-1">item_id</code></p>
|
||||
|
||||
<p className="mt-1"><strong>매핑 3: 품목 기준단가</strong></p>
|
||||
<p className="ml-2">• 소스 테이블: <code className="bg-green-100 px-1">item_info</code></p>
|
||||
<p className="ml-2">• 원본 필드: <code className="bg-green-100 px-1">standard_price</code> → 저장 필드: <code className="bg-green-100 px-1">base_price</code></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사용 예시 */}
|
||||
<div className="rounded-lg bg-blue-50 p-2 text-xs sm:p-3 sm:text-sm">
|
||||
<p className="mb-1 font-medium text-blue-900">💡 사용 예시</p>
|
||||
|
|
|
|||
|
|
@ -121,6 +121,20 @@ export interface AutoCalculationConfig {
|
|||
calculationSteps?: CalculationStep[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 부모 화면 데이터 매핑 설정
|
||||
*/
|
||||
export interface ParentDataMapping {
|
||||
/** 소스 테이블명 (필수) */
|
||||
sourceTable: string;
|
||||
/** 소스 테이블의 필드명 */
|
||||
sourceField: string;
|
||||
/** 저장할 테이블의 필드명 */
|
||||
targetField: string;
|
||||
/** 부모 데이터가 없을 때 사용할 기본값 (선택사항) */
|
||||
defaultValue?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* SelectedItemsDetailInput 컴포넌트 설정 타입
|
||||
*/
|
||||
|
|
@ -160,6 +174,13 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
|
|||
*/
|
||||
targetTable?: string;
|
||||
|
||||
/**
|
||||
* 🆕 부모 화면 데이터 매핑
|
||||
* 이전 화면(예: 거래처 테이블)에서 넘어온 데이터를 저장 테이블의 필드에 자동 매핑
|
||||
* 예: { sourceField: "id", targetField: "customer_id" }
|
||||
*/
|
||||
parentDataMapping?: ParentDataMapping[];
|
||||
|
||||
/**
|
||||
* 🆕 자동 계산 설정
|
||||
* 특정 필드가 변경되면 다른 필드를 자동으로 계산
|
||||
|
|
|
|||
|
|
@ -208,10 +208,17 @@ export class ButtonActionExecutor {
|
|||
console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId });
|
||||
|
||||
// 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집)
|
||||
window.dispatchEvent(new CustomEvent("beforeFormSave"));
|
||||
// context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함
|
||||
window.dispatchEvent(new CustomEvent("beforeFormSave", {
|
||||
detail: {
|
||||
formData: context.formData
|
||||
}
|
||||
}));
|
||||
|
||||
// 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
console.log("📦 [handleSave] beforeFormSave 이벤트 후 formData:", context.formData);
|
||||
|
||||
// 🆕 SelectedItemsDetailInput 배치 저장 처리 (fieldGroups 구조)
|
||||
console.log("🔍 [handleSave] formData 구조 확인:", {
|
||||
|
|
@ -508,7 +515,14 @@ export class ButtonActionExecutor {
|
|||
context: ButtonActionContext,
|
||||
selectedItemsKeys: string[]
|
||||
): Promise<boolean> {
|
||||
const { formData, tableName, screenId } = context;
|
||||
const { formData, tableName, screenId, selectedRowsData, originalData } = context;
|
||||
|
||||
console.log(`🔍 [handleBatchSave] context 확인:`, {
|
||||
hasSelectedRowsData: !!selectedRowsData,
|
||||
selectedRowsCount: selectedRowsData?.length || 0,
|
||||
hasOriginalData: !!originalData,
|
||||
originalDataKeys: originalData ? Object.keys(originalData) : [],
|
||||
});
|
||||
|
||||
if (!tableName || !screenId) {
|
||||
toast.error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
|
||||
|
|
@ -520,6 +534,15 @@ export class ButtonActionExecutor {
|
|||
let failCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
// 🆕 부모 화면 데이터 준비 (parentDataMapping용)
|
||||
// selectedRowsData 또는 originalData를 parentData로 사용
|
||||
const parentData = selectedRowsData?.[0] || originalData || {};
|
||||
|
||||
console.log(`🔍 [handleBatchSave] 부모 데이터:`, {
|
||||
hasParentData: Object.keys(parentData).length > 0,
|
||||
parentDataKeys: Object.keys(parentData),
|
||||
});
|
||||
|
||||
// 각 SelectedItemsDetailInput 컴포넌트의 데이터 처리
|
||||
for (const key of selectedItemsKeys) {
|
||||
// 🆕 새로운 데이터 구조: ItemData[] with fieldGroups
|
||||
|
|
@ -531,22 +554,152 @@ export class ButtonActionExecutor {
|
|||
|
||||
console.log(`📦 [handleBatchSave] ${key} 처리 중 (${items.length}개 품목)`);
|
||||
|
||||
// 각 품목의 모든 그룹의 모든 항목을 개별 저장
|
||||
// 🆕 이 컴포넌트의 parentDataMapping 설정 가져오기
|
||||
// TODO: 실제로는 componentConfig에서 가져와야 함
|
||||
// 현재는 selectedItemsKeys[0]을 사용하여 임시로 가져옴
|
||||
const componentConfig = (context as any).componentConfigs?.[key];
|
||||
const parentDataMapping = componentConfig?.parentDataMapping || [];
|
||||
|
||||
console.log(`🔍 [handleBatchSave] parentDataMapping 설정:`, {
|
||||
hasMapping: parentDataMapping.length > 0,
|
||||
mappings: parentDataMapping
|
||||
});
|
||||
|
||||
// 🆕 각 품목의 그룹 간 조합(카티션 곱) 생성
|
||||
for (const item of items) {
|
||||
const allGroupEntries = Object.values(item.fieldGroups).flat();
|
||||
console.log(`🔍 [handleBatchSave] 품목 처리: ${item.id} (${allGroupEntries.length}개 입력 항목)`);
|
||||
const groupKeys = Object.keys(item.fieldGroups);
|
||||
console.log(`🔍 [handleBatchSave] 품목 처리: ${item.id} (${groupKeys.length}개 그룹)`);
|
||||
|
||||
// 모든 그룹의 모든 항목을 개별 레코드로 저장
|
||||
for (const entry of allGroupEntries) {
|
||||
// 각 그룹의 항목 배열 가져오기
|
||||
const groupArrays = groupKeys.map(groupKey => ({
|
||||
groupKey,
|
||||
entries: item.fieldGroups[groupKey] || []
|
||||
}));
|
||||
|
||||
console.log(`📊 [handleBatchSave] 그룹별 항목 수:`,
|
||||
groupArrays.map(g => `${g.groupKey}: ${g.entries.length}개`).join(", ")
|
||||
);
|
||||
|
||||
// 카티션 곱 계산 함수
|
||||
const cartesianProduct = (arrays: any[][]): any[][] => {
|
||||
if (arrays.length === 0) return [[]];
|
||||
if (arrays.length === 1) return arrays[0].map(item => [item]);
|
||||
|
||||
const [first, ...rest] = arrays;
|
||||
const restProduct = cartesianProduct(rest);
|
||||
|
||||
return first.flatMap(item =>
|
||||
restProduct.map(combination => [item, ...combination])
|
||||
);
|
||||
};
|
||||
|
||||
// 모든 그룹의 카티션 곱 생성
|
||||
const entryArrays = groupArrays.map(g => g.entries);
|
||||
const combinations = cartesianProduct(entryArrays);
|
||||
|
||||
console.log(`🔢 [handleBatchSave] 생성된 조합 수: ${combinations.length}개`);
|
||||
|
||||
// 각 조합을 개별 레코드로 저장
|
||||
for (let i = 0; i < combinations.length; i++) {
|
||||
const combination = combinations[i];
|
||||
try {
|
||||
// 원본 데이터 + 입력 데이터 병합
|
||||
const mergedData = {
|
||||
...item.originalData,
|
||||
...entry,
|
||||
};
|
||||
// 🆕 부모 데이터 매핑 적용
|
||||
const mappedData: any = {};
|
||||
|
||||
// id 필드 제거 (entry.id는 임시 ID이므로)
|
||||
delete mergedData.id;
|
||||
// 1. parentDataMapping 설정이 있으면 적용
|
||||
if (parentDataMapping.length > 0) {
|
||||
console.log(` 🔗 [parentDataMapping] 매핑 시작 (${parentDataMapping.length}개 매핑)`);
|
||||
|
||||
for (const mapping of parentDataMapping) {
|
||||
// sourceTable을 기준으로 데이터 소스 결정
|
||||
let sourceData: any;
|
||||
|
||||
// 🔍 sourceTable과 실제 데이터 테이블 비교
|
||||
// - parentData는 이전 화면 데이터 (예: 거래처 테이블)
|
||||
// - item.originalData는 선택된 항목 데이터 (예: 품목 테이블)
|
||||
|
||||
// 원본 데이터 테이블명 확인 (sourceTable이 config에 명시되어 있음)
|
||||
const sourceTableName = mapping.sourceTable;
|
||||
|
||||
// 현재 선택된 항목의 테이블 = config.sourceTable
|
||||
const selectedItemTable = componentConfig?.sourceTable;
|
||||
|
||||
if (sourceTableName === selectedItemTable) {
|
||||
// 선택된 항목 데이터 사용
|
||||
sourceData = item.originalData;
|
||||
console.log(` 📦 소스: 선택된 항목 데이터 (${sourceTableName})`);
|
||||
} else {
|
||||
// 이전 화면 데이터 사용
|
||||
sourceData = parentData;
|
||||
console.log(` 👤 소스: 이전 화면 데이터 (${sourceTableName})`);
|
||||
}
|
||||
|
||||
const sourceValue = sourceData[mapping.sourceField];
|
||||
|
||||
if (sourceValue !== undefined && sourceValue !== null) {
|
||||
mappedData[mapping.targetField] = sourceValue;
|
||||
console.log(` ✅ [${sourceTableName}] ${mapping.sourceField} → ${mapping.targetField}: ${sourceValue}`);
|
||||
} else if (mapping.defaultValue !== undefined) {
|
||||
mappedData[mapping.targetField] = mapping.defaultValue;
|
||||
console.log(` ⚠️ [${sourceTableName}] ${mapping.sourceField} 없음, 기본값 사용 → ${mapping.targetField}: ${mapping.defaultValue}`);
|
||||
} else {
|
||||
console.log(` ⚠️ [${sourceTableName}] ${mapping.sourceField} 없음, 건너뜀`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 🔧 parentDataMapping 설정이 없는 경우 기본 매핑 (하위 호환성)
|
||||
console.log(` ⚠️ [parentDataMapping] 설정 없음, 기본 매핑 적용`);
|
||||
|
||||
// 기본 item_id 매핑 (item.originalData의 id)
|
||||
if (item.originalData.id) {
|
||||
mappedData.item_id = item.originalData.id;
|
||||
console.log(` ✅ [기본] item_id 매핑: ${item.originalData.id}`);
|
||||
}
|
||||
|
||||
// 기본 customer_id 매핑 (parentData의 id 또는 customer_id)
|
||||
if (parentData.id || parentData.customer_id) {
|
||||
mappedData.customer_id = parentData.customer_id || parentData.id;
|
||||
console.log(` ✅ [기본] customer_id 매핑: ${mappedData.customer_id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 공통 필드 복사 (company_code, currency_code 등)
|
||||
if (item.originalData.company_code && !mappedData.company_code) {
|
||||
mappedData.company_code = item.originalData.company_code;
|
||||
}
|
||||
if (item.originalData.currency_code && !mappedData.currency_code) {
|
||||
mappedData.currency_code = item.originalData.currency_code;
|
||||
}
|
||||
|
||||
// 원본 데이터로 시작 (매핑된 데이터 사용)
|
||||
let mergedData = { ...mappedData };
|
||||
|
||||
console.log(`🔍 [handleBatchSave] 조합 ${i + 1}/${combinations.length} 병합 시작:`, {
|
||||
originalDataKeys: Object.keys(item.originalData),
|
||||
mappedDataKeys: Object.keys(mappedData),
|
||||
combinationLength: combination.length
|
||||
});
|
||||
|
||||
// 각 그룹의 항목 데이터를 순차적으로 병합
|
||||
for (let j = 0; j < combination.length; j++) {
|
||||
const entry = combination[j];
|
||||
const { id, ...entryData } = entry; // id 제외
|
||||
|
||||
console.log(` 🔸 그룹 ${j + 1} 데이터 병합:`, entryData);
|
||||
|
||||
mergedData = { ...mergedData, ...entryData };
|
||||
}
|
||||
|
||||
console.log(`📝 [handleBatchSave] 조합 ${i + 1}/${combinations.length} 최종 데이터:`, mergedData);
|
||||
|
||||
// 🆕 조합 저장 시 id 필드 제거 (각 조합이 독립된 새 레코드가 되도록)
|
||||
// originalData의 id는 원본 품목의 ID이므로, 새로운 customer_item_mapping 레코드 생성 시 제거 필요
|
||||
const { id: _removedId, ...dataWithoutId } = mergedData;
|
||||
|
||||
console.log(`🔧 [handleBatchSave] 조합 ${i + 1}/${combinations.length} id 제거됨:`, {
|
||||
removedId: _removedId,
|
||||
hasId: 'id' in dataWithoutId
|
||||
});
|
||||
|
||||
// 사용자 정보 추가
|
||||
if (!context.userId) {
|
||||
|
|
@ -557,16 +710,17 @@ export class ButtonActionExecutor {
|
|||
const companyCodeValue = context.companyCode || "";
|
||||
|
||||
const dataWithUserInfo = {
|
||||
...mergedData,
|
||||
writer: mergedData.writer || writerValue,
|
||||
...dataWithoutId, // id가 제거된 데이터 사용
|
||||
writer: dataWithoutId.writer || writerValue,
|
||||
created_by: writerValue,
|
||||
updated_by: writerValue,
|
||||
company_code: mergedData.company_code || companyCodeValue,
|
||||
company_code: dataWithoutId.company_code || companyCodeValue,
|
||||
};
|
||||
|
||||
console.log(`💾 [handleBatchSave] 입력 항목 저장:`, {
|
||||
console.log(`💾 [handleBatchSave] 조합 ${i + 1}/${combinations.length} 저장 요청:`, {
|
||||
itemId: item.id,
|
||||
entryId: entry.id,
|
||||
combinationIndex: i + 1,
|
||||
totalCombinations: combinations.length,
|
||||
data: dataWithUserInfo
|
||||
});
|
||||
|
||||
|
|
@ -580,16 +734,19 @@ export class ButtonActionExecutor {
|
|||
|
||||
if (saveResult.success) {
|
||||
successCount++;
|
||||
console.log(`✅ [handleBatchSave] 입력 항목 저장 성공: ${item.id} > ${entry.id}`);
|
||||
console.log(`✅ [handleBatchSave] 조합 ${i + 1}/${combinations.length} 저장 성공!`, {
|
||||
savedId: saveResult.data?.id,
|
||||
itemId: item.id
|
||||
});
|
||||
} else {
|
||||
failCount++;
|
||||
errors.push(`품목 ${item.id} > 항목 ${entry.id}: ${saveResult.message}`);
|
||||
console.error(`❌ [handleBatchSave] 입력 항목 저장 실패: ${item.id} > ${entry.id}`, saveResult.message);
|
||||
errors.push(`품목 ${item.id} > 조합 ${i + 1}: ${saveResult.message}`);
|
||||
console.error(`❌ [handleBatchSave] 조합 ${i + 1}/${combinations.length} 저장 실패:`, saveResult.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
failCount++;
|
||||
errors.push(`품목 ${item.id} > 항목 ${entry.id}: ${error.message}`);
|
||||
console.error(`❌ [handleBatchSave] 입력 항목 저장 오류: ${item.id} > ${entry.id}`, error);
|
||||
errors.push(`품목 ${item.id} > 조합 ${i + 1}: ${error.message}`);
|
||||
console.error(`❌ [handleBatchSave] 조합 ${i + 1}/${combinations.length} 저장 오류:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue