- 협상카드 성공률(사용 세션 대비 타결) 집계 추가, 견적 생성 카드선택 순위 정렬·통계 TOP5 - 상품 엑셀: 품목코드·모델명 라벨화, 리드타임 suffix 제거, 공급사 컬럼 신설(등록 후 supplier_items 매핑) - 설정 화면 JSON 불러오기/내보내기(빈 값은 미갱신), IMK 설정 JSON 보관 - 설정·회원 페이지 새로고침 forbidden 수정(자식 loader 가 initAuth 대기) - 협력사·견적 목록도 포털형 카드(툴바+테이블 결합)로 통일
239 lines
9.3 KiB
TypeScript
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>
|
|
);
|
|
}
|