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; /** 라운드 타임라인에서 다른 차수로 전환(같은 견적번호의 다른 견적 상세 열기). */ onSwitchRound: (qtId: string) => void; /** 마감된 견적의 다음 라운드를 수동 생성(공급사·기한·목표가·카드 재선택). 성공 시 새 qt_id 반환. */ onRegenerate: (qtId: string, input: RegenerateInput) => Promise; /** 재생성 모달의 협상카드 재선택 후보(회사 카드 카탈로그). */ cards: NegotiationCard[]; /** 협상 초청 메일 — 견적 단위(미발송 세션 전체) 발송. */ onNotify: (qtId: string) => Promise; /** 협상 초청 메일 — 세션(공급사) 단위 재발송. */ onNotifySession: (sessionId: string, qtId: string) => Promise; /** 넘기면 헤더에 삭제 버튼 노출(개발자 전용 — 페이지에서 권한 판정 후 주입). 삭제 성공 시 시트를 닫는다. */ 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('status'); const [showHeaderCards, setShowHeaderCards] = useState(true); const [regenOpen, setRegenOpen] = useState(false); const [targetSessionId, setTargetSessionId] = useState(null); const navigate = useNavigate(); // 시트 열린 동안 뒤 견적 리스트(
) 스크롤 잠금 — 옆에 배경 스크롤바가 같이 뜨는 것 방지. 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(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(
{/* 백드롭(어두운 영역) — 클릭 시 닫힘. 좌상단에 뒤로가기(직전 화면 히스토리 back). 패널이 w-full max-w-5xl(64rem/1024px)이라 뷰포트<1024px에선 패널이 폭을 다 먹고 백드롭이 0이 됨. 그때 absolute 뒤로버튼이 패널 위로 겹치므로, 백드롭에 버튼 여유폭이 남는 1152px+에서만 노출(태블릿 폭에선 숨김). */}
{/* Header title bar (고정) — 아래에 상세 그리드(펼침) 또는 결과 요약 밴드(접힘)가 항상 붙는다. */}
{q_name}
{q_number} {quotationTypeLabel(quotation.type)} {/* 차수는 라운드가 하나뿐이라 타임라인이 안 뜰 때만 — 뜨면 그쪽이 현재 차수를 보여준다. */} {chainRounds.length <= 1 && {quotation.round ?? 1}차}
{quotation.number && ( )}
{/* 마감된 '마지막 차수'에서만 다음 라운드 수동 재생성(공급사는 모달에서 선택). 서버도 비-마지막은 에러로 방어. */} {canRegenerate && ( )} {/* 마감 버튼은 항상 노출하되, 본인/최고관리자가 아니거나 마감 가능한 상태가 아니면 비활성화한다. */} {(() => { const alreadyClosed = quotation.status === QuotationStatus.CLOSED; const canClose = canMutate && !alreadyClosed; return ( ); })()} {/* 견적 삭제 — 개발자만(onDelete 주입 시). 연결된 협상 세션·대화까지 함께 소프트 삭제 후 시트 닫힘. */} {onDelete && ( )}
{/* 가격 레일 — 접힘·펼침·탭 전환과 무관하게 항상 붙어 있다. 상세를 펼쳐 스크롤해도 기준가는 남는다. */}
setTargetSessionId(railTargetSessionId) : undefined} />
{/* 견적 상세 정보 — 펼치면 탭과 flex 비율(헤더:탭 = 2:1)로 높이를 나눠 가지고 자체 스크롤 */} {showHeaderCards && (
)} {/* Tabs */}
{tabs.map((tab) => { const Icon = tab.icon; return ( ); })}
{/* Tab content */}
{activeTab === 'status' && ( onNotify(qtId)} onNotifyOne={(sessionId) => onNotifySession(sessionId, qtId)} onAward={(supplierId, supplierName, contractPrice, contractNote) => onAward(qtId, supplierId, supplierName, contractPrice, contractNote) } /> )} {activeTab === 'cards' && } {activeTab === 'chat' && ( )}
{targetSessionId && currentItem && (() => { const ts = sessionViews.find((s) => s.session_id === targetSessionId); if (!ts) return null; return ( 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 && ( { const newId = await onRegenerate(qtId, input); if (newId) { onSwitchRound(newId); // 새 라운드 상세로 전환 return true; } return false; }} onClose={() => setRegenOpen(false)} /> )}
, document.body, ); }