o2o-negosium-original/negodata/front/src/features/quotations/hooks/useQuotations.ts
Mina Choi b45889ec0a [feat] negodata: chats.meta 컬럼 추가 + 견적생성 에러 응답패킷 규약대로 표기
- negotiation.chats 에 meta(JSONB) 추가 — 말풍선 표현 데이터(script/step/input_mode 등)
- 견적생성 실패 토스트를 result.code/desc + msg 규약대로 표시(HTTP 200·success=false 포함)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:45:17 +09:00

204 lines
8.8 KiB
TypeScript

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<Estimate[]>([]);
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<string | null> => {
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,
};
}