Merge remote-tracking branch 'origin/master'

This commit is contained in:
2026-02-10 16:01:57 +09:00
42 changed files with 1683 additions and 1144 deletions

View File

@@ -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} ( &amp; )
</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} ( &amp; )</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}
/>
);
}
}