[견적·목표가] - 목표가 산정 KTC 기준 정비(MD 우선, 없으면 인터넷최저가·매입가·판매가 중 최저 / 신규는 인터넷최저가만) - 인터넷 평균 수수료 0.078 상수화(견적설정 컬럼 제거) - 앵커링가 세션 저장 + 목표가 클릭 시 산정내역 모달 - 재생성 시 목표가·앵커링가 재계산 없이 직전 값 상속(KTC) - MD 제시가·협력사 유형(유통·제조·총판·기타) 입력 [협상카드] - 사용 구분(공통/신규견적전용/재견적전용) [협상 진행] - 협력사 초청 메일 발송(일괄/개별·재발송, 발송상태 표시) [기타] - 견적상세 창 닫기 버그 수정, 불필요한 컬럼 주석 정리 - DB: 기존 DB는 postgres-init/04-alter.sql 적용 필요(자동 마이그레이션 없음) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
299 lines
13 KiB
TypeScript
299 lines
13 KiB
TypeScript
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<Estimate[]>([]);
|
|
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<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;
|
|
}
|
|
|
|
// 담당자는 로그인 유저(개인정보 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<string | null> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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,
|
|
};
|
|
}
|