feat: 부모 데이터 매핑 기능 구현 (선택항목 상세입력 컴포넌트)

- 여러 테이블(거래처, 품목 등)에서 데이터를 가져와 자동 매핑 가능
- 각 매핑마다 소스 테이블, 원본 필드, 저장 필드를 독립적으로 설정
- 검색 가능한 Combobox로 테이블 및 컬럼 선택 UX 개선
- 소스 테이블 선택 시 해당 테이블의 컬럼 자동 로드
- 라벨, 컬럼명, 데이터 타입으로 검색 가능
- 세로 레이아웃으로 가독성 향상

기술적 변경사항:
- ParentDataMapping 인터페이스 추가 (sourceTable, sourceField, targetField)
- buttonActions.ts의 handleBatchSave에서 소스 테이블 기반 데이터 소스 자동 판단
- tableManagementApi.getColumnList() 사용하여 테이블 컬럼 동적 로드
- Command + Popover 조합으로 검색 가능한 Select 구현
- 각 매핑별 독립적인 컬럼 상태 관리 (mappingSourceColumns)
This commit is contained in:
kjs
2025-11-19 13:22:49 +09:00
parent b74cb94191
commit f4e4ee13e2
7 changed files with 689 additions and 56 deletions

View File

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