o2o-negosium-original/negodata/front/src/features/quotations/hooks/useQuotations.ts
Mina Choi 2470183e0f [fix] negodata/front: 상품·협력사·견적 목록 연동 + 배송형태 서버 enum 코드 전송
- 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>
2026-06-17 15:58:57 +09:00

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()}원을 입력 제출합니다.`,
},
],
};
}