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";
|