협상 불가 사유를 내면 500 이 나고, 거부 폼은 목록·채팅이 따로 놀았으며,
제출한 내용을 다시 볼 방법이 없었다. 결렬 건은 협력사가 낸 거부가를 계약가로
간주해 낙찰시켜 절감 통계가 음수로 뒤집힐 수 있었고, 견적 상세는 판정 가격이
세 곳에 흩어져 대화 탭에선 아예 보이지 않았다.
agent
- 결렬 종료 로깅 크래시 수정 — _log 를 action_id 기반으로 되돌리고 선택 근거
(Q·UCB·방문수)는 decision/policy 가 있을 때만 채운다. 종료 행은 카드 선택이
없고 policy.update 뒤라 값을 넣으면 학습 화면 집계가 오염된다
negosium
- 거부 폼을 목록·채팅 공용 컴포넌트 하나로 통일(사유 3종 + 공급 희망가·의견 선택)
- 거부 사유 열람 — 목록에 '거부 사유 보기'(부가정보 보기와 같은 규격), 채팅
재진입 시 대화 끝에 거부 내역 카드. 목록·채팅 init 응답에 reject_reason·reject_price 추가
- 자유 입력 거부("협상 포기합니다")가 사유 NULL 로 저장되던 문제 수정 — 폼 마커가
없으면 원문을 사유로 쓰고, 문장 속 숫자를 희망가로 오인하지 않는다
- koreanNumber 를 전역 lib 으로 이동(공용 폼이 쓴다)
negodata
- 직접 낙찰에 계약가 입력 — 결렬·미응찰 건을 오프라인으로 다시 협상한 결과를
담당자가 확정해 넣는다. 후보는 초청 협력사 전부(가격 미제출도 포함),
계약가는 sessions.custom.offline_award 에 근거·작성자·시각과 함께 남긴다
- 통계 계약가 = 담당자 확정가 우선, 없으면 투찰가. 거부가를 계약가로 치던 파생 제거.
KPI 에 오프라인 반영 건수 추가
- 견적 상세 리모델링 — 가격 레일(앵커링가/투찰현황 · 목표가 · 타결 상한가 · 결과가)을
시트에 고정해 접힘·탭 전환에도 남기고, 스펙트럼에 타결 판정선과 구간색 추가.
라벨은 폭을 실측해 두 레인으로 배치(겹침 불가). 상품·마감시각 등 전 행 동일 컬럼 제거,
협상현황에 부가정보 노출, 1:1 은 협력사·세션상태를 결과 밴드로 올림
테스트: negosium 58 · negodata 110 통과. 프론트 빌드/린트 통과.
405 lines
20 KiB
TypeScript
405 lines
20 KiB
TypeScript
import { useState } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
import { createPortal } from 'react-dom';
|
|
import { CheckCircle2, ChevronLeft, X, UserCheck, MessageSquare, Layers, RefreshCw, Trash2 } from 'lucide-react';
|
|
import { Typography } from '@/components/ui/typography';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
useGetQuotationSessions,
|
|
useGetSessionChat,
|
|
useGetQuotationCards,
|
|
} from '@/api/generated/quotation/quotation';
|
|
import { useGetItem } from '@/api/generated/item/item';
|
|
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
|
import { useListSettings } from '@/api/generated/quotation-setting/quotation-setting';
|
|
import { useQuotationChain } from '../../hooks/useQuotationChain';
|
|
import { useScrollLock } from '@/lib/useScrollLock';
|
|
import { useAuthStore, canManage } from '@/stores/auth';
|
|
import type { QuotationData } from '@/api/generated/model/quotationData';
|
|
import {
|
|
mapItem,
|
|
mapSupplier,
|
|
mapSetting,
|
|
mapServerSessionView,
|
|
mapServerCardView,
|
|
buildPriceRail,
|
|
quotationTypeLabel,
|
|
chainRoundState,
|
|
type NegotiationCard,
|
|
} from '../../types';
|
|
import { QuotationStatus } from '@/api/generated/model';
|
|
import { DrawerHeaderCards } from './DrawerHeaderCards';
|
|
import { PriceRail } from './PriceRail';
|
|
import { RoundTimeline } from './RoundTimeline';
|
|
import { StatusPill } from './StatusPill';
|
|
import { RegenerateModal } from './RegenerateModal';
|
|
import type { RegenerateInput } from '../../hooks/useQuotations';
|
|
import { SessionsStatusTab } from './SessionsStatusTab';
|
|
import { TargetPriceModal } from './TargetPriceModal';
|
|
import { QuotationCardsTab } from './QuotationCardsTab';
|
|
import { ChatTab } from './ChatTab';
|
|
|
|
type DrawerTab = 'status' | 'cards' | 'chat';
|
|
|
|
type QuotationDetailSheetProps = {
|
|
quotation: QuotationData;
|
|
onCloseQuotation: (id: string, name: string) => void;
|
|
/** 개찰(낙찰자 미정 마감) 견적을 협상현황 표에서 직접 낙찰. 성공 시 true. */
|
|
onAward: (
|
|
qtId: string,
|
|
winnerSupplierId: string,
|
|
winnerName: string,
|
|
contractPrice: number,
|
|
contractNote: string,
|
|
) => Promise<boolean>;
|
|
/** 라운드 타임라인에서 다른 차수로 전환(같은 견적번호의 다른 견적 상세 열기). */
|
|
onSwitchRound: (qtId: string) => void;
|
|
/** 마감된 견적의 다음 라운드를 수동 생성(공급사·기한·목표가·카드 재선택). 성공 시 새 qt_id 반환. */
|
|
onRegenerate: (qtId: string, input: RegenerateInput) => Promise<string | null>;
|
|
/** 재생성 모달의 협상카드 재선택 후보(회사 카드 카탈로그). */
|
|
cards: NegotiationCard[];
|
|
/** 협상 초청 메일 — 견적 단위(미발송 세션 전체) 발송. */
|
|
onNotify: (qtId: string) => Promise<void>;
|
|
/** 협상 초청 메일 — 세션(공급사) 단위 재발송. */
|
|
onNotifySession: (sessionId: string, qtId: string) => Promise<void>;
|
|
/** 넘기면 헤더에 삭제 버튼 노출(개발자 전용 — 페이지에서 권한 판정 후 주입). 삭제 성공 시 시트를 닫는다. */
|
|
onDelete?: (id: string, name: string) => void;
|
|
onClose: () => void;
|
|
};
|
|
|
|
export function QuotationDetailSheet({
|
|
quotation,
|
|
cards,
|
|
onCloseQuotation,
|
|
onAward,
|
|
onSwitchRound,
|
|
onRegenerate,
|
|
onNotify,
|
|
onNotifySession,
|
|
onDelete,
|
|
onClose,
|
|
}: QuotationDetailSheetProps) {
|
|
const [activeTab, setActiveTab] = useState<DrawerTab>('status');
|
|
const [showHeaderCards, setShowHeaderCards] = useState(true);
|
|
const [regenOpen, setRegenOpen] = useState(false);
|
|
const [targetSessionId, setTargetSessionId] = useState<string | null>(null);
|
|
const navigate = useNavigate();
|
|
// 시트 열린 동안 뒤 견적 리스트(<main>) 스크롤 잠금 — 옆에 배경 스크롤바가 같이 뜨는 것 방지.
|
|
useScrollLock();
|
|
|
|
const suppliersQuery = useListSuppliers({ size: 100 });
|
|
const settingsQuery = useListSettings();
|
|
const partners = (suppliersQuery.data?.suppliers ?? []).map(mapSupplier);
|
|
const quotationSettings = (
|
|
settingsQuery.data?.settings ?? []
|
|
).map(mapSetting);
|
|
|
|
const qtId = quotation.qt_id ?? '';
|
|
// 소유자 게이팅 — 견적을 바꾸는 액션(초청메일·마감·재생성·낙찰)은 '본인 견적' 또는 최고관리자만.
|
|
// 프론트 1차 차단이며, 실제 보안은 백엔드가 동일 스코프로 강제해야 함(버튼 숨김만으론 우회 가능).
|
|
const myUserId = useAuthStore((s) => s.user?.userId);
|
|
const isSuperAdmin = useAuthStore((s) => canManage(s.user?.role));
|
|
const canMutate = !!myUserId && (quotation.user_id === myUserId || isSuperAdmin);
|
|
const canNotify = canMutate; // 초청 메일 발송/재발송
|
|
// 직접 낙찰 = 개찰(낙찰자 미정 마감) 견적에서만. 후보(투찰한 협상완료 협력사) 유무는 표에서 판정.
|
|
const canAward = canMutate && chainRoundState(quotation) === 'opened';
|
|
// 협상 세션·사용 카드는 견적 단위, 채팅은 선택 세션 단위로 서버에서 읽는다.
|
|
// 세션은 협상 진행으로 계속 바뀌므로 탭 복귀 시 재조회한다. 카드는 생성 후 불변이라 끄둔다.
|
|
const sessionsQuery = useGetQuotationSessions(qtId, {
|
|
query: { enabled: !!qtId, refetchOnWindowFocus: true },
|
|
});
|
|
const cardsQuery = useGetQuotationCards(qtId, { query: { enabled: !!qtId } });
|
|
const serverSessions = sessionsQuery.data?.sessions ?? [];
|
|
const serverCards = cardsQuery.data?.cards ?? [];
|
|
// 재생성 모달 기본 선택 = 이 라운드에 부른 공급사들(세션 distinct supplier).
|
|
const currentSupplierIds = [...new Set(serverSessions.map((s) => s.supplier_id))];
|
|
// 재생성 모달엔 '현재 견적에 연결된 공급사'만 + 각자의 협상 단계·직전 투찰가를 함께 보여준다.
|
|
const connectedPartners = partners.filter((p) => currentSupplierIds.includes(p.id ?? ''));
|
|
// 재생성 카드 재선택 기본값 = 이 라운드가 실제로 쓴 카드.
|
|
const previousCardIds = serverCards
|
|
.map((c) => c.nego_card_id ?? c.wild_card_id ?? '')
|
|
.filter((id): id is string => !!id);
|
|
// 타결 상한율(‰) — 견적 override 가 없으면 적용 중인 견적 세팅값이 기본.
|
|
const settingCeilingRate =
|
|
quotationSettings.find((qs) => qs.qt_setting_id === quotation.qt_setting_id)?.done_ceiling_rate ?? 50;
|
|
|
|
// 재생성 버튼은 '체인의 마지막 차수(마감됨)'에서만 노출. 체인 로딩 끝난 뒤 판정해 옛 라운드에서 깜빡임 방지.
|
|
const { rounds: chainRounds, isLoading: chainLoading } = useQuotationChain(quotation.number);
|
|
const maxRound = chainRounds.length ? Math.max(...chainRounds.map((r) => r.round)) : (quotation.round ?? 1);
|
|
const isLatestRound = (quotation.round ?? 1) >= maxRound;
|
|
const canRegenerate = canMutate && !chainLoading && quotation.status === QuotationStatus.CLOSED && isLatestRound;
|
|
|
|
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
|
|
const effectiveSessionId = selectedSessionId ?? serverSessions[0]?.session_id ?? null;
|
|
const chatQuery = useGetSessionChat(effectiveSessionId ?? '', {
|
|
query: { enabled: !!effectiveSessionId, refetchOnWindowFocus: true },
|
|
});
|
|
const chatMessages = chatQuery.data?.messages ?? [];
|
|
|
|
const currentSession = serverSessions.find((s) => s.session_id === effectiveSessionId);
|
|
const currentSupplierName =
|
|
partners.find((p) => p.id === currentSession?.supplier_id)?.name || currentSession?.supplier_id || '-';
|
|
|
|
const itemId = serverSessions[0]?.item_id ?? '';
|
|
const itemQuery = useGetItem(itemId, { query: { enabled: !!itemId } });
|
|
const currentItem = itemQuery.data?.item;
|
|
// 이미지·규격 등 상세 + 카드 변수 치환용 상품명.
|
|
const currentProduct = currentItem ? mapItem(currentItem) : undefined;
|
|
|
|
// 세션은 모두 같은 상품을 가리키므로(1견적=1상품) 단건 상품 하나로 item_name 해석이 끝난다.
|
|
const productList = currentProduct ? [currentProduct] : [];
|
|
const sessionViews = serverSessions.map((sd) => mapServerSessionView(sd, partners, productList));
|
|
// 헤더에 고정할 가격 레일 — 접힘/펼침·탭과 무관하게 항상 같은 값을 본다.
|
|
const railCeilingRate = quotation.done_ceiling_rate ?? settingCeilingRate;
|
|
const priceRail = buildPriceRail(quotation, sessionViews, railCeilingRate);
|
|
const railTargetSessionId =
|
|
(sessionViews.find((s) => s.target_price > 0) ?? sessionViews[0])?.session_id ?? null;
|
|
const quotationCardViews = serverCards.map(mapServerCardView);
|
|
|
|
// 헤더 상단바·마감 버튼에 필요한 최소 표시값만 (나머지 견적 표시값은 DrawerHeaderCards 내부 계산).
|
|
const q_name = quotation.name || '-';
|
|
const q_number = quotation.number || '-';
|
|
|
|
const goToChat = (sessionId: string) => {
|
|
setSelectedSessionId(sessionId);
|
|
setActiveTab('chat');
|
|
setShowHeaderCards(false); // 채팅 진입 시 대화 영역 넓게 — 견적 상세 정보 접기
|
|
};
|
|
|
|
const tabs: { id: DrawerTab; label: string; icon: typeof UserCheck }[] = [
|
|
{ id: 'status', label: '협상 현황', icon: UserCheck },
|
|
{ id: 'chat', label: '협상 대화', icon: MessageSquare },
|
|
{ id: 'cards', label: `협상 카드 (${serverCards.length})`, icon: Layers },
|
|
];
|
|
|
|
// body 로 포털 — main(페이지 스크롤 컨테이너) 안에 렌더되면 시트 내부 스크롤이 끝에서
|
|
// 부모로 체이닝돼 모바일에서 뒤 화면이 스크롤된다.
|
|
return createPortal(
|
|
<div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-xs flex justify-end animate-fade-in">
|
|
{/* 백드롭(어두운 영역) — 클릭 시 닫힘. 좌상단에 뒤로가기(직전 화면 히스토리 back).
|
|
패널이 w-full max-w-5xl(64rem/1024px)이라 뷰포트<1024px에선 패널이 폭을 다 먹고 백드롭이 0이 됨.
|
|
그때 absolute 뒤로버튼이 패널 위로 겹치므로, 백드롭에 버튼 여유폭이 남는 1152px+에서만 노출(태블릿 폭에선 숨김). */}
|
|
<div className="flex-1 cursor-pointer relative" onClick={onClose}>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { e.stopPropagation(); navigate(-1); }}
|
|
title="이전 화면으로"
|
|
className="hidden min-[1152px]:inline-flex items-center gap-1.5 absolute top-6 left-6 px-3 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white cursor-pointer backdrop-blur-sm"
|
|
>
|
|
<ChevronLeft size={18} />
|
|
<Typography as="span" variant="small" className="text-inherit font-semibold">뒤로</Typography>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="w-full max-w-5xl bg-card border-l border-border h-full flex flex-col shadow-2xl overflow-hidden animate-slide-left">
|
|
|
|
{/* Header title bar (고정) — 아래에 상세 그리드(펼침) 또는 결과 요약 밴드(접힘)가 항상 붙는다. */}
|
|
<div className="shrink-0 px-6 pt-6 pb-3 bg-muted/30">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<Typography variant="h3">{q_name}</Typography>
|
|
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
|
<span className="font-mono text-[12px] font-bold tracking-tight text-foreground">{q_number}</span>
|
|
<StatusPill tone="zinc">{quotationTypeLabel(quotation.type)}</StatusPill>
|
|
{/* 차수는 라운드가 하나뿐이라 타임라인이 안 뜰 때만 — 뜨면 그쪽이 현재 차수를 보여준다. */}
|
|
{chainRounds.length <= 1 && <StatusPill tone="zinc">{quotation.round ?? 1}차</StatusPill>}
|
|
</div>
|
|
{quotation.number && (
|
|
<RoundTimeline number={quotation.number} currentQtId={qtId} onSwitchRound={onSwitchRound} />
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setShowHeaderCards(!showHeaderCards)}
|
|
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-muted hover:bg-muted-foreground/15 text-foreground border border-border rounded text-xs font-semibold cursor-pointer transition-colors"
|
|
>
|
|
<span>{showHeaderCards ? '견적 상세 정보 접기 ▲' : '견적 상세 정보 펼치기 ▼'}</span>
|
|
</button>
|
|
{/* 마감된 '마지막 차수'에서만 다음 라운드 수동 재생성(공급사는 모달에서 선택). 서버도 비-마지막은 에러로 방어. */}
|
|
{canRegenerate && (
|
|
<button
|
|
onClick={() => setRegenOpen(true)}
|
|
className="flex items-center gap-1 px-3 py-1.5 bg-violet-600 hover:bg-violet-700 text-white rounded text-xs font-semibold cursor-pointer transition-colors"
|
|
>
|
|
<RefreshCw size={14} />
|
|
<span>다음 견적 재생성</span>
|
|
</button>
|
|
)}
|
|
{/* 마감 버튼은 항상 노출하되, 본인/최고관리자가 아니거나 마감 가능한 상태가 아니면 비활성화한다. */}
|
|
{(() => {
|
|
const alreadyClosed = quotation.status === QuotationStatus.CLOSED;
|
|
const canClose = canMutate && !alreadyClosed;
|
|
return (
|
|
<button
|
|
onClick={() => onCloseQuotation(quotation.qt_id ?? '', q_name)}
|
|
disabled={!canClose}
|
|
title={
|
|
!canMutate
|
|
? '본인이 생성한 견적만 마감할 수 있습니다.'
|
|
: alreadyClosed
|
|
? '이미 마감된 견적입니다.'
|
|
: undefined
|
|
}
|
|
className="flex items-center gap-1 px-3 py-1.5 bg-red-600 hover:bg-rose-700 text-white rounded text-xs font-semibold cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-red-600"
|
|
>
|
|
<CheckCircle2 size={14} />
|
|
<span>견적 마감</span>
|
|
</button>
|
|
);
|
|
})()}
|
|
{/* 견적 삭제 — 개발자만(onDelete 주입 시). 연결된 협상 세션·대화까지 함께 소프트 삭제 후 시트 닫힘. */}
|
|
{onDelete && (
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => onDelete(quotation.qt_id ?? '', q_name)}
|
|
title="이 견적과 연결된 협상 세션·대화를 모두 삭제합니다(되돌릴 수 없음)."
|
|
>
|
|
<Trash2 />
|
|
삭제
|
|
</Button>
|
|
)}
|
|
<button
|
|
onClick={onClose}
|
|
className="p-1.5 rounded-full text-muted-foreground hover:bg-muted cursor-pointer"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 가격 레일 — 접힘·펼침·탭 전환과 무관하게 항상 붙어 있다. 상세를 펼쳐 스크롤해도 기준가는 남는다. */}
|
|
<div className="shrink-0">
|
|
<PriceRail
|
|
rail={priceRail}
|
|
onShowTarget={railTargetSessionId ? () => setTargetSessionId(railTargetSessionId) : undefined}
|
|
/>
|
|
</div>
|
|
|
|
{/* 견적 상세 정보 — 펼치면 탭과 flex 비율(헤더:탭 = 2:1)로 높이를 나눠 가지고 자체 스크롤 */}
|
|
{showHeaderCards && (
|
|
<div
|
|
style={{ flex: '2 1 0%' }}
|
|
className="min-h-0 overflow-y-auto overscroll-contain px-6 py-4 bg-muted/30 border-b border-border"
|
|
>
|
|
<DrawerHeaderCards
|
|
quotation={quotation}
|
|
quotationSettings={quotationSettings}
|
|
sessionViews={sessionViews}
|
|
currentProduct={currentProduct}
|
|
onShowTarget={setTargetSessionId}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tabs */}
|
|
<div className="shrink-0 border-b border-border bg-background px-6">
|
|
<div className="flex gap-4">
|
|
{tabs.map((tab) => {
|
|
const Icon = tab.icon;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => {
|
|
setActiveTab(tab.id);
|
|
// 탭을 고르면 견적 상세 정보를 접어 본문에 높이를 넘긴다(가격 레일은 그대로 남는다).
|
|
setShowHeaderCards(false);
|
|
if (tab.id === 'chat' && serverSessions.length > 0 && !selectedSessionId) {
|
|
setSelectedSessionId(serverSessions[0].session_id);
|
|
}
|
|
}}
|
|
className={`flex items-center gap-2 py-4 px-3 text-xs tracking-tight font-semibold border-b-2 transition-all cursor-pointer ${
|
|
activeTab === tab.id
|
|
? 'border-primary text-primary font-bold'
|
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
<Icon size={14} />
|
|
<span>{tab.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab content */}
|
|
<div style={{ flex: '1 1 0%' }} className="min-h-0 p-6 pb-[calc(1.5rem+env(safe-area-inset-bottom))] overflow-y-auto overscroll-contain bg-background/50">
|
|
{activeTab === 'status' && (
|
|
<SessionsStatusTab
|
|
sessionViews={sessionViews}
|
|
canNotify={canNotify}
|
|
canAward={canAward}
|
|
winnerSupplierId={quotation.preferred_sp_id ?? null}
|
|
onOpenChat={goToChat}
|
|
onNotifyAll={() => onNotify(qtId)}
|
|
onNotifyOne={(sessionId) => onNotifySession(sessionId, qtId)}
|
|
onAward={(supplierId, supplierName, contractPrice, contractNote) =>
|
|
onAward(qtId, supplierId, supplierName, contractPrice, contractNote)
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'cards' && <QuotationCardsTab quotationCardViews={quotationCardViews} />}
|
|
|
|
{activeTab === 'chat' && (
|
|
<ChatTab
|
|
serverSessions={serverSessions}
|
|
partners={partners}
|
|
effectiveSessionId={effectiveSessionId}
|
|
onSelectSession={setSelectedSessionId}
|
|
chatMessages={chatMessages}
|
|
currentSupplierName={currentSupplierName}
|
|
currentProduct={currentProduct}
|
|
serverCards={serverCards}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{targetSessionId && currentItem && (() => {
|
|
const ts = sessionViews.find((s) => s.session_id === targetSessionId);
|
|
if (!ts) return null;
|
|
return (
|
|
<TargetPriceModal
|
|
onClose={() => setTargetSessionId(null)}
|
|
sessionId={targetSessionId}
|
|
qtNumber={quotation.number ?? '-'}
|
|
itemName={currentItem.name ?? ts.item_name ?? '-'}
|
|
vatYn={currentItem.vat_yn}
|
|
deliveryFeeYn={currentItem.delivery_fee_yn}
|
|
category={currentItem.category}
|
|
/>
|
|
);
|
|
})()}
|
|
|
|
{/* 카드·세션이 도착한 뒤에 열어야 '직전 라운드 카드' 기본선택과 결과 요약이 제 값으로 뜬다. */}
|
|
{regenOpen && !cardsQuery.isLoading && !sessionsQuery.isLoading && (
|
|
<RegenerateModal
|
|
open
|
|
quotation={quotation}
|
|
sessionViews={sessionViews}
|
|
partners={connectedPartners}
|
|
cards={cards}
|
|
previousCardIds={previousCardIds}
|
|
settingCeilingRate={settingCeilingRate}
|
|
productId={itemId}
|
|
internetLowest={currentItem?.internet_lowest_price ?? null}
|
|
defaultSupplierIds={currentSupplierIds}
|
|
onConfirm={async (input) => {
|
|
const newId = await onRegenerate(qtId, input);
|
|
if (newId) {
|
|
onSwitchRound(newId); // 새 라운드 상세로 전환
|
|
return true;
|
|
}
|
|
return false;
|
|
}}
|
|
onClose={() => setRegenOpen(false)}
|
|
/>
|
|
)}
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|