refactor(WEB): SearchableSelectionModal 공통화 및 actions lookup 통합
- SearchableSelectionModal<T> 제네릭 컴포넌트 추출 (organisms) - 검색 모달 5개 리팩토링: SupplierSearch, QuotationSelect, SalesOrderSelect, OrderSelect, ItemSearch - shared-lookups API 유틸 추가 (거래처/품목/수주 등 공통 조회) - create-crud-service 확장 (lookup, search 메서드) - actions.ts 20+개 파일 lookup 패턴 통일 - 공통 페이지 패턴 가이드 문서 추가 - CLAUDE.md Common Component Usage Rules 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,25 +1,19 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 견적 선택 팝업
|
||||
*
|
||||
* 확정된 견적 목록에서 수주 전환할 견적을 선택하는 다이얼로그
|
||||
* API 연동: getQuotesForSelect (FINALIZED 상태 견적만 조회)
|
||||
* SearchableSelectionModal 공통 컴포넌트 기반
|
||||
* 확정된 견적 목록에서 수주 전환할 견적을 선택
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Search, FileText, Check, Loader2 } from "lucide-react";
|
||||
import { formatAmount } from "@/utils/formatAmount";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getQuotesForSelect, type QuotationForSelect } from "./actions";
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { FileText, Check } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { formatAmount } from '@/utils/formatAmount';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal';
|
||||
import { getQuotesForSelect, type QuotationForSelect } from './actions';
|
||||
|
||||
interface QuotationSelectDialogProps {
|
||||
open: boolean;
|
||||
@@ -31,13 +25,13 @@ interface QuotationSelectDialogProps {
|
||||
// 등급 배지 컴포넌트
|
||||
function GradeBadge({ grade }: { grade: string }) {
|
||||
const config: Record<string, { label: string; className: string }> = {
|
||||
A: { label: "A (우량)", className: "bg-green-100 text-green-700 border-green-200" },
|
||||
B: { label: "B (관리)", className: "bg-yellow-100 text-yellow-700 border-yellow-200" },
|
||||
C: { label: "C (주의)", className: "bg-red-100 text-red-700 border-red-200" },
|
||||
A: { label: 'A (우량)', className: 'bg-green-100 text-green-700 border-green-200' },
|
||||
B: { label: 'B (관리)', className: 'bg-yellow-100 text-yellow-700 border-yellow-200' },
|
||||
C: { label: 'C (주의)', className: 'bg-red-100 text-red-700 border-red-200' },
|
||||
};
|
||||
const cfg = config[grade] || config.B;
|
||||
return (
|
||||
<Badge variant="outline" className={cn("text-xs", cfg.className)}>
|
||||
<Badge variant="outline" className={cn('text-xs', cfg.className)}>
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
);
|
||||
@@ -49,148 +43,71 @@ export function QuotationSelectDialog({
|
||||
onSelect,
|
||||
selectedId,
|
||||
}: QuotationSelectDialogProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [quotations, setQuotations] = useState<QuotationForSelect[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 견적 목록 조회
|
||||
const fetchQuotations = useCallback(async (query?: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getQuotesForSelect({ q: query, size: 50 });
|
||||
if (result.success && result.data) {
|
||||
setQuotations(result.data.items);
|
||||
} else {
|
||||
setError(result.error || "견적 목록 조회에 실패했습니다.");
|
||||
setQuotations([]);
|
||||
}
|
||||
} catch {
|
||||
setError("서버 오류가 발생했습니다.");
|
||||
setQuotations([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
const handleFetchData = useCallback(async (query: string) => {
|
||||
const result = await getQuotesForSelect({ q: query || undefined, size: 50 });
|
||||
if (result.success && result.data) {
|
||||
return result.data.items;
|
||||
}
|
||||
throw new Error(result.error || '견적 목록 조회에 실패했습니다.');
|
||||
}, []);
|
||||
|
||||
// 다이얼로그 열릴 때 데이터 로드 + 검색어 변경 시 디바운스 적용
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
// 검색어가 빈 문자열이면 즉시 호출 (다이얼로그 열림 시)
|
||||
// 검색어가 있으면 디바운스 적용
|
||||
const delay = searchTerm === "" ? 0 : 300;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
fetchQuotations(searchTerm || undefined);
|
||||
}, delay);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm, open, fetchQuotations]);
|
||||
|
||||
const handleSelect = (quotation: QuotationForSelect) => {
|
||||
onSelect(quotation);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
견적 선택
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색창 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="견적번호, 거래처, 현장명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
견적 목록을 불러오는 중...
|
||||
</span>
|
||||
) : error ? (
|
||||
<span className="text-red-500">{error}</span>
|
||||
) : (
|
||||
`전환 가능한 견적 ${quotations.length}건 (최종확정 상태)`
|
||||
<SearchableSelectionModal<QuotationForSelect>
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
견적 선택
|
||||
</span>
|
||||
}
|
||||
searchPlaceholder="견적번호, 거래처, 현장명 검색..."
|
||||
fetchData={handleFetchData}
|
||||
keyExtractor={(q) => q.id}
|
||||
loadOnOpen
|
||||
dialogClassName="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col"
|
||||
listContainerClassName="flex-1 overflow-y-auto space-y-3 pr-2"
|
||||
infoText={(items, isLoading) =>
|
||||
isLoading
|
||||
? null
|
||||
: `전환 가능한 견적 ${items.length}건 (최종확정 상태)`
|
||||
}
|
||||
mode="single"
|
||||
onSelect={onSelect}
|
||||
renderItem={(quotation) => (
|
||||
<div
|
||||
className={cn(
|
||||
'p-4 border rounded-lg hover:bg-muted/50 hover:border-primary/50 transition-colors',
|
||||
selectedId === quotation.id && 'border-primary bg-primary/5'
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 견적 목록 */}
|
||||
<div className="flex-1 overflow-y-auto space-y-3 pr-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm font-mono bg-gray-100 px-2 py-0.5 rounded">
|
||||
{quotation.quoteNumber}
|
||||
</code>
|
||||
<GradeBadge grade={quotation.grade} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{quotations.map((quotation) => (
|
||||
<div
|
||||
key={quotation.id}
|
||||
onClick={() => handleSelect(quotation)}
|
||||
className={cn(
|
||||
"p-4 border rounded-lg cursor-pointer transition-colors",
|
||||
"hover:bg-muted/50 hover:border-primary/50",
|
||||
selectedId === quotation.id && "border-primary bg-primary/5"
|
||||
)}
|
||||
>
|
||||
{/* 상단: 견적번호 + 등급 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm font-mono bg-gray-100 px-2 py-0.5 rounded">
|
||||
{quotation.quoteNumber}
|
||||
</code>
|
||||
<GradeBadge grade={quotation.grade} />
|
||||
</div>
|
||||
{selectedId === quotation.id && (
|
||||
<Check className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 발주처 */}
|
||||
<div className="font-medium text-base mb-1">
|
||||
{quotation.client}
|
||||
</div>
|
||||
|
||||
{/* 현장명 + 금액 */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
[{quotation.siteName}]
|
||||
</span>
|
||||
<span className="font-medium text-green-600">
|
||||
{formatAmount(quotation.amount)}원
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 품목 수 */}
|
||||
<div className="text-xs text-muted-foreground mt-1 text-right">
|
||||
{quotation.itemCount}개 품목
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{quotations.length === 0 && !error && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{selectedId === quotation.id && (
|
||||
<Check className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="font-medium text-base mb-1">
|
||||
{quotation.client}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
[{quotation.siteName}]
|
||||
</span>
|
||||
<span className="font-medium text-green-600">
|
||||
{formatAmount(quotation.amount)}원
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 text-right">
|
||||
{quotation.itemCount}개 품목
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use server';
|
||||
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
|
||||
// ============================================================================
|
||||
// API 타입 정의
|
||||
@@ -219,14 +220,6 @@ interface ApiResponse<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
current_page: number;
|
||||
data: T[];
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Frontend 타입 정의
|
||||
// ============================================================================
|
||||
@@ -815,7 +808,7 @@ export async function getOrders(params?: {
|
||||
if (params?.date_from) searchParams.set('date_from', params.date_from);
|
||||
if (params?.date_to) searchParams.set('date_to', params.date_to);
|
||||
|
||||
const result = await executeServerAction<PaginatedResponse<ApiOrder>>({
|
||||
const result = await executeServerAction<PaginatedApiResponse<ApiOrder>>({
|
||||
url: `${API_URL}/api/v1/orders?${searchParams.toString()}`,
|
||||
errorMessage: '목록 조회에 실패했습니다.',
|
||||
});
|
||||
@@ -1179,7 +1172,7 @@ export async function getQuotesForSelect(params?: {
|
||||
if (params?.page) searchParams.set('page', String(params.page));
|
||||
if (params?.size) searchParams.set('size', String(params.size || 50));
|
||||
|
||||
const result = await executeServerAction<PaginatedResponse<ApiQuoteForSelect>>({
|
||||
const result = await executeServerAction<PaginatedApiResponse<ApiQuoteForSelect>>({
|
||||
url: `${API_URL}/api/v1/quotes?${searchParams.toString()}`,
|
||||
errorMessage: '견적 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user