import { useEffect, useState } from 'react'; import { keepPreviousData, 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, useRegenerateQuotation, useNotifyQuotation, useNotifySession, getGetQuotationQueryKey, getGetQuotationSessionsQueryKey, } from '@/api/generated/quotation/quotation'; import type { ListQuotationsParams } from '@/api/generated/model/listQuotationsParams'; import type { ReqCreateQuotation } from '@/api/generated/model/reqCreateQuotation'; import { showToast } from '@/lib/notify'; import { confirm } from '@/lib/confirm'; import { useAuthStore } from '@/stores/auth'; import type { Estimate } from '../types'; import { mapItem, mapSupplier, mapSetting, mapQuotation } from '../types'; import { QuotationStatus } from '@/api/generated/model'; export type CreateQuotationInput = { title: string; type: number; // QuotationType 코드 (1=재협상, 2=재견적) productId: string; partnerIds: string[]; dueDate: string; // datetime-local 원본값 settingId: string; cardIds: string[]; memo: string; mdPrice?: number | null; // MD 제시가(원). 비우면 미전송 → 서버가 기존 마진식으로 목표가 산정 supplierType?: number | null; // 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록 }; export type SettingInput = { targetMargin: string; anchoringValue: string; cardUseCount: string; }; // 견적 화면 데이터 허브. // 상품/협력사/세팅/견적은 서버(orval)에서 읽고, 견적·세팅·채팅은 로컬 state로 낙관적 갱신한다. // (협상카드 카탈로그/채팅은 백엔드 미연동 → 빈 상태) export function useQuotations(params: ListQuotationsParams) { const queryClient = useQueryClient(); const itemsQuery = useListItems({ size: 100 }); const suppliersQuery = useListSuppliers({ size: 100 }); const cardsQuery = useListCards({ size: 100 }); const settingsQuery = useListSettings(); // 견적 목록은 서버 검색/상태·유형 필터/페이지네이션. 페이지 이동 시 이전 데이터 유지(깜빡임 방지). const quotationsQuery = useListQuotations(params, { query: { placeholderData: keepPreviousData } }); const createSettingMutation = useCreateSetting(); const deleteSettingMutation = useDeleteSetting(); const createQuotationMutation = useCreateQuotation(); const stopQuotationMutation = useStopQuotation(); const regenerateQuotationMutation = useRegenerateQuotation(); const notifyQuotationMutation = useNotifyQuotation(); const notifySessionMutation = useNotifySession(); // 파라미터별 목록 쿼리 키 전부 재조회(prefix 무효화). const invalidateQuotations = () => queryClient.invalidateQueries({ queryKey: ['/v1/quotation/list'] }); const products = (itemsQuery.data?.items ?? []).map(mapItem); const partners = (suppliersQuery.data?.suppliers ?? []).map(mapSupplier); // 견적 세팅은 서버가 정본 — 목록 쿼리에서 바로 파생하고, 추가/삭제 후 쿼리를 무효화해 재조회한다. const quotationSettings = ( settingsQuery.data?.settings ?? [] ).map(mapSetting); const [quotations, setQuotations] = useState([]); useEffect(() => { const qs = quotationsQuery.data?.quotations; if (qs) setQuotations(qs.map(mapQuotation)); }, [quotationsQuery.data]); // 서버 전체 건수(선택 필터 반영) — 페이지네이션용. const total = quotationsQuery.data?.total ?? 0; // 협상카드 카탈로그는 서버(orval)에서 읽어 단계 3/3 카드 선택지로 쓴다. const cards = (cardsQuery.data?.cards ?? []).map(mapCardData); // 견적 마감 → 서버 stop_quotation 호출(상태 '견적마감'으로 영속 + 협상생성 세션은 미참여로 전이). // 성공 시 목록 무효화로 서버값 재동기화. const closeQuotation = 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: QuotationStatus.CLOSED } : e))); stopQuotationMutation.mutate( { qtId: id }, { onSuccess: () => { invalidateQuotations(); // 열려있는 상세 Sheet 도 즉시 동기화(단건 견적 상태 + 세션 상태 재조회). queryClient.invalidateQueries({ queryKey: getGetQuotationQueryKey(id) }); queryClient.invalidateQueries({ queryKey: getGetQuotationSessionsQueryKey(id) }); 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; } // 담당자는 로그인 유저(개인정보 store)에서 그대로 채워 보낸다 — 견적 생성자 = 담당자. const me = useAuthStore.getState().user; const payload: ReqCreateQuotation = { qt_setting_id: input.settingId, name: input.title, type: input.type, end_time: new Date(input.dueDate).toISOString(), item_ids: [input.productId], supplier_ids: input.partnerIds, card_ids: input.cardIds, manager_name: me?.name || undefined, manager_email: me?.email || undefined, manager_contact_number: me?.contact || undefined, memo: input.memo.trim() || undefined, md_price: input.mdPrice && input.mdPrice > 0 ? input.mdPrice : undefined, supplier_type: input.supplierType ?? undefined, }; try { const res = await createQuotationMutation.mutateAsync({ data: payload }); const qtId = res?.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?.session_count ?? 0}건.`, 'success'); return qtId; } catch { showToast('견적 생성에 실패했습니다. 입력값을 확인해 주세요.', 'error'); return null; } }; // 마감된 견적을 골라 다음 라운드를 수동 생성한다(공급사는 프론트 선택, 상품·번호·기간은 원 견적 승계). // 성공 시 새 라운드 qt_id 반환, 실패/검증오류 시 null. const regenerateQuotation = async (qtId: string, supplierIds: string[]): Promise => { if (supplierIds.length === 0) { showToast('다음 견적에 부를 공급사를 한 곳 이상 선택해 주세요.', 'error'); return null; } try { const res = await regenerateQuotationMutation.mutateAsync({ qtId, data: { supplier_ids: supplierIds } }); const newId = res?.qt_id ?? null; if (!res?.result?.success || !newId) { const reason = res?.msg ?? res?.result?.desc ?? '서버 오류'; const code = res?.result?.code; showToast(`재생성 실패${code ? ` [${code}]` : ''}: ${reason}`, 'error'); return null; } await invalidateQuotations(); showToast(`다음 견적이 생성되었습니다 — 협상 세션 ${res?.session_count ?? 0}건.`, 'success'); return newId; } catch { showToast('견적 재생성에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'error'); return null; } }; // 협상 초청 메일 — 견적 단위(미발송 세션 전체) 발송. 발송 후 세션 재조회로 발송배지 갱신. const notifyQuotation = async (qtId: string): Promise => { try { const res = await notifyQuotationMutation.mutateAsync({ qtId }); if (!res?.result?.success) { const reason = res?.msg ?? res?.result?.desc ?? '서버 오류'; showToast(`초청 메일 발송 실패: ${reason}`, 'error'); return; } const sent = res.sent ?? 0; const parts = [`${sent}건 발송`]; if (res.failed) parts.push(`${res.failed}건 실패`); if (res.skipped) parts.push(`${res.skipped}건 이메일없음`); showToast(`초청 메일 — ${parts.join(' · ')}`, sent > 0 ? 'success' : 'info'); } catch { showToast('초청 메일 발송에 실패했습니다.', 'error'); } finally { queryClient.invalidateQueries({ queryKey: getGetQuotationSessionsQueryKey(qtId) }); } }; // 협상 초청 메일 — 세션(공급사) 단위 재발송. qtId 는 세션 목록 재조회용. const notifySession = async (sessionId: string, qtId: string): Promise => { try { const res = await notifySessionMutation.mutateAsync({ sessionId }); if (!res?.result?.success) { const reason = res?.msg ?? res?.result?.desc ?? '서버 오류'; showToast(`재발송 실패: ${reason}`, 'error'); } else if (res.skipped) { showToast('담당자 이메일이 없어 발송하지 못했습니다.', 'info'); } else if (res.sent) { showToast('초청 메일을 재발송했습니다.', 'success'); } else { showToast('초청 메일 발송에 실패했습니다.', 'error'); } } catch { showToast('재발송에 실패했습니다.', 'error'); } finally { queryClient.invalidateQueries({ queryKey: getGetQuotationSessionsQueryKey(qtId) }); } }; return { products, partners, cards, quotations, total, quotationSettings, closeQuotation, addSetting, deleteSetting, createQuotation, regenerateQuotation, notifyQuotation, notifySession, }; }