feat: 부모 데이터 매핑 기능 구현 (선택항목 상세입력 컴포넌트)
- 여러 테이블(거래처, 품목 등)에서 데이터를 가져와 자동 매핑 가능 - 각 매핑마다 소스 테이블, 원본 필드, 저장 필드를 독립적으로 설정 - 검색 가능한 Combobox로 테이블 및 컬럼 선택 UX 개선 - 소스 테이블 선택 시 해당 테이블의 컬럼 자동 로드 - 라벨, 컬럼명, 데이터 타입으로 검색 가능 - 세로 레이아웃으로 가독성 향상 기술적 변경사항: - ParentDataMapping 인터페이스 추가 (sourceTable, sourceField, targetField) - buttonActions.ts의 handleBatchSave에서 소스 테이블 기반 데이터 소스 자동 판단 - tableManagementApi.getColumnList() 사용하여 테이블 컬럼 동적 로드 - Command + Popover 조합으로 검색 가능한 Select 구현 - 각 매핑별 독립적인 컬럼 상태 관리 (mappingSourceColumns)
This commit is contained in:
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user