Repeater 컴포넌트에 하위 데이터 조회 기능 추가 (재고/단가 조회) 조건부 입력 활성화 및 최대값 제한 기능 구현 필드 정의 순서 변경 기능 추가 (드래그앤드롭, 화살표 버튼) TableListComponent의 DataProvider 클로저 문제 해결 ButtonPrimaryComponent에 modalDataStore fallback 로직 추가
423 lines
15 KiB
TypeScript
423 lines
15 KiB
TypeScript
"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";
|