[feat] negodata: 견적·카드 검색/필터 서버사이드 전환 + 견적 상세 Sheet 리팩터

- 백엔드: quotation 목록 search 파라미터(name/number ILIKE) 추가; card 목록 is_wildcard 탭 필터 + total_nego/total_wild 탭 카운트 추가; status/type 코드 int 비교 보정
- 프론트: cards/quotation 검색·상태·유형 필터·페이지네이션을 useServerList 기반 서버사이드로 전환; 엔터 즉시검색 + 디바운스 500ms·최소 2글자; 죽은 client 필터 훅(useCardFilters/useQuotationFilters) 제거
- generated 파라미터/응답 타입 손보강(listCardsParams.is_wildcard, listQuotationsParams.search, resCardList.total_nego/total_wild) — orval 재생성 시 동일
- 견적 상세 Drawer→Sheet 분리, useOverlayParams→useOverlayRouter, CreateQuotationWizard→QuotationCreateModal

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-06-19 17:02:12 +09:00
parent c3d96fb7d3
commit 6fa024137f
36 changed files with 1172 additions and 967 deletions

View File

@ -2,7 +2,7 @@ from abc import ABC, abstractmethod
from datetime import datetime
from typing import Optional, Tuple
from sqlalchemy import select, func, and_, update
from sqlalchemy import select, func, and_, or_, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
@ -19,7 +19,7 @@ from common.utils.gtime import GTime
class IQuotationCRUD(ABC):
@abstractmethod
async def search(
self, cdb: AsyncSession, status, type_, start_from, start_to, skip, limit
self, cdb: AsyncSession, search, status, type_, start_from, start_to, skip, limit
) -> Tuple[ErrorType, list, int]:
pass
@ -88,6 +88,7 @@ class QuotationCRUD(IQuotationCRUD):
async def search(
self,
cdb: AsyncSession,
search: Optional[str],
status: Optional[str],
type_: Optional[str],
start_from: Optional[datetime],
@ -97,10 +98,12 @@ class QuotationCRUD(IQuotationCRUD):
) -> Tuple[ErrorType, list, int]:
try:
conditions = [quotations.deleted == False] # noqa: E712
if search:
conditions.append(or_(quotations.name.ilike(f"%{search}%"), quotations.number.ilike(f"%{search}%")))
if status:
conditions.append(quotations.status == status)
conditions.append(quotations.status == int(status)) # status/type 는 SMALLINT 코드 — 문자열 쿼리값을 정수로
if type_:
conditions.append(quotations.type == type_)
conditions.append(quotations.type == int(type_))
if start_from:
conditions.append(quotations.start_time >= start_from)
if start_to:

View File

@ -21,9 +21,10 @@ async def list_cards(
service: CardService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
search: str | None = Query(None, description="카드명/카드번호/스크립트 검색"),
is_wildcard: bool | None = Query(None, description="탭 필터: 미지정=전체 / false=협상카드 / true=와일드카드"),
pg: PageParams = Depends(),
):
return RemoveNoneResponse(await service.list_cards(user_info.user_id, search, pg))
return RemoveNoneResponse(await service.list_cards(user_info.user_id, search, is_wildcard, pg))
@router.post(path="/create", response_model=Res_Card, summary="협상카드 등록")

View File

@ -57,6 +57,8 @@ class Res_Card(Res_WebPacketProtocol):
class Res_CardList(Res_PageProtocol):
cards: list[CardData] = []
total_nego: int = 0 # 협상카드 탭 카운트(검색 필터 반영)
total_wild: int = 0 # 와일드카드 탭 카운트(검색 필터 반영)
class Res_DeleteCard(Res_WebPacketProtocol):

View File

@ -31,13 +31,14 @@ router = APIRouter(prefix="/v1/quotation", tags=["Quotation"], responses={404: {
async def list_quotations(
service: QuotationService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
search: str | None = Query(None, description="견적명/견적번호 검색"),
status: str | None = Query(None, description="상태 필터(정확히 일치)"),
type: str | None = Query(None, description="유형 필터(정확히 일치)"),
start_from: datetime | None = Query(None, description="시작일시 이후(ISO)"),
start_to: datetime | None = Query(None, description="시작일시 이전(ISO)"),
pg: PageParams = Depends(),
):
return RemoveNoneResponse(await service.list_quotations(status, type, start_from, start_to, pg))
return RemoveNoneResponse(await service.list_quotations(search, status, type, start_from, start_to, pg))
@router.post(path="/create", response_model=Res_CreateQuotation, summary="견적 생성")

View File

@ -79,18 +79,23 @@ class CardService:
return ErrorType.CARD_NOT_FOUND, None, None, None, False
# ---- 목록 ----------------------------------------------------------------
async def list_cards(self, user_id: str, search, pg: PageParams) -> Res_CardList:
async def list_cards(self, user_id: str, search, is_wildcard, pg: PageParams) -> Res_CardList:
"""is_wildcard: None=전체(두 테이블 머지) / False=협상카드만 / True=와일드카드만.
탭이 무엇이든 양쪽 카운트(total_nego/total_wild)는 항상 채운다(검색 필터 반영).
선택 안 된 탭은 limit=0 으로 카운트만 받아 행은 가져오지 않는다."""
res = Res_CardList(page=pg.page, size=pg.size)
if not user_id:
return res
user_uuid = uuid.UUID(user_id)
# 합쳐서 정렬/페이징하므로 각 테이블에서 skip+limit 까지 받아온다(카드 수가 적어 충분).
fetch = pg.skip + pg.size
nego_limit = 0 if is_wildcard is True else fetch
wild_limit = 0 if is_wildcard is False else fetch
err_n, nego_rows, total_n = await DB_SESSION_MNG.execute_lambda(
nego_cards.DBType(),
DBWRType.DB_READ.value,
lambda s: self.card_crud.search(s, nego_cards, user_uuid, search, 0, fetch),
lambda s: self.card_crud.search(s, nego_cards, user_uuid, search, 0, nego_limit),
)
if err_n != ErrorType.SUCCESS:
res.result.SetResult(err_n)
@ -99,7 +104,7 @@ class CardService:
err_w, wild_rows, total_w = await DB_SESSION_MNG.execute_lambda(
wild_cards.DBType(),
DBWRType.DB_READ.value,
lambda s: self.card_crud.search(s, wild_cards, user_uuid, search, 0, fetch),
lambda s: self.card_crud.search(s, wild_cards, user_uuid, search, 0, wild_limit),
)
if err_w != ErrorType.SUCCESS:
res.result.SetResult(err_w)
@ -108,7 +113,15 @@ class CardService:
merged = [self._nego_to_data(r) for r in nego_rows] + [self._wild_to_data(r) for r in wild_rows]
merged.sort(key=lambda c: c.created_at or "", reverse=True)
res.cards = merged[pg.skip : pg.skip + pg.size]
res.total = total_n + total_w
res.total_nego = total_n
res.total_wild = total_w
# 선택된 탭 기준 페이지네이션 총건수(전체=합산).
if is_wildcard is True:
res.total = total_w
elif is_wildcard is False:
res.total = total_n
else:
res.total = total_n + total_w
return res
# ---- 단건 조회 -----------------------------------------------------------

View File

@ -82,13 +82,13 @@ class QuotationService:
return ErrorType.QUOTATION_NOT_FOUND, None
return ErrorType.SUCCESS, quotation
async def list_quotations(self, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList:
async def list_quotations(self, search, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList:
res = Res_QuotationList(page=pg.page, size=pg.size)
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.search(s, status, type_, start_from, start_to, pg.skip, pg.size),
lambda s: self.quotation_crud.search(s, search, status, type_, start_from, start_to, pg.skip, pg.size),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)

View File

@ -10,6 +10,10 @@ export type ListCardsParams = {
* 카드명/카드번호/스크립트 검색
*/
search?: string | null;
/**
* 탭 필터: 미지정=전체 / false=협상카드 / true=와일드카드
*/
is_wildcard?: boolean | null;
/**
* @minimum 1
*/

View File

@ -6,6 +6,10 @@
*/
export type ListQuotationsParams = {
/**
* 견적명/견적번호 검색
*/
search?: string | null;
/**
* 상태 필터(정확히 일치)
*/

View File

@ -15,4 +15,8 @@ export interface ResCardList {
page?: number;
size?: number;
cards?: CardData[];
/** 협상카드 탭 카운트(검색 필터 반영) */
total_nego?: number;
/** 와일드카드 탭 카운트(검색 필터 반영) */
total_wild?: number;
}

View File

@ -1,4 +1,4 @@
import React, { useState, useRef, DragEvent, ChangeEvent } from 'react';
import React, { useState, useRef, useEffect, DragEvent, ChangeEvent } from 'react';
import { Upload, Image as ImageIcon, X, AlertCircle, Link2, Loader2 } from 'lucide-react';
interface ImageDropzoneProps {
@ -31,6 +31,12 @@ export default function ImageDropzone({
const [urlDraft, setUrlDraft] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
// 현재 값이 일반 URL 이면 입력칸에 그대로 노출(보기/수정/복사 가능).
// base64 data URL(파일 업로드 폴백)은 거대 문자열이라 칸엔 넣지 않는다.
useEffect(() => {
setUrlDraft(value && /^https?:\/\//i.test(value) ? value : '');
}, [value]);
const processFile = async (file: File) => {
setError(null);
@ -107,8 +113,7 @@ export default function ImageDropzone({
const url = urlDraft.trim();
if (!url) return;
setError(null);
onChange(url);
setUrlDraft('');
onChange(url); // value 변경 → 위 effect 가 입력칸을 적용된 URL 로 다시 채움
};
return (

View File

@ -97,10 +97,7 @@ export function DataTable<T>({
const detailCols = mobileCols.filter((c) => c !== primaryCol)
return (
// 전환 기준은 뷰포트가 아니라 '표가 들어갈 실제 폭'(컨테이너). 사이드바가 폭을 먹어도
// 어긋나지 않는다. 컨테이너 ≥ 48rem(@3xl)이면 표, 그 미만은 카드 리스트. (Tailwind v4 내장 @container)
<div className={cn("@container border border-border rounded-lg bg-card overflow-hidden", className)}>
{/* 넓을 때: 표 — 그래도 넘치면 Table 내부에서 가로 스크롤 */}
<div className="hidden @3xl:block">
<Table className="w-full text-xs">
<TableHeader>
@ -184,7 +181,6 @@ export function DataTable<T>({
</Table>
</div>
{/* 좁을 때(컨테이너 < 48rem): 카드 리스트 — 행=카드, 컬럼=라벨:값 */}
<div className="@3xl:hidden divide-y divide-border">
{data.length > 0 ? (
data.map((row) => {
@ -218,7 +214,6 @@ export function DataTable<T>({
</div>
)}
{/* 상세: 기본=라벨:값 한 줄(컴팩트) / mobileBlock=라벨 아래 풀폭 */}
{detailCols.length > 0 && (
<dl className="mt-2.5 space-y-1.5 border-t border-border/40 pt-2.5 text-[11px] leading-tight">
{detailCols.map((c, i) =>

View File

@ -54,10 +54,11 @@ export function CardTable({ data, onEdit, footer }: CardTableProps) {
},
{
header: '스크립트',
headClassName: 'w-[22rem]', // 컬럼 폭 고정 → 긴 스크립트가 표를 늘리지 않게
cellClassName: 'font-mono text-muted-foreground',
mobileBlock: true, // 긴 미리보기 블록 → 모바일 카드뷰에서 라벨 아래 풀폭
cell: (card) => (
<div className="line-clamp-1 bg-muted/20 px-2 py-1 rounded border border-border/30 text-[11px] leading-snug">
<div className="max-w-[22rem] truncate bg-muted/20 px-2 py-1 rounded border border-border/30 text-[11px] leading-snug">
{card.scriptPreview}
</div>
),

View File

@ -1,27 +0,0 @@
import { useState } from 'react';
import type { NegotiationCard, CardTab } from '../types';
// 카드 목록의 탭(전체/협상/와일드) + 검색 필터 state와 파생 결과/카운트.
export function useCardFilters(cards: NegotiationCard[]) {
const [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState<CardTab>('ALL');
const filtered = cards.filter((card) => {
const matchesTab =
activeTab === 'ALL' || (activeTab === 'WILD' ? card.isWildcard : !card.isWildcard);
const q = search.toLowerCase();
const matchesSearch =
card.title.toLowerCase().includes(q) ||
card.code.toLowerCase().includes(q) ||
card.scriptPreview.toLowerCase().includes(q);
return matchesTab && matchesSearch;
});
const counts = {
all: cards.length,
card: cards.filter((c) => !c.isWildcard).length,
wild: cards.filter((c) => c.isWildcard).length,
};
return { search, setSearch, activeTab, setActiveTab, filtered, counts };
}

View File

@ -1,11 +1,11 @@
import { useQueryClient } from '@tanstack/react-query';
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import {
useListCards,
createCard,
updateCard,
deleteCard,
getListCardsQueryKey,
} from '@/api/generated/card/card';
import type { ListCardsParams } from '@/api/generated/model/listCardsParams';
import type { Descendant } from 'slate';
import type { ReqCreateCard } from '@/api/generated/model/reqCreateCard';
import type { ResCard } from '@/api/generated/model/resCard';
@ -13,8 +13,6 @@ import type { NegotiationCard } from '@/types';
import { mapCardData, toCardStatusCode } from '../types';
import { serializeToText } from '../editor';
const LIST_PARAMS = { size: 100 };
// 카드 폼이 넘기는 입력값(편집/생성 공통).
// 스크립트는 Slate JSON(editorScript)을 정본으로 받고, 평문 script 는 저장 시 직렬화로 파생한다.
export type CardInput = {
@ -50,12 +48,13 @@ function toReq(input: CardInput): ReqCreateCard {
// 협상카드 카탈로그 서버 데이터 + CRUD. orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회).
// 실패 시 throw → 호출부(폼/페이지)에서 toast 처리.
export function useCards() {
export function useCards(params: ListCardsParams) {
const queryClient = useQueryClient();
const cardsQuery = useListCards(LIST_PARAMS);
// 테이블용(현재 페이지). 페이지 이동 시 placeholderData 로 이전 데이터 유지(깜빡임 방지).
const cardsQuery = useListCards(params, { query: { placeholderData: keepPreviousData } });
const refresh = () =>
queryClient.invalidateQueries({ queryKey: getListCardsQueryKey(LIST_PARAMS) });
// 변경 후 모든 카드 목록 쿼리(파라미터별 키 전부) 재조회.
const refresh = () => queryClient.invalidateQueries({ queryKey: ['/v1/card/list'] });
const createCardFn = async (input: CardInput) => {
const msg = cardError(await createCard(toReq(input)));
@ -74,9 +73,15 @@ export function useCards() {
// customFetch 가 본문을 그대로 주므로 cardsQuery.data 가 곧 ResCardList → .cards.
const cards: NegotiationCard[] = (cardsQuery.data?.cards ?? []).map(mapCardData);
const total = cardsQuery.data?.total ?? 0; // 선택 탭 기준 총건수(페이지네이션)
const totalNego = cardsQuery.data?.total_nego ?? 0; // 협상카드 탭 카운트
const totalWild = cardsQuery.data?.total_wild ?? 0; // 와일드카드 탭 카운트
return {
cards,
total,
totalNego,
totalWild,
createCard: createCardFn,
updateCard: updateCardFn,
deleteCard: deleteCardFn,

View File

@ -27,9 +27,7 @@ function supplierError(res: ResSupplier): string | null {
return r.desc || '협력사 등록에 실패했습니다.';
}
// 협력사 서버 데이터 + CRUD.
// - params: 테이블용 서버 페이지네이션/검색/우선순위 (useServerList 가 만든다)
// orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회). 실패 시 throw → 호출부에서 toast 처리.
export function usePartners(params: ListSuppliersParams) {
const queryClient = useQueryClient();

View File

@ -33,7 +33,7 @@ export function ProductTable({
rowKey={(prod) => prod.item_id}
onRowClick={onRowClick}
selection={{ selectedKeys: selectedIds, onSelectionChange }}
empty="부합하는 B2B 상품 데이터 정보가 식별되지 않습니다."
empty="부합하는 상품 데이터 정보가 식별되지 않습니다."
footer={
<TablePagination
page={page}

View File

@ -7,7 +7,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
import type { CreateQuotationInput } from '../hooks/useQuotations';
type CreateQuotationWizardProps = {
type QuotationCreateModalProps = {
open: boolean;
products: Product[];
partners: Partner[];
@ -17,7 +17,7 @@ type CreateQuotationWizardProps = {
onClose: () => void;
};
export function CreateQuotationWizard({
export function QuotationCreateModal({
open,
products,
partners,
@ -25,7 +25,7 @@ export function CreateQuotationWizard({
quotationSettings,
onCreate,
onClose,
}: CreateQuotationWizardProps) {
}: QuotationCreateModalProps) {
const [step, setStep] = useState(1);
const [title, setTitle] = useState('');
const [type, setType] = useState<'RE_NEGOTIATION' | 'RE_ESTIMATE'>('RE_NEGOTIATION');

View File

@ -1,741 +0,0 @@
import { useState } from 'react';
import { Link } from 'react-router';
import {
StopCircle,
X,
UserCheck,
MessageSquare,
Layers,
Sparkles,
Package,
ExternalLink,
Copy,
} from 'lucide-react';
import { showToast } from '@/lib/notify';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { Typography } from '@/components/ui/typography';
import { Input } from '@/components/ui/input';
import SlateRenderer from '@/components/SlateRenderer';
import {
useGetQuotationSessions,
useGetSessionChat,
useGetQuotationCards,
} from '@/api/generated/quotation/quotation';
import {
type Estimate,
type Product,
type Partner,
type QuotationSetting,
normalizeQuotationStatus,
buildBidSummary,
mapServerSessionView,
sessionStatusLabel,
mapServerCardView,
} from '../types';
type DrawerTab = 'status' | 'cards' | 'chat';
type QuotationDetailDrawerProps = {
estimate: Estimate;
products: Product[];
partners: Partner[];
quotationSettings: QuotationSetting[];
onStop: (id: string, name: string) => void;
onClose: () => void;
};
export function QuotationDetailDrawer({
estimate,
products,
partners,
quotationSettings,
onStop,
onClose,
}: QuotationDetailDrawerProps) {
const [activeTab, setActiveTab] = useState<DrawerTab>('status');
const [showHeaderCards, setShowHeaderCards] = useState(true);
const qtId = estimate.id ?? '';
// 협상 세션·사용 카드는 견적 단위, 채팅은 선택 세션 단위로 서버에서 읽는다.
const sessionsQuery = useGetQuotationSessions(qtId, { query: { enabled: !!qtId } });
const cardsQuery = useGetQuotationCards(qtId, { query: { enabled: !!qtId } });
const serverSessions = sessionsQuery.data?.sessions ?? [];
const serverCards = cardsQuery.data?.cards ?? [];
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const effectiveSessionId = selectedSessionId ?? serverSessions[0]?.session_id ?? null;
const chatQuery = useGetSessionChat(effectiveSessionId ?? '', {
query: { enabled: !!effectiveSessionId },
});
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 currentProduct = products.find((p) => p.id === currentSession?.item_id);
// 상품 상세 패널 행(negowiz 협상대화의 상품 정보 대응). negodata 컬럼명: maker_name→manufacturer, min_order_quantity→moq.
const fmtYn = (b: boolean | null | undefined, yes: string, no: string) => (b == null ? '-' : b ? yes : no);
const productSpecRows = currentProduct
? [
{ label: '상품코드', value: currentProduct.code || '-' },
{ label: '단가', value: currentProduct.price != null ? `₩${Number(currentProduct.price).toLocaleString()}` : '-' },
{ label: '모델명', value: currentProduct.model_name || '-' },
{ label: '규격', value: currentProduct.spec || '-' },
{ label: '제조사', value: currentProduct.manufacturer || '-' },
{ label: '원산지', value: currentProduct.made_in || '-' },
{ label: 'MOQ', value: currentProduct.moq || '-' },
{ label: '리드타임', value: currentProduct.lead_time != null ? `${currentProduct.lead_time}일` : '-' },
{ label: 'VAT', value: fmtYn(currentProduct.vat_yn, '포함', '별도') },
{ label: '배송비', value: fmtYn(currentProduct.delivery_fee_yn, '포함', '별도') },
]
: [];
const selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === estimate.settingApplied);
const bidSummaryObj = buildBidSummary(estimate, partners);
const sessionViews = serverSessions.map((sd) => mapServerSessionView(sd, partners, products));
const quotationCardViews = serverCards.map(mapServerCardView);
// Quotations DDL 표시값
const q_name = estimate.name || estimate.title || '미지정';
const q_number = estimate.number || 'EST-000000-0000';
const q_type = estimate.type || '1:1';
const q_round = estimate.round || 1;
const q_status = estimate.status || '견적생성';
const q_end_time = estimate.end_time || estimate.dueDate || '미지정';
const q_manager_name = estimate.manager_name || '홍길동 파트너';
const q_manager_email = estimate.manager_email || 'gildong@negodata.com';
const q_memo = estimate.memo || '안내사항 없음';
const statusKey = normalizeQuotationStatus(q_status);
const goToChat = (sessionId: string) => {
setSelectedSessionId(sessionId);
setActiveTab('chat');
};
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 },
];
return (
<div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-xs flex justify-end animate-fade-in">
<div className="flex-1 cursor-pointer" onClick={onClose} />
<div className="w-full max-w-5xl bg-card border-l border-border h-full flex flex-col justify-between shadow-2xl overflow-hidden animate-slide-left">
{/* Header */}
<div className="p-6 border-b border-border 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>B2B 견적 상세 // {q_number}</span>
</div>
<Typography variant="h3" className="mt-1">{q_name}</Typography>
</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>
{statusKey === '견적진행중' && (
<button
onClick={() => onStop(estimate.id ?? '', q_name)}
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"
>
<StopCircle size={14} />
<span>중지</span>
</button>
)}
<button
onClick={onClose}
className="p-1.5 rounded-full text-muted-foreground hover:bg-muted cursor-pointer"
>
<X size={20} />
</button>
</div>
</div>
{/* DB mapping info cards */}
{showHeaderCards && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4 text-xs font-mono">
{/* 좌측 컬럼: 견적정보 + 진행상태 */}
<div className="space-y-4">
{/* Quotations */}
<div className="p-3 bg-card border border-border/80 rounded shadow-xs space-y-2">
<span className="font-bold text-foreground text-[11px] border-b border-border pb-1 block font-sans">
견적 정보
</span>
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
<div>
<span className="text-[10px] block opacity-70">견적명</span>
<span className="text-foreground font-semibold font-sans">{q_name}</span>
</div>
<div>
<span className="text-[10px] block opacity-70">견적번호</span>
<span className="text-foreground font-semibold">{q_number}</span>
</div>
<div>
<span className="text-[10px] block opacity-70">유형</span>
<span className="text-foreground font-semibold">
{q_type === 'RE_NEGOTIATION' ? '재협상' : '재견적'}
</span>
</div>
<div>
<span className="text-[10px] block opacity-70">차수</span>
<span className="text-foreground font-semibold">{q_round}차</span>
</div>
<div>
<span className="text-[10px] block opacity-90 font-bold mb-1">견적상태</span>
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 text-[11px] font-bold rounded-full border shadow-2xs ${
statusKey === '견적생성'
? 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-700/50 animate-pulse'
: statusKey === '견적진행중'
? 'bg-emerald-100 text-emerald-800 border-emerald-300 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-700/50'
: statusKey === '견적마감'
? 'bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-950/40 dark:text-blue-300 dark:border-blue-700/50'
: statusKey === '협상보류'
? 'bg-rose-100 text-rose-800 border-rose-300 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-700/50'
: 'bg-zinc-100 text-zinc-800 border-zinc-300'
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
statusKey === '견적생성'
? 'bg-amber-500'
: statusKey === '견적진행중'
? 'bg-emerald-500'
: statusKey === '견적마감'
? 'bg-blue-500'
: statusKey === '협상보류'
? 'bg-rose-500'
: 'bg-zinc-500'
}`}
/>
{statusKey || q_status}
</span>
</div>
<div>
<span className="text-[10px] block opacity-70">마감시각</span>
<span className="text-foreground font-semibold">{q_end_time}</span>
</div>
<div>
<span className="text-[10px] block opacity-70">담당자</span>
<span className="text-foreground font-semibold font-sans">{q_manager_name} ({q_manager_email})</span>
</div>
<div>
<span className="text-[10px] block opacity-70">메모</span>
<span className="text-foreground font-semibold font-sans truncate block" title={q_memo || ''}>
{q_memo}
</span>
</div>
</div>
</div>
{/* Bid Summary */}
<div className="p-3 bg-card border border-border/80 rounded shadow-xs space-y-2">
<span className="font-bold text-foreground text-[11px] border-b border-border pb-1 block font-sans">
견적 진행상태/결과
</span>
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
<div>
<span className="text-[10px] block opacity-70">식별자</span>
<span className="text-foreground font-semibold">{bidSummaryObj.bid_summary_id}</span>
</div>
<div>
<span className="text-[10px] block opacity-70">진행/결과 상태</span>
<span className="text-foreground font-semibold">{bidSummaryObj.status}</span>
</div>
<div>
<span className="text-[10px] block opacity-70">반복횟수</span>
<span className="text-foreground font-semibold">{bidSummaryObj.qt_iteration}회</span>
</div>
<div>
<span className="text-[10px] block opacity-70">우선협상자 존재여부</span>
<span className={`text-foreground font-semibold ${bidSummaryObj.has_preferred ? 'text-emerald-600 font-bold' : ''}`}>
{bidSummaryObj.has_preferred ? '존재' : '미존재'}
</span>
</div>
<div className="col-span-2">
<span className="text-[10px] block opacity-70">우선협상자명</span>
<span className="text-foreground font-semibold font-sans">{bidSummaryObj.preferred_sp_name}</span>
</div>
<div className="col-span-2">
<span className="text-[10px] block opacity-70">동가입찰정보</span>
<code className="text-foreground font-semibold bg-muted/60 p-1 rounded text-[10px] block overflow-x-auto whitespace-pre">
{bidSummaryObj.equal_data}
</code>
</div>
</div>
</div>
</div>
{/* 우측 컬럼: 상품정보 + 세팅 */}
<div className="space-y-4">
{/* 상품 정보 (협상 대상 상품) */}
<div className="p-3 bg-card border border-border/80 rounded shadow-xs space-y-2">
<span className="font-bold text-foreground text-[11px] border-b border-border pb-1 block font-sans">
상품 정보
</span>
{currentProduct ? (
<div className="flex gap-4">
<div className="h-24 w-24 shrink-0 rounded-md border border-border bg-background overflow-hidden flex items-center justify-center">
{currentProduct.image_url ? (
<img
src={currentProduct.image_url}
alt={currentProduct.name || '상품'}
className="h-full w-full object-cover"
/>
) : (
<Package size={28} className="text-muted-foreground/50" />
)}
</div>
<div className="flex-1 min-w-0">
<Link
to={`/products?edit=${currentProduct.id}`}
className="text-foreground font-bold font-sans text-[12px] mb-2 truncate block hover:text-primary hover:underline"
title={`${currentProduct.name || ''} — 상품 상세로 이동`}
>
{currentProduct.name || '-'}
</Link>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-x-3 gap-y-1.5 font-mono text-muted-foreground">
{productSpecRows.map((r) => (
<div key={r.label}>
<span className="text-[10px] block opacity-70">{r.label}</span>
<span className="text-foreground font-semibold font-sans truncate block" title={r.value}>
{r.value}
</span>
</div>
))}
</div>
</div>
</div>
) : (
<div className="text-muted-foreground py-6 text-center">상품 정보가 비어있습니다.</div>
)}
</div>
{/* Quotation Settings */}
<div className="p-3 bg-card border border-border/80 rounded shadow-xs space-y-2">
<span className="font-bold text-foreground text-[11px] border-b border-border pb-1 block font-sans">
견적 세팅
</span>
{selectedSettingObj ? (
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
<div>
<span className="text-[10px] block opacity-70">목표 마진율</span>
<span className="text-foreground font-bold text-emerald-600 dark:text-emerald-400 font-sans">{selectedSettingObj.target_margin}</span>
</div>
<div>
<span className="text-[10px] block opacity-70">앵커링 설정 값</span>
<span className="text-foreground font-semibold font-sans">{selectedSettingObj.anchoring_value}</span>
</div>
<div className="col-span-2 col-start-1">
<span className="text-[10px] block opacity-70">카드 사용 횟수</span>
<span className="text-foreground font-semibold">{selectedSettingObj.card_use_count}</span>
</div>
</div>
) : (
<div className="text-muted-foreground py-6 text-center">적용된 견적 세팅이 비어있습니다.</div>
)}
</div>
</div>
</div>
)}
</div>
{/* Tabs */}
<div className="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 className="flex-1 p-6 overflow-y-auto bg-background/50">
{/* Tab: Sessions Status */}
{activeTab === 'status' && (
<div className="space-y-4">
<div className="border border-border rounded-lg bg-card overflow-x-auto">
<Table className="w-full text-left text-xs border-collapse font-mono min-w-[1250px]">
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
<TableRow>
<TableHead className="p-3 font-semibold">세션 ID</TableHead>
<TableHead className="p-3 font-semibold font-sans">협력사</TableHead>
<TableHead className="p-3 font-semibold font-sans">협상 URL</TableHead>
<TableHead className="p-3 font-semibold font-sans">상품</TableHead>
<TableHead className="p-3 font-semibold text-center font-sans">협상상태</TableHead>
<TableHead className="p-3 font-semibold text-right">목표가</TableHead>
<TableHead className="p-3 font-semibold text-right">투찰가</TableHead>
<TableHead className="p-3 font-semibold">투찰시각</TableHead>
<TableHead className="p-3 font-semibold">마감시각</TableHead>
<TableHead className="p-3 font-semibold font-sans">거절사유</TableHead>
<TableHead className="p-3 font-semibold text-right">거절가격</TableHead>
<TableHead className="p-3 font-semibold font-sans">거절배송방식</TableHead>
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-border">
{sessionViews.length === 0 && (
<TableRow>
<TableCell colSpan={12} className="p-12 text-center text-muted-foreground">
참여 중인 협상 세션이 없습니다. (리스트가 비어 있습니다)
</TableCell>
</TableRow>
)}
{sessionViews.map((sess) => (
<TableRow key={sess.session_id} className="hover:bg-muted/30 transition-colors text-[11px]">
<TableCell className="p-3 text-muted-foreground font-mono">{sess.session_id}</TableCell>
<TableCell className="p-3 font-bold text-foreground font-sans">
<div className="flex items-center gap-2">
<span>{sess.supplier_name}</span>
<button
onClick={() => goToChat(sess.session_id)}
title="협상 대화방으로 이동"
className="p-1 hover:bg-primary/10 rounded text-primary hover:text-primary/80 transition-colors cursor-pointer"
>
<MessageSquare size={13} />
</button>
</div>
</TableCell>
<TableCell className="p-3 font-mono">
{sess.url ? (
<div className="flex items-center gap-1.5">
<a
href={sess.url}
target="_blank"
rel="noopener noreferrer"
title={sess.url}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors text-[10px] font-semibold"
>
<ExternalLink size={11} /> 세션 열기
</a>
<button
onClick={() => {
void navigator.clipboard?.writeText(sess.url);
showToast('협상 URL을 복사했습니다.', 'success');
}}
title="협상 URL 복사"
className="p-1 hover:bg-muted rounded text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<Copy size={12} />
</button>
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="p-3 font-semibold font-sans">{sess.item_name}</TableCell>
<TableCell className="p-3 text-center">
<span
className={`inline-flex px-2 py-0.5 rounded-full text-[10px] font-bold ${
sess.status === '협상완료' || sess.status === 'COMPLETED'
? 'bg-blue-100 text-blue-800 dark:bg-blue-950/20 dark:text-blue-300'
: sess.status === '협상거부' || sess.status === 'REJECTED'
? 'bg-red-100 text-red-800 dark:bg-red-950/20 dark:text-red-300'
: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/20 dark:text-emerald-300'
}`}
>
{sess.status}
</span>
</TableCell>
<TableCell className="p-3 text-right font-bold text-muted-foreground">
₩{sess.target_price?.toLocaleString() || '-'}
</TableCell>
<TableCell className="p-3 text-right font-bold text-foreground">
{sess.bid_price ? `₩${sess.bid_price.toLocaleString()}` : '-'}
</TableCell>
<TableCell className="p-3 text-muted-foreground">{sess.bid_at || '-'}</TableCell>
<TableCell className="p-3 text-muted-foreground font-sans">{sess.end_time || '-'}</TableCell>
<TableCell className="p-3 text-rose-600 font-sans">{sess.reject_reason || '-'}</TableCell>
<TableCell className="p-3 text-right text-rose-600 font-mono">{sess.reject_price ? `₩${sess.reject_price.toLocaleString()}` : '-'}</TableCell>
<TableCell className="p-3 text-muted-foreground font-sans">{sess.reject_delivery_type || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{/* Tab: Quotation Cards */}
{activeTab === 'cards' && (
<div className="space-y-4">
<div className="border border-border rounded-lg bg-card overflow-hidden">
<Table className="w-full text-left text-xs border-collapse font-mono">
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
<TableRow>
<TableHead className="p-3 font-semibold">세션 카드 ID</TableHead>
<TableHead className="p-3 font-semibold font-sans">카드 이름</TableHead>
<TableHead className="p-3 font-semibold font-sans">타입</TableHead>
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-border">
{quotationCardViews.length > 0 ? (
quotationCardViews.map((qc) => (
<TableRow key={qc.session_card_id} className="hover:bg-muted/30 transition-colors text-[11px]">
<TableCell className="p-3 text-muted-foreground">{qc.session_card_id}</TableCell>
<TableCell className="p-3 text-foreground font-sans font-semibold">
{qc.card_id ? (
<Link
to={`/cards?edit=${qc.card_id}`}
className="hover:text-primary hover:underline"
title={`${qc.card_name} — 협상카드 상세로 이동`}
>
{qc.card_name}
</Link>
) : (
qc.card_name
)}
</TableCell>
<TableCell className="p-3">
<span
className={`inline-flex px-2 py-0.5 rounded text-[10px] font-bold ${
qc.type === '와일드 카드'
? 'bg-amber-100 text-amber-800 dark:bg-amber-950/20 dark:text-amber-300'
: 'bg-zinc-100 text-zinc-800 dark:bg-zinc-800/40 dark:text-zinc-300'
}`}
>
{qc.type}
</span>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={3} className="p-12 text-center text-muted-foreground">
사용된 협상 카드가 없습니다. (리스트가 비어 있습니다)
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
)}
{/* Tab: Chat */}
{activeTab === 'chat' && (
<div className="h-[500px] border border-border rounded-lg overflow-hidden bg-card flex">
{/* Sessions list */}
<div className="w-1/3 border-r border-border bg-muted/20 flex flex-col">
<div className="p-3 border-b border-border bg-muted/40 font-mono text-[10px] text-muted-foreground uppercase">
참여자 협력사 리스트
</div>
<div className="flex-1 overflow-y-auto divide-y divide-border font-sans">
{serverSessions.length === 0 && (
<div className="p-4 text-center text-muted-foreground text-xs font-mono">
참여 협상 세션이 없습니다. (리스트가 비어 있습니다)
</div>
)}
{serverSessions.map((sd) => {
const isSelected = sd.session_id === effectiveSessionId;
const name = partners.find((p) => p.id === sd.supplier_id)?.name || sd.supplier_id;
const statusLabel = sessionStatusLabel(sd.status);
return (
<button
key={sd.session_id}
onClick={() => setSelectedSessionId(sd.session_id)}
className={`w-full text-left p-3 flex flex-col justify-between transition-colors cursor-pointer ${
isSelected ? 'bg-primary/5 border-l-4 border-primary' : 'hover:bg-muted/30'
}`}
>
<div className="flex items-center justify-between">
<span className="font-bold text-foreground text-xs">{name}</span>
<span
className={`text-[9px] font-bold px-1.5 py-0.5 rounded ${
statusLabel === '협상거부'
? 'bg-rose-100 text-rose-800 dark:bg-rose-950/30 dark:text-rose-300'
: statusLabel === '협상완료'
? 'bg-blue-100 text-blue-800 dark:bg-blue-950/30 dark:text-blue-300'
: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300'
}`}
>
{statusLabel}
</span>
</div>
<div className="flex items-center justify-between text-[10px] font-mono text-muted-foreground mt-2">
<span>최종 제의</span>
<span className="font-bold text-foreground">
{sd.bid_price ? `₩${Number(sd.bid_price).toLocaleString()}` : '-'}
</span>
</div>
</button>
);
})}
</div>
</div>
{/* Chat zone */}
<div className="flex-1 flex flex-col bg-background justify-between">
<div className="p-3 bg-muted/30 border-b border-border text-xs flex items-center justify-between font-mono">
<div className="text-muted-foreground">
협력사: <strong className="text-foreground">{currentSupplierName}</strong>
</div>
<div className="text-xs text-muted-foreground">
기록: <span className="font-semibold text-foreground">{chatMessages.length}</span> 메시지
</div>
</div>
<div className="flex-1 p-4 overflow-y-auto space-y-4">
{!effectiveSessionId ? (
<div className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs">
선택된 협력사가 없습니다.
</div>
) : chatMessages.length === 0 ? (
<div className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs">
기록된 협상 대화가 없습니다.
</div>
) : (
chatMessages.map((m) => {
const isBot = m.sender === 1;
// 메시지가 쓴 협상카드 전체(이름만이 아니라 멘트/조건/메모까지) 를 chat_id 로 매칭.
const usedCard = m.card_used_yn
? serverCards.find((c) => c.session_card_id === m.chat_id)
: undefined;
const cardNodes = Array.isArray(usedCard?.edit_script) ? (usedCard.edit_script as unknown[]) : null;
const isWildCard = usedCard?.type === 2;
return (
<div
key={m.chat_id}
className={`flex ${isBot ? 'justify-start' : 'justify-end'}`}
>
<div className="space-y-1 max-w-[85%]">
<div className={`text-[10px] text-muted-foreground font-mono flex items-center gap-1.5 ${isBot ? '' : 'justify-end'}`}>
<span>{isBot ? 'Negosium Bot' : currentSupplierName}</span>
<span>·</span>
<span>#{m.index}</span>
</div>
<div
className={`p-3 rounded-md border text-xs shadow-xs ${
isBot ? 'bg-secondary border-border text-foreground' : 'bg-primary border-transparent text-primary-foreground'
}`}
>
<div className="font-bold">제시 단가 ₩{Number(m.target_price).toLocaleString()}</div>
{usedCard && (
<div
className={`mt-2 rounded border p-2 ${
isBot
? 'bg-amber-50/70 border-amber-200 dark:bg-amber-950/20 dark:border-amber-900/40'
: 'bg-white/10 border-white/20'
}`}
>
{/* 헤더: 어떤 카드인지(번호·이름·종류) */}
<div
className={`flex items-center gap-1 text-[10px] font-semibold ${
isBot ? 'text-amber-800 dark:text-amber-300' : 'text-primary-foreground'
}`}
>
<Sparkles size={10} />
<span>협상카드</span>
{usedCard.number && <span className="font-mono opacity-70">#{usedCard.number}</span>}
{usedCard.name && <span>· {usedCard.name}</span>}
<span
className={`ml-auto px-1.5 py-0.5 rounded font-bold ${
isWildCard
? 'bg-amber-200 text-amber-900 dark:bg-amber-400/25 dark:text-amber-100'
: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-600/40 dark:text-zinc-100'
}`}
>
{isWildCard ? '와일드' : '협상'}
</span>
</div>
{/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script */}
{cardNodes ? (
<div className="mt-1.5">
<SlateRenderer
nodes={cardNodes}
variables={{
target_price: m.target_price,
partner_name: currentSupplierName,
product_name: currentProduct?.name ?? '',
}}
/>
</div>
) : usedCard.script ? (
<p className="mt-1.5 text-xs leading-relaxed whitespace-pre-line text-foreground/85">
{usedCard.script}
</p>
) : null}
{/* 와일드카드 부가 정보: 사용 조건 / 메모 */}
{isWildCard && (usedCard.condition || usedCard.memo) && (
<div className="mt-1.5 pt-1.5 border-t border-amber-200/60 dark:border-amber-900/40 space-y-0.5 text-[10px] text-muted-foreground">
{usedCard.condition && (
<div>
<span className="font-semibold">조건:</span> {usedCard.condition}
</div>
)}
{usedCard.memo && (
<div>
<span className="font-semibold">메모:</span> {usedCard.memo}
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
})
)}
</div>
<div className="p-3 border-t border-border bg-muted/20 flex gap-2">
<Input
type="text"
disabled
placeholder="이 대화방은 입찰 참여 세션 기록이므로 정독 전용입니다."
className="flex-1 text-xs text-muted-foreground"
/>
<button
disabled
className="py-1.5 px-3 bg-muted text-muted-foreground text-xs rounded border border-border cursor-not-allowed"
>
전송
</button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,211 @@
import { Sparkles } from 'lucide-react';
import type { SessionData } from '@/api/generated/model/sessionData';
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
import type { ChatMessageData } from '@/api/generated/model/chatMessageData';
import { Input } from '@/components/ui/input';
import SlateRenderer from '@/components/SlateRenderer';
import { StatusPill, chatStatusTone } from './StatusPill';
import { type Product, type Partner, sessionStatusLabel } from '../../types';
export function ChatTab({
serverSessions,
partners,
effectiveSessionId,
onSelectSession,
chatMessages,
currentSupplierName,
currentProduct,
serverCards,
}: {
serverSessions: SessionData[];
partners: Partner[];
effectiveSessionId: string | null;
onSelectSession: (sessionId: string) => void;
chatMessages: ChatMessageData[];
currentSupplierName: string;
currentProduct: Product | undefined;
serverCards: QuotationCardData[];
}) {
return (
<div className="h-[500px] border border-border rounded-lg overflow-hidden bg-card flex">
{/* Sessions list */}
<div className="w-1/3 border-r border-border bg-muted/20 flex flex-col">
<div className="p-3 border-b border-border bg-muted/40 font-mono text-[10px] text-muted-foreground uppercase">
참여자 협력사 리스트
</div>
<div className="flex-1 overflow-y-auto divide-y divide-border font-sans">
{serverSessions.length === 0 && (
<div className="p-4 text-center text-muted-foreground text-xs font-mono">
참여 협상 세션이 없습니다. (리스트가 비어 있습니다)
</div>
)}
{serverSessions.map((sd) => {
const isSelected = sd.session_id === effectiveSessionId;
const name = partners.find((p) => p.id === sd.supplier_id)?.name || sd.supplier_id;
const statusLabel = sessionStatusLabel(sd.status);
return (
<button
key={sd.session_id}
onClick={() => onSelectSession(sd.session_id)}
className={`w-full text-left p-3 flex flex-col justify-between transition-colors cursor-pointer ${
isSelected ? 'bg-primary/5 border-l-4 border-primary' : 'hover:bg-muted/30'
}`}
>
<div className="flex items-center justify-between">
<span className="font-bold text-foreground text-xs">{name}</span>
<StatusPill tone={chatStatusTone(statusLabel)} className="text-[9px] px-1.5 rounded">
{statusLabel}
</StatusPill>
</div>
<div className="flex items-center justify-between text-[10px] font-mono text-muted-foreground mt-2">
<span>최종 제의</span>
<span className="font-bold text-foreground">
{sd.bid_price ? `₩${Number(sd.bid_price).toLocaleString()}` : '-'}
</span>
</div>
</button>
);
})}
</div>
</div>
{/* Chat zone */}
<div className="flex-1 flex flex-col bg-background justify-between">
<div className="p-3 bg-muted/30 border-b border-border text-xs flex items-center justify-between font-mono">
<div className="text-muted-foreground">
협력사: <strong className="text-foreground">{currentSupplierName}</strong>
</div>
<div className="text-xs text-muted-foreground">
기록: <span className="font-semibold text-foreground">{chatMessages.length}</span> 메시지
</div>
</div>
<div className="flex-1 p-4 overflow-y-auto space-y-4">
{!effectiveSessionId ? (
<div className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs">
선택된 협력사가 없습니다.
</div>
) : chatMessages.length === 0 ? (
<div className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs">
기록된 협상 대화가 없습니다.
</div>
) : (
chatMessages.map((m) => {
const isBot = m.sender === 1;
// 메시지가 쓴 협상카드 전체(이름만이 아니라 멘트/조건/메모까지) 를 chat_id 로 매칭.
const usedCard = m.card_used_yn
? serverCards.find((c) => c.session_card_id === m.chat_id)
: undefined;
const cardNodes = Array.isArray(usedCard?.edit_script) ? (usedCard.edit_script as unknown[]) : null;
const isWildCard = usedCard?.type === 2;
return (
<div key={m.chat_id} className={`flex ${isBot ? 'justify-start' : 'justify-end'}`}>
<div className="space-y-1 max-w-[85%]">
<div
className={`text-[10px] text-muted-foreground font-mono flex items-center gap-1.5 ${
isBot ? '' : 'justify-end'
}`}
>
<span>{isBot ? 'Negosium Bot' : currentSupplierName}</span>
<span>·</span>
<span>#{m.index}</span>
</div>
<div
className={`p-3 rounded-md border text-xs shadow-xs ${
isBot
? 'bg-secondary border-border text-foreground'
: 'bg-primary border-transparent text-primary-foreground'
}`}
>
<div className="font-bold">제시 단가 ₩{Number(m.target_price).toLocaleString()}</div>
{usedCard && (
<div
className={`mt-2 rounded border p-2 ${
isBot
? 'bg-amber-50/70 border-amber-200 dark:bg-amber-950/20 dark:border-amber-900/40'
: 'bg-white/10 border-white/20'
}`}
>
{/* 헤더: 어떤 카드인지(번호·이름·종류) */}
<div
className={`flex items-center gap-1 text-[10px] font-semibold ${
isBot ? 'text-amber-800 dark:text-amber-300' : 'text-primary-foreground'
}`}
>
<Sparkles size={10} />
<span>협상카드</span>
{usedCard.number && <span className="font-mono opacity-70">#{usedCard.number}</span>}
{usedCard.name && <span>· {usedCard.name}</span>}
<span
className={`ml-auto px-1.5 py-0.5 rounded font-bold ${
isWildCard
? 'bg-amber-200 text-amber-900 dark:bg-amber-400/25 dark:text-amber-100'
: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-600/40 dark:text-zinc-100'
}`}
>
{isWildCard ? '와일드' : '협상'}
</span>
</div>
{/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script */}
{cardNodes ? (
<div className="mt-1.5">
<SlateRenderer
nodes={cardNodes}
variables={{
target_price: m.target_price,
partner_name: currentSupplierName,
product_name: currentProduct?.name ?? '',
}}
/>
</div>
) : usedCard.script ? (
<p className="mt-1.5 text-xs leading-relaxed whitespace-pre-line text-foreground/85">
{usedCard.script}
</p>
) : null}
{/* 와일드카드 부가 정보: 사용 조건 / 메모 */}
{isWildCard && (usedCard.condition || usedCard.memo) && (
<div className="mt-1.5 pt-1.5 border-t border-amber-200/60 dark:border-amber-900/40 space-y-0.5 text-[10px] text-muted-foreground">
{usedCard.condition && (
<div>
<span className="font-semibold">조건:</span> {usedCard.condition}
</div>
)}
{usedCard.memo && (
<div>
<span className="font-semibold">메모:</span> {usedCard.memo}
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
})
)}
</div>
<div className="p-3 border-t border-border bg-muted/20 flex gap-2">
<Input
type="text"
disabled
placeholder="이 대화방은 입찰 참여 세션 기록이므로 정독 전용입니다."
className="flex-1 text-xs text-muted-foreground"
/>
<button
disabled
className="py-1.5 px-3 bg-muted text-muted-foreground text-xs rounded border border-border cursor-not-allowed"
>
전송
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,194 @@
import type { ReactNode } from 'react';
import { Link } from 'react-router';
import { Package } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { InfoField } from './InfoField';
import { QuotationStatusBadge } from './StatusPill';
import type { QuotationData } from '@/api/generated/model/quotationData';
import {
type Product,
type Partner,
type QuotationSetting,
normalizeQuotationStatus,
normalizeQuotationType,
buildBidSummary,
} from '../../types';
const fmtYn = (b: boolean | null | undefined, yes: string, no: string) =>
b == null ? '-' : b ? yes : no;
/** 헤더 정보 카드 컨테이너. ui/Card 의 넉넉한 기본 여백을 촘촘하게 덮어쓴다. */
function SectionCard({ title, children }: { title: string; children: ReactNode }) {
return (
<Card className="p-3 gap-2 rounded shadow-xs border-border/80">
<span className="font-bold text-foreground text-[11px] border-b border-border pb-1 block font-sans">
{title}
</span>
{children}
</Card>
);
}
type DrawerHeaderCardsProps = {
quotation: QuotationData;
partners: Partner[];
quotationSettings: QuotationSetting[];
/** 현재 선택 세션의 상품(없으면 상품 카드는 빈 상태). */
currentProduct: Product | undefined;
};
export function DrawerHeaderCards({
quotation,
partners,
quotationSettings,
currentProduct,
}: DrawerHeaderCardsProps) {
// Quotations DDL 표시값
const q_name = quotation.name || '미지정';
const q_number = quotation.number || 'EST-000000-0000';
const q_type = normalizeQuotationType(quotation.type);
const q_round = quotation.round || 1;
const q_status = String(quotation.status ?? '견적생성');
const q_end_time = quotation.end_time || '미지정';
const q_manager_name = quotation.manager_name || '홍길동 파트너';
const q_manager_email = quotation.manager_email || 'gildong@negodata.com';
const q_memo = quotation.memo || '안내사항 없음';
const statusKey = normalizeQuotationStatus(quotation.status);
const bidSummaryObj = buildBidSummary(quotation, partners);
const selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === quotation.qt_setting_id);
// 상품 상세 패널 행(negowiz 협상대화의 상품 정보 대응). negodata 컬럼명: maker_name→manufacturer, min_order_quantity→moq.
const productSpecRows = currentProduct
? [
{ label: '상품코드', value: currentProduct.code || '-' },
{ label: '단가', value: currentProduct.price != null ? `₩${Number(currentProduct.price).toLocaleString()}` : '-' },
{ label: '모델명', value: currentProduct.model_name || '-' },
{ label: '규격', value: currentProduct.spec || '-' },
{ label: '제조사', value: currentProduct.manufacturer || '-' },
{ label: '원산지', value: currentProduct.made_in || '-' },
{ label: 'MOQ', value: currentProduct.moq || '-' },
{ label: '리드타임', value: currentProduct.lead_time != null ? `${currentProduct.lead_time}일` : '-' },
{ label: 'VAT', value: fmtYn(currentProduct.vat_yn, '포함', '별도') },
{ label: '배송비', value: fmtYn(currentProduct.delivery_fee_yn, '포함', '별도') },
]
: [];
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4 text-xs font-mono">
{/* 좌측 컬럼: 견적정보 + 진행상태 */}
<div className="space-y-4">
{/* Quotations */}
<SectionCard title="견적 정보">
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
<InfoField label="견적명" value={q_name} valueClassName="font-sans" />
<InfoField label="견적번호" value={q_number} />
<InfoField label="유형" value={q_type === 'RE_NEGOTIATION' ? '재협상' : '재견적'} />
<InfoField label="차수" value={`${q_round}차`} />
<InfoField label="견적상태" labelClassName="opacity-90 font-bold mb-1">
<QuotationStatusBadge statusKey={statusKey} fallbackLabel={q_status} />
</InfoField>
<InfoField label="마감시각" value={q_end_time} />
<InfoField label="담당자" value={`${q_manager_name} (${q_manager_email})`} valueClassName="font-sans" />
<InfoField
label="메모"
value={q_memo}
valueClassName="font-sans truncate block"
title={q_memo || ''}
/>
</div>
</SectionCard>
{/* Bid Summary */}
<SectionCard title="견적 진행상태/결과">
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
<InfoField label="식별자" value={bidSummaryObj.bid_summary_id} />
<InfoField label="진행/결과 상태" value={bidSummaryObj.status} />
<InfoField label="반복횟수" value={`${bidSummaryObj.qt_iteration}회`} />
<InfoField
label="우선협상자 존재여부"
value={bidSummaryObj.has_preferred ? '존재' : '미존재'}
valueClassName={bidSummaryObj.has_preferred ? 'text-emerald-600 font-bold' : undefined}
/>
<InfoField
label="우선협상자명"
value={bidSummaryObj.preferred_sp_name}
className="col-span-2"
valueClassName="font-sans"
/>
<InfoField label="동가입찰정보" className="col-span-2">
<code className="text-foreground font-semibold bg-muted/60 p-1 rounded text-[10px] block overflow-x-auto whitespace-pre">
{bidSummaryObj.equal_data}
</code>
</InfoField>
</div>
</SectionCard>
</div>
{/* 우측 컬럼: 상품정보 + 세팅 */}
<div className="space-y-4">
{/* 상품 정보 (협상 대상 상품) */}
<SectionCard title="상품 정보">
{currentProduct ? (
<div className="flex gap-4">
<div className="h-24 w-24 shrink-0 rounded-md border border-border bg-background overflow-hidden flex items-center justify-center">
{currentProduct.image_url ? (
<img
src={currentProduct.image_url}
alt={currentProduct.name || '상품'}
className="h-full w-full object-cover"
/>
) : (
<Package size={28} className="text-muted-foreground/50" />
)}
</div>
<div className="flex-1 min-w-0">
<Link
to={`/products?edit=${currentProduct.id}`}
className="text-foreground font-bold font-sans text-[12px] mb-2 truncate block hover:text-primary hover:underline"
title={`${currentProduct.name || ''} — 상품 상세로 이동`}
>
{currentProduct.name || '-'}
</Link>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-x-3 gap-y-1.5 font-mono text-muted-foreground">
{productSpecRows.map((r) => (
<InfoField
key={r.label}
label={r.label}
value={r.value}
valueClassName="font-sans truncate block"
title={r.value}
/>
))}
</div>
</div>
</div>
) : (
<div className="text-muted-foreground py-6 text-center">상품 정보가 비어있습니다.</div>
)}
</SectionCard>
{/* Quotation Settings */}
<SectionCard title="견적 세팅">
{selectedSettingObj ? (
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
<InfoField
label="목표 마진율"
value={selectedSettingObj.target_margin}
valueClassName="font-bold text-emerald-600 dark:text-emerald-400 font-sans"
/>
<InfoField label="앵커링 설정 값" value={selectedSettingObj.anchoring_value} valueClassName="font-sans" />
<InfoField
label="카드 사용 횟수"
value={selectedSettingObj.card_use_count}
className="col-span-2 col-start-1"
/>
</div>
) : (
<div className="text-muted-foreground py-6 text-center">적용된 견적 세팅이 비어있습니다.</div>
)}
</SectionCard>
</div>
</div>
);
}

View File

@ -0,0 +1,35 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
type InfoFieldProps = {
label: string;
/** 단순 텍스트 값. 커스텀 마크업이 필요하면 value 대신 children 을 쓴다. */
value?: ReactNode;
children?: ReactNode;
className?: string;
labelClassName?: string;
valueClassName?: string;
title?: string;
};
/** 헤더 카드의 `라벨 / 값` 한 칸. (드로어 곳곳에서 ~20회 반복되던 패턴) */
export function InfoField({
label,
value,
children,
className,
labelClassName,
valueClassName,
title,
}: InfoFieldProps) {
return (
<div className={className}>
<span className={cn('text-[10px] block opacity-70', labelClassName)}>{label}</span>
{children ?? (
<span className={cn('text-foreground font-semibold', valueClassName)} title={title}>
{value}
</span>
)}
</div>
);
}

View File

@ -0,0 +1,57 @@
import { Link } from 'react-router';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { StatusPill } from './StatusPill';
import { mapServerCardView } from '../../types';
type CardView = ReturnType<typeof mapServerCardView>;
export function QuotationCardsTab({ quotationCardViews }: { quotationCardViews: CardView[] }) {
return (
<div className="space-y-4">
<div className="border border-border rounded-lg bg-card overflow-hidden">
<Table className="w-full text-left text-xs border-collapse font-mono">
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
<TableRow>
<TableHead className="p-3 font-semibold">세션 카드 ID</TableHead>
<TableHead className="p-3 font-semibold font-sans">카드 이름</TableHead>
<TableHead className="p-3 font-semibold font-sans">타입</TableHead>
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-border">
{quotationCardViews.length > 0 ? (
quotationCardViews.map((qc) => (
<TableRow key={qc.session_card_id} className="hover:bg-muted/30 transition-colors text-[11px]">
<TableCell className="p-3 text-muted-foreground">{qc.session_card_id}</TableCell>
<TableCell className="p-3 text-foreground font-sans font-semibold">
{qc.card_id ? (
<Link
to={`/cards?edit=${qc.card_id}`}
className="hover:text-primary hover:underline"
title={`${qc.card_name} — 협상카드 상세로 이동`}
>
{qc.card_name}
</Link>
) : (
qc.card_name
)}
</TableCell>
<TableCell className="p-3">
<StatusPill tone={qc.type === '와일드 카드' ? 'amber' : 'zinc'} className="rounded">
{qc.type}
</StatusPill>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={3} className="p-12 text-center text-muted-foreground">
사용된 협상 카드가 없습니다. (리스트가 비어 있습니다)
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@ -0,0 +1,110 @@
import { MessageSquare, ExternalLink, Copy } from 'lucide-react';
import { showToast } from '@/lib/notify';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { StatusPill, sessionStatusTone } from './StatusPill';
import { mapServerSessionView } from '../../types';
type SessionView = ReturnType<typeof mapServerSessionView>;
export function SessionsStatusTab({
sessionViews,
onOpenChat,
}: {
sessionViews: SessionView[];
onOpenChat: (sessionId: string) => void;
}) {
return (
<div className="space-y-4">
<div className="border border-border rounded-lg bg-card overflow-x-auto">
<Table className="w-full text-left text-xs border-collapse font-mono min-w-[1250px]">
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
<TableRow>
<TableHead className="p-3 font-semibold">세션 ID</TableHead>
<TableHead className="p-3 font-semibold font-sans">협력사</TableHead>
<TableHead className="p-3 font-semibold font-sans">협상 URL</TableHead>
<TableHead className="p-3 font-semibold font-sans">상품</TableHead>
<TableHead className="p-3 font-semibold text-center font-sans">협상상태</TableHead>
<TableHead className="p-3 font-semibold text-right">목표가</TableHead>
<TableHead className="p-3 font-semibold text-right">투찰가</TableHead>
<TableHead className="p-3 font-semibold">투찰시각</TableHead>
<TableHead className="p-3 font-semibold">마감시각</TableHead>
<TableHead className="p-3 font-semibold font-sans">거절사유</TableHead>
<TableHead className="p-3 font-semibold text-right">거절가격</TableHead>
<TableHead className="p-3 font-semibold font-sans">거절배송방식</TableHead>
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-border">
{sessionViews.length === 0 && (
<TableRow>
<TableCell colSpan={12} className="p-12 text-center text-muted-foreground">
참여 중인 협상 세션이 없습니다. (리스트가 비어 있습니다)
</TableCell>
</TableRow>
)}
{sessionViews.map((sess) => (
<TableRow key={sess.session_id} className="hover:bg-muted/30 transition-colors text-[11px]">
<TableCell className="p-3 text-muted-foreground font-mono">{sess.session_id}</TableCell>
<TableCell className="p-3 font-bold text-foreground font-sans">
<div className="flex items-center gap-2">
<span>{sess.supplier_name}</span>
<button
onClick={() => onOpenChat(sess.session_id)}
title="협상 대화방으로 이동"
className="p-1 hover:bg-primary/10 rounded text-primary hover:text-primary/80 transition-colors cursor-pointer"
>
<MessageSquare size={13} />
</button>
</div>
</TableCell>
<TableCell className="p-3 font-mono">
{sess.url ? (
<div className="flex items-center gap-1.5">
<a
href={sess.url}
target="_blank"
rel="noopener noreferrer"
title={sess.url}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors text-[10px] font-semibold"
>
<ExternalLink size={11} /> 세션 열기
</a>
<button
onClick={() => {
void navigator.clipboard?.writeText(sess.url);
showToast('협상 URL을 복사했습니다.', 'success');
}}
title="협상 URL 복사"
className="p-1 hover:bg-muted rounded text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<Copy size={12} />
</button>
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="p-3 font-semibold font-sans">{sess.item_name}</TableCell>
<TableCell className="p-3 text-center">
<StatusPill tone={sessionStatusTone(sess.status)}>{sess.status}</StatusPill>
</TableCell>
<TableCell className="p-3 text-right font-bold text-muted-foreground">
₩{sess.target_price?.toLocaleString() || '-'}
</TableCell>
<TableCell className="p-3 text-right font-bold text-foreground">
{sess.bid_price ? `₩${sess.bid_price.toLocaleString()}` : '-'}
</TableCell>
<TableCell className="p-3 text-muted-foreground">{sess.bid_at || '-'}</TableCell>
<TableCell className="p-3 text-muted-foreground font-sans">{sess.end_time || '-'}</TableCell>
<TableCell className="p-3 text-rose-600 font-sans">{sess.reject_reason || '-'}</TableCell>
<TableCell className="p-3 text-right text-rose-600 font-mono">
{sess.reject_price ? `₩${sess.reject_price.toLocaleString()}` : '-'}
</TableCell>
<TableCell className="p-3 text-muted-foreground font-sans">{sess.reject_delivery_type || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@ -0,0 +1,95 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
/* ── 작은 상태 pill (세션 상태 / 카드 타입 / 채팅 목록 상태) ──
기존엔 곳마다 색맵을 손으로 박았고 dark 알파(/20·/30)와 red·rose 가 미묘하게
달랐다. 여기서 한 팔레트로 통일한다. */
export type PillTone = 'blue' | 'rose' | 'emerald' | 'amber' | 'zinc';
const PILL_TONE: Record<PillTone, string> = {
blue: 'bg-blue-100 text-blue-800 dark:bg-blue-950/30 dark:text-blue-300',
rose: 'bg-rose-100 text-rose-800 dark:bg-rose-950/30 dark:text-rose-300',
emerald: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300',
amber: 'bg-amber-100 text-amber-800 dark:bg-amber-950/20 dark:text-amber-300',
zinc: 'bg-zinc-100 text-zinc-800 dark:bg-zinc-800/40 dark:text-zinc-300',
};
export function StatusPill({
tone,
className,
children,
}: {
tone: PillTone;
className?: string;
children: ReactNode;
}) {
return (
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold',
PILL_TONE[tone],
className,
)}
>
{children}
</span>
);
}
/** 협상 세션 상태(현황 테이블) → pill 색 */
export function sessionStatusTone(status: string | null | undefined): PillTone {
if (status === '협상완료' || status === 'COMPLETED') return 'blue';
if (status === '협상거부' || status === 'REJECTED') return 'rose';
return 'emerald';
}
/** 채팅 목록의 협상 상태 라벨 → pill 색 */
export function chatStatusTone(label: string): PillTone {
if (label === '협상거부') return 'rose';
if (label === '협상완료') return 'blue';
return 'emerald';
}
/* ── 견적 상태 배지 (헤더, dot + border + 견적생성 시 pulse) ──
작은 pill 들과 모양이 달라(테두리·점·pulse) 별도 컴포넌트로 둔다. */
const QSTATUS_TONE: Record<string, { box: string; dot: string }> = {
견적생성: {
box: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-700/50 animate-pulse',
dot: 'bg-amber-500',
},
견적진행중: {
box: 'bg-emerald-100 text-emerald-800 border-emerald-300 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-700/50',
dot: 'bg-emerald-500',
},
견적마감: {
box: 'bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-950/40 dark:text-blue-300 dark:border-blue-700/50',
dot: 'bg-blue-500',
},
협상보류: {
box: 'bg-rose-100 text-rose-800 border-rose-300 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-700/50',
dot: 'bg-rose-500',
},
};
const QSTATUS_FALLBACK = { box: 'bg-zinc-100 text-zinc-800 border-zinc-300', dot: 'bg-zinc-500' };
export function QuotationStatusBadge({
statusKey,
fallbackLabel,
}: {
statusKey: string;
fallbackLabel: string;
}) {
const t = QSTATUS_TONE[statusKey] ?? QSTATUS_FALLBACK;
return (
<span
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-0.5 text-[11px] font-bold rounded-full border shadow-2xs',
t.box,
)}
>
<span className={cn('h-1.5 w-1.5 rounded-full', t.dot)} />
{statusKey || fallbackLabel}
</span>
);
}

View File

@ -0,0 +1,203 @@
import { useState } from 'react';
import { StopCircle, X, UserCheck, MessageSquare, Layers } from 'lucide-react';
import { Typography } from '@/components/ui/typography';
import {
useGetQuotationSessions,
useGetSessionChat,
useGetQuotationCards,
} from '@/api/generated/quotation/quotation';
import { useListItems } from '@/api/generated/item/item';
import { useListSuppliers } from '@/api/generated/supplier/supplier';
import { useListSettings } from '@/api/generated/quotation-setting/quotation-setting';
import type { ItemData } from '@/api/generated/model/itemData';
import type { SupplierData } from '@/api/generated/model/supplierData';
import type { QuotationSettingData } from '@/api/generated/model/quotationSettingData';
import type { QuotationData } from '@/api/generated/model/quotationData';
import {
unwrap,
mapItem,
mapSupplier,
mapSetting,
normalizeQuotationStatus,
mapServerSessionView,
mapServerCardView,
} from '../../types';
import { DrawerHeaderCards } from './DrawerHeaderCards';
import { SessionsStatusTab } from './SessionsStatusTab';
import { QuotationCardsTab } from './QuotationCardsTab';
import { ChatTab } from './ChatTab';
type DrawerTab = 'status' | 'cards' | 'chat';
type QuotationDetailSheetProps = {
quotation: QuotationData;
onStop: (id: string, name: string) => void;
onClose: () => void;
};
export function QuotationDetailSheet({
quotation,
onStop,
onClose,
}: QuotationDetailSheetProps) {
const [activeTab, setActiveTab] = useState<DrawerTab>('status');
const [showHeaderCards, setShowHeaderCards] = useState(true);
// 상품·협력사·견적세팅 목록은 sheet 안에서 직접 서버(orval)로 읽는다(부모 props 의존 제거).
const itemsQuery = useListItems({ size: 100 });
const suppliersQuery = useListSuppliers({ size: 100 });
const settingsQuery = useListSettings();
const products = (unwrap<{ items?: ItemData[] }>(itemsQuery.data)?.items ?? []).map(mapItem);
const partners = (unwrap<{ suppliers?: SupplierData[] }>(suppliersQuery.data)?.suppliers ?? []).map(mapSupplier);
const quotationSettings = (
unwrap<{ settings?: QuotationSettingData[] }>(settingsQuery.data)?.settings ?? []
).map(mapSetting);
const qtId = quotation.qt_id ?? '';
// 협상 세션·사용 카드는 견적 단위, 채팅은 선택 세션 단위로 서버에서 읽는다.
const sessionsQuery = useGetQuotationSessions(qtId, { query: { enabled: !!qtId } });
const cardsQuery = useGetQuotationCards(qtId, { query: { enabled: !!qtId } });
const serverSessions = sessionsQuery.data?.sessions ?? [];
const serverCards = cardsQuery.data?.cards ?? [];
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const effectiveSessionId = selectedSessionId ?? serverSessions[0]?.session_id ?? null;
const chatQuery = useGetSessionChat(effectiveSessionId ?? '', {
query: { enabled: !!effectiveSessionId },
});
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 currentProduct = products.find((p) => p.id === currentSession?.item_id);
const sessionViews = serverSessions.map((sd) => mapServerSessionView(sd, partners, products));
const quotationCardViews = serverCards.map(mapServerCardView);
// 헤더 상단바·중지 버튼에 필요한 최소 표시값만 (나머지 견적 표시값은 DrawerHeaderCards 내부 계산).
const q_name = quotation.name || '미지정';
const q_number = quotation.number || 'EST-000000-0000';
const statusKey = normalizeQuotationStatus(quotation.status);
const goToChat = (sessionId: string) => {
setSelectedSessionId(sessionId);
setActiveTab('chat');
};
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 },
];
return (
<div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-xs flex justify-end animate-fade-in">
<div className="flex-1 cursor-pointer" onClick={onClose} />
<div className="w-full max-w-5xl bg-card border-l border-border h-full flex flex-col justify-between shadow-2xl overflow-hidden animate-slide-left">
{/* Header */}
<div className="p-6 border-b border-border 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>
</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>
{statusKey === '견적진행중' && (
<button
onClick={() => onStop(quotation.qt_id ?? '', q_name)}
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"
>
<StopCircle size={14} />
<span>중지</span>
</button>
)}
<button
onClick={onClose}
className="p-1.5 rounded-full text-muted-foreground hover:bg-muted cursor-pointer"
>
<X size={20} />
</button>
</div>
</div>
{/* DB mapping info cards */}
{showHeaderCards && (
<DrawerHeaderCards
quotation={quotation}
partners={partners}
quotationSettings={quotationSettings}
currentProduct={currentProduct}
/>
)}
</div>
{/* Tabs */}
<div className="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 className="flex-1 p-6 overflow-y-auto bg-background/50">
{activeTab === 'status' && (
<SessionsStatusTab sessionViews={sessionViews} onOpenChat={goToChat} />
)}
{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>
</div>
);
}

View File

@ -1,29 +0,0 @@
import { useState } from 'react';
import { type Estimate, normalizeQuotationStatus } from '../types';
// 견적 목록의 검색/상태/유형 필터 state + 파생 결과.
export function useQuotationFilters(quotations: Estimate[]) {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('ALL');
const [typeFilter, setTypeFilter] = useState('ALL');
const filtered = quotations.filter((est) => {
const q = search.toLowerCase();
const matchesSearch =
(est.title || '').toLowerCase().includes(q) || (est.number || '').toLowerCase().includes(q);
const matchesStatus =
statusFilter === 'ALL' || normalizeQuotationStatus(est.status) === statusFilter;
const matchesType = typeFilter === 'ALL' || est.type === typeFilter;
return matchesSearch && matchesStatus && matchesType;
});
return {
search,
setSearch,
statusFilter,
setStatusFilter,
typeFilter,
setTypeFilter,
filtered,
};
}

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
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';
@ -14,8 +14,8 @@ import {
useListQuotations,
useCreateQuotation,
useStopQuotation,
getListQuotationsQueryKey,
} from '@/api/generated/quotation/quotation';
import type { ListQuotationsParams } from '@/api/generated/model/listQuotationsParams';
import type { ReqCreateQuotation } from '@/api/generated/model/reqCreateQuotation';
import type { ItemData } from '@/api/generated/model/itemData';
import type { SupplierData } from '@/api/generated/model/supplierData';
@ -46,20 +46,22 @@ export type SettingInput = {
// 견적 화면 데이터 허브.
// 상품/협력사/세팅/견적은 서버(orval)에서 읽고, 견적·세팅·채팅은 로컬 state로 낙관적 갱신한다.
// (협상카드 카탈로그/채팅은 백엔드 미연동 → 빈 상태)
export function useQuotations() {
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(undefined);
// 견적 목록은 서버 검색/상태·유형 필터/페이지네이션. 페이지 이동 시 이전 데이터 유지(깜빡임 방지).
const quotationsQuery = useListQuotations(params, { query: { placeholderData: keepPreviousData } });
const createSettingMutation = useCreateSetting();
const deleteSettingMutation = useDeleteSetting();
const createQuotationMutation = useCreateQuotation();
const stopQuotationMutation = useStopQuotation();
// 파라미터별 목록 쿼리 키 전부 재조회(prefix 무효화).
const invalidateQuotations = () =>
queryClient.invalidateQueries({ queryKey: getListQuotationsQueryKey(undefined) });
queryClient.invalidateQueries({ queryKey: ['/v1/quotation/list'] });
const products = (unwrap<{ items?: ItemData[] }>(itemsQuery.data)?.items ?? []).map(mapItem);
const partners = (unwrap<{ suppliers?: SupplierData[] }>(suppliersQuery.data)?.suppliers ?? []).map(mapSupplier);
@ -74,6 +76,8 @@ export function useQuotations() {
const qs = unwrap<{ quotations?: QuotationData[] }>(quotationsQuery.data)?.quotations;
if (qs) setQuotations(qs.map(mapQuotation));
}, [quotationsQuery.data]);
// 서버 전체 건수(선택 필터 반영) — 페이지네이션용.
const total = unwrap<{ total?: number }>(quotationsQuery.data)?.total ?? 0;
// 협상카드 카탈로그는 서버(orval)에서 읽어 단계 3/3 카드 선택지로 쓴다.
const cards = (unwrap<{ cards?: CardData[] }>(cardsQuery.data)?.cards ?? []).map(mapCardData);
@ -194,6 +198,7 @@ export function useQuotations() {
partners,
cards,
quotations,
total,
quotationSettings,
stopNegotiation,
addSetting,

View File

@ -131,6 +131,18 @@ export function normalizeQuotationType(type?: string | number | null): 'RE_NEGOT
return type === '재협상' ? 'RE_NEGOTIATION' : 'RE_ESTIMATE';
}
// UI 필터값 → 서버 코드(SMALLINT). 서버 목록 필터(status/type 쿼리)로 보낼 때 사용.
export const QUOTATION_STATUS_CODE: Record<string, number> = {
견적생성: 1,
견적진행중: 2,
견적마감: 3,
협상보류: 4,
};
export const QUOTATION_TYPE_CODE: Record<string, number> = {
RE_NEGOTIATION: 1,
RE_ESTIMATE: 2,
};
// ── 상세 드로어용 파생 뷰 모델(서버 미연동 영역의 목업 보강 포함) ────────
export type BidSummaryView = {
@ -169,8 +181,8 @@ export type QuotationCardView = {
};
// 견적당 1개의 입찰 요약(bid_summary). est-1~3은 데모용 정적 매핑, 그 외는 견적 데이터에서 산출.
export function buildBidSummary(est: Estimate, partners: Partner[]): BidSummaryView {
if (est.id === 'est-1') {
export function buildBidSummary(q: QuotationData, partners: Partner[]): BidSummaryView {
if (q.qt_id === 'est-1') {
return {
bid_summary_id: 'bid-summary-111-uuid',
status: '입찰진행중 (ACTIVE)',
@ -181,7 +193,7 @@ export function buildBidSummary(est: Estimate, partners: Partner[]): BidSummaryV
equal_data: '-',
};
}
if (est.id === 'est-2') {
if (q.qt_id === 'est-2') {
return {
bid_summary_id: 'bid-summary-222-uuid',
status: '입찰종료 (COMPLETED)',
@ -192,7 +204,7 @@ export function buildBidSummary(est: Estimate, partners: Partner[]): BidSummaryV
equal_data: JSON.stringify({ 'part-2': 730000, 'part-3': 730000 }),
};
}
if (est.id === 'est-3') {
if (q.qt_id === 'est-3') {
return {
bid_summary_id: 'bid-summary-333-uuid',
status: '입찰활성화 (ACTIVE)',
@ -203,16 +215,15 @@ export function buildBidSummary(est: Estimate, partners: Partner[]): BidSummaryV
equal_data: '-',
};
}
const winnerId = q.preferred_sp_id ?? null;
return {
bid_summary_id: `bid-summary-${est.id}`,
status: est.status === 'COMPLETED' ? '입찰종료 (COMPLETED)' : '입찰활성화 (ACTIVE)',
qt_iteration: 1,
has_preferred: !!est.winnerPartnerId,
preferred_sp_id: est.winnerPartnerId || null,
preferred_sp_name: est.winnerPartnerId
? partners.find((p) => p.id === est.winnerPartnerId)?.name || '-'
: '-',
equal_data: '-',
bid_summary_id: `bid-summary-${q.qt_id}`,
status: normalizeQuotationStatus(q.status) === '견적마감' ? '입찰종료 (COMPLETED)' : '입찰활성화 (ACTIVE)',
qt_iteration: q.iteration ?? 1,
has_preferred: !!winnerId,
preferred_sp_id: winnerId,
preferred_sp_name: q.preferred_sp_name || (winnerId ? partners.find((p) => p.id === winnerId)?.name || '-' : '-'),
equal_data: typeof q.equal_bid_data === 'string' ? q.equal_bid_data : '-',
};
}

View File

@ -1,35 +0,0 @@
import { useSearchParams } from 'react-router';
// 시트/드로어/모달 같은 "오버레이" 열림 상태를 쿼리스트링으로 표현하는 단일 출처.
// 로컬 useState 대신 URL 에 담아 딥링크·뒤로가기·새로고침을 지원한다.
// 같은 그룹(keys) 안에서는 한 번에 하나만 연다(상호배타: 열 때 나머지 키 제거).
//
// const overlay = useOverlayParams(['edit', 'new', 'modal']);
// overlay.get('edit') // ?edit=<id> 의 값(없으면 null) — 값 있는 오버레이
// overlay.has('new') // ?new 존재 여부 — 플래그 오버레이
// overlay.open('edit', id) // ?edit=<id> (다른 오버레이 키는 지움)
// overlay.open('new') // ?new (값 생략 시 '1')
// overlay.close() // 그룹 내 모든 오버레이 키 제거
export function useOverlayParams<K extends string>(keys: readonly K[]) {
const [searchParams, setSearchParams] = useSearchParams();
const get = (key: K) => searchParams.get(key);
const has = (key: K) => searchParams.has(key);
const open = (key: K, value = '1') =>
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
keys.forEach((k) => next.delete(k));
next.set(key, value);
return next;
});
const close = () =>
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
keys.forEach((k) => next.delete(k));
return next;
});
return { get, has, open, close };
}

View File

@ -0,0 +1,50 @@
import { useLocation, useNavigate, useSearchParams } from 'react-router';
// 시트/드로어/모달 같은 "오버레이" 열림 상태를 쿼리스트링으로 표현하는 단일 출처.
// 로컬 useState 대신 URL 에 담아 딥링크·뒤로가기·새로고침을 지원한다.
// 같은 그룹(keys) 안에서는 한 번에 하나만 연다(상호배타: 열 때 나머지 키 제거).
//
// const overlay = useOverlayRouter(['detail', 'new', 'modal']);
// overlay.get('detail') // ?detail=<id> 의 값(없으면 null) — 값 있는 오버레이
// overlay.has('new') // ?new 존재 여부 — 플래그 오버레이
// overlay.open('detail', id) // ?detail=<id> (다른 오버레이 키는 지움) — 히스토리 push
// overlay.open('new') // ?new (값 생략 시 '1')
// overlay.close() // 그룹 내 모든 오버레이 키 제거
const OVERLAY_PUSHED = '__overlayPushed';
export function useOverlayRouter<K extends string>(keys: readonly K[]) {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
const get = (key: K) => searchParams.get(key);
const has = (key: K) => searchParams.has(key);
// 현재 쿼리에서 그룹 키를 모두 지운 뒤 mutate 를 적용해 search 문자열을 만든다.
const buildSearch = (mutate: (params: URLSearchParams) => void) => {
const next = new URLSearchParams(searchParams);
keys.forEach((k) => next.delete(k));
mutate(next);
const s = next.toString();
return s ? `?${s}` : '';
};
const open = (key: K, value = '1') =>
navigate(
{ pathname: location.pathname, search: buildSearch((p) => p.set(key, value)) },
{ state: { ...(location.state ?? {}), [OVERLAY_PUSHED]: true } },
);
const close = () => {
if (location.state?.[OVERLAY_PUSHED]) {
navigate(-1);
return;
}
navigate(
{ pathname: location.pathname, search: buildSearch(() => {}) },
{ replace: true },
);
};
return { get, has, open, close };
}

View File

@ -1,13 +1,7 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
// 서버사이드 리스트(검색·필터·페이지네이션)의 UI 상태 단일 출처.
// 실제 데이터 패칭은 각 도메인 훅(useProducts/usePartners 등)이 이 상태로
// 쿼리 파라미터를 만들어 수행한다 — 이 훅은 패칭을 하지 않고 상태만 관리한다.
//
// - search: 입력 즉시 반영(controlled) + debouncedSearch(쿼리용, 기본 300ms)로 분리해
// 키 입력마다 서버를 때리지 않는다.
// - filters: 임의 키-값(category/priority 등). 'ALL' 같은 "전체" 값의 의미는
// 호출부가 파라미터를 만들 때 결정한다(여기선 단순 보관).
// - 검색/필터가 바뀌면 page 를 1 로 리셋한다(다른 결과셋의 동일 페이지로 점프 방지).
export type ServerListControls = {
page: number;
@ -15,6 +9,7 @@ export type ServerListControls = {
pageSize: number;
search: string; // input value (controlled)
setSearch: (v: string) => void;
submitSearch: () => void; // 엔터/즉시 검색용 (디바운스·최소길이 무시하고 바로 발사)
debouncedSearch: string; // 쿼리 파라미터용 (디바운스 적용)
filters: Record<string, string>;
setFilter: (key: string, value: string) => void;
@ -25,25 +20,39 @@ export function useServerList(opts?: {
pageSize?: number;
initialFilters?: Record<string, string>;
debounceMs?: number;
minSearchLength?: number;
}): ServerListControls {
const pageSize = opts?.pageSize ?? 10;
const debounceMs = opts?.debounceMs ?? 300;
const debounceMs = opts?.debounceMs ?? 500;
const minSearchLength = opts?.minSearchLength ?? 2;
const [page, setPage] = useState(1);
const [search, setSearchInput] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [filters, setFilters] = useState<Record<string, string>>(() => opts?.initialFilters ?? {});
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// 입력 디바운스 → 쿼리용 검색어
// 입력 디바운스 → 쿼리용 검색어.
// 최소 길이 미만은 빈 검색(전체)으로 둬서 1글자 스캔 요청이 서버로 나가지 않게 막는다.
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search.trim()), debounceMs);
return () => clearTimeout(t);
}, [search, debounceMs]);
timerRef.current = setTimeout(() => {
const q = search.trim();
setDebouncedSearch(q.length >= minSearchLength ? q : '');
}, debounceMs);
return () => clearTimeout(timerRef.current);
}, [search, debounceMs, minSearchLength]);
const setSearch = (v: string) => {
setSearchInput(v);
setPage(1);
};
// 엔터: 대기 중인 디바운스 타이머를 버리고 최소길이 무시하고 즉시 1회 발사(의도적 검색).
const submitSearch = () => {
clearTimeout(timerRef.current);
setDebouncedSearch(search.trim());
setPage(1);
};
const setFilter = (key: string, value: string) => {
setFilters((f) => ({ ...f, [key]: value }));
setPage(1);
@ -51,5 +60,5 @@ export function useServerList(opts?: {
const totalPages = (total: number) => Math.max(1, Math.ceil(total / pageSize));
return { page, setPage, pageSize, search, setSearch, debouncedSearch, filters, setFilter, totalPages };
return { page, setPage, pageSize, search, setSearch, submitSearch, debouncedSearch, filters, setFilter, totalPages };
}

View File

@ -1,33 +1,43 @@
import { Plus, BookOpen } from 'lucide-react';
import { useOverlayParams } from '@/lib/useOverlayParams';
import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
import { PageContainer } from '@/components/layout/PageContainer';
import { SearchInput } from '@/components/layout/PageToolbar';
import { TablePagination } from '@/components/ui/table-pagination';
import { Typography } from '@/components/ui/typography';
import { useClientPagination } from '@/lib/useClientPagination';
import { useServerList } from '@/lib/useServerList';
import { useCards } from '@/features/cards/hooks/useCards';
import { useCardFilters } from '@/features/cards/hooks/useCardFilters';
import { useGetCard } from '@/api/generated/card/card';
import { CardTable } from '@/features/cards/components/CardTable';
import { CardFormSheet } from '@/features/cards/components/CardFormSheet';
import type { CardTab, NegotiationCard } from '@/features/cards/types';
import { mapCardData, type CardTab, type NegotiationCard } from '@/features/cards/types';
import type { ListCardsParams } from '@/api/generated/model/listCardsParams';
export default function CardsPage() {
const { cards, createCard, updateCard, deleteCard } = useCards();
const { search, setSearch, activeTab, setActiveTab, filtered, counts } = useCardFilters(cards);
const { page, setPage, pageSize, totalPages, totalCount, pageItems } = useClientPagination(filtered);
// 검색/탭/페이지 상태(재사용 훅) → 서버 쿼리 파라미터로 변환.
const list = useServerList({ pageSize: 10, initialFilters: { tab: 'ALL' } });
const activeTab = list.filters.tab as CardTab;
const params: ListCardsParams = {
search: list.debouncedSearch || undefined,
is_wildcard: activeTab === 'ALL' ? undefined : activeTab === 'WILD',
page: list.page,
size: list.pageSize,
};
const { cards, total, totalNego, totalWild, createCard, updateCard, deleteCard } = useCards(params);
const totalPages = list.totalPages(total);
// 오버레이(폼)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
// ?edit=<id> 직접 접근 시 데이터 로드 후 수정 폼이 자동으로 열린다.
const overlay = useOverlayParams(['new', 'edit']);
const editId = overlay.get('edit');
const editing = editId ? cards.find((c) => c.id === editId) ?? null : null;
// ?detail=<id> 직접 접근 시 단건 API 로 받아 수정 폼을 연다(현재 페이지에 없어도 동작).
const overlay = useOverlayRouter(['new', 'detail']);
const editId = overlay.get('detail');
const editQuery = useGetCard(editId ?? '', { query: { enabled: !!editId } });
const editing: NegotiationCard | null = editQuery.data?.card ? mapCardData(editQuery.data.card) : null;
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
const isFormOpen = overlay.has('new') || !!editing;
const openCreate = () => overlay.open('new');
const openEdit = (card: NegotiationCard) => overlay.open('edit', card.id);
const openEdit = (card: NegotiationCard) => overlay.open('detail', card.id);
const handleDeleteCard = async (id: string, cardName: string) => {
if (await confirm({ title: '카드 삭제', description: `[${cardName}]을 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
@ -41,9 +51,9 @@ export default function CardsPage() {
};
const tabs: { id: CardTab; label: string; count: number }[] = [
{ id: 'ALL', label: '전체', count: counts.all },
{ id: 'CARD', label: '협상카드', count: counts.card },
{ id: 'WILD', label: '와일드카드', count: counts.wild },
{ id: 'ALL', label: '전체', count: totalNego + totalWild },
{ id: 'CARD', label: '협상카드', count: totalNego },
{ id: 'WILD', label: '와일드카드', count: totalWild },
];
return (
@ -76,7 +86,7 @@ export default function CardsPage() {
<button
key={tab.id}
id={`card-tab-${tab.id.toLowerCase()}`}
onClick={() => setActiveTab(tab.id)}
onClick={() => list.setFilter('tab', tab.id)}
className={`py-2 px-6 font-bold text-xs tracking-tight border-b-2 transition-all cursor-pointer ${
activeTab === tab.id
? 'border-primary text-primary'
@ -91,21 +101,22 @@ export default function CardsPage() {
{/* Filtering Search Bar */}
<SearchInput
id="card-search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="전체 카드이름, 카드번호, 코드 및 핵심멘트 검색..."
value={list.search}
onChange={(e) => list.setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
placeholder="전체 카드이름, 카드번호, 코드 검색..."
/>
<CardTable
data={pageItems}
data={cards}
onEdit={openEdit}
footer={
<TablePagination
page={page}
page={list.page}
totalPages={totalPages}
totalCount={totalCount}
pageSize={pageSize}
onPageChange={setPage}
totalCount={total}
pageSize={list.pageSize}
onPageChange={list.setPage}
label="전체 카드"
unit="개"
/>

View File

@ -1,5 +1,5 @@
import { Plus, Upload } from 'lucide-react';
import { useOverlayParams } from '@/lib/useOverlayParams';
import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
import { PageContainer } from '@/components/layout/PageContainer';
@ -30,16 +30,16 @@ export default function PartnersPage() {
const totalPages = list.totalPages(total);
// 오버레이(폼/엑셀)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
// ?edit=<id> 직접 접근 시 데이터 로드 후 수정 폼이 자동으로 열린다.
const overlay = useOverlayParams(['new', 'edit', 'modal']);
const editId = overlay.get('edit');
// ?detail=<id> 직접 접근 시 데이터 로드 후 수정 폼이 자동으로 열린다.
const overlay = useOverlayRouter(['new', 'detail', 'modal']);
const editId = overlay.get('detail');
const modal = overlay.get('modal'); // 'excel' | null
const editing = editId ? allPartners.find((p) => p.supplier_id === editId) ?? null : null;
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
const isFormOpen = overlay.has('new') || !!editing;
const openCreate = () => overlay.open('new');
const openEdit = (part: Partner) => overlay.open('edit', part.supplier_id);
const openEdit = (part: Partner) => overlay.open('detail', part.supplier_id);
const handleDeletePartner = async (id: string, partnerName: string) => {
if (await confirm({ title: '협력사 삭제', description: `[${partnerName}] 파트너사를 협력사 목록에서 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
@ -73,6 +73,7 @@ export default function PartnersPage() {
id="partner-search"
value={list.search}
onChange={(e) => list.setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
placeholder="협력사명, 코드 또는 담당자명으로 추적 검색..."
/>

View File

@ -1,6 +1,6 @@
import { useState } from 'react';
import { Plus, Upload, TrendingDown } from 'lucide-react';
import { useOverlayParams } from '@/lib/useOverlayParams';
import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
import { PageContainer } from '@/components/layout/PageContainer';
@ -45,17 +45,16 @@ export default function ProductsPage() {
// 행 선택(테이블 체크박스 + 최저가 모달 대상)
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// 오버레이(폼/최저가/엑셀)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
// ?edit=<id> 직접 접근 시 데이터 로드 후 수정 폼이 자동으로 열린다.
const overlay = useOverlayParams(['new', 'edit', 'modal']);
const editId = overlay.get('edit');
// 딥링크·뒤로가기·새로고침 지원
const overlay = useOverlayRouter(['new', 'detail', 'modal']);
const editId = overlay.get('detail');
const modal = overlay.get('modal'); // 'price' | 'excel' | null
const editing = editId ? allProducts.find((p) => p.item_id === editId) ?? null : null;
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
const isFormOpen = overlay.has('new') || !!editing;
const openCreate = () => overlay.open('new');
const openEdit = (prod: Product) => overlay.open('edit', prod.item_id);
const openEdit = (prod: Product) => overlay.open('detail', prod.item_id);
const handleDeleteProduct = async (id: string, prodName: string) => {
if (await confirm({ title: '상품 삭제', description: `[${prodName}] 상품 정보를 완전 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
@ -99,6 +98,7 @@ export default function ProductsPage() {
id="product-search"
value={list.search}
onChange={(e) => list.setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
placeholder="상품명 또는 상품 코드로 통합 검색..."
/>

View File

@ -1,43 +1,54 @@
import { Settings, Plus } from 'lucide-react';
import { useOverlayParams } from '@/lib/useOverlayParams';
import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { PageContainer } from '@/components/layout/PageContainer';
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
import { TablePagination } from '@/components/ui/table-pagination';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useClientPagination } from '@/lib/useClientPagination';
import { useServerList } from '@/lib/useServerList';
import { useQuotations } from '@/features/quotations/hooks/useQuotations';
import { useQuotationFilters } from '@/features/quotations/hooks/useQuotationFilters';
import { useGetQuotation } from '@/api/generated/quotation/quotation';
import { QuotationTable } from '@/features/quotations/components/QuotationTable';
import { QuotationDetailDrawer } from '@/features/quotations/components/QuotationDetailDrawer';
import { CreateQuotationWizard } from '@/features/quotations/components/CreateQuotationWizard';
import { QuotationDetailSheet } from '@/features/quotations/components/QuotationDetailSheet';
import { QuotationCreateModal } from '@/features/quotations/components/QuotationCreateModal';
import { QuotationSettingsModal } from '@/features/quotations/components/QuotationSettingsModal';
import { QUOTATION_STATUS_FILTERS } from '@/features/quotations/types';
import { QUOTATION_STATUS_FILTERS, QUOTATION_STATUS_CODE, QUOTATION_TYPE_CODE } from '@/features/quotations/types';
import type { ListQuotationsParams } from '@/api/generated/model/listQuotationsParams';
export default function QuotationPage() {
// 검색/상태·유형 필터/페이지 상태 → 서버 쿼리 파라미터로 변환.
const list = useServerList({ pageSize: 10, initialFilters: { status: 'ALL', type: 'ALL' } });
const statusFilter = list.filters.status;
const typeFilter = list.filters.type;
const params: ListQuotationsParams = {
search: list.debouncedSearch || undefined,
status: statusFilter !== 'ALL' ? String(QUOTATION_STATUS_CODE[statusFilter] ?? '') : undefined,
type: typeFilter !== 'ALL' ? String(QUOTATION_TYPE_CODE[typeFilter] ?? '') : undefined,
page: list.page,
size: list.pageSize,
};
const {
products,
partners,
cards,
quotations,
total,
quotationSettings,
stopNegotiation,
addSetting,
deleteSetting,
createQuotation,
} = useQuotations();
} = useQuotations(params);
const totalPages = list.totalPages(total);
const { search, setSearch, statusFilter, setStatusFilter, typeFilter, setTypeFilter, filtered } =
useQuotationFilters(quotations);
const { page, setPage, pageSize, totalPages, totalCount, pageItems } = useClientPagination(filtered);
// 오버레이(상세/생성/세팅)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
// ?detail=<id> 로 직접 접근하면 데이터 로드 후 상세가 자동으로 열린다.
const overlay = useOverlayParams(['detail', 'create', 'settings']);
const overlay = useOverlayRouter(['detail', 'create', 'settings']);
const detailId = overlay.get('detail');
const isCreateOpen = overlay.has('create');
const isSettingsOpen = overlay.has('settings');
const activeQuotation = quotations.find((e) => e.id === detailId) ?? null;
// 상세 요약은 리스트에서 find 하지 않고 단건 API 로 받아온다(딥링크 시 리스트 의존 제거).
const detailQuery = useGetQuotation(detailId ?? '', { query: { enabled: !!detailId } });
const activeQuotation = detailQuery.data?.quotation ?? null;
return (
<PageContainer>
@ -66,13 +77,14 @@ export default function QuotationPage() {
>
<SearchInput
id="quotation-search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="견적명 또는 견적 번호로 실시간 서치..."
value={list.search}
onChange={(e) => list.setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
placeholder="견적명 또는 견적 번호로 검색..."
/>
<div className="grid grid-cols-2 gap-2">
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as string)}>
<Select value={statusFilter} onValueChange={(v) => list.setFilter('status', v as string)}>
<SelectTrigger id="quotation-status-filter" className="font-bold">
<SelectValue />
</SelectTrigger>
@ -84,7 +96,7 @@ export default function QuotationPage() {
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={(v) => setTypeFilter(v as string)}>
<Select value={typeFilter} onValueChange={(v) => list.setFilter('type', v as string)}>
<SelectTrigger id="quotation-type-filter">
<SelectValue>
{(value) => (value === 'ALL' ? '전체 유형' : value === 'RE_NEGOTIATION' ? '재협상' : '재견적')}
@ -100,16 +112,16 @@ export default function QuotationPage() {
</PageToolbar>
<QuotationTable
data={pageItems}
data={quotations}
products={products}
onOpenDetail={(id) => overlay.open('detail', id)}
footer={
<TablePagination
page={page}
page={list.page}
totalPages={totalPages}
totalCount={totalCount}
pageSize={pageSize}
onPageChange={setPage}
totalCount={total}
pageSize={list.pageSize}
onPageChange={list.setPage}
label="전체 견적"
unit="건"
/>
@ -117,19 +129,16 @@ export default function QuotationPage() {
/>
{activeQuotation && (
<QuotationDetailDrawer
key={activeQuotation.id}
estimate={activeQuotation}
products={products}
partners={partners}
quotationSettings={quotationSettings}
<QuotationDetailSheet
key={activeQuotation.qt_id}
quotation={activeQuotation}
onStop={stopNegotiation}
onClose={overlay.close}
/>
)}
{isCreateOpen && (
<CreateQuotationWizard
<QuotationCreateModal
open
products={products}
partners={partners}

View File

@ -187,7 +187,7 @@ export interface Session extends BaseEntity {
// 12. Bid Summary (견적 입찰 정보)
// ⚠ DB 없음: 별도 테이블이 없고 quotation.quotations 의 preferred_sp_* / equal_bid_* 컬럼으로 흡수됨.
// UI(QuotationDetailDrawer) 의 입찰 요약 표시용 파생 모델로만 존재.
// UI(QuotationDetailSheet) 의 입찰 요약 표시용 파생 모델로만 존재.
export interface BidSummary {
bid_summary_id: string; // (파생) UI 식별자
qt_id: string; // 견적 아이디 (quotation.quotations.qt_id)