o2o-negosium-original/negodata/front/src/features/quotations/components/QuotationDetailSheet/PriceRail.tsx
Mina Choi 6eb4dc26f8 [feat] negosium·negodata: 협상 거부 흐름 정리 + 오프라인 계약가 낙찰 + 견적 상세 리모델링
협상 불가 사유를 내면 500 이 나고, 거부 폼은 목록·채팅이 따로 놀았으며,
제출한 내용을 다시 볼 방법이 없었다. 결렬 건은 협력사가 낸 거부가를 계약가로
간주해 낙찰시켜 절감 통계가 음수로 뒤집힐 수 있었고, 견적 상세는 판정 가격이
세 곳에 흩어져 대화 탭에선 아예 보이지 않았다.

agent
- 결렬 종료 로깅 크래시 수정 — _log 를 action_id 기반으로 되돌리고 선택 근거
  (Q·UCB·방문수)는 decision/policy 가 있을 때만 채운다. 종료 행은 카드 선택이
  없고 policy.update 뒤라 값을 넣으면 학습 화면 집계가 오염된다

negosium
- 거부 폼을 목록·채팅 공용 컴포넌트 하나로 통일(사유 3종 + 공급 희망가·의견 선택)
- 거부 사유 열람 — 목록에 '거부 사유 보기'(부가정보 보기와 같은 규격), 채팅
  재진입 시 대화 끝에 거부 내역 카드. 목록·채팅 init 응답에 reject_reason·reject_price 추가
- 자유 입력 거부("협상 포기합니다")가 사유 NULL 로 저장되던 문제 수정 — 폼 마커가
  없으면 원문을 사유로 쓰고, 문장 속 숫자를 희망가로 오인하지 않는다
- koreanNumber 를 전역 lib 으로 이동(공용 폼이 쓴다)

negodata
- 직접 낙찰에 계약가 입력 — 결렬·미응찰 건을 오프라인으로 다시 협상한 결과를
  담당자가 확정해 넣는다. 후보는 초청 협력사 전부(가격 미제출도 포함),
  계약가는 sessions.custom.offline_award 에 근거·작성자·시각과 함께 남긴다
- 통계 계약가 = 담당자 확정가 우선, 없으면 투찰가. 거부가를 계약가로 치던 파생 제거.
  KPI 에 오프라인 반영 건수 추가
- 견적 상세 리모델링 — 가격 레일(앵커링가/투찰현황 · 목표가 · 타결 상한가 · 결과가)을
  시트에 고정해 접힘·탭 전환에도 남기고, 스펙트럼에 타결 판정선과 구간색 추가.
  라벨은 폭을 실측해 두 레인으로 배치(겹침 불가). 상품·마감시각 등 전 행 동일 컬럼 제거,
  협상현황에 부가정보 노출, 1:1 은 협력사·세션상태를 결과 밴드로 올림

테스트: negosium 58 · negodata 110 통과. 프론트 빌드/린트 통과.
2026-08-12 10:25:26 +09:00

150 lines
5.4 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 { Typography } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
import type { PriceRailView } from '../../types';
const won = (n?: number | null) => (n != null ? `${n.toLocaleString()}` : '-');
/**
* 헤더 가격 레일 — 협상을 판정하는 네 가격을 왼쪽에서 오른쪽 흐름 순으로 고정 노출한다.
* 접힘·대화 탭에서도 남으므로 협상을 보며 기준가를 확인할 수 있다.
*
* 1번 칸만 유형에 따라 갈린다:
* 1:1 = 앵커링가(협력사가 하나라 견적의 값)
* 1:N = 투찰 현황(앵커는 (상품×공급사)마다 달라 대표값이 성립하지 않는다 → 협상현황 표의 협력사별 컬럼으로)
* 4번 칸은 상태로 갈린다: 낙찰가 / 최저 투찰가(개찰) / 현재 제시가·최저가(진행 중).
*/
export function PriceRail({ rail, onShowTarget }: { rail: PriceRailView; onShowTarget?: () => void }) {
const savingsText =
rail.savings != null
? `목표가 대비 ${rail.savings >= 0 ? '' : '+'}${won(Math.abs(rail.savings))}` +
(rail.savingsRate != null ? ` (${rail.savings >= 0 ? '' : '+'}${Math.abs(rail.savingsRate * 100).toFixed(1)}%)` : '')
: null;
return (
<div className="grid grid-cols-2 border-b border-border bg-card sm:grid-cols-4">
{rail.oneToOne ? (
<Cell
label="앵커링가"
dot="bg-zinc-400"
value={won(rail.anchorPrice)}
sub={
rail.anchorPrice != null && rail.targetPrice
? `목표가 ${pctGap(rail.targetPrice, rail.anchorPrice)}`
: undefined
}
/>
) : (
<Cell
label="투찰 현황"
dot="bg-zinc-400"
value={`${rail.bidCount} / ${rail.supplierCount}`}
sub="협력사별 앵커링가는 아래 표에서"
/>
)}
<Cell label="목표가" dot="bg-primary" value={won(rail.targetPrice)} onSub={onShowTarget} />
<Cell
label="타결 상한가"
dot="bg-amber-500"
value={won(rail.ceilingPrice)}
valueClassName="text-amber-600 dark:text-amber-400"
sub={`목표가 +${(rail.ceilingRate / 10).toFixed(1)}% · 초과 시 결렬`}
/>
<Cell
label={rail.resultLabel}
badge={rail.resultBadge}
dot={rail.outcome === 'awarded' ? 'bg-emerald-500' : 'bg-amber-500'}
value={rail.resultPrice != null ? won(rail.resultPrice) : '미응찰'}
valueClassName={
rail.resultPrice == null
? 'text-muted-foreground'
: rail.outcome === 'awarded'
? 'text-emerald-600 dark:text-emerald-400'
: 'text-amber-600 dark:text-amber-400'
}
className={rail.outcome === 'awarded' ? 'bg-emerald-50/60 dark:bg-emerald-950/20' : undefined}
sub={savingsText ?? undefined}
/>
</div>
);
}
/** 목표가 대비 하락폭(%) — 앵커가 목표가보다 낮은 정상 케이스만 의미가 있다. */
function pctGap(target: number, anchor: number): string {
if (!target) return '0.0%';
return `${(((target - anchor) / target) * 100).toFixed(1)}%`;
}
function Cell({
label,
badge,
dot,
value,
sub,
onSub,
className,
valueClassName,
}: {
label: string;
badge?: string | null;
dot: string;
value: ReactNode;
sub?: string;
/** 있으면 보조줄이 버튼(목표가 산정 내역) */
onSub?: () => void;
className?: string;
valueClassName?: string;
}) {
return (
<div
className={cn(
'min-w-0 border-r border-border px-4 py-2.5 last:border-r-0',
// 2열(모바일)에서는 윗줄에만 밑줄을 긋고 2번째 칸의 오른쪽 선을 지운다.
// 컨테이너가 이미 아래 테두리를 가지므로 아랫줄에 또 그으면 선이 겹쳐 두꺼워진다.
'[&:nth-child(-n+2)]:border-b sm:[&:nth-child(-n+2)]:border-b-0',
'[&:nth-child(2)]:border-r-0 sm:[&:nth-child(2)]:border-r',
className,
)}
>
<div className="mb-0.5 flex items-center gap-1.5">
<span className={cn('size-1.5 shrink-0 rounded-full', dot)} />
<Typography as="span" variant="caption" className="text-[10.5px] leading-tight">
{label}
</Typography>
{badge && (
<span className="rounded-full bg-amber-100 px-1.5 py-px text-[9.5px] font-bold text-amber-700 dark:bg-amber-950/50 dark:text-amber-400">
{badge}
</span>
)}
</div>
<Typography
as="p"
variant="small"
className={cn('truncate font-mono text-[15px] font-bold tabular-nums tracking-tight text-foreground', valueClassName)}
>
{value}
</Typography>
{/* 보조줄은 값이 없어도 자리를 지킨다 — 칸마다 높이가 달라지면 레일 바닥이 들쭉날쭉해진다. */}
<div className="min-h-[16px] leading-4">
{onSub ? (
<button
type="button"
onClick={onSub}
title="목표가 산정 내역 보기"
className="cursor-pointer text-[10.5px] text-primary hover:underline"
>
</button>
) : sub ? (
<Typography as="p" variant="caption" className="truncate text-[10.5px] tabular-nums">
{sub}
</Typography>
) : null}
</div>
</div>
);
}