- orval 실제 타입(ItemData/SupplierData/QuotationData/QuotationSettingData) 사용, 응답 봉투 과중첩(.data.data) 제거 → 목록 정상 렌더 - 배송형태(delivery_type) 자유텍스트 → 서버 /v1/enums 코드 select 로 전환 - orval 클라이언트 재생성 (enums 훅, companyData 등) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
229 lines
9.2 KiB
TypeScript
229 lines
9.2 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useListItems } from '@/api/generated/item/item';
|
|
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
|
import { useListSettings } from '@/api/generated/quotation-setting/quotation-setting';
|
|
import { useListQuotations } from '@/api/generated/quotation/quotation';
|
|
import type { ItemData } from '@/api/generated/model/itemData';
|
|
import type { SupplierData } from '@/api/generated/model/supplierData';
|
|
import type { QuotationSettingData } from '@/api/generated/model/quotationSettingData';
|
|
import type { QuotationData } from '@/api/generated/model/quotationData';
|
|
import { showToast } from '@/lib/notify';
|
|
import type { Estimate, QuotationSetting, ChatSession, NegotiationCard, Product } from '@/types';
|
|
import { unwrap, mapItem, mapSupplier, mapSetting, mapQuotation } from '../types';
|
|
|
|
export type CreateQuotationInput = {
|
|
title: string;
|
|
type: 'RE_NEGOTIATION' | 'RE_ESTIMATE';
|
|
productId: string;
|
|
partnerIds: string[];
|
|
dueDate: string; // datetime-local 원본값
|
|
settingId: string;
|
|
cardIds: string[];
|
|
};
|
|
|
|
export type SettingInput = {
|
|
targetMargin: string;
|
|
anchoringValue: string;
|
|
cardUseCount: string;
|
|
};
|
|
|
|
// 견적 화면 데이터 허브.
|
|
// 상품/협력사/세팅/견적은 서버(orval)에서 읽고, 견적·세팅·채팅은 로컬 state로 낙관적 갱신한다.
|
|
// (협상카드 카탈로그/채팅은 백엔드 미연동 → 빈 상태)
|
|
export function useQuotations() {
|
|
const itemsQuery = useListItems({ size: 100 });
|
|
const suppliersQuery = useListSuppliers({ size: 100 });
|
|
const settingsQuery = useListSettings();
|
|
const quotationsQuery = useListQuotations(undefined);
|
|
|
|
const products = (unwrap<{ items?: ItemData[] }>(itemsQuery.data)?.items ?? []).map(mapItem);
|
|
const partners = (unwrap<{ suppliers?: SupplierData[] }>(suppliersQuery.data)?.suppliers ?? []).map(mapSupplier);
|
|
|
|
const [quotationSettings, setQuotationSettings] = useState<QuotationSetting[]>([]);
|
|
useEffect(() => {
|
|
const s = unwrap<{ settings?: QuotationSettingData[] }>(settingsQuery.data)?.settings;
|
|
if (s) setQuotationSettings(s.map(mapSetting));
|
|
}, [settingsQuery.data]);
|
|
|
|
const [quotations, setQuotations] = useState<Estimate[]>([]);
|
|
useEffect(() => {
|
|
const qs = unwrap<{ quotations?: QuotationData[] }>(quotationsQuery.data)?.quotations;
|
|
if (qs) setQuotations(qs.map(mapQuotation));
|
|
}, [quotationsQuery.data]);
|
|
|
|
// 협상카드 카탈로그/채팅은 아직 백엔드 미연동 → 빈 상태.
|
|
const [cards] = useState<NegotiationCard[]>([]);
|
|
const [chatSessions, setChatSessions] = useState<Record<string, ChatSession[]>>({});
|
|
|
|
// '견적생성(PROCESSING)' 견적을 3초 후 '견적진행중'으로 전이시키는 폴링 시뮬레이션.
|
|
useEffect(() => {
|
|
const processingItem = quotations.find(
|
|
(est) => est.status === 'PROCESSING' || est.status === '견적생성',
|
|
);
|
|
if (!processingItem) return;
|
|
const timer = setTimeout(() => {
|
|
setQuotations((prev) =>
|
|
prev.map((est) => {
|
|
if (est.status === 'PROCESSING' || est.status === '견적생성') {
|
|
showToast(`[${est.title}] 견적 절차가 개시되었습니다. 파트너 입찰 채널이 활성화되었습니다.`, 'success');
|
|
return { ...est, status: '견적진행중' };
|
|
}
|
|
return est;
|
|
}),
|
|
);
|
|
}, 3000);
|
|
return () => clearTimeout(timer);
|
|
}, [quotations]);
|
|
|
|
// 협상 강제중단 → '협상보류'.
|
|
const stopNegotiation = (id: string, name: string) => {
|
|
if (!confirm(`현재 입찰 중인 [${name}] 단가 협상 절차를 즉시 조기 중단(강제종료)하시겠습니까?`)) return;
|
|
setQuotations((prev) => prev.map((e) => (e.id === id ? { ...e, status: '협상보류' } : e)));
|
|
showToast("해당 협상이 관리자에 의하여 '협상보류' 상태로 지정되었습니다.", 'info');
|
|
};
|
|
|
|
// 견적 세팅 추가. 검증 실패 시 toast 후 false.
|
|
const addSetting = (input: SettingInput): boolean => {
|
|
if (!input.targetMargin.trim() || !input.anchoringValue.trim() || !input.cardUseCount.trim()) {
|
|
showToast('모든 세팅 항목을 입력해야 합니다.', 'error');
|
|
return false;
|
|
}
|
|
const newSetting: QuotationSetting = {
|
|
qt_setting_id: `set-${Date.now()}`,
|
|
user_id: 'user-777-uuid',
|
|
target_margin: input.targetMargin.includes('%') ? input.targetMargin : `${input.targetMargin}%`,
|
|
anchoring_value: input.anchoringValue,
|
|
card_use_count: input.cardUseCount.includes('회') ? input.cardUseCount : `${input.cardUseCount}회`,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
deleted: false,
|
|
};
|
|
setQuotationSettings((prev) => [...prev, newSetting]);
|
|
showToast('새 견적 세팅 템플릿이 추가되었습니다.', 'success');
|
|
return true;
|
|
};
|
|
|
|
// 견적 세팅 삭제(최소 1개 유지).
|
|
const deleteSetting = (id: string) => {
|
|
if (quotationSettings.length <= 1) {
|
|
showToast('최소 한 개의 세팅은 유지되어야 합니다.', 'error');
|
|
return;
|
|
}
|
|
if (!confirm('선택한 견적 세팅을 삭제하시겠습니까?')) return;
|
|
setQuotationSettings((prev) => prev.filter((qs) => qs.qt_setting_id !== id));
|
|
showToast('세팅이 삭제되었습니다.', 'info');
|
|
};
|
|
|
|
// 신규 견적 발의 — 견적 레코드 + 협력사별 채팅 세션을 생성. 검증 실패 시 toast 후 false.
|
|
const createQuotation = (input: CreateQuotationInput): boolean => {
|
|
if (!input.title.trim()) {
|
|
showToast('견적 건명을 올바르게 작성해 주세요.', 'error');
|
|
return false;
|
|
}
|
|
if (!input.productId) {
|
|
showToast('협상 대상 품목을 지정하지 않았습니다.', 'error');
|
|
return false;
|
|
}
|
|
if (input.partnerIds.length === 0) {
|
|
showToast('최소 한 곳 이상의 벤더사(참여 협력사)를 선정해 주세요.', 'error');
|
|
return false;
|
|
}
|
|
|
|
const targetProduct = products.find((p) => p.id === input.productId);
|
|
const estId = `est-${Date.now()}`;
|
|
const newEstNumber = `EST-202606-${Math.floor(1000 + Math.random() * 9000)}`;
|
|
|
|
const newQuotationRecord: Estimate = {
|
|
id: estId,
|
|
title: input.title,
|
|
number: newEstNumber,
|
|
type: input.type,
|
|
round: 1,
|
|
status: '견적생성',
|
|
dueDate: input.dueDate.replace('T', ' '),
|
|
participationCount: input.partnerIds.length,
|
|
productId: input.productId,
|
|
partnerIds: input.partnerIds,
|
|
settingApplied: input.settingId,
|
|
usedCardIds: input.cardIds,
|
|
winnerPartnerId: undefined,
|
|
finalPrice: undefined,
|
|
isEqualPrice: false,
|
|
};
|
|
|
|
const generatedSessions: ChatSession[] = input.partnerIds.map((pId) =>
|
|
buildInitialSession(pId, estId, partners, targetProduct),
|
|
);
|
|
|
|
setChatSessions((prev) => ({ ...prev, [estId]: generatedSessions }));
|
|
setQuotations((prev) => [newQuotationRecord, ...prev]);
|
|
showToast(`신규 견정 제안서[${input.title}]가 발행되었습니다. 협상 세션 수립 중... (폴링 가동)`, 'info');
|
|
return true;
|
|
};
|
|
|
|
return {
|
|
products,
|
|
partners,
|
|
cards,
|
|
quotations,
|
|
quotationSettings,
|
|
chatSessions,
|
|
stopNegotiation,
|
|
addSetting,
|
|
deleteSetting,
|
|
createQuotation,
|
|
};
|
|
}
|
|
|
|
// 참여 협력사 1곳에 대한 초기 채팅 세션(시스템/봇/협력사 메시지) 생성.
|
|
function buildInitialSession(
|
|
pId: string,
|
|
estId: string,
|
|
partners: ReturnType<typeof mapSupplier>[],
|
|
targetProduct: Product | undefined,
|
|
): ChatSession {
|
|
const partnerObj = partners.find((part) => part.id === pId);
|
|
const partnerName = partnerObj?.name ?? '미지정 파트너';
|
|
const initialPartnerBid = Math.round((targetProduct?.price || 1000000) * (0.95 + Math.random() * 0.1));
|
|
const targetNegoPrice = Math.round((targetProduct?.price || 1000000) * 0.9);
|
|
|
|
return {
|
|
id: pId,
|
|
partnerName,
|
|
status: 'NEGOTIATING',
|
|
currentBid: initialPartnerBid,
|
|
bidTime: '2026-06-11 01:10',
|
|
messages: [
|
|
{
|
|
id: `msg-${estId}-sys`,
|
|
sender: 'SYSTEM',
|
|
timestamp: '2026-06-11 01:00',
|
|
content: `사전에 조율된 [${targetProduct?.name}] 입찰 참여 세션이 개시되었습니다.`,
|
|
},
|
|
{
|
|
id: `msg-${estId}-bot`,
|
|
sender: 'BOT',
|
|
timestamp: '2026-06-11 01:02',
|
|
content: '안녕하세요, 구매 대행 봇입니다.',
|
|
editorScript: [
|
|
{
|
|
type: 'paragraph',
|
|
children: [
|
|
{ text: `${partnerName} 조달담당자님 안녕하십니까.\n` },
|
|
{ text: `이번에 진행되는 ${targetProduct?.name} 품목에 대해 당사 타깃가는 ` },
|
|
{ text: `${targetNegoPrice.toLocaleString('ko-KR')}원`, bold: true, color: 'primary' },
|
|
{ text: ' 수준입니다. 상호 이익 극대화를 위해 적합 단가를 제안해주시면 검토 후 즉시 타결 또는 와일드카드 혜택이 적용됩니다.' },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: `msg-${estId}-part`,
|
|
sender: 'PARTNER',
|
|
timestamp: '2026-06-11 01:05',
|
|
content: `안녕하세요. 본 제품은 제조 원가 고사양으로 인해 타사 대비 높은 정밀도가 들어갑니다. 우선 1차 투찰로 ${initialPartnerBid.toLocaleString()}원을 입력 제출합니다.`,
|
|
},
|
|
],
|
|
};
|
|
}
|