o2o-negosium-original/negodata/front/src/pages/cards.tsx
Mina Choi 1b9ca062f3 [feat] negodata: 협상카드 회사 스코프·공용 읽기전용 + 견적 목표가 산정내역 수정
협상카드
- 목록/조회를 등록자 개인 → 회사(company.users 조인) 단위로 확장: 같은 회사 유저 카드 + 공용(user_id NULL)
- 공용 카드는 기본 제공 자산 → 수정·삭제 불가(OWNER 포함), is_shared=True API 등록 거부
- 개인 카드 변경은 본인∪최고관리자만, 프론트 버튼 disable+툴팁
- 공개범위 셀렉터·엑셀 공개범위 컬럼·CardTable 전체 배지 제거(항상 개인 등록)
- test_card.py 5케이스 재작성

견적 목표가 산정내역
- get_setting_rates: 단일 컬럼 select 결과를 이중 인덱싱해 항상 예외 → 마진율이 늘 0으로 적용되던 버그 수정
- get_target_breakdown: 채택 후보 판정을 '현재 최소값' → '저장 목표가와 값 일치'로 변경(산정 후 상품가 변동에도 초록 체크 유지)
- 견적상세 협상카드 탭 세션ID 컬럼 제거

견적생성 모달
- 목표가 미리보기·마감기한 기본값(+1h)·미래 검증, combobox 팝오버 scrollIntoView, maskPrices 비율(%) 마스킹

Little-Helped: Stupid Claude
2026-07-09 17:04:40 +09:00

175 lines
7.0 KiB
TypeScript

import { Plus, BookOpen, Upload, Download, FileSpreadsheet, ChevronDown } from 'lucide-react';
import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
import { PageContainer } from '@/components/layout/PageContainer';
import { SearchInput } from '@/components/layout/PageToolbar';
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { TablePagination } from '@/components/ui/table-pagination';
import { Typography } from '@/components/ui/typography';
import { useServerList } from '@/lib/useServerList';
import { useCards } from '@/features/cards/hooks/useCards';
import { useGetCard } from '@/api/generated/card/card';
import { CardTable } from '@/features/cards/components/CardTable';
import { CardFormSheet } from '@/features/cards/components/CardFormSheet';
import { CardExcelUploadModal, downloadCardTemplate } from '@/features/cards/components/CardExcelUploadModal';
import { mapCardData, type CardTab, type NegotiationCard } from '@/features/cards/types';
import type { ListCardsParams } from '@/api/generated/model/listCardsParams';
export default function CardsPage() {
// 검색/탭/페이지 상태(재사용 훅) → 서버 쿼리 파라미터로 변환.
const list = useServerList({ pageSize: 10, initialFilters: { tab: 'ALL' } });
const activeTab = list.filters.tab as CardTab;
const params: ListCardsParams = {
search: list.debouncedSearch || undefined,
is_wildcard: activeTab === 'ALL' ? undefined : activeTab === 'WILD',
page: list.page,
size: list.pageSize,
};
const { cards, total, totalNego, totalWild, createCard, updateCard, deleteCard, bulkCreate } = useCards(params);
const totalPages = list.totalPages(total);
// 오버레이(폼/엑셀)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
// ?detail=<id> 직접 접근 시 단건 API 로 받아 수정 폼을 연다(현재 페이지에 없어도 동작).
const overlay = useOverlayRouter(['new', 'detail', 'modal']);
const editId = overlay.get('detail');
const modal = overlay.get('modal'); // 'excel' | null
const editQuery = useGetCard(editId ?? '', { query: { enabled: !!editId } });
const editing: NegotiationCard | null = editQuery.data?.card ? mapCardData(editQuery.data.card) : null;
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
const isFormOpen = overlay.has('new') || !!editing;
const openCreate = () => overlay.open('new');
const openEdit = (card: NegotiationCard) => overlay.open('detail', card.id);
const handleDeleteCard = async (id: string, cardName: string) => {
if (await confirm({ title: '카드 삭제', description: `[${cardName}]을 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
try {
await deleteCard(id);
showToast('삭제되었습니다.', 'info');
} catch (err) {
showToast(err instanceof Error ? err.message : '카드 삭제 실패', 'error');
}
}
};
const tabs: { id: CardTab; label: string; count: number }[] = [
{ id: 'ALL', label: '전체', count: totalNego + totalWild },
{ id: 'CARD', label: '협상카드', count: totalNego },
{ id: 'WILD', label: '와일드카드', count: totalWild },
];
return (
<PageContainer>
{/* Upper Information Banner */}
<div className="p-4 rounded-lg border border-border bg-card flex flex-col sm:flex-row items-center justify-between gap-4 font-mono text-xs">
<div className="flex items-center gap-3">
<BookOpen className="text-muted-foreground" size={20} />
<div>
<Typography variant="h3" className="text-xs">협상카드 및 와일드카드 관리</Typography>
<Typography variant="muted" className="text-[10px] mt-0.5">
우리 회사 카드와 기본 제공(공용) 카드를 함께 조회합니다. 공용 카드는 수정·삭제할 수 없습니다.
</Typography>
</div>
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" />}>
<FileSpreadsheet />
엑셀업로드
<ChevronDown />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => overlay.open('modal', 'excel')}>
<Upload />
일괄 업로드
</DropdownMenuItem>
<DropdownMenuItem onClick={downloadCardTemplate}>
<Download />
양식 다운로드
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
id="card-register-btn"
onClick={openCreate}
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 whitespace-nowrap"
>
<Plus size={15} />
<span>신규 카드 등록</span>
</button>
</div>
</div>
{/* Primary Navigation Tab */}
<div className="flex border-b border-border">
{tabs.map((tab) => (
<button
key={tab.id}
id={`card-tab-${tab.id.toLowerCase()}`}
onClick={() => list.setFilter('tab', tab.id)}
className={`py-2 px-6 font-bold text-xs tracking-tight border-b-2 transition-all cursor-pointer ${
activeTab === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{tab.label} ({tab.count})
</button>
))}
</div>
{/* Filtering Search Bar */}
<SearchInput
id="card-search"
value={list.search}
onChange={(e) => list.setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
onClear={list.clearSearch}
placeholder="전체 카드이름, 카드번호, 코드 검색..."
/>
<CardTable
data={cards}
onEdit={openEdit}
footer={
<TablePagination
page={list.page}
totalPages={totalPages}
totalCount={total}
pageSize={list.pageSize}
onPageChange={list.setPage}
label="전체 카드"
unit="개"
/>
}
/>
{isFormOpen && (
<CardFormSheet
key={`${formMode}-${editing?.id ?? 'new'}`}
open
mode={formMode}
card={editing}
activeTab={activeTab}
onCreate={createCard}
onUpdate={updateCard}
onDelete={handleDeleteCard}
onClose={overlay.close}
/>
)}
{modal === 'excel' && (
<CardExcelUploadModal
open
onConfirm={bulkCreate}
onClose={overlay.close}
/>
)}
</PageContainer>
);
}