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 (
+ {hasValue && onClear && ( + + )}
); } diff --git a/negodata/front/src/features/quotations/components/QuotationTable.tsx b/negodata/front/src/features/quotations/components/QuotationTable.tsx index 2f73bb4..5e65321 100644 --- a/negodata/front/src/features/quotations/components/QuotationTable.tsx +++ b/negodata/front/src/features/quotations/components/QuotationTable.tsx @@ -143,6 +143,14 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo {est.createdDate ?? '-'} ), }, + { + header: '작성자', + align: 'center', + cellClassName: 'text-muted-foreground whitespace-nowrap', + cell: (est) => ( + {est.creatorName ?? '-'} + ), + }, { header: '협력사수', align: 'center', diff --git a/negodata/front/src/features/quotations/types.ts b/negodata/front/src/features/quotations/types.ts index cff64f1..49c00d7 100644 --- a/negodata/front/src/features/quotations/types.ts +++ b/negodata/front/src/features/quotations/types.ts @@ -18,6 +18,7 @@ export type Estimate = Partial & { productId?: string; productName?: string; partnerIds?: string[]; + creatorName?: string; // 등록자(작성자) 이름. 서버 목록 조인(user_id→users.name) participationCount?: number; winnerPartnerId?: string | null; finalPrice?: number; @@ -82,6 +83,7 @@ export function mapQuotation(q: QuotationData): Estimate { productName: q.item_name ?? undefined, // products 목록에 없을 때 표기 폴백 dueDate: formatDueDate(q.end_time), createdDate: fmtDateTime(q.created_at), + creatorName: q.creator_name ?? undefined, participationCount: q.participation_count ?? 0, winnerPartnerId: q.preferred_sp_id ?? q.preferred_sp_name ?? null, isEqualPrice: !!q.equal_bid_yn, diff --git a/negodata/front/src/lib/useServerList.ts b/negodata/front/src/lib/useServerList.ts index f6f3b60..0b04518 100644 --- a/negodata/front/src/lib/useServerList.ts +++ b/negodata/front/src/lib/useServerList.ts @@ -10,6 +10,7 @@ export type ServerListControls = { search: string; // input value (controlled) setSearch: (v: string) => void; submitSearch: () => void; // 엔터/즉시 검색용 (디바운스·최소길이 무시하고 바로 발사) + clearSearch: () => void; // X 버튼: 입력 비우고 즉시 전체 재검색 debouncedSearch: string; // 쿼리 파라미터용 (디바운스 적용) filters: Record; setFilter: (key: string, value: string) => void; @@ -53,6 +54,13 @@ export function useServerList(opts?: { setDebouncedSearch(search.trim()); setPage(1); }; + // X 버튼: 입력·디바운스 검색어를 즉시 비우고(대기 타이머 취소) 1페이지로 → 전체 목록 재조회. + const clearSearch = () => { + clearTimeout(timerRef.current); + setSearchInput(''); + setDebouncedSearch(''); + setPage(1); + }; const setFilter = (key: string, value: string) => { setFilters((f) => ({ ...f, [key]: value })); setPage(1); @@ -60,5 +68,5 @@ export function useServerList(opts?: { const totalPages = (total: number) => Math.max(1, Math.ceil(total / pageSize)); - return { page, setPage, pageSize, search, setSearch, submitSearch, debouncedSearch, filters, setFilter, totalPages }; + return { page, setPage, pageSize, search, setSearch, submitSearch, clearSearch, debouncedSearch, filters, setFilter, totalPages }; } diff --git a/negodata/front/src/pages/cards.tsx b/negodata/front/src/pages/cards.tsx index 05afe62..cae4944 100644 --- a/negodata/front/src/pages/cards.tsx +++ b/negodata/front/src/pages/cards.tsx @@ -104,6 +104,7 @@ export default function CardsPage() { value={list.search} onChange={(e) => list.setSearch(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()} + onClear={list.clearSearch} placeholder="전체 카드이름, 카드번호, 코드 검색..." /> diff --git a/negodata/front/src/pages/members.tsx b/negodata/front/src/pages/members.tsx index bac9412..b2d6954 100644 --- a/negodata/front/src/pages/members.tsx +++ b/negodata/front/src/pages/members.tsx @@ -65,6 +65,7 @@ export default function MembersPage() { value={list.search} onChange={(e) => list.setSearch(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()} + onClear={list.clearSearch} placeholder="로그인 ID, 이름 또는 이메일로 검색..." /> diff --git a/negodata/front/src/pages/partners.tsx b/negodata/front/src/pages/partners.tsx index 013ddf1..75590b5 100644 --- a/negodata/front/src/pages/partners.tsx +++ b/negodata/front/src/pages/partners.tsx @@ -88,6 +88,7 @@ export default function PartnersPage() { value={list.search} onChange={(e) => list.setSearch(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()} + onClear={list.clearSearch} placeholder="협력사명, 코드 또는 담당자명으로 추적 검색..." /> diff --git a/negodata/front/src/pages/products.tsx b/negodata/front/src/pages/products.tsx index c0c3c90..705ef0f 100644 --- a/negodata/front/src/pages/products.tsx +++ b/negodata/front/src/pages/products.tsx @@ -113,6 +113,7 @@ export default function ProductsPage() { value={list.search} onChange={(e) => list.setSearch(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()} + onClear={list.clearSearch} placeholder="상품명 또는 상품 코드로 통합 검색..." /> diff --git a/negodata/front/src/pages/quotation.tsx b/negodata/front/src/pages/quotation.tsx index 0f16516..fa9e641 100644 --- a/negodata/front/src/pages/quotation.tsx +++ b/negodata/front/src/pages/quotation.tsx @@ -17,15 +17,17 @@ import type { ListQuotationsParams } from '@/api/generated/model/listQuotationsP export default function QuotationPage() { // 검색/상태·유형 필터/페이지 상태 → 서버 쿼리 파라미터로 변환. - const list = useServerList({ pageSize: 10, initialFilters: { status: 'ALL', type: 'ALL' } }); + const list = useServerList({ pageSize: 10, initialFilters: { status: 'ALL', type: 'ALL', mine: 'ALL' } }); const statusFilter = list.filters.status; const typeFilter = list.filters.type; + const mineFilter = list.filters.mine; const statusOptions = QUOTATION_STATUS_OPTIONS; const typeOptions = QUOTATION_TYPE_OPTIONS; const params: ListQuotationsParams = { search: list.debouncedSearch || undefined, status: statusFilter !== 'ALL' ? statusFilter : undefined, type: typeFilter !== 'ALL' ? typeFilter : undefined, + mine: mineFilter === 'MINE' ? true : undefined, page: list.page, size: list.pageSize, }; @@ -94,12 +96,25 @@ export default function QuotationPage() { value={list.search} onChange={(e) => list.setSearch(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()} + onClear={list.clearSearch} placeholder="견적명 또는 견적 번호로 검색..." /> -
+
+ +