o2o-negosium-original/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx
Mina Choi 6eb4dc26f8 [feat] negosium·negodata: 협상 거부 흐름 정리 + 오프라인 계약가 낙찰 + 견적 상세 리모델링
협상 불가 사유를 내면 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 통과. 프론트 빌드/린트 통과.
2026-08-12 10:25:26 +09:00

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,
);
}