- 백엔드: 도메인 enum CodeEnum 상속(x-enum-varnames) → orval이 이름 있는 enum 생성. 응답 필드 enum 타입 지정(요청은 int 유지). ENUM_LABELS/DOMAIN_ENUMS·/v1/enums 제거 - 프론트: 생성 enum + 라벨맵으로 교체(매직넘버·발명 어휘 제거), 견적·세션 상태 코드화, role/delivery 라벨 프론트 소유 - 동반 정리: unwrap 캐스트·목업(buildSessions·데모)·死코드 제거, mapItem toMinPrice dedupe Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
191 lines
8.3 KiB
TypeScript
191 lines
8.3 KiB
TypeScript
import type { ReactNode } from 'react';
|
|
import { Link } from 'react-router';
|
|
import { Package } from 'lucide-react';
|
|
import { Card } from '@/components/ui/card';
|
|
import { InfoField } from './InfoField';
|
|
import { QuotationStatusBadge } from './StatusPill';
|
|
import type { QuotationData } from '@/api/generated/model/quotationData';
|
|
import {
|
|
type Product,
|
|
type Partner,
|
|
type QuotationSetting,
|
|
buildBidSummary,
|
|
quotationTypeLabel,
|
|
} from '../../types';
|
|
|
|
const fmtYn = (b: boolean | null | undefined, yes: string, no: string) =>
|
|
b == null ? '-' : b ? yes : no;
|
|
|
|
/** 헤더 정보 카드 컨테이너. ui/Card 의 넉넉한 기본 여백을 촘촘하게 덮어쓴다. */
|
|
function SectionCard({ title, children }: { title: string; children: ReactNode }) {
|
|
return (
|
|
<Card className="p-3 gap-2 rounded shadow-xs border-border/80">
|
|
<span className="font-bold text-foreground text-[11px] border-b border-border pb-1 block font-sans">
|
|
{title}
|
|
</span>
|
|
{children}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
type DrawerHeaderCardsProps = {
|
|
quotation: QuotationData;
|
|
partners: Partner[];
|
|
quotationSettings: QuotationSetting[];
|
|
/** 현재 선택 세션의 상품(없으면 상품 카드는 빈 상태). */
|
|
currentProduct: Product | undefined;
|
|
};
|
|
|
|
export function DrawerHeaderCards({
|
|
quotation,
|
|
partners,
|
|
quotationSettings,
|
|
currentProduct,
|
|
}: DrawerHeaderCardsProps) {
|
|
// Quotations DDL 표시값
|
|
const q_name = quotation.name || '미지정';
|
|
const q_number = quotation.number || 'EST-000000-0000';
|
|
const q_round = quotation.round || 1;
|
|
const q_end_time = quotation.end_time || '미지정';
|
|
const q_manager_name = quotation.manager_name || '홍길동 파트너';
|
|
const q_manager_email = quotation.manager_email || 'gildong@negodata.com';
|
|
const q_memo = quotation.memo || '안내사항 없음';
|
|
|
|
const bidSummaryObj = buildBidSummary(quotation, partners);
|
|
const selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === quotation.qt_setting_id);
|
|
|
|
// 상품 상세 패널 행(negowiz 협상대화의 상품 정보 대응). negodata 컬럼명: maker_name→manufacturer, min_order_quantity→moq.
|
|
const productSpecRows = currentProduct
|
|
? [
|
|
{ label: '상품코드', value: currentProduct.code || '-' },
|
|
{ label: '단가', value: currentProduct.price != null ? `₩${Number(currentProduct.price).toLocaleString()}` : '-' },
|
|
{ label: '모델명', value: currentProduct.model_name || '-' },
|
|
{ label: '규격', value: currentProduct.spec || '-' },
|
|
{ label: '제조사', value: currentProduct.manufacturer || '-' },
|
|
{ label: '원산지', value: currentProduct.made_in || '-' },
|
|
{ label: 'MOQ', value: currentProduct.moq || '-' },
|
|
{ label: '리드타임', value: currentProduct.lead_time != null ? `${currentProduct.lead_time}일` : '-' },
|
|
{ label: 'VAT', value: fmtYn(currentProduct.vat_yn, '포함', '별도') },
|
|
{ label: '배송비', value: fmtYn(currentProduct.delivery_fee_yn, '포함', '별도') },
|
|
]
|
|
: [];
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4 text-xs font-mono">
|
|
{/* 좌측 컬럼: 견적정보 + 진행상태 */}
|
|
<div className="space-y-4">
|
|
{/* Quotations */}
|
|
<SectionCard title="견적 정보">
|
|
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
|
|
<InfoField label="견적명" value={q_name} valueClassName="font-sans" />
|
|
<InfoField label="견적번호" value={q_number} />
|
|
<InfoField label="유형" value={quotationTypeLabel(quotation.type)} />
|
|
<InfoField label="차수" value={`${q_round}차`} />
|
|
<InfoField label="견적상태" labelClassName="opacity-90 font-bold mb-1">
|
|
<QuotationStatusBadge status={quotation.status} />
|
|
</InfoField>
|
|
<InfoField label="마감시각" value={q_end_time} />
|
|
<InfoField label="담당자" value={`${q_manager_name} (${q_manager_email})`} valueClassName="font-sans" />
|
|
<InfoField
|
|
label="메모"
|
|
value={q_memo}
|
|
valueClassName="font-sans truncate block"
|
|
title={q_memo || ''}
|
|
/>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
{/* Bid Summary */}
|
|
<SectionCard title="견적 진행상태/결과">
|
|
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
|
|
<InfoField label="식별자" value={bidSummaryObj.bid_summary_id} />
|
|
<InfoField label="진행/결과 상태" value={bidSummaryObj.status} />
|
|
<InfoField label="반복횟수" value={`${bidSummaryObj.qt_iteration}회`} />
|
|
<InfoField
|
|
label="우선협상자 존재여부"
|
|
value={bidSummaryObj.has_preferred ? '존재' : '미존재'}
|
|
valueClassName={bidSummaryObj.has_preferred ? 'text-emerald-600 font-bold' : undefined}
|
|
/>
|
|
<InfoField
|
|
label="우선협상자명"
|
|
value={bidSummaryObj.preferred_sp_name}
|
|
className="col-span-2"
|
|
valueClassName="font-sans"
|
|
/>
|
|
<InfoField label="동가입찰정보" className="col-span-2">
|
|
<code className="text-foreground font-semibold bg-muted/60 p-1 rounded text-[10px] block overflow-x-auto whitespace-pre">
|
|
{bidSummaryObj.equal_data}
|
|
</code>
|
|
</InfoField>
|
|
</div>
|
|
</SectionCard>
|
|
</div>
|
|
|
|
{/* 우측 컬럼: 상품정보 + 세팅 */}
|
|
<div className="space-y-4">
|
|
{/* 상품 정보 (협상 대상 상품) */}
|
|
<SectionCard title="상품 정보">
|
|
{currentProduct ? (
|
|
<div className="flex gap-4">
|
|
<div className="h-24 w-24 shrink-0 rounded-md border border-border bg-background overflow-hidden flex items-center justify-center">
|
|
{currentProduct.image_url ? (
|
|
<img
|
|
src={currentProduct.image_url}
|
|
alt={currentProduct.name || '상품'}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
) : (
|
|
<Package size={28} className="text-muted-foreground/50" />
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<Link
|
|
to={`/products?detail=${currentProduct.id}`}
|
|
className="text-foreground font-bold font-sans text-[12px] mb-2 truncate block hover:text-primary hover:underline"
|
|
title={`${currentProduct.name || ''} — 상품 상세로 이동`}
|
|
>
|
|
{currentProduct.name || '-'}
|
|
</Link>
|
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-x-3 gap-y-1.5 font-mono text-muted-foreground">
|
|
{productSpecRows.map((r) => (
|
|
<InfoField
|
|
key={r.label}
|
|
label={r.label}
|
|
value={r.value}
|
|
valueClassName="font-sans truncate block"
|
|
title={r.value}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-muted-foreground py-6 text-center">상품 정보가 비어있습니다.</div>
|
|
)}
|
|
</SectionCard>
|
|
|
|
{/* Quotation Settings */}
|
|
<SectionCard title="견적 세팅">
|
|
{selectedSettingObj ? (
|
|
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
|
|
<InfoField
|
|
label="목표 마진율"
|
|
value={selectedSettingObj.target_margin}
|
|
valueClassName="font-bold text-emerald-600 dark:text-emerald-400 font-sans"
|
|
/>
|
|
<InfoField label="앵커링 설정 값" value={selectedSettingObj.anchoring_value} valueClassName="font-sans" />
|
|
<InfoField
|
|
label="카드 사용 횟수"
|
|
value={selectedSettingObj.card_use_count}
|
|
className="col-span-2 col-start-1"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="text-muted-foreground py-6 text-center">적용된 견적 세팅이 비어있습니다.</div>
|
|
)}
|
|
</SectionCard>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|