feat(repeater): 하위 데이터 조회 및 조건부 입력 기능 구현, 테이블 선택 데이터 동기화 개선
Repeater 컴포넌트에 하위 데이터 조회 기능 추가 (재고/단가 조회) 조건부 입력 활성화 및 최대값 제한 기능 구현 필드 정의 순서 변경 기능 추가 (드래그앤드롭, 화살표 버튼) TableListComponent의 DataProvider 클로저 문제 해결 ButtonPrimaryComponent에 modalDataStore fallback 로직 추가
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { ChevronDown, ChevronUp, Loader2, AlertCircle, Check, Package, Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SubDataLookupConfig } from "@/types/repeater";
|
||||
import { useSubDataLookup } from "./useSubDataLookup";
|
||||
|
||||
export interface SubDataLookupPanelProps {
|
||||
config: SubDataLookupConfig;
|
||||
linkValue: string | number | null; // 상위 항목의 연결 값 (예: item_code)
|
||||
itemIndex: number; // 상위 항목 인덱스
|
||||
onSelectionChange: (selectedItem: any | null, maxValue: number | null) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 데이터 조회 패널
|
||||
* 품목 선택 시 재고/단가 등 관련 데이터를 표시하고 선택할 수 있는 패널
|
||||
*/
|
||||
export const SubDataLookupPanel: React.FC<SubDataLookupPanelProps> = ({
|
||||
config,
|
||||
linkValue,
|
||||
itemIndex,
|
||||
onSelectionChange,
|
||||
disabled = false,
|
||||
className,
|
||||
}) => {
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
selectedItem,
|
||||
setSelectedItem,
|
||||
isInputEnabled,
|
||||
maxValue,
|
||||
isExpanded,
|
||||
setIsExpanded,
|
||||
refetch,
|
||||
getSelectionSummary,
|
||||
} = useSubDataLookup({
|
||||
config,
|
||||
linkValue,
|
||||
itemIndex,
|
||||
enabled: !disabled,
|
||||
});
|
||||
|
||||
// 선택 핸들러
|
||||
const handleSelect = (item: any) => {
|
||||
if (disabled) return;
|
||||
|
||||
// 이미 선택된 항목이면 선택 해제
|
||||
const newSelectedItem = selectedItem?.id === item.id ? null : item;
|
||||
setSelectedItem(newSelectedItem);
|
||||
|
||||
// 최대값 계산
|
||||
let newMaxValue: number | null = null;
|
||||
if (newSelectedItem && config.conditionalInput.maxValueField) {
|
||||
const val = newSelectedItem[config.conditionalInput.maxValueField];
|
||||
newMaxValue = typeof val === "number" ? val : parseFloat(val) || null;
|
||||
}
|
||||
|
||||
onSelectionChange(newSelectedItem, newMaxValue);
|
||||
};
|
||||
|
||||
// 컬럼 라벨 가져오기
|
||||
const getColumnLabel = (columnName: string): string => {
|
||||
return config.lookup.columnLabels?.[columnName] || columnName;
|
||||
};
|
||||
|
||||
// 표시할 컬럼 목록
|
||||
const displayColumns = config.lookup.displayColumns || [];
|
||||
|
||||
// 요약 정보 표시용 선택 상태
|
||||
const summaryText = useMemo(() => {
|
||||
if (!selectedItem) return null;
|
||||
return getSelectionSummary();
|
||||
}, [selectedItem, getSelectionSummary]);
|
||||
|
||||
// linkValue가 없으면 렌더링하지 않음
|
||||
if (!linkValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 인라인 모드 렌더링
|
||||
if (config.ui?.expandMode === "inline" || !config.ui?.expandMode) {
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
{/* 토글 버튼 및 요약 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const willExpand = !isExpanded;
|
||||
setIsExpanded(willExpand);
|
||||
if (willExpand) {
|
||||
refetch(); // 펼칠 때 데이터 재조회
|
||||
}
|
||||
}}
|
||||
disabled={disabled || isLoading}
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : isExpanded ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
<Package className="h-3 w-3" />
|
||||
<span>재고 조회</span>
|
||||
{data.length > 0 && <span className="text-muted-foreground">({data.length})</span>}
|
||||
</Button>
|
||||
|
||||
{/* 선택 요약 표시 */}
|
||||
{selectedItem && summaryText && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<Check className="h-3 w-3 text-green-600" />
|
||||
<span className="text-green-700">{summaryText}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 확장된 패널 */}
|
||||
{isExpanded && (
|
||||
<div
|
||||
className="mt-2 rounded-md border bg-gray-50"
|
||||
style={{ maxHeight: config.ui?.maxHeight || "150px", overflowY: "auto" }}
|
||||
>
|
||||
{/* 에러 상태 */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 text-xs text-red-600">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>{error}</span>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={refetch} className="ml-auto h-6 text-xs">
|
||||
재시도
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center gap-2 p-4 text-xs text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>조회 중...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 없음 */}
|
||||
{!isLoading && !error && data.length === 0 && (
|
||||
<div className="p-4 text-center text-xs text-gray-500">
|
||||
{config.ui?.emptyMessage || "재고 데이터가 없습니다"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
{!isLoading && !error && data.length > 0 && (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-gray-100">
|
||||
<tr>
|
||||
<th className="w-8 p-2 text-center">선택</th>
|
||||
{displayColumns.map((col) => (
|
||||
<th key={col} className="p-2 text-left font-medium">
|
||||
{getColumnLabel(col)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((item, idx) => {
|
||||
const isSelected = selectedItem?.id === item.id;
|
||||
return (
|
||||
<tr
|
||||
key={item.id || idx}
|
||||
onClick={() => handleSelect(item)}
|
||||
className={cn(
|
||||
"cursor-pointer border-t transition-colors",
|
||||
isSelected ? "bg-blue-50" : "hover:bg-gray-100",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<td className="p-2 text-center">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex h-4 w-4 items-center justify-center rounded-full border",
|
||||
isSelected ? "border-blue-600 bg-blue-600" : "border-gray-300",
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="h-3 w-3 text-white" />}
|
||||
</div>
|
||||
</td>
|
||||
{displayColumns.map((col) => (
|
||||
<td key={col} className="p-2">
|
||||
{item[col] ?? "-"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필수 선택 안내 */}
|
||||
{!isInputEnabled && selectedItem && config.selection.requiredFields.length > 0 && (
|
||||
<p className="mt-1 text-[10px] text-amber-600">
|
||||
{config.selection.requiredFields.map((f) => getColumnLabel(f)).join(", ")}을(를) 선택해주세요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 모달 모드 렌더링
|
||||
if (config.ui?.expandMode === "modal") {
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
{/* 재고 조회 버튼 및 요약 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsExpanded(true);
|
||||
refetch(); // 모달 열 때 데이터 재조회
|
||||
}}
|
||||
disabled={disabled || isLoading}
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-3 w-3" />
|
||||
)}
|
||||
<Package className="h-3 w-3" />
|
||||
<span>재고 조회</span>
|
||||
{data.length > 0 && <span className="text-muted-foreground">({data.length})</span>}
|
||||
</Button>
|
||||
|
||||
{/* 선택 요약 표시 */}
|
||||
{selectedItem && summaryText && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<Check className="h-3 w-3 text-green-600" />
|
||||
<span className="text-green-700">{summaryText}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필수 선택 안내 */}
|
||||
{!isInputEnabled && selectedItem && config.selection.requiredFields.length > 0 && (
|
||||
<p className="mt-1 text-[10px] text-amber-600">
|
||||
{config.selection.requiredFields.map((f) => getColumnLabel(f)).join(", ")}을(를) 선택해주세요
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 모달 */}
|
||||
<Dialog open={isExpanded} onOpenChange={setIsExpanded}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">재고 현황</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
출고할 재고를 선택하세요. 창고/위치별 재고 수량을 확인할 수 있습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
className="rounded-md border"
|
||||
style={{ maxHeight: config.ui?.maxHeight || "300px", overflowY: "auto" }}
|
||||
>
|
||||
{/* 에러 상태 */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 text-xs text-red-600">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>{error}</span>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={refetch} className="ml-auto h-6 text-xs">
|
||||
재시도
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center gap-2 p-8 text-sm text-gray-500">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>재고 조회 중...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 없음 */}
|
||||
{!isLoading && !error && data.length === 0 && (
|
||||
<div className="p-8 text-center text-sm text-gray-500">
|
||||
{config.ui?.emptyMessage || "해당 품목의 재고가 없습니다"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
{!isLoading && !error && data.length > 0 && (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-gray-100">
|
||||
<tr>
|
||||
<th className="w-12 p-3 text-center">선택</th>
|
||||
{displayColumns.map((col) => (
|
||||
<th key={col} className="p-3 text-left font-medium">
|
||||
{getColumnLabel(col)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((item, idx) => {
|
||||
const isSelected = selectedItem?.id === item.id;
|
||||
return (
|
||||
<tr
|
||||
key={item.id || idx}
|
||||
onClick={() => handleSelect(item)}
|
||||
className={cn(
|
||||
"cursor-pointer border-t transition-colors",
|
||||
isSelected ? "bg-blue-50" : "hover:bg-gray-50",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<td className="p-3 text-center">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex h-5 w-5 items-center justify-center rounded-full border-2",
|
||||
isSelected ? "border-blue-600 bg-blue-600" : "border-gray-300",
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="h-3 w-3 text-white" />}
|
||||
</div>
|
||||
</td>
|
||||
{displayColumns.map((col) => (
|
||||
<td key={col} className="p-3">
|
||||
{item[col] ?? "-"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsExpanded(false)}
|
||||
disabled={!selectedItem}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
선택 완료
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 기본값: inline 모드로 폴백 (설정이 없거나 알 수 없는 모드인 경우)
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const willExpand = !isExpanded;
|
||||
setIsExpanded(willExpand);
|
||||
if (willExpand) {
|
||||
refetch(); // 펼칠 때 데이터 재조회
|
||||
}
|
||||
}}
|
||||
disabled={disabled || isLoading}
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : isExpanded ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
<Package className="h-3 w-3" />
|
||||
<span>재고 조회</span>
|
||||
{data.length > 0 && <span className="text-muted-foreground">({data.length})</span>}
|
||||
</Button>
|
||||
{selectedItem && summaryText && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<Check className="h-3 w-3 text-green-600" />
|
||||
<span className="text-green-700">{summaryText}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SubDataLookupPanel.displayName = "SubDataLookupPanel";
|
||||
Reference in New Issue
Block a user