feat(WEB): 수주관리 Phase 3 완료 - 고급 기능 구현

- 3.1 견적→수주 변환: QuotationSelectDialog API 연동 + createOrderFromQuote()
- 3.2 생산지시 생성 연동: createProductionOrder() + production-order 페이지 개선
- 3.3 상태 흐름 관리: 수주확정 다이얼로그 + updateOrderStatus() 연동

주요 변경:
- [id]/page.tsx: 수주확정 버튼/다이얼로그 추가 (DRAFT→CONFIRMED 상태 전환)
- [id]/production-order/page.tsx: API 연동으로 실제 생산지시 생성
- actions.ts: createProductionOrder(), createOrderFromQuote(), getQuotesForSelect() 추가
- QuotationSelectDialog.tsx: Mock→API 연동 (확정된 견적 조회)
- OrderRegistration.tsx: 견적 연동 처리

수주관리 API 연동 100% 완료 (Phase 1-3)
This commit is contained in:
2026-01-09 10:25:22 +09:00
parent 2d7809b4e0
commit c651e7bc72
5 changed files with 461 additions and 199 deletions

View File

@@ -4,9 +4,10 @@
* 견적 선택 팝업
*
* 확정된 견적 목록에서 수주 전환할 견적을 선택하는 다이얼로그
* API 연동: getQuotesForSelect (FINALIZED 상태 견적만 조회)
*/
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
@@ -15,37 +16,10 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Search, FileText, Check } from "lucide-react";
import { Search, FileText, Check, Loader2 } from "lucide-react";
import { formatAmount } from "@/utils/formatAmount";
import { cn } from "@/lib/utils";
// 견적 타입
export interface QuotationForSelect {
id: string;
quoteNumber: string; // KD-PR-XXXXXX-XX
grade: string; // A(우량), B(관리), C(주의)
client: string; // 발주처
siteName: string; // 현장명
amount: number; // 총 금액
itemCount: number; // 품목 수
registrationDate: string; // 견적일
manager?: string; // 담당자
contact?: string; // 연락처
items?: QuotationItem[]; // 품목 내역
}
export interface QuotationItem {
id: string;
itemCode: string;
itemName: string;
type: string; // 종
symbol: string; // 부호
spec: string; // 규격
quantity: number;
unit: string;
unitPrice: number;
amount: number;
}
import { getQuotesForSelect, type QuotationForSelect } from "./actions";
interface QuotationSelectDialogProps {
open: boolean;
@@ -54,81 +28,6 @@ interface QuotationSelectDialogProps {
selectedId?: string;
}
// 샘플 견적 데이터 (실제 구현에서는 API 연동)
const SAMPLE_QUOTATIONS: QuotationForSelect[] = [
{
id: "QT-001",
quoteNumber: "KD-PR-251210-01",
grade: "A",
client: "태영건설(주)",
siteName: "데시앙 동탄 파크뷰",
amount: 38800000,
itemCount: 5,
registrationDate: "2024-12-10",
manager: "김철수",
contact: "010-1234-5678",
items: [
{ id: "1", itemCode: "PRD-001", itemName: "국민방화스크린세터", type: "B1", symbol: "FSS1", spec: "7260×2600", quantity: 2, unit: "EA", unitPrice: 8000000, amount: 16000000 },
{ id: "2", itemCode: "PRD-002", itemName: "국민방화스크린세터", type: "B1", symbol: "FSS2", spec: "5000×2400", quantity: 3, unit: "EA", unitPrice: 7600000, amount: 22800000 },
],
},
{
id: "QT-002",
quoteNumber: "KD-PR-251211-02",
grade: "A",
client: "현대건설(주)",
siteName: "힐스테이트 판교역",
amount: 52500000,
itemCount: 8,
registrationDate: "2024-12-11",
manager: "이영희",
contact: "010-2345-6789",
items: [
{ id: "1", itemCode: "PRD-003", itemName: "국민방화스크린세터", type: "B2", symbol: "FSS1", spec: "6000×3000", quantity: 4, unit: "EA", unitPrice: 9500000, amount: 38000000 },
{ id: "2", itemCode: "PRD-004", itemName: "국민방화스크린세터", type: "B1", symbol: "FSS2", spec: "4500×2500", quantity: 2, unit: "EA", unitPrice: 7250000, amount: 14500000 },
],
},
{
id: "QT-003",
quoteNumber: "KD-PR-251208-03",
grade: "B",
client: "GS건설(주)",
siteName: "자이 강남센터",
amount: 45000000,
itemCount: 6,
registrationDate: "2024-12-08",
manager: "박민수",
contact: "010-3456-7890",
items: [],
},
{
id: "QT-004",
quoteNumber: "KD-PR-251205-04",
grade: "B",
client: "대우건설(주)",
siteName: "푸르지오 송도",
amount: 28900000,
itemCount: 4,
registrationDate: "2024-12-05",
manager: "최지원",
contact: "010-4567-8901",
items: [],
},
{
id: "QT-005",
quoteNumber: "KD-PR-251201-05",
grade: "A",
client: "포스코건설",
siteName: "더샵 분당센트럴",
amount: 62000000,
itemCount: 10,
registrationDate: "2024-12-01",
manager: "정수민",
contact: "010-5678-9012",
items: [],
},
];
// 등급 배지 컴포넌트
function GradeBadge({ grade }: { grade: string }) {
const config: Record<string, { label: string; className: string }> = {
@@ -151,25 +50,48 @@ export function QuotationSelectDialog({
selectedId,
}: QuotationSelectDialogProps) {
const [searchTerm, setSearchTerm] = useState("");
const [quotations] = useState<QuotationForSelect[]>(SAMPLE_QUOTATIONS);
const [quotations, setQuotations] = useState<QuotationForSelect[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 검색 필터링
const filteredQuotations = quotations.filter((q) => {
const searchLower = searchTerm.toLowerCase();
return (
!searchTerm ||
q.quoteNumber.toLowerCase().includes(searchLower) ||
q.client.toLowerCase().includes(searchLower) ||
q.siteName.toLowerCase().includes(searchLower)
);
});
// 견적 목록 조회
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);
}
}, []);
// 다이얼로그 열릴 때 검색어 초기화
// 다이얼로그 열릴 때 데이터 로드
useEffect(() => {
if (open) {
setSearchTerm("");
fetchQuotations();
}
}, [open]);
}, [open, fetchQuotations]);
// 검색어 변경 시 디바운스 적용하여 API 호출
useEffect(() => {
if (!open) return;
const timer = setTimeout(() => {
fetchQuotations(searchTerm || undefined);
}, 300);
return () => clearTimeout(timer);
}, [searchTerm, open, fetchQuotations]);
const handleSelect = (quotation: QuotationForSelect) => {
onSelect(quotation);
@@ -199,60 +121,77 @@ export function QuotationSelectDialog({
{/* 안내 문구 */}
<div className="text-sm text-muted-foreground">
{filteredQuotations.length} ( )
{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}건 (최종확정 상태)`
)}
</div>
{/* 견적 목록 */}
<div className="flex-1 overflow-y-auto space-y-3 pr-2">
{filteredQuotations.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} />
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</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>
{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>
))}
{filteredQuotations.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
.
</div>
{quotations.length === 0 && !error && (
<div className="text-center py-8 text-muted-foreground">
.
</div>
)}
</>
)}
</div>
</DialogContent>