- 소유권 게이팅(common/authz): 변경 액션 본인∪OWNER, 협력사 삭제 OWNER 전용 - 견적 수동 낙찰(award) + 작성자명(creatorName) 표시 + 전화번호 입력 컴포넌트 + 카드 엑셀 업로드 - supplier_type 은 이번 커밋 미변경(다음 커밋에서 코드부터 정리 예정) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
219 lines
8.8 KiB
TypeScript
219 lines
8.8 KiB
TypeScript
import { Settings, Plus } from 'lucide-react';
|
|
import { keepPreviousData } from '@tanstack/react-query';
|
|
import { useOverlayRouter } from '@/lib/useOverlayRouter';
|
|
import { PageContainer } from '@/components/layout/PageContainer';
|
|
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
|
|
import { TablePagination } from '@/components/ui/table-pagination';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { useServerList } from '@/lib/useServerList';
|
|
import { useQuotations } from '@/features/quotations/hooks/useQuotations';
|
|
import { useGetQuotation } from '@/api/generated/quotation/quotation';
|
|
import { QuotationTable } from '@/features/quotations/components/QuotationTable';
|
|
import { QuotationDetailSheet } from '@/features/quotations/components/QuotationDetailSheet';
|
|
import { QuotationCreateModal } from '@/features/quotations/components/QuotationCreateModal';
|
|
import { QuotationSettingsModal } from '@/features/quotations/components/QuotationSettingsModal';
|
|
import { QUOTATION_STATUS_OPTIONS, QUOTATION_TYPE_OPTIONS } from '@/features/quotations/types';
|
|
import type { ListQuotationsParams } from '@/api/generated/model/listQuotationsParams';
|
|
|
|
export default function QuotationPage() {
|
|
// 검색/상태·유형 필터/페이지 상태 → 서버 쿼리 파라미터로 변환.
|
|
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,
|
|
};
|
|
|
|
const {
|
|
products,
|
|
partners,
|
|
cards,
|
|
quotations,
|
|
total,
|
|
quotationSettings,
|
|
closeQuotation,
|
|
awardQuotation,
|
|
addSetting,
|
|
deleteSetting,
|
|
createQuotation,
|
|
regenerateQuotation,
|
|
notifyQuotation,
|
|
notifySession,
|
|
} = useQuotations(params);
|
|
const totalPages = list.totalPages(total);
|
|
|
|
const overlay = useOverlayRouter(['detail', 'create', 'settings']);
|
|
const detailId = overlay.get('detail');
|
|
const isCreateOpen = overlay.has('create');
|
|
const isSettingsOpen = overlay.has('settings');
|
|
|
|
// 상세 요약은 리스트에서 find 하지 않고 단건 API 로 받아온다(딥링크 시 리스트 의존 제거).
|
|
// 탭 복귀 시 재조회(자리비운 사이 스케줄러가 마감/낙찰/재생성했을 수 있음). 전역 기본은 false라 상세만 켠다.
|
|
// 라운드 전환(detailId 교체) 시 새 데이터 도착 전까지 이전 견적을 유지한다.
|
|
// 이렇게 해야 activeQuotation 이 잠시 null 로 떨어지지 않아 시트(key={qt_id})가 언마운트→재마운트되지 않고,
|
|
// 그 사이 useScrollLock 이 풀려 배경이 스크롤되는 현상도 사라진다. (목록 등 다른 쿼리와 동일한 패턴)
|
|
const detailQuery = useGetQuotation(detailId ?? '', {
|
|
query: { enabled: !!detailId, refetchOnWindowFocus: true, placeholderData: keepPreviousData },
|
|
});
|
|
// detailId 로 게이트한다 — keepPreviousData 가 닫은 뒤에도 이전 견적을 들고 있어
|
|
// detailId 가 null(닫힘)이어도 시트가 안 사라지던 버그 방지. 라운드 전환(둘 다 truthy)은 영향 없음.
|
|
const activeQuotation = detailId ? (detailQuery.data?.quotation ?? null) : null;
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageToolbar
|
|
actions={
|
|
<>
|
|
<button
|
|
id="quotation-settings-btn"
|
|
onClick={() => overlay.open('settings')}
|
|
className="flex items-center gap-2 px-3 py-2.5 bg-muted text-foreground border border-border text-xs font-semibold rounded hover:bg-muted-foreground/10 cursor-pointer transition-colors"
|
|
>
|
|
<Settings size={14} />
|
|
<span>견적 세팅</span>
|
|
</button>
|
|
|
|
<button
|
|
id="quotation-create-btn"
|
|
onClick={() => overlay.open('create')}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground text-xs font-bold rounded hover:opacity-95 cursor-pointer transition-colors"
|
|
>
|
|
<Plus size={15} />
|
|
<span>신규 견적 등록</span>
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<SearchInput
|
|
id="quotation-search"
|
|
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-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">
|
|
<SelectValue>
|
|
{(value) =>
|
|
value === 'ALL'
|
|
? '전체 견적상태'
|
|
: statusOptions.find((o) => String(o.value) === value)?.label ?? ''
|
|
}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="ALL">전체 견적상태</SelectItem>
|
|
{statusOptions.map((o) => (
|
|
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select value={typeFilter} onValueChange={(v) => list.setFilter('type', v as string)}>
|
|
<SelectTrigger id="quotation-type-filter">
|
|
<SelectValue>
|
|
{(value) =>
|
|
value === 'ALL'
|
|
? '전체 유형'
|
|
: typeOptions.find((o) => String(o.value) === value)?.label ?? ''
|
|
}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="ALL">전체 유형</SelectItem>
|
|
{typeOptions.map((o) => (
|
|
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</PageToolbar>
|
|
|
|
<QuotationTable
|
|
data={quotations}
|
|
products={products}
|
|
onOpenDetail={(id) => overlay.open('detail', id)}
|
|
onFilterChain={(number) => {
|
|
// 견적번호 클릭 → 검색어를 그 번호로 즉시 세팅(같은 체인의 차수만 모아 보기).
|
|
list.setSearch(number);
|
|
list.submitSearch();
|
|
}}
|
|
footer={
|
|
<TablePagination
|
|
page={list.page}
|
|
totalPages={totalPages}
|
|
totalCount={total}
|
|
pageSize={list.pageSize}
|
|
onPageChange={list.setPage}
|
|
label="전체 견적"
|
|
unit="건"
|
|
/>
|
|
}
|
|
/>
|
|
|
|
{detailId && activeQuotation && (
|
|
<QuotationDetailSheet
|
|
key={activeQuotation.qt_id}
|
|
quotation={activeQuotation}
|
|
onCloseQuotation={closeQuotation}
|
|
onAward={awardQuotation}
|
|
onSwitchRound={(qtId) => overlay.open('detail', qtId, { replace: true })}
|
|
onRegenerate={regenerateQuotation}
|
|
onNotify={notifyQuotation}
|
|
onNotifySession={notifySession}
|
|
onClose={overlay.close}
|
|
/>
|
|
)}
|
|
|
|
{isCreateOpen && (
|
|
<QuotationCreateModal
|
|
open
|
|
products={products}
|
|
partners={partners}
|
|
cards={cards}
|
|
quotationSettings={quotationSettings}
|
|
onCreate={async (input) => {
|
|
const qtId = await createQuotation(input);
|
|
if (qtId) overlay.open('detail', qtId); // 생성 완료된 실제 qt_id 로 상세(협상현황) 자동 오픈
|
|
return !!qtId;
|
|
}}
|
|
onClose={overlay.close}
|
|
/>
|
|
)}
|
|
|
|
{isSettingsOpen && (
|
|
<QuotationSettingsModal
|
|
open
|
|
settings={quotationSettings}
|
|
onAdd={addSetting}
|
|
onDelete={deleteSetting}
|
|
onClose={overlay.close}
|
|
/>
|
|
)}
|
|
</PageContainer>
|
|
);
|
|
}
|