[feat] negodata/front: 견적생성 UX 재개편(협상/견적 2축·낙찰기준 스펙트럼·카드 스텝) + 개찰 표기 + 세팅 정리

Co-Authored-By: Claude Opus 4.8뀨 <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-07-06 16:36:33 +09:00
parent 38709b3098
commit f1a29b8bde
27 changed files with 440 additions and 332 deletions

View File

@ -6,8 +6,9 @@
*/
/**
* quotations.close_reason 코드값(SMALLINT). 마감 사유 — 재생성 한도 카운팅(REGEN_*)과 유찰 사유 구분에 쓴다.
기존 preferred_sp_yn/equal_bid_yn 2플래그로는 4상태만 표현돼 '목표초과 재협상'이 미참여와 충돌하고 유찰 사유가 뭉개짐 → 이 컬럼으로 명시.
* quotations.close_reason 코드값(SMALLINT). 마감 사유 — 낙찰(AWARDED) 또는 개찰(OPEN_*)로 가른다.
개찰=결렬(유찰)이 아니라 '낙찰자 미정으로 마감' — 자동 재협상/재생성 없이 담당자가 수동 처리(수동 재생성 등)한다.
(구 자동재협상 사유 REGEN_*(2~4)·유찰 개념은 폐지. 사유 플래그 값 5~8은 보존해 OPEN_* 로 재명명.)
*/
export type CloseReason = typeof CloseReason[keyof typeof CloseReason];
@ -15,11 +16,8 @@ export type CloseReason = typeof CloseReason[keyof typeof CloseReason];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const CloseReason = {
AWARDED: 1,
REGEN_PRICE: 2,
REGEN_EQUAL: 3,
REGEN_NOSHOW: 4,
FAIL_PRICE: 5,
FAIL_EQUAL: 6,
FAIL_NOSHOW: 7,
FAIL_REJECT: 8,
OPEN_PRICE: 5,
OPEN_EQUAL: 6,
OPEN_NOSHOW: 7,
OPEN_REJECT: 8,
} as const;

View File

@ -82,7 +82,6 @@ export * from './notificationDataReadAt';
export * from './notificationDataRefQtId';
export * from './notificationDataRefSessionId';
export * from './notificationType';
export * from './priceGateAction';
export * from './quotationCardData';
export * from './quotationCardDataCondition';
export * from './quotationCardDataEditScript';
@ -107,6 +106,8 @@ export * from './quotationDataManagerEmail';
export * from './quotationDataManagerName';
export * from './quotationDataMdPrice';
export * from './quotationDataMemo';
export * from './quotationDataMidAction';
export * from './quotationDataOverAction';
export * from './quotationDataPreferredSpId';
export * from './quotationDataPreferredSpName';
export * from './quotationDataPreferredSpYn';
@ -151,6 +152,8 @@ export * from './reqCreateQuotationManagerEmail';
export * from './reqCreateQuotationManagerName';
export * from './reqCreateQuotationMdPrice';
export * from './reqCreateQuotationMemo';
export * from './reqCreateQuotationMidAction';
export * from './reqCreateQuotationOverAction';
export * from './reqCreateQuotationSetting';
export * from './reqCreateQuotationStartTime';
export * from './reqCreateQuotationSupplierType';
@ -205,11 +208,7 @@ export * from './reqUpdateMeEmail';
export * from './reqUpdateMeName';
export * from './reqUpdateMePassword';
export * from './reqUpdateQuotationSetting';
export * from './reqUpdateQuotationSettingAnchoringValue';
export * from './reqUpdateQuotationSettingCardCount';
export * from './reqUpdateQuotationSettingMidAction';
export * from './reqUpdateQuotationSettingOverAction';
export * from './reqUpdateQuotationSettingRegenLimit';
export * from './reqUpdateQuotationSettingTargetMarginRate';
export * from './reqUpdateSupplier';
export * from './reqUpdateSupplierCode';

View File

@ -1,20 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* quotation_settings 의 가격 구간별 처리 정책. '앵커링가<투찰가≤목표가'(mid) / '목표가<투찰가'(over) 구간에 적용.
(투찰가≤앵커링가 는 항상 낙찰이라 설정 없음.)
*/
export type PriceGateAction = typeof PriceGateAction[keyof typeof PriceGateAction];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const PriceGateAction = {
AWARD: 1,
RENEGO: 2,
FAIL: 3,
} as const;

View File

@ -18,6 +18,8 @@ import type { QuotationDataPreferredSpName } from './quotationDataPreferredSpNam
import type { QuotationDataEqualBidYn } from './quotationDataEqualBidYn';
import type { QuotationDataEqualBidData } from './quotationDataEqualBidData';
import type { QuotationDataCloseReason } from './quotationDataCloseReason';
import type { QuotationDataMidAction } from './quotationDataMidAction';
import type { QuotationDataOverAction } from './quotationDataOverAction';
import type { QuotationDataItemId } from './quotationDataItemId';
import type { QuotationDataItemName } from './quotationDataItemName';
import type { QuotationDataCreatorName } from './quotationDataCreatorName';
@ -49,6 +51,8 @@ export interface QuotationData {
equal_bid_yn?: QuotationDataEqualBidYn;
equal_bid_data?: QuotationDataEqualBidData;
close_reason?: QuotationDataCloseReason;
mid_action?: QuotationDataMidAction;
over_action?: QuotationDataOverAction;
participation_count?: number;
item_id?: QuotationDataItemId;
item_name?: QuotationDataItemName;

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type ReqUpdateQuotationSettingRegenLimit = number | null;
export type QuotationDataMidAction = number | null;

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type ReqUpdateQuotationSettingAnchoringValue = number | null;
export type QuotationDataOverAction = number | null;

View File

@ -5,7 +5,6 @@
* OpenAPI spec version: 0.1.0
*/
import type { QuotationSettingDataUserId } from './quotationSettingDataUserId';
import type { PriceGateAction } from './priceGateAction';
import type { QuotationSettingDataCreatedAt } from './quotationSettingDataCreatedAt';
import type { QuotationSettingDataUpdatedAt } from './quotationSettingDataUpdatedAt';
@ -13,11 +12,7 @@ export interface QuotationSettingData {
qt_setting_id: string;
user_id?: QuotationSettingDataUserId;
target_margin_rate: number;
anchoring_value: number;
card_count: number;
mid_action?: PriceGateAction;
over_action?: PriceGateAction;
regen_limit?: number;
created_at?: QuotationSettingDataCreatedAt;
updated_at?: QuotationSettingDataUpdatedAt;
}

View File

@ -12,6 +12,8 @@ import type { ReqCreateQuotationManagerContactNumber } from './reqCreateQuotatio
import type { ReqCreateQuotationMemo } from './reqCreateQuotationMemo';
import type { ReqCreateQuotationMdPrice } from './reqCreateQuotationMdPrice';
import type { ReqCreateQuotationSupplierType } from './reqCreateQuotationSupplierType';
import type { ReqCreateQuotationMidAction } from './reqCreateQuotationMidAction';
import type { ReqCreateQuotationOverAction } from './reqCreateQuotationOverAction';
export interface ReqCreateQuotation {
qt_setting_id: string;
@ -31,4 +33,6 @@ export interface ReqCreateQuotation {
item_ids?: string[];
supplier_ids?: string[];
card_ids?: string[];
mid_action?: ReqCreateQuotationMidAction;
over_action?: ReqCreateQuotationOverAction;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ReqCreateQuotationMidAction = number | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ReqCreateQuotationOverAction = number | null;

View File

@ -4,13 +4,8 @@
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { PriceGateAction } from './priceGateAction';
export interface ReqCreateQuotationSetting {
target_margin_rate: number;
anchoring_value?: number;
card_count?: number;
mid_action?: PriceGateAction;
over_action?: PriceGateAction;
regen_limit?: number;
}

View File

@ -5,17 +5,9 @@
* OpenAPI spec version: 0.1.0
*/
import type { ReqUpdateQuotationSettingTargetMarginRate } from './reqUpdateQuotationSettingTargetMarginRate';
import type { ReqUpdateQuotationSettingAnchoringValue } from './reqUpdateQuotationSettingAnchoringValue';
import type { ReqUpdateQuotationSettingCardCount } from './reqUpdateQuotationSettingCardCount';
import type { ReqUpdateQuotationSettingMidAction } from './reqUpdateQuotationSettingMidAction';
import type { ReqUpdateQuotationSettingOverAction } from './reqUpdateQuotationSettingOverAction';
import type { ReqUpdateQuotationSettingRegenLimit } from './reqUpdateQuotationSettingRegenLimit';
export interface ReqUpdateQuotationSetting {
target_margin_rate?: ReqUpdateQuotationSettingTargetMarginRate;
anchoring_value?: ReqUpdateQuotationSettingAnchoringValue;
card_count?: ReqUpdateQuotationSettingCardCount;
mid_action?: ReqUpdateQuotationSettingMidAction;
over_action?: ReqUpdateQuotationSettingOverAction;
regen_limit?: ReqUpdateQuotationSettingRegenLimit;
}

View File

@ -1,9 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { PriceGateAction } from './priceGateAction';
export type ReqUpdateQuotationSettingMidAction = PriceGateAction | null;

View File

@ -1,9 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { PriceGateAction } from './priceGateAction';
export type ReqUpdateQuotationSettingOverAction = PriceGateAction | null;

View File

@ -25,7 +25,6 @@ export interface ResTargetBreakdown {
selling?: ResTargetBreakdownSelling;
fee?: number;
margin?: number;
anchoring_value?: number;
candidates?: TargetCandidate[];
chosen_basis?: ResTargetBreakdownChosenBasis;
target_price?: number;

View File

@ -35,7 +35,7 @@ export function ScopeSection({
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<DeadlineWidget data={s.deadline_soon} onOpen={onOpen} />
<EmailUnsentWidget data={s.email_unsent} onOpen={onOpen} />
<RefWidget title="결렬 (선정자 없이 마감)" icon={Ban} tone="rose" data={s.ruptured} onOpen={onOpen} />
<RefWidget title="개찰 (낙찰자 미정 마감)" icon={Ban} tone="amber" data={s.ruptured} onOpen={onOpen} />
</div>
</div>
);

View File

@ -13,7 +13,7 @@ import { Badge } from '@/components/ui/badge';
import { Typography } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
// 신규 유저용 프로세스 안내. '흐름 설명'(6단계)·'견적 유형'(4종)·'마감 판정'(가격게이트+재생성 한도 결정표) 세 뷰.
// 신규 유저용 프로세스 안내. '흐름 설명'(6단계)·'견적 유형'(4종)·'마감 판정'(가격게이트+개찰 결정표) 세 뷰.
// 읽기 전용 정적 안내(테이블/서버 없음). 첫 방문 자동 노출 + 대시보드 버튼으로 재오픈은 호출부(대시보드)에서 제어.
type Actor = 'user' | 'partner' | 'system';
@ -92,9 +92,9 @@ const STEPS: Step[] = [
actor: '시스템',
actorType: 'system',
icon: Award,
short: '단독최저+정책통과=낙찰 / 목표초과·동가·미참여=재생성 / 결렬',
short: '단독최저+기준통과=낙찰 / 그 외(목표가 초과·동가·미응찰·거부)=개찰',
detail:
'낙찰 후보는 협상완료한 협력사 중 최저가입니다. 다만 최저가라고 바로 낙찰이 아니라, 그 최저가가 단독(동점 아님)이고 회사 가격정책의 가격게이트를 낙찰로 통과해야 낙찰됩니다. 최저가가 목표가 구간을 넘어 정책이 재협상이면 다음 차수로 더 깎고, 동가·전원 미참여도 다음 차수를 재생성합니다(모두 체인 전체 재생성 한도 안에서, 사유 무관 합산·기본 1회). 정책이 결렬이거나 한도를 소진하면, 또는 완료 투찰 없이 거부만 있으면 결렬됩니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.',
'낙찰 후보는 협상완료한 협력사 중 최저가입니다. 다만 최저가라고 바로 낙찰이 아니라, 그 최저가가 단독(동점 아님)이고 견적의 낙찰 기준(가격게이트)을 통과해야 낙찰됩니다. 낙찰 기준을 못 넘기거나(목표가 초과 등), 동가·전원 미응찰·협상거부인 경우는 결렬이 아니라 개찰 — 낙찰자 미정으로 마감됩니다. 개찰은 자동 재협상/재생성 없이 담당자가 상세에서 수동으로 다음 라운드를 생성하거나 처리합니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.',
},
];
@ -104,14 +104,16 @@ const ACTOR_CLASS: Record<Actor, string> = {
system: 'text-muted-foreground',
};
// ----- 견적 마감 판정(close_and_decide 기준): 낙찰 후보=협상완료 최저가 → (1)단독/동가/미완료 (2)가격게이트(mid/over_action) (3)재생성 한도(체인 전체 총·사유무관, regen_limit)로 낙찰/재생성/결렬. 아래 결정표 8행 = close_reason 8종 -----
type Tone = 'win' | 'tie' | 'fail';
// ----- 견적 마감 판정(close_and_decide): 낙찰 후보=협상완료 최저가 → 단독 최저가가 낙찰 기준(가격게이트) 통과면 낙찰, 아니면 개찰.
// 동가·협상거부·전원 미응찰도 결렬이 아니라 개찰(낙찰자 미정 마감). 자동 재협상/재생성 없음 — 다음 라운드는 담당자가 수동 재생성.
// 결정표 = close_reason 5종(낙찰 + 개찰 4: 가격/동가/미응찰/거부). -----
type Tone = 'win' | 'open';
// (1) 가격게이트: 투찰가가 앵커링가/목표가 대비 어느 구간이냐로 회사 정책(mid/over_action) 적용.
// (1) 가격게이트(1:1 협상): 투찰가가 앵커링가/목표가 대비 어느 구간이냐로 견적 낙찰 기준 적용. 미달이면 개찰.
const GATE_ZONES: { range: string; action: string; note: string }[] = [
{ range: '투찰가 ≤ 앵커링가', action: '무조건 낙찰', note: '고정 · 설정 불가' },
{ range: '앵커링가 < 투찰가 ≤ 목표가', action: '견적세팅 mid_action 대로', note: '기본값 = 낙찰' },
{ range: '목표가 < 투찰가', action: '견적세팅 over_action 대로', note: '기본값 = 낙찰' },
{ range: '앵커링가 < 투찰가 ≤ 목표가', action: '낙찰 기준대로 (낙찰 / 개찰)', note: '목표가까지 낙찰이면 낙찰 · 앵커링가까지면 개찰' },
{ range: '목표가 < 투찰가', action: '개찰', note: '항상 개찰(목표가 초과)' },
];
// (2) 완료 양상별 결정. rows 는 위에서부터 순서대로 판정(close_and_decide 분기 순서와 동일).
@ -131,34 +133,30 @@ const DECISION_TREE: DecisionGroup[] = [
group: '단독 최저가',
desc: '협상완료 협력사 중 최저가가 한 곳',
rows: [
{ cond: '가격게이트 = 낙찰', result: '낙찰', tone: 'win' },
{ cond: '가격게이트 = 재협상 · 재생성 한도 남음', result: '재협상 · 더 깎기', tone: 'tie' },
{ cond: '가격게이트 = 결렬, 또는 재협상인데 한도 소진', result: '결렬', tone: 'fail' },
{ cond: '낙찰 기준 통과(앵커 이하 · 또는 목표가 이내·목표가까지 낙찰)', result: '낙찰', tone: 'win' },
{ cond: '낙찰 기준 미달(목표가 초과 등)', result: '개찰 · 낙찰자 미정', tone: 'open' },
],
},
{
group: '동가',
desc: '최저가가 2곳 이상 동일 → 단독 낙찰 불가',
rows: [
{ cond: '가격게이트 ≠ 결렬 · 한도 남음', result: '재입찰 · 동가끼리', tone: 'tie' },
{ cond: '가격게이트 = 결렬, 또는 한도 소진', result: '결렬', tone: 'fail' },
{ cond: '항상', result: '개찰 · 낙찰자 미정', tone: 'open' },
],
},
{
group: '완료한 투찰 없음',
desc: '아무도 협상을 완료(투찰 확정)하지 않음',
rows: [
{ cond: '거부한 협력사가 있음', result: '결렬', tone: 'fail' },
{ cond: '전원 미참여 · 한도 남음', result: '재소집 · 공급사 전체', tone: 'tie' },
{ cond: '전원 미참여 · 한도 소진', result: '결렬', tone: 'fail' },
{ cond: '거부한 협력사가 있음', result: '개찰 · 거부', tone: 'open' },
{ cond: '전원 미응찰', result: '개찰 · 미응찰', tone: 'open' },
],
},
];
const TONE_CHIP: Record<Tone, string> = {
win: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
tie: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
fail: 'bg-destructive/10 text-destructive',
open: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
};
// ----- 견적 유형(4종): 두 축으로 갈림 — 신규/재(목표가 산정 후보) × 협상 1:1 / 견적 1:N(부르는 협력사 수).
@ -434,16 +432,16 @@ export function OnboardingGuideModal({
<Badge variant="outline">자동</Badge>
</div>
<Typography variant="muted">
낙찰 후보는 협상완료한 협력사 중 최저가입니다. 최저가라고 곧 낙찰이 아니라, 그 가격을 견적세팅의 가격정책(가격게이트)에 통과시켜 낙찰 · 재협상 · 결렬이 갈립니다.
낙찰 후보는 협상완료한 협력사 중 최저가입니다. 최저가라고 곧 낙찰이 아니라, 그 가격을 견적의 낙찰 기준(가격게이트)에 통과시켜 낙찰 · 개찰이 갈립니다.
</Typography>
{/* 핵심: 처리 방식은 견적세팅이 정하고, 기본은 낙찰 */}
{/* 핵심: 낙찰 기준은 견적 생성 시 정하고, 미달은 개찰 */}
<div className="rounded-lg border border-primary/40 bg-primary/5 p-3">
<Typography as="p" variant="small" className="font-semibold text-primary">
낙찰 · 재협상 · 결렬은 견적세팅에서 정합니다
낙찰 기준은 견적 생성 시 정합니다 (1:1 협상)
</Typography>
<Typography variant="caption" className="mt-0.5 block leading-relaxed">
각 가격 구간을 어떻게 처리할지는 견적세팅의 가격정책(mid_action · over_action)에서 지정합니다. 기본값은 전 구간 낙찰이라, 따로 바꾸지 않으면 목표가를 초과해도 낙찰됩니다.
1:1 협상은 낙찰선을 앵커링가까지 / 목표가까지 중 택1합니다. 앵커링가 이하는 항상 낙찰, 목표가 초과는 항상 개찰이며, 그 사이 구간만 이 선택으로 갈립니다. 낙찰 기준을 못 넘기면 결렬이 아니라 개찰(낙찰자 미정 마감)이고, 자동 재협상/재생성은 없습니다. 1:N 견적은 무조건 최저가 낙찰.
</Typography>
</div>
@ -509,13 +507,13 @@ export function OnboardingGuideModal({
))}
</div>
{/* 3) 재생성 한도 */}
{/* 3) 개찰 처리 */}
<div className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-3">
<Typography as="p" variant="small" className="font-semibold text-amber-700 dark:text-amber-400">
3) 재생성 한도
3) 개찰(낙찰자 미정 마감)
</Typography>
<Typography variant="caption" className="mt-0.5 block leading-relaxed">
재협상 · 재입찰 · 재소집은 모두 다음 차수를 자동 생성합니다. 한도(regen_limit)는 체인(같은 견적번호) 전체에서 사유 무관 합산한 총 횟수이며 기본 1회이고, 소진하면 결렬됩니다. 모든 결과는 알림함으로 통지됩니다.
낙찰이 아닌 마감(기준 미달·동가·미응찰·거부)은 결렬이 아니라 개찰입니다. 자동 재협상/재생성은 없고, 다음 라운드가 필요하면 담당자가 상세 화면에서 수동으로 만듭니다. 모든 결과는 알림함으로 통지됩니다.
</Typography>
</div>
</div>

View File

@ -74,9 +74,9 @@ export const STEPS: Step[] = [
actor: '시스템',
actorType: 'system',
icon: Award,
short: '단독최저=낙찰 / 동가·미참여=재생성 / 결렬',
short: '단독최저+기준통과=낙찰 / 그 외=개찰(낙찰자 미정)',
detail:
'단독 최저가면 그 협력사로 낙찰됩니다. 최저가가 둘 이상 같은 동가이거나 전원 미참여면 다음 차수가 자동 재생성되고, 거절·한도 등으로 낙찰자가 없으면 결렬 처리됩니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.',
'단독 최저가가 견적의 낙찰 기준을 통과하면 그 협력사로 낙찰됩니다. 기준을 못 넘기거나 동가·전원 미응찰·협상거부인 경우는 결렬이 아니라 개찰 — 낙찰자 미정으로 마감되고, 다음 라운드는 담당자가 상세에서 수동으로 만듭니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.',
},
];

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { X, PlusSquare, ArrowRight, Loader2 } from 'lucide-react';
import { X, PlusSquare, ArrowRight, Loader2, Gavel } from 'lucide-react';
import { useNavigate } from 'react-router';
import { useGetSupplierLastType } from '@/api/generated/quotation/quotation';
import { Button } from '@/components/ui/button';
@ -10,7 +10,16 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
import type { CreateQuotationInput } from '../hooks/useQuotations';
import { QuotationType } from '@/api/generated/model';
import { QUOTATION_TYPE_OPTIONS, supplierTypeOptions, isNewQuotationType } from '../types';
import {
supplierTypeOptions,
is1v1,
toQuotationType,
awardStrategySummary,
PriceGateAction,
DEFAULT_MID_ACTION,
DEFAULT_OVER_ACTION,
type QuotationMode,
} from '../types';
import { supplierTypeLabel } from '@/lib/enumLabels';
import { showToast } from '@/lib/notify';
@ -42,7 +51,9 @@ export function QuotationCreateModal({
}: QuotationCreateModalProps) {
const [step, setStep] = useState(1);
const [title, setTitle] = useState('');
const [type, setType] = useState<number>(QuotationType.REQUOTE);
// 유형은 '진행 방식(협상/경매) × 대상(신규/후속)' 2축으로 받아 제출 직전 4코드로 합성한다.
const [mode, setMode] = useState<QuotationMode>('nego'); // 1:1 협상 / 1:N 경매
const [isNew, setIsNew] = useState(true); // 신규 / 후속(재)
const [productId, setProductId] = useState('');
const [selectedPartnerIds, setSelectedPartnerIds] = useState<string[]>([]);
const [dueDate, setDueDate] = useState(nowKstLocalInput);
@ -51,8 +62,18 @@ export function QuotationCreateModal({
const [memo, setMemo] = useState('');
const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 상품값으로 목표가 산정
const [supplierType, setSupplierType] = useState(''); // 협력사 유형(SupplierType). 전 유형에서 입력 → 견적에 기록
const [midAction, setMidAction] = useState<number>(DEFAULT_MID_ACTION); // 앵커~목표가 구간: 낙찰/개찰 (1:1 전용)
const [overAction, setOverAction] = useState<number>(DEFAULT_OVER_ACTION); // 목표가 초과 구간: 낙찰/개찰 (1:1 전용)
const [submitting, setSubmitting] = useState(false);
const typeOptions = QUOTATION_TYPE_OPTIONS;
const type = toQuotationType(mode, isNew); // 4코드 합성값
const oneToOne = is1v1(type); // 1:1 협상 여부 — 협력사 단일선택·낙찰기준·협상카드 노출을 가른다
const isReType = !isNew;
// 스텝 구성 — 1:1 협상만 협상카드 스텝 추가(작은 화면 과밀 방지). 경매는 3스텝.
const steps = oneToOne
? ['기본 정보', '협력사 초청', '낙찰 기준', '협상카드']
: ['기본 정보', '협력사 초청', '확인·완료'];
const totalSteps = steps.length;
// 협력사 유형은 전 유형에서 입력받되, 재협상(1:1)이면 선택 협력사의 직전 견적 supplier_type 을 조회해 디폴트로 채운다.
const renegoSupplierId = type === QuotationType.RENEGO ? (selectedPartnerIds[0] ?? '') : '';
@ -69,7 +90,6 @@ export function QuotationCreateModal({
const navigate = useNavigate();
// 인터넷최저가·매입가·판매가는 상품 속성 — 모달에선 읽기전용으로만 보여주고, 수정은 상품 상세에서 한다.
const selectedProduct = products.find((p) => p.id === productId);
const isReType = !isNewQuotationType(type);
const internetLowest = selectedProduct?.internet_lowest_price ?? null;
const purchase = selectedProduct?.purchase_price ?? null;
const selling = selectedProduct?.selling_price ?? null;
@ -82,16 +102,25 @@ export function QuotationCreateModal({
if (!open) return null;
const selectMode = (next: QuotationMode) => {
setMode(next);
// 경매→협상 전환 시 다중선택으로 담긴 협력사를 1곳으로 줄인다(1:1 단일선택).
if (next === 'nego') setSelectedPartnerIds((prev) => prev.slice(0, 1));
// 경매는 3스텝뿐 — 협상카드 스텝(4)에 있던 상태면 마지막(3)으로 당긴다.
if (next === 'auction') setStep((s) => Math.min(s, 3));
};
const togglePartner = (id: string) =>
setSelectedPartnerIds((prev) =>
type === QuotationType.RENEGO
? prev.includes(id) ? [] : [id]
: prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id],
oneToOne
? prev.includes(id)
? []
: [id]
: prev.includes(id)
? prev.filter((p) => p !== id)
: [...prev, id],
);
const toggleCard = (id: string) =>
setSelectedCardIds((prev) =>
prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id],
);
setSelectedCardIds((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]));
const handleSubmit = async () => {
if (submitting) return;
@ -102,7 +131,7 @@ export function QuotationCreateModal({
showToast('목표가 산정에 쓸 값이 없습니다 — MD 제시가를 입력하거나, 상품 상세에서 인터넷최저가·매입가를 채워주세요.', 'error');
return; // finally 에서 submitting 해제
}
// 서버가 견적+세션 생성을 끝내고 응답할 때까지 기다린 뒤에 완료(닫기) 처리한다.
// 낙찰 기준은 1:1 협상만 전송(경매는 미전송 → 서버가 mid=over=AWARD 강제). 카드도 1:1 전용.
const ok = await onCreate({
title,
type,
@ -110,10 +139,12 @@ export function QuotationCreateModal({
partnerIds: selectedPartnerIds,
dueDate,
settingId,
cardIds: selectedCardIds,
cardIds: oneToOne ? selectedCardIds : [],
memo,
mdPrice: mdPrice ? Number(mdPrice) : null,
supplierType: supplierType ? Number(supplierType) : null,
midAction: oneToOne ? midAction : undefined,
overAction: oneToOne ? overAction : undefined,
});
if (ok) onClose();
} finally {
@ -138,26 +169,26 @@ export function QuotationCreateModal({
<div className="flex items-center justify-between pb-4 border-b border-border">
<div className="flex items-center gap-2">
<PlusSquare className="text-foreground" size={18} />
<Typography variant="small" className="font-bold">신규 견적 등록 (단계 {step}/3)</Typography>
<Typography variant="small" className="font-bold">신규 견적 등록 (단계 {step}/{totalSteps})</Typography>
</div>
<button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
<X size={18} />
</button>
</div>
{/* Steps indicator */}
{/* Steps indicator — 스텝 수는 유형에 따라 3(경매)/4(협상) */}
<div className="flex items-center justify-between gap-2 py-4 border-b border-border/40 text-muted-foreground">
<button type="button" onClick={() => setStep(1)} className="cursor-pointer hover:opacity-75 transition-opacity">
<Typography as="span" variant="body" className={`font-semibold ${step >= 1 ? 'text-primary' : 'text-muted-foreground'}`}>1. 기본 정보</Typography>
</button>
<ArrowRight size={14} className="shrink-0" />
<button type="button" onClick={() => setStep(2)} className="cursor-pointer hover:opacity-75 transition-opacity">
<Typography as="span" variant="body" className={`font-semibold ${step >= 2 ? 'text-primary' : 'text-muted-foreground'}`}>2. 협력사 초청</Typography>
</button>
<ArrowRight size={14} className="shrink-0" />
<button type="button" onClick={() => setStep(3)} className="cursor-pointer hover:opacity-75 transition-opacity">
<Typography as="span" variant="body" className={`font-semibold ${step >= 3 ? 'text-primary' : 'text-muted-foreground'}`}>3. 설정·완료</Typography>
</button>
{steps.map((label, i) => {
const n = i + 1;
return (
<div key={label} className="flex items-center gap-2 min-w-0">
{i > 0 && <ArrowRight size={14} className="shrink-0" />}
<button type="button" onClick={() => setStep(n)} className="cursor-pointer hover:opacity-75 transition-opacity truncate">
<Typography as="span" variant="body" className={`font-semibold ${step === n ? 'text-primary' : 'text-muted-foreground'}`}>{n}. {label}</Typography>
</button>
</div>
);
})}
</div>
{/* Step content */}
@ -165,41 +196,41 @@ export function QuotationCreateModal({
{step === 1 && (
<div className="space-y-4">
{/* 견적 유형이 맨 위 — 신규/재 여부가 아래 매입가 필수 여부까지 결정한다 */}
{/* 진행 방식 × 대상 2축 — 협상/경매 갈림이 아래 카드·낙찰기준 노출까지 결정한다 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1">
<Typography as="label" variant="label">견적 유형</Typography>
<Select
value={String(type)}
onValueChange={(v) => {
const next = Number(v);
setType(next);
if (next === QuotationType.RENEGO) setSelectedPartnerIds((prev) => prev.slice(0, 1));
}}
>
<SelectTrigger id="wizard-type" className="w-full">
<SelectValue>
{(value) => typeOptions.find((o) => String(o.value) === value)?.label ?? ''}
</SelectValue>
</SelectTrigger>
<SelectContent>
{typeOptions.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Typography as="label" variant="label">마감기한</Typography>
<input
id="wizard-date"
type="datetime-local"
className="w-full p-2 bg-background border border-border rounded text-xs"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
<Typography as="label" variant="label">진행 방식</Typography>
<Segmented
options={[
{ value: 'nego', label: '1:1 협상', sub: '협상카드·낙찰기준 선택' },
{ value: 'auction', label: '1:N 견적', sub: '최저가 자동 낙찰' },
]}
value={mode}
onChange={(v) => selectMode(v as QuotationMode)}
/>
</div>
<div className="space-y-1">
<Typography as="label" variant="label">대상</Typography>
<Segmented
options={[
{ value: 'new', label: '신규', sub: '처음 진행하는 견적' },
{ value: 're', label: '후속·재', sub: '이전 견적에 이어 진행' },
]}
value={isNew ? 'new' : 're'}
onChange={(v) => setIsNew(v === 'new')}
/>
</div>
</div>
<div className="space-y-1">
<Typography as="label" variant="label">마감기한</Typography>
<input
id="wizard-date"
type="datetime-local"
className="w-full p-2 bg-background border border-border rounded text-xs"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
<div className="space-y-1">
@ -216,7 +247,7 @@ export function QuotationCreateModal({
<div className="space-y-1">
<Typography as="label" variant="label">상품</Typography>
<Select value={productId} onValueChange={setProductId}>
<Select value={productId} onValueChange={(v) => setProductId(v ?? '')}>
<SelectTrigger id="wizard-product" className="w-full">
<SelectValue>
{(value) => {
@ -296,7 +327,7 @@ export function QuotationCreateModal({
{step === 2 && (
<div className="space-y-3">
<Typography as="span" variant="label" className="block">협력사 초청 ({type === QuotationType.RENEGO ? '단일선택' : '다중선택'})</Typography>
<Typography as="span" variant="label" className="block">협력사 초청 ({oneToOne ? '단일선택' : '다중선택'})</Typography>
<div className="border border-border rounded overflow-hidden max-h-56 overflow-y-auto divide-y divide-border bg-background">
{partners.map((part) => {
const isChecked = selectedPartnerIds.includes(part.id ?? '');
@ -357,13 +388,13 @@ export function QuotationCreateModal({
<div className="space-y-4">
<div className="space-y-1 font-mono text-xs">
<Typography as="label" variant="label">적용할 견적 세팅 지정</Typography>
<Select value={settingId} onValueChange={setSettingId}>
<Select value={settingId} onValueChange={(v) => setSettingId(v ?? '')}>
<SelectTrigger id="wizard-setting-select" className="w-full">
<SelectValue>
{(value) => {
const qs = quotationSettings.find((s) => s.qt_setting_id === value);
return qs
? `[목표 마진: ${qs.target_margin}] ${qs.anchoring_value} (${qs.card_use_count})`
? `[목표 마진: ${qs.target_margin}] 카드 ${qs.card_use_count}`
: '';
}}
</SelectValue>
@ -371,45 +402,42 @@ export function QuotationCreateModal({
<SelectContent>
{quotationSettings.map((qs) => (
<SelectItem key={qs.qt_setting_id} value={qs.qt_setting_id}>
[목표 마진: {qs.target_margin}] {qs.anchoring_value} ({qs.card_use_count})
[목표 마진: {qs.target_margin}] 카드 {qs.card_use_count}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Typography as="span" variant="label" className="block">협상카드 및 와일드카드 선택</Typography>
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto">
{cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => {
const isChecked = selectedCardIds.includes(card.id);
return (
<div
key={card.id}
onClick={() => toggleCard(card.id)}
className={`p-2.5 rounded border cursor-pointer transition-all flex items-start gap-2 ${
isChecked ? 'bg-primary/5 border-primary font-bold' : 'bg-background border-border hover:bg-muted/10'
}`}
>
<input type="checkbox" checked={isChecked} readOnly className="accent-primary h-3.5 w-3.5 mt-0.5" />
<div>
<div className="flex items-center gap-1.5">
<Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography>
<span
className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${
card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'
}`}
>
{card.isWildcard ? '와일드' : '협상'}
</span>
</div>
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography>
</div>
</div>
);
})}
{oneToOne ? (
<>
{/* 낙찰 기준(1:1 전용) — 스펙트럼 = 선택. 낙찰선을 앵커/목표가 중 택1, 목표가 초과는 항상 개찰. */}
<AwardLinePicker
mid={midAction}
over={overAction}
// 단조성: 초과=낙찰이면 앵커~목표가도 낙찰, 앵커~목표가=개찰이면 초과도 개찰(혼합 조합 방지)
onMid={(v) => {
setMidAction(v);
if (v === PriceGateAction.OPEN) setOverAction(PriceGateAction.OPEN);
}}
onOver={(v) => {
setOverAction(v);
if (v === PriceGateAction.AWARD) setMidAction(PriceGateAction.AWARD);
}}
/>
</>
) : (
/* 경매(1:N) — 낙찰 기준·협상카드 없음. 최저가 자동 낙찰 안내만. */
<div className="rounded border border-border bg-muted/20 p-3 flex items-start gap-2.5">
<Gavel size={18} className="text-primary mt-0.5 shrink-0" />
<div>
<Typography as="span" variant="small" className="font-semibold block">최저가 자동 낙찰</Typography>
<Typography as="span" variant="small" className="text-muted-foreground text-[10px] leading-snug">
1:N 견적은 가장 낮은 투찰가가 자동 낙찰됩니다. 낙찰 기준·협상카드 설정이 없습니다.
</Typography>
</div>
</div>
</div>
)}
<div className="space-y-1">
<Typography as="label" variant="label">메모 (선택)</Typography>
@ -426,6 +454,45 @@ export function QuotationCreateModal({
</div>
)}
{/* Step 4 — 협상카드(1:1 협상 전용, 별도 스텝으로 분리해 과밀 방지) */}
{step === 4 && oneToOne && (
<div className="space-y-2">
<Typography as="span" variant="label" className="block">협상카드 및 와일드카드 선택 (선택)</Typography>
<Typography as="span" variant="small" className="block text-[10px] text-muted-foreground">
1:1 협상에서 AI 협상봇이 발동할 카드입니다.
</Typography>
<div className="grid grid-cols-2 gap-2 max-h-72 overflow-y-auto">
{cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => {
const isChecked = selectedCardIds.includes(card.id);
return (
<div
key={card.id}
onClick={() => toggleCard(card.id)}
className={`p-2.5 rounded border cursor-pointer transition-all flex items-start gap-2 ${
isChecked ? 'bg-primary/5 border-primary font-bold' : 'bg-background border-border hover:bg-muted/10'
}`}
>
<input type="checkbox" checked={isChecked} readOnly className="accent-primary h-3.5 w-3.5 mt-0.5" />
<div>
<div className="flex items-center gap-1.5">
<Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography>
<span
className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${
card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'
}`}
>
{card.isWildcard ? '와일드' : '협상'}
</span>
</div>
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
{/* Footer nav */}
@ -440,7 +507,7 @@ export function QuotationCreateModal({
</Button>
<div className="flex gap-2">
{step < 3 ? (
{step < totalSteps ? (
<Button
type="button"
size="sm"
@ -466,3 +533,117 @@ export function QuotationCreateModal({
</div>
);
}
// ── 헬퍼 컴포넌트 (메인 아래) ──────────────────────────────────────────────
// 세그먼트 컨트롤 — 소수의 명명된 이산 선택(진행 방식·대상)에 라디오보다 명확. 값은 문자열.
function Segmented({
options,
value,
onChange,
}: {
options: { value: string; label: string; sub?: string }[];
value: string;
onChange: (v: string) => void;
}) {
return (
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}>
{options.map((o) => {
const active = o.value === value;
return (
<button
key={o.value}
type="button"
onClick={() => onChange(o.value)}
className={cn(
'rounded border p-2.5 text-left transition-all cursor-pointer',
active ? 'bg-primary/5 border-primary' : 'bg-background border-border hover:bg-muted/20',
)}
>
<Typography as="span" variant="small" className={cn('block font-semibold', active && 'text-primary')}>{o.label}</Typography>
{o.sub && <Typography as="span" variant="small" className="block text-[10px] text-muted-foreground mt-0.5 leading-tight">{o.sub}</Typography>}
</button>
);
})}
</div>
);
}
// 낙찰 기준 컨트롤 — 스펙트럼 선(線) 위 구간을 눌러 낙찰↔개찰 전환. 앵커 이하는 항상 낙찰(고정, 표시만) —
// '앵커~목표가'(mid_action)·'목표가 초과'(over_action) 두 구간만 사용자가 각각 낙찰/개찰로 정한다.
function AwardLinePicker({
mid, over, onMid, onOver,
}: {
mid: number;
over: number;
onMid: (v: number) => void;
onOver: (v: number) => void;
}) {
const A = PriceGateAction.AWARD;
const O = PriceGateAction.OPEN;
const zones = [
{ label: '앵커링가 이하', win: true, locked: true, toggle: undefined },
{ label: '앵커링가~목표가', win: mid === A, locked: false, toggle: () => onMid(mid === A ? O : A) },
{ label: '목표가 초과', win: over === A, locked: false, toggle: () => onOver(over === A ? O : A) },
];
return (
<div className="space-y-1.5 pt-1">
<div className="flex items-center justify-between">
<Typography as="span" variant="label">낙찰 기준</Typography>
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">구간을 눌러 낙찰↔개찰 · 싸다◀▶비싸다</Typography>
</div>
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
협력사 <span className="font-semibold text-foreground">최저 투찰가</span>가 어느 구간에 오느냐로 낙찰/개찰이 정해집니다.
</Typography>
{/* 스펙트럼 선: 구간이 곧 선택 버튼 */}
<div className="flex rounded-md overflow-hidden border border-border text-center">
{zones.map((z, i) => {
const body = (
<>
<Typography as="span" variant="small" className="block text-[9px] leading-tight text-muted-foreground">{z.label}</Typography>
<Typography as="span" variant="small" className={cn('block text-[12px] font-bold leading-tight', z.win ? 'text-emerald-700 dark:text-emerald-400' : 'text-zinc-500')}>
{z.win ? '낙찰' : '개찰'}{z.locked ? ' 🔒' : ''}
</Typography>
</>
);
const cls = cn('flex-1 px-1 py-2', i > 0 && 'border-l border-border', z.win ? 'bg-emerald-50 dark:bg-emerald-950/30' : 'bg-muted');
return z.locked ? (
<div key={z.label} className={cls} title="앵커링가 이하는 항상 낙찰(고정)">{body}</div>
) : (
<button key={z.label} type="button" onClick={z.toggle} className={cn(cls, 'cursor-pointer transition-[filter] hover:brightness-95')}>{body}</button>
);
})}
</div>
{/* 경계 마커 — 구간 경계(1/3·2/3)에 ▲ 중앙 정렬(앵커링가·목표가) */}
<div className="relative h-6">
{[
{ left: '33.3333%', label: '앵커링가' },
{ left: '66.6667%', label: '목표가' },
].map((mk) => (
<span
key={mk.label}
className="absolute top-0 flex -translate-x-1/2 flex-col items-center text-[9px] text-muted-foreground"
style={{ left: mk.left }}
>
<span className="leading-none">▲</span>
<span className="leading-tight whitespace-nowrap">{mk.label}</span>
</span>
))}
</div>
{/* 전략 한 줄 요약(관대/기본/엄격 전략) — 기본 전략(목표가까지 낙찰·초과 개찰)일 때만 경계가 애매하니 '목표가 포함' 부기 */}
{(() => {
const t = awardStrategySummary(mid, over);
const isBasic = mid === PriceGateAction.AWARD && over === PriceGateAction.OPEN;
return (
<Typography as="p" variant="small" className="text-[11px]">
<span className="font-bold text-primary">{t.strategy}</span>
<span className="text-muted-foreground"> · {t.desc}</span>
{isBasic && (
<span className="text-emerald-700 dark:text-emerald-400 font-semibold"> · 목표가 포함</span>
)}
</Typography>
);
})()}
</div>
);
}

View File

@ -13,7 +13,7 @@ import {
type QuotationSetting,
type SessionView,
quotationTypeLabel,
priceGateActionLabel,
awardCriterionLabel,
fmtDateTime,
} from '../../types';
@ -89,6 +89,7 @@ export function DrawerHeaderCards({
<InfoField label="견적번호" value={q_number} />
<InfoField label="유형" value={quotationTypeLabel(quotation.type)} />
<InfoField label="차수" value={`${q_round}차`} />
<InfoField label="낙찰 기준" value={awardCriterionLabel(quotation.type, quotation.mid_action, quotation.over_action)} valueClassName="font-sans" />
<InfoField label="목표가">
{repSessionId ? (
<button
@ -169,11 +170,7 @@ export function DrawerHeaderCards({
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} />
<InfoField label="앵커~목표 마감처리" value={priceGateActionLabel(selectedSettingObj.mid_action)} />
<InfoField label="목표초과 마감처리" value={priceGateActionLabel(selectedSettingObj.over_action)} />
<InfoField label="재생성 한도" value={`${selectedSettingObj.regen_limit}회`} />
</div>
) : (
<div className="text-muted-foreground py-6 text-center">적용된 견적 세팅이 비어있습니다.</div>

View File

@ -15,11 +15,10 @@ import {
const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-');
// 결과 상태별 배지 톤(협상현황 pill 팔레트 재사용).
// 결과 상태별 배지 톤(협상현황 pill 팔레트 재사용). 개찰=주황(낙찰자 미정, 수동 처리 필요).
const OUTCOME_TONE: Record<ChainRoundState, PillTone> = {
awarded: 'emerald',
regenerated: 'amber',
failed: 'blue',
opened: 'amber',
active: 'zinc',
};

View File

@ -122,16 +122,15 @@ export function TargetPriceModal({
})}
</div>
{/* 앵커링 (negodata: 설정 anchoring_value 고정율) */}
{/* 앵커링 (negodata: 앵커링가는 칸 rate v1.2로 산정·박제, 세팅 고정율 폐지) */}
<div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1">
<Typography as="p" variant="small" className="font-bold text-foreground text-[11px]">앵커링</Typography>
{category && (
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">서비스 카테고리: {category}</Typography>
)}
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">공급 업체 유형: {supplierTypeLabel}</Typography>
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">앵커링 값: {bd.anchoring_value}</Typography>
<Typography as="p" variant="small" className="text-[11px] font-bold text-foreground">
앵커링가: {won(bd.target_anchoring_price)} <span className="font-normal text-[10px] text-muted-foreground">= 목표가×(1−{bd.anchoring_value})</span>
앵커링가: {won(bd.target_anchoring_price)} <span className="font-normal text-[10px] text-muted-foreground">= 목표가 × (1 − 앵커링율)</span>
</Typography>
</div>
</>

View File

@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
import { Typography } from '@/components/ui/typography';
import { Input } from '@/components/ui/input';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { type QuotationSetting, priceGateActionLabel, PRICE_GATE_ACTION_OPTIONS } from '../types';
import { type QuotationSetting } from '../types';
import type { SettingInput } from '../hooks/useQuotations';
type QuotationSettingsModalProps = {
@ -23,27 +23,17 @@ export function QuotationSettingsModal({
onClose,
}: QuotationSettingsModalProps) {
const [targetMargin, setTargetMargin] = useState('');
const [anchoringValue, setAnchoringValue] = useState('');
const [cardUseCount, setCardUseCount] = useState('');
const [midAction, setMidAction] = useState(1); // PriceGateAction 기본 낙찰
const [overAction, setOverAction] = useState(1);
const [regenLimit, setRegenLimit] = useState('1');
if (!open) return null;
// 세팅은 목표 마진율·카드 사용 횟수만. 낙찰 정책은 견적 생성으로 이관, 앵커링은 칸 rate(v1.2)로 대체.
const handleAdd = (e: React.FormEvent) => {
e.preventDefault();
const ok = onAdd({
targetMargin, anchoringValue, cardUseCount,
midAction, overAction, regenLimit: parseInt(regenLimit, 10),
});
const ok = onAdd({ targetMargin, cardUseCount });
if (ok) {
setTargetMargin('');
setAnchoringValue('');
setCardUseCount('');
setMidAction(1);
setOverAction(1);
setRegenLimit('1');
}
};
@ -70,18 +60,14 @@ export function QuotationSettingsModal({
<TableHeader className="bg-muted text-muted-foreground">
<TableRow>
<TableHead className="p-2">목표 마진율</TableHead>
<TableHead className="p-2">앵커링 값</TableHead>
<TableHead className="p-2">카드 사용 횟수</TableHead>
<TableHead className="p-2">앵커~목표</TableHead>
<TableHead className="p-2">목표초과</TableHead>
<TableHead className="p-2">재생성</TableHead>
<TableHead className="p-2 text-center w-12">삭제</TableHead>
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-border bg-background">
{settings.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="p-6 text-center text-muted-foreground">
<TableCell colSpan={3} className="p-6 text-center text-muted-foreground">
등록된 견적 세팅이 없습니다. (리스트가 비어 있습니다)
</TableCell>
</TableRow>
@ -89,11 +75,7 @@ export function QuotationSettingsModal({
{settings.map((qs) => (
<TableRow key={qs.qt_setting_id} className="hover:bg-muted/30">
<TableCell className="p-2 font-bold text-primary">{qs.target_margin}</TableCell>
<TableCell className="p-2 text-foreground">{qs.anchoring_value}</TableCell>
<TableCell className="p-2 text-muted-foreground">{qs.card_use_count}</TableCell>
<TableCell className="p-2 text-muted-foreground">{priceGateActionLabel(qs.mid_action)}</TableCell>
<TableCell className="p-2 text-muted-foreground">{priceGateActionLabel(qs.over_action)}</TableCell>
<TableCell className="p-2 text-muted-foreground">{qs.regen_limit}회</TableCell>
<TableCell className="p-2 text-center">
<button
type="button"
@ -119,42 +101,10 @@ export function QuotationSettingsModal({
<Typography as="label" variant="muted" className="text-[10px] font-semibold">목표 마진율 (%)</Typography>
<Input type="number" step="0.1" value={targetMargin} onChange={(e) => setTargetMargin(e.target.value)} placeholder="예: 12" />
</div>
<div className="space-y-1">
<Typography as="label" variant="muted" className="text-[10px] font-semibold">앵커링 값</Typography>
<Input type="number" step="0.01" value={anchoringValue} onChange={(e) => setAnchoringValue(e.target.value)} placeholder="예: 0.01" />
</div>
<div className="space-y-1">
<Typography as="label" variant="muted" className="text-[10px] font-semibold">카드 사용 횟수</Typography>
<Input type="number" step="1" value={cardUseCount} onChange={(e) => setCardUseCount(e.target.value)} placeholder="예: 3" />
</div>
<div className="space-y-1">
<Typography as="label" variant="muted" className="text-[10px] font-semibold">앵커링가~목표가 마감처리</Typography>
<select
value={midAction}
onChange={(e) => setMidAction(Number(e.target.value))}
className="w-full h-9 rounded-md border border-input bg-background px-3 text-xs"
>
{PRICE_GATE_ACTION_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="space-y-1">
<Typography as="label" variant="muted" className="text-[10px] font-semibold">목표가초과 마감처리</Typography>
<select
value={overAction}
onChange={(e) => setOverAction(Number(e.target.value))}
className="w-full h-9 rounded-md border border-input bg-background px-3 text-xs"
>
{PRICE_GATE_ACTION_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="space-y-1">
<Typography as="label" variant="muted" className="text-[10px] font-semibold">재생성 최대 횟수 (체인 전체)</Typography>
<Input type="number" step="1" min="0" value={regenLimit} onChange={(e) => setRegenLimit(e.target.value)} placeholder="예: 1" />
</div>
</div>
<div className="flex justify-end pt-2">

View File

@ -11,9 +11,10 @@ import {
quotationStatusLabel,
quotationTypeLabel,
chainRoundState,
is1v1,
CHAIN_ROUND_STATE_LABEL,
} from '../types';
import { QuotationType, QuotationStatus } from '@/api/generated/model';
import { QuotationStatus } from '@/api/generated/model';
type QuotationTableProps = {
data: Estimate[];
@ -37,15 +38,13 @@ const statusBadgeClass = (status?: number | null) => {
}
};
// 마감결과 배지 색. 낙찰=초록/재생성=주황/결렬=로즈/진행중=회색(아직 마감 전).
// 마감결과 배지 색. 낙찰=초록 / 개찰(낙찰자 미정, 수동 처리)=주황 / 진행중=회색(아직 마감 전).
const outcomeBadgeClass = (state: ChainRoundState) => {
switch (state) {
case 'awarded':
return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/25 dark:text-emerald-400 border-emerald-300/50';
case 'regenerated':
case 'opened':
return 'bg-amber-50 text-amber-700 dark:bg-amber-950/25 dark:text-amber-400 border-amber-300/50';
case 'failed':
return 'bg-rose-50 text-rose-700 dark:bg-rose-950/25 dark:text-rose-400 border-rose-300/50';
default:
return 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800/40 dark:text-zinc-400 border-zinc-300/40';
}
@ -104,15 +103,12 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo
{
header: '유형',
align: 'center',
// 유형은 고정 속성이라 pill 대신 평문 — 협상(1:1)만 살짝 진하게, 경매(1:N)는 연하게.
cell: (est) => (
<Typography
as="span"
variant="small"
className={`px-2 py-0.5 rounded-full text-[9px] font-bold ${
est.type === QuotationType.RENEGO
? 'bg-neutral-900 text-white dark:bg-zinc-100 dark:text-black'
: 'bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-200'
}`}
className={cn('text-xs', is1v1(est.type) ? 'font-semibold text-foreground' : 'text-muted-foreground')}
>
{quotationTypeLabel(est.type)}
</Typography>

View File

@ -27,7 +27,7 @@ import { confirm } from '@/lib/confirm';
import { useAuthStore } from '@/stores/auth';
import type { Estimate } from '../types';
import { mapItem, mapSupplier, mapSetting, mapQuotation } from '../types';
import { QuotationStatus, type PriceGateAction } from '@/api/generated/model';
import { QuotationStatus } from '@/api/generated/model';
export type CreateQuotationInput = {
title: string;
@ -40,15 +40,14 @@ export type CreateQuotationInput = {
memo: string;
mdPrice?: number | null; // MD 제시가(원). 비우면 미전송 → 서버가 기존 마진식으로 목표가 산정
supplierType?: number | null; // 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록
// 낙찰 기준 — 1:1 협상만 전송(경매는 미전송 → 서버가 mid=over=AWARD 강제). 2전략을 mid/over 로 전개해 담는다(over 항상 OPEN).
midAction?: number; // PriceGateAction (앵커~목표가 처리: 낙찰/개찰)
overAction?: number; // PriceGateAction (목표가 초과 처리: 협상은 항상 개찰)
};
export type SettingInput = {
targetMargin: string;
anchoringValue: string;
cardUseCount: string;
midAction?: number; // PriceGateAction (앵커~목표 마감처리). 미지정=1(낙찰)
overAction?: number; // PriceGateAction (목표초과 마감처리). 미지정=1(낙찰)
regenLimit?: number; // 재생성 최대 횟수(체인 전체, 사유 무관). 미지정=1
};
// 견적 화면 데이터 허브.
@ -124,18 +123,15 @@ export function useQuotations(params: ListQuotationsParams) {
// 백엔드는 target_margin_rate 를 비율(0.12)로 저장하므로 % 입력을 100 으로 나눠 보낸다.
const addSetting = (input: SettingInput): boolean => {
const marginPct = Number(String(input.targetMargin).replace('%', '').trim());
const anchoring = Number(String(input.anchoringValue).trim());
const cardCount = parseInt(String(input.cardUseCount).replace(/[^0-9-]/g, ''), 10);
if (!Number.isFinite(marginPct) || !Number.isFinite(anchoring) || !Number.isInteger(cardCount)) {
showToast('목표 마진율·앵커링 값·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error');
if (!Number.isFinite(marginPct) || !Number.isInteger(cardCount)) {
showToast('목표 마진율·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error');
return false;
}
createSettingMutation.mutate(
// 낙찰 정책은 견적 생성으로 이관, 앵커링은 칸 rate(v1.2) → 세팅은 목표 마진율·카드 사용 횟수만.
{ data: {
target_margin_rate: marginPct / 100, anchoring_value: anchoring, card_count: cardCount,
mid_action: (input.midAction ?? 1) as PriceGateAction,
over_action: (input.overAction ?? 1) as PriceGateAction,
regen_limit: Number.isInteger(input.regenLimit) ? (input.regenLimit as number) : 1,
target_margin_rate: marginPct / 100, card_count: cardCount,
} },
{
onSuccess: () => {
@ -199,6 +195,9 @@ export function useQuotations(params: ListQuotationsParams) {
memo: input.memo.trim() || undefined,
md_price: input.mdPrice && input.mdPrice > 0 ? input.mdPrice : undefined,
supplier_type: input.supplierType ?? undefined,
// 낙찰 기준은 1:1 협상만 전송(모달이 미리 걸러 담음) — 경매면 미전송 → 서버가 AWARD 강제.
mid_action: input.midAction ?? undefined,
over_action: input.overAction ?? undefined,
};
try {

View File

@ -4,12 +4,17 @@ import type { QuotationSettingData } from '@/api/generated/model/quotationSettin
import type { QuotationData } from '@/api/generated/model/quotationData';
import type { SessionData } from '@/api/generated/model/sessionData';
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
import { QuotationType, QuotationStatus, SessionStatus, CardType, CloseReason, PriceGateAction } from '@/api/generated/model';
import { QuotationType, QuotationStatus, SessionStatus, CardType, CloseReason } from '@/api/generated/model';
import { DELIVERY_TYPE_LABEL } from '@/lib/enumLabels';
import type { Product, Partner, NegotiationCard } from '@/types';
export type { Product, Partner, NegotiationCard } from '@/types';
// 낙찰 기준 가격게이트 값 — 백엔드 mid_action/over_action(SMALLINT)에 그대로 저장.
// (백엔드 스키마가 int 라 orval 이 enum 을 안 만들어 → 프론트 로컬 정의.) AWARD=낙찰, OPEN=개찰(낙찰자 미정 마감).
export const PriceGateAction = { AWARD: 1, OPEN: 2 } as const;
export type PriceGateAction = (typeof PriceGateAction)[keyof typeof PriceGateAction];
export type Estimate = Partial<QuotationData> & {
id?: string;
dueDate?: string;
@ -31,30 +36,12 @@ export interface QuotationSetting {
qt_setting_id: string;
user_id: string;
target_margin: string;
anchoring_value: string;
card_use_count: string;
mid_action: PriceGateAction; // 앵커링가<투찰가≤목표가 마감 처리
over_action: PriceGateAction; // 목표가<투찰가 마감 처리
regen_limit: number; // 재생성 최대 횟수(체인 전체, 사유 무관)
created_at: string;
updated_at: string;
deleted: boolean;
}
// 마감 가격정책 처리 라벨(견적세팅 표시·선택).
export const PRICE_GATE_ACTION_LABEL: Record<PriceGateAction, string> = {
[PriceGateAction.AWARD]: '낙찰',
[PriceGateAction.RENEGO]: '재협상',
[PriceGateAction.FAIL]: '결렬',
};
export const priceGateActionLabel = (a?: number | null): string =>
a != null ? PRICE_GATE_ACTION_LABEL[a as PriceGateAction] ?? String(a) : '';
export const PRICE_GATE_ACTION_OPTIONS = [
PriceGateAction.AWARD,
PriceGateAction.RENEGO,
PriceGateAction.FAIL,
].map((value) => ({ value, label: PRICE_GATE_ACTION_LABEL[value] }));
// ── 서버 응답 → UI 모델 매퍼 ─────────────────────────────────────────────
export function mapItem(it: ItemData): Product {
@ -80,11 +67,7 @@ export function mapSetting(s: QuotationSettingData): QuotationSetting {
qt_setting_id: s.qt_setting_id,
user_id: s.user_id || '',
target_margin: `${Number.isFinite(ratePct) ? +ratePct.toFixed(2) : 0}%`,
anchoring_value: String(s.anchoring_value ?? ''),
card_use_count: `${s.card_count ?? 0}회`,
mid_action: s.mid_action ?? PriceGateAction.AWARD,
over_action: s.over_action ?? PriceGateAction.AWARD,
regen_limit: s.regen_limit ?? 1,
created_at: s.created_at ?? '',
updated_at: s.updated_at ?? '',
deleted: false,
@ -174,36 +157,77 @@ export const QUOTATION_TYPE_OPTIONS = [
export const isNewQuotationType = (t?: number | null): boolean =>
t === QuotationType.NEW_NEGO || t === QuotationType.NEW_QUOTE;
// 1:1 협상(RENEGO/NEW_NEGO) 여부. 협상카드·낙찰기준 노출, 협력사 단일선택이 이 분기에 의존.
// 나머지(REQUOTE/NEW_QUOTE)는 1:N 경매 — 무조건 최저가 낙찰.
export const is1v1 = (t?: number | null): boolean =>
t === QuotationType.RENEGO || t === QuotationType.NEW_NEGO;
// 견적 생성 폼의 2축(진행 방식 × 대상) → 4코드 유형 합성.
export type QuotationMode = 'nego' | 'auction'; // 1:1 협상 / 1:N 경매
export const toQuotationType = (mode: QuotationMode, isNew: boolean): QuotationType =>
mode === 'nego'
? isNew
? QuotationType.NEW_NEGO
: QuotationType.RENEGO
: isNew
? QuotationType.NEW_QUOTE
: QuotationType.REQUOTE;
// ── 낙찰 기준(1:1 협상 전용) ──────────────────────────────────────────────
// 앵커링가 이하는 항상 낙찰(고정·선택 불가, 표시만). 사용자는 '앵커~목표가'(mid_action)와 '목표가 초과'(over_action)
// 두 구간만 각각 낙찰(AWARD)/개찰(OPEN)로 정한다. 개찰 = 낙찰자 미정으로 마감(결렬 아님, 담당자 수동 처리).
export const DEFAULT_MID_ACTION: PriceGateAction = PriceGateAction.AWARD; // 앵커~목표가 기본 낙찰
export const DEFAULT_OVER_ACTION: PriceGateAction = PriceGateAction.OPEN; // 목표가 초과 기본 개찰
// 견적의 낙찰 기준 한 줄 라벨(상세 드로어). 경매(1:N)=최저가 자동 낙찰. 협상=두 구간 조합 요약.
export function awardCriterionLabel(type?: number | null, mid?: number | null, over?: number | null): string {
if (type != null && !is1v1(type)) return '최저가 자동 낙찰';
const m = (mid ?? PriceGateAction.AWARD) === PriceGateAction.AWARD;
const o = (over ?? PriceGateAction.AWARD) === PriceGateAction.AWARD;
if (m && o) return '목표가 초과도 낙찰';
if (m && !o) return '목표가까지 낙찰';
if (!m && !o) return '앵커링가까지 낙찰';
return '목표가 초과만 낙찰'; // 비정상 조합(앵커~목표가 개찰인데 초과 낙찰)
}
// 낙찰 기준(mid, over) 조합 → 한 줄 전략 요약(생성 모달 안내). 낙찰 범위가 넓을수록 관대.
export function awardStrategySummary(mid?: number | null, over?: number | null): { strategy: string; desc: string } {
const m = (mid ?? PriceGateAction.AWARD) === PriceGateAction.AWARD;
const o = (over ?? PriceGateAction.AWARD) === PriceGateAction.AWARD;
if (m && o) return { strategy: '관대 전략', desc: '목표가를 초과해도 최저가면 낙찰' };
if (m && !o) return { strategy: '균형 전략', desc: '목표가 이내면 낙찰 · 초과는 개찰' };
if (!m && !o) return { strategy: '엄격 전략', desc: '앵커링가 이하만 낙찰 · 그 위는 개찰' };
return { strategy: '혼합 전략', desc: '앵커~목표가는 개찰인데 초과만 낙찰 · 권장 안 함' };
}
// 협력사 유형 선택지(견적생성 모달) — 라벨은 lib/enumLabels.ts 의 SUPPLIER_TYPE 단일 출처에서 파생.
export { SUPPLIER_TYPE_OPTIONS as supplierTypeOptions } from '@/lib/enumLabels';
// ── 라운드 체인(같은 견적번호) ───────────────────────────────────────────
// 한 라운드(견적)의 결과를 한 단어로. 낙찰=종료, 동가/마감=후속 라운드 가능, 진행중=아직 안 닫힘.
export type ChainRoundState = 'awarded' | 'regenerated' | 'failed' | 'active';
// 한 라운드(견적)의 결과를 한 단어로. 낙찰=종료 / 개찰=낙찰자 미정 마감(수동 재생성 가능) / 진행중=아직 안 닫힘.
export type ChainRoundState = 'awarded' | 'opened' | 'active';
export const CHAIN_ROUND_STATE_LABEL: Record<ChainRoundState, string> = {
awarded: '낙찰',
regenerated: '재생성',
failed: '결렬',
opened: '개찰',
active: '진행중',
};
const REGEN_CLOSE_REASONS: CloseReason[] = [CloseReason.REGEN_PRICE, CloseReason.REGEN_EQUAL, CloseReason.REGEN_NOSHOW];
const FAIL_CLOSE_REASONS: CloseReason[] = [CloseReason.FAIL_PRICE, CloseReason.FAIL_EQUAL, CloseReason.FAIL_NOSHOW, CloseReason.FAIL_REJECT];
const OPEN_CLOSE_REASONS: CloseReason[] = [
CloseReason.OPEN_PRICE, CloseReason.OPEN_EQUAL, CloseReason.OPEN_NOSHOW, CloseReason.OPEN_REJECT,
];
// 마감결과 한 단어. 서버 close_reason(CloseReason) 을 '단일 근거'로 판정한다.
// close_reason 이 아직 없는 옛 데이터만 preferred_sp_id/equal_bid_yn 플래그로 폴백.
// close_reason 이 아직 없는 옛 데이터만 preferred_sp_id 플래그로 폴백(마감됐는데 낙찰자 없으면 개찰).
// QuotationData(전체)·Estimate(Partial) 둘 다 받도록 필요한 필드만 optional.
export function chainRoundState(
q: { status?: number | null; close_reason?: number | null; preferred_sp_id?: string | null; equal_bid_yn?: boolean | null },
q: { status?: number | null; close_reason?: number | null; preferred_sp_id?: string | null },
): ChainRoundState {
if (q.close_reason != null) {
if (q.close_reason === CloseReason.AWARDED) return 'awarded';
if (REGEN_CLOSE_REASONS.includes(q.close_reason as CloseReason)) return 'regenerated';
if (FAIL_CLOSE_REASONS.includes(q.close_reason as CloseReason)) return 'failed';
if (OPEN_CLOSE_REASONS.includes(q.close_reason as CloseReason)) return 'opened';
}
if (q.preferred_sp_id) return 'awarded';
if (q.equal_bid_yn) return 'regenerated';
if (q.status === QuotationStatus.CLOSED) return 'failed';
if (q.status === QuotationStatus.CLOSED) return 'opened';
return 'active';
}
@ -240,7 +264,7 @@ export type QuotationCardView = {
// DB 저장값이 아니라 이미 로드된 quotation + 세션들로 파생한다(별도 컬럼·엔드포인트 없음).
// · 목표가 = md_price 우선(없으면 세션 목표가)
// · 낙찰가 = 우선협상자 세션의 투찰가(미낙찰이면 현재 최저 투찰가 = 잠정)
// · 절감 = 목표가 − 낙찰가 (양수 = 목표보다 저렴하게 낙찰)
// · 절감 = 목표가 − 낙찰가 (양수 = 목표가보다 저렴하게 낙찰)
export type QuotationResultView = {
outcome: ChainRoundState;
closeReason: string;
@ -290,16 +314,13 @@ export function buildQuotationResult(q: QuotationData, sessions: SessionView[]):
};
}
// 마감 사유 라벨. 서버 close_reason(CloseReason) 단일 근거로 표기.
// 마감 사유 라벨. 서버 close_reason(CloseReason) 단일 근거로 표기. 개찰 = 낙찰자 미정으로 마감(결렬 아님).
export const CLOSE_REASON_LABEL: Record<CloseReason, string> = {
[CloseReason.AWARDED]: '낙찰',
[CloseReason.REGEN_PRICE]: '목표초과 — 재협상 진행',
[CloseReason.REGEN_EQUAL]: '동가 — 재입찰 진행',
[CloseReason.REGEN_NOSHOW]: '전원 미참여 — 재소집 진행',
[CloseReason.FAIL_PRICE]: '목표초과로 결렬',
[CloseReason.FAIL_EQUAL]: '동가로 결렬',
[CloseReason.FAIL_NOSHOW]: '미참여로 결렬',
[CloseReason.FAIL_REJECT]: '협상 거부로 결렬',
[CloseReason.OPEN_PRICE]: '목표가 초과 — 개찰(낙찰자 미정)',
[CloseReason.OPEN_EQUAL]: '동가 — 개찰(낙찰자 미정)',
[CloseReason.OPEN_NOSHOW]: '전원 미응찰 — 개찰',
[CloseReason.OPEN_REJECT]: '협상 거부 — 개찰',
};
// 마감결과 텍스트(정밀). 마감결과 배지·라벨은 이 값을 쓴다 — close_reason(CloseReason) 을 그대로 표기.
@ -318,10 +339,9 @@ function quotationCloseReason(q: QuotationData, sessions: SessionView[]): string
if (q.status !== QuotationStatus.CLOSED) return '협상 진행중';
if (q.close_reason != null) return CLOSE_REASON_LABEL[q.close_reason as CloseReason] ?? '마감';
if (q.preferred_sp_yn) return '낙찰';
if (q.equal_bid_yn) return '동가 — 다음 라운드 재생성';
if (q.preferred_sp_yn === false && q.equal_bid_yn === false) return '전원 미참여 — 다음 라운드 재생성';
if (sessions.some((s) => s.status === SessionStatus.REJECTED)) return '협상 거부로 마감';
return '마감';
if (q.equal_bid_yn) return '동가 — 개찰(낙찰자 미정)';
if (sessions.some((s) => s.status === SessionStatus.REJECTED)) return '협상 거부 — 개찰';
return '개찰(낙찰자 미정)';
}
// ── 서버 연동 매퍼(negotiation.sessions / chats / 사용 카드) ──────────────

View File

@ -177,9 +177,14 @@ function render(n: NotificationData): { icon: ReactNode; tone: string; event: st
number,
};
case NotificationType.FAILURE:
// 결렬 폐지 → '개찰'(낙찰자 미정으로 마감). reason 으로 사유만 부기.
return {
icon: <XCircle size={18} />, tone: 'text-rose-600', event: '견적 결렬',
line: `${name} — 낙찰 없이 마감`,
icon: <XCircle size={18} />, tone: 'text-amber-600', event: '견적 개찰',
line: `${name} — 낙찰자 미정 (${
({ price: '목표가 초과', equal: '동가', rejected: '협상거부', no_show: '전원 미응찰' } as Record<string, string>)[
String(d.reason)
] ?? '마감'
})`,
number,
};
default: