[feat] negodata: 견적 목록 작성자 컬럼·내 견적 필터 + 검색바 클리어(X) 버튼

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-06-30 10:07:46 +09:00
parent 33ce82997a
commit 7b7a37ee98
15 changed files with 104 additions and 14 deletions

View File

@ -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

View File

@ -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

View File

@ -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="견적 생성")

View File

@ -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

View File

@ -26,6 +26,10 @@ start_from?: string | null;
* 시작일시 이전(ISO)
*/
start_to?: string | null;
/**
* 내 견적만 보기(작성자=로그인 유저)
*/
mine?: boolean;
/**
* @minimum 1
*/

View File

@ -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;
}

View File

@ -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 (
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<input
type="text"
className={cn(
'w-full pl-9 pr-3 py-2 text-xs bg-background border border-border rounded focus:outline-none focus:border-foreground/40 text-foreground transition-colors font-mono',
'w-full pl-9 pr-9 py-2 text-xs bg-background border border-border rounded focus:outline-none focus:border-foreground/40 text-foreground transition-colors font-mono',
className
)}
{...props}
/>
{hasValue && onClear && (
<button
type="button"
onClick={onClear}
aria-label="검색어 지우기"
className="absolute right-2.5 top-2.5 text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
>
<X className="h-4 w-4" />
</button>
)}
</div>
);
}

View File

@ -143,6 +143,14 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo
<Typography as="span" variant="small" className="text-xs text-inherit">{est.createdDate ?? '-'}</Typography>
),
},
{
header: '작성자',
align: 'center',
cellClassName: 'text-muted-foreground whitespace-nowrap',
cell: (est) => (
<Typography as="span" variant="small" className="text-xs text-inherit">{est.creatorName ?? '-'}</Typography>
),
},
{
header: '협력사수',
align: 'center',

View File

@ -18,6 +18,7 @@ export type Estimate = Partial<QuotationData> & {
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,

View File

@ -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<string, string>;
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 };
}

View File

@ -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="전체 카드이름, 카드번호, 코드 검색..."
/>

View File

@ -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, 이름 또는 이메일로 검색..."
/>
</PageToolbar>

View File

@ -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="협력사명, 코드 또는 담당자명으로 추적 검색..."
/>

View File

@ -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="상품명 또는 상품 코드로 통합 검색..."
/>

View File

@ -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="견적명 또는 견적 번호로 검색..."
/>
<div className="grid grid-cols-2 gap-2">
<div className="grid grid-cols-3 gap-2">
<Select value={mineFilter} onValueChange={(v) => list.setFilter('mine', v as string)}>
<SelectTrigger id="quotation-mine-filter">
<SelectValue>
{(value) => (value === 'MINE' ? '내 견적' : '전체 작성자 견적')}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체 작성자 견적</SelectItem>
<SelectItem value="MINE">내 견적</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={(v) => list.setFilter('status', v as string)}>
<SelectTrigger id="quotation-status-filter" className="font-bold">
<SelectTrigger id="quotation-status-filter">
<SelectValue>
{(value) =>
value === 'ALL'