[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.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import (
|
from common.database.model.models import (
|
||||||
quotations, sessions, chats, nego_cards, wild_cards, items, suppliers, quotation_settings,
|
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.enums import ErrorType, QuotationStatus, SessionStatus
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
@ -19,7 +19,7 @@ from common.utils.gtime import GTime
|
|||||||
class IQuotationCRUD(ABC):
|
class IQuotationCRUD(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def search(
|
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]:
|
) -> Tuple[ErrorType, list, int]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -103,6 +103,10 @@ class IQuotationCRUD(ABC):
|
|||||||
async def item_map(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]:
|
async def item_map(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
|
||||||
|
pass
|
||||||
|
|
||||||
# ----- 스케줄러(크론) 전용 -----
|
# ----- 스케줄러(크론) 전용 -----
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def list_due_for_close(self, cdb: AsyncSession, now) -> Tuple[ErrorType, list]:
|
async def list_due_for_close(self, cdb: AsyncSession, now) -> Tuple[ErrorType, list]:
|
||||||
@ -145,6 +149,7 @@ class QuotationCRUD(IQuotationCRUD):
|
|||||||
async def search(
|
async def search(
|
||||||
self,
|
self,
|
||||||
cdb: AsyncSession,
|
cdb: AsyncSession,
|
||||||
|
owner,
|
||||||
search: Optional[str],
|
search: Optional[str],
|
||||||
status: Optional[str],
|
status: Optional[str],
|
||||||
type_: Optional[str],
|
type_: Optional[str],
|
||||||
@ -155,6 +160,8 @@ class QuotationCRUD(IQuotationCRUD):
|
|||||||
) -> Tuple[ErrorType, list, int]:
|
) -> Tuple[ErrorType, list, int]:
|
||||||
try:
|
try:
|
||||||
conditions = [quotations.deleted == False] # noqa: E712
|
conditions = [quotations.deleted == False] # noqa: E712
|
||||||
|
if owner:
|
||||||
|
conditions.append(quotations.user_id == owner) # '내 견적만' — 작성자(user_id)=로그인 유저
|
||||||
if search:
|
if search:
|
||||||
conditions.append(or_(quotations.name.ilike(f"%{search}%"), quotations.number.ilike(f"%{search}%")))
|
conditions.append(or_(quotations.name.ilike(f"%{search}%"), quotations.number.ilike(f"%{search}%")))
|
||||||
if status:
|
if status:
|
||||||
@ -227,6 +234,20 @@ class QuotationCRUD(IQuotationCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, {}
|
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]:
|
async def get_by_id(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, quotations]:
|
||||||
try:
|
try:
|
||||||
query = select(quotations).where(quotations.qt_id == qt_id, quotations.deleted == False).limit(1) # noqa: E712
|
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). 목록 집계로 채움.
|
participation_count: int = 0 # 견적별 참여 협력사 수(세션 distinct supplier). 목록 집계로 채움.
|
||||||
item_id: Optional[uuid.UUID] = None # 대표 상품 id(세션의 첫 item). 목록 조인으로 채움.
|
item_id: Optional[uuid.UUID] = None # 대표 상품 id(세션의 첫 item). 목록 조인으로 채움.
|
||||||
item_name: Optional[str] = None # 대표 상품명. 목록 조인으로 채움.
|
item_name: Optional[str] = None # 대표 상품명. 목록 조인으로 채움.
|
||||||
|
creator_name: Optional[str] = None # 등록자(작성자) 이름. user_id→company.users.name 조인으로 채움.
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|||||||
@ -40,9 +40,11 @@ async def list_quotations(
|
|||||||
type: str | None = Query(None, description="유형 필터(정확히 일치)"),
|
type: str | None = Query(None, description="유형 필터(정확히 일치)"),
|
||||||
start_from: datetime | None = Query(None, description="시작일시 이후(ISO)"),
|
start_from: datetime | None = Query(None, description="시작일시 이후(ISO)"),
|
||||||
start_to: datetime | None = Query(None, description="시작일시 이전(ISO)"),
|
start_to: datetime | None = Query(None, description="시작일시 이전(ISO)"),
|
||||||
|
mine: bool = Query(False, description="내 견적만 보기(작성자=로그인 유저)"),
|
||||||
pg: PageParams = Depends(),
|
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="견적 생성")
|
@router.post(path="/create", response_model=Res_CreateQuotation, summary="견적 생성")
|
||||||
|
|||||||
@ -185,23 +185,26 @@ class QuotationService:
|
|||||||
res.target_anchoring_price = sess.target_anchoring_price
|
res.target_anchoring_price = sess.target_anchoring_price
|
||||||
return res
|
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)
|
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(
|
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
|
||||||
quotations.DBType(),
|
quotations.DBType(),
|
||||||
DBWRType.DB_READ.value,
|
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:
|
if err_type != ErrorType.SUCCESS:
|
||||||
res.result.SetResult(err_type)
|
res.result.SetResult(err_type)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
# 참여 협력사 수(세션 distinct supplier)와 대표 상품(세션 item)을 이 페이지 견적들에 대해
|
# 참여 협력사 수(세션 distinct supplier)·대표 상품(세션 item)·작성자명(user→users.name)을
|
||||||
# 각각 한 방으로 모아 합친다(메인 쿼리 비건드림).
|
# 이 페이지 견적들에 대해 각각 한 방으로 모아 합친다(메인 쿼리 비건드림).
|
||||||
qt_ids = [r.qt_id for r in rows]
|
qt_ids = [r.qt_id for r in rows]
|
||||||
counts = {}
|
counts = {}
|
||||||
item_map = {}
|
item_map = {}
|
||||||
|
name_map = {}
|
||||||
if qt_ids:
|
if qt_ids:
|
||||||
cnt_err, got = await DB_SESSION_MNG.execute_lambda(
|
cnt_err, got = await DB_SESSION_MNG.execute_lambda(
|
||||||
quotations.DBType(),
|
quotations.DBType(),
|
||||||
@ -217,11 +220,20 @@ class QuotationService:
|
|||||||
)
|
)
|
||||||
if im_err == ErrorType.SUCCESS:
|
if im_err == ErrorType.SUCCESS:
|
||||||
item_map = got_im
|
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:
|
for r in rows:
|
||||||
r.participation_count = counts.get(r.qt_id, 0)
|
r.participation_count = counts.get(r.qt_id, 0)
|
||||||
item = item_map.get(r.qt_id)
|
item = item_map.get(r.qt_id)
|
||||||
if item:
|
if item:
|
||||||
r.item_id, r.item_name = 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.quotations = [QuotationData.model_validate(r) for r in rows]
|
||||||
res.total = total
|
res.total = total
|
||||||
|
|||||||
@ -26,6 +26,10 @@ start_from?: string | null;
|
|||||||
* 시작일시 이전(ISO)
|
* 시작일시 이전(ISO)
|
||||||
*/
|
*/
|
||||||
start_to?: string | null;
|
start_to?: string | null;
|
||||||
|
/**
|
||||||
|
* 내 견적만 보기(작성자=로그인 유저)
|
||||||
|
*/
|
||||||
|
mine?: boolean;
|
||||||
/**
|
/**
|
||||||
* @minimum 1
|
* @minimum 1
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -49,6 +49,7 @@ export interface QuotationData {
|
|||||||
participation_count?: number;
|
participation_count?: number;
|
||||||
item_id?: QuotationDataItemId;
|
item_id?: QuotationDataItemId;
|
||||||
item_name?: QuotationDataItemName;
|
item_name?: QuotationDataItemName;
|
||||||
|
creator_name?: string | null;
|
||||||
created_at?: QuotationDataCreatedAt;
|
created_at?: QuotationDataCreatedAt;
|
||||||
updated_at?: QuotationDataUpdatedAt;
|
updated_at?: QuotationDataUpdatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { ComponentProps, ReactNode } from 'react';
|
import type { ComponentProps, ReactNode } from 'react';
|
||||||
import { Search } from 'lucide-react';
|
import { Search, X } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
// 페이지 상단의 "검색/액션 바". card 외피 + 좌측(검색·필터) / 우측(액션 버튼) 레이아웃.
|
// 페이지 상단의 "검색/액션 바". card 외피 + 좌측(검색·필터) / 우측(액션 버튼) 레이아웃.
|
||||||
@ -27,18 +27,30 @@ export function PageToolbar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 돋보기 아이콘 + 검색 input. 매 페이지 복붙하던 동일 마크업을 컴포넌트화.
|
// 돋보기 아이콘 + 검색 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 (
|
return (
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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>
|
</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>
|
<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: '협력사수',
|
header: '협력사수',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export type Estimate = Partial<QuotationData> & {
|
|||||||
productId?: string;
|
productId?: string;
|
||||||
productName?: string;
|
productName?: string;
|
||||||
partnerIds?: string[];
|
partnerIds?: string[];
|
||||||
|
creatorName?: string; // 등록자(작성자) 이름. 서버 목록 조인(user_id→users.name)
|
||||||
participationCount?: number;
|
participationCount?: number;
|
||||||
winnerPartnerId?: string | null;
|
winnerPartnerId?: string | null;
|
||||||
finalPrice?: number;
|
finalPrice?: number;
|
||||||
@ -82,6 +83,7 @@ export function mapQuotation(q: QuotationData): Estimate {
|
|||||||
productName: q.item_name ?? undefined, // products 목록에 없을 때 표기 폴백
|
productName: q.item_name ?? undefined, // products 목록에 없을 때 표기 폴백
|
||||||
dueDate: formatDueDate(q.end_time),
|
dueDate: formatDueDate(q.end_time),
|
||||||
createdDate: fmtDateTime(q.created_at),
|
createdDate: fmtDateTime(q.created_at),
|
||||||
|
creatorName: q.creator_name ?? undefined,
|
||||||
participationCount: q.participation_count ?? 0,
|
participationCount: q.participation_count ?? 0,
|
||||||
winnerPartnerId: q.preferred_sp_id ?? q.preferred_sp_name ?? null,
|
winnerPartnerId: q.preferred_sp_id ?? q.preferred_sp_name ?? null,
|
||||||
isEqualPrice: !!q.equal_bid_yn,
|
isEqualPrice: !!q.equal_bid_yn,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export type ServerListControls = {
|
|||||||
search: string; // input value (controlled)
|
search: string; // input value (controlled)
|
||||||
setSearch: (v: string) => void;
|
setSearch: (v: string) => void;
|
||||||
submitSearch: () => void; // 엔터/즉시 검색용 (디바운스·최소길이 무시하고 바로 발사)
|
submitSearch: () => void; // 엔터/즉시 검색용 (디바운스·최소길이 무시하고 바로 발사)
|
||||||
|
clearSearch: () => void; // X 버튼: 입력 비우고 즉시 전체 재검색
|
||||||
debouncedSearch: string; // 쿼리 파라미터용 (디바운스 적용)
|
debouncedSearch: string; // 쿼리 파라미터용 (디바운스 적용)
|
||||||
filters: Record<string, string>;
|
filters: Record<string, string>;
|
||||||
setFilter: (key: string, value: string) => void;
|
setFilter: (key: string, value: string) => void;
|
||||||
@ -53,6 +54,13 @@ export function useServerList(opts?: {
|
|||||||
setDebouncedSearch(search.trim());
|
setDebouncedSearch(search.trim());
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
// X 버튼: 입력·디바운스 검색어를 즉시 비우고(대기 타이머 취소) 1페이지로 → 전체 목록 재조회.
|
||||||
|
const clearSearch = () => {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
setSearchInput('');
|
||||||
|
setDebouncedSearch('');
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
const setFilter = (key: string, value: string) => {
|
const setFilter = (key: string, value: string) => {
|
||||||
setFilters((f) => ({ ...f, [key]: value }));
|
setFilters((f) => ({ ...f, [key]: value }));
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@ -60,5 +68,5 @@ export function useServerList(opts?: {
|
|||||||
|
|
||||||
const totalPages = (total: number) => Math.max(1, Math.ceil(total / pageSize));
|
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}
|
value={list.search}
|
||||||
onChange={(e) => list.setSearch(e.target.value)}
|
onChange={(e) => list.setSearch(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
|
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
|
||||||
|
onClear={list.clearSearch}
|
||||||
placeholder="전체 카드이름, 카드번호, 코드 검색..."
|
placeholder="전체 카드이름, 카드번호, 코드 검색..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -65,6 +65,7 @@ export default function MembersPage() {
|
|||||||
value={list.search}
|
value={list.search}
|
||||||
onChange={(e) => list.setSearch(e.target.value)}
|
onChange={(e) => list.setSearch(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
|
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
|
||||||
|
onClear={list.clearSearch}
|
||||||
placeholder="로그인 ID, 이름 또는 이메일로 검색..."
|
placeholder="로그인 ID, 이름 또는 이메일로 검색..."
|
||||||
/>
|
/>
|
||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
|
|||||||
@ -88,6 +88,7 @@ export default function PartnersPage() {
|
|||||||
value={list.search}
|
value={list.search}
|
||||||
onChange={(e) => list.setSearch(e.target.value)}
|
onChange={(e) => list.setSearch(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
|
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
|
||||||
|
onClear={list.clearSearch}
|
||||||
placeholder="협력사명, 코드 또는 담당자명으로 추적 검색..."
|
placeholder="협력사명, 코드 또는 담당자명으로 추적 검색..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -113,6 +113,7 @@ export default function ProductsPage() {
|
|||||||
value={list.search}
|
value={list.search}
|
||||||
onChange={(e) => list.setSearch(e.target.value)}
|
onChange={(e) => list.setSearch(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
|
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
|
||||||
|
onClear={list.clearSearch}
|
||||||
placeholder="상품명 또는 상품 코드로 통합 검색..."
|
placeholder="상품명 또는 상품 코드로 통합 검색..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -17,15 +17,17 @@ import type { ListQuotationsParams } from '@/api/generated/model/listQuotationsP
|
|||||||
|
|
||||||
export default function QuotationPage() {
|
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 statusFilter = list.filters.status;
|
||||||
const typeFilter = list.filters.type;
|
const typeFilter = list.filters.type;
|
||||||
|
const mineFilter = list.filters.mine;
|
||||||
const statusOptions = QUOTATION_STATUS_OPTIONS;
|
const statusOptions = QUOTATION_STATUS_OPTIONS;
|
||||||
const typeOptions = QUOTATION_TYPE_OPTIONS;
|
const typeOptions = QUOTATION_TYPE_OPTIONS;
|
||||||
const params: ListQuotationsParams = {
|
const params: ListQuotationsParams = {
|
||||||
search: list.debouncedSearch || undefined,
|
search: list.debouncedSearch || undefined,
|
||||||
status: statusFilter !== 'ALL' ? statusFilter : undefined,
|
status: statusFilter !== 'ALL' ? statusFilter : undefined,
|
||||||
type: typeFilter !== 'ALL' ? typeFilter : undefined,
|
type: typeFilter !== 'ALL' ? typeFilter : undefined,
|
||||||
|
mine: mineFilter === 'MINE' ? true : undefined,
|
||||||
page: list.page,
|
page: list.page,
|
||||||
size: list.pageSize,
|
size: list.pageSize,
|
||||||
};
|
};
|
||||||
@ -94,12 +96,25 @@ export default function QuotationPage() {
|
|||||||
value={list.search}
|
value={list.search}
|
||||||
onChange={(e) => list.setSearch(e.target.value)}
|
onChange={(e) => list.setSearch(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
|
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
|
||||||
|
onClear={list.clearSearch}
|
||||||
placeholder="견적명 또는 견적 번호로 검색..."
|
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)}>
|
<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>
|
<SelectValue>
|
||||||
{(value) =>
|
{(value) =>
|
||||||
value === 'ALL'
|
value === 'ALL'
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user