[feat] negodata: 견적 목록 작성자 컬럼·내 견적 필터 + 검색바 클리어(X) 버튼
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
33ce82997a
commit
7b7a37ee98
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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="견적 생성")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -26,6 +26,10 @@ start_from?: string | null;
|
||||
* 시작일시 이전(ISO)
|
||||
*/
|
||||
start_to?: string | null;
|
||||
/**
|
||||
* 내 견적만 보기(작성자=로그인 유저)
|
||||
*/
|
||||
mine?: boolean;
|
||||
/**
|
||||
* @minimum 1
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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="전체 카드이름, 카드번호, 코드 검색..."
|
||||
/>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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="협력사명, 코드 또는 담당자명으로 추적 검색..."
|
||||
/>
|
||||
|
||||
|
||||
@ -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="상품명 또는 상품 코드로 통합 검색..."
|
||||
/>
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user