From 6fa024137f8199d5808ca7741db9051d26615329 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Fri, 19 Jun 2026 17:02:12 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20negodata:=20=EA=B2=AC=EC=A0=81=C2=B7?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EA=B2=80=EC=83=89/=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EC=82=AC=EC=9D=B4=EB=93=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20+=20=EA=B2=AC=EC=A0=81=20=EC=83=81=EC=84=B8=20Sheet?= =?UTF-8?q?=20=EB=A6=AC=ED=8C=A9=ED=84=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 백엔드: 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) --- negodata/backend/crud/quotation_crud.py | 11 +- negodata/backend/router/v1/card/card.py | 3 +- negodata/backend/router/v1/card/protocol.py | 2 + .../backend/router/v1/quotation/quotation.py | 3 +- negodata/backend/services/card_service.py | 21 +- .../backend/services/quotation_service.py | 4 +- .../api/generated/model/listCardsParams.ts | 4 + .../generated/model/listQuotationsParams.ts | 4 + .../src/api/generated/model/resCardList.ts | 4 + .../front/src/components/ImageDropzone.tsx | 11 +- .../front/src/components/ui/data-table.tsx | 5 - .../features/cards/components/CardTable.tsx | 3 +- .../features/cards/hooks/useCardFilters.ts | 27 - .../src/features/cards/hooks/useCards.ts | 21 +- .../features/partners/hooks/usePartners.ts | 4 +- .../products/components/ProductTable.tsx | 2 +- ...ionWizard.tsx => QuotationCreateModal.tsx} | 6 +- .../components/QuotationDetailDrawer.tsx | 741 ------------------ .../QuotationDetailSheet/ChatTab.tsx | 211 +++++ .../DrawerHeaderCards.tsx | 194 +++++ .../QuotationDetailSheet/InfoField.tsx | 35 + .../QuotationCardsTab.tsx | 57 ++ .../SessionsStatusTab.tsx | 110 +++ .../QuotationDetailSheet/StatusPill.tsx | 95 +++ .../components/QuotationDetailSheet/index.tsx | 203 +++++ .../quotations/hooks/useQuotationFilters.ts | 29 - .../quotations/hooks/useQuotations.ts | 15 +- .../front/src/features/quotations/types.ts | 37 +- negodata/front/src/lib/useOverlayParams.ts | 35 - negodata/front/src/lib/useOverlayRouter.ts | 50 ++ negodata/front/src/lib/useServerList.ts | 35 +- negodata/front/src/pages/cards.tsx | 59 +- negodata/front/src/pages/partners.tsx | 11 +- negodata/front/src/pages/products.tsx | 12 +- negodata/front/src/pages/quotation.tsx | 73 +- negodata/front/src/types.ts | 2 +- 36 files changed, 1172 insertions(+), 967 deletions(-) delete mode 100644 negodata/front/src/features/cards/hooks/useCardFilters.ts rename negodata/front/src/features/quotations/components/{CreateQuotationWizard.tsx => QuotationCreateModal.tsx} (99%) delete mode 100644 negodata/front/src/features/quotations/components/QuotationDetailDrawer.tsx create mode 100644 negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx create mode 100644 negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx create mode 100644 negodata/front/src/features/quotations/components/QuotationDetailSheet/InfoField.tsx create mode 100644 negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx create mode 100644 negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx create mode 100644 negodata/front/src/features/quotations/components/QuotationDetailSheet/StatusPill.tsx create mode 100644 negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx delete mode 100644 negodata/front/src/features/quotations/hooks/useQuotationFilters.ts delete mode 100644 negodata/front/src/lib/useOverlayParams.ts create mode 100644 negodata/front/src/lib/useOverlayRouter.ts diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index 0bfb93f..c4945d3 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -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: diff --git a/negodata/backend/router/v1/card/card.py b/negodata/backend/router/v1/card/card.py index 8281109..e233947 100644 --- a/negodata/backend/router/v1/card/card.py +++ b/negodata/backend/router/v1/card/card.py @@ -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="협상카드 등록") diff --git a/negodata/backend/router/v1/card/protocol.py b/negodata/backend/router/v1/card/protocol.py index c8bec4b..6af1939 100644 --- a/negodata/backend/router/v1/card/protocol.py +++ b/negodata/backend/router/v1/card/protocol.py @@ -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): diff --git a/negodata/backend/router/v1/quotation/quotation.py b/negodata/backend/router/v1/quotation/quotation.py index 5de3905..639d923 100644 --- a/negodata/backend/router/v1/quotation/quotation.py +++ b/negodata/backend/router/v1/quotation/quotation.py @@ -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="견적 생성") diff --git a/negodata/backend/services/card_service.py b/negodata/backend/services/card_service.py index 222f370..2f4ec2b 100644 --- a/negodata/backend/services/card_service.py +++ b/negodata/backend/services/card_service.py @@ -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 # ---- 단건 조회 ----------------------------------------------------------- diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index 462166b..12d39a1 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -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) diff --git a/negodata/front/src/api/generated/model/listCardsParams.ts b/negodata/front/src/api/generated/model/listCardsParams.ts index 70451cc..ed85c84 100644 --- a/negodata/front/src/api/generated/model/listCardsParams.ts +++ b/negodata/front/src/api/generated/model/listCardsParams.ts @@ -10,6 +10,10 @@ export type ListCardsParams = { * 카드명/카드번호/스크립트 검색 */ search?: string | null; +/** + * 탭 필터: 미지정=전체 / false=협상카드 / true=와일드카드 + */ +is_wildcard?: boolean | null; /** * @minimum 1 */ diff --git a/negodata/front/src/api/generated/model/listQuotationsParams.ts b/negodata/front/src/api/generated/model/listQuotationsParams.ts index 31679fb..a526f5f 100644 --- a/negodata/front/src/api/generated/model/listQuotationsParams.ts +++ b/negodata/front/src/api/generated/model/listQuotationsParams.ts @@ -6,6 +6,10 @@ */ export type ListQuotationsParams = { +/** + * 견적명/견적번호 검색 + */ +search?: string | null; /** * 상태 필터(정확히 일치) */ diff --git a/negodata/front/src/api/generated/model/resCardList.ts b/negodata/front/src/api/generated/model/resCardList.ts index 93f380a..ee8f869 100644 --- a/negodata/front/src/api/generated/model/resCardList.ts +++ b/negodata/front/src/api/generated/model/resCardList.ts @@ -15,4 +15,8 @@ export interface ResCardList { page?: number; size?: number; cards?: CardData[]; + /** 협상카드 탭 카운트(검색 필터 반영) */ + total_nego?: number; + /** 와일드카드 탭 카운트(검색 필터 반영) */ + total_wild?: number; } diff --git a/negodata/front/src/components/ImageDropzone.tsx b/negodata/front/src/components/ImageDropzone.tsx index 1f89cb1..7a22b8c 100644 --- a/negodata/front/src/components/ImageDropzone.tsx +++ b/negodata/front/src/components/ImageDropzone.tsx @@ -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(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 ( diff --git a/negodata/front/src/components/ui/data-table.tsx b/negodata/front/src/components/ui/data-table.tsx index 4ecfa1c..4218593 100644 --- a/negodata/front/src/components/ui/data-table.tsx +++ b/negodata/front/src/components/ui/data-table.tsx @@ -97,10 +97,7 @@ export function DataTable({ const detailCols = mobileCols.filter((c) => c !== primaryCol) return ( - // 전환 기준은 뷰포트가 아니라 '표가 들어갈 실제 폭'(컨테이너). 사이드바가 폭을 먹어도 - // 어긋나지 않는다. 컨테이너 ≥ 48rem(@3xl)이면 표, 그 미만은 카드 리스트. (Tailwind v4 내장 @container)
- {/* 넓을 때: 표 — 그래도 넘치면 Table 내부에서 가로 스크롤 */}
@@ -184,7 +181,6 @@ export function DataTable({
- {/* 좁을 때(컨테이너 < 48rem): 카드 리스트 — 행=카드, 컬럼=라벨:값 */}
{data.length > 0 ? ( data.map((row) => { @@ -218,7 +214,6 @@ export function DataTable({
)} - {/* 상세: 기본=라벨:값 한 줄(컴팩트) / mobileBlock=라벨 아래 풀폭 */} {detailCols.length > 0 && (
{detailCols.map((c, i) => diff --git a/negodata/front/src/features/cards/components/CardTable.tsx b/negodata/front/src/features/cards/components/CardTable.tsx index 70996b5..27368be 100644 --- a/negodata/front/src/features/cards/components/CardTable.tsx +++ b/negodata/front/src/features/cards/components/CardTable.tsx @@ -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) => ( -
+
{card.scriptPreview}
), diff --git a/negodata/front/src/features/cards/hooks/useCardFilters.ts b/negodata/front/src/features/cards/hooks/useCardFilters.ts deleted file mode 100644 index e3a98f8..0000000 --- a/negodata/front/src/features/cards/hooks/useCardFilters.ts +++ /dev/null @@ -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('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 }; -} diff --git a/negodata/front/src/features/cards/hooks/useCards.ts b/negodata/front/src/features/cards/hooks/useCards.ts index ce06312..3afbc4a 100644 --- a/negodata/front/src/features/cards/hooks/useCards.ts +++ b/negodata/front/src/features/cards/hooks/useCards.ts @@ -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, diff --git a/negodata/front/src/features/partners/hooks/usePartners.ts b/negodata/front/src/features/partners/hooks/usePartners.ts index 88e172d..34beaed 100644 --- a/negodata/front/src/features/partners/hooks/usePartners.ts +++ b/negodata/front/src/features/partners/hooks/usePartners.ts @@ -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(); diff --git a/negodata/front/src/features/products/components/ProductTable.tsx b/negodata/front/src/features/products/components/ProductTable.tsx index f9cfd29..bcbfbd2 100644 --- a/negodata/front/src/features/products/components/ProductTable.tsx +++ b/negodata/front/src/features/products/components/ProductTable.tsx @@ -33,7 +33,7 @@ export function ProductTable({ rowKey={(prod) => prod.item_id} onRowClick={onRowClick} selection={{ selectedKeys: selectedIds, onSelectionChange }} - empty="부합하는 B2B 상품 데이터 정보가 식별되지 않습니다." + empty="부합하는 상품 데이터 정보가 식별되지 않습니다." footer={ 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'); diff --git a/negodata/front/src/features/quotations/components/QuotationDetailDrawer.tsx b/negodata/front/src/features/quotations/components/QuotationDetailDrawer.tsx deleted file mode 100644 index 3673674..0000000 --- a/negodata/front/src/features/quotations/components/QuotationDetailDrawer.tsx +++ /dev/null @@ -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('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(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 ( -
-
- -
- - {/* Header */} -
-
-
-
- B2B 견적 상세 // {q_number} -
- {q_name} -
- -
- - {statusKey === '견적진행중' && ( - - )} - -
-
- - {/* DB mapping info cards */} - {showHeaderCards && ( -
- {/* 좌측 컬럼: 견적정보 + 진행상태 */} -
- {/* Quotations */} -
- - 견적 정보 - -
-
- 견적명 - {q_name} -
-
- 견적번호 - {q_number} -
-
- 유형 - - {q_type === 'RE_NEGOTIATION' ? '재협상' : '재견적'} - -
-
- 차수 - {q_round}차 -
-
- 견적상태 - - - {statusKey || q_status} - -
-
- 마감시각 - {q_end_time} -
-
- 담당자 - {q_manager_name} ({q_manager_email}) -
-
- 메모 - - {q_memo} - -
-
-
- - {/* Bid Summary */} -
- - 견적 진행상태/결과 - -
-
- 식별자 - {bidSummaryObj.bid_summary_id} -
-
- 진행/결과 상태 - {bidSummaryObj.status} -
-
- 반복횟수 - {bidSummaryObj.qt_iteration}회 -
-
- 우선협상자 존재여부 - - {bidSummaryObj.has_preferred ? '존재' : '미존재'} - -
-
- 우선협상자명 - {bidSummaryObj.preferred_sp_name} -
-
- 동가입찰정보 - - {bidSummaryObj.equal_data} - -
-
-
-
- - {/* 우측 컬럼: 상품정보 + 세팅 */} -
- {/* 상품 정보 (협상 대상 상품) */} -
- - 상품 정보 - - {currentProduct ? ( -
-
- {currentProduct.image_url ? ( - {currentProduct.name - ) : ( - - )} -
-
- - {currentProduct.name || '-'} - -
- {productSpecRows.map((r) => ( -
- {r.label} - - {r.value} - -
- ))} -
-
-
- ) : ( -
상품 정보가 비어있습니다.
- )} -
- - {/* Quotation Settings */} -
- - 견적 세팅 - - {selectedSettingObj ? ( -
-
- 목표 마진율 - {selectedSettingObj.target_margin} -
-
- 앵커링 설정 값 - {selectedSettingObj.anchoring_value} -
-
- 카드 사용 횟수 - {selectedSettingObj.card_use_count} -
-
- ) : ( -
적용된 견적 세팅이 비어있습니다.
- )} -
-
-
- )} -
- - {/* Tabs */} -
-
- {tabs.map((tab) => { - const Icon = tab.icon; - return ( - - ); - })} -
-
- - {/* Tab content */} -
- - {/* Tab: Sessions Status */} - {activeTab === 'status' && ( -
-
- - - - 세션 ID - 협력사 - 협상 URL - 상품 - 협상상태 - 목표가 - 투찰가 - 투찰시각 - 마감시각 - 거절사유 - 거절가격 - 거절배송방식 - - - - {sessionViews.length === 0 && ( - - - 참여 중인 협상 세션이 없습니다. (리스트가 비어 있습니다) - - - )} - {sessionViews.map((sess) => ( - - {sess.session_id} - -
- {sess.supplier_name} - -
-
- - {sess.url ? ( -
- - 세션 열기 - - -
- ) : ( - - - )} -
- {sess.item_name} - - - {sess.status} - - - - ₩{sess.target_price?.toLocaleString() || '-'} - - - {sess.bid_price ? `₩${sess.bid_price.toLocaleString()}` : '-'} - - {sess.bid_at || '-'} - {sess.end_time || '-'} - {sess.reject_reason || '-'} - {sess.reject_price ? `₩${sess.reject_price.toLocaleString()}` : '-'} - {sess.reject_delivery_type || '-'} -
- ))} -
-
-
-
- )} - - {/* Tab: Quotation Cards */} - {activeTab === 'cards' && ( -
-
- - - - 세션 카드 ID - 카드 이름 - 타입 - - - - {quotationCardViews.length > 0 ? ( - quotationCardViews.map((qc) => ( - - {qc.session_card_id} - - {qc.card_id ? ( - - {qc.card_name} - - ) : ( - qc.card_name - )} - - - - {qc.type} - - - - )) - ) : ( - - - 사용된 협상 카드가 없습니다. (리스트가 비어 있습니다) - - - )} - -
-
-
- )} - - {/* Tab: Chat */} - {activeTab === 'chat' && ( -
- {/* Sessions list */} -
-
- 참여자 협력사 리스트 -
-
- {serverSessions.length === 0 && ( -
- 참여 협상 세션이 없습니다. (리스트가 비어 있습니다) -
- )} - {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 ( - - ); - })} -
-
- - {/* Chat zone */} -
-
-
- 협력사: {currentSupplierName} -
-
- 기록: {chatMessages.length} 메시지 -
-
- -
- {!effectiveSessionId ? ( -
- 선택된 협력사가 없습니다. -
- ) : chatMessages.length === 0 ? ( -
- 기록된 협상 대화가 없습니다. -
- ) : ( - 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 ( -
-
-
- {isBot ? 'Negosium Bot' : currentSupplierName} - · - #{m.index} -
- -
-
제시 단가 ₩{Number(m.target_price).toLocaleString()}
- {usedCard && ( -
- {/* 헤더: 어떤 카드인지(번호·이름·종류) */} -
- - 협상카드 - {usedCard.number && #{usedCard.number}} - {usedCard.name && · {usedCard.name}} - - {isWildCard ? '와일드' : '협상'} - -
- - {/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script */} - {cardNodes ? ( -
- -
- ) : usedCard.script ? ( -

- {usedCard.script} -

- ) : null} - - {/* 와일드카드 부가 정보: 사용 조건 / 메모 */} - {isWildCard && (usedCard.condition || usedCard.memo) && ( -
- {usedCard.condition && ( -
- 조건: {usedCard.condition} -
- )} - {usedCard.memo && ( -
- 메모: {usedCard.memo} -
- )} -
- )} -
- )} -
-
-
- ); - }) - )} -
- -
- - -
-
-
- )} - -
-
-
- ); -} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx new file mode 100644 index 0000000..da8ceb2 --- /dev/null +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx @@ -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 ( +
+ {/* Sessions list */} +
+
+ 참여자 협력사 리스트 +
+
+ {serverSessions.length === 0 && ( +
+ 참여 협상 세션이 없습니다. (리스트가 비어 있습니다) +
+ )} + {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 ( + + ); + })} +
+
+ + {/* Chat zone */} +
+
+
+ 협력사: {currentSupplierName} +
+
+ 기록: {chatMessages.length} 메시지 +
+
+ +
+ {!effectiveSessionId ? ( +
+ 선택된 협력사가 없습니다. +
+ ) : chatMessages.length === 0 ? ( +
+ 기록된 협상 대화가 없습니다. +
+ ) : ( + 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 ( +
+
+
+ {isBot ? 'Negosium Bot' : currentSupplierName} + · + #{m.index} +
+ +
+
제시 단가 ₩{Number(m.target_price).toLocaleString()}
+ {usedCard && ( +
+ {/* 헤더: 어떤 카드인지(번호·이름·종류) */} +
+ + 협상카드 + {usedCard.number && #{usedCard.number}} + {usedCard.name && · {usedCard.name}} + + {isWildCard ? '와일드' : '협상'} + +
+ + {/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script */} + {cardNodes ? ( +
+ +
+ ) : usedCard.script ? ( +

+ {usedCard.script} +

+ ) : null} + + {/* 와일드카드 부가 정보: 사용 조건 / 메모 */} + {isWildCard && (usedCard.condition || usedCard.memo) && ( +
+ {usedCard.condition && ( +
+ 조건: {usedCard.condition} +
+ )} + {usedCard.memo && ( +
+ 메모: {usedCard.memo} +
+ )} +
+ )} +
+ )} +
+
+
+ ); + }) + )} +
+ +
+ + +
+
+
+ ); +} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx new file mode 100644 index 0000000..88cb4ff --- /dev/null +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx @@ -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 ( + + + {title} + + {children} + + ); +} + +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 ( +
+ {/* 좌측 컬럼: 견적정보 + 진행상태 */} +
+ {/* Quotations */} + +
+ + + + + + + + + + +
+
+ + {/* Bid Summary */} + +
+ + + + + + + + {bidSummaryObj.equal_data} + + +
+
+
+ + {/* 우측 컬럼: 상품정보 + 세팅 */} +
+ {/* 상품 정보 (협상 대상 상품) */} + + {currentProduct ? ( +
+
+ {currentProduct.image_url ? ( + {currentProduct.name + ) : ( + + )} +
+
+ + {currentProduct.name || '-'} + +
+ {productSpecRows.map((r) => ( + + ))} +
+
+
+ ) : ( +
상품 정보가 비어있습니다.
+ )} +
+ + {/* Quotation Settings */} + + {selectedSettingObj ? ( +
+ + + +
+ ) : ( +
적용된 견적 세팅이 비어있습니다.
+ )} +
+
+
+ ); +} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/InfoField.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/InfoField.tsx new file mode 100644 index 0000000..ea14d91 --- /dev/null +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/InfoField.tsx @@ -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 ( +
+ {label} + {children ?? ( + + {value} + + )} +
+ ); +} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx new file mode 100644 index 0000000..fc9014a --- /dev/null +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx @@ -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; + +export function QuotationCardsTab({ quotationCardViews }: { quotationCardViews: CardView[] }) { + return ( +
+
+ + + + 세션 카드 ID + 카드 이름 + 타입 + + + + {quotationCardViews.length > 0 ? ( + quotationCardViews.map((qc) => ( + + {qc.session_card_id} + + {qc.card_id ? ( + + {qc.card_name} + + ) : ( + qc.card_name + )} + + + + {qc.type} + + + + )) + ) : ( + + + 사용된 협상 카드가 없습니다. (리스트가 비어 있습니다) + + + )} + +
+
+
+ ); +} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx new file mode 100644 index 0000000..f390765 --- /dev/null +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx @@ -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; + +export function SessionsStatusTab({ + sessionViews, + onOpenChat, +}: { + sessionViews: SessionView[]; + onOpenChat: (sessionId: string) => void; +}) { + return ( +
+
+ + + + 세션 ID + 협력사 + 협상 URL + 상품 + 협상상태 + 목표가 + 투찰가 + 투찰시각 + 마감시각 + 거절사유 + 거절가격 + 거절배송방식 + + + + {sessionViews.length === 0 && ( + + + 참여 중인 협상 세션이 없습니다. (리스트가 비어 있습니다) + + + )} + {sessionViews.map((sess) => ( + + {sess.session_id} + +
+ {sess.supplier_name} + +
+
+ + {sess.url ? ( +
+ + 세션 열기 + + +
+ ) : ( + - + )} +
+ {sess.item_name} + + {sess.status} + + + ₩{sess.target_price?.toLocaleString() || '-'} + + + {sess.bid_price ? `₩${sess.bid_price.toLocaleString()}` : '-'} + + {sess.bid_at || '-'} + {sess.end_time || '-'} + {sess.reject_reason || '-'} + + {sess.reject_price ? `₩${sess.reject_price.toLocaleString()}` : '-'} + + {sess.reject_delivery_type || '-'} +
+ ))} +
+
+
+
+ ); +} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/StatusPill.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/StatusPill.tsx new file mode 100644 index 0000000..9db243e --- /dev/null +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/StatusPill.tsx @@ -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 = { + 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 ( + + {children} + + ); +} + +/** 협상 세션 상태(현황 테이블) → 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 = { + 견적생성: { + 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 ( + + + {statusKey || fallbackLabel} + + ); +} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx new file mode 100644 index 0000000..bdc411c --- /dev/null +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx @@ -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('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(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 ( +
+
+ +
+ + {/* Header */} +
+
+
+
+ 견적 상세 // {q_number} +
+ {q_name} +
+ +
+ + {statusKey === '견적진행중' && ( + + )} + +
+
+ + {/* DB mapping info cards */} + {showHeaderCards && ( + + )} +
+ + {/* Tabs */} +
+
+ {tabs.map((tab) => { + const Icon = tab.icon; + return ( + + ); + })} +
+
+ + {/* Tab content */} +
+ {activeTab === 'status' && ( + + )} + + {activeTab === 'cards' && } + + {activeTab === 'chat' && ( + + )} +
+
+
+ ); +} diff --git a/negodata/front/src/features/quotations/hooks/useQuotationFilters.ts b/negodata/front/src/features/quotations/hooks/useQuotationFilters.ts deleted file mode 100644 index 4a7bbc0..0000000 --- a/negodata/front/src/features/quotations/hooks/useQuotationFilters.ts +++ /dev/null @@ -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, - }; -} diff --git a/negodata/front/src/features/quotations/hooks/useQuotations.ts b/negodata/front/src/features/quotations/hooks/useQuotations.ts index b4e636e..fb6e99b 100644 --- a/negodata/front/src/features/quotations/hooks/useQuotations.ts +++ b/negodata/front/src/features/quotations/hooks/useQuotations.ts @@ -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, diff --git a/negodata/front/src/features/quotations/types.ts b/negodata/front/src/features/quotations/types.ts index 6220f64..6aabad2 100644 --- a/negodata/front/src/features/quotations/types.ts +++ b/negodata/front/src/features/quotations/types.ts @@ -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 = { + 견적생성: 1, + 견적진행중: 2, + 견적마감: 3, + 협상보류: 4, +}; +export const QUOTATION_TYPE_CODE: Record = { + 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 : '-', }; } diff --git a/negodata/front/src/lib/useOverlayParams.ts b/negodata/front/src/lib/useOverlayParams.ts deleted file mode 100644 index b234c62..0000000 --- a/negodata/front/src/lib/useOverlayParams.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useSearchParams } from 'react-router'; - -// 시트/드로어/모달 같은 "오버레이" 열림 상태를 쿼리스트링으로 표현하는 단일 출처. -// 로컬 useState 대신 URL 에 담아 딥링크·뒤로가기·새로고침을 지원한다. -// 같은 그룹(keys) 안에서는 한 번에 하나만 연다(상호배타: 열 때 나머지 키 제거). -// -// const overlay = useOverlayParams(['edit', 'new', 'modal']); -// overlay.get('edit') // ?edit= 의 값(없으면 null) — 값 있는 오버레이 -// overlay.has('new') // ?new 존재 여부 — 플래그 오버레이 -// overlay.open('edit', id) // ?edit= (다른 오버레이 키는 지움) -// overlay.open('new') // ?new (값 생략 시 '1') -// overlay.close() // 그룹 내 모든 오버레이 키 제거 -export function useOverlayParams(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 }; -} diff --git a/negodata/front/src/lib/useOverlayRouter.ts b/negodata/front/src/lib/useOverlayRouter.ts new file mode 100644 index 0000000..b82f43e --- /dev/null +++ b/negodata/front/src/lib/useOverlayRouter.ts @@ -0,0 +1,50 @@ +import { useLocation, useNavigate, useSearchParams } from 'react-router'; + +// 시트/드로어/모달 같은 "오버레이" 열림 상태를 쿼리스트링으로 표현하는 단일 출처. +// 로컬 useState 대신 URL 에 담아 딥링크·뒤로가기·새로고침을 지원한다. +// 같은 그룹(keys) 안에서는 한 번에 하나만 연다(상호배타: 열 때 나머지 키 제거). +// +// const overlay = useOverlayRouter(['detail', 'new', 'modal']); +// overlay.get('detail') // ?detail= 의 값(없으면 null) — 값 있는 오버레이 +// overlay.has('new') // ?new 존재 여부 — 플래그 오버레이 +// overlay.open('detail', id) // ?detail= (다른 오버레이 키는 지움) — 히스토리 push +// overlay.open('new') // ?new (값 생략 시 '1') +// overlay.close() // 그룹 내 모든 오버레이 키 제거 +const OVERLAY_PUSHED = '__overlayPushed'; + +export function useOverlayRouter(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 }; +} diff --git a/negodata/front/src/lib/useServerList.ts b/negodata/front/src/lib/useServerList.ts index ac8daae..f6f3b60 100644 --- a/negodata/front/src/lib/useServerList.ts +++ b/negodata/front/src/lib/useServerList.ts @@ -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; setFilter: (key: string, value: string) => void; @@ -25,25 +20,39 @@ export function useServerList(opts?: { pageSize?: number; initialFilters?: Record; 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>(() => opts?.initialFilters ?? {}); + const timerRef = useRef | 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 }; } diff --git a/negodata/front/src/pages/cards.tsx b/negodata/front/src/pages/cards.tsx index 27ac37c..05afe62 100644 --- a/negodata/front/src/pages/cards.tsx +++ b/negodata/front/src/pages/cards.tsx @@ -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= 직접 접근 시 데이터 로드 후 수정 폼이 자동으로 열린다. - const overlay = useOverlayParams(['new', 'edit']); - const editId = overlay.get('edit'); - const editing = editId ? cards.find((c) => c.id === editId) ?? null : null; + // ?detail= 직접 접근 시 단건 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() {