o2o-negosium-original/negodata/front/src/features/quotations/components/QuotationDetailSheet/ResultSummaryBand.tsx
Mina Choi e23349c11e [feat] negodata: 마감 close_reason 개편 + 견적상세 단가조정 스펙트럼
- 마감사유 quotations.close_reason(SMALLINT, 8종) 신설 — 낙찰/재협상3종/유찰4종 구분, 재생성 한도 카운팅. quotation_settings 에 mid_action·over_action·regen_limit(3구간 가격정책) 추가. models.py·enums.py·01-schema.sql·crud·service·테스트 정합.
- 프론트 노출: CloseReason/PriceGateAction 라벨·배지, 목록 마감결과 컬럼, 견적세팅 모달 3구간. orval 생성모델 갱신.
- 견적상세 결과밴드: 목표가→낙찰가→절감 stat 트리오를 단가조정 흐름 스펙트럼(목표가·최저·최고 투찰가)으로 교체. lowestBid/highestBid 는 bid_price 파생(DB 컬럼 아님), 최초가 컬럼 없어 시작앵커=최고 투찰가로 대체. 라벨 방향 값순 번갈아 배치로 겹침 방지.
- 라벨 정정: "단독 낙찰"→"낙찰"(낙찰은 단수), "목표 대비"→"목표가 대비".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 08:37:40 +09:00

108 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { ReactNode } from 'react';
import { BadgeCheck, TrendingDown, TrendingUp } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Typography } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
import type { QuotationData } from '@/api/generated/model/quotationData';
import { StatusPill, type PillTone } from './StatusPill';
import { PricingSpectrum } from './PricingSpectrum';
import {
buildQuotationResult,
CHAIN_ROUND_STATE_LABEL,
type ChainRoundState,
type SessionView,
} from '../../types';
const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-');
// 결과 상태별 배지 톤(협상현황 pill 팔레트 재사용).
const OUTCOME_TONE: Record<ChainRoundState, PillTone> = {
awarded: 'emerald',
regenerated: 'amber',
failed: 'blue',
active: 'zinc',
};
/** 견적 결과 요약 밴드 — 마감 사유/낙찰 협력사 + 절감, 하단에 단가 조정 흐름 스펙트럼(최고·최저 투찰가/목표가). */
export function ResultSummaryBand({
quotation,
sessionViews,
}: {
quotation: QuotationData;
sessionViews: SessionView[];
}) {
const r = buildQuotationResult(quotation, sessionViews);
const good = r.savings != null && r.savings >= 0;
const pct = r.savingsRate != null ? `${Math.abs(r.savingsRate * 100).toFixed(1)}%` : null;
const SavingsIcon = good ? TrendingDown : TrendingUp;
return (
<Card className="p-2.5 gap-2 rounded shadow-xs border-border/80 flex flex-col">
{/* 결과/사유 + 낙찰 협력사 + 절감 */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<StatusPill tone={OUTCOME_TONE[r.outcome]}>{CHAIN_ROUND_STATE_LABEL[r.outcome]}</StatusPill>
{/* 낙찰 건은 협력사명을 주줄로(사유 '낙찰'은 pill 과 중복이라 생략), 그 외엔 마감사유를 표기. */}
{r.winnerName ? (
<div className="min-w-0">
<Typography as="p" variant="caption" className="text-[10px] leading-tight text-muted-foreground">
낙찰 협력사
</Typography>
<Typography
as="p"
variant="small"
className="flex items-center gap-1 text-[12px] font-bold font-sans text-foreground truncate"
>
<BadgeCheck size={13} className="text-emerald-600 shrink-0" />
{r.winnerName}
</Typography>
</div>
) : (
<Typography as="p" variant="small" className="text-[12px] font-bold truncate min-w-0">
{r.closeReason}
</Typography>
)}
</div>
<Stat label={r.provisional ? '예상 절감' : '목표가 대비'}>
{r.savings == null ? (
<Typography as="span" variant="small" className="font-mono text-muted-foreground text-[13px]">
-
</Typography>
) : (
<Typography
as="span"
variant="small"
className={cn(
'inline-flex items-center gap-1 font-mono font-bold text-[13px]',
good ? 'text-emerald-600' : 'text-amber-600',
)}
>
<SavingsIcon size={13} />
{good ? '' : '−'}
{won(Math.abs(r.savings))}
{pct ? ` (${pct})` : ''}
</Typography>
)}
</Stat>
</div>
{/* 단가 조정 흐름 스펙트럼: 최고 투찰가 → 최저 투찰가 · 목표가 */}
<PricingSpectrum targetPrice={r.targetPrice} lowestBid={r.lowestBid} highestBid={r.highestBid} />
</Card>
);
}
/** 밴드 안의 라벨/값 한 칸. */
function Stat({ label, sub, children }: { label: string; sub?: string; children: ReactNode }) {
return (
<div className="flex flex-col justify-center rounded border border-border bg-muted/30 px-3 py-1.5 min-w-[108px]">
<Typography as="span" variant="caption" className="text-[10px] leading-tight">
{label}
{sub ? ` · ${sub}` : ''}
</Typography>
{children}
</div>
);
}