[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:
parent
c3d96fb7d3
commit
6fa024137f
@ -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:
|
||||
|
||||
@ -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="협상카드 등록")
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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="견적 생성")
|
||||
|
||||
@ -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
|
||||
|
||||
# ---- 단건 조회 -----------------------------------------------------------
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -10,6 +10,10 @@ export type ListCardsParams = {
|
||||
* 카드명/카드번호/스크립트 검색
|
||||
*/
|
||||
search?: string | null;
|
||||
/**
|
||||
* 탭 필터: 미지정=전체 / false=협상카드 / true=와일드카드
|
||||
*/
|
||||
is_wildcard?: boolean | null;
|
||||
/**
|
||||
* @minimum 1
|
||||
*/
|
||||
|
||||
@ -6,6 +6,10 @@
|
||||
*/
|
||||
|
||||
export type ListQuotationsParams = {
|
||||
/**
|
||||
* 견적명/견적번호 검색
|
||||
*/
|
||||
search?: string | null;
|
||||
/**
|
||||
* 상태 필터(정확히 일치)
|
||||
*/
|
||||
|
||||
@ -15,4 +15,8 @@ export interface ResCardList {
|
||||
page?: number;
|
||||
size?: number;
|
||||
cards?: CardData[];
|
||||
/** 협상카드 탭 카운트(검색 필터 반영) */
|
||||
total_nego?: number;
|
||||
/** 와일드카드 탭 카운트(검색 필터 반영) */
|
||||
total_wild?: number;
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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>
|
||||
),
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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');
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 : '-',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
50
negodata/front/src/lib/useOverlayRouter.ts
Normal file
50
negodata/front/src/lib/useOverlayRouter.ts
Normal 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 };
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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="개"
|
||||
/>
|
||||
|
||||
@ -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="협력사명, 코드 또는 담당자명으로 추적 검색..."
|
||||
/>
|
||||
|
||||
|
||||
@ -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="상품명 또는 상품 코드로 통합 검색..."
|
||||
/>
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user