Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -1,38 +1,19 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 수주 선택 모달
|
||||
* API 연동 완료 (2025-12-26)
|
||||
*
|
||||
* SearchableSelectionModal 공통 컴포넌트 기반
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Search, FileText } from 'lucide-react';
|
||||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal';
|
||||
import { getSalesOrdersForWorkOrder } from './actions';
|
||||
import type { SalesOrder } from './types';
|
||||
|
||||
// Debounce 훅
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
interface SalesOrderSelectModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -44,23 +25,13 @@ export function SalesOrderSelectModal({
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
}: SalesOrderSelectModalProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [salesOrders, setSalesOrders] = useState<SalesOrder[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 디바운스된 검색어 (300ms 딜레이)
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 300);
|
||||
|
||||
// API로 수주 목록 로드
|
||||
const loadSalesOrders = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
const handleFetchData = useCallback(async (query: string) => {
|
||||
try {
|
||||
const result = await getSalesOrdersForWorkOrder({
|
||||
q: debouncedSearchTerm || undefined,
|
||||
q: query || undefined,
|
||||
});
|
||||
if (result.success) {
|
||||
// API 응답을 SalesOrder 타입으로 변환
|
||||
const orders: SalesOrder[] = result.data.map((item) => ({
|
||||
return result.data.map((item) => ({
|
||||
id: String(item.id),
|
||||
orderNo: item.orderNo,
|
||||
client: item.client,
|
||||
@@ -70,94 +41,61 @@ export function SalesOrderSelectModal({
|
||||
itemCount: item.itemCount,
|
||||
splitCount: item.splitCount,
|
||||
}));
|
||||
setSalesOrders(orders);
|
||||
} else {
|
||||
toast.error(result.error || '수주 목록 조회에 실패했습니다.');
|
||||
}
|
||||
toast.error(result.error || '수주 목록 조회에 실패했습니다.');
|
||||
return [];
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[SalesOrderSelectModal] loadSalesOrders error:', error);
|
||||
toast.error('수주 목록 로드 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
return [];
|
||||
}
|
||||
}, [debouncedSearchTerm]);
|
||||
|
||||
// 모달이 열릴 때 데이터 로드
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadSalesOrders();
|
||||
}
|
||||
}, [open, loadSalesOrders]);
|
||||
|
||||
const handleSelect = (order: SalesOrder) => {
|
||||
onSelect(order);
|
||||
onOpenChange(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>수주 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="수주번호, 거래처, 현장명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
작업지시 가능한 수주 {salesOrders.length}건 (등록 상태 & 생산지시 미생성)
|
||||
</p>
|
||||
|
||||
{/* 수주 목록 */}
|
||||
<div className="max-h-[400px] overflow-y-auto space-y-2">
|
||||
{isLoading ? (
|
||||
<ContentSkeleton type="cards" rows={4} />
|
||||
) : salesOrders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
onClick={() => handleSelect(order)}
|
||||
className="p-4 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{order.orderNo}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{order.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-right">
|
||||
<span className="text-muted-foreground">납기: </span>
|
||||
<span>{order.dueDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mb-1">
|
||||
{order.client}
|
||||
</div>
|
||||
<div className="text-sm mb-2">{order.projectName}</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>{order.itemCount}개 품목</span>
|
||||
<span>분할 {order.splitCount}건</span>
|
||||
</div>
|
||||
<SearchableSelectionModal<SalesOrder>
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="수주 선택"
|
||||
searchPlaceholder="수주번호, 거래처, 현장명 검색..."
|
||||
fetchData={handleFetchData}
|
||||
keyExtractor={(order) => order.id}
|
||||
loadOnOpen
|
||||
dialogClassName="sm:max-w-lg"
|
||||
listContainerClassName="max-h-[400px] overflow-y-auto space-y-2"
|
||||
noResultMessage=""
|
||||
infoText={(items, isLoading) =>
|
||||
!isLoading ? (
|
||||
<span>작업지시 가능한 수주 {items.length}건 (등록 상태 & 생산지시 미생성)</span>
|
||||
) : null
|
||||
}
|
||||
mode="single"
|
||||
onSelect={onSelect}
|
||||
renderItem={(order) => (
|
||||
<div className="p-4 border rounded-lg hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{order.orderNo}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{order.status}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
{!isLoading && salesOrders.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<FileText className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p>검색 결과가 없습니다.</p>
|
||||
<div className="text-sm text-right">
|
||||
<span className="text-muted-foreground">납기: </span>
|
||||
<span>{order.dueDate}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mb-1">
|
||||
{order.client}
|
||||
</div>
|
||||
<div className="text-sm mb-2">{order.projectName}</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>{order.itemCount}개 품목</span>
|
||||
<span>분할 {order.splitCount}건</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
listWrapper={undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user