o2o-negosium-original/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx

375 lines
18 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,
chainRoundState,
} from '../../types';
import { QuotationStatus } from '@/api/generated/model';
import { DrawerHeaderCards } from './DrawerHeaderCards';
import { RoundTimeline } from './RoundTimeline';
import { RegenerateModal } from './RegenerateModal';
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) => Promise<boolean>;
/** 라운드 타임라인에서 다른 차수로 전환(같은 견적번호의 다른 견적 상세 열기). */
onSwitchRound: (qtId: string) => void;
/** 마감된 견적의 다음 라운드를 수동 생성(공급사 선택). 성공 시 새 qt_id 반환. */
onRegenerate: (qtId: string, supplierIds: string[]) => Promise<string | null>;
/** 협상 초청 메일 — 견적 단위(미발송 세션 전체) 발송. */
onNotify: (qtId: string) => Promise<void>;
/** 협상 초청 메일 — 세션(공급사) 단위 재발송. */
onNotifySession: (sessionId: string, qtId: string) => Promise<void>;
/** 넘기면 헤더에 삭제 버튼 노출(개발자 전용 — 페이지에서 권한 판정 후 주입). 삭제 성공 시 시트를 닫는다. */
onDelete?: (id: string, name: string) => void;
onClose: () => void;
};
export function QuotationDetailSheet({
quotation,
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 sessionStatusBySupplier: Record<string, number> = Object.fromEntries(
serverSessions.map((s) => [s.supplier_id, s.status]),
);
// 재생성 버튼은 '체인의 마지막 차수(마감됨)'에서만 노출. 체인 로딩 끝난 뒤 판정해 옛 라운드에서 깜빡임 방지.
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 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>
<div className="flex items-center gap-2 text-muted-foreground text-[10px] font-mono tracking-widest uppercase">
<span>견적 상세 // {q_number}</span>
</div>
<Typography variant="h3" className="mt-1">{q_name}</Typography>
{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>
{/* 견적 상세 정보 — 펼치면 탭과 flex 비율(헤더:탭 = 2:1)로 높이를 나눠 가지고 자체 스크롤 */}
{showHeaderCards ? (
<div
style={{ flex: '2 1 0%' }}
className="min-h-0 overflow-y-auto overscroll-contain px-6 pb-6 bg-muted/30 border-b border-border"
>
<DrawerHeaderCards
quotation={quotation}
quotationSettings={quotationSettings}
sessionViews={sessionViews}
currentProduct={currentProduct}
onShowTarget={setTargetSessionId}
/>
</div>
) : (
/* 접어도 결과 요약(목표가·낙찰·절감) 밴드는 상단에 그대로 남긴다. */
<div className="shrink-0 px-6 pt-4 pb-4 bg-muted/30 border-b border-border">
<DrawerHeaderCards
collapsed
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);
if (tab.id === 'chat') {
// 협상 대화 탭에선 대화 영역을 넓게 쓰도록 견적 상세 정보를 접는다.
setShowHeaderCards(false);
if (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) => onAward(qtId, supplierId, supplierName)}
/>
)}
{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 && (
<RegenerateModal
open
partners={connectedPartners}
sessionStatusBySupplier={sessionStatusBySupplier}
defaultSupplierIds={currentSupplierIds}
onConfirm={async (ids) => {
const newId = await onRegenerate(qtId, ids);
if (newId) {
onSwitchRound(newId); // 새 라운드 상세로 전환
return true;
}
return false;
}}
onClose={() => setRegenOpen(false)}
/>
)}
</div>,
document.body,
);
}