o2o-negosium-original/negodata/front/src/pages/quotation.tsx
Mina Choi c62b66e35b [feat] negodata: 소유권 게이팅 + 견적 낙찰 + 작성자명 표시 + 전화입력 + 카드 엑셀
- 소유권 게이팅(common/authz): 변경 액션 본인∪OWNER, 협력사 삭제 OWNER 전용
- 견적 수동 낙찰(award) + 작성자명(creatorName) 표시 + 전화번호 입력 컴포넌트 + 카드 엑셀 업로드
- supplier_type 은 이번 커밋 미변경(다음 커밋에서 코드부터 정리 예정)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:18:33 +09:00

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