협상 불가 사유를 내면 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 통과. 프론트 빌드/린트 통과.
150 lines
5.4 KiB
TypeScript
150 lines
5.4 KiB
TypeScript
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>
|
||
);
|
||
}
|