ERP-node/frontend/lib/registry/components/repeater-field-group/SubDataLookupPanel.tsx

423 lines
15 KiB
TypeScript
Raw Normal View History

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