feat(WEB): 발주처 검색 모달 추가 및 견적 할인 기능 개선
- SupplierSearchModal: 매입 가능 거래처 검색 모달 신규 생성 - QuoteRegistrationV2: 할인율/할인금액을 formData로 통합하여 저장/로드 연동 - QuoteFooterBar: view 모드에서 할인 버튼 비활성화 - types.ts: discountRate/discountAmount 필드 추가, 할인 반영 총액 계산 수정 - quote-management page: 저장 실패 시 에러 메시지 정확히 표시하도록 throw 방식 변경
This commit is contained in:
@@ -142,8 +142,7 @@ export default function QuoteDetailPage() {
|
||||
const updateResult = await updateQuote(quoteId, apiData);
|
||||
|
||||
if (!updateResult.success) {
|
||||
toast.error(updateResult.error || "저장 중 오류가 발생했습니다.");
|
||||
return;
|
||||
throw new Error(updateResult.error || "저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
// 2. 견적 확정인 경우 finalize API 호출 (status 변경은 여기서 처리)
|
||||
@@ -151,8 +150,7 @@ export default function QuoteDetailPage() {
|
||||
const finalizeResult = await finalizeQuote(quoteId);
|
||||
|
||||
if (!finalizeResult.success) {
|
||||
toast.error(finalizeResult.error || "견적 확정 중 오류가 발생했습니다.");
|
||||
return;
|
||||
throw new Error(finalizeResult.error || "견적 확정 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
toast.success("견적이 확정되었습니다.");
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* 발주처(매입 거래처) 검색 모달
|
||||
*
|
||||
* - 거래처명으로 검색
|
||||
* - 매입 가능 거래처만 표시 (client_type: PURCHASE, BOTH)
|
||||
* - ItemSearchModal과 동일한 Dialog + 클라이언트 프록시 패턴
|
||||
* - 최소 입력 조건: 한글 완성형 1자 또는 영문 2자 이상
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Search, X, Loader2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
// =============================================================================
|
||||
// 타입
|
||||
// =============================================================================
|
||||
|
||||
interface SupplierItem {
|
||||
id: number | string;
|
||||
name: string;
|
||||
clientCode?: string;
|
||||
clientType?: string;
|
||||
contactPerson?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
interface SupplierSearchModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelectSupplier: (supplier: { name: string; code?: string }) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API 응답 변환
|
||||
// =============================================================================
|
||||
|
||||
interface ApiClientResponse {
|
||||
id?: number | string;
|
||||
name?: string;
|
||||
client_code?: string;
|
||||
client_type?: string;
|
||||
contact_person?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
function transformClientFromApi(apiClient: ApiClientResponse): SupplierItem {
|
||||
return {
|
||||
id: String(apiClient.id || ''),
|
||||
name: apiClient.name || '',
|
||||
clientCode: apiClient.client_code || '',
|
||||
clientType: apiClient.client_type || '',
|
||||
contactPerson: apiClient.contact_person || '',
|
||||
phone: apiClient.phone || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 매입 가능 거래처 조회 (클라이언트 프록시 경유)
|
||||
* client_type: PURCHASE 또는 BOTH
|
||||
*/
|
||||
async function fetchPurchaseClients(search?: string): Promise<SupplierItem[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('q', search);
|
||||
params.set('size', '50');
|
||||
// 매입 가능 거래처만 (PURCHASE, BOTH)
|
||||
params.set('client_type', 'PURCHASE,BOTH');
|
||||
|
||||
const url = `/api/proxy/clients?${params.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`거래처 조회 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
let rawItems: ApiClientResponse[] = [];
|
||||
|
||||
if (result.success && result.data) {
|
||||
if (result.data.data && Array.isArray(result.data.data)) {
|
||||
rawItems = result.data.data;
|
||||
} else if (Array.isArray(result.data)) {
|
||||
rawItems = result.data;
|
||||
}
|
||||
} else if (Array.isArray(result)) {
|
||||
rawItems = result;
|
||||
}
|
||||
|
||||
return rawItems.map(transformClientFromApi);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 유효성 검사
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* 검색어 유효성: 한글 완성형 1자 이상 또는 영문 2자 이상
|
||||
*/
|
||||
function isValidSearchQuery(query: string): boolean {
|
||||
if (!query || !query.trim()) return false;
|
||||
const trimmed = query.trim();
|
||||
if (/[가-힣]/.test(trimmed)) return true;
|
||||
const englishChars = trimmed.replace(/[^a-zA-Z]/g, '');
|
||||
if (englishChars.length >= 2) return true;
|
||||
const alphanumeric = trimmed.replace(/[^a-zA-Z0-9]/g, '');
|
||||
if (alphanumeric.length >= 2) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 컴포넌트
|
||||
// =============================================================================
|
||||
|
||||
export function SupplierSearchModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelectSupplier,
|
||||
}: SupplierSearchModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [suppliers, setSuppliers] = useState<SupplierItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 거래처 목록 조회
|
||||
const loadSuppliers = useCallback(async (search?: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchPurchaseClients(search);
|
||||
setSuppliers(data);
|
||||
} catch (err) {
|
||||
console.error('[SupplierSearchModal] 거래처 조회 오류:', err);
|
||||
setError('거래처 목록을 불러오는데 실패했습니다.');
|
||||
setSuppliers([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 모달 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSuppliers([]);
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 검색어 변경 시 디바운스 검색
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (!isValidSearchQuery(searchQuery)) {
|
||||
setSuppliers([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
loadSuppliers(searchQuery);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, open, loadSuppliers]);
|
||||
|
||||
const handleSelect = (supplier: SupplierItem) => {
|
||||
onSelectSupplier({ name: supplier.name, code: supplier.clientCode });
|
||||
onOpenChange(false);
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>발주처 검색</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="거래처명 검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-400 hover:text-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 거래처 목록 */}
|
||||
<div className="max-h-[400px] overflow-y-auto border rounded-lg">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-8 text-gray-500">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
||||
<span>거래처 검색 중...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-center text-red-500 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : suppliers.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">
|
||||
{!searchQuery
|
||||
? '한글 1자(완성형) 또는 영문 2자 이상 입력하세요'
|
||||
: !isValidSearchQuery(searchQuery)
|
||||
? '한글 1자(완성형) 또는 영문 2자 이상 입력하세요'
|
||||
: '검색 결과가 없습니다'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{suppliers.map((supplier, index) => (
|
||||
<div
|
||||
key={`${supplier.id}-${index}`}
|
||||
onClick={() => handleSelect(supplier)}
|
||||
className="p-3 hover:bg-blue-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-gray-900">{supplier.name}</span>
|
||||
{supplier.clientCode && (
|
||||
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
|
||||
{supplier.clientCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{supplier.contactPerson && (
|
||||
<p className="text-xs text-gray-400 mt-1">담당: {supplier.contactPerson}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 거래처 개수 표시 */}
|
||||
{!isLoading && !error && (
|
||||
<div className="text-xs text-gray-400 text-right">
|
||||
총 {suppliers.length}개 거래처
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -140,10 +140,11 @@ export function QuoteFooterBar({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 할인하기 */}
|
||||
{/* 할인하기 - view 모드에서는 비활성 */}
|
||||
{onDiscount && (
|
||||
<Button
|
||||
onClick={onDiscount}
|
||||
disabled={isViewMode}
|
||||
variant="outline"
|
||||
className="gap-2 px-6 border-orange-300 text-orange-600 hover:bg-orange-50"
|
||||
>
|
||||
|
||||
@@ -96,6 +96,8 @@ export interface QuoteFormDataV2 {
|
||||
vatType: "included" | "excluded"; // 부가세 (포함/별도)
|
||||
remarks: string; // 비고
|
||||
status: "draft" | "temporary" | "final"; // 작성중, 임시저장, 최종저장
|
||||
discountRate: number; // 할인율 (%)
|
||||
discountAmount: number; // 할인 금액
|
||||
locations: LocationItem[];
|
||||
}
|
||||
|
||||
@@ -133,6 +135,8 @@ const INITIAL_FORM_DATA: QuoteFormDataV2 = {
|
||||
vatType: "included", // 기본값: 부가세 포함
|
||||
remarks: "",
|
||||
status: "draft",
|
||||
discountRate: 0,
|
||||
discountAmount: 0,
|
||||
locations: [],
|
||||
};
|
||||
|
||||
@@ -181,8 +185,9 @@ export function QuoteRegistrationV2({
|
||||
const [transactionPreviewOpen, setTransactionPreviewOpen] = useState(false);
|
||||
const [discountModalOpen, setDiscountModalOpen] = useState(false);
|
||||
const [formulaViewOpen, setFormulaViewOpen] = useState(false);
|
||||
const [discountRate, setDiscountRate] = useState(0);
|
||||
const [discountAmount, setDiscountAmount] = useState(0);
|
||||
// 할인율/할인금액은 formData에서 관리 (저장/로드 연동)
|
||||
const discountRate = formData.discountRate ?? 0;
|
||||
const discountAmount = formData.discountAmount ?? 0;
|
||||
const pendingAutoCalculateRef = useRef(false);
|
||||
|
||||
// API 데이터
|
||||
@@ -309,8 +314,7 @@ export function QuoteRegistrationV2({
|
||||
|
||||
// 할인 적용 핸들러
|
||||
const handleApplyDiscount = useCallback((rate: number, amount: number) => {
|
||||
setDiscountRate(rate);
|
||||
setDiscountAmount(amount);
|
||||
setFormData(prev => ({ ...prev, discountRate: rate, discountAmount: amount }));
|
||||
toast.success(`할인이 적용되었습니다. (${rate.toFixed(1)}%, ${amount.toLocaleString()}원)`);
|
||||
}, []);
|
||||
|
||||
@@ -650,7 +654,8 @@ export function QuoteRegistrationV2({
|
||||
toast.success(saveType === "temporary" ? "저장되었습니다." : "견적이 확정되었습니다.");
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
const message = error instanceof Error ? error.message : "저장 중 오류가 발생했습니다.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ export interface Quote {
|
||||
supplyAmount: number;
|
||||
taxAmount: number;
|
||||
totalAmount: number;
|
||||
discountRate: number;
|
||||
discountAmount: number;
|
||||
status: QuoteStatus;
|
||||
currentRevision: number;
|
||||
isFinal: boolean;
|
||||
@@ -247,6 +249,8 @@ export function transformApiToFrontend(apiData: QuoteApiData): Quote {
|
||||
supplyAmount: parseFloat(String(apiData.supply_amount)) || 0,
|
||||
taxAmount: parseFloat(String(apiData.tax_amount)) || 0,
|
||||
totalAmount: parseFloat(String(apiData.total_amount)) || 0,
|
||||
discountRate: Number(apiData.discount_rate) || 0,
|
||||
discountAmount: Number(apiData.discount_amount) || 0,
|
||||
status: apiData.status,
|
||||
currentRevision: apiData.current_revision || 0,
|
||||
isFinal: apiData.is_final || false,
|
||||
@@ -837,10 +841,12 @@ export function transformV2ToApi(
|
||||
}));
|
||||
}
|
||||
|
||||
// 3. 총액 계산
|
||||
// 3. 총액 계산 (할인 반영)
|
||||
const totalSupply = items.reduce((sum, item) => sum + item.total_price, 0);
|
||||
const totalTax = Math.round(totalSupply * 0.1);
|
||||
const grandTotal = totalSupply + totalTax;
|
||||
const discountAmt = data.discountAmount || 0;
|
||||
const discountedSupply = totalSupply - discountAmt;
|
||||
const totalTax = Math.round(discountedSupply * 0.1);
|
||||
const grandTotal = discountedSupply + totalTax;
|
||||
|
||||
// 4. API 요청 객체 반환
|
||||
return {
|
||||
@@ -858,7 +864,7 @@ export function transformV2ToApi(
|
||||
unit_symbol: '개소',
|
||||
total_amount: grandTotal,
|
||||
discount_rate: data.discountRate || 0,
|
||||
discount_amount: data.discountAmount || 0,
|
||||
discount_amount: discountAmt,
|
||||
status: data.status === 'final' ? 'finalized' : 'draft',
|
||||
is_final: data.status === 'final',
|
||||
calculation_inputs: calculationInputs,
|
||||
@@ -955,6 +961,8 @@ export function transformApiToV2(apiData: QuoteApiData): QuoteFormDataV2 {
|
||||
managerContact?: string;
|
||||
deliveryDate?: string;
|
||||
description?: string;
|
||||
discountRate?: number;
|
||||
discountAmount?: number;
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -978,8 +986,9 @@ export function transformApiToV2(apiData: QuoteApiData): QuoteFormDataV2 {
|
||||
// raw API: remarks || description, transformed: description
|
||||
remarks: apiData.remarks || apiData.description || transformed.description || '',
|
||||
status: mapStatus(apiData.status),
|
||||
discountRate: Number(apiData.discount_rate) || 0,
|
||||
discountAmount: Number(apiData.discount_amount) || 0,
|
||||
// raw API: discount_rate, transformed: discountRate
|
||||
discountRate: Number(apiData.discount_rate) || transformed.discountRate || 0,
|
||||
discountAmount: Number(apiData.discount_amount) || transformed.discountAmount || 0,
|
||||
locations: locations,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user