o2o-negosium-original/negodata/front/src/features/quotations/components/QuotationTable.tsx
Mina Choi 03322d6ca3 [feat] negodata: 카드 성공률 순위·상품 엑셀 TO-BE 싱크·회사설정 JSON 입출력·목록 포털형 UI
- 협상카드 성공률(사용 세션 대비 타결) 집계 추가, 견적 생성 카드선택 순위 정렬·통계 TOP5
- 상품 엑셀: 품목코드·모델명 라벨화, 리드타임 suffix 제거, 공급사 컬럼 신설(등록 후 supplier_items 매핑)
- 설정 화면 JSON 불러오기/내보내기(빈 값은 미갱신), IMK 설정 JSON 보관
- 설정·회원 페이지 새로고침 forbidden 수정(자식 loader 가 initAuth 대기)
- 협력사·견적 목록도 포털형 카드(툴바+테이블 결합)로 통일
2026-07-22 17:27:23 +09:00

239 lines
9.3 KiB
TypeScript

import { type ReactNode } from 'react';
import { Clock, Building2, Link2, CornerDownRight } from 'lucide-react';
import { DataTable } from '@/components/ui/data-table';
import { Typography, typographyVariants } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
import { useQuotationChain } from '../hooks/useQuotationChain';
import {
type Estimate,
type Product,
type ChainRoundState,
quotationStatusLabel,
quotationTypeLabel,
chainRoundState,
is1v1,
CHAIN_ROUND_STATE_LABEL,
} from '../types';
import { QuotationStatus } from '@/api/generated/model';
type QuotationTableProps = {
data: Estimate[];
products: Product[];
onOpenDetail: (id: string) => void;
/** 견적번호 클릭 → 그 번호로 목록 필터(같은 체인의 차수만 모아 보기). */
onFilterChain?: (number: string) => void;
footer?: ReactNode;
/** 툴바와 한 카드로 붙일 때 테이블 자체 테두리/라운드를 죽이는 용도 */
className?: string;
};
const statusBadgeClass = (status?: number | null) => {
switch (status) {
case QuotationStatus.CREATED:
return 'bg-yellow-50 text-yellow-700 border-yellow-300';
case QuotationStatus.IN_PROGRESS:
return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/25 dark:text-emerald-400 border-emerald-300/40';
case QuotationStatus.CLOSED:
return 'bg-blue-50 text-blue-700 border-blue-300';
default:
return 'bg-zinc-100 text-zinc-600';
}
};
// 마감결과 배지 색. 낙찰=초록 / 개찰(낙찰자 미정, 수동 처리)=주황 / 진행중=회색(아직 마감 전).
const outcomeBadgeClass = (state: ChainRoundState) => {
switch (state) {
case 'awarded':
return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/25 dark:text-emerald-400 border-emerald-300/50';
case 'opened':
return 'bg-amber-50 text-amber-700 dark:bg-amber-950/25 dark:text-amber-400 border-amber-300/50';
default:
return 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800/40 dark:text-zinc-400 border-zinc-300/40';
}
};
export function QuotationTable({ data, products, onOpenDetail, onFilterChain, footer, className }: QuotationTableProps) {
return (
<DataTable
className={className}
data={data}
rowKey={(est) => est.id ?? ''}
onRowClick={(est) => onOpenDetail(est.id ?? '')}
empty="진행 중인 전자 견적 및 자동 협상 계약 내역이 존재하지 않습니다."
footer={footer}
columns={[
{
header: '견적건명',
cell: (est) => {
const product = products.find((p) => p.id === est.productId);
const productName = product?.name ?? est.productName; // 목록에 없으면 서버 조인 상품명으로 폴백
return (
<div>
<Typography as="span" variant="small" className="block font-bold">
{est.title}
</Typography>
<Typography as="span" variant="small" className="mt-0.5 flex items-center gap-1.5 text-[10px] font-mono text-muted-foreground">
<Building2 size={10} />
: {productName ?? '확인 불가'} ({(product?.price ?? 0).toLocaleString()})
</Typography>
{/* 이 견적에서 재생성된 다음 차수(직속 자식)만 행 밑에 표시 — 전체 체인 반복 X */}
<RegeneratedChild number={est.number} currentQtId={est.id} onOpenRound={onOpenDetail} />
</div>
);
},
},
{
header: '견적번호',
cellClassName: 'font-mono text-muted-foreground',
cell: (est) =>
onFilterChain && est.number ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation(); // 행 클릭(상세 열기) 대신 체인 필터만
onFilterChain(est.number!);
}}
title="이 견적번호의 모든 차수만 보기"
className={cn(typographyVariants({ variant: 'link' }), 'inline-flex items-center gap-1')}
>
<Link2 size={11} className="opacity-60" />
<Typography as="span" variant="small" className="text-xs text-inherit">{est.number}</Typography>
</button>
) : (
<Typography as="span" variant="small" className="text-xs text-inherit">{est.number}</Typography>
),
},
{
header: '유형',
align: 'center',
// 유형은 고정 속성이라 pill 대신 평문 — 협상(1:1)만 살짝 진하게, 경매(1:N)는 연하게.
cell: (est) => (
<Typography
as="span"
variant="small"
className={cn('text-xs', is1v1(est.type) ? 'font-semibold text-foreground' : 'text-muted-foreground')}
>
{quotationTypeLabel(est.type)}
</Typography>
),
},
{
header: '차수',
align: 'center',
cell: (est) => (
<Typography as="span" variant="small" className="text-sm font-mono font-bold text-foreground">
{est.round}
</Typography>
),
},
{
header: '견적상태',
align: 'center',
cell: (est) => (
<Typography
as="span"
variant="small"
className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-[10px] font-semibold rounded-full border ${statusBadgeClass(est.status)}`}
>
{quotationStatusLabel(est.status)}
</Typography>
),
},
{
header: '마감결과',
align: 'center',
// 상세 마감사유는 상세 드로어에서만. 목록은 거친 결과(낙찰/재생성/결렬/진행중)만,
// 낙찰이면 배지에 '낙찰 - 낙찰사명' 한 줄로 붙인다.
cell: (est) => {
const state = chainRoundState(est);
const winner = state === 'awarded' ? est.preferred_sp_name : null;
return (
<Typography
as="span"
variant="small"
className={`inline-flex max-w-[180px] items-center px-2.5 py-0.5 text-[10px] font-semibold rounded-full border ${outcomeBadgeClass(state)}`}
title={winner ?? undefined}
>
<span className="truncate">
{CHAIN_ROUND_STATE_LABEL[state]}
{winner ? ` - ${winner}` : ''}
</span>
</Typography>
);
},
},
{
header: '마감기한',
cellClassName: 'font-mono text-muted-foreground whitespace-nowrap',
cell: (est) => (
<div className="flex items-center gap-1.5">
<Clock size={12} />
<Typography as="span" variant="small" className="text-xs text-inherit">{est.dueDate}</Typography>
</div>
),
},
{
header: '생성일',
cellClassName: 'font-mono text-muted-foreground whitespace-nowrap',
cell: (est) => (
<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',
cellClassName: 'font-mono font-bold text-foreground',
cell: (est) => (
<Typography as="span" variant="small" className="text-xs text-inherit">{est.participationCount}</Typography>
),
},
]}
/>
);
}
function RegeneratedChild({
number,
currentQtId,
onOpenRound,
}: {
number?: string | null;
currentQtId?: string;
onOpenRound: (qtId: string) => void;
}) {
const { rounds } = useQuotationChain(number);
const current = rounds.find((r) => r.qt_id === currentQtId);
// rounds 는 round 오름차순 → 현재보다 큰 첫 라운드가 직속 자식.
const child = current ? rounds.find((r) => r.round > current.round) : undefined;
if (!child) return null;
return (
<div className="mt-1.5 flex items-center gap-1.5">
<CornerDownRight size={11} className="text-muted-foreground/50 shrink-0" />
<Typography as="span" variant="small" className="text-[10px] font-mono text-muted-foreground">
</Typography>
<button
type="button"
title={`${child.round}차 견적 · ${CHAIN_ROUND_STATE_LABEL[child.state]}`}
onClick={(e) => {
e.stopPropagation(); // 행 클릭(현재 견적 상세)과 분리
onOpenRound(child.qt_id);
}}
className="inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5 text-foreground hover:bg-muted-foreground/15 cursor-pointer transition-colors"
>
<Typography as="span" variant="small" className="text-[10px] font-mono font-bold text-inherit">
{child.round} · {CHAIN_ROUND_STATE_LABEL[child.state]}
</Typography>
</button>
</div>
);
}