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:
@@ -31,6 +31,7 @@ import {
|
||||
FileCheck,
|
||||
ClipboardList,
|
||||
Eye,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
@@ -102,6 +103,8 @@ export default function OrderDetailPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
|
||||
// 취소 폼 상태
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
@@ -185,6 +188,32 @@ export default function OrderDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 수주 확정 처리
|
||||
const handleConfirmOrder = () => {
|
||||
setIsConfirmDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmOrderSubmit = async () => {
|
||||
if (order) {
|
||||
setIsConfirming(true);
|
||||
try {
|
||||
const result = await updateOrderStatus(order.id, "order_confirmed");
|
||||
if (result.success && result.data) {
|
||||
setOrder(result.data);
|
||||
toast.success("수주가 확정되었습니다.");
|
||||
setIsConfirmDialogOpen(false);
|
||||
} else {
|
||||
toast.error(result.error || "수주 확정에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error confirming order:", error);
|
||||
toast.error("수주 확정 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsConfirming(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 문서 모달 열기
|
||||
const openDocumentModal = (type: OrderDocumentType) => {
|
||||
setDocumentType(type);
|
||||
@@ -217,6 +246,8 @@ export default function OrderDetailPage() {
|
||||
|
||||
// 상태별 버튼 표시 여부
|
||||
const showEditButton = order.status !== "shipped" && order.status !== "cancelled";
|
||||
// 수주 확정 버튼: 수주등록 상태에서만 표시
|
||||
const showConfirmButton = order.status === "order_registered";
|
||||
// 생산지시 생성 버튼: 출하완료, 취소, 생산지시완료 제외하고 표시
|
||||
// (수주등록, 수주확정, 생산중, 재작업중, 작업완료에서 표시)
|
||||
const showProductionCreateButton =
|
||||
@@ -248,6 +279,12 @@ export default function OrderDetailPage() {
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
{showConfirmButton && (
|
||||
<Button onClick={handleConfirmOrder} className="bg-green-600 hover:bg-green-700">
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
수주 확정
|
||||
</Button>
|
||||
)}
|
||||
{showProductionCreateButton && (
|
||||
<Button onClick={handleProductionOrder}>
|
||||
<Factory className="h-4 w-4 mr-2" />
|
||||
@@ -553,6 +590,73 @@ export default function OrderDetailPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 수주 확정 다이얼로그 */}
|
||||
<Dialog open={isConfirmDialogOpen} onOpenChange={setIsConfirmDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
수주 확정
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 수주 정보 박스 */}
|
||||
<div className="border rounded-lg p-4 space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">수주번호</span>
|
||||
<span className="font-medium">{order.lotNumber}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">발주처</span>
|
||||
<span>{order.client}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">현장명</span>
|
||||
<span>{order.siteName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">총금액</span>
|
||||
<span className="font-medium text-green-600">
|
||||
{formatAmount(order.totalAmount)}원
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">현재 상태</span>
|
||||
{getOrderStatusBadge(order.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 확정 안내 */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-sm space-y-1">
|
||||
<p className="font-medium mb-2 text-green-700">확정 후 변경사항</p>
|
||||
<ul className="space-y-1 text-green-600">
|
||||
<li>• 수주 상태가 '수주확정'으로 변경됩니다</li>
|
||||
<li>• 생산지시를 생성할 수 있습니다</li>
|
||||
<li>• 확정 후에도 수정이 가능합니다</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsConfirmDialogOpen(false)}
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmOrderSubmit}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
disabled={isConfirming}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 mr-1" />
|
||||
{isConfirming ? "확정 중..." : "확정"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -10,9 +10,11 @@
|
||||
* - 스크린 품목 상세
|
||||
* - 모터/전장품 사양 (읽기전용)
|
||||
* - 절곡물 BOM
|
||||
*
|
||||
* API 연동: getOrderById, createProductionOrder
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -25,7 +27,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Factory, ArrowLeft, BarChart3, CheckCircle2 } from "lucide-react";
|
||||
import { Factory, ArrowLeft, BarChart3, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -39,6 +41,13 @@ import {
|
||||
import { PageHeader } from "@/components/organisms/PageHeader";
|
||||
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getOrderById,
|
||||
createProductionOrder,
|
||||
type Order,
|
||||
type CreateProductionOrderData,
|
||||
} from "@/components/orders/actions";
|
||||
import { formatAmount } from "@/utils/formatAmount";
|
||||
|
||||
// 수주 정보 타입
|
||||
interface OrderInfo {
|
||||
@@ -187,16 +196,16 @@ const PRIORITY_CONFIGS: PriorityConfig[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// 샘플 수주 정보
|
||||
const SAMPLE_ORDER_INFO: OrderInfo = {
|
||||
orderNumber: "KD-TS-251217-09",
|
||||
client: "태영건설(주)",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
dueDate: "2026-02-25",
|
||||
itemCount: 3,
|
||||
totalQuantity: "3EA",
|
||||
creditGrade: "A (우량)",
|
||||
status: "재작업중",
|
||||
// 상태 레이블
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
order_registered: "수주등록",
|
||||
order_confirmed: "수주확정",
|
||||
production_ordered: "생산지시",
|
||||
in_production: "생산중",
|
||||
rework: "재작업중",
|
||||
work_completed: "작업완료",
|
||||
shipped: "출고완료",
|
||||
cancelled: "취소",
|
||||
};
|
||||
|
||||
// 샘플 작업지시 카드
|
||||
@@ -337,7 +346,8 @@ export default function ProductionOrderCreatePage() {
|
||||
const orderId = params.id as string;
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [orderInfo, setOrderInfo] = useState<OrderInfo | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 우선순위 상태
|
||||
@@ -347,36 +357,60 @@ export default function ProductionOrderCreatePage() {
|
||||
// 성공 다이얼로그 상태
|
||||
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
|
||||
const [generatedOrderNumber, setGeneratedOrderNumber] = useState("");
|
||||
const [generatedWorkOrderId, setGeneratedWorkOrderId] = useState<string | null>(null);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setOrderInfo(SAMPLE_ORDER_INFO);
|
||||
// 수주 데이터 로드
|
||||
const fetchOrder = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getOrderById(orderId);
|
||||
if (result.success && result.data) {
|
||||
setOrder(result.data);
|
||||
} else {
|
||||
setError(result.error || "수주 정보 조회에 실패했습니다.");
|
||||
}
|
||||
} catch {
|
||||
setError("서버 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
}
|
||||
}, [orderId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrder();
|
||||
}, [fetchOrder]);
|
||||
|
||||
const handleCancel = () => {
|
||||
router.push(`/sales/order-management-sales/${orderId}`);
|
||||
router.push(`/ko/sales/order-management-sales/${orderId}`);
|
||||
};
|
||||
|
||||
const handleBackToDetail = () => {
|
||||
router.push(`/sales/order-management-sales/${orderId}`);
|
||||
router.push(`/ko/sales/order-management-sales/${orderId}`);
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!order) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
// TODO: API 호출
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const productionData: CreateProductionOrderData = {
|
||||
priority: selectedPriority,
|
||||
memo: memo || undefined,
|
||||
};
|
||||
|
||||
// 생산지시번호 생성 (실제로는 API 응답에서 받아옴)
|
||||
const today = new Date();
|
||||
const dateStr = `${String(today.getFullYear()).slice(2)}${String(today.getMonth() + 1).padStart(2, "0")}${String(today.getDate()).padStart(2, "0")}`;
|
||||
const newOrderNumber = `PO-${orderInfo?.orderNumber.replace("KD-TS-", "KD-") || "KD-000000"}-${dateStr}`;
|
||||
const result = await createProductionOrder(orderId, productionData);
|
||||
|
||||
setGeneratedOrderNumber(newOrderNumber);
|
||||
setShowSuccessDialog(true);
|
||||
if (result.success && result.data) {
|
||||
setGeneratedOrderNumber(result.data.workOrder.workOrderNo);
|
||||
setGeneratedWorkOrderId(result.data.workOrder.id);
|
||||
setShowSuccessDialog(true);
|
||||
} else {
|
||||
setError(result.error || "생산지시 생성에 실패했습니다.");
|
||||
}
|
||||
} catch {
|
||||
setError("서버 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -384,9 +418,8 @@ export default function ProductionOrderCreatePage() {
|
||||
|
||||
const handleSuccessDialogClose = () => {
|
||||
setShowSuccessDialog(false);
|
||||
// 생산지시 상세 페이지로 이동 (실제로는 API 응답에서 받은 생산지시 ID 사용)
|
||||
// 임시로 PO-002 사용 (샘플 데이터와 매칭)
|
||||
router.push("/sales/order-management-sales/production-orders/PO-002");
|
||||
// 수주 상세 페이지로 이동 (상태가 변경되었으므로)
|
||||
router.push(`/ko/sales/order-management-sales/${orderId}`);
|
||||
};
|
||||
|
||||
// 선택된 우선순위 설정 가져오기
|
||||
@@ -404,7 +437,22 @@ export default function ProductionOrderCreatePage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!orderInfo) {
|
||||
if (error && !order) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="text-center py-12">
|
||||
<AlertCircle className="h-12 w-12 mx-auto mb-4 text-red-500" />
|
||||
<p className="text-muted-foreground">{error}</p>
|
||||
<Button variant="outline" onClick={handleCancel} className="mt-4">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
돌아가기
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="text-center py-12">
|
||||
@@ -421,6 +469,19 @@ export default function ProductionOrderCreatePage() {
|
||||
const selectedConfig = getSelectedPriorityConfig();
|
||||
const workOrderCount = SAMPLE_WORK_ORDER_CARDS.length;
|
||||
|
||||
// Order에서 UI에 표시할 데이터 변환
|
||||
const orderInfo = {
|
||||
orderNumber: order.lotNumber,
|
||||
client: order.client,
|
||||
siteName: order.siteName,
|
||||
dueDate: order.expectedShipDate || "-",
|
||||
itemCount: order.itemCount,
|
||||
totalQuantity: `${order.itemCount}EA`,
|
||||
creditGrade: "B (관리)", // API에서 제공하지 않아 기본값 사용
|
||||
status: STATUS_LABELS[order.status] || order.status,
|
||||
amount: order.amount,
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
|
||||
@@ -49,7 +49,8 @@ import {
|
||||
ResponsiveFormTemplate,
|
||||
FormSection,
|
||||
} from "@/components/templates/ResponsiveFormTemplate";
|
||||
import { QuotationSelectDialog, QuotationForSelect, QuotationItem } from "./QuotationSelectDialog";
|
||||
import { QuotationSelectDialog } from "./QuotationSelectDialog";
|
||||
import { type QuotationForSelect, type QuotationItem } from "./actions";
|
||||
import { ItemAddDialog, OrderItem } from "./ItemAddDialog";
|
||||
import { formatAmount } from "@/utils/formatAmount";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -70,6 +70,47 @@ interface ApiQuote {
|
||||
site_name: string | null;
|
||||
}
|
||||
|
||||
// 견적 목록 조회용 상세 타입
|
||||
interface ApiQuoteForSelect {
|
||||
id: number;
|
||||
quote_number: string;
|
||||
registration_date: string;
|
||||
status: string;
|
||||
client_id: number | null;
|
||||
client_name: string | null;
|
||||
site_name: string | null;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
item_count?: number;
|
||||
author?: string | null;
|
||||
manager?: string | null;
|
||||
contact?: string | null;
|
||||
client?: {
|
||||
id: number;
|
||||
name: string;
|
||||
grade?: string;
|
||||
representative?: string;
|
||||
phone?: string;
|
||||
} | null;
|
||||
items?: ApiQuoteItem[];
|
||||
}
|
||||
|
||||
interface ApiQuoteItem {
|
||||
id: number;
|
||||
item_code?: string;
|
||||
item_name: string;
|
||||
type_code?: string;
|
||||
symbol?: string;
|
||||
specification?: string;
|
||||
quantity: number;
|
||||
unit?: string;
|
||||
unit_price: number;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
}
|
||||
|
||||
interface ApiWorkOrder {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
@@ -249,6 +290,7 @@ export interface CreateFromQuoteData {
|
||||
// 생산지시 생성용
|
||||
export interface CreateProductionOrderData {
|
||||
processType?: 'screen' | 'slat' | 'bending';
|
||||
priority?: 'urgent' | 'high' | 'normal' | 'low';
|
||||
assigneeId?: number;
|
||||
teamId?: number;
|
||||
scheduledDate?: string;
|
||||
@@ -280,6 +322,34 @@ export interface ProductionOrderResult {
|
||||
order: Order;
|
||||
}
|
||||
|
||||
// 견적 선택용 타입 (QuotationSelectDialog용)
|
||||
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;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 상태 매핑
|
||||
// ============================================================================
|
||||
@@ -415,6 +485,37 @@ function transformWorkOrderApiToFrontend(apiData: ApiWorkOrder): WorkOrder {
|
||||
};
|
||||
}
|
||||
|
||||
function transformQuoteForSelect(apiData: ApiQuoteForSelect): QuotationForSelect {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
quoteNumber: apiData.quote_number,
|
||||
grade: apiData.client?.grade || 'B', // 기본값 B(관리)
|
||||
client: apiData.client_name || apiData.client?.name || '',
|
||||
siteName: apiData.site_name || '',
|
||||
amount: apiData.total_amount,
|
||||
itemCount: apiData.item_count || apiData.items?.length || 0,
|
||||
registrationDate: apiData.registration_date,
|
||||
manager: apiData.manager ?? undefined,
|
||||
contact: apiData.contact ?? apiData.client?.phone ?? undefined,
|
||||
items: apiData.items?.map(transformQuoteItemForSelect),
|
||||
};
|
||||
}
|
||||
|
||||
function transformQuoteItemForSelect(apiItem: ApiQuoteItem): QuotationItem {
|
||||
return {
|
||||
id: String(apiItem.id),
|
||||
itemCode: apiItem.item_code || '',
|
||||
itemName: apiItem.item_name,
|
||||
type: apiItem.type_code || '',
|
||||
symbol: apiItem.symbol || '',
|
||||
spec: apiItem.specification || '',
|
||||
quantity: apiItem.quantity,
|
||||
unit: apiItem.unit || 'EA',
|
||||
unitPrice: apiItem.unit_price,
|
||||
amount: apiItem.total_amount,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API 함수
|
||||
// ============================================================================
|
||||
@@ -815,6 +916,7 @@ export async function createProductionOrder(
|
||||
try {
|
||||
const apiData: Record<string, unknown> = {};
|
||||
if (data?.processType) apiData.process_type = data.processType;
|
||||
if (data?.priority) apiData.priority = data.priority;
|
||||
if (data?.assigneeId) apiData.assignee_id = data.assigneeId;
|
||||
if (data?.teamId) apiData.team_id = data.teamId;
|
||||
if (data?.scheduledDate) apiData.scheduled_date = data.scheduledDate;
|
||||
@@ -851,3 +953,58 @@ export async function createProductionOrder(
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 변환용 확정 견적 목록 조회
|
||||
* QuotationSelectDialog에서 사용
|
||||
*/
|
||||
export async function getQuotesForSelect(params?: {
|
||||
q?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: { items: QuotationForSelect[]; total: number };
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
// 확정(FINALIZED) 상태의 견적만 조회
|
||||
searchParams.set('status', 'FINALIZED');
|
||||
if (params?.q) searchParams.set('q', params.q);
|
||||
if (params?.page) searchParams.set('page', String(params.page));
|
||||
if (params?.size) searchParams.set('size', String(params.size || 50));
|
||||
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes?${searchParams.toString()}`,
|
||||
{ method: 'GET', cache: 'no-store' }
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '견적 목록 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result: ApiResponse<PaginatedResponse<ApiQuoteForSelect>> = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '견적 목록 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: result.data.data.map(transformQuoteForSelect),
|
||||
total: result.data.total,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[getQuotesForSelect] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user