import { useEffect, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { useListItems } from '@/api/generated/item/item'; import { useListSuppliers } from '@/api/generated/supplier/supplier'; import { useListCards } from '@/api/generated/card/card'; import { mapCardData } from '@/features/cards/types'; import { useListSettings, useCreateSetting, useDeleteSetting, getListSettingsQueryKey, } from '@/api/generated/quotation-setting/quotation-setting'; import { useListQuotations, useCreateQuotation, useStopQuotation, getListQuotationsQueryKey, } from '@/api/generated/quotation/quotation'; import type { ReqCreateQuotation } from '@/api/generated/model/reqCreateQuotation'; 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 type { CardData } from '@/api/generated/model/cardData'; import { showToast } from '@/lib/notify'; import { confirm } from '@/lib/confirm'; import type { Estimate } 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 queryClient = useQueryClient(); const itemsQuery = useListItems({ size: 100 }); const suppliersQuery = useListSuppliers({ size: 100 }); const cardsQuery = useListCards({ size: 100 }); const settingsQuery = useListSettings(); const quotationsQuery = useListQuotations(undefined); const createSettingMutation = useCreateSetting(); const deleteSettingMutation = useDeleteSetting(); const createQuotationMutation = useCreateQuotation(); const stopQuotationMutation = useStopQuotation(); const invalidateQuotations = () => queryClient.invalidateQueries({ queryKey: getListQuotationsQueryKey(undefined) }); const products = (unwrap<{ items?: ItemData[] }>(itemsQuery.data)?.items ?? []).map(mapItem); const partners = (unwrap<{ suppliers?: SupplierData[] }>(suppliersQuery.data)?.suppliers ?? []).map(mapSupplier); // 견적 세팅은 서버가 정본 — 목록 쿼리에서 바로 파생하고, 추가/삭제 후 쿼리를 무효화해 재조회한다. const quotationSettings = ( unwrap<{ settings?: QuotationSettingData[] }>(settingsQuery.data)?.settings ?? [] ).map(mapSetting); const [quotations, setQuotations] = useState([]); useEffect(() => { const qs = unwrap<{ quotations?: QuotationData[] }>(quotationsQuery.data)?.quotations; if (qs) setQuotations(qs.map(mapQuotation)); }, [quotationsQuery.data]); // 협상카드 카탈로그는 서버(orval)에서 읽어 단계 3/3 카드 선택지로 쓴다. const cards = (unwrap<{ cards?: CardData[] }>(cardsQuery.data)?.cards ?? []).map(mapCardData); // 협상 강제중단 → 서버 stop_quotation 호출(상태 '견적마감'으로 영속). 성공 시 목록 무효화로 서버값 재동기화. const stopNegotiation = async (id: string, name: string) => { if (!(await confirm({ title: '협상 강제중단', description: `현재 입찰 중인 [${name}] 단가 협상 절차를 즉시 조기 중단(강제종료)하시겠습니까?`, confirmText: '중단', destructive: true }))) return; // 낙관적 갱신 — 서버가 CLOSED 로 바꾸므로 화면도 '견적마감'으로 선반영. setQuotations((prev) => prev.map((e) => (e.id === id ? { ...e, status: '견적마감' } : e))); stopQuotationMutation.mutate( { qtId: id }, { onSuccess: () => { invalidateQuotations(); showToast(`[${name}] 협상이 중단되어 '견적마감' 처리되었습니다.`, 'info'); }, onError: () => { invalidateQuotations(); // 실패 시 서버 진짜값으로 롤백 showToast('견적 중단에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'error'); }, }, ); }; const invalidateSettings = () => queryClient.invalidateQueries({ queryKey: getListSettingsQueryKey() }); // 견적 세팅 추가 — 서버 등록. 입력은 목표 마진율(%) / 앵커링 값 / 카드 사용 횟수(정수). // 백엔드는 target_margin_rate 를 비율(0.12)로 저장하므로 % 입력을 100 으로 나눠 보낸다. const addSetting = (input: SettingInput): boolean => { const marginPct = Number(String(input.targetMargin).replace('%', '').trim()); const anchoring = Number(String(input.anchoringValue).trim()); const cardCount = parseInt(String(input.cardUseCount).replace(/[^0-9-]/g, ''), 10); if (!Number.isFinite(marginPct) || !Number.isFinite(anchoring) || !Number.isInteger(cardCount)) { showToast('목표 마진율·앵커링 값·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error'); return false; } createSettingMutation.mutate( { data: { target_margin_rate: marginPct / 100, anchoring_value: anchoring, card_count: cardCount } }, { onSuccess: () => { invalidateSettings(); showToast('새 견적 세팅이 등록되었습니다.', 'success'); }, onError: () => showToast('견적 세팅 등록에 실패했습니다.', 'error'), }, ); return true; }; // 견적 세팅 삭제(서버 soft-delete, 최소 1개 유지). const deleteSetting = async (id: string) => { if (quotationSettings.length <= 1) { showToast('최소 한 개의 세팅은 유지되어야 합니다.', 'error'); return; } if (!(await confirm({ title: '견적 세팅 삭제', description: '선택한 견적 세팅을 삭제하시겠습니까?', confirmText: '삭제', destructive: true }))) return; deleteSettingMutation.mutate( { qtSettingId: id }, { onSuccess: () => { invalidateSettings(); showToast('세팅이 삭제되었습니다.', 'info'); }, onError: () => showToast('세팅 삭제에 실패했습니다.', 'error'), }, ); }; // 신규 협상견적 등록 — 서버에 견적 + 협력사별 협상 세션(상품×공급사)을 생성한다. // 서버 응답(실제 qt_id)을 기다린 뒤에야 완료 처리한다. 성공 시 새 qt_id 반환, 실패/검증오류 시 null. const createQuotation = async (input: CreateQuotationInput): Promise => { if (!input.title.trim()) { showToast('견적 건명을 올바르게 작성해 주세요.', 'error'); return null; } if (!input.productId) { showToast('협상 대상 품목을 지정하지 않았습니다.', 'error'); return null; } if (input.partnerIds.length === 0) { showToast('최소 한 곳 이상의 벤더사(참여 협력사)를 선정해 주세요.', 'error'); return null; } const payload: ReqCreateQuotation = { qt_setting_id: input.settingId, name: input.title, type: input.type === 'RE_ESTIMATE' ? 2 : 1, end_time: new Date(input.dueDate).toISOString(), item_ids: [input.productId], supplier_ids: input.partnerIds, card_ids: input.cardIds, }; try { const res = await createQuotationMutation.mutateAsync({ data: payload }); const qtId = res?.quotation?.qt_id ?? null; // 에러는 응답 패킷 규약(result.success/code/desc + msg)대로 표시한다. HTTP 200 이어도 success=false면 실패. if (!res?.result?.success || !qtId) { const reason = res?.msg ?? res?.result?.desc ?? '서버 오류'; const code = res?.result?.code; showToast(`견적 생성 실패${code ? ` [${code}]` : ''}: ${reason}`, 'error'); return null; } // 목록 재조회가 끝나야 새 견적이 정본 목록에 들어온다(상세 자동오픈이 그 행을 찾을 수 있게). await invalidateQuotations(); showToast(`협상견적[${input.title}] 생성 완료 — 협상 세션 ${res?.sessions?.length ?? 0}건.`, 'success'); return qtId; } catch { showToast('견적 생성에 실패했습니다. 입력값을 확인해 주세요.', 'error'); return null; } }; return { products, partners, cards, quotations, quotationSettings, stopNegotiation, addSetting, deleteSetting, createQuotation, }; }