[feat] negosium·negodata: 협상 거부 흐름 정리 + 오프라인 계약가 낙찰 + 견적 상세 리모델링
협상 불가 사유를 내면 500 이 나고, 거부 폼은 목록·채팅이 따로 놀았으며,
제출한 내용을 다시 볼 방법이 없었다. 결렬 건은 협력사가 낸 거부가를 계약가로
간주해 낙찰시켜 절감 통계가 음수로 뒤집힐 수 있었고, 견적 상세는 판정 가격이
세 곳에 흩어져 대화 탭에선 아예 보이지 않았다.
agent
- 결렬 종료 로깅 크래시 수정 — _log 를 action_id 기반으로 되돌리고 선택 근거
(Q·UCB·방문수)는 decision/policy 가 있을 때만 채운다. 종료 행은 카드 선택이
없고 policy.update 뒤라 값을 넣으면 학습 화면 집계가 오염된다
negosium
- 거부 폼을 목록·채팅 공용 컴포넌트 하나로 통일(사유 3종 + 공급 희망가·의견 선택)
- 거부 사유 열람 — 목록에 '거부 사유 보기'(부가정보 보기와 같은 규격), 채팅
재진입 시 대화 끝에 거부 내역 카드. 목록·채팅 init 응답에 reject_reason·reject_price 추가
- 자유 입력 거부("협상 포기합니다")가 사유 NULL 로 저장되던 문제 수정 — 폼 마커가
없으면 원문을 사유로 쓰고, 문장 속 숫자를 희망가로 오인하지 않는다
- koreanNumber 를 전역 lib 으로 이동(공용 폼이 쓴다)
negodata
- 직접 낙찰에 계약가 입력 — 결렬·미응찰 건을 오프라인으로 다시 협상한 결과를
담당자가 확정해 넣는다. 후보는 초청 협력사 전부(가격 미제출도 포함),
계약가는 sessions.custom.offline_award 에 근거·작성자·시각과 함께 남긴다
- 통계 계약가 = 담당자 확정가 우선, 없으면 투찰가. 거부가를 계약가로 치던 파생 제거.
KPI 에 오프라인 반영 건수 추가
- 견적 상세 리모델링 — 가격 레일(앵커링가/투찰현황 · 목표가 · 타결 상한가 · 결과가)을
시트에 고정해 접힘·탭 전환에도 남기고, 스펙트럼에 타결 판정선과 구간색 추가.
라벨은 폭을 실측해 두 레인으로 배치(겹침 불가). 상품·마감시각 등 전 행 동일 컬럼 제거,
협상현황에 부가정보 노출, 1:1 은 협력사·세션상태를 결과 밴드로 올림
테스트: negosium 58 · negodata 110 통과. 프론트 빌드/린트 통과.
This commit is contained in:
parent
d02ba20520
commit
6eb4dc26f8
@ -320,7 +320,8 @@ class ChatService:
|
|||||||
await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id)
|
await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id)
|
||||||
session.context["last_state"] = idx
|
session.context["last_state"] = idx
|
||||||
session.context["last_action"] = decision.action_id
|
session.context["last_action"] = decision.action_id
|
||||||
await self._log(repo, session, idx, decision.action_id, card_id, snap, reward, decision.propensity, done=False)
|
await self._log(repo, session, idx, decision.action_id, card_id, snap, reward, done=False,
|
||||||
|
decision=decision, policy=policy)
|
||||||
|
|
||||||
res.card_id = card_id
|
res.card_id = card_id
|
||||||
res.policy = policy.name
|
res.policy = policy.name
|
||||||
@ -420,7 +421,7 @@ class ChatService:
|
|||||||
policy.update(Transition(state_index=last_state, action_id=last_action, reward=reward.total, done=True))
|
policy.update(Transition(state_index=last_state, action_id=last_action, reward=reward.total, done=True))
|
||||||
await QTablePolicyStore.persist_cell(repo, version_id, policy, last_state, last_action)
|
await QTablePolicyStore.persist_cell(repo, version_id, policy, last_state, last_action)
|
||||||
await self._log(repo, session, last_state, last_action,
|
await self._log(repo, session, last_state, last_action,
|
||||||
self._card_id_for_action(engine, session, last_action), snap, reward, None, done=True)
|
self._card_id_for_action(engine, session, last_action), snap, reward, done=True)
|
||||||
res.updated_q = float(policy.qtable.q[last_state, last_action])
|
res.updated_q = float(policy.qtable.q[last_state, last_action])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -499,13 +500,26 @@ class ChatService:
|
|||||||
prior[a] = 0.3 * (n - rank) / n
|
prior[a] = 0.3 * (n - rank) / n
|
||||||
return prior if prior.any() else None
|
return prior if prior.any() else None
|
||||||
|
|
||||||
async def _log(self, repo: LearningRepository, session, state_index, action_id, card_id, snap, reward, propensity, done):
|
async def _log(self, repo: LearningRepository, session, state_index, action_id, card_id, snap, reward, done,
|
||||||
|
decision=None, policy=None):
|
||||||
data = {
|
data = {
|
||||||
"session_id": session.session_id, "state_index": state_index, "action_id": action_id,
|
"session_id": session.session_id, "state_index": state_index, "action_id": action_id,
|
||||||
"card_id": card_id, "snapshot": snap.to_dict(), "propensity": propensity,
|
"card_id": card_id, "snapshot": snap.to_dict(),
|
||||||
"turn": snap.round_number, "reward": reward.total, "done": done,
|
"turn": snap.round_number, "reward": reward.total, "done": done,
|
||||||
"settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None,
|
"settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None,
|
||||||
}
|
}
|
||||||
|
if decision is not None and policy is not None:
|
||||||
|
# 선택 근거(Q값·UCB·방문수)를 그 턴 값 그대로 박제한다 — 사후에 q_values 를 읽으면 이미 갱신된 뒤라
|
||||||
|
# "그때 왜 이 카드였나"를 복원할 수 없다. negodata 협상 학습 화면이 이 컬럼들을 읽는다.
|
||||||
|
# 종료 로그(카드 선택 없는 done 행)는 decision 이 없어 NULL — 화면 집계(avg/max)가 무시한다.
|
||||||
|
data.update({
|
||||||
|
"propensity": decision.propensity,
|
||||||
|
"available_actions": decision.available_actions,
|
||||||
|
"q_value_at_selection": decision.q_value,
|
||||||
|
"ucb_score_at_selection": decision.ucb_score,
|
||||||
|
"visit_count_at_selection": int(policy.qtable.visits[state_index, action_id]),
|
||||||
|
"total_visits_at_selection": int(policy.qtable.state_visits(state_index)),
|
||||||
|
})
|
||||||
try:
|
try:
|
||||||
await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [lambda s: repo.log_transition(s, data)])
|
await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [lambda s: repo.log_transition(s, data)])
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
|
|||||||
@ -97,10 +97,10 @@ class NegotiationService:
|
|||||||
|
|
||||||
# 7) experience_logs 기록
|
# 7) experience_logs 기록
|
||||||
if req.log:
|
if req.log:
|
||||||
res.logged = await self._log(engine, session_id, idx, decision, snap, reward)
|
res.logged = await self._log(engine, session_id, idx, decision, snap, reward, policy)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
async def _log(self, engine, session_id, idx, decision, snap, reward) -> bool:
|
async def _log(self, engine, session_id, idx, decision, snap, reward, policy) -> bool:
|
||||||
repo = LearningRepository(engine.company_id)
|
repo = LearningRepository(engine.company_id)
|
||||||
data = {
|
data = {
|
||||||
"session_id": session_id, "state_index": idx, "action_id": decision.action_id,
|
"session_id": session_id, "state_index": idx, "action_id": decision.action_id,
|
||||||
@ -108,6 +108,8 @@ class NegotiationService:
|
|||||||
"turn": snap.round_number, "available_actions": decision.available_actions,
|
"turn": snap.round_number, "available_actions": decision.available_actions,
|
||||||
"reward": reward.total, "done": snap.outcome != NegotiationOutcome.ONGOING,
|
"reward": reward.total, "done": snap.outcome != NegotiationOutcome.ONGOING,
|
||||||
"q_value_at_selection": decision.q_value, "ucb_score_at_selection": decision.ucb_score,
|
"q_value_at_selection": decision.q_value, "ucb_score_at_selection": decision.ucb_score,
|
||||||
|
"visit_count_at_selection": int(policy.qtable.visits[idx, decision.action_id]),
|
||||||
|
"total_visits_at_selection": int(policy.qtable.state_visits(idx)),
|
||||||
"settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None,
|
"settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -143,6 +143,9 @@ class SessionCRUD(ISessionCRUD):
|
|||||||
sessions.supplier_id, # 이 세션 소유 공급사(=조회자). 낙찰자와 대조
|
sessions.supplier_id, # 이 세션 소유 공급사(=조회자). 낙찰자와 대조
|
||||||
# 대화 이력 유무 — 종료된 협상의 '결과 보기'(열람) 버튼을 띄울지 판단용. 열 게 없으면 프론트가 감춘다.
|
# 대화 이력 유무 — 종료된 협상의 '결과 보기'(열람) 버튼을 띄울지 판단용. 열 게 없으면 프론트가 감춘다.
|
||||||
select(1).where(chats.session_id == sessions.session_id, chats.deleted == False).exists(), # noqa: E712
|
select(1).where(chats.session_id == sessions.session_id, chats.deleted == False).exists(), # noqa: E712
|
||||||
|
# 거부 건이 제출한 사유·희망가 — 목록의 '거부 내역' 열람용(의견은 custom.opinion).
|
||||||
|
sessions.reject_reason,
|
||||||
|
sessions.reject_price,
|
||||||
)
|
)
|
||||||
.join(items, items.item_id == sessions.item_id)
|
.join(items, items.item_id == sessions.item_id)
|
||||||
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||||||
@ -224,7 +227,7 @@ class SessionCRUD(ISessionCRUD):
|
|||||||
) -> ErrorType:
|
) -> ErrorType:
|
||||||
try:
|
try:
|
||||||
values = {"status": status, "reject_reason": reject_reason}
|
values = {"status": status, "reject_reason": reject_reason}
|
||||||
# 공급 희망 가격은 채팅 중 거부에서만 들어온다 — 목록 거부는 가격 없이 사유만 남긴다.
|
# 공급 희망 가격은 선택 입력이라 안 들어올 수 있다 — 그때는 컬럼을 건드리지 않는다.
|
||||||
if reject_price is not None:
|
if reject_price is not None:
|
||||||
values["reject_price"] = reject_price
|
values["reject_price"] = reject_price
|
||||||
query = (
|
query = (
|
||||||
|
|||||||
@ -79,6 +79,8 @@ class Res_ChatInit(Res_WebPacketProtocol):
|
|||||||
item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)")
|
item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)")
|
||||||
item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)")
|
item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)")
|
||||||
custom: dict = Field(default_factory=dict, description="협상완료 부가정보 기존 입력값(sessions.custom). 재진입 시 폼 프리필용")
|
custom: dict = Field(default_factory=dict, description="협상완료 부가정보 기존 입력값(sessions.custom). 재진입 시 폼 프리필용")
|
||||||
|
reject_reason: str = Field("", description="협상 거부 시 제출한 사유. 거부 건이 아니면 빈 문자열")
|
||||||
|
reject_price: Optional[int] = Field(None, description="협상 거부 시 함께 낸 공급 희망 가격(원). 미입력이면 null")
|
||||||
labels: dict = Field(default_factory=dict, description="회사 커스텀 라벨(companies.settings.labels). 상품 상세 필드명(예: lead_time) 치환용. 없으면 프론트 기본값")
|
labels: dict = Field(default_factory=dict, description="회사 커스텀 라벨(companies.settings.labels). 상품 상세 필드명(예: lead_time) 치환용. 없으면 프론트 기본값")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,8 @@ class ListItem(WebPacketProtocol):
|
|||||||
renegotiation_memo: str = Field("", description="담당자 심사 메모(반려 사유). 없으면 빈 문자열")
|
renegotiation_memo: str = Field("", description="담당자 심사 메모(반려 사유). 없으면 빈 문자열")
|
||||||
result: int = Field(0, description="공급사 관점 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰, 재협상 대상)")
|
result: int = Field(0, description="공급사 관점 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰, 재협상 대상)")
|
||||||
has_chat: bool = Field(False, description="대화 이력 존재 여부 — 종료된 협상(미참여·거부)의 '결과 보기' 노출 판단용")
|
has_chat: bool = Field(False, description="대화 이력 존재 여부 — 종료된 협상(미참여·거부)의 '결과 보기' 노출 판단용")
|
||||||
|
reject_reason: str = Field("", description="협상 거부 시 제출한 사유. 거부 건이 아니면 빈 문자열")
|
||||||
|
reject_price: Optional[int] = Field(None, description="협상 거부 시 함께 낸 공급 희망 가격(원). 미입력이면 null")
|
||||||
|
|
||||||
|
|
||||||
class Res_SessionList(Res_WebPacketProtocol):
|
class Res_SessionList(Res_WebPacketProtocol):
|
||||||
@ -37,7 +39,7 @@ class Res_Participate(Res_WebPacketProtocol):
|
|||||||
|
|
||||||
class Req_Reject(WebPacketProtocol):
|
class Req_Reject(WebPacketProtocol):
|
||||||
reject_reason: str = Field("", max_length=255, description="거부 사유 (단종/품절 프리셋 라벨 또는 직접 입력)")
|
reject_reason: str = Field("", max_length=255, description="거부 사유 (단종/품절 프리셋 라벨 또는 직접 입력)")
|
||||||
reject_price: Optional[int] = Field(None, description="공급 희망 가격(원). 채팅 중 거부에서만 들어온다 — 목록 거부엔 없음")
|
reject_price: Optional[int] = Field(None, description="공급 희망 가격(원). 선택 입력 — 없으면 컬럼 미변경")
|
||||||
opinion: Optional[str] = Field(None, max_length=255, description="추가 의견 — sessions.custom.opinion 에 병합")
|
opinion: Optional[str] = Field(None, max_length=255, description="추가 의견 — sessions.custom.opinion 에 병합")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -71,6 +71,14 @@ class ChatService:
|
|||||||
opinion = None
|
opinion = None
|
||||||
if ", 의견-" in s:
|
if ", 의견-" in s:
|
||||||
s, opinion = s.split(", 의견-", 1)
|
s, opinion = s.split(", 의견-", 1)
|
||||||
|
# 폼이 아닌 자유 입력("협상 포기합니다" 등)은 원문이 곧 사유다. 폼 마커가 없으면 가격도 읽지 않는다
|
||||||
|
# — 문장에 섞인 숫자를 희망가로 오인해 저장하는 것을 막는다.
|
||||||
|
if "합의불가사유-" not in s and "공급희망가격-" not in s:
|
||||||
|
return {
|
||||||
|
"offer_price": None,
|
||||||
|
"reason": s.strip()[:255] or None,
|
||||||
|
"opinion": (opinion.strip() or None) if opinion is not None else None,
|
||||||
|
}
|
||||||
reason = None
|
reason = None
|
||||||
if ", 합의불가사유-" in s:
|
if ", 합의불가사유-" in s:
|
||||||
price_part, reason = s.split(", 합의불가사유-", 1)
|
price_part, reason = s.split(", 합의불가사유-", 1)
|
||||||
@ -251,6 +259,9 @@ class ChatService:
|
|||||||
res.item_vat_yn = item.vat_yn
|
res.item_vat_yn = item.vat_yn
|
||||||
res.item_delivery_fee_yn = item.delivery_fee_yn
|
res.item_delivery_fee_yn = item.delivery_fee_yn
|
||||||
res.custom = sess.custom or {}
|
res.custom = sess.custom or {}
|
||||||
|
# 거부로 끝난 세션은 대화에 남지 않는 제출 내역(사유·희망가)을 열람용으로 함께 내린다.
|
||||||
|
res.reject_reason = sess.reject_reason or ""
|
||||||
|
res.reject_price = sess.reject_price
|
||||||
|
|
||||||
# 회사 커스텀 라벨(companies.settings.labels) — 상품 상세 필드명 치환용(예: lead_time→표준납기). 실패해도 빈 dict 폴백.
|
# 회사 커스텀 라벨(companies.settings.labels) — 상품 상세 필드명 치환용(예: lead_time→표준납기). 실패해도 빈 dict 폴백.
|
||||||
_e, settings = await DB_SESSION_MNG.execute_lambda(
|
_e, settings = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
|||||||
@ -184,6 +184,8 @@ class NegotiationService:
|
|||||||
renegotiation_memo=renego.get("memo") or "",
|
renegotiation_memo=renego.get("memo") or "",
|
||||||
result=NegotiationService._to_result(r[10], r[11], r[13], r[14]),
|
result=NegotiationService._to_result(r[10], r[11], r[13], r[14]),
|
||||||
has_chat=bool(r[15]),
|
has_chat=bool(r[15]),
|
||||||
|
reject_reason=r[16] or "",
|
||||||
|
reject_price=r[17],
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -220,6 +220,23 @@ async def test_chat_init_returns_meta(client, chat_seed):
|
|||||||
assert body["quotation_end_time"] # 타이머용 마감 시각
|
assert body["quotation_end_time"] # 타이머용 마감 시각
|
||||||
|
|
||||||
|
|
||||||
|
async def test_chat_init_returns_reject_detail(client, chat_seed, db_engine):
|
||||||
|
"""검증: 협상 거부로 끝난 세션에 재진입('결과 보기')했을 때의 init 응답.
|
||||||
|
기대결과: 대화에 남지 않는 제출 내역(reject_reason·reject_price)이 실려 열람 카드를 그릴 수 있다."""
|
||||||
|
token = await _login_token(client)
|
||||||
|
sid = chat_seed["sids"]["P"]
|
||||||
|
await client.post(
|
||||||
|
f"/v1/negotiation/sessions/{sid}/reject",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"reject_reason": "단종", "reject_price": 91000, "opinion": "후속 모델로 제안 가능합니다"},
|
||||||
|
)
|
||||||
|
body = (await _init(client, token, sid)).json()
|
||||||
|
assert body["session_status"] == 5
|
||||||
|
assert body["reject_reason"] == "단종"
|
||||||
|
assert body["reject_price"] == 91000
|
||||||
|
assert body["custom"]["opinion"] == "후속 모델로 제안 가능합니다"
|
||||||
|
|
||||||
|
|
||||||
async def test_chat_init_forbidden_other_supplier(client, chat_seed):
|
async def test_chat_init_forbidden_other_supplier(client, chat_seed):
|
||||||
token = await _login_token(client)
|
token = await _login_token(client)
|
||||||
body = (await _init(client, token, chat_seed["sids"]["X"])).json()
|
body = (await _init(client, token, chat_seed["sids"]["X"])).json()
|
||||||
|
|||||||
@ -419,6 +419,31 @@ async def test_reject_without_price_keeps_null(client, nego_seed, db_engine):
|
|||||||
assert row.reject_price is None and row.custom is None
|
assert row.reject_price is None and row.custom is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_returns_reject_detail(client, nego_seed):
|
||||||
|
# 거부 제출 내역은 대화에 남지 않는다 — 목록이 사유·희망가를 실어야 '거부 내역'을 열람할 수 있다.
|
||||||
|
token = await _login_token(client)
|
||||||
|
sid = nego_seed["sids"]["B"]
|
||||||
|
await client.post(
|
||||||
|
f"/v1/negotiation/sessions/{sid}/reject",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"reject_reason": "품절", "reject_price": 77000, "opinion": "재고 확보 후 연락드리겠습니다"},
|
||||||
|
)
|
||||||
|
items = (await _list(client, token)).json()["items"]
|
||||||
|
row = next(i for i in items if i["session_id"] == str(sid))
|
||||||
|
assert row["reject_reason"] == "품절"
|
||||||
|
assert row["reject_price"] == 77000
|
||||||
|
assert row["custom"]["opinion"] == "재고 확보 후 연락드리겠습니다"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_reject_detail_empty_for_active(client, nego_seed):
|
||||||
|
# 거부 건이 아니면 빈 값 — 프론트가 '거부 내역' 버튼 노출을 상태로만 판단하므로 값이 새면 안 된다.
|
||||||
|
token = await _login_token(client)
|
||||||
|
items = (await _list(client, token)).json()["items"]
|
||||||
|
row = next(i for i in items if i["session_id"] == str(nego_seed["sids"]["A"]))
|
||||||
|
assert row["reject_reason"] == ""
|
||||||
|
assert row.get("reject_price") is None
|
||||||
|
|
||||||
|
|
||||||
async def test_reject_empty_reason(client, nego_seed):
|
async def test_reject_empty_reason(client, nego_seed):
|
||||||
token = await _login_token(client)
|
token = await _login_token(client)
|
||||||
r = await _reject(client, token, nego_seed["sids"]["B"], " ") # 공백만 → 사유 없음
|
r = await _reject(client, token, nego_seed["sids"]["B"], " ") # 공백만 → 사유 없음
|
||||||
|
|||||||
@ -49,6 +49,8 @@ export interface ChatInitResponse {
|
|||||||
item_delivery_fee_yn?: boolean
|
item_delivery_fee_yn?: boolean
|
||||||
custom?: Record<string, unknown>
|
custom?: Record<string, unknown>
|
||||||
labels?: Record<string, string>
|
labels?: Record<string, string>
|
||||||
|
reject_reason?: string
|
||||||
|
reject_price?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessagesResponse {
|
export interface ChatMessagesResponse {
|
||||||
@ -108,5 +110,7 @@ export function mapInit(r: ChatInitResponse): ChatInitData {
|
|||||||
quotation_memo: r.quotation_memo ?? '',
|
quotation_memo: r.quotation_memo ?? '',
|
||||||
quotation_end_time: r.quotation_end_time ?? '',
|
quotation_end_time: r.quotation_end_time ?? '',
|
||||||
labels: r.labels ?? {},
|
labels: r.labels ?? {},
|
||||||
|
reject_reason: r.reject_reason ?? '',
|
||||||
|
reject_price: r.reject_price ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,6 +63,8 @@ export interface SessionListItem {
|
|||||||
renegotiation_memo: string // 담당자 심사 메모(반려 사유)
|
renegotiation_memo: string // 담당자 심사 메모(반려 사유)
|
||||||
result: number // 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
|
result: number // 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
|
||||||
has_chat: boolean // 대화 이력 존재 여부 — 종료된 협상의 '결과 보기' 노출 판단용
|
has_chat: boolean // 대화 이력 존재 여부 — 종료된 협상의 '결과 보기' 노출 판단용
|
||||||
|
reject_reason: string // 협상 거부 시 제출한 사유. 거부 건이 아니면 ''
|
||||||
|
reject_price?: number | null // 거부와 함께 낸 공급 희망 가격(원). 미입력이면 null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 공급사 관점 협상 결과 (sessions 파생) */
|
/** 공급사 관점 협상 결과 (sessions 파생) */
|
||||||
@ -119,9 +121,9 @@ export interface ParticipateResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- 거부 (POST /v1/negotiation/sessions/{id}/reject) ---------------------
|
// --- 거부 (POST /v1/negotiation/sessions/{id}/reject) ---------------------
|
||||||
// 목록의 협상 거부와 채팅 중 협상 거부가 같은 엔드포인트를 쓴다.
|
// 목록의 협상 거부와 채팅 중 협상 거부가 같은 폼(components/RejectPopup)·같은 엔드포인트를 쓴다.
|
||||||
// reject_reason: 프리셋(단종/품절) 라벨 또는 '기타' 직접 입력 텍스트.
|
// reject_reason: 프리셋(단종/품절) 라벨 또는 '기타' 직접 입력 텍스트. 필수.
|
||||||
// reject_price/opinion: 채팅 중 거부에서만 채운다(목록 거부는 사유만).
|
// reject_price/opinion: 선택 입력 — 빈 값이면 아예 보내지 않는다(opinion 은 sessions.custom 에 병합).
|
||||||
export interface RejectRequest {
|
export interface RejectRequest {
|
||||||
reject_reason: string
|
reject_reason: string
|
||||||
reject_price?: number
|
reject_price?: number
|
||||||
|
|||||||
81
frontend/src/components/RejectDetailPopup.tsx
Normal file
81
frontend/src/components/RejectDetailPopup.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { Modal } from '@/components/Modal'
|
||||||
|
import { numberToKorean } from '@/lib'
|
||||||
|
|
||||||
|
export interface RejectDetailPopupProps {
|
||||||
|
onClose: () => void
|
||||||
|
/** 어느 건인지 식별용 부제 (견적번호 · 상품명) */
|
||||||
|
subtitle?: string
|
||||||
|
/** sessions.reject_reason — 프리셋 라벨 또는 '기타' 직접 입력 텍스트 */
|
||||||
|
reason: string
|
||||||
|
/** sessions.reject_price — 미입력이면 null */
|
||||||
|
price: number | null
|
||||||
|
/** sessions.custom.opinion */
|
||||||
|
opinion: string
|
||||||
|
/** 'VAT포함'/'VAT별도' — 품목 VAT 를 아는 화면에서만 넘긴다 */
|
||||||
|
vatLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 협상 거부 사유 열람 팝업 — 목록의 '협상완료 부가정보(보기)'와 같은 규격(읽기전용 필드 나열).
|
||||||
|
// 거부는 제출 내역이 대화에 남지 않아 세션 컬럼이 유일한 기록이다.
|
||||||
|
export function RejectDetailPopup({ onClose, subtitle, reason, price, opinion, vatLabel }: RejectDetailPopupProps) {
|
||||||
|
const priceText = price
|
||||||
|
? `${price.toLocaleString()}원 (${numberToKorean(price)}원)${vatLabel ? ` ${vatLabel}` : ''}`
|
||||||
|
: '미입력'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose}>
|
||||||
|
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
|
||||||
|
<div className="flex items-center justify-between border-b border-border p-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-neutral-90">협상 거부 사유 (보기)</h3>
|
||||||
|
<p className="mt-0.5 text-xs text-neutral-60">
|
||||||
|
{subtitle}
|
||||||
|
{subtitle ? ' · ' : ''}제출 후에는 수정할 수 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="닫기"
|
||||||
|
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 p-5">
|
||||||
|
<Field label="거부 사유" value={reason || '-'} />
|
||||||
|
<Field label="공급 희망 가격" value={priceText} />
|
||||||
|
<Field label="의견" value={opinion} rows={2} placeholder="남긴 의견 없음" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 border-t border-border p-5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-11 flex-1 rounded-xl border border-border text-sm font-bold text-neutral-70 hover:bg-neutral-10"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부가정보 팝업의 읽기전용 필드와 같은 모양(라벨 + disabled 입력).
|
||||||
|
function Field({ label, value, rows, placeholder }: { label: string; value: string; rows?: number; placeholder?: string }) {
|
||||||
|
const style =
|
||||||
|
'w-full rounded-xl border border-border bg-neutral-10 px-3 text-sm text-neutral-60 cursor-not-allowed outline-none'
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-semibold text-neutral-80">{label}</label>
|
||||||
|
{rows ? (
|
||||||
|
<textarea value={value} rows={rows} disabled placeholder={placeholder} className={`${style} resize-none py-2`} />
|
||||||
|
) : (
|
||||||
|
<input type="text" value={value} disabled placeholder={placeholder} className={`${style} h-11`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
200
frontend/src/components/RejectPopup.tsx
Normal file
200
frontend/src/components/RejectPopup.tsx
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { Modal } from '@/components/Modal'
|
||||||
|
import { cn, numberToKorean } from '@/lib'
|
||||||
|
|
||||||
|
const MAX_PRICE = 999999999999999
|
||||||
|
|
||||||
|
// 거부 사유는 협상을 시작하지 않겠다는 사유(단종/품절/기타)다. 협상해보고 합의가 안 된
|
||||||
|
// 결렬 폼(단가인상·수량 포함 5종)과는 성격이 달라 목록을 맞추지 않는다.
|
||||||
|
const REASONS = ['단종', '품절', '기타'] as const
|
||||||
|
|
||||||
|
export interface RejectSubmitPayload {
|
||||||
|
/** 프리셋 라벨 또는 '기타' 직접 입력 텍스트 */
|
||||||
|
reject_reason: string
|
||||||
|
reject_price?: number
|
||||||
|
opinion?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RejectPopupProps {
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (payload: RejectSubmitPayload) => void
|
||||||
|
isPending?: boolean
|
||||||
|
/** 가격 옆 'VAT 별도' 표기 — 품목 VAT 를 아는 화면(채팅)에서만 켠다 */
|
||||||
|
isVatExcluded?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// 협상 거부 팝업 — 목록과 채팅 양쪽이 같은 폼·같은 엔드포인트(/reject)를 쓴다.
|
||||||
|
// 사유는 필수, 공급 희망 가격과 의견은 선택.
|
||||||
|
export function RejectPopup({ onClose, onSubmit, isPending = false, isVatExcluded = false }: RejectPopupProps) {
|
||||||
|
const [selectedReason, setSelectedReason] = useState<string | null>(null)
|
||||||
|
const [customReason, setCustomReason] = useState('')
|
||||||
|
const [price, setPrice] = useState('')
|
||||||
|
const [opinion, setOpinion] = useState('')
|
||||||
|
const [showError, setShowError] = useState(false)
|
||||||
|
|
||||||
|
const isEtcOpen = selectedReason === '기타'
|
||||||
|
const isSubmitDisabled = !selectedReason || (isEtcOpen && !customReason.trim()) || isPending
|
||||||
|
|
||||||
|
const handleReasonClick = (reason: string) => {
|
||||||
|
setShowError(false)
|
||||||
|
if (selectedReason === reason) {
|
||||||
|
setSelectedReason(null)
|
||||||
|
setCustomReason('')
|
||||||
|
} else {
|
||||||
|
setSelectedReason(reason)
|
||||||
|
if (reason !== '기타') setCustomReason('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePriceChange = (value: string) => {
|
||||||
|
const numeric = value.replace(/\D/g, '')
|
||||||
|
if (numeric && parseInt(numeric) > MAX_PRICE) return
|
||||||
|
setPrice(numeric)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (isEtcOpen && !customReason.trim()) {
|
||||||
|
setShowError(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isSubmitDisabled || !selectedReason) return
|
||||||
|
onSubmit({
|
||||||
|
reject_reason: isEtcOpen ? customReason.trim() : selectedReason,
|
||||||
|
// 빈 값이면 아예 보내지 않아 컬럼을 건드리지 않는다.
|
||||||
|
...(price ? { reject_price: parseInt(price) } : {}),
|
||||||
|
...(opinion.trim() ? { opinion: opinion.trim() } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const koreanPrice = price && parseInt(price) > 0 ? `[${numberToKorean(parseInt(price))} 원]` : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose}>
|
||||||
|
<div className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-border p-5">
|
||||||
|
<h3 className="text-base font-bold text-neutral-90">거부 사유를 입력해주세요</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="닫기"
|
||||||
|
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문 */}
|
||||||
|
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{REASONS.map((reason) => (
|
||||||
|
<button
|
||||||
|
key={reason}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleReasonClick(reason)}
|
||||||
|
disabled={isPending}
|
||||||
|
className={cn(
|
||||||
|
'h-11 rounded-xl border text-sm font-bold transition-all active:scale-[0.98] disabled:opacity-50',
|
||||||
|
selectedReason === reason
|
||||||
|
? 'border-brand-600 bg-brand-light text-brand-600'
|
||||||
|
: 'border-border bg-white text-neutral-70 hover:bg-neutral-10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{reason}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEtcOpen && (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
<textarea
|
||||||
|
placeholder="사유를 입력하여 주십시오"
|
||||||
|
value={customReason}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomReason(e.target.value)
|
||||||
|
setShowError(false)
|
||||||
|
}}
|
||||||
|
rows={3}
|
||||||
|
disabled={isPending}
|
||||||
|
className={cn(
|
||||||
|
'w-full resize-none rounded-xl border bg-white p-3 text-sm text-neutral-90 outline-none transition-all',
|
||||||
|
'placeholder:text-neutral-50 focus:ring-1',
|
||||||
|
showError
|
||||||
|
? 'border-destructive focus:border-destructive focus:ring-destructive'
|
||||||
|
: 'border-border focus:border-brand-600 focus:ring-brand-600',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{showError && <p className="mt-1.5 text-xs font-medium text-destructive">기타 사유를 입력해주세요</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 공급 희망 가격 (선택) — 결렬 폼과 같은 라벨·표기 */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm font-bold text-neutral-90">
|
||||||
|
공급 희망 가격 <span className="font-medium text-neutral-60">(선택)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={cn(
|
||||||
|
'h-10 max-w-[200px] rounded-xl border px-3 text-right text-sm outline-none transition-all placeholder:text-neutral-50',
|
||||||
|
isPending
|
||||||
|
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
|
||||||
|
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
|
||||||
|
)}
|
||||||
|
value={price ? parseInt(price).toLocaleString() : ''}
|
||||||
|
onChange={(e) => handlePriceChange(e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
<span className="whitespace-nowrap text-sm text-neutral-70">원{isVatExcluded && '(VAT 별도)'}</span>
|
||||||
|
{koreanPrice && <span className="whitespace-nowrap text-sm text-neutral-50">{koreanPrice}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 의견 (선택) — 결렬 폼과 동일 */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm font-bold text-neutral-90">
|
||||||
|
의견 <span className="font-medium text-neutral-60">(선택)</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={opinion}
|
||||||
|
onChange={(e) => setOpinion(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
maxLength={255}
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder="추가로 남길 의견이 있으면 작성해 주세요."
|
||||||
|
className={cn(
|
||||||
|
'w-full resize-none rounded-xl border px-3 py-2 text-sm outline-none transition-all placeholder:text-neutral-50',
|
||||||
|
isPending
|
||||||
|
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
|
||||||
|
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<div className="flex shrink-0 gap-2 border-t border-border p-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isPending}
|
||||||
|
className="h-11 flex-1 rounded-xl border border-border bg-white text-sm font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitDisabled}
|
||||||
|
className="h-11 flex-1 rounded-xl bg-brand-600 text-sm font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
거부 처리
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -7,3 +7,7 @@ export { Logo } from '@/components/Logo'
|
|||||||
export type { LogoProps, LogoVariant } from '@/components/Logo'
|
export type { LogoProps, LogoVariant } from '@/components/Logo'
|
||||||
export { ErrorPage } from '@/components/ErrorPage'
|
export { ErrorPage } from '@/components/ErrorPage'
|
||||||
export type { ErrorPageProps } from '@/components/ErrorPage'
|
export type { ErrorPageProps } from '@/components/ErrorPage'
|
||||||
|
export { RejectPopup } from '@/components/RejectPopup'
|
||||||
|
export type { RejectPopupProps, RejectSubmitPayload } from '@/components/RejectPopup'
|
||||||
|
export { RejectDetailPopup } from '@/components/RejectDetailPopup'
|
||||||
|
export type { RejectDetailPopupProps } from '@/components/RejectDetailPopup'
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { renderEmphasis } from '@/features/chat/lib/emphasis'
|
|||||||
import { Indicator } from '@/features/chat/components/templates/Indicator'
|
import { Indicator } from '@/features/chat/components/templates/Indicator'
|
||||||
import { Summary } from '@/features/chat/components/templates/Summary'
|
import { Summary } from '@/features/chat/components/templates/Summary'
|
||||||
import { BidSummary } from '@/features/chat/components/templates/BidSummary'
|
import { BidSummary } from '@/features/chat/components/templates/BidSummary'
|
||||||
|
import { RejectSummary } from '@/features/chat/components/templates/RejectSummary'
|
||||||
|
import { RejectedNotice } from '@/features/chat/components/templates/RejectedNotice'
|
||||||
|
|
||||||
const AI_LABEL = '아이마켓코리아 (구매담당자)'
|
const AI_LABEL = '아이마켓코리아 (구매담당자)'
|
||||||
|
|
||||||
@ -57,6 +59,7 @@ function ChatList({ scrollRef }: { scrollRef: RefObject<HTMLDivElement | null> }
|
|||||||
{chats.map((message, index) => (
|
{chats.map((message, index) => (
|
||||||
<MessageItem key={message.chat_id || index} message={message} messages={chats} currentIndex={index} />
|
<MessageItem key={message.chat_id || index} message={message} messages={chats} currentIndex={index} />
|
||||||
))}
|
))}
|
||||||
|
<RejectedNotice />
|
||||||
{isLoading && <TypingBubble />}
|
{isLoading && <TypingBubble />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -87,11 +90,16 @@ const MessageItem = memo(function MessageItem({
|
|||||||
}) {
|
}) {
|
||||||
const isBot = message.sender === 'bot'
|
const isBot = message.sender === 'bot'
|
||||||
|
|
||||||
// 직전이 reject 폼이면 사용자 답변 말풍선은 숨긴다(폼 자체가 답변을 담고 있음)
|
// 결렬 폼 답변은 제출 문자열(공급희망가격-…, 합의불가사유-…)이라 말풍선 대신 요약 카드로 낸다.
|
||||||
|
// 폼은 세션 종료와 함께 사라지므로 이 카드가 없으면 결렬 사유가 대화에 안 남는다.
|
||||||
if (!isBot && currentIndex > 0) {
|
if (!isBot && currentIndex > 0) {
|
||||||
const prev = messages[currentIndex - 1]
|
const prev = messages[currentIndex - 1]
|
||||||
if (prev?.bot_chat_type === 'rejectRSP' || prev?.bot_chat_type === 'rejectCM') {
|
if (prev?.bot_chat_type === 'rejectRSP' || prev?.bot_chat_type === 'rejectCM') {
|
||||||
return null
|
return (
|
||||||
|
<div className="mb-5 animate-fade-in">
|
||||||
|
<RejectSummary script={message.script || ''} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,23 +1,20 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { AlertTriangle } from 'lucide-react'
|
import { AlertTriangle } from 'lucide-react'
|
||||||
import { cn } from '@/lib'
|
import { cn } from '@/lib'
|
||||||
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
|
import { numberToKorean } from '@/lib'
|
||||||
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||||
import { SelectRadio, SubmitButton } from '@/features/chat/components/templates/rejectControls'
|
import { SelectRadio, SubmitButton } from '@/features/chat/components/templates/rejectControls'
|
||||||
import { OtherReason } from '@/features/chat/components/templates/OtherReason'
|
import { OtherReason } from '@/features/chat/components/templates/OtherReason'
|
||||||
import { findRestoreScript, restoreRejectRSP, extractPart } from '@/features/chat/lib/rejectForm'
|
import {
|
||||||
|
findRestoreScript,
|
||||||
|
restoreRejectRSP,
|
||||||
|
extractPart,
|
||||||
|
REJECT_REASONS,
|
||||||
|
} from '@/features/chat/lib/rejectForm'
|
||||||
|
|
||||||
const MAX_PRICE = 999999999999999
|
const MAX_PRICE = 999999999999999
|
||||||
|
|
||||||
// 합의 불가 사유 프리셋 (rejectRSP 와 동일 목록)
|
|
||||||
const REASONS = [
|
|
||||||
{ value: '단가인상', text: "'원재료 가격 상승' 또는 '제조사 가격 인상'으로 요청한 공급가격을 맞출 수 없습니다." },
|
|
||||||
{ value: '수량', text: '주문 수량이 적어, 소량 생산 시 발생하는 제조비용으로 맞출 수 없습니다.' },
|
|
||||||
{ value: '단종', text: '현재 단종된 제품으로 물량 수급이 원활하지 않아 가격을 맞출 수 없습니다.' },
|
|
||||||
{ value: '품절', text: '해당 상품이 품절되어 납품할 수 없습니다.' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// 통일된 협상 결렬 폼 — 최종 제안 단가 + 합의 불가 사유 + 의견.
|
// 통일된 협상 결렬 폼 — 최종 제안 단가 + 합의 불가 사유 + 의견.
|
||||||
// rejectRSP / rejectCM 두 유형 모두 이 폼 하나로 받는다(액션바 렌더).
|
// rejectRSP / rejectCM 두 유형 모두 이 폼 하나로 받는다(액션바 렌더).
|
||||||
export function RejectForm() {
|
export function RejectForm() {
|
||||||
@ -129,7 +126,7 @@ export function RejectForm() {
|
|||||||
<div className="flex w-full gap-3">
|
<div className="flex w-full gap-3">
|
||||||
<div className="w-[100px] shrink-0 pt-2 text-sm font-bold text-neutral-90">합의 불가 사유</div>
|
<div className="w-[100px] shrink-0 pt-2 text-sm font-bold text-neutral-90">합의 불가 사유</div>
|
||||||
<div className="flex w-full flex-col mt-2 gap-2">
|
<div className="flex w-full flex-col mt-2 gap-2">
|
||||||
{REASONS.map((r) => (
|
{REJECT_REASONS.map((r) => (
|
||||||
<SelectRadio
|
<SelectRadio
|
||||||
key={r.value}
|
key={r.value}
|
||||||
text={r.text}
|
text={r.text}
|
||||||
|
|||||||
@ -1,71 +1,25 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { X } from 'lucide-react'
|
import { RejectPopup as RejectPopupForm } from '@/components'
|
||||||
import { Modal } from '@/components'
|
|
||||||
import { getApiErrorMessage, useRejectMutation } from '@/apis'
|
import { getApiErrorMessage, useRejectMutation } from '@/apis'
|
||||||
import { cn, toast } from '@/lib'
|
import { toast } from '@/lib'
|
||||||
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
|
|
||||||
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||||
|
|
||||||
const MAX_PRICE = 999999999999999
|
// 협상 진행 중 거부 — 폼은 목록 거부와 공용이고, 여기서는 대화 세션에 붙여 제출·이탈만 처리한다.
|
||||||
|
|
||||||
// 사유 목록은 목록 화면의 거부 팝업과 동일하게 맞춘다.
|
|
||||||
const REASONS = ['단종', '품절', '기타'] as const
|
|
||||||
|
|
||||||
// 협상 진행 중 거부 팝업 — 목록의 거부 팝업과 같은 어휘·같은 엔드포인트를 쓰고,
|
|
||||||
// 대화 중이라 이미 오간 가격이 있으므로 공급 희망 가격과 의견을 더 받는다.
|
|
||||||
export function RejectPopup({ onClose }: { onClose: () => void }) {
|
export function RejectPopup({ onClose }: { onClose: () => void }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const reject = useRejectMutation()
|
const reject = useRejectMutation()
|
||||||
const sessionId = useChatStore((s) => s.sessionId)
|
const sessionId = useChatStore((s) => s.sessionId)
|
||||||
const itemVatYn = useChatInitStore((s) => s.item_vat_yn)
|
const itemVatYn = useChatInitStore((s) => s.item_vat_yn)
|
||||||
const isVAT = itemVatYn === 'VAT별도'
|
|
||||||
|
|
||||||
const [selectedReason, setSelectedReason] = useState<string | null>(null)
|
|
||||||
const [customReason, setCustomReason] = useState('')
|
|
||||||
const [price, setPrice] = useState('')
|
|
||||||
const [opinion, setOpinion] = useState('')
|
|
||||||
const [showError, setShowError] = useState(false)
|
|
||||||
|
|
||||||
const isEtcOpen = selectedReason === '기타'
|
|
||||||
const isPending = reject.isPending
|
|
||||||
const isSubmitDisabled = !selectedReason || (isEtcOpen && !customReason.trim()) || isPending
|
|
||||||
|
|
||||||
const handleReasonClick = (reason: string) => {
|
|
||||||
setShowError(false)
|
|
||||||
if (selectedReason === reason) {
|
|
||||||
setSelectedReason(null)
|
|
||||||
setCustomReason('')
|
|
||||||
} else {
|
|
||||||
setSelectedReason(reason)
|
|
||||||
if (reason !== '기타') setCustomReason('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePriceChange = (value: string) => {
|
|
||||||
const numeric = value.replace(/\D/g, '')
|
|
||||||
if (numeric && parseInt(numeric) > MAX_PRICE) return
|
|
||||||
setPrice(numeric)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (isEtcOpen && !customReason.trim()) {
|
|
||||||
setShowError(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (isSubmitDisabled || !selectedReason) return
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RejectPopupForm
|
||||||
|
onClose={onClose}
|
||||||
|
isPending={reject.isPending}
|
||||||
|
isVatExcluded={itemVatYn === 'VAT별도'}
|
||||||
|
onSubmit={(request) =>
|
||||||
reject.mutate(
|
reject.mutate(
|
||||||
{
|
{ sessionId, request },
|
||||||
sessionId,
|
|
||||||
request: {
|
|
||||||
reject_reason: isEtcOpen ? customReason.trim() : selectedReason,
|
|
||||||
// 선택 입력 — 빈 값이면 보내지 않아 컬럼을 건드리지 않는다.
|
|
||||||
...(price ? { reject_price: parseInt(price) } : {}),
|
|
||||||
...(opinion.trim() ? { opinion: opinion.trim() } : {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
onClose()
|
onClose()
|
||||||
@ -76,135 +30,6 @@ export function RejectPopup({ onClose }: { onClose: () => void }) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const koreanPrice = price && parseInt(price) > 0 ? `[${numberToKorean(parseInt(price))} 원]` : ''
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal onClose={onClose}>
|
|
||||||
<div className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex shrink-0 items-center justify-between border-b border-border p-5">
|
|
||||||
<h3 className="text-base font-bold text-neutral-90">거부 사유를 입력해주세요</h3>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="닫기"
|
|
||||||
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 본문 */}
|
|
||||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
{REASONS.map((reason) => (
|
|
||||||
<button
|
|
||||||
key={reason}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleReasonClick(reason)}
|
|
||||||
disabled={isPending}
|
|
||||||
className={cn(
|
|
||||||
'h-11 rounded-xl border text-sm font-bold transition-all active:scale-[0.98] disabled:opacity-50',
|
|
||||||
selectedReason === reason
|
|
||||||
? 'border-brand-600 bg-brand-light text-brand-600'
|
|
||||||
: 'border-border bg-white text-neutral-70 hover:bg-neutral-10',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{reason}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isEtcOpen && (
|
|
||||||
<div className="animate-fade-in">
|
|
||||||
<textarea
|
|
||||||
placeholder="사유를 입력하여 주십시오"
|
|
||||||
value={customReason}
|
|
||||||
onChange={(e) => {
|
|
||||||
setCustomReason(e.target.value)
|
|
||||||
setShowError(false)
|
|
||||||
}}
|
|
||||||
rows={3}
|
|
||||||
disabled={isPending}
|
|
||||||
className={cn(
|
|
||||||
'w-full resize-none rounded-xl border bg-white p-3 text-sm text-neutral-90 outline-none transition-all',
|
|
||||||
'placeholder:text-neutral-50 focus:ring-1',
|
|
||||||
showError
|
|
||||||
? 'border-destructive focus:border-destructive focus:ring-destructive'
|
|
||||||
: 'border-border focus:border-brand-600 focus:ring-brand-600',
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
{showError && <p className="mt-1.5 text-xs font-medium text-destructive">기타 사유를 입력해주세요</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 공급 희망 가격 (선택) — 결렬 폼과 같은 라벨·표기 */}
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<div className="text-sm font-bold text-neutral-90">
|
|
||||||
공급 희망 가격 <span className="font-medium text-neutral-60">(선택)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className={cn(
|
|
||||||
'h-10 max-w-[200px] rounded-xl border px-3 text-right text-sm outline-none transition-all placeholder:text-neutral-50',
|
|
||||||
isPending
|
|
||||||
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
|
|
||||||
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
|
|
||||||
)}
|
|
||||||
value={price ? parseInt(price).toLocaleString() : ''}
|
|
||||||
onChange={(e) => handlePriceChange(e.target.value)}
|
|
||||||
placeholder="0"
|
|
||||||
disabled={isPending}
|
|
||||||
/>
|
|
||||||
<span className="whitespace-nowrap text-sm text-neutral-70">원{isVAT && '(VAT 별도)'}</span>
|
|
||||||
{koreanPrice && <span className="whitespace-nowrap text-sm text-neutral-50">{koreanPrice}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 의견 (선택) — 결렬 폼과 동일 */}
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<div className="text-sm font-bold text-neutral-90">
|
|
||||||
의견 <span className="font-medium text-neutral-60">(선택)</span>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={opinion}
|
|
||||||
onChange={(e) => setOpinion(e.target.value)}
|
|
||||||
rows={2}
|
|
||||||
maxLength={255}
|
|
||||||
disabled={isPending}
|
|
||||||
placeholder="추가로 남길 의견이 있으면 작성해 주세요."
|
|
||||||
className={cn(
|
|
||||||
'w-full resize-none rounded-xl border px-3 py-2 text-sm outline-none transition-all placeholder:text-neutral-50',
|
|
||||||
isPending
|
|
||||||
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
|
|
||||||
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 푸터 — 목록 거부 팝업과 동일 규격 */}
|
|
||||||
<div className="flex shrink-0 gap-2 border-t border-border p-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isPending}
|
|
||||||
className="h-11 flex-1 rounded-xl border border-border bg-white text-sm font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98] disabled:opacity-50"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={isSubmitDisabled}
|
|
||||||
className="h-11 flex-1 rounded-xl bg-brand-600 text-sm font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98] disabled:opacity-40"
|
|
||||||
>
|
|
||||||
거부 처리
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { CheckCircle2 } from 'lucide-react'
|
import { CheckCircle2 } from 'lucide-react'
|
||||||
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
|
import { numberToKorean } from '@/lib'
|
||||||
import { useMeQuery } from '@/apis'
|
import { useMeQuery } from '@/apis'
|
||||||
import type { SessionField } from '@/apis/auth/auth.type'
|
import type { SessionField } from '@/apis/auth/auth.type'
|
||||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||||
|
|||||||
@ -0,0 +1,52 @@
|
|||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
import { numberToKorean } from '@/lib'
|
||||||
|
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||||
|
import { parseRejectSubmission } from '@/features/chat/lib/rejectForm'
|
||||||
|
|
||||||
|
// 결렬 제출 요약 — 타결의 BidSummary 와 대칭.
|
||||||
|
export function RejectSummary({ script }: { script: string }) {
|
||||||
|
// VAT 표기는 회사 설정 기준(item_vat_yn). 미관리(hidden)면 비어 라벨을 생략한다.
|
||||||
|
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
|
||||||
|
const { price, reasonLabel, reasonDetail, opinion, extras } = parseRejectSubmission(script)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full rounded-2xl border-2 border-[#FFD9D9] bg-white p-5 shadow-md">
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<AlertTriangle className="size-5 text-[#E5484D]" />
|
||||||
|
<h3 className="text-sm font-bold text-neutral-90">협상 결렬 사유</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-border/60 rounded-xl border border-border">
|
||||||
|
<div className="flex items-start justify-between gap-3 px-4 py-3">
|
||||||
|
<span className="shrink-0 text-sm text-neutral-60">공급 희망 가격</span>
|
||||||
|
<span className="min-w-0 flex-1 break-keep text-right text-sm">
|
||||||
|
<span className="font-bold text-[#E5484D]">{price.toLocaleString()}원</span>
|
||||||
|
<span className="text-neutral-70"> ({numberToKorean(price)}원)</span>
|
||||||
|
{item_vat_yn ? <span className="text-neutral-70"> {item_vat_yn}</span> : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start justify-between gap-3 px-4 py-3">
|
||||||
|
<span className="shrink-0 text-sm text-neutral-60">합의 불가 사유</span>
|
||||||
|
<span className="min-w-0 flex-1 break-keep text-right">
|
||||||
|
<span className="text-sm font-semibold text-neutral-90">{reasonLabel || '-'}</span>
|
||||||
|
{reasonDetail && <span className="mt-1 block text-xs text-neutral-70">{reasonDetail}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{extras.map((e) => (
|
||||||
|
<Row key={e.label} label={e.label} value={e.value} />
|
||||||
|
))}
|
||||||
|
{/* 의견은 타결·결렬 공통 기본 필드 — 값이 없어도 항상 표시 */}
|
||||||
|
<Row label="의견" value={opinion || '-'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-3 px-4 py-3">
|
||||||
|
<span className="shrink-0 text-sm text-neutral-60">{label}</span>
|
||||||
|
<span className="min-w-0 flex-1 break-keep text-right text-sm font-semibold text-neutral-90">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
import { SessionStatus } from '@/apis'
|
||||||
|
import { numberToKorean } from '@/lib'
|
||||||
|
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||||
|
|
||||||
|
// 협상 거부로 끝난 세션의 제출 내역 — 결렬의 RejectSummary 와 같은 카드 규격.
|
||||||
|
// 거부는 팝업으로 내 대화에 남지 않아 대화 끝에 따로 붙인다.
|
||||||
|
export function RejectedNotice() {
|
||||||
|
const sessionStatus = useChatInitStore((s) => s.session_status)
|
||||||
|
const reason = useChatInitStore((s) => s.reject_reason)
|
||||||
|
const price = useChatInitStore((s) => s.reject_price)
|
||||||
|
const custom = useChatInitStore((s) => s.custom)
|
||||||
|
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
|
||||||
|
|
||||||
|
if (sessionStatus !== SessionStatus.REJECTED || !reason) return null
|
||||||
|
|
||||||
|
const opinion = String(custom?.opinion ?? '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 w-full rounded-2xl border-2 border-[#FFD9D9] bg-white p-5 shadow-md">
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<AlertTriangle className="size-5 text-[#E5484D]" />
|
||||||
|
<h3 className="text-sm font-bold text-neutral-90">협상 거부 사유</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-border/60 rounded-xl border border-border">
|
||||||
|
<div className="flex items-start justify-between gap-3 px-4 py-3">
|
||||||
|
<span className="shrink-0 text-sm text-neutral-60">공급 희망 가격</span>
|
||||||
|
<span className="min-w-0 flex-1 break-keep text-right text-sm">
|
||||||
|
{price ? (
|
||||||
|
<>
|
||||||
|
<span className="font-bold text-[#E5484D]">{price.toLocaleString()}원</span>
|
||||||
|
<span className="text-neutral-70"> ({numberToKorean(price)}원)</span>
|
||||||
|
{item_vat_yn ? <span className="text-neutral-70"> {item_vat_yn}</span> : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="font-semibold text-neutral-90">미입력</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Row label="거부 사유" value={reason} />
|
||||||
|
{/* 의견은 타결·결렬·거부 공통 기본 필드 — 값이 없어도 항상 표시 */}
|
||||||
|
<Row label="의견" value={opinion || '-'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-3 px-4 py-3">
|
||||||
|
<span className="shrink-0 text-sm text-neutral-60">{label}</span>
|
||||||
|
<span className="min-w-0 flex-1 break-keep text-right text-sm font-semibold text-neutral-90">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { CheckCircle2 } from 'lucide-react'
|
import { CheckCircle2 } from 'lucide-react'
|
||||||
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
|
import { numberToKorean } from '@/lib'
|
||||||
import { formatLeadTime } from '@/features/chat/lib/format'
|
import { formatLeadTime } from '@/features/chat/lib/format'
|
||||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||||
import type { ChatSummary } from '@/features/chat/types'
|
import type { ChatSummary } from '@/features/chat/types'
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
import type { ChatMessage } from '@/features/chat/types'
|
import type { ChatMessage } from '@/features/chat/types'
|
||||||
|
|
||||||
|
// value = sessions.reject_reason 에 저장되는 값
|
||||||
|
export const REJECT_REASONS = [
|
||||||
|
{ value: '단가인상', text: "'원재료 가격 상승' 또는 '제조사 가격 인상'으로 요청한 공급가격을 맞출 수 없습니다." },
|
||||||
|
{ value: '수량', text: '주문 수량이 적어, 소량 생산 시 발생하는 제조비용으로 맞출 수 없습니다.' },
|
||||||
|
{ value: '단종', text: '현재 단종된 제품으로 물량 수급이 원활하지 않아 가격을 맞출 수 없습니다.' },
|
||||||
|
{ value: '품절', text: '해당 상품이 품절되어 납품할 수 없습니다.' },
|
||||||
|
] as const
|
||||||
|
|
||||||
// reject 폼 직전 사용자 답변(script)을 찾아 복원용으로 반환
|
// reject 폼 직전 사용자 답변(script)을 찾아 복원용으로 반환
|
||||||
export function findRestoreScript(messages: ChatMessage[], type: 'rejectCM' | 'rejectRSP'): string | null {
|
export function findRestoreScript(messages: ChatMessage[], type: 'rejectCM' | 'rejectRSP'): string | null {
|
||||||
const reversed = [...messages].reverse()
|
const reversed = [...messages].reverse()
|
||||||
@ -22,3 +30,28 @@ export function restoreRejectRSP(script: string | null) {
|
|||||||
if (reasonRaw.startsWith('기타-')) return { price, selectedReason: '기타', reason: reasonRaw.replace('기타-', '') }
|
if (reasonRaw.startsWith('기타-')) return { price, selectedReason: '기타', reason: reasonRaw.replace('기타-', '') }
|
||||||
return { price, selectedReason: reasonRaw, reason: '' }
|
return { price, selectedReason: reasonRaw, reason: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 제출 script → 표시값
|
||||||
|
export function parseRejectSubmission(script: string | null) {
|
||||||
|
const { price, selectedReason, reason } = restoreRejectRSP(script)
|
||||||
|
const preset = REJECT_REASONS.find((r) => r.value === selectedReason)
|
||||||
|
return {
|
||||||
|
price: price ? parseInt(price) : 0,
|
||||||
|
reasonLabel: selectedReason,
|
||||||
|
reasonDetail: selectedReason === '기타' ? reason : (preset?.text ?? ''),
|
||||||
|
opinion: extractPart(script, '의견-'),
|
||||||
|
extras: extraParts(script),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 통합 폼 이전 제출분의 '배송형태-' 등. 사유 뒤는 자유서술이 섞여 앞 구간만 훑는다.
|
||||||
|
function extraParts(script: string | null): { label: string; value: string }[] {
|
||||||
|
const head = (script ?? '').split(', 의견-')[0].split(', 합의불가사유-')[0]
|
||||||
|
return head
|
||||||
|
.split(', ')
|
||||||
|
.filter((p) => p && !p.startsWith('공급희망가격-'))
|
||||||
|
.map((p) => {
|
||||||
|
const i = p.indexOf('-')
|
||||||
|
return i > 0 ? { label: p.slice(0, i), value: p.slice(i + 1) } : { label: '항목', value: p }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -32,6 +32,8 @@ const initialState: ChatInitData = {
|
|||||||
quotation_end_time: '',
|
quotation_end_time: '',
|
||||||
custom: {},
|
custom: {},
|
||||||
labels: {},
|
labels: {},
|
||||||
|
reject_reason: '',
|
||||||
|
reject_price: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useChatInitStore = create<ChatInitStore>((set) => ({
|
export const useChatInitStore = create<ChatInitStore>((set) => ({
|
||||||
|
|||||||
@ -86,4 +86,6 @@ export type ChatInitData = {
|
|||||||
quotation_end_time: string
|
quotation_end_time: string
|
||||||
custom: Record<string, unknown> // 협상완료 부가정보 기존 입력값(프리필용)
|
custom: Record<string, unknown> // 협상완료 부가정보 기존 입력값(프리필용)
|
||||||
labels: Record<string, string> // 회사 커스텀 라벨(companies.settings.labels). 상품 상세 필드명 치환용
|
labels: Record<string, string> // 회사 커스텀 라벨(companies.settings.labels). 상품 상세 필드명 치환용
|
||||||
|
reject_reason: string // 협상 거부로 끝난 세션의 제출 사유(의견은 custom.opinion)
|
||||||
|
reject_price: number | null // 거부와 함께 낸 공급 희망 가격(원)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,124 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { X } from 'lucide-react'
|
|
||||||
import { Modal } from '@/components'
|
|
||||||
import { cn } from '@/lib'
|
|
||||||
|
|
||||||
const REASONS = ['단종', '품절', '기타'] as const
|
|
||||||
|
|
||||||
export interface RejectPopupProps {
|
|
||||||
onClose: () => void
|
|
||||||
/** 최종 거부 사유 (프리셋 라벨 또는 기타 입력 텍스트) */
|
|
||||||
onSubmit: (reason: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
// 거부 사유 입력 팝업 (단종/품절/기타).
|
|
||||||
export function RejectPopup({ onClose, onSubmit }: RejectPopupProps) {
|
|
||||||
const [selectedReason, setSelectedReason] = useState<string | null>(null)
|
|
||||||
const [customReason, setCustomReason] = useState('')
|
|
||||||
const [showError, setShowError] = useState(false)
|
|
||||||
|
|
||||||
const isEtcOpen = selectedReason === '기타'
|
|
||||||
const isSubmitDisabled = !selectedReason || (isEtcOpen && !customReason.trim())
|
|
||||||
|
|
||||||
const handleReasonClick = (reason: string) => {
|
|
||||||
setShowError(false)
|
|
||||||
if (selectedReason === reason) {
|
|
||||||
setSelectedReason(null)
|
|
||||||
setCustomReason('')
|
|
||||||
} else {
|
|
||||||
setSelectedReason(reason)
|
|
||||||
if (reason !== '기타') setCustomReason('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (isEtcOpen && !customReason.trim()) {
|
|
||||||
setShowError(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (isSubmitDisabled || !selectedReason) return
|
|
||||||
onSubmit(isEtcOpen ? customReason.trim() : selectedReason)
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal onClose={onClose}>
|
|
||||||
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center justify-between border-b border-border p-5">
|
|
||||||
<h3 className="text-base font-bold text-neutral-90">거부 사유를 입력해주세요</h3>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="닫기"
|
|
||||||
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 본문 */}
|
|
||||||
<div className="space-y-4 p-5">
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
{REASONS.map((reason) => (
|
|
||||||
<button
|
|
||||||
key={reason}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleReasonClick(reason)}
|
|
||||||
className={cn(
|
|
||||||
'h-11 rounded-xl border text-sm font-bold transition-all active:scale-[0.98]',
|
|
||||||
selectedReason === reason
|
|
||||||
? 'border-brand-600 bg-brand-light text-brand-600'
|
|
||||||
: 'border-border bg-white text-neutral-70 hover:bg-neutral-10',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{reason}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isEtcOpen && (
|
|
||||||
<div className="animate-fade-in">
|
|
||||||
<textarea
|
|
||||||
placeholder="사유를 입력하여 주십시오"
|
|
||||||
value={customReason}
|
|
||||||
onChange={(e) => {
|
|
||||||
setCustomReason(e.target.value)
|
|
||||||
setShowError(false)
|
|
||||||
}}
|
|
||||||
rows={3}
|
|
||||||
className={cn(
|
|
||||||
'w-full resize-none rounded-xl border bg-white p-3 text-sm text-neutral-90 outline-none transition-all',
|
|
||||||
'placeholder:text-neutral-50 focus:ring-1',
|
|
||||||
showError
|
|
||||||
? 'border-destructive focus:border-destructive focus:ring-destructive'
|
|
||||||
: 'border-border focus:border-brand-600 focus:ring-brand-600',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{showError && <p className="mt-1.5 text-xs font-medium text-destructive">기타 사유를 입력해주세요</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 푸터 */}
|
|
||||||
<div className="flex gap-2 border-t border-border p-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="h-11 flex-1 rounded-xl border border-border bg-white text-sm font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={isSubmitDisabled}
|
|
||||||
className="h-11 flex-1 rounded-xl bg-brand-600 text-sm font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98] disabled:opacity-40"
|
|
||||||
>
|
|
||||||
거부 처리
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -13,10 +13,11 @@ interface WorkspaceCardsProps {
|
|||||||
onExtraInfo: (item: ListItem) => void
|
onExtraInfo: (item: ListItem) => void
|
||||||
onRenegotiate: (item: ListItem) => void
|
onRenegotiate: (item: ListItem) => void
|
||||||
onMemo: (item: ListItem) => void
|
onMemo: (item: ListItem) => void
|
||||||
|
onRejectDetail: (item: ListItem) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택.
|
// 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택.
|
||||||
export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo }: WorkspaceCardsProps) {
|
export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo, onRejectDetail }: WorkspaceCardsProps) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16">
|
||||||
@ -31,7 +32,7 @@ export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, on
|
|||||||
return (
|
return (
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Card key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} />
|
<Card key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} onRejectDetail={onRejectDetail} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -45,6 +46,7 @@ function Card({
|
|||||||
onExtraInfo,
|
onExtraInfo,
|
||||||
onRenegotiate,
|
onRenegotiate,
|
||||||
onMemo,
|
onMemo,
|
||||||
|
onRejectDetail,
|
||||||
}: {
|
}: {
|
||||||
item: ListItem
|
item: ListItem
|
||||||
busy: boolean
|
busy: boolean
|
||||||
@ -53,11 +55,14 @@ function Card({
|
|||||||
onExtraInfo: (item: ListItem) => void
|
onExtraInfo: (item: ListItem) => void
|
||||||
onRenegotiate: (item: ListItem) => void
|
onRenegotiate: (item: ListItem) => void
|
||||||
onMemo: (item: ListItem) => void
|
onMemo: (item: ListItem) => void
|
||||||
|
onRejectDetail: (item: ListItem) => void
|
||||||
}) {
|
}) {
|
||||||
const meta = statusMeta(item.session_status)
|
const meta = statusMeta(item.session_status)
|
||||||
const ended = isEndedStatus(item.session_status)
|
const ended = isEndedStatus(item.session_status)
|
||||||
const canEnter = canEnterChat(item.session_status, item.hasChat)
|
const canEnter = canEnterChat(item.session_status, item.hasChat)
|
||||||
const canReject = ['협상생성', '협상중'].includes(item.session_status)
|
const canReject = ['협상생성', '협상중'].includes(item.session_status)
|
||||||
|
// 거부 건은 제출 내역이 대화에 남지 않는다 — 세션에 적힌 사유를 여기서만 다시 볼 수 있다.
|
||||||
|
const isRejected = item.session_status === '협상거부'
|
||||||
const isDone = item.session_status === '협상완료'
|
const isDone = item.session_status === '협상완료'
|
||||||
const enterLabel = ended ? '결과 보기' : '협상 입장'
|
const enterLabel = ended ? '결과 보기' : '협상 입장'
|
||||||
// 재협상: 요청 가능하면 버튼, 이미 요청했으면 진행 상태를 보여준다.
|
// 재협상: 요청 가능하면 버튼, 이미 요청했으면 진행 상태를 보여준다.
|
||||||
@ -113,8 +118,17 @@ function Card({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{(canEnter || canReject || isDone || item.renegotiable) && (
|
{(canEnter || canReject || isDone || isRejected || item.renegotiable) && (
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
|
{isRejected && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRejectDetail(item)}
|
||||||
|
className="flex-1 rounded-lg border border-brand-600/40 py-2 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
거부 사유 보기
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{item.renegotiable && (
|
{item.renegotiable && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -14,13 +14,14 @@ interface WorkspaceTableProps {
|
|||||||
onExtraInfo: (item: ListItem) => void
|
onExtraInfo: (item: ListItem) => void
|
||||||
onRenegotiate: (item: ListItem) => void
|
onRenegotiate: (item: ListItem) => void
|
||||||
onMemo: (item: ListItem) => void
|
onMemo: (item: ListItem) => void
|
||||||
|
onRejectDetail: (item: ListItem) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// th 기본 정렬은 center — 정렬은 베이스에 넣지 않고 컬럼마다 명시한다(cn 이 tailwind-merge 가 아니라 충돌 시 승자가 불명확).
|
// th 기본 정렬은 center — 정렬은 베이스에 넣지 않고 컬럼마다 명시한다(cn 이 tailwind-merge 가 아니라 충돌 시 승자가 불명확).
|
||||||
const HEAD = 'px-5 py-3.5 text-[11px] font-bold uppercase tracking-wider text-neutral-60 whitespace-nowrap'
|
const HEAD = 'px-5 py-3.5 text-[11px] font-bold uppercase tracking-wider text-neutral-60 whitespace-nowrap'
|
||||||
const CELL = 'px-5 py-4 align-middle text-sm text-neutral-80'
|
const CELL = 'px-5 py-4 align-middle text-sm text-neutral-80'
|
||||||
|
|
||||||
export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo }: WorkspaceTableProps) {
|
export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo, onRejectDetail }: WorkspaceTableProps) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full overflow-x-auto">
|
<div className="w-full overflow-x-auto">
|
||||||
<table className="w-full min-w-[900px] border-collapse">
|
<table className="w-full min-w-[900px] border-collapse">
|
||||||
@ -46,7 +47,7 @@ export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, on
|
|||||||
</StateRow>
|
</StateRow>
|
||||||
) : (
|
) : (
|
||||||
items.map((item) => (
|
items.map((item) => (
|
||||||
<Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} />
|
<Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} onRejectDetail={onRejectDetail} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -63,6 +64,7 @@ function Row({
|
|||||||
onExtraInfo,
|
onExtraInfo,
|
||||||
onRenegotiate,
|
onRenegotiate,
|
||||||
onMemo,
|
onMemo,
|
||||||
|
onRejectDetail,
|
||||||
}: {
|
}: {
|
||||||
item: ListItem
|
item: ListItem
|
||||||
busy: boolean
|
busy: boolean
|
||||||
@ -71,11 +73,14 @@ function Row({
|
|||||||
onExtraInfo: (item: ListItem) => void
|
onExtraInfo: (item: ListItem) => void
|
||||||
onRenegotiate: (item: ListItem) => void
|
onRenegotiate: (item: ListItem) => void
|
||||||
onMemo: (item: ListItem) => void
|
onMemo: (item: ListItem) => void
|
||||||
|
onRejectDetail: (item: ListItem) => void
|
||||||
}) {
|
}) {
|
||||||
const meta = statusMeta(item.session_status)
|
const meta = statusMeta(item.session_status)
|
||||||
const ended = isEndedStatus(item.session_status)
|
const ended = isEndedStatus(item.session_status)
|
||||||
const canEnter = canEnterChat(item.session_status, item.hasChat)
|
const canEnter = canEnterChat(item.session_status, item.hasChat)
|
||||||
const canReject = ['협상생성', '협상중'].includes(item.session_status)
|
const canReject = ['협상생성', '협상중'].includes(item.session_status)
|
||||||
|
// 거부 건은 제출 내역이 대화에 남지 않는다 — 세션에 적힌 사유를 여기서만 다시 볼 수 있다.
|
||||||
|
const isRejected = item.session_status === '협상거부'
|
||||||
const isDone = item.session_status === '협상완료'
|
const isDone = item.session_status === '협상완료'
|
||||||
const enterLabel = ended ? '결과 보기' : '협상 입장'
|
const enterLabel = ended ? '결과 보기' : '협상 입장'
|
||||||
|
|
||||||
@ -145,6 +150,15 @@ function Row({
|
|||||||
부가정보 보기
|
부가정보 보기
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isRejected && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRejectDetail(item)}
|
||||||
|
className="rounded-lg border border-brand-600/40 px-3 py-1.5 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
거부 사유 보기
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{canReject && (
|
{canReject && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
useRequestRenegotiationMutation,
|
useRequestRenegotiationMutation,
|
||||||
useSaveExtraInfoMutation,
|
useSaveExtraInfoMutation,
|
||||||
} from '@/apis'
|
} from '@/apis'
|
||||||
|
import { RejectDetailPopup, RejectPopup, type RejectSubmitPayload } from '@/components'
|
||||||
import { cn, toast } from '@/lib'
|
import { cn, toast } from '@/lib'
|
||||||
import { useList } from '@/features/list/hooks/useList'
|
import { useList } from '@/features/list/hooks/useList'
|
||||||
import { useListStore } from '@/features/list/stores/useListStore'
|
import { useListStore } from '@/features/list/stores/useListStore'
|
||||||
@ -19,7 +20,6 @@ import { SortControl } from '@/features/list/components/SortControl'
|
|||||||
import { WorkspaceTable } from '@/features/list/components/WorkspaceTable'
|
import { WorkspaceTable } from '@/features/list/components/WorkspaceTable'
|
||||||
import { WorkspaceCards } from '@/features/list/components/WorkspaceCards'
|
import { WorkspaceCards } from '@/features/list/components/WorkspaceCards'
|
||||||
import { Pagination } from '@/features/list/components/Pagination'
|
import { Pagination } from '@/features/list/components/Pagination'
|
||||||
import { RejectPopup } from '@/features/list/components/RejectPopup'
|
|
||||||
import { ExtraInfoPopup } from '@/features/list/components/ExtraInfoPopup'
|
import { ExtraInfoPopup } from '@/features/list/components/ExtraInfoPopup'
|
||||||
import { RenegotiationPopup } from '@/features/list/components/RenegotiationPopup'
|
import { RenegotiationPopup } from '@/features/list/components/RenegotiationPopup'
|
||||||
import { RenegotiationMemoPopup } from '@/features/list/components/RenegotiationMemoPopup'
|
import { RenegotiationMemoPopup } from '@/features/list/components/RenegotiationMemoPopup'
|
||||||
@ -68,6 +68,7 @@ export function ListWorkspace() {
|
|||||||
const [extraTarget, setExtraTarget] = useState<ListItem | null>(null)
|
const [extraTarget, setExtraTarget] = useState<ListItem | null>(null)
|
||||||
const [renegoTarget, setRenegoTarget] = useState<ListItem | null>(null)
|
const [renegoTarget, setRenegoTarget] = useState<ListItem | null>(null)
|
||||||
const [memoTarget, setMemoTarget] = useState<ListItem | null>(null)
|
const [memoTarget, setMemoTarget] = useState<ListItem | null>(null)
|
||||||
|
const [rejectDetailTarget, setRejectDetailTarget] = useState<ListItem | null>(null)
|
||||||
const [guideOpen, setGuideOpen] = useState(false)
|
const [guideOpen, setGuideOpen] = useState(false)
|
||||||
|
|
||||||
const handleEnter = (item: ListItem) => {
|
const handleEnter = (item: ListItem) => {
|
||||||
@ -95,12 +96,15 @@ export function ListWorkspace() {
|
|||||||
setRejectTarget(item)
|
setRejectTarget(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRejectSubmit = (reason: string) => {
|
const handleRejectSubmit = (request: RejectSubmitPayload) => {
|
||||||
if (!rejectTarget) return
|
if (!rejectTarget) return
|
||||||
reject.mutate(
|
reject.mutate(
|
||||||
{ sessionId: rejectTarget.session_id, request: { reject_reason: reason } },
|
{ sessionId: rejectTarget.session_id, request },
|
||||||
{
|
{
|
||||||
onSuccess: () => toast.warning('협상 거부가 완료되었습니다.'),
|
onSuccess: () => {
|
||||||
|
setRejectTarget(null)
|
||||||
|
toast.warning('협상 거부가 완료되었습니다.')
|
||||||
|
},
|
||||||
onError: (error) => toast.error(getApiErrorMessage(error, '거부 처리에 실패했습니다.')),
|
onError: (error) => toast.error(getApiErrorMessage(error, '거부 처리에 실패했습니다.')),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -190,6 +194,7 @@ export function ListWorkspace() {
|
|||||||
onExtraInfo={setExtraTarget}
|
onExtraInfo={setExtraTarget}
|
||||||
onRenegotiate={setRenegoTarget}
|
onRenegotiate={setRenegoTarget}
|
||||||
onMemo={setMemoTarget}
|
onMemo={setMemoTarget}
|
||||||
|
onRejectDetail={setRejectDetailTarget}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
@ -202,6 +207,7 @@ export function ListWorkspace() {
|
|||||||
onExtraInfo={setExtraTarget}
|
onExtraInfo={setExtraTarget}
|
||||||
onRenegotiate={setRenegoTarget}
|
onRenegotiate={setRenegoTarget}
|
||||||
onMemo={setMemoTarget}
|
onMemo={setMemoTarget}
|
||||||
|
onRejectDetail={setRejectDetailTarget}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -213,7 +219,11 @@ export function ListWorkspace() {
|
|||||||
{guideOpen && <GuidePopup onClose={() => setGuideOpen(false)} />}
|
{guideOpen && <GuidePopup onClose={() => setGuideOpen(false)} />}
|
||||||
|
|
||||||
{rejectTarget && (
|
{rejectTarget && (
|
||||||
<RejectPopup onClose={() => setRejectTarget(null)} onSubmit={handleRejectSubmit} />
|
<RejectPopup
|
||||||
|
onClose={() => setRejectTarget(null)}
|
||||||
|
onSubmit={handleRejectSubmit}
|
||||||
|
isPending={reject.isPending}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{extraTarget && (
|
{extraTarget && (
|
||||||
@ -228,6 +238,16 @@ export function ListWorkspace() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{rejectDetailTarget && (
|
||||||
|
<RejectDetailPopup
|
||||||
|
onClose={() => setRejectDetailTarget(null)}
|
||||||
|
subtitle={`${rejectDetailTarget.qt_number} · ${rejectDetailTarget.item_name}`}
|
||||||
|
reason={rejectDetailTarget.rejectReason}
|
||||||
|
price={rejectDetailTarget.rejectPrice}
|
||||||
|
opinion={String(rejectDetailTarget.custom?.opinion ?? '')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{memoTarget && (
|
{memoTarget && (
|
||||||
<RenegotiationMemoPopup target={memoTarget} onClose={() => setMemoTarget(null)} />
|
<RenegotiationMemoPopup target={memoTarget} onClose={() => setMemoTarget(null)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -43,5 +43,7 @@ export function toListItem(api: SessionListItem): ListItem {
|
|||||||
renegotiationMemo: api.renegotiation_memo ?? '',
|
renegotiationMemo: api.renegotiation_memo ?? '',
|
||||||
result: api.result ?? 0,
|
result: api.result ?? 0,
|
||||||
hasChat: api.has_chat ?? false,
|
hasChat: api.has_chat ?? false,
|
||||||
|
rejectReason: api.reject_reason ?? '',
|
||||||
|
rejectPrice: api.reject_price ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,4 +14,6 @@ export type ListItem = {
|
|||||||
renegotiationMemo: string // 담당자 심사 메모(반려 사유)
|
renegotiationMemo: string // 담당자 심사 메모(반려 사유)
|
||||||
result: number // 협상 결과: 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
|
result: number // 협상 결과: 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
|
||||||
hasChat: boolean // 대화 이력 존재 — 종료된 협상의 '결과 보기' 노출 조건
|
hasChat: boolean // 대화 이력 존재 — 종료된 협상의 '결과 보기' 노출 조건
|
||||||
|
rejectReason: string // 협상 거부로 끝난 건이 제출한 사유. 거부 건이 아니면 ''
|
||||||
|
rejectPrice: number | null // 거부와 함께 낸 공급 희망 가격(원)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,3 +4,4 @@ export type { ClassValue } from '@/lib/cn'
|
|||||||
export { interactive } from '@/lib/interactive'
|
export { interactive } from '@/lib/interactive'
|
||||||
export { toast } from '@/lib/toast'
|
export { toast } from '@/lib/toast'
|
||||||
export { formatKstDateTime, KST_TIME_ZONE } from '@/lib/datetime'
|
export { formatKstDateTime, KST_TIME_ZONE } from '@/lib/datetime'
|
||||||
|
export { numberToKorean } from '@/lib/koreanNumber'
|
||||||
|
|||||||
@ -15,6 +15,10 @@ _EXPERIENCE_LOGS = table(
|
|||||||
column("company_id"), column("session_id"), column("card_id"), column("reward"),
|
column("company_id"), column("session_id"), column("card_id"), column("reward"),
|
||||||
column("settled_price"), column("is_invalidated"), column("created_at"),
|
column("settled_price"), column("is_invalidated"), column("created_at"),
|
||||||
column("turn"),
|
column("turn"),
|
||||||
|
# 선택 시점 박제값 — 카드가 왜 뽑혔는지의 근거. UCB = Q + 탐색보너스 라
|
||||||
|
# 둘의 차이가 "학습으로 뽑혔나 / 덜 써봐서 뽑혔나"를 가른다.
|
||||||
|
column("q_value_at_selection"), column("ucb_score_at_selection"),
|
||||||
|
column("visit_count_at_selection"),
|
||||||
schema="learning",
|
schema="learning",
|
||||||
)
|
)
|
||||||
_ANCHORING_CURRENT = table(
|
_ANCHORING_CURRENT = table(
|
||||||
@ -78,11 +82,16 @@ class LearningCRUD(ILearningCRUD):
|
|||||||
return ErrorType.SUCCESS, tuple(rows[0])
|
return ErrorType.SUCCESS, tuple(rows[0])
|
||||||
|
|
||||||
async def card_usage(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]:
|
async def card_usage(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]:
|
||||||
"""카드별 사용 현황 — (카드번호, 사용 협상 수, 사용 횟수, 평균 라운드, 마지막 사용).
|
"""카드별 사용 현황 — (카드번호, 사용 협상 수, 사용 횟수, 평균 라운드, 마지막 사용,
|
||||||
|
평균 Q값, 평균 UCB, 누적 방문수).
|
||||||
|
|
||||||
협상 결과(타결·가격)는 카드 한 장의 성과로 나눌 수 없어 다루지 않는다 — 한 협상에 여러 장이
|
협상 결과(타결·가격)는 카드 한 장의 성과로 나눌 수 없어 다루지 않는다 — 한 협상에 여러 장이
|
||||||
나가 어느 장의 몫인지 가릴 근거가 없고, 카드 배정도 국면에 따라 정해져 무작위가 아니다.
|
나가 어느 장의 몫인지 가릴 근거가 없고, 카드 배정도 국면에 따라 정해져 무작위가 아니다.
|
||||||
평균 라운드 = 그 카드가 협상의 몇 번째 라운드에 나갔는지(설정한 국면과 실제가 맞는지 대조).
|
평균 라운드 = 그 카드가 협상의 몇 번째 라운드에 나갔는지(설정한 국면과 실제가 맞는지 대조).
|
||||||
|
|
||||||
|
Q값/방문수는 선택 시점 박제값(experience_logs)이라 q_values 테이블을 따로 안 읽는다 —
|
||||||
|
"그때 무슨 근거로 뽑혔나"가 사후 현재값보다 사용 현황에 맞는 기준이다.
|
||||||
|
방문수는 마지막 선택 시점 누적값(단조 증가라 max 가 곧 최신).
|
||||||
"""
|
"""
|
||||||
query = (
|
query = (
|
||||||
select(
|
select(
|
||||||
@ -91,6 +100,9 @@ class LearningCRUD(ILearningCRUD):
|
|||||||
func.count(),
|
func.count(),
|
||||||
func.avg(_EXPERIENCE_LOGS.c.turn),
|
func.avg(_EXPERIENCE_LOGS.c.turn),
|
||||||
func.max(_EXPERIENCE_LOGS.c.created_at),
|
func.max(_EXPERIENCE_LOGS.c.created_at),
|
||||||
|
func.avg(_EXPERIENCE_LOGS.c.q_value_at_selection),
|
||||||
|
func.avg(_EXPERIENCE_LOGS.c.ucb_score_at_selection),
|
||||||
|
func.max(_EXPERIENCE_LOGS.c.visit_count_at_selection),
|
||||||
)
|
)
|
||||||
.where(and_(*_valid(company_id), _EXPERIENCE_LOGS.c.card_id.isnot(None)))
|
.where(and_(*_valid(company_id), _EXPERIENCE_LOGS.c.card_id.isnot(None)))
|
||||||
.group_by(_EXPERIENCE_LOGS.c.card_id)
|
.group_by(_EXPERIENCE_LOGS.c.card_id)
|
||||||
|
|||||||
@ -2,7 +2,8 @@ from abc import ABC, abstractmethod
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
from sqlalchemy import select, func, and_, or_, update
|
from sqlalchemy import select, func, and_, or_, update, cast, text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
@ -79,6 +80,10 @@ class IQuotationCRUD(ABC):
|
|||||||
async def update_sessions_status(self, cdb: AsyncSession, qt_id, from_statuses: list[int], to_status: int) -> ErrorType:
|
async def update_sessions_status(self, cdb: AsyncSession, qt_id, from_statuses: list[int], to_status: int) -> ErrorType:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def merge_session_custom(self, cdb: AsyncSession, session_id, patch: dict) -> ErrorType:
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType:
|
async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType:
|
||||||
"""견적과 연결된 하위 데이터(세션·대화)를 함께 소프트 삭제."""
|
"""견적과 연결된 하위 데이터(세션·대화)를 함께 소프트 삭제."""
|
||||||
@ -571,6 +576,22 @@ class QuotationCRUD(IQuotationCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED
|
return ErrorType.DB_RUN_FAILED
|
||||||
|
|
||||||
|
async def merge_session_custom(self, cdb: AsyncSession, session_id, patch: dict) -> ErrorType:
|
||||||
|
# sessions.custom 부분 갱신(기존 키 보존). 부가정보·의견·재협상 요청이 같은 컬럼을 쓰므로 덮어쓰면 안 된다.
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
update(sessions)
|
||||||
|
.where(sessions.session_id == session_id)
|
||||||
|
.values(
|
||||||
|
custom=func.coalesce(sessions.custom, cast(text("'{}'"), JSONB)).op("||")(cast(patch, JSONB)),
|
||||||
|
updated_at=GTime.UTC(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return await DB_SESSION_MNG.add(cdb, query)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED
|
||||||
|
|
||||||
async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType:
|
async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType:
|
||||||
"""견적 + 연결된 하위 데이터(협상 세션·대화)를 한 트랜잭션으로 소프트 삭제한다.
|
"""견적 + 연결된 하위 데이터(협상 세션·대화)를 한 트랜잭션으로 소프트 삭제한다.
|
||||||
대화(chats)→세션(sessions)→견적(quotations) 순서로 deleted=True. 한 스텝이라도 실패하면 execute_lambda_run 이 롤백한다."""
|
대화(chats)→세션(sessions)→견적(quotations) 순서로 deleted=True. 한 스텝이라도 실패하면 execute_lambda_run 이 롤백한다."""
|
||||||
@ -703,14 +724,16 @@ class QuotationCRUD(IQuotationCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, 0
|
return ErrorType.DB_RUN_FAILED, 0
|
||||||
|
|
||||||
async def list_sessions_status(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]:
|
async def list_sessions_status(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]:
|
||||||
"""[마감 판정] 견적의 모든 세션 → (status, supplier_id, bid_price, name, target_price, anchoring_price, reject_price). 삭제 제외.
|
"""[마감 판정] 견적의 모든 세션 → (status, supplier_id, bid_price, name, target_price, anchoring_price, reject_price, session_id). 삭제 제외.
|
||||||
공급사가 지워졌어도 세션 집계엔 포함되도록 outerjoin(이때 name 은 None).
|
공급사가 지워졌어도 세션 집계엔 포함되도록 outerjoin(이때 name 은 None).
|
||||||
target/anchoring 은 마감 가격게이트 입력(견적당 상품 1개라 세션 공통값).
|
target/anchoring 은 마감 가격게이트 입력(견적당 상품 1개라 세션 공통값).
|
||||||
reject_price 는 거부 협력사의 공급 희망 가격 — 자동 마감 판정엔 안 쓰고 수동 직접 낙찰 후보에서만 본다."""
|
reject_price 는 거부 협력사의 공급 희망 가격 — 자동 마감 판정엔 안 쓰고 화면 표기에만 쓴다.
|
||||||
|
session_id 는 직접 낙찰이 계약가를 그 세션 custom 에 적을 때 쓴다."""
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
select(sessions.status, sessions.supplier_id, sessions.bid_price, suppliers.name,
|
select(sessions.status, sessions.supplier_id, sessions.bid_price, suppliers.name,
|
||||||
sessions.target_price, sessions.anchoring_price, sessions.reject_price)
|
sessions.target_price, sessions.anchoring_price, sessions.reject_price,
|
||||||
|
sessions.session_id)
|
||||||
.outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id)
|
.outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id)
|
||||||
.where(sessions.quotation_id == qt_id, sessions.deleted == False) # noqa: E712
|
.where(sessions.quotation_id == qt_id, sessions.deleted == False) # noqa: E712
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
from sqlalchemy import select, func, and_, or_, case
|
from sqlalchemy import select, func, and_, or_, case, cast, BigInteger
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@ -76,10 +76,12 @@ class StatisticsCRUD(IStatisticsCRUD):
|
|||||||
async def winning_sessions(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
async def winning_sessions(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||||
# 낙찰 마감 견적의 '낙찰 세션'(supplier_id=preferred_sp_id) 행 — 절감/추이/유형/카테고리/앵커도달률의 단일 원천.
|
# 낙찰 마감 견적의 '낙찰 세션'(supplier_id=preferred_sp_id) 행 — 절감/추이/유형/카테고리/앵커도달률의 단일 원천.
|
||||||
# 파생: 저장 안 하고 조회 때 조인. category 는 items LEFT JOIN(자유텍스트·NULL 허용).
|
# 파생: 저장 안 하고 조회 때 조인. category 는 items LEFT JOIN(자유텍스트·NULL 허용).
|
||||||
# 계약가는 coalesce(투찰가, 거부 시 공급 희망가) — 결렬 건을 담당자가 직접 낙찰하면 bid_price 가 없어
|
# 계약가 = 담당자가 확정한 값이 있으면 그 값, 없으면 협력사 투찰가.
|
||||||
# 절감 집계에서 통째로 빠지기 때문. 이름은 bid_price 로 유지해 statistics_service 는 그대로 쓴다.
|
# 결렬·미응찰 건을 오프라인으로 다시 협상하고 직접 낙찰하면 시스템 투찰가가 없거나 실제 계약가와
|
||||||
|
# 다르기 때문. 컬럼 이름은 bid_price 로 유지해 statistics_service 는 그대로 쓴다.
|
||||||
try:
|
try:
|
||||||
award_price = func.coalesce(sessions.bid_price, sessions.reject_price).label("bid_price")
|
offline_price = cast(sessions.custom["offline_award"]["price"].astext, BigInteger)
|
||||||
|
award_price = func.coalesce(offline_price, sessions.bid_price).label("bid_price")
|
||||||
stmt = (
|
stmt = (
|
||||||
select(
|
select(
|
||||||
quotations.updated_at,
|
quotations.updated_at,
|
||||||
@ -88,6 +90,7 @@ class StatisticsCRUD(IStatisticsCRUD):
|
|||||||
sessions.target_price,
|
sessions.target_price,
|
||||||
award_price,
|
award_price,
|
||||||
sessions.anchoring_price,
|
sessions.anchoring_price,
|
||||||
|
offline_price.isnot(None).label("is_offline"), # 오프라인 협상 반영 건수 표기용
|
||||||
)
|
)
|
||||||
.select_from(quotations)
|
.select_from(quotations)
|
||||||
.join(
|
.join(
|
||||||
@ -95,7 +98,7 @@ class StatisticsCRUD(IStatisticsCRUD):
|
|||||||
and_(
|
and_(
|
||||||
sessions.quotation_id == quotations.qt_id,
|
sessions.quotation_id == quotations.qt_id,
|
||||||
sessions.supplier_id == quotations.preferred_sp_id,
|
sessions.supplier_id == quotations.preferred_sp_id,
|
||||||
or_(sessions.bid_price.isnot(None), sessions.reject_price.isnot(None)),
|
or_(sessions.bid_price.isnot(None), offline_price.isnot(None)),
|
||||||
sessions.deleted == False, # noqa: E712
|
sessions.deleted == False, # noqa: E712
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -29,6 +29,11 @@ class CardUsageRow(LearningProtocol):
|
|||||||
share: float = 0.0 # 전체 카드 사용 중 이 카드의 비중
|
share: float = 0.0 # 전체 카드 사용 중 이 카드의 비중
|
||||||
avg_turn: float = 0.0 # 평균 몇 번째 라운드에 나갔는지 — 설정한 국면과 대조용
|
avg_turn: float = 0.0 # 평균 몇 번째 라운드에 나갔는지 — 설정한 국면과 대조용
|
||||||
last_used_at: Optional[datetime] = None
|
last_used_at: Optional[datetime] = None
|
||||||
|
# 선택 근거 — UCB = Q + 탐색보너스. 봇은 이 점수가 가장 높은 카드를 고른다.
|
||||||
|
avg_q: Optional[float] = None # 선택 시점 Q값 평균 — 학습이 매긴 이 카드의 값어치
|
||||||
|
avg_ucb: Optional[float] = None # 선택 시점 UCB 평균
|
||||||
|
explore_bonus: Optional[float] = None # avg_ucb - avg_q — 클수록 '아직 덜 검증돼서' 뽑힌 것
|
||||||
|
visits: int = 0 # 마지막 선택 시점 누적 방문수 — 적으면 Q값이 아직 추측
|
||||||
|
|
||||||
|
|
||||||
class AnchoringCell(LearningProtocol):
|
class AnchoringCell(LearningProtocol):
|
||||||
|
|||||||
@ -46,7 +46,9 @@ class Req_RegenerateQuotation(QuotationProtocol):
|
|||||||
|
|
||||||
|
|
||||||
class Req_AwardQuotation(QuotationProtocol):
|
class Req_AwardQuotation(QuotationProtocol):
|
||||||
winner_supplier_id: uuid.UUID # 담당자가 직접 낙찰시킬 협력사(투찰한 협상완료 세션 중 선택)
|
winner_supplier_id: uuid.UUID # 담당자가 직접 낙찰시킬 협력사(견적에 초청된 협력사 중 선택)
|
||||||
|
contract_price: int # 최종 계약가(원). 오프라인 협상 결과를 담당자가 확정해 넣는다
|
||||||
|
contract_note: Optional[str] = None # 계약가 근거 메모(오프라인 협상 요약)
|
||||||
|
|
||||||
|
|
||||||
class QuotationData(WebPacketProtocol):
|
class QuotationData(WebPacketProtocol):
|
||||||
@ -110,6 +112,7 @@ class SessionData(WebPacketProtocol):
|
|||||||
qt_type: QuotationType
|
qt_type: QuotationType
|
||||||
target_price: int
|
target_price: int
|
||||||
anchoring_price: Optional[int] = None # 앵커링가(원). 목표가×(1000−anchoring_value)//1000
|
anchoring_price: Optional[int] = None # 앵커링가(원). 목표가×(1000−anchoring_value)//1000
|
||||||
|
done_ceiling_price: Optional[int] = None # 완료 상한가(원). 생성 시 박제 = 목표가×(1+완료상한율/1000)
|
||||||
status: SessionStatus
|
status: SessionStatus
|
||||||
bid_price: Optional[int] = None
|
bid_price: Optional[int] = None
|
||||||
bid_at: Optional[datetime] = None
|
bid_at: Optional[datetime] = None
|
||||||
|
|||||||
@ -67,7 +67,10 @@ async def award_quotation(
|
|||||||
):
|
):
|
||||||
# 권한(본인 견적만/OWNER 예외)은 서비스에서 판정하도록 호출자 user_id·role 을 넘긴다.
|
# 권한(본인 견적만/OWNER 예외)은 서비스에서 판정하도록 호출자 user_id·role 을 넘긴다.
|
||||||
return RemoveNoneResponse(
|
return RemoveNoneResponse(
|
||||||
await service.award_quotation(str(qt_id), user_info.company_id, user_info.user_id, user_info.role, req.winner_supplier_id)
|
await service.award_quotation(
|
||||||
|
str(qt_id), user_info.company_id, user_info.user_id, user_info.role,
|
||||||
|
req.winner_supplier_id, req.contract_price, req.contract_note,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ class StatKpi(WebPacketProtocol):
|
|||||||
closed_count: int = 0 # 마감 견적 수(창)
|
closed_count: int = 0 # 마감 견적 수(창)
|
||||||
regen_avg_round: float = 0.0 # 평균 재견적 라운드
|
regen_avg_round: float = 0.0 # 평균 재견적 라운드
|
||||||
markup_suppression_rate: float = 0.0 # 인상억제율(재협상: 직전 라운드 투찰가 대비 이번 투찰가 인하율, 파생)
|
markup_suppression_rate: float = 0.0 # 인상억제율(재협상: 직전 라운드 투찰가 대비 이번 투찰가 인하율, 파생)
|
||||||
|
offline_award_count: int = 0 # 낙찰 중 오프라인 협상 결과를 담당자가 반영한 건수(절감액엔 포함)
|
||||||
|
|
||||||
|
|
||||||
class StatMonthPoint(WebPacketProtocol):
|
class StatMonthPoint(WebPacketProtocol):
|
||||||
|
|||||||
@ -59,8 +59,8 @@ class LearningService:
|
|||||||
names = await self._read(lambda s: self.crud.card_names(s), default=[])
|
names = await self._read(lambda s: self.crud.card_names(s), default=[])
|
||||||
name_map = {str(number): (name, is_wild, card_pk) for number, name, is_wild, card_pk in names if number}
|
name_map = {str(number): (name, is_wild, card_pk) for number, name, is_wild, card_pk in names if number}
|
||||||
|
|
||||||
total_uses = sum(int(uses or 0) for _n, _s, uses, _t, _l in rows) or 1
|
total_uses = sum(int(r[2] or 0) for r in rows) or 1
|
||||||
for card_id, used_sessions, uses, avg_turn, last_used in rows:
|
for card_id, used_sessions, uses, avg_turn, last_used, avg_q, avg_ucb, visits in rows:
|
||||||
number = str(card_id)
|
number = str(card_id)
|
||||||
name, is_wild, card_pk = name_map.get(number, (None, 0, None))
|
name, is_wild, card_pk = name_map.get(number, (None, 0, None))
|
||||||
res.cards.append(CardUsageRow(
|
res.cards.append(CardUsageRow(
|
||||||
@ -73,6 +73,12 @@ class LearningService:
|
|||||||
share=round(int(uses or 0) / total_uses, 3),
|
share=round(int(uses or 0) / total_uses, 3),
|
||||||
avg_turn=round(float(avg_turn), 1) if avg_turn is not None else 0.0,
|
avg_turn=round(float(avg_turn), 1) if avg_turn is not None else 0.0,
|
||||||
last_used_at=last_used,
|
last_used_at=last_used,
|
||||||
|
avg_q=round(float(avg_q), 4) if avg_q is not None else None,
|
||||||
|
avg_ucb=round(float(avg_ucb), 4) if avg_ucb is not None else None,
|
||||||
|
# 탐색보너스는 두 값이 다 있을 때만 — 한쪽만 있으면 차이가 의미를 잃는다.
|
||||||
|
explore_bonus=(round(float(avg_ucb) - float(avg_q), 4)
|
||||||
|
if avg_q is not None and avg_ucb is not None else None),
|
||||||
|
visits=int(visits or 0),
|
||||||
))
|
))
|
||||||
|
|
||||||
res.kpi = LearningKpi(
|
res.kpi = LearningKpi(
|
||||||
|
|||||||
@ -6,21 +6,12 @@ from common.authz import is_owner_or_admin
|
|||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import quotations, sessions
|
from common.database.model.models import quotations, sessions
|
||||||
from common.enums import CloseOutcome, CloseReason, DBWRType, ErrorType, NotificationType, PriceGateAction, QuotationStatus, SessionStatus
|
from common.enums import CloseOutcome, CloseReason, DBWRType, ErrorType, NotificationType, PriceGateAction, QuotationStatus, SessionStatus
|
||||||
|
from common.logger import LOG
|
||||||
|
from common.utils.gtime import GTime
|
||||||
from router.v1.quotation.protocol import Res_Quotation
|
from router.v1.quotation.protocol import Res_Quotation
|
||||||
from services.notification import create_notification
|
from services.notification import create_notification
|
||||||
|
|
||||||
|
|
||||||
def _award_price(row) -> Optional[int]:
|
|
||||||
"""직접 낙찰 시 계약 기준가 — 타결했으면 투찰가, 협상이 거부로 끝났으면 그때 써낸 공급 희망 가격.
|
|
||||||
둘 다 없으면(가격을 한 번도 안 낸 협력사) None → 낙찰 후보가 아니다.
|
|
||||||
자동 마감 판정(close_and_decide)은 이 함수를 쓰지 않는다 — 자동 낙찰은 투찰가만 본다."""
|
|
||||||
if row.status == SessionStatus.DONE.value and row.bid_price is not None:
|
|
||||||
return int(row.bid_price)
|
|
||||||
if row.status == SessionStatus.REJECTED.value and row.reject_price is not None:
|
|
||||||
return int(row.reject_price)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class ClosingMixin:
|
class ClosingMixin:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _pick_winner(done_rows) -> tuple[Optional[dict], Optional[dict]]:
|
def _pick_winner(done_rows) -> tuple[Optional[dict], Optional[dict]]:
|
||||||
@ -168,17 +159,25 @@ class ClosingMixin:
|
|||||||
await self.close_and_decide(qt_uuid)
|
await self.close_and_decide(qt_uuid)
|
||||||
return await self.get_quotation(qt_id, company_id)
|
return await self.get_quotation(qt_id, company_id)
|
||||||
|
|
||||||
async def award_quotation(self, qt_id: str, company_id, user_id, role, winner_supplier_id) -> Res_Quotation:
|
async def award_quotation(
|
||||||
|
self, qt_id: str, company_id, user_id, role, winner_supplier_id,
|
||||||
|
contract_price: int, contract_note: Optional[str] = None,
|
||||||
|
) -> Res_Quotation:
|
||||||
"""[프론트] 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리한다.
|
"""[프론트] 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리한다.
|
||||||
가격을 써낸 세션 중 고른 협력사를 낙찰자로 박고 close_reason 을 AWARDED 로 바꾼다(직접 낙찰).
|
협상이 결렬·미응찰로 끝나면 오프라인으로 다시 협상하고 그 결과를 여기서 반영한다 — 그래서
|
||||||
후보는 투찰한 협상완료(DONE) + 공급 희망 가격을 남긴 협상거부(REJECTED) — 협상이 결렬돼도
|
후보는 견적에 초청된 협력사 전부이고, 계약가는 담당자가 확정해 넣는다(시스템 제출가와 다를 수 있다).
|
||||||
마지막에 제출한 가격으로 계약을 진행하는 운영 방침을 시스템에서 그대로 처리하기 위함이다.
|
계약가는 sessions.custom.offline_award 에 근거 메모·작성자·시각과 함께 남겨 통계가 이 값을 계약가로 읽는다.
|
||||||
자동 낙찰(close_and_decide)과 결과 컬럼은 같되, 알림에 manual 플래그로 '직접 낙찰'임을 남긴다.
|
자동 낙찰(close_and_decide)과 결과 컬럼은 같되, 알림에 manual 플래그로 '직접 낙찰'임을 남긴다.
|
||||||
권한: 본인이 생성한 견적만. 단 최고관리자(OWNER)는 회사 내 남의 견적도 낙찰할 수 있다."""
|
권한: 본인이 생성한 견적만. 단 최고관리자(OWNER)는 회사 내 남의 견적도 낙찰할 수 있다."""
|
||||||
res = Res_Quotation()
|
res = Res_Quotation()
|
||||||
qt_uuid = uuid.UUID(qt_id)
|
qt_uuid = uuid.UUID(qt_id)
|
||||||
sp_uuid = winner_supplier_id if isinstance(winner_supplier_id, uuid.UUID) else uuid.UUID(str(winner_supplier_id))
|
sp_uuid = winner_supplier_id if isinstance(winner_supplier_id, uuid.UUID) else uuid.UUID(str(winner_supplier_id))
|
||||||
|
|
||||||
|
if not contract_price or contract_price <= 0:
|
||||||
|
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
||||||
|
res.msg = "계약가를 입력해야 낙찰할 수 있습니다."
|
||||||
|
return res
|
||||||
|
|
||||||
# 존재 확인(+회사 가드) — 남의 회사 견적은 NOT_FOUND.
|
# 존재 확인(+회사 가드) — 남의 회사 견적은 NOT_FOUND.
|
||||||
err_type, original = await self._fetch(qt_uuid, company_id)
|
err_type, original = await self._fetch(qt_uuid, company_id)
|
||||||
if err_type != ErrorType.SUCCESS or original is None:
|
if err_type != ErrorType.SUCCESS or original is None:
|
||||||
@ -200,20 +199,21 @@ class ClosingMixin:
|
|||||||
res.msg = "개찰(낙찰자 미정) 상태의 견적만 직접 낙찰할 수 있습니다."
|
res.msg = "개찰(낙찰자 미정) 상태의 견적만 직접 낙찰할 수 있습니다."
|
||||||
return res
|
return res
|
||||||
|
|
||||||
# 낙찰 후보 = 가격을 써낸 세션. close_and_decide 와 같은 조회(list_sessions_status,
|
# 낙찰 후보 = 이 견적에 초청된 협력사 전부. 오프라인 협상 결과를 반영하는 자리라 시스템에
|
||||||
# 공급사 삭제돼도 포함되는 outerjoin)를 쓴다.
|
# 가격을 안 낸 협력사(전원 미응찰 견적 포함)도 고를 수 있다. close_and_decide 와 같은 조회
|
||||||
|
# (list_sessions_status, 공급사 삭제돼도 포함되는 outerjoin)를 쓴다.
|
||||||
err_type, rows = await DB_SESSION_MNG.execute_lambda(
|
err_type, rows = await DB_SESSION_MNG.execute_lambda(
|
||||||
sessions.DBType(),
|
sessions.DBType(),
|
||||||
DBWRType.DB_READ.value,
|
DBWRType.DB_READ.value,
|
||||||
lambda s: self.quotation_crud.list_sessions_status(s, qt_uuid),
|
lambda s: self.quotation_crud.list_sessions_status(s, qt_uuid),
|
||||||
)
|
)
|
||||||
rows = rows if err_type == ErrorType.SUCCESS else []
|
rows = rows if err_type == ErrorType.SUCCESS else []
|
||||||
winner = next((r for r in rows if r.supplier_id == sp_uuid and _award_price(r) is not None), None)
|
winner = next((r for r in rows if r.supplier_id == sp_uuid), None)
|
||||||
if winner is None:
|
if winner is None:
|
||||||
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
||||||
res.msg = "선택한 협력사는 이 견적의 낙찰 후보(가격을 제출한 협력사)가 아닙니다."
|
res.msg = "선택한 협력사는 이 견적에 초청된 협력사가 아닙니다."
|
||||||
return res
|
return res
|
||||||
winner_price = _award_price(winner)
|
winner_price = int(contract_price)
|
||||||
|
|
||||||
# [동시 직접낙찰 가드] 개찰→낙찰 원자 선점. 실제로 전이한 호출자만 통과(재클릭·경합 방어).
|
# [동시 직접낙찰 가드] 개찰→낙찰 원자 선점. 실제로 전이한 호출자만 통과(재클릭·경합 방어).
|
||||||
claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim(
|
claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim(
|
||||||
@ -225,6 +225,22 @@ class ClosingMixin:
|
|||||||
res.msg = "이미 낙찰 처리된 견적입니다."
|
res.msg = "이미 낙찰 처리된 견적입니다."
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
# 계약가를 낙찰 세션에 남긴다 — 통계가 이 값을 계약가로 읽고(투찰가보다 우선), 누가 언제 어떤
|
||||||
|
# 근거로 확정했는지 추적한다. custom 은 부가정보·의견과 같은 컬럼이라 병합(덮어쓰기 금지).
|
||||||
|
offline_award = {
|
||||||
|
"price": winner_price,
|
||||||
|
"note": (contract_note or "").strip()[:255],
|
||||||
|
"by": str(user_id) if user_id else "",
|
||||||
|
"at": GTime.UTC().isoformat(timespec="seconds"),
|
||||||
|
}
|
||||||
|
award_err = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[sessions.DBType()],
|
||||||
|
[lambda s: self.quotation_crud.merge_session_custom(s, winner.session_id, {"offline_award": offline_award})],
|
||||||
|
)
|
||||||
|
if award_err != ErrorType.SUCCESS:
|
||||||
|
# 낙찰(견적)은 이미 선점 전이됐다 — 계약가만 못 남긴 상태라 되돌리지 않고 경고로 남긴다.
|
||||||
|
LOG.w(f"[award] 계약가 기록 실패 qt_id={qt_id} session_id={winner.session_id} price={winner_price}")
|
||||||
|
|
||||||
# 작성자 알림 — 자동낙찰과 같은 SUCCESS 코드, manual 플래그로 '직접 낙찰' 구분.
|
# 작성자 알림 — 자동낙찰과 같은 SUCCESS 코드, manual 플래그로 '직접 낙찰' 구분.
|
||||||
await create_notification(
|
await create_notification(
|
||||||
original.user_id, NotificationType.SUCCESS,
|
original.user_id, NotificationType.SUCCESS,
|
||||||
|
|||||||
@ -181,6 +181,7 @@ class QueriesMixin:
|
|||||||
qt_type=r.qt_type,
|
qt_type=r.qt_type,
|
||||||
target_price=r.target_price,
|
target_price=r.target_price,
|
||||||
anchoring_price=r.anchoring_price,
|
anchoring_price=r.anchoring_price,
|
||||||
|
done_ceiling_price=r.done_ceiling_price,
|
||||||
status=r.status,
|
status=r.status,
|
||||||
bid_price=r.bid_price,
|
bid_price=r.bid_price,
|
||||||
bid_at=r.bid_at,
|
bid_at=r.bid_at,
|
||||||
|
|||||||
@ -80,6 +80,7 @@ class StatisticsService:
|
|||||||
k.closed_count = closed
|
k.closed_count = closed
|
||||||
k.award_rate = (outcome.awarded / closed) if closed else 0.0
|
k.award_rate = (outcome.awarded / closed) if closed else 0.0
|
||||||
k.regen_avg_round = round(regen, 2)
|
k.regen_avg_round = round(regen, 2)
|
||||||
|
k.offline_award_count = sum(1 for r in win_rows if r.is_offline)
|
||||||
k.markup_suppression_rate = round(markup, 4) # 인상억제율(재협상 직전 라운드 투찰가 대비, 파생)
|
k.markup_suppression_rate = round(markup, 4) # 인상억제율(재협상 직전 라운드 투찰가 대비, 파생)
|
||||||
# 전월 대비: 마지막 두 달 절감액 차(창에 2개월 미만이면 0).
|
# 전월 대비: 마지막 두 달 절감액 차(창에 2개월 미만이면 0).
|
||||||
k.savings_delta_mom = (trend[-1].savings - trend[-2].savings) if len(trend) >= 2 else 0
|
k.savings_delta_mom = (trend[-1].savings - trend[-2].savings) if len(trend) >= 2 else 0
|
||||||
|
|||||||
@ -39,7 +39,7 @@ async def test_award_opened_sets_winner_and_notifies(clean):
|
|||||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
|
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
|
||||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=120)
|
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=120)
|
||||||
|
|
||||||
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a)
|
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a, 100)
|
||||||
|
|
||||||
assert res.result.success is True
|
assert res.result.success is True
|
||||||
row = await _quotation(engine, qt)
|
row = await _quotation(engine, qt)
|
||||||
@ -65,7 +65,7 @@ async def test_award_rejects_when_not_opened(clean):
|
|||||||
qt = await _seed_opened(engine, user_id=user_id, number="A-DONE", close_reason=CloseReason.AWARDED.value)
|
qt = await _seed_opened(engine, user_id=user_id, number="A-DONE", close_reason=CloseReason.AWARDED.value)
|
||||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
|
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
|
||||||
|
|
||||||
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a)
|
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a, 100)
|
||||||
|
|
||||||
assert res.result.success is False
|
assert res.result.success is False
|
||||||
assert res.result.code == ErrorType.INVALID_REQUEST_DATA.value
|
assert res.result.code == ErrorType.INVALID_REQUEST_DATA.value
|
||||||
@ -73,14 +73,14 @@ async def test_award_rejects_when_not_opened(clean):
|
|||||||
|
|
||||||
|
|
||||||
async def test_award_rejects_unknown_supplier(clean):
|
async def test_award_rejects_unknown_supplier(clean):
|
||||||
"""검증: 개찰 견적에, 투찰 후보가 아닌 협력사 id 를 지정.
|
"""검증: 개찰 견적에, 이 견적에 초청되지 않은(세션이 없는) 협력사 id 를 지정.
|
||||||
기대결과: 거부 + close_reason 은 개찰(OPEN_PRICE) 그대로 유지, 알림 없음."""
|
기대결과: 거부 + close_reason 은 개찰(OPEN_PRICE) 그대로 유지, 알림 없음."""
|
||||||
engine = clean
|
engine = clean
|
||||||
user_id, bidder, stranger = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
user_id, bidder, stranger = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
||||||
qt = await _seed_opened(engine, user_id=user_id, number="A-STRANGER", close_reason=CloseReason.OPEN_PRICE.value)
|
qt = await _seed_opened(engine, user_id=user_id, number="A-STRANGER", close_reason=CloseReason.OPEN_PRICE.value)
|
||||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=bidder)
|
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=bidder)
|
||||||
|
|
||||||
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, stranger)
|
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, stranger, 100)
|
||||||
|
|
||||||
assert res.result.success is False
|
assert res.result.success is False
|
||||||
row = await _quotation(engine, qt)
|
row = await _quotation(engine, qt)
|
||||||
@ -97,8 +97,8 @@ async def test_award_is_idempotent(clean):
|
|||||||
qt = await _seed_opened(engine, user_id=user_id, number="A-IDEMP", close_reason=CloseReason.OPEN_REJECT.value)
|
qt = await _seed_opened(engine, user_id=user_id, number="A-IDEMP", close_reason=CloseReason.OPEN_REJECT.value)
|
||||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
|
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
|
||||||
|
|
||||||
first = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a)
|
first = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a, 100)
|
||||||
second = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a)
|
second = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a, 100)
|
||||||
|
|
||||||
assert first.result.success is True
|
assert first.result.success is True
|
||||||
assert second.result.success is False
|
assert second.result.success is False
|
||||||
@ -113,7 +113,7 @@ async def test_award_rejects_non_owner(clean):
|
|||||||
qt = await _seed_opened(engine, user_id=owner, number="A-NONOWNER", close_reason=CloseReason.OPEN_PRICE.value)
|
qt = await _seed_opened(engine, user_id=owner, number="A-NONOWNER", close_reason=CloseReason.OPEN_PRICE.value)
|
||||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
|
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
|
||||||
|
|
||||||
res = await _service().award_quotation(str(qt), None, other, UserRole.USER.value, supplier_a)
|
res = await _service().award_quotation(str(qt), None, other, UserRole.USER.value, supplier_a, 100)
|
||||||
|
|
||||||
assert res.result.success is False
|
assert res.result.success is False
|
||||||
assert res.result.code == ErrorType.ACCOUNT_FORBIDDEN.value
|
assert res.result.code == ErrorType.ACCOUNT_FORBIDDEN.value
|
||||||
@ -131,7 +131,7 @@ async def test_award_allows_owner_role(clean):
|
|||||||
qt = await _seed_opened(engine, user_id=creator, number="A-OWNER", close_reason=CloseReason.OPEN_EQUAL.value)
|
qt = await _seed_opened(engine, user_id=creator, number="A-OWNER", close_reason=CloseReason.OPEN_EQUAL.value)
|
||||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
|
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
|
||||||
|
|
||||||
res = await _service().award_quotation(str(qt), None, admin, UserRole.OWNER.value, supplier_a)
|
res = await _service().award_quotation(str(qt), None, admin, UserRole.OWNER.value, supplier_a, 100)
|
||||||
|
|
||||||
assert res.result.success is True
|
assert res.result.success is True
|
||||||
row = await _quotation(engine, qt)
|
row = await _quotation(engine, qt)
|
||||||
@ -141,6 +141,64 @@ async def test_award_allows_owner_role(clean):
|
|||||||
assert len(await _notifications(engine, admin)) == 0
|
assert len(await _notifications(engine, admin)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_award_records_contract_price_on_session(clean):
|
||||||
|
"""검증: 협력사 제출가(100)와 다른 계약가(88)로 직접 낙찰 — 오프라인 재협상 결과 반영.
|
||||||
|
기대결과: 낙찰 세션 custom.offline_award 에 계약가·메모가 남고, 알림 winner_price 도 계약가."""
|
||||||
|
engine = clean
|
||||||
|
user_id, supplier_a = uuid.uuid4(), uuid.uuid4()
|
||||||
|
qt = await _seed_opened(engine, user_id=user_id, number="A-OFFLINE", close_reason=CloseReason.OPEN_PRICE.value)
|
||||||
|
await _add_session(engine, qt, status=SessionStatus.REJECTED.value, bid_price=None, supplier_id=supplier_a)
|
||||||
|
|
||||||
|
res = await _service().award_quotation(
|
||||||
|
str(qt), None, user_id, UserRole.USER.value, supplier_a, 88, "오프라인 협상, 8/12 통화 합의",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.result.success is True
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
row = (await conn.execute(
|
||||||
|
text("SELECT custom FROM sessions WHERE quotation_id = :qt AND supplier_id = :sp"),
|
||||||
|
{"qt": qt, "sp": supplier_a},
|
||||||
|
)).first()
|
||||||
|
award = row.custom["offline_award"]
|
||||||
|
assert award["price"] == 88
|
||||||
|
assert award["note"] == "오프라인 협상, 8/12 통화 합의"
|
||||||
|
assert award["by"] == str(user_id)
|
||||||
|
notis = await _notifications(engine, user_id)
|
||||||
|
assert notis[0][1]["winner_price"] == 88
|
||||||
|
|
||||||
|
|
||||||
|
async def test_award_allows_supplier_without_price(clean):
|
||||||
|
"""검증: 시스템에 가격을 한 번도 안 낸(미참여) 협력사를 계약가와 함께 직접 낙찰.
|
||||||
|
기대결과: 낙찰 성공 — 전원 미응찰 견적도 오프라인 협상 결과를 반영할 수 있다."""
|
||||||
|
engine = clean
|
||||||
|
user_id, supplier_a = uuid.uuid4(), uuid.uuid4()
|
||||||
|
qt = await _seed_opened(engine, user_id=user_id, number="A-NOSHOW", close_reason=CloseReason.OPEN_NOSHOW.value)
|
||||||
|
await _add_session(engine, qt, status=SessionStatus.NOT_PARTICIPATED.value, bid_price=None, supplier_id=supplier_a)
|
||||||
|
|
||||||
|
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a, 77_000)
|
||||||
|
|
||||||
|
assert res.result.success is True
|
||||||
|
row = await _quotation(engine, qt)
|
||||||
|
assert row.close_reason == CloseReason.AWARDED.value
|
||||||
|
assert str(row.preferred_sp_id) == str(supplier_a)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_award_rejects_without_contract_price(clean):
|
||||||
|
"""검증: 계약가 없이(0) 직접 낙찰 시도.
|
||||||
|
기대결과: 거부(INVALID_REQUEST_DATA) — 계약가는 절감 통계의 기준이라 필수다."""
|
||||||
|
engine = clean
|
||||||
|
user_id, supplier_a = uuid.uuid4(), uuid.uuid4()
|
||||||
|
qt = await _seed_opened(engine, user_id=user_id, number="A-NOPRICE", close_reason=CloseReason.OPEN_PRICE.value)
|
||||||
|
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
|
||||||
|
|
||||||
|
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a, 0)
|
||||||
|
|
||||||
|
assert res.result.success is False
|
||||||
|
assert res.result.code == ErrorType.INVALID_REQUEST_DATA.value
|
||||||
|
row = await _quotation(engine, qt)
|
||||||
|
assert row.close_reason == CloseReason.OPEN_PRICE.value
|
||||||
|
|
||||||
|
|
||||||
# ===== 헬퍼 =====
|
# ===== 헬퍼 =====
|
||||||
async def _seed_opened(engine, *, user_id, number, close_reason, round_=1):
|
async def _seed_opened(engine, *, user_id, number, close_reason, round_=1):
|
||||||
"""개찰/낙찰 상태(status=CLOSED + close_reason)로 견적 1건 시드. 낙찰자 컬럼은 비운 채 시작."""
|
"""개찰/낙찰 상태(status=CLOSED + close_reason)로 견적 1건 시드. 낙찰자 컬럼은 비운 채 시작."""
|
||||||
|
|||||||
@ -7,6 +7,9 @@
|
|||||||
import type { CardUsageRowName } from './cardUsageRowName';
|
import type { CardUsageRowName } from './cardUsageRowName';
|
||||||
import type { CardUsageRowCardId } from './cardUsageRowCardId';
|
import type { CardUsageRowCardId } from './cardUsageRowCardId';
|
||||||
import type { CardUsageRowLastUsedAt } from './cardUsageRowLastUsedAt';
|
import type { CardUsageRowLastUsedAt } from './cardUsageRowLastUsedAt';
|
||||||
|
import type { CardUsageRowAvgQ } from './cardUsageRowAvgQ';
|
||||||
|
import type { CardUsageRowAvgUcb } from './cardUsageRowAvgUcb';
|
||||||
|
import type { CardUsageRowExploreBonus } from './cardUsageRowExploreBonus';
|
||||||
|
|
||||||
export interface CardUsageRow {
|
export interface CardUsageRow {
|
||||||
number: string;
|
number: string;
|
||||||
@ -18,4 +21,8 @@ export interface CardUsageRow {
|
|||||||
share?: number;
|
share?: number;
|
||||||
avg_turn?: number;
|
avg_turn?: number;
|
||||||
last_used_at?: CardUsageRowLastUsedAt;
|
last_used_at?: CardUsageRowLastUsedAt;
|
||||||
|
avg_q?: CardUsageRowAvgQ;
|
||||||
|
avg_ucb?: CardUsageRowAvgUcb;
|
||||||
|
explore_bonus?: CardUsageRowExploreBonus;
|
||||||
|
visits?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 CardUsageRowAvgQ = 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 CardUsageRowAvgUcb = 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 CardUsageRowExploreBonus = number | null;
|
||||||
@ -25,7 +25,10 @@ export * from './cardDataUserId';
|
|||||||
export * from './cardStatus';
|
export * from './cardStatus';
|
||||||
export * from './cardType';
|
export * from './cardType';
|
||||||
export * from './cardUsageRow';
|
export * from './cardUsageRow';
|
||||||
|
export * from './cardUsageRowAvgQ';
|
||||||
|
export * from './cardUsageRowAvgUcb';
|
||||||
export * from './cardUsageRowCardId';
|
export * from './cardUsageRowCardId';
|
||||||
|
export * from './cardUsageRowExploreBonus';
|
||||||
export * from './cardUsageRowLastUsedAt';
|
export * from './cardUsageRowLastUsedAt';
|
||||||
export * from './cardUsageRowName';
|
export * from './cardUsageRowName';
|
||||||
export * from './cardUsageType';
|
export * from './cardUsageType';
|
||||||
@ -159,6 +162,7 @@ export * from './renegotiationDataNextQuotationId';
|
|||||||
export * from './renegotiationDataTargetPrice';
|
export * from './renegotiationDataTargetPrice';
|
||||||
export * from './reqApproveRenegotiation';
|
export * from './reqApproveRenegotiation';
|
||||||
export * from './reqAwardQuotation';
|
export * from './reqAwardQuotation';
|
||||||
|
export * from './reqAwardQuotationContractNote';
|
||||||
export * from './reqBulkMapByNames';
|
export * from './reqBulkMapByNames';
|
||||||
export * from './reqCheckCodes';
|
export * from './reqCheckCodes';
|
||||||
export * from './reqCreateCard';
|
export * from './reqCreateCard';
|
||||||
@ -432,6 +436,7 @@ export * from './sessionDataBidAt';
|
|||||||
export * from './sessionDataBidPrice';
|
export * from './sessionDataBidPrice';
|
||||||
export * from './sessionDataCustom';
|
export * from './sessionDataCustom';
|
||||||
export * from './sessionDataCustomAnyOf';
|
export * from './sessionDataCustomAnyOf';
|
||||||
|
export * from './sessionDataDoneCeilingPrice';
|
||||||
export * from './sessionDataEmailSentAt';
|
export * from './sessionDataEmailSentAt';
|
||||||
export * from './sessionDataRejectDeliveryType';
|
export * from './sessionDataRejectDeliveryType';
|
||||||
export * from './sessionDataRejectPrice';
|
export * from './sessionDataRejectPrice';
|
||||||
|
|||||||
@ -4,7 +4,10 @@
|
|||||||
* Negodata Api Server
|
* Negodata Api Server
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
import type { ReqAwardQuotationContractNote } from './reqAwardQuotationContractNote';
|
||||||
|
|
||||||
export interface ReqAwardQuotation {
|
export interface ReqAwardQuotation {
|
||||||
winner_supplier_id: string;
|
winner_supplier_id: string;
|
||||||
|
contract_price: number;
|
||||||
|
contract_note?: ReqAwardQuotationContractNote;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 ReqAwardQuotationContractNote = string | null;
|
||||||
@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { QuotationType } from './quotationType';
|
import type { QuotationType } from './quotationType';
|
||||||
import type { SessionDataAnchoringPrice } from './sessionDataAnchoringPrice';
|
import type { SessionDataAnchoringPrice } from './sessionDataAnchoringPrice';
|
||||||
|
import type { SessionDataDoneCeilingPrice } from './sessionDataDoneCeilingPrice';
|
||||||
import type { SessionStatus } from './sessionStatus';
|
import type { SessionStatus } from './sessionStatus';
|
||||||
import type { SessionDataBidPrice } from './sessionDataBidPrice';
|
import type { SessionDataBidPrice } from './sessionDataBidPrice';
|
||||||
import type { SessionDataBidAt } from './sessionDataBidAt';
|
import type { SessionDataBidAt } from './sessionDataBidAt';
|
||||||
@ -25,6 +26,7 @@ export interface SessionData {
|
|||||||
qt_type: QuotationType;
|
qt_type: QuotationType;
|
||||||
target_price: number;
|
target_price: number;
|
||||||
anchoring_price?: SessionDataAnchoringPrice;
|
anchoring_price?: SessionDataAnchoringPrice;
|
||||||
|
done_ceiling_price?: SessionDataDoneCeilingPrice;
|
||||||
status: SessionStatus;
|
status: SessionStatus;
|
||||||
bid_price?: SessionDataBidPrice;
|
bid_price?: SessionDataBidPrice;
|
||||||
bid_at?: SessionDataBidAt;
|
bid_at?: SessionDataBidAt;
|
||||||
|
|||||||
@ -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 SessionDataDoneCeilingPrice = number | null;
|
||||||
@ -14,4 +14,5 @@ export interface StatKpi {
|
|||||||
closed_count?: number;
|
closed_count?: number;
|
||||||
regen_avg_round?: number;
|
regen_avg_round?: number;
|
||||||
markup_suppression_rate?: number;
|
markup_suppression_rate?: number;
|
||||||
|
offline_award_count?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,6 +57,15 @@ export function CardLearningView({ data }: { data: LearningData }) {
|
|||||||
<TableHead className="w-32 text-right">
|
<TableHead className="w-32 text-right">
|
||||||
<HeadWithHelp label="평균 라운드" help="이 카드가 협상의 몇 번째 라운드에 나갔는지의 평균입니다. 값이 작으면 초반에, 크면 종결 무렵에 나간다는 뜻입니다. 종결 전용으로 설정한 카드가 초반에 나오고 있지는 않은지 확인할 때 봅니다." />
|
<HeadWithHelp label="평균 라운드" help="이 카드가 협상의 몇 번째 라운드에 나갔는지의 평균입니다. 값이 작으면 초반에, 크면 종결 무렵에 나간다는 뜻입니다. 종결 전용으로 설정한 카드가 초반에 나오고 있지는 않은지 확인할 때 봅니다." />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead className="w-28 text-right">
|
||||||
|
<HeadWithHelp label="Q값" help="강화학습이 이 카드에 매긴 값어치입니다(선택 시점 평균). 높을수록 이 국면에서 성과가 좋았다고 학습된 카드입니다. 봇은 Q값에 탐색 보너스를 더한 점수가 가장 높은 카드를 고릅니다." />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-28 text-right">
|
||||||
|
<HeadWithHelp label="탐색 보너스" help="아직 덜 써봐서 붙는 가산점입니다(UCB − Q). 이 값이 Q값보다 크면 성과가 좋아서가 아니라 '아직 검증이 덜 돼서' 뽑힌 것입니다. 방문수가 쌓이면 0에 수렴합니다." />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-24 text-right">
|
||||||
|
<HeadWithHelp label="방문수" help="이 카드가 학습에서 선택된 누적 횟수입니다. 적으면 Q값이 아직 추측에 가깝고, 많을수록 검증된 값입니다." />
|
||||||
|
</TableHead>
|
||||||
<TableHead className="w-36 text-right">마지막 사용</TableHead>
|
<TableHead className="w-36 text-right">마지막 사용</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@ -84,6 +93,9 @@ export function CardLearningView({ data }: { data: LearningData }) {
|
|||||||
<TableCell className="text-right">{c.uses}</TableCell>
|
<TableCell className="text-right">{c.uses}</TableCell>
|
||||||
<TableCell className="text-right">{pct(c.share)}</TableCell>
|
<TableCell className="text-right">{pct(c.share)}</TableCell>
|
||||||
<TableCell className="text-right">{c.avgTurn ? `${c.avgTurn}R` : '-'}</TableCell>
|
<TableCell className="text-right">{c.avgTurn ? `${c.avgTurn}R` : '-'}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">{num4(c.avgQ)}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-muted-foreground">{num4(c.exploreBonus)}</TableCell>
|
||||||
|
<TableCell className="text-right">{c.visits || '-'}</TableCell>
|
||||||
<TableCell className="text-right text-muted-foreground">
|
<TableCell className="text-right text-muted-foreground">
|
||||||
{c.lastUsedAt ? fmtDateTime(c.lastUsedAt) : '-'}
|
{c.lastUsedAt ? fmtDateTime(c.lastUsedAt) : '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@ -286,6 +298,11 @@ function pct(v: number): string {
|
|||||||
return `${(v * 100).toFixed(0)}%`;
|
return `${(v * 100).toFixed(0)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Q값·탐색 보너스는 0에 가까운 소수라 반올림하면 차이가 사라진다 — 네 자리까지 그대로 보여준다.
|
||||||
|
function num4(v: number | null): string {
|
||||||
|
return v == null ? '-' : v.toFixed(4);
|
||||||
|
}
|
||||||
|
|
||||||
// 앵커링 값은 천분율(‰)로 저장된다 — 10 = 1%. 화면은 담당자가 쓰는 단위(%)로 보여준다.
|
// 앵커링 값은 천분율(‰)로 저장된다 — 10 = 1%. 화면은 담당자가 쓰는 단위(%)로 보여준다.
|
||||||
function rate(v: number): string {
|
function rate(v: number): string {
|
||||||
return `${(v / 10).toFixed(1)}%`;
|
return `${(v / 10).toFixed(1)}%`;
|
||||||
|
|||||||
@ -24,6 +24,9 @@ export function useLearningData(): { data: LearningData; isLoading: boolean } {
|
|||||||
share: c.share ?? 0,
|
share: c.share ?? 0,
|
||||||
avgTurn: c.avg_turn ?? 0,
|
avgTurn: c.avg_turn ?? 0,
|
||||||
lastUsedAt: c.last_used_at ?? null,
|
lastUsedAt: c.last_used_at ?? null,
|
||||||
|
avgQ: c.avg_q ?? null,
|
||||||
|
exploreBonus: c.explore_bonus ?? null,
|
||||||
|
visits: c.visits ?? 0,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,6 +10,10 @@ export type CardRow = {
|
|||||||
share: number; // 전체 카드 사용 중 비중
|
share: number; // 전체 카드 사용 중 비중
|
||||||
avgTurn: number; // 평균 몇 번째 라운드에 나갔는지
|
avgTurn: number; // 평균 몇 번째 라운드에 나갔는지
|
||||||
lastUsedAt: string | null;
|
lastUsedAt: string | null;
|
||||||
|
// 선택 근거 — 봇은 UCB(=Q + 탐색보너스)가 가장 높은 카드를 고른다.
|
||||||
|
avgQ: number | null; // 선택 시점 Q값 평균
|
||||||
|
exploreBonus: number | null; // UCB - Q. 클수록 '아직 덜 검증돼서' 뽑힌 것
|
||||||
|
visits: number; // 마지막 선택 시점 누적 방문수
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LearningData = {
|
export type LearningData = {
|
||||||
|
|||||||
@ -0,0 +1,116 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { X, Gavel, Loader2 } from 'lucide-react';
|
||||||
|
import { useScrollLock } from '@/lib/useScrollLock';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Typography } from '@/components/ui/typography';
|
||||||
|
import { SessionStatus } from '@/api/generated/model';
|
||||||
|
import { awardPrice, sessionStatusLabel, type SessionView } from '../../types';
|
||||||
|
|
||||||
|
type AwardModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
/** 낙찰시킬 협력사의 세션 — 마지막 제출가를 계약가 기본값으로 쓴다. */
|
||||||
|
winner: SessionView;
|
||||||
|
/** 확정 → 직접 낙찰 호출. 성공(true) 시 모달 닫힘. */
|
||||||
|
onConfirm: (contractPrice: number, contractNote: string) => Promise<boolean>;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개찰 견적의 직접 낙찰 모달.
|
||||||
|
// 결렬·미응찰 건은 오프라인으로 다시 협상하고 그 결과를 여기에 반영한다 — 그래서 계약가는
|
||||||
|
// 시스템 제출가를 기본값으로 채워두되 담당자가 고쳐 넣을 수 있고, 근거를 메모로 남긴다.
|
||||||
|
export function AwardModal({ open, winner, onConfirm, onClose }: AwardModalProps) {
|
||||||
|
const submitted = awardPrice(winner);
|
||||||
|
const [price, setPrice] = useState<string>(submitted != null ? String(submitted) : '');
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
useScrollLock(open);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const parsed = Number(price.replace(/[^0-9]/g, ''));
|
||||||
|
const canSubmit = parsed > 0 && !saving;
|
||||||
|
const changed = submitted != null && parsed > 0 && parsed !== submitted;
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!canSubmit) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const ok = await onConfirm(parsed, note.trim());
|
||||||
|
if (ok) onClose();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||||
|
<div className="w-full max-w-md overflow-hidden rounded-xl border border-border bg-background shadow-xl">
|
||||||
|
<div className="flex items-start justify-between border-b border-border p-5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Typography variant="h4" className="flex items-center gap-2">
|
||||||
|
<Gavel className="size-4 text-primary" />
|
||||||
|
직접 낙찰
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="muted" className="mt-1 truncate">
|
||||||
|
{winner.supplier_name} · {sessionStatusLabel(winner.status)}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} aria-label="닫기" className="rounded-md p-1 hover:bg-muted">
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 p-5">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Typography variant="small" className="font-semibold">
|
||||||
|
계약가
|
||||||
|
</Typography>
|
||||||
|
<Input
|
||||||
|
inputMode="numeric"
|
||||||
|
value={parsed ? parsed.toLocaleString() : ''}
|
||||||
|
onChange={(e) => setPrice(e.target.value)}
|
||||||
|
placeholder="예: 12,800,000"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Typography variant="muted">
|
||||||
|
{submitted != null
|
||||||
|
? `협력사 제출가 ₩${submitted.toLocaleString()}${changed ? ' — 오프라인 협상으로 바뀐 금액을 넣었습니다.' : ''}`
|
||||||
|
: winner.status === SessionStatus.NOT_PARTICIPATED
|
||||||
|
? '이 협력사는 협상에 참여하지 않았습니다 — 오프라인 합의가를 직접 넣어 주세요.'
|
||||||
|
: '제출한 가격이 없습니다 — 오프라인 합의가를 직접 넣어 주세요.'}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Typography variant="small" className="font-semibold">
|
||||||
|
계약 근거 <span className="font-normal text-muted-foreground">(선택)</span>
|
||||||
|
</Typography>
|
||||||
|
<textarea
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
maxLength={255}
|
||||||
|
className="w-full resize-none rounded border border-border bg-background p-2 text-xs"
|
||||||
|
placeholder="예: 오프라인 협상 결과, 8/12 통화로 단가 합의"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Typography variant="muted">
|
||||||
|
낙찰은 되돌릴 수 없습니다. 계약가는 통계의 절감액 기준이 됩니다.
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 border-t border-border p-4">
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleConfirm} disabled={!canSubmit}>
|
||||||
|
{saving && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
낙찰 확정
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,12 +7,14 @@ import { typographyVariants } from '@/components/ui/typography';
|
|||||||
import { InfoField } from './InfoField';
|
import { InfoField } from './InfoField';
|
||||||
import { QuotationStatusBadge } from './StatusPill';
|
import { QuotationStatusBadge } from './StatusPill';
|
||||||
import { ResultSummaryBand } from './ResultSummaryBand';
|
import { ResultSummaryBand } from './ResultSummaryBand';
|
||||||
|
import { PriceRail } from './PriceRail';
|
||||||
import type { QuotationData } from '@/api/generated/model/quotationData';
|
import type { QuotationData } from '@/api/generated/model/quotationData';
|
||||||
import { useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
|
import { useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
|
||||||
import {
|
import {
|
||||||
type Product,
|
type Product,
|
||||||
type QuotationSetting,
|
type QuotationSetting,
|
||||||
type SessionView,
|
type SessionView,
|
||||||
|
buildPriceRail,
|
||||||
quotationTypeLabel,
|
quotationTypeLabel,
|
||||||
awardCriterionLabel,
|
awardCriterionLabel,
|
||||||
fmtDateTime,
|
fmtDateTime,
|
||||||
@ -33,14 +35,12 @@ function SectionCard({ title, children }: { title: string; children: ReactNode }
|
|||||||
type DrawerHeaderCardsProps = {
|
type DrawerHeaderCardsProps = {
|
||||||
quotation: QuotationData;
|
quotation: QuotationData;
|
||||||
quotationSettings: QuotationSetting[];
|
quotationSettings: QuotationSetting[];
|
||||||
/** 협상 세션 뷰 — 결과 밴드가 낙찰가/절감 계산에 쓴다. */
|
/** 협상 세션 뷰 — 가격 레일과 결과 밴드가 기준가/낙찰가 계산에 쓴다. */
|
||||||
sessionViews: SessionView[];
|
sessionViews: SessionView[];
|
||||||
/** 현재 선택 세션의 상품(없으면 상품 카드는 빈 상태). */
|
/** 현재 선택 세션의 상품(없으면 상품 카드는 빈 상태). */
|
||||||
currentProduct: Product | undefined;
|
currentProduct: Product | undefined;
|
||||||
/** 목표가 클릭 → 산정내역 모달(대표 세션 기준). 목표가/앵커링가는 견적 단위(1견적=1상품)라 세션 공통값. */
|
/** 목표가 클릭 → 산정내역 모달(대표 세션 기준). 목표가는 견적 단위(1견적=1상품)라 세션 공통값. */
|
||||||
onShowTarget: (sessionId: string) => void;
|
onShowTarget: (sessionId: string) => void;
|
||||||
/** 접힘 상태 — 상세 그리드는 감추고 상단 결과 요약(목표가·낙찰·절감) 밴드만 남긴다. */
|
|
||||||
collapsed?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DrawerHeaderCards({
|
export function DrawerHeaderCards({
|
||||||
@ -49,39 +49,23 @@ export function DrawerHeaderCards({
|
|||||||
sessionViews,
|
sessionViews,
|
||||||
currentProduct,
|
currentProduct,
|
||||||
onShowTarget,
|
onShowTarget,
|
||||||
collapsed = false,
|
|
||||||
}: DrawerHeaderCardsProps) {
|
}: DrawerHeaderCardsProps) {
|
||||||
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
|
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
|
||||||
const isHidden = useHiddenFields(); // 회사설정으로 감춘 가격 필드는 상품 카드에서도 제외
|
const isHidden = useHiddenFields(); // 회사설정으로 감춘 가격 필드는 상품 카드에서도 제외
|
||||||
// 접으면 결과 요약 밴드만 노출(목표가·낙찰·절감). 상세 그리드 계산은 건너뛴다.
|
|
||||||
if (collapsed) {
|
|
||||||
return (
|
|
||||||
<div className="text-xs font-mono">
|
|
||||||
<ResultSummaryBand quotation={quotation} sessionViews={sessionViews} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quotations DDL 표시값
|
const selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === quotation.qt_setting_id);
|
||||||
const q_name = quotation.name || '-';
|
// 타결 상한율(‰) — 견적 override 우선, 없으면 적용 세팅값.
|
||||||
const q_number = quotation.number || '-';
|
const ceilingRate = quotation.done_ceiling_rate ?? selectedSettingObj?.done_ceiling_rate ?? 50;
|
||||||
const q_round = quotation.round || 1;
|
|
||||||
|
const won = (n?: number | null) => (n != null ? `₩${Number(n).toLocaleString()}` : '-');
|
||||||
const q_end_time = quotation.end_time ? fmtDateTime(quotation.end_time) : '-';
|
const q_end_time = quotation.end_time ? fmtDateTime(quotation.end_time) : '-';
|
||||||
const q_created_at = fmtDateTime(quotation.created_at);
|
|
||||||
const q_manager =
|
const q_manager =
|
||||||
quotation.manager_name || quotation.manager_email
|
quotation.manager_name || quotation.manager_email
|
||||||
? `${quotation.manager_name || '-'} (${quotation.manager_email || '-'})`
|
? `${quotation.manager_name || '-'} (${quotation.manager_email || '-'})`
|
||||||
: '-';
|
: '-';
|
||||||
const q_memo = quotation.memo || '-';
|
const q_memo = quotation.memo || '-';
|
||||||
|
|
||||||
const selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === quotation.qt_setting_id);
|
// 상품 카드는 '가격 관련' 값만 노출(모델명·규격 등 스펙은 상품 상세에서).
|
||||||
|
|
||||||
// 목표가·앵커링가는 견적 단위값(1견적=1상품 → 모든 세션 공통). 대표 세션 하나에서 읽어 견적 정보에 표기한다.
|
|
||||||
const repSession = sessionViews[0];
|
|
||||||
const repSessionId = repSession?.session_id;
|
|
||||||
|
|
||||||
// 상품 카드는 '가격 관련' 값만 노출(모델명·규격·제조사 등 스펙은 상품 상세에서 확인).
|
|
||||||
const won = (n?: number | null) => (n != null ? `₩${Number(n).toLocaleString()}` : '-');
|
|
||||||
const productPriceRows = currentProduct
|
const productPriceRows = currentProduct
|
||||||
? [
|
? [
|
||||||
{ key: 'price', label: label('item.price'), value: won(currentProduct.price) },
|
{ key: 'price', label: label('item.price'), value: won(currentProduct.price) },
|
||||||
@ -92,39 +76,26 @@ export function DrawerHeaderCards({
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 space-y-4 text-xs">
|
<div className="text-xs">
|
||||||
{/* 결과 요약: 목표가 대비 낙찰/절감 */}
|
<div className="space-y-4">
|
||||||
<ResultSummaryBand quotation={quotation} sessionViews={sessionViews} />
|
{/* 마감 결과·낙찰 협력사 + 가격 스펙트럼 (금액은 레일이 책임진다) */}
|
||||||
|
<ResultSummaryBand quotation={quotation} sessionViews={sessionViews} ceilingRate={ceilingRate} />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{/* 견적 정보 */}
|
{/* 견적 정보 — 견적명·번호·차수는 헤더 제목줄에 있어 여기서 반복하지 않는다. */}
|
||||||
<SectionCard title="견적 정보">
|
<SectionCard title="견적 정보">
|
||||||
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
|
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
|
||||||
<InfoField label="견적명" value={q_name} valueClassName="font-sans" />
|
<InfoField label="유형" value={quotationTypeLabel(quotation.type)} valueClassName="font-sans" />
|
||||||
<InfoField label="견적번호" value={q_number} />
|
<InfoField
|
||||||
<InfoField label="유형" value={quotationTypeLabel(quotation.type)} />
|
label="낙찰 기준"
|
||||||
<InfoField label="차수" value={`${q_round}차`} />
|
value={awardCriterionLabel(quotation.type, quotation.mid_action, quotation.over_action)}
|
||||||
<InfoField label="낙찰 기준" value={awardCriterionLabel(quotation.type, quotation.mid_action, quotation.over_action)} valueClassName="font-sans" />
|
valueClassName="font-sans"
|
||||||
<InfoField label="목표가">
|
/>
|
||||||
{repSessionId ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onShowTarget(repSessionId)}
|
|
||||||
title="목표가 산정 내역 보기"
|
|
||||||
className={cn(typographyVariants({ variant: 'link' }), 'font-bold font-sans')}
|
|
||||||
>
|
|
||||||
{won(repSession?.target_price)}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="font-sans font-bold text-foreground">{won(repSession?.target_price)}</span>
|
|
||||||
)}
|
|
||||||
</InfoField>
|
|
||||||
<InfoField label="앵커링가" value={won(repSession?.anchoring_price)} valueClassName="font-sans" />
|
|
||||||
<InfoField label="견적상태" labelClassName="opacity-90 font-bold mb-1">
|
<InfoField label="견적상태" labelClassName="opacity-90 font-bold mb-1">
|
||||||
<QuotationStatusBadge status={quotation.status} />
|
<QuotationStatusBadge status={quotation.status} />
|
||||||
</InfoField>
|
</InfoField>
|
||||||
<InfoField label="마감시각" value={q_end_time} />
|
<InfoField label="마감시각" value={q_end_time} />
|
||||||
<InfoField label="생성일" value={q_created_at} />
|
<InfoField label="생성일" value={fmtDateTime(quotation.created_at)} />
|
||||||
<InfoField label="담당자" value={q_manager} valueClassName="font-sans" />
|
<InfoField label="담당자" value={q_manager} valueClassName="font-sans" />
|
||||||
<InfoField
|
<InfoField
|
||||||
label="메모"
|
label="메모"
|
||||||
@ -135,9 +106,8 @@ export function DrawerHeaderCards({
|
|||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{/* 상품 정보(가격) + 견적 세팅 */}
|
{/* 상품 가격과 협상 규칙은 함께 읽힌다 — 한 카드로 합친다. */}
|
||||||
<div className="space-y-4">
|
<SectionCard title="상품 · 협상 규칙">
|
||||||
<SectionCard title="상품 정보 (가격)">
|
|
||||||
{currentProduct ? (
|
{currentProduct ? (
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="h-24 w-24 shrink-0 rounded-md border border-border bg-background overflow-hidden flex items-center justify-center">
|
<div className="h-24 w-24 shrink-0 rounded-md border border-border bg-background overflow-hidden flex items-center justify-center">
|
||||||
@ -169,6 +139,18 @@ export function DrawerHeaderCards({
|
|||||||
title={row.value}
|
title={row.value}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{selectedSettingObj ? (
|
||||||
|
<>
|
||||||
|
<InfoField
|
||||||
|
label={label('target_margin')}
|
||||||
|
value={selectedSettingObj.target_margin}
|
||||||
|
valueClassName="font-bold text-emerald-600 dark:text-emerald-400 font-sans"
|
||||||
|
/>
|
||||||
|
<InfoField label="카드 사용 횟수" value={selectedSettingObj.card_use_count} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<InfoField label="견적 세팅" value="미적용" valueClassName="font-sans" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -176,21 +158,6 @@ export function DrawerHeaderCards({
|
|||||||
<div className="text-muted-foreground py-6 text-center">상품 정보가 비어있습니다.</div>
|
<div className="text-muted-foreground py-6 text-center">상품 정보가 비어있습니다.</div>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard title="견적 세팅">
|
|
||||||
{selectedSettingObj ? (
|
|
||||||
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
|
|
||||||
<InfoField
|
|
||||||
label={label('target_margin')}
|
|
||||||
value={selectedSettingObj.target_margin}
|
|
||||||
valueClassName="font-bold text-emerald-600 dark:text-emerald-400 font-sans"
|
|
||||||
/>
|
|
||||||
<InfoField label="카드 사용 횟수" value={selectedSettingObj.card_use_count} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-muted-foreground py-6 text-center">적용된 견적 세팅이 비어있습니다.</div>
|
|
||||||
)}
|
|
||||||
</SectionCard>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,149 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Typography } from '@/components/ui/typography';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { PriceRailView } from '../../types';
|
||||||
|
|
||||||
|
const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 헤더 가격 레일 — 협상을 판정하는 네 가격을 왼쪽에서 오른쪽 흐름 순으로 고정 노출한다.
|
||||||
|
* 접힘·대화 탭에서도 남으므로 협상을 보며 기준가를 확인할 수 있다.
|
||||||
|
*
|
||||||
|
* 1번 칸만 유형에 따라 갈린다:
|
||||||
|
* 1:1 = 앵커링가(협력사가 하나라 견적의 값)
|
||||||
|
* 1:N = 투찰 현황(앵커는 (상품×공급사)마다 달라 대표값이 성립하지 않는다 → 협상현황 표의 협력사별 컬럼으로)
|
||||||
|
* 4번 칸은 상태로 갈린다: 낙찰가 / 최저 투찰가(개찰) / 현재 제시가·최저가(진행 중).
|
||||||
|
*/
|
||||||
|
export function PriceRail({ rail, onShowTarget }: { rail: PriceRailView; onShowTarget?: () => void }) {
|
||||||
|
const savingsText =
|
||||||
|
rail.savings != null
|
||||||
|
? `목표가 대비 ${rail.savings >= 0 ? '−' : '+'}${won(Math.abs(rail.savings))}` +
|
||||||
|
(rail.savingsRate != null ? ` (${rail.savings >= 0 ? '−' : '+'}${Math.abs(rail.savingsRate * 100).toFixed(1)}%)` : '')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 border-b border-border bg-card sm:grid-cols-4">
|
||||||
|
{rail.oneToOne ? (
|
||||||
|
<Cell
|
||||||
|
label="앵커링가"
|
||||||
|
dot="bg-zinc-400"
|
||||||
|
value={won(rail.anchorPrice)}
|
||||||
|
sub={
|
||||||
|
rail.anchorPrice != null && rail.targetPrice
|
||||||
|
? `목표가 −${pctGap(rail.targetPrice, rail.anchorPrice)}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Cell
|
||||||
|
label="투찰 현황"
|
||||||
|
dot="bg-zinc-400"
|
||||||
|
value={`${rail.bidCount} / ${rail.supplierCount}`}
|
||||||
|
sub="협력사별 앵커링가는 아래 표에서"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Cell label="목표가" dot="bg-primary" value={won(rail.targetPrice)} onSub={onShowTarget} />
|
||||||
|
|
||||||
|
<Cell
|
||||||
|
label="타결 상한가"
|
||||||
|
dot="bg-amber-500"
|
||||||
|
value={won(rail.ceilingPrice)}
|
||||||
|
valueClassName="text-amber-600 dark:text-amber-400"
|
||||||
|
sub={`목표가 +${(rail.ceilingRate / 10).toFixed(1)}% · 초과 시 결렬`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Cell
|
||||||
|
label={rail.resultLabel}
|
||||||
|
badge={rail.resultBadge}
|
||||||
|
dot={rail.outcome === 'awarded' ? 'bg-emerald-500' : 'bg-amber-500'}
|
||||||
|
value={rail.resultPrice != null ? won(rail.resultPrice) : '미응찰'}
|
||||||
|
valueClassName={
|
||||||
|
rail.resultPrice == null
|
||||||
|
? 'text-muted-foreground'
|
||||||
|
: rail.outcome === 'awarded'
|
||||||
|
? 'text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'text-amber-600 dark:text-amber-400'
|
||||||
|
}
|
||||||
|
className={rail.outcome === 'awarded' ? 'bg-emerald-50/60 dark:bg-emerald-950/20' : undefined}
|
||||||
|
sub={savingsText ?? undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 목표가 대비 하락폭(%) — 앵커가 목표가보다 낮은 정상 케이스만 의미가 있다. */
|
||||||
|
function pctGap(target: number, anchor: number): string {
|
||||||
|
if (!target) return '0.0%';
|
||||||
|
return `${(((target - anchor) / target) * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Cell({
|
||||||
|
label,
|
||||||
|
badge,
|
||||||
|
dot,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
onSub,
|
||||||
|
className,
|
||||||
|
valueClassName,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
badge?: string | null;
|
||||||
|
dot: string;
|
||||||
|
value: ReactNode;
|
||||||
|
sub?: string;
|
||||||
|
/** 있으면 보조줄이 버튼(목표가 산정 내역) */
|
||||||
|
onSub?: () => void;
|
||||||
|
className?: string;
|
||||||
|
valueClassName?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 border-r border-border px-4 py-2.5 last:border-r-0',
|
||||||
|
// 2열(모바일)에서는 윗줄에만 밑줄을 긋고 2번째 칸의 오른쪽 선을 지운다.
|
||||||
|
// 컨테이너가 이미 아래 테두리를 가지므로 아랫줄에 또 그으면 선이 겹쳐 두꺼워진다.
|
||||||
|
'[&:nth-child(-n+2)]:border-b sm:[&:nth-child(-n+2)]:border-b-0',
|
||||||
|
'[&:nth-child(2)]:border-r-0 sm:[&:nth-child(2)]:border-r',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-0.5 flex items-center gap-1.5">
|
||||||
|
<span className={cn('size-1.5 shrink-0 rounded-full', dot)} />
|
||||||
|
<Typography as="span" variant="caption" className="text-[10.5px] leading-tight">
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
{badge && (
|
||||||
|
<span className="rounded-full bg-amber-100 px-1.5 py-px text-[9.5px] font-bold text-amber-700 dark:bg-amber-950/50 dark:text-amber-400">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Typography
|
||||||
|
as="p"
|
||||||
|
variant="small"
|
||||||
|
className={cn('truncate font-mono text-[15px] font-bold tabular-nums tracking-tight text-foreground', valueClassName)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
{/* 보조줄은 값이 없어도 자리를 지킨다 — 칸마다 높이가 달라지면 레일 바닥이 들쭉날쭉해진다. */}
|
||||||
|
<div className="min-h-[16px] leading-4">
|
||||||
|
{onSub ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSub}
|
||||||
|
title="목표가 산정 내역 보기"
|
||||||
|
className="cursor-pointer text-[10.5px] text-primary hover:underline"
|
||||||
|
>
|
||||||
|
산정 내역 보기 ›
|
||||||
|
</button>
|
||||||
|
) : sub ? (
|
||||||
|
<Typography as="p" variant="caption" className="truncate text-[10.5px] tabular-nums">
|
||||||
|
{sub}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,152 +1,228 @@
|
|||||||
import { Fragment } from 'react';
|
import { useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { Typography } from '@/components/ui/typography';
|
import { Typography } from '@/components/ui/typography';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-');
|
const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-');
|
||||||
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
|
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
|
||||||
|
|
||||||
type Tone = 'amber' | 'emerald' | 'zinc';
|
/** 라벨 사이 최소 간격(px). 이보다 가까우면 다른 레인으로 보내거나 밀어낸다. */
|
||||||
const TONE_DOT: Record<Tone, string> = {
|
const LABEL_GAP = 10;
|
||||||
amber: 'bg-amber-500',
|
|
||||||
emerald: 'bg-emerald-500',
|
|
||||||
zinc: 'bg-zinc-400',
|
|
||||||
};
|
|
||||||
const TONE_TEXT: Record<Tone, string> = {
|
|
||||||
amber: 'text-amber-600',
|
|
||||||
emerald: 'text-emerald-600',
|
|
||||||
zinc: 'text-muted-foreground',
|
|
||||||
};
|
|
||||||
|
|
||||||
type Point = { name: string; value: number; tone: Tone; strong?: boolean; place?: 'above' | 'below' };
|
export type SpectrumBid = { name: string; value: number; isWinner?: boolean };
|
||||||
|
|
||||||
|
type Mark = { key: string; name: string; value: number; dot: string; tone?: string; strong?: boolean };
|
||||||
|
type Placed = { left: number; lane: 0 | 1 };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 단가 조정 흐름 스펙트럼 — 얇은 바에 목표가·최저·최고 투찰가를 찍고 값 라벨을 바로 옆에 붙인다.
|
* 가격 스펙트럼 — 목표가·타결 상한가를 세로 게이트로 세우고 그 사이를 타결 구간으로 칠한다.
|
||||||
* 값은 buildQuotationResult 파생값(별도 컬럼 없음). 위치는 세 값 min~max 도메인 선형 배치라 목표가가 범위 밖이어도 안 깨진다.
|
* 상한 초과 구역은 결렬 색. 협력사 제출가는 점으로 찍고 낙찰(또는 최저가)만 강조한다.
|
||||||
* 라벨 방향(위/아래)은 값 순 정렬 후 번갈아 배치 → 위치가 가까운(정렬상 인접) 라벨끼리 항상 반대쪽이라 안 겹친다.
|
*
|
||||||
|
* 라벨은 자기 기준선 위치에 붙인다. 겹침은 렌더 후 폭을 실측해 위/아래 두 레인으로 나누고,
|
||||||
|
* 같은 레인에서 부딪히면 오른쪽으로 밀어 해결한다(양 끝은 트랙 안으로 clamp).
|
||||||
*/
|
*/
|
||||||
export function PricingSpectrum({
|
export function PricingSpectrum({
|
||||||
targetPrice,
|
targetPrice,
|
||||||
lowestBid,
|
ceilingPrice,
|
||||||
highestBid,
|
anchorPrice,
|
||||||
|
bids,
|
||||||
}: {
|
}: {
|
||||||
targetPrice: number | null;
|
targetPrice: number | null;
|
||||||
lowestBid: number | null;
|
/** 타결 상한가 — 이 위는 결렬 구간. */
|
||||||
highestBid: number | null;
|
ceilingPrice: number | null;
|
||||||
|
/** 1:1 협상에서만. 1:N 은 협력사마다 달라 대표값이 없다. */
|
||||||
|
anchorPrice?: number | null;
|
||||||
|
/** 가격을 제출한 협력사들(투찰가 또는 거부 시 희망가). */
|
||||||
|
bids: SpectrumBid[];
|
||||||
}) {
|
}) {
|
||||||
// 투찰 0건 → 목표가만 한 줄(폴백). lowestBid/highestBid 는 같은 배열에서 나와 함께 null.
|
const winner = bids.find((b) => b.isWinner) ?? null;
|
||||||
if (lowestBid == null || highestBid == null) {
|
const lowest = bids.length ? bids.reduce((a, b) => (b.value < a.value ? b : a)) : null;
|
||||||
|
const spot = winner ?? lowest; // 강조점 — 낙찰가가 있으면 그것, 없으면 최저 제출가.
|
||||||
|
|
||||||
|
// 라벨을 다는 기준값들(값 오름차순). 나머지 제출가는 점만 찍고 title 로 알려준다.
|
||||||
|
const marks: Mark[] = [
|
||||||
|
anchorPrice != null ? { key: 'anchor', name: '앵커링가', value: anchorPrice, dot: 'bg-zinc-400' } : null,
|
||||||
|
targetPrice != null ? { key: 'target', name: '목표가', value: targetPrice, dot: 'bg-primary', tone: 'text-primary' } : null,
|
||||||
|
ceilingPrice != null
|
||||||
|
? { key: 'ceiling', name: '타결 상한', value: ceilingPrice, dot: 'bg-amber-500', tone: 'text-amber-600 dark:text-amber-400' }
|
||||||
|
: null,
|
||||||
|
spot
|
||||||
|
? {
|
||||||
|
key: 'spot',
|
||||||
|
name: winner ? `낙찰 · ${winner.name}` : `최저 · ${spot.name}`,
|
||||||
|
value: spot.value,
|
||||||
|
dot: winner ? 'bg-emerald-500' : 'bg-zinc-500',
|
||||||
|
tone: winner ? 'text-emerald-600 dark:text-emerald-400' : undefined,
|
||||||
|
strong: true,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter((m): m is Mark => m != null)
|
||||||
|
.sort((a, b) => a.value - b.value);
|
||||||
|
|
||||||
|
const values = [...marks.map((m) => m.value), ...bids.map((b) => b.value)];
|
||||||
|
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
const labelRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
||||||
|
const [placed, setPlaced] = useState<Placed[]>([]);
|
||||||
|
|
||||||
|
// 축 도메인. 타결 상한가가 최댓값이면(= 상한을 넘긴 제출이 없으면) 위쪽에 여유를 크게 둬서
|
||||||
|
// '초과 구간'이 눈에 보이게 한다 — 여유가 없으면 상한선이 트랙 끝에 처박혀 결렬 구간이 사라진다.
|
||||||
|
const lo = values.length ? Math.min(...values) : 0;
|
||||||
|
const hi = values.length ? Math.max(...values) : 0;
|
||||||
|
const base = hi - lo || Math.max(hi * 0.02, 1);
|
||||||
|
const padHi = ceilingPrice != null && hi <= ceilingPrice ? base * 0.22 : base * 0.08;
|
||||||
|
const min = lo - base * 0.08;
|
||||||
|
const span = hi + padHi - min;
|
||||||
|
const posOf = (v: number) => clamp(span > 0 ? ((v - min) / span) * 100 : 50, 0, 100);
|
||||||
|
|
||||||
|
// 렌더된 라벨 폭을 재서 레인·위치를 정한다. 폭이 바뀌면(창 크기·글꼴) 다시 잰다.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const track = trackRef.current;
|
||||||
|
if (!track) return;
|
||||||
|
|
||||||
|
const layout = () => {
|
||||||
|
const trackW = track.clientWidth;
|
||||||
|
if (!trackW) return;
|
||||||
|
const laneEnd: [number, number] = [-Infinity, -Infinity];
|
||||||
|
const next: Placed[] = marks.map((m, i) => {
|
||||||
|
const w = labelRefs.current[i]?.offsetWidth ?? 0;
|
||||||
|
const wanted = clamp((posOf(m.value) / 100) * trackW - w / 2, 0, Math.max(trackW - w, 0));
|
||||||
|
// 부딪히지 않는 레인을 먼저 찾고, 둘 다 막히면 덜 채워진 레인에서 오른쪽으로 민다.
|
||||||
|
const fits0 = wanted >= laneEnd[0] + LABEL_GAP;
|
||||||
|
const fits1 = wanted >= laneEnd[1] + LABEL_GAP;
|
||||||
|
const lane: 0 | 1 = fits0 ? 0 : fits1 ? 1 : laneEnd[0] <= laneEnd[1] ? 0 : 1;
|
||||||
|
const left = clamp(Math.max(wanted, laneEnd[lane] + LABEL_GAP), 0, Math.max(trackW - w, 0));
|
||||||
|
laneEnd[lane] = left + w;
|
||||||
|
return { left, lane };
|
||||||
|
});
|
||||||
|
setPlaced(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
layout();
|
||||||
|
const ro = new ResizeObserver(layout);
|
||||||
|
ro.observe(track);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
// 값 목록이 바뀌면 다시 배치한다(라벨 문자열·개수가 바뀌므로).
|
||||||
|
}, [marks.map((m) => `${m.key}:${m.value}:${m.name}`).join('|')]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
if (values.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Typography as="p" variant="caption" className="text-[11px]">
|
<Typography as="p" variant="caption" className="text-[11px]">
|
||||||
<span className="text-amber-600">목표가</span>{' '}
|
표시할 가격이 없습니다.
|
||||||
<span className="font-mono font-semibold text-foreground">{won(targetPrice)}</span>
|
|
||||||
<span className="text-muted-foreground"> · 투찰 없음</span>
|
|
||||||
</Typography>
|
</Typography>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const singleBid = lowestBid === highestBid; // 투찰 1건 → 최저=최고, 라벨 하나로 축약.
|
// 아무도 가격을 내지 않았으면 타결/결렬 구간을 색으로 단정하지 않는다 — 판정할 대상이 없다.
|
||||||
|
const hasBids = bids.length > 0;
|
||||||
// 표시할 점(목표가는 없을 수 있음). 값 순으로 정렬해 라벨 방향을 번갈아(below/above) 주면
|
const laneUsed = (lane: 0 | 1) => placed.some((p) => p.lane === lane);
|
||||||
// 위치가 가까운(정렬상 인접) 라벨끼리 항상 반대쪽에 놓여 안 겹친다.
|
|
||||||
const points: Point[] = [
|
|
||||||
targetPrice != null ? { name: '목표가', value: targetPrice, tone: 'amber' } : null,
|
|
||||||
{ name: singleBid ? '투찰가' : '최저 투찰가', value: lowestBid, tone: 'emerald', strong: true },
|
|
||||||
singleBid ? null : { name: '최고 투찰가', value: highestBid, tone: 'zinc' },
|
|
||||||
].filter((p): p is Point => p != null);
|
|
||||||
|
|
||||||
const vals = points.map((p) => p.value);
|
|
||||||
const min = Math.min(...vals);
|
|
||||||
const max = Math.max(...vals);
|
|
||||||
const span = max - min;
|
|
||||||
const posOf = (v: number) => (span > 0 ? ((v - min) / span) * 100 : 50);
|
|
||||||
|
|
||||||
[...points]
|
|
||||||
.sort((a, b) => a.value - b.value)
|
|
||||||
.forEach((p, i) => {
|
|
||||||
p.place = i % 2 === 0 ? 'below' : 'above';
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full px-1">
|
<div className="w-full">
|
||||||
<div className="relative mx-1 my-3.5 h-1">
|
{/* 위 레인 자리(라벨이 올라갈 때만 높이 확보) */}
|
||||||
{/* 트랙 */}
|
<div className={cn('relative', laneUsed(1) ? 'h-6' : 'h-0')} aria-hidden />
|
||||||
<div className="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-border/60" />
|
|
||||||
{/* 투찰 스프레드(최저~최고) */}
|
<div ref={trackRef} className="relative h-1.5">
|
||||||
{!singleBid && (
|
<div className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-muted" />
|
||||||
|
|
||||||
|
{/* 타결 구간(상한 이하) / 상한 초과 구간 */}
|
||||||
|
{ceilingPrice != null && (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
className="absolute top-1/2 h-1 -translate-y-1/2 rounded-full bg-gradient-to-r from-emerald-400 to-zinc-300"
|
className={cn(
|
||||||
style={{ left: `${posOf(lowestBid)}%`, width: `${posOf(highestBid) - posOf(lowestBid)}%` }}
|
'absolute top-1/2 h-1.5 -translate-y-1/2 rounded-l-full',
|
||||||
/>
|
hasBids ? 'bg-emerald-100 dark:bg-emerald-950/60' : 'bg-muted-foreground/15',
|
||||||
)}
|
)}
|
||||||
{points.map((p) => (
|
style={{ left: 0, width: `${posOf(ceilingPrice)}%` }}
|
||||||
<Fragment key={p.name}>
|
/>
|
||||||
<Dot pos={posOf(p.value)} tone={p.tone} strong={p.strong} />
|
<div
|
||||||
<BarLabel
|
className={cn(
|
||||||
pos={posOf(p.value)}
|
'absolute top-1/2 h-1.5 -translate-y-1/2 rounded-r-full',
|
||||||
place={p.place ?? 'below'}
|
hasBids ? 'bg-rose-100 dark:bg-rose-950/50' : 'bg-muted-foreground/25',
|
||||||
name={p.name}
|
)}
|
||||||
value={won(p.value)}
|
style={{ left: `${posOf(ceilingPrice)}%`, right: 0 }}
|
||||||
tone={p.tone}
|
/>
|
||||||
strong={p.strong}
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 판정 게이트 */}
|
||||||
|
{anchorPrice != null && <Gate pos={posOf(anchorPrice)} className="bg-zinc-400" />}
|
||||||
|
{targetPrice != null && <Gate pos={posOf(targetPrice)} className="bg-primary" />}
|
||||||
|
{ceilingPrice != null && <Gate pos={posOf(ceilingPrice)} className="bg-amber-500" />}
|
||||||
|
|
||||||
|
{/* 협력사 제출가 */}
|
||||||
|
{bids.map((b) => (
|
||||||
|
<span
|
||||||
|
key={`${b.name}-${b.value}`}
|
||||||
|
title={`${b.name} · ${won(b.value)}`}
|
||||||
|
className={cn(
|
||||||
|
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-card',
|
||||||
|
b.isWinner
|
||||||
|
? 'size-3.5 bg-emerald-500 ring-2 ring-emerald-200 dark:ring-emerald-900'
|
||||||
|
: 'size-2.5 bg-zinc-400 dark:bg-zinc-500',
|
||||||
|
)}
|
||||||
|
style={{ left: `${posOf(b.value)}%` }}
|
||||||
/>
|
/>
|
||||||
</Fragment>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 트랙 위 점. 위치는 0~100% 로 클램프. */
|
{/* 라벨 — 실측 배치. 첫 렌더(placed 비어있음)에는 기준선 위치에 그대로 두고 바로 재배치된다. */}
|
||||||
function Dot({ pos, tone, strong }: { pos: number; tone: Tone; strong?: boolean }) {
|
{marks.map((m, i) => {
|
||||||
|
const p = placed[i];
|
||||||
|
const lane = p?.lane ?? 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<span
|
||||||
|
key={m.key}
|
||||||
|
ref={(el) => {
|
||||||
|
labelRefs.current[i] = el;
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full ring-2 ring-background',
|
'absolute inline-flex items-center gap-1 whitespace-nowrap leading-none',
|
||||||
strong ? 'h-2.5 w-2.5' : 'h-2 w-2',
|
lane === 0 ? 'top-3.5' : 'bottom-3.5',
|
||||||
TONE_DOT[tone],
|
|
||||||
)}
|
)}
|
||||||
style={{ left: `${clamp(pos, 0, 100)}%` }}
|
style={
|
||||||
/>
|
p
|
||||||
);
|
? { left: `${p.left}px` }
|
||||||
|
: { left: `${posOf(m.value)}%`, transform: 'translateX(-50%)', visibility: 'hidden' }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 점 위/아래 한 줄 라벨(이름+금액). 양 끝에선 안쪽으로 정렬해 넘침 방지. */
|
|
||||||
function BarLabel({
|
|
||||||
pos,
|
|
||||||
place,
|
|
||||||
name,
|
|
||||||
value,
|
|
||||||
tone,
|
|
||||||
strong,
|
|
||||||
}: {
|
|
||||||
pos: number;
|
|
||||||
place: 'above' | 'below';
|
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
tone: Tone;
|
|
||||||
strong?: boolean;
|
|
||||||
}) {
|
|
||||||
const align =
|
|
||||||
pos <= 8 ? 'translate-x-0 text-left' : pos >= 92 ? '-translate-x-full text-right' : '-translate-x-1/2 text-center';
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'absolute whitespace-nowrap leading-none',
|
|
||||||
place === 'above' ? 'bottom-full mb-1' : 'top-full mt-1',
|
|
||||||
align,
|
|
||||||
)}
|
|
||||||
style={{ left: `${clamp(pos, 0, 100)}%` }}
|
|
||||||
>
|
>
|
||||||
<Typography as="span" variant="caption" className={cn('text-[10px]', TONE_TEXT[tone])}>
|
<span className={cn('size-1.5 shrink-0 rounded-full', m.dot)} />
|
||||||
{name}{' '}
|
<Typography as="span" variant="caption" className={cn('text-[10px]', m.tone)}>
|
||||||
|
{m.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
as="span"
|
as="span"
|
||||||
variant="small"
|
variant="small"
|
||||||
className={cn('font-mono text-[11px] text-foreground', strong ? 'font-bold' : 'font-semibold')}
|
className={cn(
|
||||||
|
'font-mono text-[10.5px] tabular-nums text-foreground',
|
||||||
|
m.strong ? 'font-bold' : 'font-semibold',
|
||||||
|
m.tone,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{value}
|
{won(m.value)}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 아래 레인 자리. 제출 건수는 있을 때만 — 없다는 사실은 상단 결과 배지와 레일이 이미 말한다. */}
|
||||||
|
<div className={cn(laneUsed(0) ? 'mt-6' : 'mt-1.5')}>
|
||||||
|
{hasBids && (
|
||||||
|
<Typography as="span" variant="caption" className="text-[10.5px]">
|
||||||
|
제출 {bids.length}건
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 트랙을 가로지르는 판정선. */
|
||||||
|
function Gate({ pos, className }: { pos: number; className: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn('absolute -top-1.5 -bottom-1.5 w-0.5 -translate-x-1/2 rounded-full', className)}
|
||||||
|
style={{ left: `${pos}%` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -183,7 +183,7 @@ export function RegenerateModal({
|
|||||||
<div className="my-4 space-y-4 text-xs">
|
<div className="my-4 space-y-4 text-xs">
|
||||||
{/* ── 직전 라운드 결과 — 무엇을 바꿔야 할지의 근거 ── */}
|
{/* ── 직전 라운드 결과 — 무엇을 바꿔야 할지의 근거 ── */}
|
||||||
<Section title="직전 라운드 결과" sub={`${quotation.round ?? 1}차`}>
|
<Section title="직전 라운드 결과" sub={`${quotation.round ?? 1}차`}>
|
||||||
<ResultSummaryBand quotation={quotation} sessionViews={sessionViews} />
|
<ResultSummaryBand quotation={quotation} sessionViews={sessionViews} ceilingRate={inheritedCeilingRate} />
|
||||||
{hint && (
|
{hint && (
|
||||||
<div className="mt-2 flex items-start gap-2 rounded border border-amber-200 bg-amber-50/60 dark:border-amber-900/50 dark:bg-amber-950/20 px-2.5 py-2">
|
<div className="mt-2 flex items-start gap-2 rounded border border-amber-200 bg-amber-50/60 dark:border-amber-900/50 dark:bg-amber-950/20 px-2.5 py-2">
|
||||||
<Lightbulb size={13} className="mt-0.5 shrink-0 text-amber-600" />
|
<Lightbulb size={13} className="mt-0.5 shrink-0 text-amber-600" />
|
||||||
|
|||||||
@ -1,20 +1,20 @@
|
|||||||
import type { ReactNode } from 'react';
|
import { BadgeCheck } from 'lucide-react';
|
||||||
import { BadgeCheck, TrendingDown, TrendingUp } from 'lucide-react';
|
|
||||||
import { Card } from '@/components/ui/card';
|
|
||||||
import { Typography } from '@/components/ui/typography';
|
import { Typography } from '@/components/ui/typography';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { QuotationData } from '@/api/generated/model/quotationData';
|
import type { QuotationData } from '@/api/generated/model/quotationData';
|
||||||
import { StatusPill, type PillTone } from './StatusPill';
|
import { StatusPill, sessionStatusTone, type PillTone } from './StatusPill';
|
||||||
import { PricingSpectrum } from './PricingSpectrum';
|
import { PricingSpectrum, type SpectrumBid } from './PricingSpectrum';
|
||||||
|
import { SessionStatus } from '@/api/generated/model';
|
||||||
import {
|
import {
|
||||||
|
awardPrice,
|
||||||
buildQuotationResult,
|
buildQuotationResult,
|
||||||
|
ceilingPriceOf,
|
||||||
|
is1v1,
|
||||||
|
sessionStatusLabel,
|
||||||
CHAIN_ROUND_STATE_LABEL,
|
CHAIN_ROUND_STATE_LABEL,
|
||||||
type ChainRoundState,
|
type ChainRoundState,
|
||||||
type SessionView,
|
type SessionView,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
|
||||||
const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-');
|
|
||||||
|
|
||||||
// 결과 상태별 배지 톤(협상현황 pill 팔레트 재사용). 개찰=주황(낙찰자 미정, 수동 처리 필요).
|
// 결과 상태별 배지 톤(협상현황 pill 팔레트 재사용). 개찰=주황(낙찰자 미정, 수동 처리 필요).
|
||||||
const OUTCOME_TONE: Record<ChainRoundState, PillTone> = {
|
const OUTCOME_TONE: Record<ChainRoundState, PillTone> = {
|
||||||
awarded: 'emerald',
|
awarded: 'emerald',
|
||||||
@ -22,85 +22,86 @@ const OUTCOME_TONE: Record<ChainRoundState, PillTone> = {
|
|||||||
active: 'zinc',
|
active: 'zinc',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 견적 결과 요약 밴드 — 마감 사유/낙찰 협력사 + 절감, 하단에 단가 조정 흐름 스펙트럼(최고·최저 투찰가/목표가). */
|
/**
|
||||||
|
* 결과 요약 밴드 — 마감 결과/사유와 낙찰 협력사, 그리고 가격 스펙트럼.
|
||||||
|
* 금액(목표가·상한가·낙찰가·절감)은 헤더 가격 레일이 책임진다. 여기서 되풀이하지 않는다.
|
||||||
|
*/
|
||||||
export function ResultSummaryBand({
|
export function ResultSummaryBand({
|
||||||
quotation,
|
quotation,
|
||||||
sessionViews,
|
sessionViews,
|
||||||
|
ceilingRate,
|
||||||
}: {
|
}: {
|
||||||
quotation: QuotationData;
|
quotation: QuotationData;
|
||||||
sessionViews: SessionView[];
|
sessionViews: SessionView[];
|
||||||
|
/** 타결 상한율(‰) — 견적 override 없으면 적용 세팅값. */
|
||||||
|
ceilingRate: number;
|
||||||
}) {
|
}) {
|
||||||
const r = buildQuotationResult(quotation, sessionViews);
|
const r = buildQuotationResult(quotation, sessionViews);
|
||||||
const good = r.savings != null && r.savings >= 0;
|
const rep = sessionViews.find((s) => s.target_price > 0) ?? sessionViews[0];
|
||||||
const pct = r.savingsRate != null ? `${Math.abs(r.savingsRate * 100).toFixed(1)}%` : null;
|
const winnerId = quotation.preferred_sp_id ?? null;
|
||||||
const SavingsIcon = good ? TrendingDown : TrendingUp;
|
// 1:1 은 협력사가 하나라 그 세션이 곧 이 협상이다(세션이 여럿이면 대표를 세우지 않는다).
|
||||||
|
const oneToOne = is1v1(quotation.type);
|
||||||
|
const solo = oneToOne && sessionViews.length === 1 ? sessionViews[0] : null;
|
||||||
|
|
||||||
|
// 가격을 제출한 협력사만 점으로. 낙찰자는 강조.
|
||||||
|
const bids: SpectrumBid[] = sessionViews.flatMap((s) => {
|
||||||
|
const value = awardPrice(s);
|
||||||
|
if (value == null) return [];
|
||||||
|
return [{ name: s.supplier_name, value, isWinner: winnerId != null && s.supplier_id === winnerId }];
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-2.5 gap-2 rounded shadow-xs border-border/80 flex flex-col">
|
<div className="flex flex-col gap-3 rounded border border-border/80 bg-card p-3 shadow-xs">
|
||||||
{/* 결과/사유 + 낙찰 협력사 + 절감 */}
|
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<div className="flex items-center justify-between gap-2">
|
{/* 1:1 은 협력사가 하나라 세션 상태가 곧 이 협상의 결과다 — 견적 결과 배지까지 붙이면
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
'거부해서 개찰됐다'는 한 사실을 배지 두 개로 두 번 말하게 된다(견적 결과는 가격 레일이 말한다). */}
|
||||||
<StatusPill tone={OUTCOME_TONE[r.outcome]}>{CHAIN_ROUND_STATE_LABEL[r.outcome]}</StatusPill>
|
{solo ? (
|
||||||
{/* 낙찰 건은 협력사명을 주줄로(사유 '낙찰'은 pill 과 중복이라 생략), 그 외엔 마감사유를 표기. */}
|
<>
|
||||||
{r.winnerName ? (
|
<Typography as="span" variant="small" className="truncate font-sans text-[12px] font-bold text-foreground">
|
||||||
<div className="min-w-0">
|
{solo.supplier_name}
|
||||||
<Typography as="p" variant="caption" className="text-[10px] leading-tight text-muted-foreground">
|
|
||||||
낙찰 협력사
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{/* 마감된 1:1 은 '무엇을 했고(세션) 그래서 어떻게 끝났는지(견적)'가 한 사실이라 배지 하나로 잇는다.
|
||||||
|
진행 중이면 견적 결과가 '진행중'이라 같은 말이 되므로 세션 상태만. */}
|
||||||
|
<StatusPill
|
||||||
|
tone={
|
||||||
|
solo.status === SessionStatus.REJECTED
|
||||||
|
? 'rose' // 거부는 결과(개찰)보다 강한 신호다 — 색을 결과 톤에 묻히게 두지 않는다.
|
||||||
|
: r.outcome === 'active'
|
||||||
|
? sessionStatusTone(solo.status)
|
||||||
|
: OUTCOME_TONE[r.outcome]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{r.outcome === 'active'
|
||||||
|
? sessionStatusLabel(solo.status)
|
||||||
|
: `${sessionStatusLabel(solo.status)} — ${CHAIN_ROUND_STATE_LABEL[r.outcome]}`}
|
||||||
|
</StatusPill>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<StatusPill tone={OUTCOME_TONE[r.outcome]}>{CHAIN_ROUND_STATE_LABEL[r.outcome]}</StatusPill>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{solo ? null : r.winnerName ? (
|
||||||
<Typography
|
<Typography
|
||||||
as="p"
|
as="p"
|
||||||
variant="small"
|
variant="small"
|
||||||
className="flex items-center gap-1 text-[12px] font-bold font-sans text-foreground truncate"
|
className="flex min-w-0 items-center gap-1 truncate font-sans text-[12px] font-bold text-foreground"
|
||||||
>
|
>
|
||||||
<BadgeCheck size={13} className="text-emerald-600 shrink-0" />
|
<BadgeCheck size={13} className="shrink-0 text-emerald-600" />
|
||||||
{r.winnerName}
|
{r.winnerName}
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<Typography as="p" variant="small" className="text-[12px] font-bold truncate min-w-0">
|
<Typography as="p" variant="small" className="min-w-0 truncate text-[12px] font-bold">
|
||||||
{r.closeReason}
|
{r.closeReason}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Stat label={r.provisional ? '예상 절감' : '목표가 대비'}>
|
<PricingSpectrum
|
||||||
{r.savings == null ? (
|
targetPrice={r.targetPrice}
|
||||||
<Typography as="span" variant="small" className="font-mono text-muted-foreground text-[13px]">
|
ceilingPrice={ceilingPriceOf(rep, ceilingRate)}
|
||||||
-
|
anchorPrice={is1v1(quotation.type) ? (rep?.anchoring_price ?? null) : null}
|
||||||
</Typography>
|
bids={bids}
|
||||||
) : (
|
/>
|
||||||
<Typography
|
|
||||||
as="span"
|
|
||||||
variant="small"
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-1 font-mono font-bold text-[13px]',
|
|
||||||
good ? 'text-emerald-600' : 'text-amber-600',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<SavingsIcon size={13} />
|
|
||||||
{good ? '' : '−'}
|
|
||||||
{won(Math.abs(r.savings))}
|
|
||||||
{pct ? ` (${pct})` : ''}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Stat>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 단가 조정 흐름 스펙트럼: 최고 투찰가 → 최저 투찰가 · 목표가 */}
|
|
||||||
<PricingSpectrum targetPrice={r.targetPrice} lowestBid={r.lowestBid} highestBid={r.highestBid} />
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 밴드 안의 라벨/값 한 칸. */
|
|
||||||
function Stat({ label, sub, children }: { label: string; sub?: string; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col justify-center rounded border border-border bg-muted/30 px-3 py-1.5 min-w-[108px]">
|
|
||||||
<Typography as="span" variant="caption" className="text-[10px] leading-tight">
|
|
||||||
{label}
|
|
||||||
{sub ? ` · ${sub}` : ''}
|
|
||||||
</Typography>
|
|
||||||
{children}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,8 @@ import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@
|
|||||||
import { SessionStatus } from '@/api/generated/model';
|
import { SessionStatus } from '@/api/generated/model';
|
||||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||||
import { StatusPill, sessionStatusTone } from './StatusPill';
|
import { StatusPill, sessionStatusTone } from './StatusPill';
|
||||||
import { awardPrice, mapServerSessionView, sessionStatusLabel } from '../../types';
|
import { AwardModal } from './AwardModal';
|
||||||
|
import { awardPrice, offlineAward, mapServerSessionView, sessionStatusLabel } from '../../types';
|
||||||
import { useCompanySettings } from '@/features/settings/useCompanySettings';
|
import { useCompanySettings } from '@/features/settings/useCompanySettings';
|
||||||
|
|
||||||
type SessionView = ReturnType<typeof mapServerSessionView>;
|
type SessionView = ReturnType<typeof mapServerSessionView>;
|
||||||
@ -37,7 +38,7 @@ export function SessionsStatusTab({
|
|||||||
/** 한 세션(공급사)에 초청 메일 발송/재발송. */
|
/** 한 세션(공급사)에 초청 메일 발송/재발송. */
|
||||||
onNotifyOne: (sessionId: string) => Promise<void>;
|
onNotifyOne: (sessionId: string) => Promise<void>;
|
||||||
/** 고른 협력사를 낙찰 처리. 성공 시 true. */
|
/** 고른 협력사를 낙찰 처리. 성공 시 true. */
|
||||||
onAward: (supplierId: string, supplierName: string) => Promise<boolean>;
|
onAward: (supplierId: string, supplierName: string, contractPrice: number, contractNote: string) => Promise<boolean>;
|
||||||
}) {
|
}) {
|
||||||
const { settings } = useCompanySettings();
|
const { settings } = useCompanySettings();
|
||||||
const sessionFields = settings.session_fields ?? [];
|
const sessionFields = settings.session_fields ?? [];
|
||||||
@ -55,34 +56,32 @@ export function SessionsStatusTab({
|
|||||||
const [sendingId, setSendingId] = useState<string | null>(null);
|
const [sendingId, setSendingId] = useState<string | null>(null);
|
||||||
const [selectedWinnerId, setSelectedWinnerId] = useState<string | null>(null);
|
const [selectedWinnerId, setSelectedWinnerId] = useState<string | null>(null);
|
||||||
const [awarding, setAwarding] = useState(false);
|
const [awarding, setAwarding] = useState(false);
|
||||||
|
const [awardOpen, setAwardOpen] = useState(false);
|
||||||
const unsentCount = sessionViews.filter((s) => !s.email_sent_at).length;
|
const unsentCount = sessionViews.filter((s) => !s.email_sent_at).length;
|
||||||
|
|
||||||
// 낙찰 후보 = 가격을 써낸 협력사(투찰한 협상완료 + 공급 희망가를 남긴 협상거부).
|
// 낙찰 후보 = 이 견적에 초청된 협력사 전부. 결렬·미응찰 건은 오프라인으로 다시 협상하고 그 결과를
|
||||||
// 협상이 결렬돼도 최종 제출가로 계약을 진행하므로 거부 건도 후보에 넣는다.
|
// 반영하는 자리라, 시스템에 가격을 안 낸 협력사도 고를 수 있어야 한다(계약가는 담당자가 입력).
|
||||||
const candidates = sessionViews.filter((s) => awardPrice(s) != null);
|
const candidates = sessionViews;
|
||||||
const showAward = canAward && candidates.length > 0;
|
const showAward = canAward && candidates.length > 0;
|
||||||
// 최저가 = 자동낙찰과 같은 기준 → 추천 표시(담당자가 동가/사정상 다른 곳을 골라도 됨).
|
// 최저 제출가 = 자동낙찰과 같은 기준 → 추천 표시(담당자가 동가/사정상 다른 곳을 골라도 됨).
|
||||||
const lowestBid = candidates.length ? Math.min(...candidates.map((s) => awardPrice(s) as number)) : null;
|
const submitted = candidates.map(awardPrice).filter((v): v is number => v != null);
|
||||||
|
const lowestBid = submitted.length ? Math.min(...submitted) : null;
|
||||||
const selectedWinner = candidates.find((s) => s.supplier_id === selectedWinnerId) ?? null;
|
const selectedWinner = candidates.find((s) => s.supplier_id === selectedWinnerId) ?? null;
|
||||||
const colCount = showAward ? 13 : 12;
|
// 상품은 견적당 하나다 — 모든 행이 같은 값이라 컬럼을 두지 않는다(헤더 상품 카드가 정본).
|
||||||
|
// 마감시각은 세션 컬럼이라 갈릴 수 있어, 실제로 다를 때만 세운다.
|
||||||
|
const showEndTimeCol = new Set(sessionViews.map((s) => s.end_time)).size > 1;
|
||||||
|
const colCount = (showAward ? 13 : 12) - (showEndTimeCol ? 0 : 1);
|
||||||
|
|
||||||
const handleAward = async () => {
|
const handleAward = async (contractPrice: number, contractNote: string): Promise<boolean> => {
|
||||||
if (!selectedWinner) return;
|
if (!selectedWinner) return false;
|
||||||
const name = selectedWinner.supplier_name;
|
|
||||||
const winnerPrice = awardPrice(selectedWinner);
|
|
||||||
const price = winnerPrice != null ? `₩${winnerPrice.toLocaleString()}` : '-';
|
|
||||||
if (
|
|
||||||
!(await confirm({
|
|
||||||
title: '직접 낙찰',
|
|
||||||
description: `[${name}] (계약가 ${price})을(를) 낙찰 처리하시겠습니까? 낙찰은 되돌릴 수 없습니다.`,
|
|
||||||
confirmText: '낙찰 확정',
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
setAwarding(true);
|
setAwarding(true);
|
||||||
try {
|
try {
|
||||||
const ok = await onAward(selectedWinner.supplier_id, name);
|
const ok = await onAward(selectedWinner.supplier_id, selectedWinner.supplier_name, contractPrice, contractNote);
|
||||||
if (ok) setSelectedWinnerId(null);
|
if (ok) {
|
||||||
|
setSelectedWinnerId(null);
|
||||||
|
setAwardOpen(false);
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
} finally {
|
} finally {
|
||||||
setAwarding(false);
|
setAwarding(false);
|
||||||
}
|
}
|
||||||
@ -156,7 +155,7 @@ export function SessionsStatusTab({
|
|||||||
{selectedWinner && <span className="ml-1 font-bold">선택: {selectedWinner.supplier_name}</span>}
|
{selectedWinner && <span className="ml-1 font-bold">선택: {selectedWinner.supplier_name}</span>}
|
||||||
</Typography>
|
</Typography>
|
||||||
<button
|
<button
|
||||||
onClick={handleAward}
|
onClick={() => setAwardOpen(true)}
|
||||||
disabled={!selectedWinner || awarding}
|
disabled={!selectedWinner || awarding}
|
||||||
title={selectedWinner ? '선택한 협력사를 낙찰 처리합니다.' : '먼저 낙찰할 협력사를 선택하세요.'}
|
title={selectedWinner ? '선택한 협력사를 낙찰 처리합니다.' : '먼저 낙찰할 협력사를 선택하세요.'}
|
||||||
className="flex shrink-0 items-center gap-2 px-3 py-2 bg-success text-white text-xs font-bold rounded hover:bg-success/90 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
className="flex shrink-0 items-center gap-2 px-3 py-2 bg-success text-white text-xs font-bold rounded hover:bg-success/90 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
@ -168,22 +167,22 @@ export function SessionsStatusTab({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="hidden xl:block border border-border rounded-lg bg-card overflow-x-auto">
|
<div className="hidden xl:block border border-border rounded-lg bg-card overflow-x-auto">
|
||||||
<Table className="w-full text-left text-xs border-collapse font-mono min-w-[1250px]">
|
<Table className={cn('w-full text-left text-xs border-collapse font-mono', showEndTimeCol ? 'min-w-[1140px]' : 'min-w-[1040px]')}>
|
||||||
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
|
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{showAward && <TableHead className="p-3 font-semibold text-center font-sans w-14">낙찰선택</TableHead>}
|
{showAward && <TableHead className="p-3 font-semibold text-center font-sans w-14">낙찰선택</TableHead>}
|
||||||
<TableHead className="p-3 font-semibold font-sans">협력사</TableHead>
|
<TableHead className="p-3 font-semibold font-sans">협력사</TableHead>
|
||||||
<TableHead className="p-2 font-semibold text-center w-10">URL</TableHead>
|
<TableHead className="p-2 font-semibold text-center w-10">URL</TableHead>
|
||||||
<TableHead className="p-3 font-semibold text-center font-sans">초청메일</TableHead>
|
<TableHead className="p-3 font-semibold text-center font-sans">초청메일</TableHead>
|
||||||
<TableHead className="p-3 font-semibold font-sans">상품</TableHead>
|
|
||||||
<TableHead className="p-3 font-semibold text-center font-sans">협상상태</TableHead>
|
<TableHead className="p-3 font-semibold text-center font-sans">협상상태</TableHead>
|
||||||
<TableHead className="p-3 font-semibold text-right">앵커링가</TableHead>
|
<TableHead className="p-3 font-semibold text-right">앵커링가</TableHead>
|
||||||
<TableHead className="p-3 font-semibold text-right">투찰가</TableHead>
|
<TableHead className="p-3 font-semibold text-right">투찰가</TableHead>
|
||||||
<TableHead className="p-3 font-semibold">투찰시각</TableHead>
|
<TableHead className="p-3 font-semibold">투찰시각</TableHead>
|
||||||
<TableHead className="p-3 font-semibold">마감시각</TableHead>
|
{showEndTimeCol && <TableHead className="p-3 font-semibold">마감시각</TableHead>}
|
||||||
<TableHead className="p-3 font-semibold font-sans">거부사유</TableHead>
|
<TableHead className="p-3 font-semibold font-sans">거부사유</TableHead>
|
||||||
<TableHead className="p-3 font-semibold text-right">거부가격</TableHead>
|
<TableHead className="p-3 font-semibold text-right">거부가격</TableHead>
|
||||||
<TableHead className="p-3 font-semibold font-sans">거부배송방식</TableHead>
|
<TableHead className="p-3 font-semibold font-sans">거부배송방식</TableHead>
|
||||||
|
<TableHead className="p-3 font-semibold font-sans">부가정보</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody className="divide-y divide-border">
|
<TableBody className="divide-y divide-border">
|
||||||
@ -195,7 +194,7 @@ export function SessionsStatusTab({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{sessionViews.map((sess) => {
|
{sessionViews.map((sess) => {
|
||||||
const isCandidate = awardPrice(sess) != null;
|
const isCandidate = true; // 오프라인 협상 반영 — 가격을 안 낸 협력사도 낙찰 대상
|
||||||
const isRecommended = isCandidate && awardPrice(sess) === lowestBid;
|
const isRecommended = isCandidate && awardPrice(sess) === lowestBid;
|
||||||
const isWinner = !!winnerSupplierId && sess.supplier_id === winnerSupplierId;
|
const isWinner = !!winnerSupplierId && sess.supplier_id === winnerSupplierId;
|
||||||
return (
|
return (
|
||||||
@ -314,7 +313,6 @@ export function SessionsStatusTab({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="p-3 font-semibold font-sans">{sess.item_name}</TableCell>
|
|
||||||
<TableCell className="p-3 text-center">
|
<TableCell className="p-3 text-center">
|
||||||
<StatusPill tone={sessionStatusTone(sess.status)}>{sessionStatusLabel(sess.status)}</StatusPill>
|
<StatusPill tone={sessionStatusTone(sess.status)}>{sessionStatusLabel(sess.status)}</StatusPill>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@ -334,14 +332,24 @@ export function SessionsStatusTab({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="p-3 text-right font-bold text-foreground">
|
<TableCell className="p-3 text-right font-bold text-foreground">
|
||||||
{sess.bid_price ? `₩${sess.bid_price.toLocaleString()}` : '-'}
|
{sess.bid_price ? `₩${sess.bid_price.toLocaleString()}` : '-'}
|
||||||
|
{offlineAward(sess) && (
|
||||||
|
<Typography as="span" variant="caption" className="block text-[10px] font-bold text-success">
|
||||||
|
계약 ₩{offlineAward(sess)!.price.toLocaleString()}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="p-3 text-muted-foreground">{sess.bid_at || '-'}</TableCell>
|
<TableCell className="p-3 text-muted-foreground">{sess.bid_at || '-'}</TableCell>
|
||||||
|
{showEndTimeCol && (
|
||||||
<TableCell className="p-3 text-muted-foreground font-sans">{sess.end_time || '-'}</TableCell>
|
<TableCell className="p-3 text-muted-foreground font-sans">{sess.end_time || '-'}</TableCell>
|
||||||
|
)}
|
||||||
<TableCell className="p-3 text-rose-600 font-sans">{sess.reject_reason || '-'}</TableCell>
|
<TableCell className="p-3 text-rose-600 font-sans">{sess.reject_reason || '-'}</TableCell>
|
||||||
<TableCell className="p-3 text-right text-rose-600 font-mono">
|
<TableCell className="p-3 text-right text-rose-600 font-mono">
|
||||||
{sess.reject_price ? `₩${sess.reject_price.toLocaleString()}` : '-'}
|
{sess.reject_price ? `₩${sess.reject_price.toLocaleString()}` : '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="p-3 text-muted-foreground font-sans">{sess.reject_delivery_type || '-'}</TableCell>
|
<TableCell className="p-3 text-muted-foreground font-sans">{sess.reject_delivery_type || '-'}</TableCell>
|
||||||
|
<TableCell className="p-3 font-sans">
|
||||||
|
<ExtraInfoCell rows={extraRows(sess)} onOpen={() => setExtraSession(sess)} />
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -357,23 +365,28 @@ export function SessionsStatusTab({
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{sessionViews.map((sess) => {
|
{sessionViews.map((sess) => {
|
||||||
const isCandidate = awardPrice(sess) != null;
|
const isCandidate = true; // 오프라인 협상 반영 — 가격을 안 낸 협력사도 낙찰 대상
|
||||||
const isRecommended = isCandidate && awardPrice(sess) === lowestBid;
|
const isRecommended = isCandidate && awardPrice(sess) === lowestBid;
|
||||||
const isWinner = !!winnerSupplierId && sess.supplier_id === winnerSupplierId;
|
const isWinner = !!winnerSupplierId && sess.supplier_id === winnerSupplierId;
|
||||||
const rows: { label: string; value: string }[] = [
|
const rows: { label: string; value: string }[] = [
|
||||||
{ label: '상품', value: sess.item_name || '-' },
|
|
||||||
{
|
{
|
||||||
label: '앵커링가',
|
label: '앵커링가',
|
||||||
value: sess.anchoring_price > 0 ? `₩${sess.anchoring_price.toLocaleString()}` : '-',
|
value: sess.anchoring_price > 0 ? `₩${sess.anchoring_price.toLocaleString()}` : '-',
|
||||||
},
|
},
|
||||||
{ label: '투찰가', value: sess.bid_price ? `₩${sess.bid_price.toLocaleString()}` : '-' },
|
{ label: '투찰가', value: sess.bid_price ? `₩${sess.bid_price.toLocaleString()}` : '-' },
|
||||||
{ label: '투찰시각', value: sess.bid_at || '-' },
|
{ label: '투찰시각', value: sess.bid_at || '-' },
|
||||||
{ label: '마감시각', value: sess.end_time || '-' },
|
|
||||||
];
|
];
|
||||||
// 거부 정보는 값이 있을 때만(빈 줄로 카드가 길어지지 않게).
|
// 거부 정보는 값이 있을 때만(빈 줄로 카드가 길어지지 않게).
|
||||||
if (sess.reject_reason) rows.push({ label: '거부사유', value: sess.reject_reason });
|
if (sess.reject_reason) rows.push({ label: '거부사유', value: sess.reject_reason });
|
||||||
if (sess.reject_price) rows.push({ label: '거부가격', value: `₩${sess.reject_price.toLocaleString()}` });
|
if (sess.reject_price) rows.push({ label: '거부가격', value: `₩${sess.reject_price.toLocaleString()}` });
|
||||||
if (sess.reject_delivery_type) rows.push({ label: '거부배송방식', value: sess.reject_delivery_type });
|
if (sess.reject_delivery_type) rows.push({ label: '거부배송방식', value: sess.reject_delivery_type });
|
||||||
|
const offline = offlineAward(sess);
|
||||||
|
if (offline) rows.push({ label: '계약가(오프라인)', value: `₩${offline.price.toLocaleString()}` });
|
||||||
|
if (showEndTimeCol) rows.push({ label: '마감시각', value: sess.end_time || '-' });
|
||||||
|
const extras = extraRows(sess);
|
||||||
|
if (extras.length > 0) {
|
||||||
|
rows.push({ label: '부가정보', value: extras.map((r) => `${r.label} ${String(r.value)}`).join(' · ') });
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div key={sess.session_id} className={cn('p-3', isWinner && 'bg-success/10')}>
|
<div key={sess.session_id} className={cn('p-3', isWinner && 'bg-success/10')}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -489,6 +502,37 @@ export function SessionsStatusTab({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{awardOpen && selectedWinner && (
|
||||||
|
<AwardModal
|
||||||
|
open={awardOpen}
|
||||||
|
winner={selectedWinner}
|
||||||
|
onConfirm={handleAward}
|
||||||
|
onClose={() => setAwardOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 협상현황 표의 부가정보 칸 — 입력값을 한 줄로 요약하고, 클릭하면 전체를 모달로 연다. */
|
||||||
|
function ExtraInfoCell({
|
||||||
|
rows,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
rows: { label: string; value: unknown }[];
|
||||||
|
onOpen: () => void;
|
||||||
|
}) {
|
||||||
|
if (rows.length === 0) return <span className="text-muted-foreground">-</span>;
|
||||||
|
const text = rows.map((r) => `${r.label} ${String(r.value)}`).join(' · ');
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
title={text}
|
||||||
|
className="block max-w-[220px] truncate text-left text-emerald-700 hover:underline cursor-pointer dark:text-emerald-400"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -22,12 +22,16 @@ import {
|
|||||||
mapSetting,
|
mapSetting,
|
||||||
mapServerSessionView,
|
mapServerSessionView,
|
||||||
mapServerCardView,
|
mapServerCardView,
|
||||||
|
buildPriceRail,
|
||||||
|
quotationTypeLabel,
|
||||||
chainRoundState,
|
chainRoundState,
|
||||||
type NegotiationCard,
|
type NegotiationCard,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
import { QuotationStatus } from '@/api/generated/model';
|
import { QuotationStatus } from '@/api/generated/model';
|
||||||
import { DrawerHeaderCards } from './DrawerHeaderCards';
|
import { DrawerHeaderCards } from './DrawerHeaderCards';
|
||||||
|
import { PriceRail } from './PriceRail';
|
||||||
import { RoundTimeline } from './RoundTimeline';
|
import { RoundTimeline } from './RoundTimeline';
|
||||||
|
import { StatusPill } from './StatusPill';
|
||||||
import { RegenerateModal } from './RegenerateModal';
|
import { RegenerateModal } from './RegenerateModal';
|
||||||
import type { RegenerateInput } from '../../hooks/useQuotations';
|
import type { RegenerateInput } from '../../hooks/useQuotations';
|
||||||
import { SessionsStatusTab } from './SessionsStatusTab';
|
import { SessionsStatusTab } from './SessionsStatusTab';
|
||||||
@ -41,7 +45,13 @@ type QuotationDetailSheetProps = {
|
|||||||
quotation: QuotationData;
|
quotation: QuotationData;
|
||||||
onCloseQuotation: (id: string, name: string) => void;
|
onCloseQuotation: (id: string, name: string) => void;
|
||||||
/** 개찰(낙찰자 미정 마감) 견적을 협상현황 표에서 직접 낙찰. 성공 시 true. */
|
/** 개찰(낙찰자 미정 마감) 견적을 협상현황 표에서 직접 낙찰. 성공 시 true. */
|
||||||
onAward: (qtId: string, winnerSupplierId: string, winnerName: string) => Promise<boolean>;
|
onAward: (
|
||||||
|
qtId: string,
|
||||||
|
winnerSupplierId: string,
|
||||||
|
winnerName: string,
|
||||||
|
contractPrice: number,
|
||||||
|
contractNote: string,
|
||||||
|
) => Promise<boolean>;
|
||||||
/** 라운드 타임라인에서 다른 차수로 전환(같은 견적번호의 다른 견적 상세 열기). */
|
/** 라운드 타임라인에서 다른 차수로 전환(같은 견적번호의 다른 견적 상세 열기). */
|
||||||
onSwitchRound: (qtId: string) => void;
|
onSwitchRound: (qtId: string) => void;
|
||||||
/** 마감된 견적의 다음 라운드를 수동 생성(공급사·기한·목표가·카드 재선택). 성공 시 새 qt_id 반환. */
|
/** 마감된 견적의 다음 라운드를 수동 생성(공급사·기한·목표가·카드 재선택). 성공 시 새 qt_id 반환. */
|
||||||
@ -139,6 +149,11 @@ export function QuotationDetailSheet({
|
|||||||
// 세션은 모두 같은 상품을 가리키므로(1견적=1상품) 단건 상품 하나로 item_name 해석이 끝난다.
|
// 세션은 모두 같은 상품을 가리키므로(1견적=1상품) 단건 상품 하나로 item_name 해석이 끝난다.
|
||||||
const productList = currentProduct ? [currentProduct] : [];
|
const productList = currentProduct ? [currentProduct] : [];
|
||||||
const sessionViews = serverSessions.map((sd) => mapServerSessionView(sd, partners, productList));
|
const sessionViews = serverSessions.map((sd) => mapServerSessionView(sd, partners, productList));
|
||||||
|
// 헤더에 고정할 가격 레일 — 접힘/펼침·탭과 무관하게 항상 같은 값을 본다.
|
||||||
|
const railCeilingRate = quotation.done_ceiling_rate ?? settingCeilingRate;
|
||||||
|
const priceRail = buildPriceRail(quotation, sessionViews, railCeilingRate);
|
||||||
|
const railTargetSessionId =
|
||||||
|
(sessionViews.find((s) => s.target_price > 0) ?? sessionViews[0])?.session_id ?? null;
|
||||||
const quotationCardViews = serverCards.map(mapServerCardView);
|
const quotationCardViews = serverCards.map(mapServerCardView);
|
||||||
|
|
||||||
// 헤더 상단바·마감 버튼에 필요한 최소 표시값만 (나머지 견적 표시값은 DrawerHeaderCards 내부 계산).
|
// 헤더 상단바·마감 버튼에 필요한 최소 표시값만 (나머지 견적 표시값은 DrawerHeaderCards 내부 계산).
|
||||||
@ -182,10 +197,13 @@ export function QuotationDetailSheet({
|
|||||||
<div className="shrink-0 px-6 pt-6 pb-3 bg-muted/30">
|
<div className="shrink-0 px-6 pt-6 pb-3 bg-muted/30">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 text-muted-foreground text-[10px] font-mono tracking-widest uppercase">
|
<Typography variant="h3">{q_name}</Typography>
|
||||||
<span>견적 상세 // {q_number}</span>
|
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="font-mono text-[12px] font-bold tracking-tight text-foreground">{q_number}</span>
|
||||||
|
<StatusPill tone="zinc">{quotationTypeLabel(quotation.type)}</StatusPill>
|
||||||
|
{/* 차수는 라운드가 하나뿐이라 타임라인이 안 뜰 때만 — 뜨면 그쪽이 현재 차수를 보여준다. */}
|
||||||
|
{chainRounds.length <= 1 && <StatusPill tone="zinc">{quotation.round ?? 1}차</StatusPill>}
|
||||||
</div>
|
</div>
|
||||||
<Typography variant="h3" className="mt-1">{q_name}</Typography>
|
|
||||||
{quotation.number && (
|
{quotation.number && (
|
||||||
<RoundTimeline number={quotation.number} currentQtId={qtId} onSwitchRound={onSwitchRound} />
|
<RoundTimeline number={quotation.number} currentQtId={qtId} onSwitchRound={onSwitchRound} />
|
||||||
)}
|
)}
|
||||||
@ -252,25 +270,21 @@ export function QuotationDetailSheet({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 견적 상세 정보 — 펼치면 탭과 flex 비율(헤더:탭 = 2:1)로 높이를 나눠 가지고 자체 스크롤 */}
|
{/* 가격 레일 — 접힘·펼침·탭 전환과 무관하게 항상 붙어 있다. 상세를 펼쳐 스크롤해도 기준가는 남는다. */}
|
||||||
{showHeaderCards ? (
|
<div className="shrink-0">
|
||||||
<div
|
<PriceRail
|
||||||
style={{ flex: '2 1 0%' }}
|
rail={priceRail}
|
||||||
className="min-h-0 overflow-y-auto overscroll-contain px-6 pb-6 bg-muted/30 border-b border-border"
|
onShowTarget={railTargetSessionId ? () => setTargetSessionId(railTargetSessionId) : undefined}
|
||||||
>
|
|
||||||
<DrawerHeaderCards
|
|
||||||
quotation={quotation}
|
|
||||||
quotationSettings={quotationSettings}
|
|
||||||
sessionViews={sessionViews}
|
|
||||||
currentProduct={currentProduct}
|
|
||||||
onShowTarget={setTargetSessionId}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
/* 접어도 결과 요약(목표가·낙찰·절감) 밴드는 상단에 그대로 남긴다. */
|
{/* 견적 상세 정보 — 펼치면 탭과 flex 비율(헤더:탭 = 2:1)로 높이를 나눠 가지고 자체 스크롤 */}
|
||||||
<div className="shrink-0 px-6 pt-4 pb-4 bg-muted/30 border-b border-border">
|
{showHeaderCards && (
|
||||||
|
<div
|
||||||
|
style={{ flex: '2 1 0%' }}
|
||||||
|
className="min-h-0 overflow-y-auto overscroll-contain px-6 py-4 bg-muted/30 border-b border-border"
|
||||||
|
>
|
||||||
<DrawerHeaderCards
|
<DrawerHeaderCards
|
||||||
collapsed
|
|
||||||
quotation={quotation}
|
quotation={quotation}
|
||||||
quotationSettings={quotationSettings}
|
quotationSettings={quotationSettings}
|
||||||
sessionViews={sessionViews}
|
sessionViews={sessionViews}
|
||||||
@ -290,13 +304,11 @@ export function QuotationDetailSheet({
|
|||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveTab(tab.id);
|
setActiveTab(tab.id);
|
||||||
if (tab.id === 'chat') {
|
// 탭을 고르면 견적 상세 정보를 접어 본문에 높이를 넘긴다(가격 레일은 그대로 남는다).
|
||||||
// 협상 대화 탭에선 대화 영역을 넓게 쓰도록 견적 상세 정보를 접는다.
|
|
||||||
setShowHeaderCards(false);
|
setShowHeaderCards(false);
|
||||||
if (serverSessions.length > 0 && !selectedSessionId) {
|
if (tab.id === 'chat' && serverSessions.length > 0 && !selectedSessionId) {
|
||||||
setSelectedSessionId(serverSessions[0].session_id);
|
setSelectedSessionId(serverSessions[0].session_id);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className={`flex items-center gap-2 py-4 px-3 text-xs tracking-tight font-semibold border-b-2 transition-all cursor-pointer ${
|
className={`flex items-center gap-2 py-4 px-3 text-xs tracking-tight font-semibold border-b-2 transition-all cursor-pointer ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
@ -323,7 +335,9 @@ export function QuotationDetailSheet({
|
|||||||
onOpenChat={goToChat}
|
onOpenChat={goToChat}
|
||||||
onNotifyAll={() => onNotify(qtId)}
|
onNotifyAll={() => onNotify(qtId)}
|
||||||
onNotifyOne={(sessionId) => onNotifySession(sessionId, qtId)}
|
onNotifyOne={(sessionId) => onNotifySession(sessionId, qtId)}
|
||||||
onAward={(supplierId, supplierName) => onAward(qtId, supplierId, supplierName)}
|
onAward={(supplierId, supplierName, contractPrice, contractNote) =>
|
||||||
|
onAward(qtId, supplierId, supplierName, contractPrice, contractNote)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -134,9 +134,18 @@ export function useQuotations(params: ListQuotationsParams) {
|
|||||||
|
|
||||||
// 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리 → 서버 award_quotation(close_reason→낙찰 + 낙찰자 박제 + 작성자 알림).
|
// 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리 → 서버 award_quotation(close_reason→낙찰 + 낙찰자 박제 + 작성자 알림).
|
||||||
// 성공 시 단건 견적·세션·알림 목록 재조회로 결과밴드('개찰'→'낙찰')와 알림함을 동기화.
|
// 성공 시 단건 견적·세션·알림 목록 재조회로 결과밴드('개찰'→'낙찰')와 알림함을 동기화.
|
||||||
const awardQuotation = async (qtId: string, winnerSupplierId: string, winnerName: string): Promise<boolean> => {
|
const awardQuotation = async (
|
||||||
|
qtId: string,
|
||||||
|
winnerSupplierId: string,
|
||||||
|
winnerName: string,
|
||||||
|
contractPrice: number,
|
||||||
|
contractNote?: string,
|
||||||
|
): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const res = await awardQuotationMutation.mutateAsync({ qtId, data: { winner_supplier_id: winnerSupplierId } });
|
const res = await awardQuotationMutation.mutateAsync({
|
||||||
|
qtId,
|
||||||
|
data: { winner_supplier_id: winnerSupplierId, contract_price: contractPrice, contract_note: contractNote || null },
|
||||||
|
});
|
||||||
if (!res?.result?.success) {
|
if (!res?.result?.success) {
|
||||||
const reason = res?.msg ?? res?.result?.desc ?? '서버 오류';
|
const reason = res?.msg ?? res?.result?.desc ?? '서버 오류';
|
||||||
const code = res?.result?.code;
|
const code = res?.result?.code;
|
||||||
|
|||||||
@ -244,6 +244,7 @@ export type SessionView = {
|
|||||||
status: number;
|
status: number;
|
||||||
target_price: number;
|
target_price: number;
|
||||||
anchoring_price: number;
|
anchoring_price: number;
|
||||||
|
done_ceiling_price: number; // 완료 상한가(원) — 생성 시 박제값. 0=미박제(구 견적)
|
||||||
bid_price: number | null;
|
bid_price: number | null;
|
||||||
bid_at: string;
|
bid_at: string;
|
||||||
reject_reason: string | null;
|
reject_reason: string | null;
|
||||||
@ -283,14 +284,95 @@ export type QuotationResultView = {
|
|||||||
savingsRate: number | null;
|
savingsRate: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 계약 기준가 — 타결했으면 투찰가, 협상이 거부로 끝났으면 그때 써낸 공급 희망 가격.
|
// 타결 상한가 — 합의가가 이 금액 이하면 타결, 초과하면 결렬.
|
||||||
// 결렬 건도 최종 제출가로 계약을 진행하므로 낙찰 후보·결과 표기가 같은 기준을 본다.
|
// 세션 박제값(생성 시점)이 정본이다. 생성 후 세팅이 바뀌어도 협상은 박제값으로 판정하기 때문.
|
||||||
|
// 박제 이전 견적은 값이 0/없음이라 같은 식(목표가×(1+율/1000), 10원 반올림)으로 환산해 보여준다.
|
||||||
|
export function ceilingPriceOf(session: SessionView | undefined, ceilingRate: number): number | null {
|
||||||
|
if (session?.done_ceiling_price) return session.done_ceiling_price;
|
||||||
|
if (!session?.target_price) return null;
|
||||||
|
return Math.round((session.target_price * (1000 + ceilingRate)) / 10000) * 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PriceRailView = {
|
||||||
|
oneToOne: boolean;
|
||||||
|
/** 1:1 전용 — 앵커링가는 (상품×공급사)마다 달라 1:N 에는 견적 대표값이 없다(협상현황 표의 협력사별 컬럼으로 본다). */
|
||||||
|
anchorPrice: number | null;
|
||||||
|
/** 1:N 전용 — 가격을 제출한 협력사 수 / 초청 협력사 수. */
|
||||||
|
bidCount: number;
|
||||||
|
supplierCount: number;
|
||||||
|
targetPrice: number | null;
|
||||||
|
ceilingPrice: number | null;
|
||||||
|
ceilingRate: number; // ‰
|
||||||
|
/** 마지막 칸 — 상태에 따라 낙찰가 / 최저 투찰가 / 현재 제시가로 바뀐다. */
|
||||||
|
resultLabel: string;
|
||||||
|
resultPrice: number | null;
|
||||||
|
resultBadge: string | null;
|
||||||
|
savings: number | null;
|
||||||
|
savingsRate: number | null;
|
||||||
|
outcome: ChainRoundState;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 헤더 가격 레일 파생. 칸 수는 항상 넷이라 상태가 바뀌어도 화면이 흔들리지 않는다.
|
||||||
|
export function buildPriceRail(
|
||||||
|
q: QuotationData,
|
||||||
|
sessions: SessionView[],
|
||||||
|
ceilingRate: number,
|
||||||
|
): PriceRailView {
|
||||||
|
const r = buildQuotationResult(q, sessions);
|
||||||
|
const oneToOne = is1v1(q.type);
|
||||||
|
// 목표가·상한가는 상품 단위(1견적=1상품)라 대표 세션 하나로 읽는다. 앵커는 공급사 단위라 1:1 에서만.
|
||||||
|
const rep = sessions.find((s) => s.target_price > 0) ?? sessions[0];
|
||||||
|
const priced = sessions.map(awardPrice).filter((v): v is number => v != null);
|
||||||
|
|
||||||
|
const resultLabel =
|
||||||
|
r.outcome === 'awarded' ? '낙찰가'
|
||||||
|
: r.outcome === 'opened' ? '최저 투찰가'
|
||||||
|
: oneToOne ? '현재 제시가' : '현재 최저 투찰가';
|
||||||
|
const resultBadge =
|
||||||
|
r.outcome === 'awarded' ? null
|
||||||
|
: r.outcome === 'opened' ? '낙찰자 미정'
|
||||||
|
: '협상 중';
|
||||||
|
|
||||||
|
return {
|
||||||
|
oneToOne,
|
||||||
|
anchorPrice: oneToOne ? (rep?.anchoring_price ?? null) : null,
|
||||||
|
bidCount: priced.length,
|
||||||
|
supplierCount: sessions.length,
|
||||||
|
targetPrice: r.targetPrice,
|
||||||
|
ceilingPrice: ceilingPriceOf(rep, ceilingRate),
|
||||||
|
ceilingRate,
|
||||||
|
resultLabel,
|
||||||
|
resultPrice: r.outcome === 'awarded' ? r.winnerPrice : r.lowestBid,
|
||||||
|
resultBadge,
|
||||||
|
// 절감은 낙찰 확정 건만 — 진행 중 잠정 최저가로 절감을 말하면 나중에 뒤집힌다.
|
||||||
|
savings: r.outcome === 'awarded' ? r.savings : null,
|
||||||
|
savingsRate: r.outcome === 'awarded' ? r.savingsRate : null,
|
||||||
|
outcome: r.outcome,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 협력사가 시스템에 제출한 가격 — 타결했으면 투찰가, 거부로 끝났으면 그때 써낸 공급 희망 가격.
|
||||||
|
// 직접 낙찰의 계약가 기본값·최저가 추천에 쓴다. 확정 계약가는 contractPrice 를 본다.
|
||||||
export function awardPrice(s: SessionView): number | null {
|
export function awardPrice(s: SessionView): number | null {
|
||||||
if (s.status === SessionStatus.DONE && s.bid_price != null) return s.bid_price;
|
if (s.status === SessionStatus.DONE && s.bid_price != null) return s.bid_price;
|
||||||
if (s.status === SessionStatus.REJECTED && s.reject_price != null) return s.reject_price;
|
if (s.status === SessionStatus.REJECTED && s.reject_price != null) return s.reject_price;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 오프라인 협상 결과로 담당자가 확정한 계약가(sessions.custom.offline_award).
|
||||||
|
// 결렬·미응찰 건을 오프라인으로 다시 협상해 낙찰시킨 경우라 협력사 제출가와 다를 수 있다.
|
||||||
|
export function offlineAward(s: SessionView): { price: number; note: string; at: string } | null {
|
||||||
|
const raw = s.custom?.offline_award as { price?: unknown; note?: unknown; at?: unknown } | undefined;
|
||||||
|
const price = Number(raw?.price);
|
||||||
|
if (!raw || !Number.isFinite(price) || price <= 0) return null;
|
||||||
|
return { price, note: String(raw.note ?? ''), at: String(raw.at ?? '') };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 확정 계약가 — 담당자가 넣은 값이 있으면 그것, 없으면 협력사 제출가. 결과 표기·절감 계산의 기준.
|
||||||
|
export function contractPrice(s: SessionView): number | null {
|
||||||
|
return offlineAward(s)?.price ?? awardPrice(s);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildQuotationResult(q: QuotationData, sessions: SessionView[]): QuotationResultView {
|
export function buildQuotationResult(q: QuotationData, sessions: SessionView[]): QuotationResultView {
|
||||||
const winnerId = q.preferred_sp_id ?? null;
|
const winnerId = q.preferred_sp_id ?? null;
|
||||||
const winnerSession = winnerId ? sessions.find((s) => s.supplier_id === winnerId) ?? null : null;
|
const winnerSession = winnerId ? sessions.find((s) => s.supplier_id === winnerId) ?? null : null;
|
||||||
@ -299,7 +381,8 @@ export function buildQuotationResult(q: QuotationData, sessions: SessionView[]):
|
|||||||
const bids = sessions.map(awardPrice).filter((v): v is number => v != null);
|
const bids = sessions.map(awardPrice).filter((v): v is number => v != null);
|
||||||
const lowestBid = bids.length ? Math.min(...bids) : null;
|
const lowestBid = bids.length ? Math.min(...bids) : null;
|
||||||
const highestBid = bids.length ? Math.max(...bids) : null;
|
const highestBid = bids.length ? Math.max(...bids) : null;
|
||||||
const winnerPrice = (winnerSession ? awardPrice(winnerSession) : null) ?? lowestBid;
|
// 낙찰가는 확정 계약가 기준 — 오프라인으로 다시 협상해 낙찰한 건은 제출가와 다르다.
|
||||||
|
const winnerPrice = (winnerSession ? contractPrice(winnerSession) : null) ?? lowestBid;
|
||||||
const provisional = !winnerSession;
|
const provisional = !winnerSession;
|
||||||
|
|
||||||
const sessionTarget =
|
const sessionTarget =
|
||||||
@ -392,6 +475,7 @@ export function mapServerSessionView(sd: SessionData, partners: Partner[], produ
|
|||||||
status: sd.status,
|
status: sd.status,
|
||||||
target_price: sd.target_price ?? 0,
|
target_price: sd.target_price ?? 0,
|
||||||
anchoring_price: sd.anchoring_price ?? 0,
|
anchoring_price: sd.anchoring_price ?? 0,
|
||||||
|
done_ceiling_price: sd.done_ceiling_price ?? 0,
|
||||||
bid_price: sd.bid_price ?? null,
|
bid_price: sd.bid_price ?? null,
|
||||||
bid_at: sd.bid_at ? fmtDateTime(sd.bid_at) : '-',
|
bid_at: sd.bid_at ? fmtDateTime(sd.bid_at) : '-',
|
||||||
reject_reason: sd.reject_reason ?? null,
|
reject_reason: sd.reject_reason ?? null,
|
||||||
|
|||||||
@ -34,7 +34,14 @@ export function StatisticsView({ data }: { data: StatData }) {
|
|||||||
delta={{ text: `${signedWonCompact(k.savingsDeltaMoM)} 전월비`, good: k.savingsDeltaMoM >= 0 }}
|
delta={{ text: `${signedWonCompact(k.savingsDeltaMoM)} 전월비`, good: k.savingsDeltaMoM >= 0 }}
|
||||||
/>
|
/>
|
||||||
<StatTile label="평균 절감률" value={pct(k.savingsRate)} icon={Percent} tone="emerald" />
|
<StatTile label="평균 절감률" value={pct(k.savingsRate)} icon={Percent} tone="emerald" />
|
||||||
<StatTile label="낙찰률" value={pct(k.awardRate)} icon={Award} tone="blue" />
|
<StatTile
|
||||||
|
label="낙찰률"
|
||||||
|
value={pct(k.awardRate)}
|
||||||
|
icon={Award}
|
||||||
|
tone="blue"
|
||||||
|
// 오프라인으로 다시 협상해 담당자가 반영한 건수 — 절감액엔 포함되지만 AI 협상 성과와 섞이지 않게 따로 짚는다.
|
||||||
|
delta={k.offlineAwardCount > 0 ? { text: `오프라인 반영 ${k.offlineAwardCount}건` } : undefined}
|
||||||
|
/>
|
||||||
<StatTile label="평균 앵커 도달률" value={pct(k.anchorReachRate)} icon={Target} tone="purple" />
|
<StatTile label="평균 앵커 도달률" value={pct(k.anchorReachRate)} icon={Target} tone="purple" />
|
||||||
<StatTile label="마감 견적" value={`${k.closedCount}건`} icon={CircleCheckBig} tone="zinc" />
|
<StatTile label="마감 견적" value={`${k.closedCount}건`} icon={CircleCheckBig} tone="zinc" />
|
||||||
<StatTile label="평균 재견적 라운드" value={k.regenAvgRound.toFixed(1)} icon={RefreshCw} tone="amber" />
|
<StatTile label="평균 재견적 라운드" value={k.regenAvgRound.toFixed(1)} icon={RefreshCw} tone="amber" />
|
||||||
|
|||||||
@ -18,6 +18,7 @@ function mapScope(s?: ApiScope): StatData {
|
|||||||
closedCount: k.closed_count ?? 0,
|
closedCount: k.closed_count ?? 0,
|
||||||
regenAvgRound: k.regen_avg_round ?? 0,
|
regenAvgRound: k.regen_avg_round ?? 0,
|
||||||
markupSuppressionRate: k.markup_suppression_rate ?? 0,
|
markupSuppressionRate: k.markup_suppression_rate ?? 0,
|
||||||
|
offlineAwardCount: k.offline_award_count ?? 0,
|
||||||
},
|
},
|
||||||
trend: (s?.trend ?? []).map((t) => ({ month: t.month, savings: t.savings ?? 0, rate: t.rate ?? 0 })),
|
trend: (s?.trend ?? []).map((t) => ({ month: t.month, savings: t.savings ?? 0, rate: t.rate ?? 0 })),
|
||||||
markupTrend: (s?.markup_trend ?? []).map((t) => ({ month: t.month, rate: t.rate ?? 0 })),
|
markupTrend: (s?.markup_trend ?? []).map((t) => ({ month: t.month, rate: t.rate ?? 0 })),
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export interface StatKpi {
|
|||||||
closedCount: number; // 마감 견적 수(창)
|
closedCount: number; // 마감 견적 수(창)
|
||||||
regenAvgRound: number; // 평균 재견적 라운드(1=재견적 없음)
|
regenAvgRound: number; // 평균 재견적 라운드(1=재견적 없음)
|
||||||
markupSuppressionRate: number; // 인상억제율(재협상: 직전 라운드 투찰가 대비 이번 투찰가 인하율)
|
markupSuppressionRate: number; // 인상억제율(재협상: 직전 라운드 투찰가 대비 이번 투찰가 인하율)
|
||||||
|
offlineAwardCount: number; // 낙찰 중 오프라인 협상 결과를 담당자가 반영한 건수(절감액엔 포함)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MonthPoint {
|
export interface MonthPoint {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user