- 마감사유 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>
108 lines
4.1 KiB
TypeScript
108 lines
4.1 KiB
TypeScript
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>
|
||
);
|
||
}
|