[feat] negodata/front: 견적생성 UX 재개편(협상/견적 2축·낙찰기준 스펙트럼·카드 스텝) + 개찰 표기 + 세팅 정리
Co-Authored-By: Claude Opus 4.8뀨 <noreply@anthropic.com>
This commit is contained in:
parent
38709b3098
commit
f1a29b8bde
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
|
||||
@ -5,4 +5,4 @@
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ReqUpdateQuotationSettingRegenLimit = number | null;
|
||||
export type QuotationDataMidAction = number | null;
|
||||
@ -5,4 +5,4 @@
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ReqUpdateQuotationSettingAnchoringValue = number | null;
|
||||
export type QuotationDataOverAction = number | null;
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -25,7 +25,6 @@ export interface ResTargetBreakdown {
|
||||
selling?: ResTargetBreakdownSelling;
|
||||
fee?: number;
|
||||
margin?: number;
|
||||
anchoring_value?: number;
|
||||
candidates?: TargetCandidate[];
|
||||
chosen_basis?: ResTargetBreakdownChosenBasis;
|
||||
target_price?: number;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -74,9 +74,9 @@ export const STEPS: Step[] = [
|
||||
actor: '시스템',
|
||||
actorType: 'system',
|
||||
icon: Award,
|
||||
short: '단독최저=낙찰 / 동가·미참여=재생성 / 결렬',
|
||||
short: '단독최저+기준통과=낙찰 / 그 외=개찰(낙찰자 미정)',
|
||||
detail:
|
||||
'단독 최저가면 그 협력사로 낙찰됩니다. 최저가가 둘 이상 같은 동가이거나 전원 미참여면 다음 차수가 자동 재생성되고, 거절·한도 등으로 낙찰자가 없으면 결렬 처리됩니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.',
|
||||
'단독 최저가가 견적의 낙찰 기준을 통과하면 그 협력사로 낙찰됩니다. 기준을 못 넘기거나 동가·전원 미응찰·협상거부인 경우는 결렬이 아니라 개찰 — 낙찰자 미정으로 마감되고, 다음 라운드는 담당자가 상세에서 수동으로 만듭니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 / 사용 카드) ──────────────
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user