From 6fa024137f8199d5808ca7741db9051d26615329 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Fri, 19 Jun 2026 17:02:12 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[feat]=20negodata:=20=EA=B2=AC=EC=A0=81?= =?UTF-8?q?=C2=B7=EC=B9=B4=EB=93=9C=20=EA=B2=80=EC=83=89/=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EC=84=9C=EB=B2=84=EC=82=AC=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20+=20=EA=B2=AC=EC=A0=81=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20Sheet=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() { - {statusKey === '견적진행중' && ( - - )} + {/* 마감 버튼은 항상 노출하되, 마감 가능한 상태(생성·진행중·보류)가 아니면 비활성화만 한다. */} + {(() => { + const canClose = quotation.status !== QuotationStatus.CLOSED; + return ( + + ); + })()} } @@ -86,12 +88,18 @@ export default function QuotationPage() {
@@ -99,13 +107,18 @@ export default function QuotationPage() {
@@ -132,7 +145,7 @@ export default function QuotationPage() { )} From ae103fd0f13bf549cbf878e47c7fb8eb493132c5 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 22 Jun 2026 17:06:37 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[refactor]=20negodata/front:=20=EC=A0=84?= =?UTF-8?q?=EC=97=AD=20=ED=83=80=EC=9E=85=20=EC=A0=95=EB=A6=AC=20=E2=80=94?= =?UTF-8?q?=20=EA=B3=B5=EC=9C=A0(2+)=20=ED=83=80=EC=9E=85=EB=A7=8C=20?= =?UTF-8?q?=EA=B8=80=EB=A1=9C=EB=B2=8C,=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/types.ts: DB 미러 중복 제거, generated DTO 기반 별칭만(Product/Partner/NegotiationCard/PageType) - products/partners 의 로컬 Product/Partner 중복 → 글로벌 단일 정본 재수출 Co-Authored-By: Claude Opus 4.8 --- negodata/front/src/features/partners/types.ts | 9 +- negodata/front/src/features/products/types.ts | 12 +- negodata/front/src/types.ts | 321 ++---------------- 3 files changed, 27 insertions(+), 315 deletions(-) diff --git a/negodata/front/src/features/partners/types.ts b/negodata/front/src/features/partners/types.ts index 539e01f..3608843 100644 --- a/negodata/front/src/features/partners/types.ts +++ b/negodata/front/src/features/partners/types.ts @@ -1,11 +1,4 @@ -import type { SupplierData } from '@/api/generated/model/supplierData'; - -// UI에서 쓰는 협력사 타입. 서버 SupplierData에 화면 전용 파생 필드만 얹는다. -// (level/rank/status 등 서버 미연동 가짜 필드는 두지 않는다 — 표시 가능한 건 priority뿐.) -export type Partner = SupplierData & { - deleted?: boolean; - id?: string; -}; +export type { Partner } from '@/types'; // 우선순위 필터 목록. 'ALL'은 필터 전용(폼에서는 제외). export const prioritiesList = ['ALL', 'HIGH', 'MEDIUM', 'LOW']; diff --git a/negodata/front/src/features/products/types.ts b/negodata/front/src/features/products/types.ts index 59610e6..f1b2602 100644 --- a/negodata/front/src/features/products/types.ts +++ b/negodata/front/src/features/products/types.ts @@ -1,14 +1,4 @@ -import type { ItemData } from '@/api/generated/model/itemData'; - -// UI에서 쓰는 상품 타입. 서버 ItemData에 화면 전용 파생 필드를 얹는다. -export type Product = ItemData & { - deleted?: boolean; - id?: string; - minPrice?: number; - status?: string; -}; - -// 분류 카테고리는 서버 items 에서 distinct 로 파생한다(useProducts). 하드코딩 상수 제거됨. +export type { Product } from '@/types'; // 인터넷 최저가 데모 산정(표준 단가의 83%). 서버 미연동 — 표시/초기값 용도. export const toMinPrice = (price?: number | null) => Math.round((price || 0) * 0.83); diff --git a/negodata/front/src/types.ts b/negodata/front/src/types.ts index 5341ea5..6693cd6 100644 --- a/negodata/front/src/types.ts +++ b/negodata/front/src/types.ts @@ -1,306 +1,35 @@ -// ============================================================ -// Negosium/NegoData ERD Type Definitions (TypeScript) -// ------------------------------------------------------------ -// 기준: postgres-init/01-schema.sql (negosium_db, 도메인별 schema) + 현행 백엔드 API 계약. -// 표기 규칙: -// - 코드값(status/role/type/quantity_unit/delivery_type 등)은 DB에서 SMALLINT 정수코드지만, -// 현행 API(openapi.json) 가 문자열로 직렬화하므로 여기서는 string 으로 둔다(주석에 DB 타입 명시). -// - DB 에 대응 테이블/컬럼이 없는 항목은 "⚠ DB 없음" 으로 표시한다. -// ============================================================ +// 2개 이상 feature가 공유하는 타입만 둔다. 단일 feature 전용은 그 feature/types.ts 로. +import type { ItemData } from '@/api/generated/model/itemData'; +import type { SupplierData } from '@/api/generated/model/supplierData'; -// Base abstract structure properties -interface BaseEntity { - created_at: string; - updated_at: string; - deleted: boolean; -} - -// 1. Companies (고객사) — company.companies -export interface Company extends BaseEntity { - company_id: string; // uuid (Primary Key) - name: string; // 회사명 - business_number: string | null; // 사업자등록번호 - code: number | null; // 회사코드 (내부 인덱스용) - representative_name: string | null; // 대표자 명 - email: string | null; // 대표 이메일 - contact_number: string | null; // 대표 연락처 - website_url: string | null; // 홈페이지 URL - industry: string | null; // 업종 (DB: SMALLINT 코드) - status: string; // 상태 (DB: SMALLINT NOT NULL, 1=active 2=inactive) -} - -// 2. Users (유저) — company.users -export interface User extends BaseEntity { - user_id: string; // uuid (Primary Key) - company_id: string; // 회사 아이디 (company.companies.company_id) - id: string; // 로그인시, 입력 아이디 - password: string; // 비밀번호 (해시) - name: string | null; // 이름 - email: string | null; // 이메일 - contact_number: string | null; // 전화번호 - last_accessed_at: string; // 마지막 접속 시간 - status: string; // 상태 (DB: SMALLINT, 1=active 2=inactive) - role: string; // 권한 (DB: SMALLINT, 1=user 2=manager) -} - -// 3. User Tokens (유저 토큰) — company.user_tokens -export interface UserToken extends BaseEntity { - user_tokens_id: string; // uuid (Primary Key) — DB 컬럼명 user_tokens_id - user_id: string; // 유저 아이디 (company.users.user_id) - type: string; // 토큰 타입 (DB: SMALLINT 코드) - token: any; // 토큰 값 (JSONB) - issued_at: string; - expired_at: string; -} - -// 4. Suppliers (협력사) - 기존 Partner 대응 — partner.suppliers -export interface Supplier extends BaseEntity { - supplier_id: string; // uuid (Primary Key) - company_id: string; // 회사 아이디 (company.companies.company_id) - user_id: string; // 등록 유저 (company.users.user_id) — DB NOT NULL - name: string; // 협력사명 - code: string | null; // 협력사코드 - manager_name: string | null; // 담당자명 - manager_email: string | null; // 담당자 이메일 - manager_contact_number: string | null; // 담당자 연락처 (DB 철자 정상 — 기존 typo 가정은 오류였음) - priority: string | null; // 우선순위 (DB: VARCHAR, 고객사별 문자열 유지) -} - -// 5. Items (상품 정보) - 기존 Product 대응 — partner.items -export interface Item extends BaseEntity { - item_id: string; // uuid (Primary Key) - company_id: string; // 회사 아이디 (company.companies.company_id) - user_id: string; // 등록 유저 (company.users.user_id) — DB NOT NULL - name: string; // 상품명 - code: string | null; // 상품코드 - price: number | null; // 상품 단가 (DB: BIGINT) - category: string | null; // 상품 카테고리 (free text) - category_type: number; // 카테고리 조회용 정수 (DB: INTEGER NOT NULL DEFAULT 1, 자동증가 아님) - image_url: string | null; // 상품 이미지 URL - model_name: string | null; // 상품 모델명 - spec: string | null; // 상품 규격 - moq: string | null; // 상품 MOQ - lead_time: number | null; // 상품 리드타임 (DB: SMALLINT) - manufacturer: string | null; // 상품 제조사 - made_in: string | null; // 상품 제조 국가 - quantity_unit: string | null; // 상품 취급 단위 (DB: SMALLINT 코드) - delivery_type: string | null; // 상품 배송형태 (DB: SMALLINT 코드) - vat_yn: boolean | null; // VAT 포함 여부 - delivery_fee_yn: boolean | null; // 배송비 포함 여부 - internet_lowest_price_yn: boolean; // 인터넷 최저가 보조 플래그 -} - -// 6. Item Internet Lowest Prices — partner.item_internet_lowest_prices -// (※ 기존 url/crawled_at 필드는 DB 에 없어 제거. 실제 DB 컬럼에 맞춤.) -export interface ItemInternetLowestPrice extends BaseEntity { - lp_id: string; // uuid (Primary Key) - item_id: string; // 상품 아이디 (partner.items.item_id) - lp_price: number | null; // 크롤링한 최저가 (DB: BIGINT) - website: number; // 크롤링 대상 사이트 (DB: SMALLINT 코드) - success_yn: boolean; // 크롤링 성공 여부 - fail_reason: string | null; // 실패 사유 - ai_model: number | null; // 사용한 AI 모델 (DB: SMALLINT 코드) - crawl_duration_ms: number | null; // 크롤링 소요 시간(ms) - crawl_end_time: string; // 크롤링 종료 시각 -} - -// 7. Quotation Settings (견적 세팅) — quotation.quotation_settings -// (※ DB·API 모두 user_id 보유. 전역 기본 설정은 NULL 가능.) -export interface QuotationSetting extends BaseEntity { - qt_setting_id: string; // uuid (Primary Key) - user_id: string; // 유저 아이디 (company.users.user_id). DB 는 nullable(전역 기본=NULL)이나 UI 매퍼가 '' 로 정규화 - target_margin: string; // 목표 마진율 (DB: NUMERIC(8,6)) - anchoring_value: string; // 앵커링 설정 값 (DB: NUMERIC(8,6)) - card_use_count: string; // 협상 카드 사용 횟수 (DB: card_count INTEGER) -} - -// 8. Quotations (견적) - 기존 Estimate 대응 — quotation.quotations -// (※ company_id 는 DB/API 모두에 없어 제거. 회사 스코프는 user_id 경유.) -export interface Quotation extends BaseEntity { - qt_id: string; // uuid (Primary Key) - user_id: string; // 유저 아이디 (company.users.user_id) - qt_setting_id: string; // 견적 세팅 아이디 (quotation.quotation_settings.qt_setting_id) - version_id: string; // 협상전략 버전 (card.versions.version_id) — DB NOT NULL - name: string; // 견적 명 - number: string; // 견적 번호 - type: string; // 견적 타입 (DB: SMALLINT, 1=renego 2=requote) - round: number; // 견적 차수 (기본 1) - status: string; // 견적 상태 (DB: SMALLINT 코드) - start_time: string; // 견적 시작 시간 - end_time: string; // 견적 마감 시간 - manager_name: string | null; // 견적 담당자 명 - manager_email: string | null; // 견적 담당자 이메일 - manager_contact_number: string | null; // 견적 담당자 연락처 - memo: string | null; // 견적 안내사항 - iteration: number; // 반복 횟수 (DB NOT NULL DEFAULT 0) - preferred_sp_yn: boolean | null; // 선호 공급사 지정 여부 - preferred_sp_id: string | null; // 선호 공급사 (partner.suppliers.supplier_id) - preferred_sp_name: string | null; // 선호 공급사명(스냅샷) - equal_bid_yn: boolean | null; // 동일가 입찰 발생 여부 - equal_bid_data: any | null; // 동일가 입찰 상세 (JSONB) -} - -// 9. Nego Cards (협상카드) — card.nego_cards -// (※ company_id 는 DB 에 없어 제거. 소유는 user_id(nullable) 만.) -export interface NegoCard extends BaseEntity { - nego_card_id: string; // uuid (Primary Key) - user_id: string | null; // 유저 아이디 (o2o 기본 카드는 NULL) - name: string | null; // 카드 이름 - number: string | null; // 카드 번호 (식별번호) - script: string | null; // 스크립트 - edit_script: any | null; // 편집 스크립트 (JSONB) -} - -// 10. Wild Cards (와일드 카드) — card.wild_cards -// (※ company_id 는 DB 에 없어 제거.) -export interface WildCard extends BaseEntity { - wild_card_id: string; // uuid (Primary Key) - user_id: string | null; // 유저 아이디 (o2o 기본 카드는 NULL) - name: string | null; // 카드 이름 - number: string | null; // 카드 번호 (식별번호) - script: string | null; // 스크립트 - edit_script: any | null; // 편집 스크립트 (JSONB) - condition: string | null; // 카드 사용 조건 - available: boolean; // 협상 적용 가능 여부 - memo: string | null; // 메모 -} - -// 11. Sessions (세션) - 기존 ChatSession 대응 — negotiation.sessions -// (※ user_id, bid_summary_id 는 DB sessions 에 없어 제거. bid_summary 개념은 quotations 로 흡수됨.) -export interface Session extends BaseEntity { - session_id: string; // uuid (Primary Key) - quotation_id: string; // 소속 견적 (quotation.quotations.qt_id) — DB 컬럼명 quotation_id - item_id: string; // 상품 아이디 (partner.items.item_id) - supplier_id: string; // 협력사 아이디 (partner.suppliers.supplier_id) - qt_number: string; // 견적번호(스냅샷) - qt_round: number; // 견적 차수(스냅샷) - qt_type: string; // 견적 타입(스냅샷, DB: SMALLINT) - target_price: number; // 목표 가격 (DB: BIGINT) - status: string; // 협상 상태 (DB: SMALLINT 코드) - bid_price: number | null; // 최종 입찰 가격 - bid_at: string | null; // 최종 입찰 시간 - end_time: string; // 협상 종료 시간 - reject_reason: string | null; // 협상 거부 사유 - reject_price: number | null; // 협상 거부 가격 - reject_delivery_type: string | null; // 협상 거부 시 배송 형태 (DB: SMALLINT 코드) -} - -// 12. Bid Summary (견적 입찰 정보) -// ⚠ DB 없음: 별도 테이블이 없고 quotation.quotations 의 preferred_sp_* / equal_bid_* 컬럼으로 흡수됨. -// UI(QuotationDetailSheet) 의 입찰 요약 표시용 파생 모델로만 존재. -export interface BidSummary { - bid_summary_id: string; // (파생) UI 식별자 - qt_id: string; // 견적 아이디 (quotation.quotations.qt_id) - qt_type: string; // 견적 타입 (재협상/재견적) - qt_iteration: number; // 견적 반복횟수 (← quotations.iteration) - status: string; // 입찰 상태 (← quotations.status) - has_preferred: boolean; // ← quotations.preferred_sp_yn - preferred_sp_id: string | null; // ← quotations.preferred_sp_id - preferred_sp_name: string | null; // ← quotations.preferred_sp_name - equal_data: any | null; // ← quotations.equal_bid_data (JSONB) -} - -// 13. Results (세션 결과) — negotiation.results (스키마 미확정 스텁) -export interface Result extends BaseEntity { - result_id: string; // uuid (Primary Key) — DB 컬럼명 result_id -} - -// 14. Quotation Cards (견적↔카드 연결) -// API(QuotationCardResponse) 로는 노출되나, 33KB DB 에는 quotation_cards 테이블이 없고 -// card.version_nego_cards / card.version_wild_cards (버전↔카드) 매핑으로 실현된다 -// (quotation.version_id → version_*_cards → nego/wild_cards). -export interface QuotationCard { - session_card_id: string; // uuid (Primary Key, API 기준) - wild_card_id: string | null; // 와일드 카드 아이디 (card.wild_cards.wild_card_id) - nego_card_id: string | null; // 협상카드 아이디 (card.nego_cards.nego_card_id) - qt_id: string | null; // 견적 아이디 (quotation.quotations.qt_id) - type: string | null; // 카드 타입 (DB: SMALLINT, 1=nego 2=wild) -} - -// 15. Chats (채팅 내역) — negotiation.chats -// (※ API(ChatMessageResponse) 는 순번 컬럼을 index 로, DB 는 seq 로 부른다.) -export interface Chat extends BaseEntity { - chat_id: string; // uuid (Primary Key) - session_id: string; // 소속 세션 (negotiation.sessions.session_id) - card_id: string | null; // 사용된 카드 (card.nego_cards/wild_cards, 다형성) - seq: number; // 세션 내 메시지 순번 (API: index) - sender: string; // 발신자 구분 (DB: SMALLINT 코드) - target_price: number; // 제시 목표가 (DB: BIGINT) - card_used_yn: boolean | null; // 카드 사용 여부 - indicator_value: number | null; // 지표값 (DB: NUMERIC(8,6)) - card_type: string | null; // 카드 유형 (DB: SMALLINT, 1=nego 2=wild) -} - -// 16. CopyOfChat (채팅 임시/백업) -// ⚠ DB 없음: 대응 테이블 없음 (프론트 임시 보관용). -export interface CopyOfChat extends BaseEntity { - id: string; -} - -// ============================================================ -// React UI Compatibility Helper Types -// ============================================================ - -export type Product = Partial & { - id?: string; // item_id back-compatibility map - minPrice?: number; // UI minimum reserve limit - status?: string; // UI lifecycle state +export type Product = ItemData & { + deleted?: boolean; + id?: string; + minPrice?: number; + status?: string; }; -export type Partner = Partial & { - id?: string; // supplier_id back-compatibility map - managerName?: string; // manager_name - managerEmail?: string; // manager_email - managerPhone?: string; // manager_contact_number - manager_contact_number?: string; // DB 컬럼 직접 매핑(철자 정상) - rank?: 'A' | 'B' | 'C' | 'S'; // computed from priority - status?: string; // mapped to deleted - memo?: string; // back-compatible details +export type Partner = SupplierData & { + deleted?: boolean; + id?: string; + managerName?: string; + managerEmail?: string; + managerPhone?: string; + rank?: 'A' | 'B' | 'C' | 'S'; + status?: string; + memo?: string; }; -export type Estimate = Partial & { - id?: string; // qt_id back-compatibility map - dueDate?: string; // end_time - title?: string; // mapped to name in UI - productId?: string; // mapped to association - productName?: string; // 서버 목록 조인 상품명(products 목록에 없을 때 폴백) - partnerIds?: string[]; // mapped B2B suppliers - participationCount?: number; - winnerPartnerId?: string | null; - finalPrice?: number; - isEqualPrice?: boolean; - usedCardIds?: string[]; - settingApplied?: boolean | string; -}; - -// UI Chat Message definition (corresponds to in-memory/rendered chats) -export interface ChatMessage { - id: string; - sender: 'BOT' | 'PARTNER' | 'SYSTEM'; - timestamp: string; - content: string; - editorScript?: any; // Slate JSON structure -} - -export interface ChatSession { - id: string; // matches supplier_id (or supplier.supplier_id) - partnerName: string; - status: 'NEGOTIATING' | 'COMPLETED' | 'REJECTED' | '협상생성' | '협상중' | '협상완료' | '미참여' | '협상거부'; - currentBid: number; - bidTime: string; - messages: ChatMessage[]; -} - export interface NegotiationCard { - id: string; // nego_card_id or wild_card_id - isWildcard: boolean; // mapping based on source table - code: string; // number (식별번호) or custom code - title: string; // name - scriptPreview: string; // script - editorScript: any; // edit_script (JSON) + id: string; + isWildcard: boolean; + code: string; + title: string; + scriptPreview: string; + editorScript: any; status: 'ACTIVE' | 'INACTIVE'; - triggerCondition?: string; // wild_card's condition - memo?: string; // wild_card's memo + triggerCondition?: string; + memo?: string; } export type PageType = 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS'; From 5406094ea1990d8d0a84e797a08f0c06509412b5 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 22 Jun 2026 17:10:26 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[wip]=20negodata/backend:=20=EA=B2=AC?= =?UTF-8?q?=EC=A0=81=20=EB=A7=88=EA=B0=90=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scheduler/(__init__·jobs): 마감 처리·낙찰자 선정 잡 - quotation_crud·service: 스케줄러 연동 조회/마감 로직 - web_main·requirements·docker-compose·config: 스케줄러 기동 설정 Co-Authored-By: Claude Opus 4.8 --- backend/config/config.local.toml.example | 6 + docker-compose.yml | 4 + negodata/backend/crud/quotation_crud.py | 146 +++++++++++++++++- negodata/backend/requirements.txt | 1 + negodata/backend/scheduler/__init__.py | 74 +++++++++ negodata/backend/scheduler/jobs.py | 124 +++++++++++++++ .../backend/services/quotation_service.py | 13 +- negodata/backend/web_main.py | 35 +++-- .../QuotationCardsTab.tsx | 2 +- 9 files changed, 383 insertions(+), 22 deletions(-) create mode 100644 negodata/backend/scheduler/__init__.py create mode 100644 negodata/backend/scheduler/jobs.py diff --git a/backend/config/config.local.toml.example b/backend/config/config.local.toml.example index 06082bd..93dfe89 100644 --- a/backend/config/config.local.toml.example +++ b/backend/config/config.local.toml.example @@ -37,3 +37,9 @@ access_key = "" refresh_key = "" access_expire_min = 30 refresh_expire_day = 7 + +# 협상 agent(포트 9500) 접속. use_mock=true 면 agent 미연동 — 내장 mock 응답 사용(통합 테스트/로컬 기본). +[AgentConfig] +base_url = "http://127.0.0.1:9500" +timeout_sec = 10.0 +use_mock = true diff --git a/docker-compose.yml b/docker-compose.yml index 8126521..b5d45c5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,10 @@ services: environment: APP_ENV: local DB_HOST: host.docker.internal # 컨테이너→호스트 DB (config.local.toml의 127.0.0.1 override) + RELOAD: "1" # uvicorn --reload 활성 → 소스 저장 시 자동 재기동(재빌드 불필요) + SCHEDULER_ENABLED: "1" # 마감 크론 활성(단일 워커라 중복 없음). 운영 다중 워커면 1개 프로세스에서만 1 + volumes: + - ./negodata/backend:/app # 호스트 소스 = 컨테이너 코드. 이게 있어야 수정이 즉시 반영됨 ports: - "9400:9400" extra_hosts: diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index c4945d3..a22f69a 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -7,10 +7,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import ( - quotations, sessions, chats, nego_cards, wild_cards, items, quotation_settings, + quotations, sessions, chats, nego_cards, wild_cards, items, suppliers, quotation_settings, version_nego_cards, version_wild_cards, ) -from common.enums import ErrorType +from common.enums import ErrorType, QuotationStatus, QuotationType, SessionStatus from common.logger import LOG from common.utils.gtime import GTime @@ -59,6 +59,10 @@ class IQuotationCRUD(ABC): async def update_quotation(self, cdb: AsyncSession, qt_id, data: dict) -> ErrorType: pass + @abstractmethod + async def update_sessions_status(self, cdb: AsyncSession, qt_id, from_statuses: list[int], to_status: int) -> ErrorType: + pass + @abstractmethod async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType: pass @@ -83,6 +87,27 @@ class IQuotationCRUD(ABC): async def item_map(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]: pass + # ----- 스케줄러(크론) 전용 ----- + @abstractmethod + async def list_due_for_close(self, cdb: AsyncSession, now) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def list_requote_done(self, cdb: AsyncSession) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def list_done_sessions(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def bulk_update_quotation_status(self, cdb: AsyncSession, qt_ids, status: int) -> ErrorType: + pass + + @abstractmethod + async def bulk_update_sessions_status(self, cdb: AsyncSession, qt_ids, from_statuses: list[int], to_status: int) -> ErrorType: + pass + class QuotationCRUD(IQuotationCRUD): async def search( @@ -311,6 +336,23 @@ class QuotationCRUD(IQuotationCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED + async def update_sessions_status(self, cdb: AsyncSession, qt_id, from_statuses: list[int], to_status: int) -> ErrorType: + # 견적에 딸린 세션 중 from_statuses 에 속한 것만 to_status 로 일괄 전이(삭제 제외). 다른 상태는 건드리지 않는다. + try: + query = ( + update(sessions) + .where( + sessions.quotation_id == qt_id, + sessions.status.in_(from_statuses), + sessions.deleted == False, # noqa: E712 + ) + .values(status=to_status, updated_at=GTime.UTC()) + ) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType: try: query = update(quotations).where(quotations.qt_id == qt_id).values(deleted=True, updated_at=GTime.UTC()) @@ -319,6 +361,106 @@ class QuotationCRUD(IQuotationCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED + # ----- 스케줄러(크론) 전용 ----- + async def list_due_for_close(self, cdb: AsyncSession, now) -> Tuple[ErrorType, list]: + """[잡①] 마감시각이 지났는데 아직 안 닫힌 견적 qt_id 목록. + 조건: end_time < now AND status != 견적마감 AND not deleted.""" + try: + query = select(quotations.qt_id).where( + quotations.end_time < now, + quotations.status != QuotationStatus.CLOSED.value, + quotations.deleted == False, # noqa: E712 + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, [] + return ErrorType.SUCCESS, list(rows) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + + async def list_requote_done(self, cdb: AsyncSession) -> Tuple[ErrorType, list]: + """[잡②] 재견적(REQUOTE) 중 협상완료(DONE) 세션이 1건 이상이고 아직 안 닫힌 견적 qt_id 목록. + 재견적은 세션이 독립적이라 하나라도 완료되면 나머지를 기다리지 않고 마감 대상.""" + try: + done_exists = ( + select(sessions.session_id) + .where( + sessions.quotation_id == quotations.qt_id, + sessions.status == SessionStatus.DONE.value, + sessions.deleted == False, # noqa: E712 + ) + .exists() + ) + query = select(quotations.qt_id).where( + quotations.type == QuotationType.REQUOTE.value, + quotations.status != QuotationStatus.CLOSED.value, + quotations.deleted == False, # noqa: E712 + done_exists, + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, [] + return ErrorType.SUCCESS, list(rows) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + + async def list_done_sessions(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: + """[잡②] 견적의 협상완료(DONE) 세션 → (supplier_id, bid_price, supplier_name) 목록. 낙찰자 판정 입력.""" + try: + query = ( + select(sessions.supplier_id, sessions.bid_price, suppliers.name) + .join(suppliers, suppliers.supplier_id == sessions.supplier_id) + .where( + sessions.quotation_id == qt_id, + sessions.status == SessionStatus.DONE.value, + sessions.deleted == False, # noqa: E712 + suppliers.deleted == False, # noqa: E712 + ) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, [] + return ErrorType.SUCCESS, list(rows) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + + async def bulk_update_quotation_status(self, cdb: AsyncSession, qt_ids, status: int) -> ErrorType: + """[잡①] 여러 견적의 status 를 한 번에 전이.""" + try: + if not qt_ids: + return ErrorType.SUCCESS + query = ( + update(quotations) + .where(quotations.qt_id.in_(qt_ids)) + .values(status=status, updated_at=GTime.UTC()) + ) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + + async def bulk_update_sessions_status(self, cdb: AsyncSession, qt_ids, from_statuses: list[int], to_status: int) -> ErrorType: + """[잡①] 여러 견적에 딸린 세션 중 from_statuses 에 속한 것만 to_status 로 일괄 전이(삭제 제외).""" + try: + if not qt_ids: + return ErrorType.SUCCESS + query = ( + update(sessions) + .where( + sessions.quotation_id.in_(qt_ids), + sessions.status.in_(from_statuses), + sessions.deleted == False, # noqa: E712 + ) + .values(status=to_status, updated_at=GTime.UTC()) + ) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + # ----- 견적 상세: 세션 / 채팅 / 사용카드 (읽기 전용) ----- async def list_sessions(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: try: diff --git a/negodata/backend/requirements.txt b/negodata/backend/requirements.txt index bfe5743..1d50362 100644 --- a/negodata/backend/requirements.txt +++ b/negodata/backend/requirements.txt @@ -10,3 +10,4 @@ pydantic>=2.0 python-multipart openpyxl httpx +apscheduler>=3.10 diff --git a/negodata/backend/scheduler/__init__.py b/negodata/backend/scheduler/__init__.py new file mode 100644 index 0000000..9955173 --- /dev/null +++ b/negodata/backend/scheduler/__init__.py @@ -0,0 +1,74 @@ +"""백그라운드 스케줄러(크론) 패키지 — '언제'(when) 담당. + +router/(HTTP 진입점)와 동급의 '시간 진입점' 계층. APScheduler 수명주기와 잡 등록(타이밍)만 책임지고, +실제로 하는 일(what)은 scheduler/jobs.py 에 있다. + +- 다중 워커(운영)에서 잡이 워커마다 중복 실행되면 안 되므로 SCHEDULER_ENABLED=1 인 프로세스에서만 등록한다. + (개발은 RELOAD=1 단일 워커라 docker-compose 에서 SCHEDULER_ENABLED=1 로 켠다.) +- apscheduler import 는 start_scheduler() 안에서 한다 → 미설치(이미지 미재빌드) 상태라도 API 는 부팅된다. + +잡 ① close_expired_quotations : 매일 UTC 00:10(KST 09:10) — 마감시각 지난 견적 마감 +잡 ② complete_requote_quotations: 1시간마다 — 재견적 중 DONE 세션 있으면 즉시 마감 +""" +import os + +from common.logger import LOG +from scheduler import jobs + +__all__ = ["start_scheduler", "shutdown_scheduler"] + +_scheduler = None # AsyncIOScheduler | None + + +def _is_enabled() -> bool: + return os.environ.get("SCHEDULER_ENABLED", "0") == "1" + + +def start_scheduler(): + """lifespan startup 에서 호출. SCHEDULER_ENABLED=1 일 때만 스케줄러를 띄운다.""" + global _scheduler + if not _is_enabled(): + LOG.i("[scheduler] disabled (SCHEDULER_ENABLED != 1)") + return + if _scheduler is not None: + return + + try: + from apscheduler.schedulers.asyncio import AsyncIOScheduler + from apscheduler.triggers.cron import CronTrigger + from apscheduler.triggers.interval import IntervalTrigger + except ImportError: + # 의존성 미설치(이미지 미재빌드) → API 는 살리고 스케줄러만 끈다. + LOG.e_no_callstack("[scheduler] apscheduler 미설치 → 스케줄러 비활성. requirements 재설치(이미지 재빌드) 필요") + return + + _scheduler = AsyncIOScheduler(timezone="UTC") + # 잡 ① 마감시간 처리: 매일 UTC 00:10 + _scheduler.add_job( + jobs.close_expired_quotations, + CronTrigger(hour=0, minute=10), + id="close_expired_quotations", + coalesce=True, # 밀린 실행이 여러 번 쌓여도 1번만 + misfire_grace_time=3600, # 정시보다 늦게 깨어나도 1시간 내면 실행 + max_instances=1, + ) + # 잡 ② 재견적 협상완료 처리: 1시간마다 + _scheduler.add_job( + jobs.complete_requote_quotations, + IntervalTrigger(hours=1), + id="complete_requote_quotations", + coalesce=True, + misfire_grace_time=600, + max_instances=1, + ) + _scheduler.start() + LOG.i("[scheduler] started (close_expired=daily 00:10 UTC, complete_requote=hourly)") + + +def shutdown_scheduler(): + """lifespan shutdown 에서 호출.""" + global _scheduler + if _scheduler is not None: + _scheduler.shutdown(wait=False) + _scheduler = None + LOG.i("[scheduler] stopped") diff --git a/negodata/backend/scheduler/jobs.py b/negodata/backend/scheduler/jobs.py new file mode 100644 index 0000000..c320a11 --- /dev/null +++ b/negodata/backend/scheduler/jobs.py @@ -0,0 +1,124 @@ +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import quotations, sessions +from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus +from common.logger import LOG +from common.utils.gtime import GTime +from crud.quotation_crud import QuotationCRUD + + +async def close_expired_quotations() -> int: + """[잡①] 마감일이 지난 견적을 자동으로 견적마감 처리한다. 하루 한 번 실행. + 대상: 마감 시각이 이미 지났는데 아직 마감되지 않은(삭제되지도 않은) 견적. + 처리: 그 견적들을 견적마감 상태로 바꾸고, 아직 시작 전인 세션은 미참여로 정리한다. + 반환: 마감 처리한 견적 수.""" + crud = QuotationCRUD() + now = GTime.UTC() + err_type, qt_ids = await DB_SESSION_MNG.execute_lambda( + quotations.DBType(), + DBWRType.DB_READ.value, + lambda s: crud.list_due_for_close(s, now), + ) + if err_type != ErrorType.SUCCESS: + LOG.e_no_callstack(f"[scheduler] close_expired 대상 조회 실패: {err_type.name}") + return 0 + if not qt_ids: + return 0 + + err_type = await DB_SESSION_MNG.execute_lambda_run( + [quotations.DBType()], + [ + lambda s: crud.bulk_update_quotation_status(s, qt_ids, QuotationStatus.CLOSED.value), + lambda s: crud.bulk_update_sessions_status( + s, qt_ids, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value + ), + ], + ) + if err_type != ErrorType.SUCCESS: + LOG.e_no_callstack(f"[scheduler] close_expired 마감 실패: {err_type.name}") + return 0 + LOG.i(f"[scheduler] close_expired: {len(qt_ids)}건 견적마감") + return len(qt_ids) + + +async def complete_requote_quotations() -> int: + """[잡②] 재견적은 협상완료된 세션이 생기면 나머지를 기다리지 않고 바로 마감한다. 한 시간마다 실행. + 대상: 아직 마감되지 않은 재견적 견적 중, 협상완료된 세션이 있는 것. + 낙찰: 협상완료된 세션 중 입찰가가 가장 낮은 공급사를 낙찰자로 정한다. 같은 최저가가 둘 이상이면(동가) 낙찰자를 비우고 동가 정보만 남긴다. + (현재 재견적은 견적당 세션이 하나라 실제로는 단독 낙찰만 일어나지만, 모델상 1:N이라 일반 규칙을 그대로 둔다.) + 처리: 낙찰 정보를 기록하고 견적을 견적마감 상태로 바꾸며, 아직 시작 전인 세션은 미참여로 정리한다. + 반환: 마감 처리한 견적 수.""" + crud = QuotationCRUD() + err_type, qt_ids = await DB_SESSION_MNG.execute_lambda( + quotations.DBType(), + DBWRType.DB_READ.value, + lambda s: crud.list_requote_done(s), + ) + if err_type != ErrorType.SUCCESS: + LOG.e_no_callstack(f"[scheduler] complete_requote 대상 조회 실패: {err_type.name}") + return 0 + if not qt_ids: + return 0 + + closed = 0 + for qt_id in qt_ids: + e2, done_rows = await DB_SESSION_MNG.execute_lambda( + sessions.DBType(), + DBWRType.DB_READ.value, + lambda s, q=qt_id: crud.list_done_sessions(s, q), + ) + if e2 != ErrorType.SUCCESS: + LOG.e_no_callstack(f"[scheduler] complete_requote DONE세션 조회 실패 qt_id={qt_id}: {e2.name}") + continue + + # 현재 재견적은 세션이 하나라 사실상 단독 낙찰만 타지만, 모델상 1:N이라 일반 규칙(_pick_winner)을 그대로 쓴다. + winner, equal = _pick_winner(done_rows) + # 단독 낙찰과 동가는 상호배타(KTC 정본). 플래그를 명시적으로 박는다. + data = { + "status": QuotationStatus.CLOSED.value, + "preferred_sp_yn": winner is not None, + "equal_bid_yn": equal is not None, + } + if winner is not None: + data["preferred_sp_id"] = winner["supplier_id"] + data["preferred_sp_name"] = (winner["name"] or "")[:20] + if equal is not None: + data["equal_bid_data"] = equal + + e3 = await DB_SESSION_MNG.execute_lambda_run( + [quotations.DBType()], + [ + lambda s, d=data, q=qt_id: crud.update_quotation(s, q, d), + lambda s, q=qt_id: crud.update_sessions_status( + s, q, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value + ), + ], + ) + if e3 == ErrorType.SUCCESS: + closed += 1 + else: + LOG.e_no_callstack(f"[scheduler] complete_requote 마감 실패 qt_id={qt_id}: {e3.name}") + + if closed: + LOG.i(f"[scheduler] complete_requote: {closed}건 견적마감") + return closed + + +def _pick_winner(done_rows): + """협상완료된 세션들 중에서 낙찰자를 정한다(KTC 정본 규칙). complete_requote_quotations 전용 헬퍼. + 입찰가가 매겨진 세션들 가운데 가장 낮은 가격을 부른 공급사를 낙찰자로 본다. + - 최저가를 부른 곳이 한 곳뿐이면: 그 공급사를 낙찰자로 정하고, 동가는 없다. + - 최저가가 둘 이상으로 같으면(동가): 낙찰자는 비우고 동가 정보(최저가와 그 공급사들)만 남긴다. + - 입찰가가 매겨진 세션이 하나도 없으면: 낙찰자도 동가 정보도 없다. + 낙찰자와 동가 정보를 한 쌍으로 돌려주며, 둘은 동시에 채워지지 않는다(단독 낙찰 또는 동가, 둘 중 하나).""" + cands = [(sid, int(bp), name) for sid, bp, name in done_rows if bp is not None] + if not cands: + return None, None + min_price = min(c[1] for c in cands) + tied = [c for c in cands if c[1] == min_price] + if len(tied) > 1: # 동가입찰: 최저가가 여럿 → 낙찰 미지정, 동가만 기록 + equal = { + "price": min_price, + "suppliers": [{"supplier_id": str(sid), "name": name} for sid, _, name in tied], + } + return None, equal + return {"supplier_id": tied[0][0], "name": tied[0][2]}, None diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index 12d39a1..618d676 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -274,10 +274,16 @@ class QuotationService: res.result.SetResult(err_type) return res - # 상태를 '견적마감'으로 변경(실제 DB 업데이트) + # 견적 '견적마감'(CLOSED) + 딸린 세션 정리를 한 트랜잭션으로. + # 세션은 아직 시작 전(협상생성)인 것만 미참여로 떨군다. 협상중/완료/거부/미참여는 그대로 둔다. err_type = await DB_SESSION_MNG.execute_lambda_run( [quotations.DBType()], - [lambda s: self.quotation_crud.update_quotation(s, qt_uuid, {"status": QuotationStatus.CLOSED.value})], + [ + lambda s: self.quotation_crud.update_quotation(s, qt_uuid, {"status": QuotationStatus.CLOSED.value}), + lambda s: self.quotation_crud.update_sessions_status( + s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value + ), + ], ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) @@ -387,6 +393,7 @@ class QuotationService: return res # chats.seq → ChatMessageData.index 로 매핑. indicator_value(Decimal) → float. + # 말풍선 텍스트는 chats.meta.script 에 영속화돼 있어 그대로 꺼낸다(프론트 하드코딩 X). res.messages = [ ChatMessageData( chat_id=r.chat_id, @@ -398,6 +405,8 @@ class QuotationService: card_used_yn=r.card_used_yn, indicator_value=float(r.indicator_value) if r.indicator_value is not None else None, card_type=r.card_type, + script=(r.meta or {}).get("script"), + step=(r.meta or {}).get("step"), ) for r in rows ] diff --git a/negodata/backend/web_main.py b/negodata/backend/web_main.py index e312a77..b924af8 100644 --- a/negodata/backend/web_main.py +++ b/negodata/backend/web_main.py @@ -6,6 +6,8 @@ # 또는 uvicorn 직접 실행: # uvicorn router.router:app --reload --host=0.0.0.0 --port=9400 +import os + import uvicorn from common.logger import LOG @@ -21,21 +23,20 @@ if __name__ == "__main__": LOG.i(f"Server Port : {web_server_config.port}") LOG.i(f"API Server start time : {router.router.API_SERVER_START_TIME}") - if web_server_config.is_ssl: - uvicorn.run( - "router.router:app", - host="0.0.0.0", - port=web_server_config.port, - access_log=False, - workers=web_server_config.process_count, - ssl_keyfile="./SSL/key.pem", - ssl_certfile="./SSL/cert.pem", - ) + # RELOAD=1 (개발 컨테이너) → 소스 변경 시 자동 재기동. reload 와 workers(다중) 는 함께 못 쓰므로 분기. + reload = os.environ.get("RELOAD") == "1" + + run_kwargs = dict( + host="0.0.0.0", + port=web_server_config.port, + access_log=False, + ) + if reload: + run_kwargs["reload"] = True else: - uvicorn.run( - "router.router:app", - host="0.0.0.0", - port=web_server_config.port, - access_log=False, - workers=web_server_config.process_count, - ) + run_kwargs["workers"] = web_server_config.process_count + if web_server_config.is_ssl: + run_kwargs["ssl_keyfile"] = "./SSL/key.pem" + run_kwargs["ssl_certfile"] = "./SSL/cert.pem" + + uvicorn.run("router.router:app", **run_kwargs) diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx index fc9014a..0df411c 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx @@ -25,7 +25,7 @@ export function QuotationCardsTab({ quotationCardViews }: { quotationCardViews: {qc.card_id ? (