diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index 4131bff..2011f32 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -8,7 +8,7 @@ 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, suppliers, quotation_settings, - version_nego_cards, version_wild_cards, + version_nego_cards, version_wild_cards, users, ) from common.enums import ErrorType, QuotationStatus, SessionStatus from common.logger import LOG @@ -19,7 +19,7 @@ from common.utils.gtime import GTime class IQuotationCRUD(ABC): @abstractmethod async def search( - self, cdb: AsyncSession, search, status, type_, start_from, start_to, skip, limit + self, cdb: AsyncSession, owner, search, status, type_, start_from, start_to, skip, limit ) -> Tuple[ErrorType, list, int]: pass @@ -103,6 +103,10 @@ class IQuotationCRUD(ABC): async def item_map(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]: pass + @abstractmethod + async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]: + pass + # ----- 스케줄러(크론) 전용 ----- @abstractmethod async def list_due_for_close(self, cdb: AsyncSession, now) -> Tuple[ErrorType, list]: @@ -145,6 +149,7 @@ class QuotationCRUD(IQuotationCRUD): async def search( self, cdb: AsyncSession, + owner, search: Optional[str], status: Optional[str], type_: Optional[str], @@ -155,6 +160,8 @@ class QuotationCRUD(IQuotationCRUD): ) -> Tuple[ErrorType, list, int]: try: conditions = [quotations.deleted == False] # noqa: E712 + if owner: + conditions.append(quotations.user_id == owner) # '내 견적만' — 작성자(user_id)=로그인 유저 if search: conditions.append(or_(quotations.name.ilike(f"%{search}%"), quotations.number.ilike(f"%{search}%"))) if status: @@ -227,6 +234,20 @@ class QuotationCRUD(IQuotationCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, {} + async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]: + """user_id 목록 → {user_id: name}. 견적 목록 '작성자(등록자)' 표기용(company.users 조인).""" + try: + if not user_ids: + return ErrorType.SUCCESS, {} + query = select(users.user_id, users.name).where(users.user_id.in_(user_ids)) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, {} + return ErrorType.SUCCESS, {uid: name for uid, name in rows} + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, {} + async def get_by_id(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, quotations]: try: query = select(quotations).where(quotations.qt_id == qt_id, quotations.deleted == False).limit(1) # noqa: E712 diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py index a2cf80f..31901e6 100644 --- a/negodata/backend/router/v1/quotation/protocol.py +++ b/negodata/backend/router/v1/quotation/protocol.py @@ -65,6 +65,7 @@ class QuotationData(WebPacketProtocol): participation_count: int = 0 # 견적별 참여 협력사 수(세션 distinct supplier). 목록 집계로 채움. item_id: Optional[uuid.UUID] = None # 대표 상품 id(세션의 첫 item). 목록 조인으로 채움. item_name: Optional[str] = None # 대표 상품명. 목록 조인으로 채움. + creator_name: Optional[str] = None # 등록자(작성자) 이름. user_id→company.users.name 조인으로 채움. created_at: Optional[datetime] = None updated_at: Optional[datetime] = None diff --git a/negodata/backend/router/v1/quotation/quotation.py b/negodata/backend/router/v1/quotation/quotation.py index d01ba25..6f4ce7b 100644 --- a/negodata/backend/router/v1/quotation/quotation.py +++ b/negodata/backend/router/v1/quotation/quotation.py @@ -40,9 +40,11 @@ async def list_quotations( type: str | None = Query(None, description="유형 필터(정확히 일치)"), start_from: datetime | None = Query(None, description="시작일시 이후(ISO)"), start_to: datetime | None = Query(None, description="시작일시 이전(ISO)"), + mine: bool = Query(False, description="내 견적만 보기(작성자=로그인 유저)"), pg: PageParams = Depends(), ): - return RemoveNoneResponse(await service.list_quotations(search, status, type, start_from, start_to, pg)) + owner = user_info.user_id if mine else None + return RemoveNoneResponse(await service.list_quotations(owner, search, status, type, start_from, start_to, pg)) @router.post(path="/create", response_model=Res_CreateQuotation, summary="견적 생성") diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index f31b820..ef2ae51 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -185,23 +185,26 @@ class QuotationService: res.target_anchoring_price = sess.target_anchoring_price return res - async def list_quotations(self, search, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList: + async def list_quotations(self, owner, search, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList: + """견적 목록. owner(user_id) 가 주어지면 '내 견적만'(작성자=로그인 유저)으로 필터한다.""" res = Res_QuotationList(page=pg.page, size=pg.size) + owner_uuid = uuid.UUID(owner) if owner else None err_type, rows, total = await DB_SESSION_MNG.execute_lambda( quotations.DBType(), DBWRType.DB_READ.value, - lambda s: self.quotation_crud.search(s, search, status, type_, start_from, start_to, pg.skip, pg.size), + lambda s: self.quotation_crud.search(s, owner_uuid, search, status, type_, start_from, start_to, pg.skip, pg.size), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res - # 참여 협력사 수(세션 distinct supplier)와 대표 상품(세션 item)을 이 페이지 견적들에 대해 - # 각각 한 방으로 모아 합친다(메인 쿼리 비건드림). + # 참여 협력사 수(세션 distinct supplier)·대표 상품(세션 item)·작성자명(user→users.name)을 + # 이 페이지 견적들에 대해 각각 한 방으로 모아 합친다(메인 쿼리 비건드림). qt_ids = [r.qt_id for r in rows] counts = {} item_map = {} + name_map = {} if qt_ids: cnt_err, got = await DB_SESSION_MNG.execute_lambda( quotations.DBType(), @@ -217,11 +220,20 @@ class QuotationService: ) if im_err == ErrorType.SUCCESS: item_map = got_im + user_ids = list({r.user_id for r in rows}) + nm_err, got_nm = await DB_SESSION_MNG.execute_lambda( + quotations.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.user_name_map(s, user_ids), + ) + if nm_err == ErrorType.SUCCESS: + name_map = got_nm for r in rows: r.participation_count = counts.get(r.qt_id, 0) item = item_map.get(r.qt_id) if item: r.item_id, r.item_name = item + r.creator_name = name_map.get(r.user_id) res.quotations = [QuotationData.model_validate(r) for r in rows] res.total = total diff --git a/negodata/front/src/api/generated/model/listQuotationsParams.ts b/negodata/front/src/api/generated/model/listQuotationsParams.ts index a526f5f..758a89b 100644 --- a/negodata/front/src/api/generated/model/listQuotationsParams.ts +++ b/negodata/front/src/api/generated/model/listQuotationsParams.ts @@ -26,6 +26,10 @@ start_from?: string | null; * 시작일시 이전(ISO) */ start_to?: string | null; +/** + * 내 견적만 보기(작성자=로그인 유저) + */ +mine?: boolean; /** * @minimum 1 */ diff --git a/negodata/front/src/api/generated/model/quotationData.ts b/negodata/front/src/api/generated/model/quotationData.ts index 2952862..23fa400 100644 --- a/negodata/front/src/api/generated/model/quotationData.ts +++ b/negodata/front/src/api/generated/model/quotationData.ts @@ -49,6 +49,7 @@ export interface QuotationData { participation_count?: number; item_id?: QuotationDataItemId; item_name?: QuotationDataItemName; + creator_name?: string | null; created_at?: QuotationDataCreatedAt; updated_at?: QuotationDataUpdatedAt; } diff --git a/negodata/front/src/components/layout/PageToolbar.tsx b/negodata/front/src/components/layout/PageToolbar.tsx index 6e7946c..a3dc820 100644 --- a/negodata/front/src/components/layout/PageToolbar.tsx +++ b/negodata/front/src/components/layout/PageToolbar.tsx @@ -1,5 +1,5 @@ import type { ComponentProps, ReactNode } from 'react'; -import { Search } from 'lucide-react'; +import { Search, X } from 'lucide-react'; import { cn } from '@/lib/utils'; // 페이지 상단의 "검색/액션 바". card 외피 + 좌측(검색·필터) / 우측(액션 버튼) 레이아웃. @@ -27,18 +27,30 @@ export function PageToolbar({ } // 돋보기 아이콘 + 검색 input. 매 페이지 복붙하던 동일 마크업을 컴포넌트화. -export function SearchInput({ className, ...props }: ComponentProps<'input'>) { +// onClear 가 주어지고 입력값이 있으면 우측에 X 버튼 노출 → 클릭 시 비우고 재검색. +export function SearchInput({ className, onClear, ...props }: ComponentProps<'input'> & { onClear?: () => void }) { + const hasValue = props.value != null && String(props.value).length > 0; return (