Compare commits

..

No commits in common. "main" and "fix/naver-container-wtm" have entirely different histories.

268 changed files with 2441 additions and 11634 deletions

1
.gitignore vendored
View File

@ -31,4 +31,3 @@ CLAUDE.md
/mobile.mov /mobile.mov
.gstack/ .gstack/
.playwright-mcp/

Binary file not shown.

View File

@ -8,8 +8,9 @@
2) 변수 정의 — 그 금액을 지금 쓸 수 있는지 (OFFER_VARIABLES 의 계산식 + 유효조건) 2) 변수 정의 — 그 금액을 지금 쓸 수 있는지 (OFFER_VARIABLES 의 계산식 + 유효조건)
3) tactic JSONB — 문장으로 알 수 없는 운영 규칙 (min_round·closing) 3) tactic JSONB — 문장으로 알 수 없는 운영 규칙 (min_round·closing)
유효 조건은 카드가 아니라 '변수'에 붙인다 — 금액이 성립하는지는 계산식의 성질이지 카드의 유효 조건을 카드가 아니라 '변수'에 붙이는 이유: 절충가가 목표가를 넘을 수 있는 것은
성질이 아니다. 새 변수는 OFFER_VARIABLES 에 한 줄 추가하면 코드 분기 없이 끝난다. (직전제안+제시가)/2 라는 계산식의 성질이지 특정 카드의 성질이 아니다. 같은 변수를 쓰는
카드가 늘어도 규칙은 한 곳이고, 새 변수는 이 표에 한 줄 추가하면 코드 분기 없이 끝난다.
""" """
import re import re
@ -31,9 +32,6 @@ OFFER_VARIABLES: Dict[str, Callable[[float, float, float, float], Optional[float
_TOKEN_RE = re.compile(r"\{([a-z_]+)\}") _TOKEN_RE = re.compile(r"\{([a-z_]+)\}")
# 절충 계열 변수 — 양측 사이/우리 두 값 사이의 중간을 부르는 카드. 목표가 이상이면 미발동한다.
_MID_VARIABLES = ("middle_price", "target_mid_price")
# 세션 데이터에 따라 값이 없을 수 있는 읽기 전용 변수 → 그 값을 담는 컨텍스트 키. # 세션 데이터에 따라 값이 없을 수 있는 읽기 전용 변수 → 그 값을 담는 컨텍스트 키.
# 스크립트가 이런 변수를 인용하면 값이 있을 때만 카드가 나간다 — 없는데 나가면 협력사 채팅에 # 스크립트가 이런 변수를 인용하면 값이 있을 때만 카드가 나간다 — 없는데 나가면 협력사 채팅에
@ -67,8 +65,9 @@ HOLD = CardSpec() # 스펙을 못 찾은 카드(테넌트 데모·회사 커스
def settle_ceiling(context: Dict[str, Any]) -> float: def settle_ceiling(context: Dict[str, Any]) -> float:
"""이 협상에서 받아줄 수 있는 최고가 — 타결 판정선이자 카드 제안가의 상한. """이 협상에서 받아줄 수 있는 최고가 — 타결 판정선이자 카드 제안가의 상한.
견적 생성 시 세션에 박제한 done_ceiling_price(= 목표가 × (1 + 타결상한율)) — 목표가를 조금 견적 생성 시 세션에 박제한 done_ceiling_price(= 목표가 × (1 + 타결상한율)). 목표가를 조금
넘더라도 기존 단가보다 인하됐으면 타결시키기 위한 값. 박제가 없으면 목표가로 폴백한다. 넘더라도 기존 단가보다 인하됐으면 타결시키기 위한 값이다(IMK: 기존 17,500 / 목표 16,980 /
최종 17,300 이 결렬되던 케이스). 박제가 없는 옛 세션·데모는 목표가로 폴백 — 종전 동작 유지.
""" """
return float(context.get("done_ceiling_price") or context.get("target_price") or 0) return float(context.get("done_ceiling_price") or context.get("target_price") or 0)
@ -76,8 +75,9 @@ def settle_ceiling(context: Dict[str, Any]) -> float:
def parse_offer_variable(script: Optional[str]) -> Optional[str]: def parse_offer_variable(script: Optional[str]) -> Optional[str]:
"""스크립트가 제시하는 제안가 변수. 없으면 None(설득 카드). """스크립트가 제시하는 제안가 변수. 없으면 None(설득 카드).
변수가 여럿이면 마지막에 등장하는 것이 제안가다 — 카드 문장은 배경을 먼저 깔고 실제 제안을 제안가 변수가 여럿이면 **마지막에 등장하는 것**이 제안가다 — 카드 문장은 배경을 먼저 깔고
마지막에 하기 때문이다. (예: "당초 검토한 적정가는 {anchoring_price}원이었으나") 실제 제안을 마지막에 하기 때문이다
(예: "이에 {target_price}원으로 조정하여 제안 드립니다").
""" """
found = [m.group(1) for m in _TOKEN_RE.finditer(script or "") if m.group(1) in OFFER_VARIABLES] found = [m.group(1) for m in _TOKEN_RE.finditer(script or "") if m.group(1) in OFFER_VARIABLES]
return found[-1] if found else None return found[-1] if found else None
@ -115,50 +115,16 @@ def spec_from_context(context: Dict[str, Any], number: Optional[str]) -> CardSpe
) )
@dataclass(frozen=True)
class Offer:
"""확정된 제안 한 건 — 금액과 그 금액을 만든 재료를 함께 들고 다닌다.
멘트 치환이 재료를 다시 계산하지 않게 하기 위한 것 — 재계산하면 그 사이 갱신된
prev_customer 를 읽어 문장이 자기모순이 된다.
"""
price: int # 협력사에게 제시할 금액(수락 시 타결가)
variable: str # 이 금액을 만든 멘트 변수
prev_customer: int # 계산에 쓴 당사 직전 제안
prev_partner: int # 계산에 쓴 협력사 제시가
def record_offer(context: Dict[str, Any], offer: Offer) -> None:
"""확정 제안을 세션에 기록한다 — 수락 판정용 금액과 멘트 치환용 재료를 한 자리에서 쓴다.
두 키를 항상 함께 써야 표시가와 타결가가 갈라지지 않으므로 기록 지점을 여기 하나로 묶는다.
"""
context["pending_counter_price"] = offer.price
context["pending_offer"] = {
"price": offer.price, "variable": offer.variable,
"prev_customer": offer.prev_customer, "prev_partner": offer.prev_partner,
}
context["prev_customer_price"] = offer.price # 갑의 최신 포지션 — 다음 라운드 계산·역행 금지 기준
def compute_offer_detail(spec: CardSpec, context: Dict[str, Any]) -> Optional[Offer]:
"""카드가 제시할 금액 + 그 계산에 쓴 재료. 쓸 수 없는 상황이면 None."""
price = int(float(context.get("input_price") or 0))
prev_customer = int(float(context.get("prev_customer_price") or context.get("anchor_price") or 0))
value = compute_offer(spec, context)
if value is None:
return None
return Offer(price=value, variable=spec.offer_variable or "", prev_customer=prev_customer, prev_partner=price)
def compute_offer(spec: CardSpec, context: Dict[str, Any]) -> Optional[int]: def compute_offer(spec: CardSpec, context: Dict[str, Any]) -> Optional[int]:
"""카드가 제시할 금액. 쓸 수 없는 상황이면 None → 호출부가 카드를 건너뛴다. """카드가 제시할 금액. 쓸 수 없는 상황이면 None → 호출부가 카드를 건너뛴다.
변수 공통 유효조건 (전부 만족해야 발동): 변수 공통 유효조건 (전부 만족해야 발동):
· 값 ≤ 타결 상한가 — 받아줄 수 없는 금액은 부르지 않는다. 넘으면 깎지 않고 미발동 · 값 ≤ 타결 상한가 — 구매자는 받아줄 수 없는 금액을 부르지 않는다. 넘으면 클램프가 아니라
**미발동**(깎아 부르면 "중간에서 만나자"면서 상한을 부르는 모순이 된다).
상한은 견적 생성 시 박제한 done_ceiling_price(목표가×(1+율)), 없으면 목표가.
· 값 < 협력사 제시가 — 이미 더 싸게 받았는데 더 비싼 값을 부를 이유가 없다 · 값 < 협력사 제시가 — 이미 더 싸게 받았는데 더 비싼 값을 부를 이유가 없다
· 값 ≥ 당사 직전 제안 — 역행 금지. 제안 시퀀스는 앵커→…→목표가로 단조 수렴해야 한다 · 값 ≥ 당사 직전 제안 — 역행 금지(IMK 논의). 16,980을 불러놓고 16,810(앵커)을 부르면 협상이
좁혀지지 않고 되돌아간다. 제안 시퀀스는 앵커→…→목표가로 단조 수렴해야 한다
""" """
variable = spec.offer_variable variable = spec.offer_variable
if not variable: if not variable:
@ -179,16 +145,7 @@ def compute_offer(spec: CardSpec, context: Dict[str, Any]) -> Optional[int]:
return None # 재료 부족(앵커 미박제·직전 제안 없음) return None # 재료 부족(앵커 미박제·직전 제안 없음)
if value > settle_ceiling(context): if value > settle_ceiling(context):
return None # 타결 상한 초과 — 받아줄 수 없는 금액이라 지금 못 쓴다 return None # 타결 상한 초과 — 받아줄 수 없는 금액이라 지금 못 쓴다
if variable in _MID_VARIABLES and value >= target: offer = int(value / 10 + 0.5) * 10 # 10원 단위 반올림 — 앵커가·목표가 산정과 표기 통일
# 절충 계열은 목표가 미만일 때만 의미가 있다. 목표가 이상이면 "절반씩 나누자"면서 목표가를
# 부르는 꼴이라 미발동 — 목표가 제시는 목표가 카드(최후통첩)가 할 일이다.
return None
if variable in ("target_price", "anchoring_price", "anchor_price"):
# 원값 인용 변수 — 멘트엔 {target_price} 등 저장 원값이 그대로 나가므로, 반올림하면
# 표시가≠타결가 미스매치가 난다(목표가 7652 멘트 → 7650 타결). 저장값 그대로 제시.
offer = int(value)
else:
offer = int(value / 10 + 0.5) * 10 # 파생가(절충·중간) 10원 반올림 — 앵커·목표가 산정과 표기 통일
if offer >= price: if offer >= price:
return None # 제시가가 이미 그 값 이하 → 부를 이유 없음 return None # 제시가가 이미 그 값 이하 → 부를 이유 없음
if prev_customer and offer < prev_customer: if prev_customer and offer < prev_customer:
@ -216,7 +173,7 @@ def playable(spec: CardSpec, context: Dict[str, Any], *, closing_phase: bool = F
금액을 인용하는 카드(offer_variable 있음)는 그 금액을 못 부르는 상황이면 설득 폴백으로도 금액을 인용하는 카드(offer_variable 있음)는 그 금액을 못 부르는 상황이면 설득 폴백으로도
내보내지 않는다 — 멘트에 무효한 금액(직전 제안보다 낮은 앵커, 제시가보다 높은 목표가)이 내보내지 않는다 — 멘트에 무효한 금액(직전 제안보다 낮은 앵커, 제시가보다 높은 목표가)이
글자로 박혀 나가 역행/모순 서사가 되기 때문. 설득 카드는 금액이 없으니 무관. 글자로 박혀 나가 역행/모순 서사가 되기 때문(IMK 역행 논의). 설득 카드는 금액이 없으니 무관.
""" """
if not available(spec, context, closing_phase=closing_phase): if not available(spec, context, closing_phase=closing_phase):
return False return False

View File

@ -36,26 +36,26 @@ _QUOTATION_SETTINGS = table(
) )
_ITEMS = table("items", column("item_id"), column("name"), column("price"), column("purchase_price"), _ITEMS = table("items", column("item_id"), column("name"), column("price"), column("purchase_price"),
column("company_id"), column("internet_lowest_price"), column("deleted"), schema="partner") column("company_id"), column("internet_lowest_price"), column("deleted"), schema="partner")
# 고객사 설정(companies.settings) — 협상 기준가로 쓸 가격 컬럼을 여기서 정한다. # 고객사 설정(companies.settings) — 협상 기준가로 쓸 가격 컬럼과 그 호칭을 여기서 정한다.
_COMPANIES = table("companies", column("company_id"), column("settings"), column("deleted"), schema="company") _COMPANIES = table("companies", column("company_id"), column("settings"), column("deleted"), schema="company")
# 협상 기준가 후보 컬럼. 어느 컬럼을 고르든 공급사 화면 호칭은 '공급가'로 고정한다 — # 협상 기준가 후보: items 컬럼 ↔ 용어 카탈로그 키 ↔ 용어 미설정 시 기본값.
# 같은 돈을 고객사는 매입가·상품 단가 등으로 부르지만 챗은 공급사가 보는 화면이라 # 기본값은 negodata 용어 카탈로그(LABEL_CATALOG)의 base 와 같아야 한다 — 화면 라벨과
# 공급사 관점 용어 하나만 쓴다. 회사 용어 사전(labels)은 관리자 화면 전용. # 협상 멘트 호칭이 갈리지 않도록. 문장이 어색하면 회사가 용어 탭에서 바꾼다.
_BASELINE_PRICE = "price" _BASELINE_PRICE = ("price", "item.price", "상품 단가")
_BASELINE_PURCHASE = "purchase_price" _BASELINE_PURCHASE = ("purchase_price", "item.purchase_price", "매입가")
_SUPPLIER_PRICE_LABEL = "공급가" _BASELINE_BY_FIELD = {"price": _BASELINE_PRICE, "purchase_price": _BASELINE_PURCHASE}
def _resolve_baseline(settings: dict) -> str: def _resolve_baseline(settings: dict) -> tuple:
"""회사 설정 → 협상 기준가로 쓸 items 컬럼명. """회사 설정 → 협상 기준가로 쓸 (컬럼, 라벨키, 호칭 폴백).
1순위는 관리자가 회사 설정에서 고른 값(features.nego_baseline_field). 1순위는 관리자가 회사 설정에서 고른 값(features.nego_baseline_field).
미설정 회사는 공급가가 기본이되, 공급가를 화면에서 감췄다면 그 회사는 공급가를 관리하지 미설정 회사는 공급가가 기본이되, 공급가를 화면에서 감췄다면 그 회사는 공급가를 관리하지
않는다는 뜻이므로 매입가로 폴백한다 — 설정 화면이 생기기 전에 만들어진 회사를 위한 안전망.""" 않는다는 뜻이므로 매입가로 폴백한다 — 설정 화면이 생기기 전에 만들어진 회사를 위한 안전망."""
chosen = (settings.get("features") or {}).get("nego_baseline_field") chosen = (settings.get("features") or {}).get("nego_baseline_field")
if chosen in (_BASELINE_PRICE, _BASELINE_PURCHASE): if chosen in _BASELINE_BY_FIELD:
return chosen return _BASELINE_BY_FIELD[chosen]
hidden = set(settings.get("hidden_fields") or []) hidden = set(settings.get("hidden_fields") or [])
if "price" in hidden and "purchase_price" not in hidden: if "price" in hidden and "purchase_price" not in hidden:
return _BASELINE_PURCHASE return _BASELINE_PURCHASE
@ -84,7 +84,6 @@ _NEGO_CARDS = table(
_WILD_CARDS = table( _WILD_CARDS = table(
"wild_cards", "wild_cards",
column("wild_card_id"), column("number"), column("script"), column("tactic"), column("deleted"), column("wild_card_id"), column("number"), column("script"), column("tactic"), column("deleted"),
column("available"),
schema="card", schema="card",
) )
# 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType). # 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType).
@ -179,7 +178,7 @@ class NegoContextCRUD(INegoContextCRUD):
return ErrorType.DB_RUN_FAILED, None return ErrorType.DB_RUN_FAILED, None
async def get_item_baseline(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, Tuple[int, str, dict]]: async def get_item_baseline(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, Tuple[int, str, dict]]:
_fallback = (0, _SUPPLIER_PRICE_LABEL, {}) _fallback = (0, _BASELINE_PRICE[2], {})
try: try:
# 상품 + 소속 고객사 설정 한 번에. 회사가 없어도(데이터 이상) 상품 행은 나오도록 outer join. # 상품 + 소속 고객사 설정 한 번에. 회사가 없어도(데이터 이상) 상품 행은 나오도록 outer join.
query = ( query = (
@ -195,9 +194,10 @@ class NegoContextCRUD(INegoContextCRUD):
price, purchase_price, settings = rows[0] price, purchase_price, settings = rows[0]
settings = settings if isinstance(settings, dict) else {} settings = settings if isinstance(settings, dict) else {}
labels = settings.get("labels") or {} labels = settings.get("labels") or {}
field = _resolve_baseline(settings) field, label_key, label_fallback = _resolve_baseline(settings)
value = purchase_price if field == _BASELINE_PURCHASE else price label = labels.get(label_key) or label_fallback
return ErrorType.SUCCESS, (int(value or 0), _SUPPLIER_PRICE_LABEL, labels) value = purchase_price if field == "purchase_price" else price
return ErrorType.SUCCESS, (int(value or 0), label, labels)
except Exception as ex: except Exception as ex:
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, _fallback return ErrorType.DB_RUN_FAILED, _fallback
@ -375,8 +375,6 @@ class NegoContextCRUD(INegoContextCRUD):
_VERSION_WILD_CARDS.c.version_id == version_id, _VERSION_WILD_CARDS.c.version_id == version_id,
_VERSION_WILD_CARDS.c.deleted == False, # noqa: E712 _VERSION_WILD_CARDS.c.deleted == False, # noqa: E712
_WILD_CARDS.c.deleted == False, # noqa: E712 _WILD_CARDS.c.deleted == False, # noqa: E712
# 협상 적용 여부(카드 설정 '적용 대기(수동)') — 꺼진 카드는 견적에 담겨 있어도 발동 금지
_WILD_CARDS.c.available == True, # noqa: E712
) )
.order_by(_VERSION_WILD_CARDS.c.created_at) .order_by(_VERSION_WILD_CARDS.c.created_at)
) )

View File

@ -11,15 +11,11 @@ from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from negotiation.cards.domain.tactics import ( from negotiation.cards.domain.tactics import (
OFFER_VARIABLES, Offer, available, compute_offer_detail, is_played, mark_played, playable, available, compute_offer, is_played, mark_played, playable, settle_ceiling, spec_from_context,
record_offer, settle_ceiling, spec_from_context,
) )
from negotiation.chat.service.script_repository import ScriptRepository from negotiation.chat.service.script_repository import ScriptRepository
MAX_ROUNDS = 3 # config 미주입 시 폴백 (규칙 정본은 tenant config negotiation.max_counter_rounds) MAX_ROUNDS = 3 # config 미주입 시 폴백 (규칙 정본은 tenant config negotiation.max_counter_rounds)
# 멘트에 찍히는 파생 가격 — 값이 다른 값에서 계산돼 나오는 것들(원값 인용 target/anchor 는 제외).
_DERIVED_PRICE_VARIABLES = ("target_mid_price", "middle_price")
_PRICE_MODES = ("price",) _PRICE_MODES = ("price",)
_CHOICE_MODES = ("yes_no", "confirm", "delivery_type") _CHOICE_MODES = ("yes_no", "confirm", "delivery_type")
@ -59,10 +55,10 @@ _SCRIPT_LABELS = {
"label_delivery_type_2": ("delivery_type.2", "지정택배배송"), "label_delivery_type_2": ("delivery_type.2", "지정택배배송"),
"label_delivery_type_3": ("delivery_type.3", "픽업배송"), "label_delivery_type_3": ("delivery_type.3", "픽업배송"),
"label_product": ("item.name", "상품명"), "label_product": ("item.name", "상품명"),
# 협상 기준가 호칭의 최후 폴백. 실제 값은 loader 가 회사 설정에서 정해 컨텍스트에 박제하고,
# 이 값은 DB 컨텍스트가 없는 데모/직접호출 경로에서만 쓰인다.
"label_item_price": ("item.price", "상품 단가"),
} }
# 협상 기준가 호칭 — 공급사 화면 고정 용어. 회사 용어 사전(labels)을 타지 않는다(그건 관리자 화면 전용).
# 실제 값은 loader 가 컨텍스트에 박제하고, DB 컨텍스트가 없는 데모/직접호출 경로만 이 폴백을 쓴다.
_SUPPLIER_PRICE_LABEL = "공급가"
# 조사 자동 보정: 토큰 뒤에 조사가 붙는 자리는 {label_supplier_를} 처럼 대표형을 적는다. # 조사 자동 보정: 토큰 뒤에 조사가 붙는 자리는 {label_supplier_를} 처럼 대표형을 적는다.
# 회사가 바꾼 용어의 받침을 예측할 수 없어 스크립트에 조사를 고정할 수 없다("협력사를"/"공급업체을"). # 회사가 바꾼 용어의 받침을 예측할 수 없어 스크립트에 조사를 고정할 수 없다("협력사를"/"공급업체을").
_JOSA = {"은": ("은", "는"), "는": ("은", "는"), "이": ("이", "가"), "가": ("이", "가"), _JOSA = {"은": ("은", "는"), "는": ("은", "는"), "이": ("이", "가"), "가": ("이", "가"),
@ -135,9 +131,8 @@ class ChatEngine:
if price is None: if price is None:
return self._error(session, "가격을 숫자로 입력해 주세요.") return self._error(session, "가격을 숫자로 입력해 주세요.")
session.context["input_price"] = price session.context["input_price"] = price
# 새 가격 제시 = 직전 카운터 제안 거절 확정 → 대기 중 카운터·그 재료 폐기. # 새 가격 제시 = 직전 카운터 제안 거절 확정 → 대기 중 카운터 폐기.
session.context.pop("pending_counter_price", None) session.context.pop("pending_counter_price", None)
session.context.pop("pending_offer", None)
session.context["prev_partner_price"] = price session.context["prev_partner_price"] = price
# 협력사 첫 제시가 — 가격 수용률(첫 제시가 대비 양보율) 동적 계산의 기준값. # 협력사 첫 제시가 — 가격 수용률(첫 제시가 대비 양보율) 동적 계산의 기준값.
session.context.setdefault("first_offer_price", price) session.context.setdefault("first_offer_price", price)
@ -155,7 +150,6 @@ class ChatEngine:
# 원 제시가 수락 종결 — 는 카운터를 버리고 기존 input_price 로 타결한다.) # 원 제시가 수락 종결 — 는 카운터를 버리고 기존 input_price 로 타결한다.)
if mode in _CHOICE_MODES and nxt in _SUCCESS_STEPS: if mode in _CHOICE_MODES and nxt in _SUCCESS_STEPS:
pending = session.context.pop("pending_counter_price", None) pending = session.context.pop("pending_counter_price", None)
session.context.pop("pending_offer", None)
if pending and user_input in _ACCEPT_INPUTS: if pending and user_input in _ACCEPT_INPUTS:
session.context["input_price"] = float(pending) session.context["input_price"] = float(pending)
return self._render(session, nxt) return self._render(session, nxt)
@ -246,7 +240,7 @@ class ChatEngine:
) )
if exhausted: if exhausted:
# 타결선은 목표가가 아니라 타결 상한가(견적 생성 시 박제) — 목표가를 넘어도 # 타결선은 목표가가 아니라 타결 상한가(견적 생성 시 박제) — 목표가를 넘어도
# 상한 이내면 타결한다. # 상한 이내면 타결한다(IMK: 기존 단가보다 인하됐는데 결렬되던 케이스).
ceiling = settle_ceiling(ctx) ceiling = settle_ceiling(ctx)
if not ctx.get("closing_played"): if not ctx.get("closing_played"):
ctx["force_closing"] = True ctx["force_closing"] = True
@ -261,8 +255,12 @@ class ChatEngine:
def _pick_wildcard(self, session: ChatSession) -> str: def _pick_wildcard(self, session: ChatSession) -> str:
"""앵커가에 아주 근접(≤ anchor×wildcard_1pct_ratio)한 구간에서만 1% 인하 요청(wild_card_1pct)으로 """앵커가에 아주 근접(≤ anchor×wildcard_1pct_ratio)한 구간에서만 1% 인하 요청(wild_card_1pct)으로
앵커가 이하로 유도한다. 그 외 구간은 견적에서 선택한 와일드카드의 전술로 카운터하고, 앵커가 이하로 유도한다. 그 외 구간은 일반 가격협상(카드 플레이)으로 돌린다.
낼 카드가 없으면 일반 가격협상(카드 플레이)으로 돌린다.
과거 여기서 반환하던 '재원부족'(wild_card_budget) 하드코딩 카드는 제거했다 —
견적에서 실제 선택한 와일드카드(중간값 절충·목표가 선제안 등)와 매핑되지 않은 채
'와일드카드를 하나라도 골랐으면' 조건만으로 발동해, 선택하지도 않은 재원부족 멘트가
노출되는 오작동이 있었다.
""" """
ctx = session.context ctx = session.context
price = ctx.get("input_price", 0) price = ctx.get("input_price", 0)
@ -270,16 +268,15 @@ class ChatEngine:
target = ctx.get("target_price", 0) target = ctx.get("target_price", 0)
if anchor > 0 and price <= anchor * self.rules.wildcard_1pct_ratio: if anchor > 0 and price <= anchor * self.rules.wildcard_1pct_ratio:
offer_1pct = int(price * 0.99 / 10 + 0.5) * 10 # 1% 인하가 — 10원 반올림(앵커·카운터와 통일) offer_1pct = int(price * 0.99 / 10 + 0.5) * 10 # 1% 인하가 — 10원 반올림(앵커·카운터와 통일)
# 제안가 공통 유효조건(≤목표가 · <제시가 · 직전 당사 제안 이상=역행 금지)은 시스템 1% 카드에도 # 제안가 공통 유효조건(≤목표가 · <제시가)은 시스템 1% 카드에도 동일하게 건다.
# 동일하게 건다. 기본 앵커 밴드에선 수학적으로 항상 통과하지만 극단 데이터를 방어한다. # 기본 앵커 밴드에선 수학적으로 항상 통과하지만, 앵커율 0 등 극단 데이터를 방어한다.
prev_customer = ctx.get("prev_customer_price") or 0 if 0 < offer_1pct < price and (target <= 0 or offer_1pct <= target):
if 0 < offer_1pct < price and (target <= 0 or offer_1pct <= target) and offer_1pct >= prev_customer:
# 와일드카드는 실제로 노출할 때만 '사용됨'으로 마킹한다 — 가격협상으로 돌아가는 # 와일드카드는 실제로 노출할 때만 '사용됨'으로 마킹한다 — 가격협상으로 돌아가는
# 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다. # 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다.
ctx["wildcard_used"] = True ctx["wildcard_used"] = True
ctx["offer_1pct"] = offer_1pct ctx["offer_1pct"] = offer_1pct
record_offer(ctx, Offer(price=offer_1pct, variable="offer_1pct", ctx["pending_counter_price"] = offer_1pct # 수락 시 이 가격으로 타결
prev_customer=int(prev_customer or anchor), prev_partner=int(price))) ctx["prev_customer_price"] = offer_1pct # 갑의 최신 포지션 — 이후 절충가 계산 기준
return "wild_card_1pct" return "wild_card_1pct"
# 1.02 초과 ~ entry(1.05) 구간: 견적에서 선택한 와일드카드의 전술로 카운터 제시. # 1.02 초과 ~ entry(1.05) 구간: 견적에서 선택한 와일드카드의 전술로 카운터 제시.
# (구현 전에는 이 구간이 일반 가격협상으로 회귀해 선택형 WC 가 영영 발동하지 않던 갭.) # (구현 전에는 이 구간이 일반 가격협상으로 회귀해 선택형 WC 가 영영 발동하지 않던 갭.)
@ -291,10 +288,11 @@ class ChatEngine:
# 이미 쓴 카드도 제외(같은 멘트 반복 방지). # 이미 쓴 카드도 제외(같은 멘트 반복 방지).
if not available(spec, ctx) or is_played(ctx, number): if not available(spec, ctx) or is_played(ctx, number):
continue continue
offer = compute_offer_detail(spec, ctx) offer = compute_offer(spec, ctx)
if offer is not None: if offer is not None:
ctx["wildcard_used"] = True ctx["wildcard_used"] = True
record_offer(ctx, offer) ctx["pending_counter_price"] = offer
ctx["prev_customer_price"] = offer # 갑의 최신 포지션 — "당사 제안 ○원" 멘트가 실제 이력과 일치
ctx["active_wild_card_number"] = number ctx["active_wild_card_number"] = number
mark_played(ctx, number) mark_played(ctx, number)
return "wild_card_dynamic" return "wild_card_dynamic"
@ -322,11 +320,6 @@ class ChatEngine:
out[token] = word out[token] = word
for form in ("는", "가", "를", "와"): for form in ("는", "가", "를", "와"):
out[f"{token}_{form}"] = _josa(word, form) out[f"{token}_{form}"] = _josa(word, form)
# 기준가 호칭은 회사 용어가 아니라 공급사 관점 고정 — loader 박제값(없으면 '공급가').
price_word = str(ctx.get("item_price_label") or _SUPPLIER_PRICE_LABEL)
out["label_item_price"] = price_word
for form in ("는", "가", "를", "와"):
out[f"label_item_price_{form}"] = _josa(price_word, form)
# 카드 에디터 카탈로그의 협력사명/상품명(partner_name·product_name) 치환. # 카드 에디터 카탈로그의 협력사명/상품명(partner_name·product_name) 치환.
if ctx.get("partner_name"): if ctx.get("partner_name"):
out["partner_name"] = str(ctx["partner_name"]) out["partner_name"] = str(ctx["partner_name"])
@ -343,38 +336,32 @@ class ChatEngine:
# 전술 카운터 변수(카드 시드 멘트의 가격 변수) — tactics.OFFER_VARIABLES 산식과 동일 정의. # 전술 카운터 변수(카드 시드 멘트의 가격 변수) — tactics.OFFER_VARIABLES 산식과 동일 정의.
anchor = ctx.get("anchor_price") or 0 anchor = ctx.get("anchor_price") or 0
target = ctx.get("target_price") or 0 target = ctx.get("target_price") or 0
# 표시 기준값 — 제안이 확정된 턴이면 그 계산에 쓴 재료(pending_offer)를 쓴다. if "input_price" in ctx:
# prev_customer_price 는 확정 즉시 새 제안가로 갱신되므로, 그대로 읽으면 멘트가 out["prev_partner_price"] = int(ctx.get("prev_partner_price") or ctx["input_price"])
# "당사 제안과 귀사 제안의 절반이 당사 제안" 같은 자기모순이 된다. prev_customer = ctx.get("prev_customer_price") or anchor
pending_offer = ctx.get("pending_offer") or {}
prev_customer = int(pending_offer.get("prev_customer") or ctx.get("prev_customer_price") or anchor or 0)
partner_price = int(pending_offer.get("prev_partner") or ctx.get("prev_partner_price") or ctx.get("input_price") or 0)
offer_price = int(pending_offer.get("price") or ctx.get("pending_counter_price") or 0)
offer_variable = pending_offer.get("variable") or ""
if prev_customer: if prev_customer:
out["prev_customer_price"] = prev_customer out["prev_customer_price"] = int(prev_customer)
if partner_price: if anchor and target:
out["prev_partner_price"] = partner_price out["target_mid_price"] = int(round((anchor + target) / 2))
if offer_price: if prev_customer and "input_price" in ctx:
out["counter_price"] = offer_price out["middle_price"] = int(round((prev_customer + ctx["input_price"]) / 2))
# 파생 가격(절충가·중간가) — 제안가로 확정된 변수는 그 금액을 그대로 쓴다(멘트에 보이는 금액과 if ctx.get("pending_counter_price"):
# 수락 시 타결가는 항상 같아야 한다). 나머지는 참고 인용이므로 tactics 산식으로 채운다. # 카운터 제시 중: 멘트에 보이는 제시가와 수락 시 타결가(pending)를 반드시 일치시킨다.
# 산식을 여기 복사해 두면 갱신 시점 차이로 표시가와 제안가가 갈라지므로 정의를 호출만 한다. # 절충/중간 변수(middle_price·target_mid_price)는 vars_for 재계산 값이 compute_offer 의
# 어느 변수가 제안가인지 모르는 진행 중 세션(구버전 기록)은 종전대로 전부 제안가로 고정한다. # target 클램프·prev_customer 갱신과 어긋나, 멘트엔 1,740,000 이 보이는데 실제로는
for name in _DERIVED_PRICE_VARIABLES: # 1,700,000 으로 타결되던 버그(표시가≠투찰가)가 있었다. pending 은 이 시점 유일한 '제안가'이므로
if offer_price and (name == offer_variable or not offer_variable): # 세 변수 모두 pending 으로 고정한다(카운터 제시 턴에만 적용 — 비-카운터 렌더는 원 계산값 유지).
out[name] = offer_price pending_i = int(ctx["pending_counter_price"])
continue out["counter_price"] = pending_i
value = OFFER_VARIABLES[name](target, anchor, partner_price, prev_customer) out["middle_price"] = pending_i
if value: out["target_mid_price"] = pending_i
out[name] = int(value / 10 + 0.5) * 10 # 10원 반올림 — compute_offer 와 동일
# 인하율 = (협상 기준가 - 제시가) / 기준가 * 100. 기준가 없으면 미표시(0.0). # 인하율 = (협상 기준가 - 제시가) / 기준가 * 100. 기준가 없으면 미표시(0.0).
# 제시가가 기준가보다 높으면(인상 제시) 음수가 나오는데, "-1.3% 인하된 금액" 같은 # 제시가가 기준가보다 높으면(인상 제시) 음수가 나오는데, "-1.3% 인하된 금액" 같은
# 모순 표현이 되므로 discount_rate 는 0 미만 금지하고, 인상/동일/인하를 구분한 # 모순 표현이 되므로 discount_rate 는 0 미만 금지하고, 인상/동일/인하를 구분한
# 문구는 discount_phrase 로 별도 제공한다(가격협상_확인 멘트가 사용). # 문구는 discount_phrase 로 별도 제공한다(가격협상_확인 멘트가 사용).
# 기준가 호칭은 공급사 화면 고정 용어('공급가') — loader 가 박제한 값. # 기준가 호칭(공급가/매입가/회사 라벨)은 회사 설정에서 온다 — loader 가 박제한 값.
base = ctx.get("item_price") or 0 base = ctx.get("item_price") or 0
label = ctx.get("item_price_label") or _SUPPLIER_PRICE_LABEL label = ctx.get("item_price_label") or _SCRIPT_LABELS["label_item_price"][1]
if base > 1 and "input_price" in ctx: if base > 1 and "input_price" in ctx:
rate = ((base - ctx["input_price"]) / base) * 100 rate = ((base - ctx["input_price"]) / base) * 100
out["discount_rate"] = f"{max(0.0, rate):.1f}" out["discount_rate"] = f"{max(0.0, rate):.1f}"
@ -403,8 +390,8 @@ class ChatEngine:
return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}") return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}")
# 가드레일(최후 방어선): 구매자 대리는 타결 상한가를 넘겨 타결하지 않는다. # 가드레일(최후 방어선): 구매자 대리는 타결 상한가를 넘겨 타결하지 않는다.
# 상한 = 견적 생성 시 박제한 done_ceiling_price(목표가×(1+타결상한율)), 미박제면 목표가. # 상한 = 견적 생성 시 박제한 done_ceiling_price(목표가×(1+타결상한율)), 미박제면 목표가.
# 목표가를 조금 넘어도 상한 이내면 타결이 정상이므로 여기서 뒤집지 않는다. # 목표가를 조금 넘어도 상한 이내면 타결이 정상이므로(IMK: 기존 단가보다 인하됐는데
# 상한까지 넘은 경우만 결렬로 강제 전환한다. # 결렬되던 케이스) 여기서 뒤집으면 안 된다. 상한까지 넘은 경우만 결렬로 강제 전환한다.
if step_key in _SUCCESS_STEPS and self.rq_type == "재협상": if step_key in _SUCCESS_STEPS and self.rq_type == "재협상":
ctx = session.context ctx = session.context
ceiling = settle_ceiling(ctx) ceiling = settle_ceiling(ctx)

View File

@ -13,9 +13,7 @@ from common.enums import DBType, ErrorType
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.logger import LOG from common.logger import LOG
from config.server_configs import agent_config from config.server_configs import agent_config
from negotiation.cards.domain.tactics import ( from negotiation.cards.domain.tactics import available, compute_offer, is_played, mark_played, playable, spec_from_context
Offer, available, compute_offer_detail, is_played, mark_played, playable, record_offer, spec_from_context,
)
from negotiation.chat.service.chat_engine import ( from negotiation.chat.service.chat_engine import (
_CHOICE_MODES, _PRICE_MODES, ChatEngine, ChatSession, StepView, _CHOICE_MODES, _PRICE_MODES, ChatEngine, ChatSession, StepView,
) )
@ -43,7 +41,7 @@ _DEFAULT_REVENUE_AMOUNT = 20_000_000 # 매출액(원) — suppliers.total_reven
_DEFAULT_DISTRIBUTION_CODE = "A" # 유통 코드 — supplier_items.supply_type 미지정 시 폴백 _DEFAULT_DISTRIBUTION_CODE = "A" # 유통 코드 — supplier_items.supply_type 미지정 시 폴백
_DEFAULT_PARTNER_NAME = "귀사" # 협력사명 — suppliers.name 미기재/데모 시 폴백(카드 {partner_name}) _DEFAULT_PARTNER_NAME = "귀사" # 협력사명 — suppliers.name 미기재/데모 시 폴백(카드 {partner_name})
_DEFAULT_PRODUCT_NAME = "본 상품" # 상품명 — items.name 미기재/데모 시 폴백(카드 {product_name}) _DEFAULT_PRODUCT_NAME = "본 상품" # 상품명 — items.name 미기재/데모 시 폴백(카드 {product_name})
_DEFAULT_ITEM_PRICE_LABEL = "공급가" # 협상 기준가 호칭(공급사 화면 고정 용어) — DB 컨텍스트 없는 데모/직접호출 경로 폴백 _DEFAULT_ITEM_PRICE_LABEL = "상품 단가" # 협상 기준가 호칭 — DB 컨텍스트 없는 데모/직접호출 경로 폴백
# (negodata 용어 카탈로그 item.price 의 base 와 같아야 표기가 갈리지 않는다) # (negodata 용어 카탈로그 item.price 의 base 와 같아야 표기가 갈리지 않는다)
@ -311,17 +309,16 @@ class ChatService:
# 협력사가 수락하면 이 가격으로 즉시 타결된다(chat_engine 의 수락 메커니즘). # 협력사가 수락하면 이 가격으로 즉시 타결된다(chat_engine 의 수락 메커니즘).
# 유효조건(≤목표가 · <제시가) 미달이면 None → 금액 없이 설득 멘트만 나간다(HOLD 강등). # 유효조건(≤목표가 · <제시가) 미달이면 None → 금액 없이 설득 멘트만 나간다(HOLD 강등).
spec = spec_from_context(session.context, card_id) spec = spec_from_context(session.context, card_id)
offer = compute_offer_detail(spec, session.context) if available(spec, session.context) else None counter = compute_offer(spec, session.context) if available(spec, session.context) else None
counter = offer.price if offer else None if counter is not None:
if offer is not None: session.context["pending_counter_price"] = counter
record_offer(session.context, offer) session.context["prev_customer_price"] = counter # 갑의 최신 포지션(절충가 계산 기준)
reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap) reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap)
policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=reward.total, done=False)) policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=reward.total, done=False))
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, done=False, await self._log(repo, session, idx, decision.action_id, card_id, snap, reward, decision.propensity, done=False)
decision=decision, policy=policy)
res.card_id = card_id res.card_id = card_id
res.policy = policy.name res.policy = policy.name
@ -372,29 +369,26 @@ class ChatService:
ctx["closing_played"] = True ctx["closing_played"] = True
# 선택 와일드카드 중 종결 전용 카드(closing) — 이미 쓴 카드는 건너뛰고(같은 멘트 반복 방지), # 선택 와일드카드 중 종결 전용 카드(closing) — 이미 쓴 카드는 건너뛰고(같은 멘트 반복 방지),
# 제안가 유효조건(≤목표가 · <제시가) 미달 카드도 건너뛴다(예: 절충가가 목표가 초과 → 미발동). # 제안가 유효조건(≤목표가 · <제시가) 미달 카드도 건너뛴다(예: 절충가가 목표가 초과 → 미발동).
closing_number, closing_offer = None, None closing_number, counter = None, None
for n in (ctx.get("selected_wild_card_numbers") or []): for n in (ctx.get("selected_wild_card_numbers") or []):
n = str(n) n = str(n)
spec = spec_from_context(ctx, n) spec = spec_from_context(ctx, n)
if not available(spec, ctx, closing_phase=True) or is_played(ctx, n): if not available(spec, ctx, closing_phase=True) or is_played(ctx, n):
continue continue
offer = compute_offer_detail(spec, ctx) offer = compute_offer(spec, ctx)
if offer is not None: if offer is not None:
closing_number, closing_offer = n, offer closing_number, counter = n, offer
break break
if closing_offer is None: if counter is None:
# 폴백 최후통첩: 목표가 제시 (여기 도달 = 제시가 > target 이므로 항상 유효한 카운터). # 폴백 최후통첩: 목표가 제시 (여기 도달 = 제시가 > target 이므로 항상 유효한 카운터).
closing_number = None closing_number = None
target = int(ctx.get("target_price") or 0) target = int(ctx.get("target_price") or 0)
price = int(ctx.get("input_price") or 0) counter = target if 0 < target < ctx.get("input_price", 0) else None
if 0 < target < price: if counter is None:
closing_offer = Offer(price=target, variable="target_price",
prev_customer=int(ctx.get("prev_customer_price") or ctx.get("anchor_price") or 0),
prev_partner=price)
if closing_offer is None:
return # 컨텍스트 이상 — 기존 가격협상 스텝 그대로(재제안 요구) return # 컨텍스트 이상 — 기존 가격협상 스텝 그대로(재제안 요구)
mark_played(ctx, closing_number) # None(폴백 최후통첩)이면 no-op mark_played(ctx, closing_number) # None(폴백 최후통첩)이면 no-op
record_offer(ctx, closing_offer) ctx["pending_counter_price"] = counter
ctx["prev_customer_price"] = counter
template = None template = None
if closing_number: if closing_number:
@ -421,7 +415,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, done=True) self._card_id_for_action(engine, session, last_action), snap, reward, None, 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
@ -500,26 +494,13 @@ 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, done, async def _log(self, repo: LearningRepository, session, state_index, action_id, card_id, snap, reward, propensity, 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(), "card_id": card_id, "snapshot": snap.to_dict(), "propensity": propensity,
"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:

View File

@ -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, policy) res.logged = await self._log(engine, session_id, idx, decision, snap, reward)
return res return res
async def _log(self, engine, session_id, idx, decision, snap, reward, policy) -> bool: async def _log(self, engine, session_id, idx, decision, snap, reward) -> 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,8 +108,6 @@ 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:

View File

@ -191,9 +191,8 @@ async def test_loader_with_crud_double(db_engine):
return ErrorType.SUCCESS, (2, 50000, None, 52500, uuid.uuid4(), uuid.uuid4(), uuid.uuid4()) return ErrorType.SUCCESS, (2, 50000, None, 52500, uuid.uuid4(), uuid.uuid4(), uuid.uuid4())
async def get_item_baseline(self, cdb, item_id): async def get_item_baseline(self, cdb, item_id):
# 기준가를 매입가로 고른 회사 + 거래상대 호칭을 '공급업체'로 바꾼 용어 사전. # 기준가를 매입가로 고른 회사 + 거래상대 호칭을 '공급업체'로 바꾼 용어 사전
# 호칭은 crud 가 어떤 회사든 '공급가'(공급사 화면 고정 용어)로 내려준다. return ErrorType.SUCCESS, (7000, "매입가", {"supplier": "공급업체"})
return ErrorType.SUCCESS, (7000, "공급가", {"supplier": "공급업체"})
async def get_item_lowest_price(self, cdb, item_id): async def get_item_lowest_price(self, cdb, item_id):
return ErrorType.SUCCESS, 6300 # 인터넷 최저가(items.internet_lowest_price) return ErrorType.SUCCESS, 6300 # 인터넷 최저가(items.internet_lowest_price)
@ -234,7 +233,7 @@ async def test_loader_with_crud_double(db_engine):
assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target) assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target)
assert ctx.done_ceiling_price == 52500 # 타결 상한가 박제값(목표가 +5%) assert ctx.done_ceiling_price == 52500 # 타결 상한가 박제값(목표가 +5%)
assert ctx.item_price == 7000 assert ctx.item_price == 7000
assert ctx.item_price_label == "공급가" # 기준가 호칭이 멘트까지 전달되는지 assert ctx.item_price_label == "매입가" # 기준가 호칭이 멘트까지 전달되는지
assert ctx.labels == {"supplier": "공급업체"} # 회사 용어 사전이 스크립트 토큰용으로 실리는지 assert ctx.labels == {"supplier": "공급업체"} # 회사 용어 사전이 스크립트 토큰용으로 실리는지
assert ctx.internet_lowest_price == 6300 # 인터넷 최저가 로드 확인 assert ctx.internet_lowest_price == 6300 # 인터넷 최저가 로드 확인
assert ctx.card_count == 3 # 협상카드 사용 횟수 상한 로드 확인 assert ctx.card_count == 3 # 협상카드 사용 횟수 상한 로드 확인

View File

@ -1,33 +1,18 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Optional, Tuple from typing import Tuple
from sqlalchemy import and_, case, cast, func, nulls_last, or_, select, text, update from sqlalchemy import case, cast, func, nulls_last, or_, select, text, update
from sqlalchemy.dialects.postgresql import JSONB 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
from common.database.model.models import chats, items, quotations, sessions from common.database.model.models import items, quotations, sessions
from common.enums import CloseReason, ErrorType, QuotationStatus, RENEGOTIABLE_CLOSE_REASONS, SessionStatus from common.enums import CloseReason, ErrorType, QuotationStatus, RENEGOTIABLE_CLOSE_REASONS, SessionStatus
from common.logger import LOG from common.logger import LOG
# 협상 세션 CRUD. 목록은 세션(negotiation) ⨝ 상품(partner) ⨝ 견적(quotation) 조인으로 만든다. # 협상 세션 CRUD. 목록은 세션(negotiation) ⨝ 상품(partner) ⨝ 견적(quotation) 조인으로 만든다.
# 마감일(qt_end_time)은 견적(quotation.end_time)이 진실값이다(session.end_time 은 협상 종료 시점 기록용). # 마감일(qt_end_time)은 견적(quotation.end_time)이 진실값이다(session.end_time 은 협상 종료 시점 기록용).
def _effective_status():
"""표시용 세션 상태. 견적이 마감됐거나 마감시간이 지났으면 협상생성(1)은 더 참여할 수 없으므로 미참여(4)로 본다.
참여/채팅진입이 진입 시점에 하는 전이(negotiation_service._load_actionable_session, chat_service.init)와 같은 규칙을
목록에서는 쓰기 없이 파생으로만 맞춘다. 마감 일괄정리 이후에 만들어진 세션도 '협상 대기'로 남지 않는다.
"""
ended = or_(quotations.status == QuotationStatus.CLOSED.value, quotations.end_time < func.now())
return case(
(and_(sessions.status == SessionStatus.CREATED.value, ended), SessionStatus.NOT_PARTICIPATED.value),
else_=sessions.status,
)
class ISessionCRUD(ABC): class ISessionCRUD(ABC):
@abstractmethod @abstractmethod
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit, keyword=None, result=None) -> Tuple[ErrorType, list]: async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit, keyword=None, result=None) -> Tuple[ErrorType, list]:
@ -54,9 +39,7 @@ class ISessionCRUD(ABC):
pass pass
@abstractmethod @abstractmethod
async def update_session_reject( async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType:
self, cdb: AsyncSession, session_id, status: int, reject_reason: str, reject_price: Optional[int] = None,
) -> ErrorType:
pass pass
@abstractmethod @abstractmethod
@ -64,6 +47,19 @@ class ISessionCRUD(ABC):
pass pass
@abstractmethod @abstractmethod
async def chain_max_round(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, int]:
# 같은 견적번호(체인)의 최대 차수. 이미 다음 라운드가 있으면 재협상 요청은 의미가 없다.
try:
query = select(func.max(quotations.round)).where(quotations.number == number, quotations.deleted == False) # noqa: E712
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, 0
top = rows[0][0] if rows and rows[0] else None
return ErrorType.SUCCESS, int(top or 0)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def merge_session_custom(self, cdb: AsyncSession, session_id, supplier_id, patch: dict) -> ErrorType: async def merge_session_custom(self, cdb: AsyncSession, session_id, supplier_id, patch: dict) -> ErrorType:
pass pass
@ -77,8 +73,7 @@ class SessionCRUD(ISessionCRUD):
def __filters(supplier_id, status, qt_type, keyword=None, result=None): def __filters(supplier_id, status, qt_type, keyword=None, result=None):
conds = [sessions.supplier_id == supplier_id, sessions.deleted == False] # noqa: E712 conds = [sessions.supplier_id == supplier_id, sessions.deleted == False] # noqa: E712
if status is not None: if status is not None:
# 표시 상태로 필터 — 탭/KPI 카운트가 목록 배지와 어긋나지 않게 파생값을 그대로 쓴다. conds.append(sessions.status == status)
conds.append(_effective_status() == status)
if qt_type is not None: if qt_type is not None:
conds.append(sessions.qt_type == qt_type) conds.append(sessions.qt_type == qt_type)
# 검색: 견적번호·상품명·상품코드 부분일치(대소문자 무시). items 는 목록/카운트 둘 다 조인돼 있다. # 검색: 견적번호·상품명·상품코드 부분일치(대소문자 무시). items 는 목록/카운트 둘 다 조인돼 있다.
@ -118,7 +113,7 @@ class SessionCRUD(ISessionCRUD):
else: else:
# 그룹별로 정렬 방향이 달라, case 로 '자기 그룹 행만 end_time' 을 갖는 키를 만들고 # 그룹별로 정렬 방향이 달라, case 로 '자기 그룹 행만 end_time' 을 갖는 키를 만들고
# 반대 그룹은 NULL 로 눌러 간섭을 없앤다. status_rank 가 1차 키라 그룹 경계는 항상 유지. # 반대 그룹은 NULL 로 눌러 간섭을 없앤다. status_rank 가 1차 키라 그룹 경계는 항상 유지.
actionable = _effective_status().in_((SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value)) actionable = sessions.status.in_((SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value))
status_rank = case((actionable, 0), else_=1) status_rank = case((actionable, 0), else_=1)
action_order = case((actionable, quotations.end_time), else_=None).asc() action_order = case((actionable, quotations.end_time), else_=None).asc()
done_order = case((~actionable, quotations.end_time), else_=None).desc() done_order = case((~actionable, quotations.end_time), else_=None).desc()
@ -127,7 +122,7 @@ class SessionCRUD(ISessionCRUD):
query = ( query = (
select( select(
sessions.session_id, sessions.session_id,
_effective_status(), # 마감 후 남은 협상생성은 미참여로 내린다 sessions.status,
sessions.qt_type, sessions.qt_type,
sessions.qt_number, sessions.qt_number,
quotations.end_time, # qt_end_time = 견적 마감 시각 quotations.end_time, # qt_end_time = 견적 마감 시각
@ -141,11 +136,6 @@ class SessionCRUD(ISessionCRUD):
quotations.round, quotations.round,
quotations.preferred_sp_id, # 낙찰자(공급사) — 나와 같으면 낙찰, 다르면 미낙찰 quotations.preferred_sp_id, # 낙찰자(공급사) — 나와 같으면 낙찰, 다르면 미낙찰
sessions.supplier_id, # 이 세션 소유 공급사(=조회자). 낙찰자와 대조 sessions.supplier_id, # 이 세션 소유 공급사(=조회자). 낙찰자와 대조
# 대화 이력 유무 — 종료된 협상의 '결과 보기'(열람) 버튼을 띄울지 판단용. 열 게 없으면 프론트가 감춘다.
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)
@ -222,18 +212,12 @@ class SessionCRUD(ISessionCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED return ErrorType.DB_RUN_FAILED
async def update_session_reject( async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType:
self, cdb: AsyncSession, session_id, status: int, reject_reason: str, reject_price: Optional[int] = None,
) -> ErrorType:
try: try:
values = {"status": status, "reject_reason": reject_reason}
# 공급 희망 가격은 선택 입력이라 안 들어올 수 있다 — 그때는 컬럼을 건드리지 않는다.
if reject_price is not None:
values["reject_price"] = reject_price
query = ( query = (
update(sessions) update(sessions)
.where(sessions.session_id == session_id) .where(sessions.session_id == session_id)
.values(**values) .values(status=status, reject_reason=reject_reason)
) )
return await DB_SESSION_MNG.add(cdb, query) return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex: except Exception as ex:
@ -247,8 +231,7 @@ class SessionCRUD(ISessionCRUD):
err_type, rows = await DB_SESSION_MNG.execute(cdb, query) err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
return err_type, 0 return err_type, 0
# 단일 컬럼 select 는 scalars() 로 내려와 rows 가 값 리스트다(행 튜플이 아님). top = rows[0][0] if rows and rows[0] else None
top = rows[0] if rows else None
return ErrorType.SUCCESS, int(top or 0) return ErrorType.SUCCESS, int(top or 0)
except Exception as ex: except Exception as ex:
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)

View File

@ -72,4 +72,5 @@ class Res_HidePopup(Res_WebPacketProtocol):
class Res_SessionBranding(Res_WebPacketProtocol): class Res_SessionBranding(Res_WebPacketProtocol):
service_name: str = Field("", description="회사 서비스명(companies.settings.branding.service_name). 미설정 시 빈 값") service_name: str = Field("", description="회사 서비스명(companies.settings.branding.service_name). 미설정 시 빈 값")
logo_url: str = Field("", description="회사 로고 URL") logo_url: str = Field("", description="회사 로고 URL")
primary_color: str = Field("", description="브랜드 색상(hex)")
helpdesk: list = Field(default_factory=list, description="헬프데스크 연락처 줄 목록(companies.settings.branding.helpdesk). 한 줄 = 담당자 한 명") helpdesk: list = Field(default_factory=list, description="헬프데스크 연락처 줄 목록(companies.settings.branding.helpdesk). 한 줄 = 담당자 한 명")

View File

@ -79,8 +79,6 @@ 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) 치환용. 없으면 프론트 기본값")

View File

@ -21,9 +21,6 @@ class ListItem(WebPacketProtocol):
renegotiation_status: int = Field(0, description="현재 재협상 요청 상태(RenegotiationStatus). 요청 이력이 없으면 0") renegotiation_status: int = Field(0, description="현재 재협상 요청 상태(RenegotiationStatus). 요청 이력이 없으면 0")
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="대화 이력 존재 여부 — 종료된 협상(미참여·거부)의 '결과 보기' 노출 판단용")
reject_reason: str = Field("", description="협상 거부 시 제출한 사유. 거부 건이 아니면 빈 문자열")
reject_price: Optional[int] = Field(None, description="협상 거부 시 함께 낸 공급 희망 가격(원). 미입력이면 null")
class Res_SessionList(Res_WebPacketProtocol): class Res_SessionList(Res_WebPacketProtocol):
@ -39,8 +36,6 @@ 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="공급 희망 가격(원). 선택 입력 — 없으면 컬럼 미변경")
opinion: Optional[str] = Field(None, max_length=255, description="추가 의견 — sessions.custom.opinion 에 병합")
class Res_Reject(Res_WebPacketProtocol): class Res_Reject(Res_WebPacketProtocol):

View File

@ -62,7 +62,7 @@ async def participate(
path="/sessions/{session_id}/reject", path="/sessions/{session_id}/reject",
response_model=Res_Reject, response_model=Res_Reject,
summary="협상 거부", summary="협상 거부",
description="세션 참여를 거부하거나 진행 중인 협상을 거부한다. 소유(공급사)·세션상태(완료/미참여/거부 불가)·견적마감·마감시간 검증 후 협상거부로 전이하고 사유·공급 희망 가격·의견을 저장.", description="세션 참여를 거부한다. 소유(공급사)·세션상태(완료/미참여/거부 불가)·견적마감·마감시간 검증 후 협상거부로 전이하고 사유를 저장.",
) )
async def reject( async def reject(
session_id: str = Path(description="대상 협상 세션 uuid"), session_id: str = Path(description="대상 협상 세션 uuid"),
@ -71,11 +71,7 @@ async def reject(
credentials: HTTPAuthorizationCredentials = Depends(security), credentials: HTTPAuthorizationCredentials = Depends(security),
service: NegotiationService = Depends(), service: NegotiationService = Depends(),
): ):
return RemoveNoneResponse( return RemoveNoneResponse(await service.reject(user_info, credentials.credentials, session_id, req.reject_reason))
await service.reject(
user_info, credentials.credentials, session_id, req.reject_reason, req.reject_price, req.opinion,
)
)
@router.post( @router.post(

View File

@ -279,6 +279,7 @@ class AuthService:
branding = branding or {} branding = branding or {}
res.service_name = branding.get("service_name") or "" res.service_name = branding.get("service_name") or ""
res.logo_url = branding.get("logo_url") or "" res.logo_url = branding.get("logo_url") or ""
res.primary_color = branding.get("primary_color") or ""
res.helpdesk = branding.get("helpdesk") or [] res.helpdesk = branding.get("helpdesk") or []
return res return res

View File

@ -71,14 +71,6 @@ 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)
@ -235,8 +227,11 @@ class ChatService:
) )
sess.status = SessionStatus.NOT_PARTICIPATED.value sess.status = SessionStatus.NOT_PARTICIPATED.value
# 미참여/협상거부 세션도 '결과 보기'로 지난 대화를 열람할 수 있다(중간 이탈·거부로 끝난 건). # 미참여/협상거부 상태는 진입(열람) 불가 (participate/reject 와 동일 규칙).
# 대화 재개는 send() 가 협상중(2)만 허용하므로 여기서 막지 않아도 읽기 전용이다. # 위 마감 변환으로 미참여가 된 세션도 여기서 함께 막힌다.
if sess.status in (SessionStatus.NOT_PARTICIPATED.value, SessionStatus.REJECTED.value):
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
return res
await self._ensure_in_progress(sess, quote) await self._ensure_in_progress(sess, quote)
@ -259,9 +254,6 @@ 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(
@ -270,18 +262,14 @@ class ChatService:
) )
res.labels = (settings.get("labels") or {}) if _e == ErrorType.SUCCESS and settings else {} res.labels = (settings.get("labels") or {}) if _e == ErrorType.SUCCESS and settings else {}
# 회사가 VAT(vat_yn)를 관리하지 않으면(hidden_fields) 협상 화면 VAT 표기를 숨긴다(값 null → 프론트 라벨 생략).
_hidden = (settings.get("hidden_fields") or []) if _e == ErrorType.SUCCESS and settings else [] _hidden = (settings.get("hidden_fields") or []) if _e == ErrorType.SUCCESS and settings else []
_features = (settings.get("features") or {}) if _e == ErrorType.SUCCESS and settings else {} if "vat_yn" in _hidden:
# VAT 표기 — 부가세 전체 통일 회사(features.vat_mode)는 상품 잔존값과 무관하게 'VAT 별도' 고정(False).
# 상품별 관리 회사가 vat_yn 을 숨겼으면(구 방식) 표기 자체를 생략한다(값 null → 프론트 라벨 생략).
if _features.get("vat_mode") == "unified_excluded":
res.item_vat_yn = False
elif "vat_yn" in _hidden:
res.item_vat_yn = None res.item_vat_yn = None
# 협상 기준가 — 회사 설정에서 고른 가격 컬럼(features.nego_baseline_field). # 협상 기준가 — 회사 설정에서 고른 가격 컬럼(features.nego_baseline_field).
# agent 의 인하율 멘트(nego_context_crud._resolve_baseline)와 같은 규칙이어야 화면과 멘트가 어긋나지 않는다. # agent 의 인하율 멘트(nego_context_crud._resolve_baseline)와 같은 규칙이어야 화면과 멘트가 어긋나지 않는다.
_features = (settings.get("features") or {}) if _e == ErrorType.SUCCESS and settings else {}
_baseline = _features.get("nego_baseline_field") _baseline = _features.get("nego_baseline_field")
if _baseline not in ("price", "purchase_price"): if _baseline not in ("price", "purchase_price"):
# 미설정 회사 폴백 — 공급가를 감췄으면 그 회사는 공급가를 관리하지 않는다는 뜻. # 미설정 회사 폴백 — 공급가를 감췄으면 그 회사는 공급가를 관리하지 않는다는 뜻.

View File

@ -1,6 +1,5 @@
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional
from fastapi import Depends from fastapi import Depends
@ -183,9 +182,6 @@ class NegotiationService:
renegotiation_status=status, renegotiation_status=status,
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]),
reject_reason=r[16] or "",
reject_price=r[17],
) )
@staticmethod @staticmethod
@ -438,10 +434,7 @@ class NegotiationService:
res.session_id = str(sess.session_id) res.session_id = str(sess.session_id)
return res return res
async def reject( async def reject(self, user_info: UserInfo, access_token: str, session_id_str: str, reject_reason: str) -> Res_Reject:
self, user_info: UserInfo, access_token: str, session_id_str: str, reject_reason: str,
reject_price: Optional[int] = None, opinion: Optional[str] = None,
) -> Res_Reject:
res = Res_Reject() res = Res_Reject()
# 거부 사유 필수 # 거부 사유 필수
@ -461,19 +454,11 @@ class NegotiationService:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res return res
# 거부 처리 — 세션을 협상거부로 전이하고 사유·공급 희망 가격 저장. # 거부 처리 — 세션을 협상거부로 전이하고 사유 저장
# 의견은 부가정보와 같은 custom 컬럼이라 병합(덮어쓰기 금지) — 채팅 결렬 폼과 같은 자리. err_type = await DB_SESSION_MNG.execute_lambda_run(
funcs = [ [sessions.DBType()],
lambda s: self.session_crud.update_session_reject( [lambda s: self.session_crud.update_session_reject(s, sess.session_id, SessionStatus.REJECTED.value, reason)],
s, sess.session_id, SessionStatus.REJECTED.value, reason, reject_price, )
)
]
note = (opinion or "").strip()[:255]
if note:
funcs.append(
lambda s: self.session_crud.merge_session_custom(s, sess.session_id, sess.supplier_id, {"opinion": note})
)
err_type = await DB_SESSION_MNG.execute_lambda_run([sessions.DBType()], funcs)
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res return res

View File

@ -220,58 +220,12 @@ 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()
assert body["result"]["code"] == 1300 # NEGO_FORBIDDEN assert body["result"]["code"] == 1300 # NEGO_FORBIDDEN
async def test_chat_init_vat_mode_unified_shows_excluded(client, chat_seed, db_engine):
"""검증: 부가세 전체 통일 회사(features.vat_mode=unified_excluded)의 세션 채팅 init.
기대결과: 상품에 vat_yn=true 잔존값이 있어도 item_vat_yn=False — 프론트가 'VAT별도'로 고정 표기."""
import json
company_id = uuid.uuid4()
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO company.companies (company_id, name, status, settings) VALUES (:c, :n, 1, CAST(:s AS JSONB))"),
{"c": company_id, "n": f"{MARK}VAT통일사", "s": json.dumps({"features": {"vat_mode": "unified_excluded"}})},
)
await conn.execute(
text("UPDATE partner.suppliers SET company_id = :c WHERE supplier_id = :sid"),
{"c": company_id, "sid": chat_seed["supplier_id"]},
)
await conn.execute(
text("UPDATE partner.items SET vat_yn = true WHERE item_id = (SELECT item_id FROM negotiation.sessions WHERE session_id = :s)"),
{"s": chat_seed["sids"]["P"]},
)
try:
token = await _login_token(client)
body = (await _init(client, token, chat_seed["sids"]["P"])).json()
assert body["result"]["success"] is True
assert body["item_vat_yn"] is False
finally:
async with db_engine.begin() as conn:
await conn.execute(text("DELETE FROM company.companies WHERE company_id = :c"), {"c": company_id})
# ---- messages (오프닝 seed) ------------------------------------------------- # ---- messages (오프닝 seed) -------------------------------------------------
async def test_messages_seeds_opening(client, chat_seed): async def test_messages_seeds_opening(client, chat_seed):
token = await _login_token(client) token = await _login_token(client)
@ -397,28 +351,23 @@ async def test_send_blocked_when_prev_turn_pending(client, chat_seed, db_engine)
async def test_init_marks_expired_created_as_not_participated(client, chat_seed, db_engine): async def test_init_marks_expired_created_as_not_participated(client, chat_seed, db_engine):
"""검증: 마감시간이 지난 협상생성 세션으로 채팅 진입.
기대결과: DB 상태가 미참여(4)로 정리되고, init 자체는 열람용으로 성공한다."""
token = await _login_token(client) token = await _login_token(client)
sid, qid = chat_seed["sids"]["C"], chat_seed["qids"]["C"] # 협상생성(1) sid, qid = chat_seed["sids"]["C"], chat_seed["qids"]["C"] # 협상생성(1)
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
await conn.execute(text("UPDATE quotation.quotations SET end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"), {"qid": qid}) await conn.execute(text("UPDATE quotation.quotations SET end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"), {"qid": qid})
body = (await _init(client, token, sid)).json() body = (await _init(client, token, sid)).json()
assert body["result"]["success"] is True # 마감된 협상생성은 DB 상 미참여로 정리되고, 미참여는 진입 불가라 init 은 에러로 막는다.
assert body["session_status"] == 4 assert body["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE
assert await _session_status(db_engine, sid) == 4 # DB 도 미참여로 전이 assert await _session_status(db_engine, sid) == 4 # DB 는 미참여로 전이됨
async def test_init_allows_viewing_rejected_session(client, chat_seed, db_engine): async def test_init_blocks_rejected_session(client, chat_seed, db_engine):
"""검증: 협상거부(5)로 끝난 세션에 '결과 보기'로 재진입.
기대결과: init 성공(열람 허용) — 대화 재개는 send 가 협상중만 허용해 막는다."""
token = await _login_token(client) token = await _login_token(client)
sid = chat_seed["sids"]["P"] sid = chat_seed["sids"]["P"]
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
await conn.execute(text("UPDATE negotiation.sessions SET status = 5 WHERE session_id = :sid"), {"sid": sid}) # 협상거부 await conn.execute(text("UPDATE negotiation.sessions SET status = 5 WHERE session_id = :sid"), {"sid": sid}) # 협상거부
body = (await _init(client, token, sid)).json() body = (await _init(client, token, sid)).json()
assert body["result"]["success"] is True and body["session_status"] == 5 assert body["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE — 거부 세션 진입 차단
assert (await _send(client, token, sid, "네")).json()["result"]["code"] == 1400 # CHAT_NOT_IN_PROGRESS
# ---- 순수 헬퍼 단위 테스트 (DB 불필요, ChatService @staticmethod) ---------- # ---- 순수 헬퍼 단위 테스트 (DB 불필요, ChatService @staticmethod) ----------

View File

@ -16,35 +16,6 @@ TEST_SUPPLIER_NAME = "파이테스트협상공급사"
MARK = "PYTESTNEGO-" # 시드 식별용 prefix (item code / qt number) MARK = "PYTESTNEGO-" # 시드 식별용 prefix (item code / qt number)
async def _seed_case(conn, code, sess_st, qt_type, hrs, quote_st, sup):
"""상품·견적·세션 1세트 시드. 코드/견적번호에 MARK prefix 를 달아 cleanup 이 함께 지운다.
hrs 는 마감(quotation.end_time)까지의 시간 — 음수면 이미 마감시간이 지난 건. 반환: (session_id, qt_id)."""
item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
await conn.execute(
text(
"INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, :model, '테스트제조사')"
),
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"},
)
await conn.execute(
text(
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time) "
"VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, :tp, :st, now(), now() + make_interval(hours => :hrs))"
),
{"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "tp": qt_type, "st": quote_st, "hrs": hrs},
)
await conn.execute(
text(
"INSERT INTO negotiation.sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time) "
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, :qtt, 100000, :st, now())"
),
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "qtt": qt_type, "st": sess_st},
)
return session_id, qt_id
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def nego_seed(db_engine): async def nego_seed(db_engine):
"""공급사 + 유저 + 세션/견적 3건(본인) + 1건(타 공급사) 시드. 세션/견적 id 를 반환.""" """공급사 + 유저 + 세션/견적 3건(본인) + 1건(타 공급사) 시드. 세션/견적 id 를 반환."""
@ -62,11 +33,6 @@ async def nego_seed(db_engine):
sids, qids = {}, {} sids, qids = {}, {}
async def _cleanup(conn): async def _cleanup(conn):
# 대화는 세션보다 먼저 지운다(세션이 사라지면 대상을 못 고른다).
await conn.execute(text(
f"DELETE FROM negotiation.chats WHERE session_id IN "
f"(SELECT session_id FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%')"
))
await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'")) await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'"))
await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'")) await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'"))
await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'")) await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'"))
@ -86,9 +52,31 @@ async def nego_seed(db_engine):
), ),
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash}, {"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash},
) )
for spec in specs: for code, sess_st, qt_type, hrs, quote_st, sup in specs:
code = spec[0] item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
sids[code], qids[code] = await _seed_case(conn, *spec) sids[code], qids[code] = session_id, qt_id
await conn.execute(
text(
"INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, :model, '테스트제조사')"
),
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"},
)
await conn.execute(
text(
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time) "
"VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, :tp, :st, now(), now() + make_interval(hours => :hrs))"
),
{"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "tp": qt_type, "st": quote_st, "hrs": hrs},
)
await conn.execute(
text(
"INSERT INTO negotiation.sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time) "
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, :qtt, 100000, :st, now())"
),
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "qtt": qt_type, "st": sess_st},
)
yield {"supplier_id": supplier_id, "sids": sids, "qids": qids} yield {"supplier_id": supplier_id, "sids": sids, "qids": qids}
@ -153,63 +141,6 @@ async def test_list_filter_status(client, nego_seed):
assert body["total"] == 1 and body["items"][0]["item_code"] == f"{MARK}B" assert body["total"] == 1 and body["items"][0]["item_code"] == f"{MARK}B"
async def test_list_shows_closed_quotation_created_session_as_not_participated(client, db_engine, nego_seed):
"""검증: 견적이 마감(3)된 뒤에도 세션이 협상생성(1)으로 남아 있는 건(마감 일괄정리 이후 생성 등).
기대결과: 목록 상태는 미참여(4) — '협상 대기'로 새지 않고, status=1 필터에서도 빠지고 status=4 필터에 잡힌다."""
async with db_engine.begin() as conn:
await _seed_case(conn, "CLOSED1", 1, 2, -1, 3, nego_seed["supplier_id"])
token = await _login_token(client)
listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}CLOSED1")
assert listed["session_status"] == 4
waiting = (await _list(client, token, status=1)).json()
assert waiting["total"] == 1 and {i["item_code"] for i in waiting["items"]} == {f"{MARK}A"}
assert f"{MARK}CLOSED1" in {i["item_code"] for i in (await _list(client, token, status=4)).json()["items"]}
async def test_list_shows_deadline_passed_created_session_as_not_participated(client, db_engine, nego_seed):
"""검증: 견적은 아직 진행중(2)인데 마감시간(end_time)만 지난 협상생성 세션.
기대결과: 미참여(4) — 참여/입장이 막히는 건이라 목록도 같은 상태로 보인다(DB 값은 그대로)."""
async with db_engine.begin() as conn:
session_id, _ = await _seed_case(conn, "OVERDUE", 1, 2, -3, 2, nego_seed["supplier_id"])
token = await _login_token(client)
listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}OVERDUE")
assert listed["session_status"] == 4
assert await _session_status(db_engine, session_id) == 1 # 목록은 파생 표시만, 쓰기는 하지 않는다
async def test_list_marks_stale_round_not_renegotiable(client, db_engine, nego_seed):
"""검증: 개찰(결렬) 마감된 1차 견적에 2차가 이미 생성돼 있는 체인.
기대결과: renegotiable False — 다음 라운드가 있으면 재협상 요청 대상이 아니다(체인 최대 차수 판정)."""
async with db_engine.begin() as conn:
await _seed_case(conn, "CHAIN", 3, 2, -2, 3, nego_seed["supplier_id"])
await conn.execute(text(
f"UPDATE quotation.quotations SET close_reason = 5 WHERE number = '{MARK}CHAIN'"))
# 같은 견적번호의 2차 — 번호가 같아야 체인으로 묶인다.
await conn.execute(text(
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, round, start_time, end_time) "
f"VALUES (gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), '견적 CHAIN 2차', '{MARK}CHAIN', 2, 2, 2, now(), now() + make_interval(hours => 2))"))
token = await _login_token(client)
listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}CHAIN")
assert listed["result"] == 3 and listed["renegotiable"] is False
async def test_list_has_chat_flags_sessions_with_history(client, db_engine, nego_seed):
"""검증: 대화 이력이 있는 세션과 없는 세션의 has_chat.
기대결과: 이력 있는 건만 True — 종료 건의 '결과 보기' 노출이 이 값으로 갈린다."""
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO negotiation.chats (session_id, seq, sender, target_price) VALUES (:sid, 1, 1, 0)"),
{"sid": nego_seed["sids"]["C"]},
)
token = await _login_token(client)
by_code = {i["item_code"]: i["has_chat"] for i in (await _list(client, token)).json()["items"]}
assert by_code[f"{MARK}C"] is True
assert by_code[f"{MARK}A"] is False
async def test_list_filter_qt_type(client, nego_seed): async def test_list_filter_qt_type(client, nego_seed):
token = await _login_token(client) token = await _login_token(client)
body = (await _list(client, token, qt_type=2)).json() body = (await _list(client, token, qt_type=2)).json()
@ -385,65 +316,6 @@ async def test_reject_success(client, nego_seed, db_engine):
assert status == 5 and reason == "단종 상품입니다" # REJECTED + 사유 저장 assert status == 5 and reason == "단종 상품입니다" # REJECTED + 사유 저장
async def test_reject_with_price_and_opinion(client, nego_seed, db_engine):
# 채팅 내 협상 거부 경로 — 사유 외에 공급 희망 가격과 의견까지 함께 남긴다.
token = await _login_token(client)
sid = nego_seed["sids"]["B"]
r = await client.post(
f"/v1/negotiation/sessions/{sid}/reject",
headers={"Authorization": f"Bearer {token}"},
json={"reject_reason": "품절", "reject_price": 88000, "opinion": "대체품으로 재견적 부탁드립니다"},
)
assert r.json()["result"]["success"] is True
async with db_engine.begin() as conn:
row = (await conn.execute(
text("SELECT status, reject_reason, reject_price, custom FROM negotiation.sessions WHERE session_id = :sid"),
{"sid": sid},
)).first()
assert row.status == 5 and row.reject_reason == "품절"
assert row.reject_price == 88000
assert row.custom["opinion"] == "대체품으로 재견적 부탁드립니다"
async def test_reject_without_price_keeps_null(client, nego_seed, db_engine):
# 목록 거부 경로 — 가격이 없으면 reject_price 를 건드리지 않는다.
token = await _login_token(client)
sid = nego_seed["sids"]["B"]
r = await _reject(client, token, sid, "단종")
assert r.json()["result"]["success"] is True
async with db_engine.begin() as conn:
row = (await conn.execute(
text("SELECT reject_price, custom FROM negotiation.sessions WHERE session_id = :sid"),
{"sid": sid},
)).first()
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"], " ") # 공백만 → 사유 없음

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

View File

@ -1,157 +0,0 @@
import AppKit
import CoreGraphics
import Foundation
let output = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "docs/AIO2O-요청사항-반영보고서.pdf"
let W: CGFloat = 595, H: CGFloat = 842, M: CGFloat = 42
let navy = NSColor(calibratedRed: 0.06, green: 0.08, blue: 0.16, alpha: 1)
let ink = NSColor(calibratedRed: 0.11, green: 0.13, blue: 0.18, alpha: 1)
let muted = NSColor(calibratedRed: 0.39, green: 0.43, blue: 0.50, alpha: 1)
let paper = NSColor(calibratedRed: 0.98, green: 0.985, blue: 0.995, alpha: 1)
let line = NSColor(calibratedRed: 0.86, green: 0.88, blue: 0.92, alpha: 1)
let purple = NSColor(calibratedRed: 0.48, green: 0.25, blue: 0.92, alpha: 1)
let green = NSColor(calibratedRed: 0.08, green: 0.60, blue: 0.37, alpha: 1)
let orange = NSColor(calibratedRed: 0.94, green: 0.48, blue: 0.10, alpha: 1)
let red = NSColor(calibratedRed: 0.85, green: 0.24, blue: 0.28, alpha: 1)
let blue = NSColor(calibratedRed: 0.13, green: 0.39, blue: 0.92, alpha: 1)
func pr(_ r: CGRect) -> CGRect { CGRect(x: r.minX, y: H-r.maxY, width: r.width, height: r.height) }
func font(_ s: CGFloat, _ w: NSFont.Weight = .regular) -> NSFont {
NSFont(name: "Apple SD Gothic Neo", size: s) ?? .systemFont(ofSize: s, weight: w)
}
func style(_ s: CGFloat, _ c: NSColor = ink, _ w: NSFont.Weight = .regular,
_ a: NSTextAlignment = .left, _ spacing: CGFloat = 2.5) -> [NSAttributedString.Key:Any] {
let p = NSMutableParagraphStyle(); p.alignment = a; p.lineSpacing = spacing; p.lineBreakMode = .byWordWrapping
return [.font:font(s,w), .foregroundColor:c, .paragraphStyle:p]
}
func text(_ t:String,_ r:CGRect,_ s:CGFloat=10,_ c:NSColor=ink,_ w:NSFont.Weight = .regular,
_ a:NSTextAlignment = .left,_ spacing:CGFloat=2.5) {
NSAttributedString(string:t,attributes:style(s,c,w,a,spacing)).draw(with:pr(r),options:[.usesLineFragmentOrigin,.usesFontLeading])
}
func box(_ r:CGRect,_ fill:NSColor = .white,_ stroke:NSColor? = line,_ radius:CGFloat=10) {
let p=NSBezierPath(roundedRect:pr(r),xRadius:radius,yRadius:radius); fill.setFill(); p.fill()
if let stroke { stroke.setStroke(); p.lineWidth=0.8; p.stroke() }
}
func pill(_ t:String,_ r:CGRect,_ c:NSColor) {
box(r,c.withAlphaComponent(0.12),nil,r.height/2)
text(t,CGRect(x:r.minX,y:r.minY+4,width:r.width,height:r.height-7),8.2,c,.semibold,.center,1)
}
func begin(_ ctx:CGContext,_ page:Int,_ title:String) {
ctx.beginPDFPage(nil); ctx.saveGState(); NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current=NSGraphicsContext(cgContext:ctx,flipped:false)
paper.setFill(); NSBezierPath(rect:pr(CGRect(x:0,y:0,width:W,height:H))).fill()
text(title,CGRect(x:M,y:30,width:420,height:16),7.5,muted,.medium)
text(String(format:"%02d",page),CGRect(x:W-M-30,y:30,width:30,height:16),8,muted,.medium,.right)
let p=NSBezierPath(); p.move(to:CGPoint(x:M,y:31)); p.line(to:CGPoint(x:W-M,y:31))
line.setStroke(); p.lineWidth=0.7; p.stroke()
}
func end(_ ctx:CGContext) {
NSGraphicsContext.restoreGraphicsState(); ctx.restoreGState(); ctx.endPDFPage()
}
func heading(_ n:String,_ t:String,_ sub:String) {
pill(n,CGRect(x:M,y:54,width:34,height:24),purple)
text(t,CGRect(x:86,y:49,width:465,height:30),21,navy,.bold)
text(sub,CGRect(x:M,y:87,width:W-2*M,height:31),9.5,muted,.regular,.left,3)
}
func statusRow(_ no:String,_ title:String,_ body:String,_ status:String,_ c:NSColor,_ y:CGFloat,_ h:CGFloat=82) {
box(CGRect(x:M,y:y,width:W-2*M,height:h),.white,line,9)
pill(no,CGRect(x:M+12,y:y+13,width:28,height:20),c)
text(title,CGRect(x:M+50,y:y+12,width:338,height:19),10.5,navy,.bold)
pill(status,CGRect(x:W-M-102,y:y+12,width:90,height:21),c)
text(body,CGRect(x:M+50,y:y+37,width:W-2*M-64,height:h-44),8.8,ink,.regular,.left,2.4)
}
func metric(_ value:String,_ label:String,_ x:CGFloat,_ c:NSColor) {
box(CGRect(x:x,y:435,width:117,height:96),c.withAlphaComponent(0.08),c.withAlphaComponent(0.3),12)
text(value,CGRect(x:x+8,y:454,width:101,height:32),25,c,.bold,.center)
text(label,CGRect(x:x+8,y:493,width:101,height:20),9,muted,.medium,.center)
}
var media=CGRect(x:0,y:0,width:W,height:H)
guard let consumer=CGDataConsumer(url:URL(fileURLWithPath:output) as CFURL),
let ctx=CGContext(consumer:consumer,mediaBox:&media,nil) else { fatalError("PDF 생성 실패") }
// 1. cover
begin(ctx,1,"AIO2O · 요청사항 반영 보고서")
box(CGRect(x:0,y:0,width:W,height:H),navy,nil,0)
pill("IMPLEMENTATION REVIEW",CGRect(x:M,y:112,width:148,height:25),NSColor(calibratedRed:0.42,green:0.78,blue:1,alpha:1))
text("AIO2O 테스트 및 요청사항\n반영 결과 보고서",CGRect(x:M,y:166,width:510,height:112),34,.white,.bold,.left,7)
text("260727_AIO2O 테스트 및 요청사항.xlsx 기준\n현재 저장소 구현·커밋·검증 캡처 대조",CGRect(x:M,y:310,width:510,height:60),14,NSColor(calibratedWhite:0.78,alpha:1),.regular,.left,7)
box(CGRect(x:M,y:435,width:W-2*M,height:176),NSColor.white.withAlphaComponent(0.07),NSColor.white.withAlphaComponent(0.12),16)
text("결론",CGRect(x:M+22,y:458,width:460,height:25),13,.white,.bold)
text("핵심 업무 흐름은 대부분 구현되었습니다. 견적 목록·상세, 재협상 접수, 목표가 자동계산, 인터넷 최저가, 종료 의견, 결렬폼 통일, VAT 별도 표기, 10원 반올림은 코드 근거가 확인됩니다.\n\n다만 절충안/자동 제안가의 업무 적정성, SG명·유통레벨의 최종 UX, 최저가 VAT 산식은 추가 확인이 필요합니다.",CGRect(x:M+22,y:495,width:W-2*M-44,height:96),11,NSColor(calibratedWhite:0.88,alpha:1),.regular,.left,5)
text("작성일 2026.07.31 | 기준 브랜치 feature/negodata | HEAD 775984fe",CGRect(x:M,y:758,width:W-2*M,height:18),8.5,NSColor(calibratedWhite:0.60,alpha:1))
end(ctx)
// 2. summary
begin(ctx,2,"AIO2O · 요청사항 반영 보고서")
heading("01","종합 요약","엑셀 RAW 시트의 24개 요청을 현재 저장소 상태로 재판정했습니다. 중복 요청은 원 요청 번호를 유지했습니다.")
metric("18","완료·반영",M,green); metric("3","부분 반영",M+130,orange); metric("3","확인 필요",M+260,red); metric("24","전체 항목",M+390,blue)
text("판정 기준",CGRect(x:M,y:566,width:507,height:24),13,navy,.bold)
statusRow("A","완료·반영","사용자 화면과 처리 로직이 모두 확인되거나, 동일 기능을 제공하는 구현 및 검증 캡처가 존재합니다.","18건",green,603,60)
statusRow("B","부분 반영","핵심 기능은 있으나 요청한 명칭·선택값·산식 중 일부가 다르거나 배포/운영 확인이 남았습니다.","3건",orange,675,60)
statusRow("C","확인 필요","코드는 존재하지만 계산 결과의 업무 적정성을 확정할 수 없거나 요청 산식이 명시적으로 확인되지 않습니다.","3건",red,747,60)
end(ctx)
// 3. system 1
begin(ctx,3,"AIO2O · 요청사항 반영 보고서")
heading("02","기본 시스템 반영 내역","견적 생성부터 협력사·최저가·협상 화면까지의 공통 요청입니다.")
statusRow("01","견적관리 목록·상세/히스토리","견적 목록과 상세 드로어가 있으며, 상세의 채팅 탭·협상카드 탭이 세션 데이터를 연결합니다. 견적번호별 진행/완료 상태와 상세 확인 경로가 마련됐습니다.","완료",green,132)
statusRow("02","재협상 접수 및 관리","공급사 포털에서 결렬 건 재협상 요청·철회가 가능하고, 구매자 화면에 재협상 요청 목록·검토 시트·승인/반려 및 알림이 구현됐습니다.","완료",green,226)
statusRow("03","MD 제시가 용어·위치·판매가","‘MD’는 ‘구매담당자’로 통일했고 제시가/산정후보를 ‘3. 낙찰기준’으로 이동했습니다. 판매가 설정은 숨김 처리되어 요청 흐름과 일치합니다.","완료",green,320)
statusRow("04","목표가 자동 산출","매입가 × (1 − 목표 네고율)로 구매담당자 제시가를 자동 입력합니다. 예: 10,000원, 2% → 9,800원. 프론트 자동계산과 백엔드 가격 처리 근거가 있습니다.","완료",green,414)
statusRow("05","공급사/매입가 라벨 일원화","상품 및 견적 화면의 회사별 필드 라벨 설정을 연동해 ‘공급사=매입가’ 표기 정책을 적용할 수 있게 했습니다. 실제 운영 회사 설정값 확인은 필요합니다.","부분",orange,508)
statusRow("06","인터넷 최저가 수집","15%에서 멈추던 Worker/큐 처리 문제를 수정하고, 몰별 결과·진행 상태·이력 화면을 재설계했습니다. 다만 요청 산식 ‘(상품가+배송비)/1.1’의 최종 대표값 적용은 코드에서 확정되지 않습니다.","부분",orange,602)
statusRow("07","신규 상품 공급사 입력","신규 상품 등록 폼에 공급사 선택기를 추가하고 상품–공급사 매핑을 저장하도록 구현했습니다.","완료",green,696)
end(ctx)
// 4. system 2
begin(ctx,4,"AIO2O · 요청사항 반영 보고서")
heading("03","협력사·협상 화면 반영","용어 통일, 종료 단계, 가격 표기와 협상 지표를 중심으로 확인했습니다.")
statusRow("08","협력사 SG명·유통레벨","취급상품 기반 분류와 공급유형 선택/저장은 구현되어 있습니다. 다만 요청한 SG명 콤보와 유통레벨 4종(제조·총판·대리점·일반유통), 취급상품 삭제가 그대로 완성됐는지는 추가 UX 확인이 필요합니다.","부분",orange,132)
statusRow("09","리드타임 → 표준납기","협상 완료 부가정보와 API 설명에 ‘표준납기’가 반영되고 회사 정의 session_fields와 연결됩니다.","완료",green,226)
statusRow("10","협상 단가 VAT 별도","협상 상품정보·요약·목록/상세의 단가 표기를 VAT 별도로 통일했습니다. 검증 캡처도 존재합니다.","완료",green,320)
statusRow("11","협상 성공률 기준 안내","성공률은 공급사 제시가를 앵커가·목표가와 비교한 1~99 지표입니다. 100% 미달이 결렬 조건은 아니며, 실제 종료는 별도 낙찰/개찰 규칙이 결정합니다.","완료(안내)",blue,414)
statusRow("12","협상 종료 추가 의견","타결 부가정보와 결렬 통합폼 모두 ‘기타 의견’을 받으며 sessions.custom.opinion에 저장합니다. 구매자 상세·요약에서 조회되고 완료 후 잠깁니다.","완료",green,508)
statusRow("13","결렬 사유·희망가격 통일","기존 RejectRSP/RejectCM을 단일 RejectForm으로 교체했습니다. 결렬사유·희망가·의견을 한 흐름에서 받고 reject_reason/reject_price에 저장합니다.","완료",green,602)
statusRow("14","카드 사용 횟수 제한","견적 설정의 card_count(기본 3)를 컨텍스트에서 읽어 실제 사용 가능한 카드 수와 종료 조건을 제한하도록 반영했습니다. 운영 시 기존 세션 회귀검증을 권장합니다.","완료",green,696)
end(ctx)
// 5. case-specific
begin(ctx,5,"AIO2O · 요청사항 반영 보고서")
heading("04","견적번호별 이슈 반영","EST-202607-05DE·8945·C9D2 사례에서 제기된 가격/종료 흐름을 대조했습니다.")
statusRow("15","앵커·자동 제안가 10원 반올림","앵커 생성가, 목표가 후보, 협상카드 카운터를 공통으로 10원 단위 반올림합니다. 예: 15,213원 → 15,210원. 관련 커밋과 단위 테스트가 있습니다.","완료",green,132)
statusRow("16","절충안 계산식","협상카드 전술에는 앵커·목표가·직전 제시가를 이용한 중간값 및 목표가 상한 로직이 존재합니다. 다만 ‘절충안’의 기대 공식이 엑셀에 없어 업무적으로 맞는지 확정할 수 없습니다.","확인 필요",red,226)
statusRow("17","05DE/C9D2 결렬 희망가격","견적 유형별로 갈리던 결렬 화면을 단일 폼으로 통합해 희망가격 입력 절차를 동일하게 만들었습니다.","완료",green,320)
statusRow("18","8945 협상 마무리 개편","종료 후 표준납기·MOQ·발주배수·배송유형 등 회사 정의 부가정보를 선택/입력하고, 기타 의견과 함께 최종 요약에 반영합니다. 배송 선택값은 회사 설정에 따라 구성됩니다.","완료",green,414)
statusRow("19","C9D2 자동 제안가 갭","제안가는 카드 전술과 앵커·목표가·직전 제시가의 조합으로 계산되고 10원 반올림됩니다. 17,500원→15,210원의 10.5% 갭이 정책상 적정한지는 목표/앵커 설정을 포함한 별도 검증이 필요합니다.","확인 필요",red,508)
statusRow("20","성공/실패 후 의견 조회","협력사가 입력한 종료 의견은 공급사 요약과 구매자 견적 상세 양쪽에서 확인할 수 있고, 종료 후 읽기 전용으로 잠깁니다.","완료",green,602)
statusRow("21","중복 요청 통합 반영","엑셀 18/22(성공률), 19/25(종료 의견), 5/23(목표가), 14/17/20(결렬폼)은 각각 하나의 공통 구현으로 해소했습니다.","완료",green,696)
end(ctx)
// 6. evidence
begin(ctx,6,"AIO2O · 요청사항 반영 보고서")
heading("05","구현 근거","최근 커밋과 현재 코드에서 확인한 핵심 근거입니다. 커밋 단위로 기능 범위를 추적할 수 있습니다.")
statusRow("A","b33ae05c · 견적 가격/상품/라벨","목표가 자동입력, 산정후보 위치 이동, 앵커·후보 10원 반올림, 신규 상품 공급사 입력, 회사 설정 라벨 연동.","커밋",purple,132,72)
statusRow("B","a56589c6 · 종료폼/의견/VAT","결렬폼 통합, 희망가·사유 저장, 타결/결렬 의견 수취, 상품정보 라벨 연동, 협상 단가 VAT 별도 표기.","커밋",purple,216,72)
statusRow("C","30f13483 · 인터넷 최저가","무한 로딩 버그 수정, Worker 설정 보강, 몰별 최저가·진행/상세 UI 및 이력 저장 개선.","커밋",purple,300,72)
statusRow("D","9dca78dc · 완료 부가정보","완료 부가정보 수취·요약 표시·잠금, 구매자 상세 노출, VAT 표기 통일.","커밋",purple,384,72)
statusRow("E","2a004734 · 견적 상세 연결","견적 상세의 채팅·협상카드 탭 연동과 드로어 탐색 개선.","커밋",purple,468,72)
statusRow("F","775984fe / f554202c · 반올림","협상카드 카운터와 자동 앵커를 10원 단위 반올림으로 통일.","커밋",purple,552,72)
statusRow("G","화면 검증 캡처","목록/상세, 종료 의견, VAT, 완료 요약, 읽기 전용 잠금 등 12개 캡처가 저장소 루트에 남아 있습니다.","캡처",blue,636,72)
text("주의: 본 보고서는 2026-07-31 현재 로컬 저장소의 코드·커밋·캡처를 기준으로 합니다. 운영 배포 여부와 기존 데이터 마이그레이션 상태는 별도 확인 대상입니다.",CGRect(x:M,y:742,width:W-2*M,height:42),8.8,muted,.regular,.left,3)
end(ctx)
// 7. actions
begin(ctx,7,"AIO2O · 요청사항 반영 보고서")
heading("06","남은 확인 및 권고","기능 누락이라기보다 업무 규칙·운영 설정을 확정해야 하는 항목입니다.")
statusRow("1","최저가 VAT 대표값 확정","현재 LPS는 상품가와 배송비를 별도 수집·표시합니다. 대표 최저가를 반드시 (상품가+배송비)/1.1로 저장할지, 화면 표시만 할지 정책을 확정한 뒤 테스트를 추가해야 합니다.","우선순위 높음",red,142,98)
statusRow("2","절충안/자동 제안가 기준 검증","05DE·C9D2의 실제 앵커가·목표가·직전 제시가를 넣어 계산 결과를 재현하고, 허용 최대 인하폭 또는 목표가 클램프 기준을 업무 담당자와 합의하는 것이 좋습니다.","우선순위 높음",red,254,98)
statusRow("3","SG명·유통레벨 UX 확정","현행 취급상품 기반 분류/공급유형을 요청한 SG 콤보와 유통레벨 4종으로 대체할지, 데이터 모델을 유지한 채 라벨만 조정할지 결정이 필요합니다.","우선순위 중간",orange,366,98)
statusRow("4","운영 배포·기존 세션 회귀검증","종료폼, 의견, 카드 횟수 제한은 신규 코드에 반영됐습니다. 운영 컨테이너 재빌드 후 기존 세션과 신규 세션에서 각각 1회 이상 확인해야 합니다.","배포 확인",blue,478,98)
box(CGRect(x:M,y:612,width:W-2*M,height:118),purple.withAlphaComponent(0.08),purple.withAlphaComponent(0.28),12)
text("권장 최종 승인 기준",CGRect(x:M+18,y:630,width:470,height:22),12,purple,.bold)
text("① 운영 배포 버전 확인 ② 대표 견적 3건 시나리오 재실행 ③ 계산식 2건 서면 확정\n④ SG/유통레벨 화면 승인 ⑤ 완료·결렬 의견이 구매자 상세에 저장되는지 확인",CGRect(x:M+18,y:662,width:470,height:50),10,ink,.medium,.left,5)
text("— End of report —",CGRect(x:M,y:760,width:W-2*M,height:20),8,muted,.medium,.center)
end(ctx)
ctx.closePDF()

View File

@ -1,790 +0,0 @@
import AppKit
import CoreGraphics
import Foundation
let outPath = CommandLine.arguments.count > 1
? CommandLine.arguments[1]
: "docs/backend-advanced-concepts-ko.pdf"
let W: CGFloat = 595
let H: CGFloat = 842
let margin: CGFloat = 44
let navy = NSColor(calibratedRed: 0.055, green: 0.086, blue: 0.16, alpha: 1)
let ink = NSColor(calibratedRed: 0.10, green: 0.13, blue: 0.18, alpha: 1)
let muted = NSColor(calibratedRed: 0.37, green: 0.42, blue: 0.50, alpha: 1)
let paper = NSColor(calibratedRed: 0.975, green: 0.98, blue: 0.99, alpha: 1)
let line = NSColor(calibratedRed: 0.86, green: 0.88, blue: 0.92, alpha: 1)
let blue = NSColor(calibratedRed: 0.16, green: 0.39, blue: 0.93, alpha: 1)
let cyan = NSColor(calibratedRed: 0.10, green: 0.69, blue: 0.74, alpha: 1)
let green = NSColor(calibratedRed: 0.10, green: 0.63, blue: 0.39, alpha: 1)
let orange = NSColor(calibratedRed: 0.94, green: 0.47, blue: 0.12, alpha: 1)
let red = NSColor(calibratedRed: 0.88, green: 0.25, blue: 0.28, alpha: 1)
let purple = NSColor(calibratedRed: 0.48, green: 0.32, blue: 0.89, alpha: 1)
func pdfRect(_ r: CGRect) -> CGRect {
CGRect(x: r.minX, y: H - r.maxY, width: r.width, height: r.height)
}
func pdfPoint(_ p: CGPoint) -> CGPoint {
CGPoint(x: p.x, y: H - p.y)
}
func font(_ size: CGFloat, _ weight: NSFont.Weight = .regular) -> NSFont {
NSFont(name: "Apple SD Gothic Neo", size: size)
?? NSFont.systemFont(ofSize: size, weight: weight)
}
func mono(_ size: CGFloat) -> NSFont {
NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
}
func attrs(_ size: CGFloat, color: NSColor = ink, weight: NSFont.Weight = .regular,
align: NSTextAlignment = .left, lineSpacing: CGFloat = 3) -> [NSAttributedString.Key: Any] {
let p = NSMutableParagraphStyle()
p.alignment = align
p.lineSpacing = lineSpacing
p.lineBreakMode = .byWordWrapping
return [.font: font(size, weight), .foregroundColor: color, .paragraphStyle: p]
}
func drawText(_ text: String, _ rect: CGRect, size: CGFloat = 11, color: NSColor = ink,
weight: NSFont.Weight = .regular, align: NSTextAlignment = .left,
lineSpacing: CGFloat = 3) {
NSAttributedString(string: text, attributes: attrs(size, color: color, weight: weight,
align: align, lineSpacing: lineSpacing))
.draw(with: pdfRect(rect), options: [.usesLineFragmentOrigin, .usesFontLeading])
}
func rounded(_ rect: CGRect, radius: CGFloat = 12, fill: NSColor = .white,
stroke: NSColor? = line, width: CGFloat = 1) {
let p = NSBezierPath(roundedRect: pdfRect(rect), xRadius: radius, yRadius: radius)
fill.setFill(); p.fill()
if let stroke { stroke.setStroke(); p.lineWidth = width; p.stroke() }
}
func pill(_ text: String, x: CGFloat, y: CGFloat, w: CGFloat, color: NSColor) {
rounded(CGRect(x: x, y: y, width: w, height: 25), radius: 12.5,
fill: color.withAlphaComponent(0.12), stroke: nil)
drawText(text, CGRect(x: x, y: y + 5, width: w, height: 16), size: 9.5,
color: color, weight: .semibold, align: .center)
}
func arrow(_ from: CGPoint, _ to: CGPoint, color: NSColor = muted) {
let from = pdfPoint(from), to = pdfPoint(to)
let p = NSBezierPath(); p.move(to: from); p.line(to: to)
color.setStroke(); p.lineWidth = 1.8; p.stroke()
let a = atan2(to.y - from.y, to.x - from.x)
let l: CGFloat = 7
let h = NSBezierPath()
h.move(to: to)
h.line(to: CGPoint(x: to.x - l * cos(a - .pi / 6), y: to.y - l * sin(a - .pi / 6)))
h.line(to: CGPoint(x: to.x - l * cos(a + .pi / 6), y: to.y - l * sin(a + .pi / 6)))
h.close(); color.setFill(); h.fill()
}
func node(_ title: String, _ sub: String, rect: CGRect, color: NSColor) {
rounded(rect, radius: 10, fill: color.withAlphaComponent(0.10),
stroke: color.withAlphaComponent(0.55), width: 1.2)
drawText(title, CGRect(x: rect.minX + 8, y: rect.minY + 10, width: rect.width - 16, height: 18),
size: 10.5, color: color, weight: .bold, align: .center)
drawText(sub, CGRect(x: rect.minX + 8, y: rect.minY + 31, width: rect.width - 16, height: rect.height - 36),
size: 8.5, color: muted, align: .center, lineSpacing: 1)
}
func sectionTitle(_ number: String, _ title: String, _ subtitle: String, color: NSColor) {
pill(number, x: margin, y: 48, w: 34, color: color)
drawText(title, CGRect(x: 86, y: 46, width: 450, height: 30), size: 22,
color: navy, weight: .bold)
drawText(subtitle, CGRect(x: margin, y: 82, width: W - 2 * margin, height: 26),
size: 10.5, color: muted)
}
func footer(_ page: Int, _ label: String = "O2O Negosium · Backend Concepts") {
let p = NSBezierPath()
p.move(to: CGPoint(x: margin, y: H - 34)); p.line(to: CGPoint(x: W - margin, y: H - 34))
line.setStroke(); p.lineWidth = 0.7; p.stroke()
drawText(label, CGRect(x: margin, y: H - 28, width: 350, height: 14), size: 7.5, color: muted)
drawText("\(page)", CGRect(x: W - margin - 35, y: H - 28, width: 35, height: 14),
size: 8, color: muted, align: .right)
}
func callout(_ title: String, _ body: String, rect: CGRect, color: NSColor) {
rounded(rect, radius: 12, fill: color.withAlphaComponent(0.08),
stroke: color.withAlphaComponent(0.35))
rounded(CGRect(x: rect.minX, y: rect.minY, width: 5, height: rect.height),
radius: 2.5, fill: color, stroke: nil)
drawText(title, CGRect(x: rect.minX + 16, y: rect.minY + 12,
width: rect.width - 28, height: 20),
size: 11, color: color, weight: .bold)
drawText(body, CGRect(x: rect.minX + 16, y: rect.minY + 37,
width: rect.width - 28, height: rect.height - 45),
size: 9.5, color: ink, lineSpacing: 3)
}
func comparison(_ leftTitle: String, _ left: String, _ rightTitle: String, _ right: String,
y: CGFloat, color: NSColor) {
let gap: CGFloat = 14
let cw = (W - 2 * margin - gap) / 2
callout(leftTitle, left, rect: CGRect(x: margin, y: y, width: cw, height: 126), color: red)
callout(rightTitle, right, rect: CGRect(x: margin + cw + gap, y: y, width: cw, height: 126), color: color)
}
func codeBox(_ title: String, _ path: String, _ code: String, rect: CGRect, accent: NSColor) {
rounded(rect, radius: 10, fill: navy, stroke: nil)
drawText(title, CGRect(x: rect.minX + 14, y: rect.minY + 11,
width: rect.width - 28, height: 17),
size: 10, color: .white, weight: .bold)
drawText(path, CGRect(x: rect.minX + 14, y: rect.minY + 30,
width: rect.width - 28, height: 14),
size: 7.5, color: accent)
let p = NSMutableParagraphStyle(); p.lineSpacing = 2; p.lineBreakMode = .byClipping
NSAttributedString(string: code, attributes: [.font: mono(7.8), .foregroundColor: NSColor(calibratedWhite: 0.88, alpha: 1), .paragraphStyle: p])
.draw(with: pdfRect(CGRect(x: rect.minX + 14, y: rect.minY + 51,
width: rect.width - 28, height: rect.height - 60)),
options: [.usesLineFragmentOrigin])
}
func beginPage(_ ctx: CGContext, page: Int, label: String = "O2O Negosium · Backend Concepts") {
ctx.beginPDFPage(nil)
ctx.saveGState()
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: false)
paper.setFill(); NSBezierPath(rect: pdfRect(CGRect(x: 0, y: 0, width: W, height: H))).fill()
footer(page, label)
}
func endPage(_ ctx: CGContext) {
NSGraphicsContext.restoreGraphicsState()
ctx.restoreGState()
ctx.endPDFPage()
}
var mediaBox = CGRect(x: 0, y: 0, width: W, height: H)
guard let consumer = CGDataConsumer(url: URL(fileURLWithPath: outPath) as CFURL),
let ctx = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
fatalError("PDF context 생성 실패")
}
// 1 — Cover
beginPage(ctx, page: 1, label: "O2O Negosium · Backend Field Guide")
rounded(CGRect(x: 0, y: 0, width: W, height: H), radius: 0, fill: navy, stroke: nil)
for i in 0..<7 {
let x = CGFloat(50 + i * 78)
let c = [blue, cyan, green, orange, purple][i % 5]
rounded(CGRect(x: x, y: 85 + CGFloat((i % 3) * 28), width: 48, height: 48),
radius: 24, fill: c.withAlphaComponent(0.35), stroke: nil)
}
drawText("BACKEND", CGRect(x: margin, y: 190, width: 507, height: 35), size: 15,
color: cyan, weight: .bold)
drawText("어려운 개념 5가지,\n코드로 이해하기", CGRect(x: margin, y: 228, width: 507, height: 118),
size: 36, color: .white, weight: .bold, lineSpacing: 7)
drawText("분산 시스템 · 트랜잭션/동시성 · 멀티테넌시\n캐시 정합성 · 스케줄러/배치",
CGRect(x: margin, y: 370, width: 507, height: 62), size: 15,
color: NSColor(calibratedWhite: 0.80, alpha: 1), lineSpacing: 8)
rounded(CGRect(x: margin, y: 485, width: 507, height: 154), radius: 18,
fill: NSColor.white.withAlphaComponent(0.07),
stroke: NSColor.white.withAlphaComponent(0.15))
drawText("이 문서는 이렇게 읽어요", CGRect(x: 66, y: 510, width: 455, height: 25),
size: 14, color: .white, weight: .bold)
drawText("① 일상 비유로 개념 잡기\n② 실제 서비스 흐름을 그림으로 보기\n③ 프로젝트 코드에서 구현 확인하기\n④ 없을 때 생기는 문제와 비교하기",
CGRect(x: 66, y: 548, width: 455, height: 78), size: 11.5,
color: NSColor(calibratedWhite: 0.88, alpha: 1), lineSpacing: 6)
drawText("Generated from the current repository · 2026-07-29",
CGRect(x: margin, y: 758, width: 507, height: 18), size: 8.5,
color: NSColor(calibratedWhite: 0.62, alpha: 1))
endPage(ctx)
// 2 — Architecture map
beginPage(ctx, page: 2)
drawText("먼저, 서비스 지도를 봅시다", CGRect(x: margin, y: 48, width: 507, height: 34),
size: 24, color: navy, weight: .bold)
drawText("다섯 개념은 따로 노는 것이 아니라, 한 요청이 여러 서비스와 저장소를 지나면서 함께 작동합니다.",
CGRect(x: margin, y: 88, width: 507, height: 32), size: 10.5, color: muted)
node("사용자", "브라우저", rect: CGRect(x: 44, y: 170, width: 90, height: 62), color: purple)
node("Backend", "채팅·공급사", rect: CGRect(x: 184, y: 145, width: 102, height: 72), color: blue)
node("Agent", "AI 협상", rect: CGRect(x: 348, y: 145, width: 102, height: 72), color: orange)
node("Negodata", "견적·관리", rect: CGRect(x: 184, y: 270, width: 102, height: 72), color: cyan)
node("LPS", "최저가 Worker", rect: CGRect(x: 348, y: 270, width: 102, height: 72), color: green)
node("PostgreSQL", "업무 원본", rect: CGRect(x: 184, y: 405, width: 130, height: 72), color: purple)
node("Redis", "앵커링 캐시", rect: CGRect(x: 368, y: 405, width: 100, height: 72), color: red)
arrow(CGPoint(x: 134, y: 200), CGPoint(x: 184, y: 182), color: purple)
arrow(CGPoint(x: 286, y: 180), CGPoint(x: 348, y: 180), color: blue)
arrow(CGPoint(x: 235, y: 217), CGPoint(x: 235, y: 270), color: cyan)
arrow(CGPoint(x: 286, y: 304), CGPoint(x: 348, y: 304), color: green)
arrow(CGPoint(x: 235, y: 342), CGPoint(x: 235, y: 405), color: purple)
arrow(CGPoint(x: 399, y: 342), CGPoint(x: 415, y: 405), color: red)
callout("① 경계가 생기면 ‘분산 시스템’", "서비스 A가 서비스 B를 네트워크로 호출하는 순간, 지연·타임아웃·부분 실패를 다뤄야 합니다.",
rect: CGRect(x: margin, y: 525, width: 246, height: 105), color: blue)
callout("② 여러 실행자가 만나면 ‘동시성’", "사용자 클릭과 스케줄러가 같은 견적을 동시에 마감할 수 있어, DB가 최종 심판 역할을 합니다.",
rect: CGRect(x: 305, y: 525, width: 246, height: 105), color: orange)
callout("③ 빠르게 읽되 원본을 지키면 ‘캐시’", "Redis와 프로세스 메모리는 복사본입니다. PostgreSQL과 설정 파일이 원본입니다.",
rect: CGRect(x: margin, y: 650, width: 246, height: 105), color: red)
callout("④ 회사별 경계를 지키면 ‘멀티테넌시’", "요청 헤더에서 회사 ID를 결정하고, 회사별 설정·엔진을 선택합니다.",
rect: CGRect(x: 305, y: 650, width: 246, height: 105), color: purple)
endPage(ctx)
// 3 — Distributed systems: concept first
beginPage(ctx, page: 3)
sectionTitle("3", "분산 시스템 — 개념부터", "여러 독립 실행 단위가 네트워크를 통해 하나의 업무를 완성하는 시스템", color: blue)
callout("정확한 정의", "프로세스·컨테이너·서버가 각자 메모리와 실행 상태를 가지고, HTTP나 메시지로 통신하는 구조입니다. 한 서비스의 함수 호출과 달리 상대의 상태를 직접 볼 수 없고, 네트워크 응답만으로 결과를 추론해야 합니다.",
rect: CGRect(x: margin, y: 126, width: 507, height: 92), color: blue)
drawText("왜 어려운가: 네트워크에는 네 가지 결과가 있습니다", CGRect(x: margin, y: 244, width: 507, height: 24),
size: 13.5, color: navy, weight: .bold)
let distCases: [(String, String, NSColor)] = [
("성공", "상대가 처리했고 응답도 받음", green),
("명확한 실패", "상대가 오류 응답을 보냄", red),
("연결 실패", "상대에게 요청이 도착하지 않음", orange),
("애매한 타임아웃", "처리는 됐지만 응답만 늦었을 수도 있음", purple),
]
for (i, c) in distCases.enumerated() {
let col = i % 2, row = i / 2
let x = margin + CGFloat(col) * 260
let y = CGFloat(286 + row * 86)
callout(c.0, c.1, rect: CGRect(x: x, y: y, width: 247, height: 70), color: c.2)
}
drawText("대표적인 대응 수단", CGRect(x: margin, y: 475, width: 507, height: 24),
size: 13.5, color: navy, weight: .bold)
callout("Timeout", "얼마나 기다릴지 상한을 둡니다. 짧으면 정상 요청도 실패하고, 길면 자원이 오래 묶입니다.",
rect: CGRect(x: margin, y: 515, width: 159, height: 92), color: blue)
callout("Retry", "일시 실패를 다시 시도합니다. 단, 중복 처리에 안전한 작업에서만 제한적으로 사용합니다.",
rect: CGRect(x: 218, y: 515, width: 159, height: 92), color: orange)
callout("Idempotency", "같은 요청을 여러 번 보내도 결과가 한 번 처리한 것과 같도록 만듭니다.",
rect: CGRect(x: 392, y: 515, width: 159, height: 92), color: purple)
callout("Fallback", "주 서비스가 실패하면 대체 경로·기본값·이전 데이터를 사용합니다. 대체 결과가 업무적으로 허용될 때만 가능합니다.",
rect: CGRect(x: margin, y: 630, width: 247, height: 92), color: green)
callout("Circuit breaker", "실패가 계속되는 서비스를 잠시 호출하지 않아 연쇄 장애를 막습니다. 현재 프로젝트에는 명시적 구현이 없습니다.",
rect: CGRect(x: 304, y: 630, width: 247, height: 92), color: red)
endPage(ctx)
// 4 distributed concept
beginPage(ctx, page: 4)
sectionTitle("3", "분산 시스템과 장애 대응", "한 프로그램이 아니라 여러 서비스가 네트워크로 협력하는 구조", color: blue)
callout("쉬운 비유", "한 식당 안에서 주방과 홀 직원이 말로 협업하는 것이 단일 시스템이라면, 분산 시스템은 서로 다른 건물의 팀이 전화로 협업하는 것입니다. 전화는 늦거나 끊길 수 있고, 상대가 일을 끝냈는데 답만 못 받을 수도 있습니다.",
rect: CGRect(x: margin, y: 130, width: 507, height: 105), color: blue)
drawText("프로젝트의 대표 흐름", CGRect(x: margin, y: 266, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
node("Backend", "사용자 채팅 요청", rect: CGRect(x: 52, y: 315, width: 110, height: 74), color: blue)
node("HTTPX", "timeout 설정", rect: CGRect(x: 242, y: 315, width: 110, height: 74), color: cyan)
node("Agent", "협상 턴 계산", rect: CGRect(x: 432, y: 315, width: 110, height: 74), color: orange)
arrow(CGPoint(x: 162, y: 352), CGPoint(x: 242, y: 352), color: blue)
arrow(CGPoint(x: 352, y: 352), CGPoint(x: 432, y: 352), color: cyan)
drawText("성공", CGRect(x: 394, y: 414, width: 80, height: 18), size: 9, color: green, weight: .bold)
arrow(CGPoint(x: 485, y: 389), CGPoint(x: 485, y: 458), color: green)
node("응답 반영", "채팅 상태 저장", rect: CGRect(x: 430, y: 458, width: 112, height: 65), color: green)
drawText("타임아웃/실패", CGRect(x: 185, y: 414, width: 105, height: 18), size: 9, color: red, weight: .bold)
arrow(CGPoint(x: 297, y: 389), CGPoint(x: 297, y: 458), color: red)
node("안전한 실패", "ok=false 반환", rect: CGRect(x: 241, y: 458, width: 112, height: 65), color: red)
callout("중요한 함정: 타임아웃 ≠ 상대가 아무 일도 안 함", "Agent가 DB 상태를 이미 전진시킨 직후 응답만 늦었을 수 있습니다. 그래서 무조건 재시도하면 같은 턴을 두 번 처리할 위험이 있습니다. 코드가 timed_out을 따로 표시하는 이유입니다.",
rect: CGRect(x: margin, y: 566, width: 507, height: 105), color: orange)
comparison("이 장치가 없으면", "Agent가 느린 순간 Backend 요청도 끝없이 대기합니다. 무작정 재시도하면 협상 step이 두 번 전진할 수 있습니다.",
"현재 방식", "HTTP timeout을 두고 성공/일반 실패/타임아웃을 구분합니다. 호출 경계에서 예외를 응답 객체로 변환합니다.",
y: 695, color: blue)
endPage(ctx)
// 5 distributed code
beginPage(ctx, page: 5)
sectionTitle("3", "분산 시스템 — 실제 코드", "서비스 경계마다 timeout, fallback, best-effort 정책이 다릅니다.", color: blue)
codeBox("Agent 호출: 타임아웃을 별도 상태로 반환",
"backend/services/agent_client.py · lines 81–91",
"""
async with httpx.AsyncClient(
base_url=agent_config.base_url,
timeout=agent_config.timeout_sec,
) as cli:
resp = await cli.post("/v1/chat", json=body, headers=headers)
except httpx.TimeoutException as ex:
# Agent가 이미 처리했을 수 있어 단순 재시도는 위험
return AgentTurn(ok=False, timed_out=True)
except Exception:
return AgentTurn(ok=False)
""",
rect: CGRect(x: margin, y: 130, width: 507, height: 245), accent: cyan)
codeBox("카탈로그 변경 알림: 핵심 업무를 막지 않는 best-effort",
"negodata/backend/services/agent_notify.py · lines 15–26",
"""
try:
async with httpx.AsyncClient(timeout=3.0) as cli:
await cli.post(f"{base}/v1/catalog-refresh-all")
except Exception as ex:
# 알림 실패는 카드 작업을 막지 않는다
LOG.w(f"[agent_notify] 변경 알림 실패(무시): {ex}")
""",
rect: CGRect(x: margin, y: 395, width: 507, height: 160), accent: green)
callout("어떻게 정책을 고르나요?", "결제처럼 반드시 성공해야 하는 작업은 실패를 호출자에게 알려 재처리해야 합니다. 반면 ‘캐시 무효화 알림’처럼 보조적인 작업은 실패해도 핵심 카드 변경을 성공시킬 수 있습니다. 모든 외부 호출을 똑같이 재시도하면 안 됩니다.",
rect: CGRect(x: margin, y: 580, width: 507, height: 105), color: blue)
drawText("기억할 단어", CGRect(x: margin, y: 712, width: 120, height: 20), size: 12,
color: navy, weight: .bold)
pill("timeout", x: 150, y: 707, w: 82, color: blue)
pill("partial failure", x: 242, y: 707, w: 102, color: orange)
pill("best-effort", x: 354, y: 707, w: 92, color: green)
pill("idempotency", x: 456, y: 707, w: 90, color: purple)
endPage(ctx)
// 6 — Transactions and concurrency: concept first
beginPage(ctx, page: 6)
sectionTitle("4", "트랜잭션·동시성 — 개념부터", "데이터의 일관성을 지키는 작업 단위와, 동시에 실행되는 요청을 제어하는 방법", color: orange)
drawText("트랜잭션의 ACID", CGRect(x: margin, y: 126, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
let acid: [(String, String, NSColor)] = [
("A · Atomicity", "전부 성공하거나 전부 취소", orange),
("C · Consistency", "규칙을 만족하는 상태로 이동", green),
("I · Isolation", "동시 작업의 중간 상태를 서로 숨김", purple),
("D · Durability", "COMMIT된 결과는 장애 후에도 보존", blue),
]
for (i, a) in acid.enumerated() {
let x = margin + CGFloat(i % 2) * 260
let y = CGFloat(165 + (i / 2) * 84)
callout(a.0, a.1, rect: CGRect(x: x, y: y, width: 247, height: 68), color: a.2)
}
drawText("동시성 문제는 어떻게 생기나?", CGRect(x: margin, y: 352, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
node("요청 A", "status=OPEN 읽음", rect: CGRect(x: 48, y: 401, width: 116, height: 64), color: blue)
node("요청 B", "status=OPEN 읽음", rect: CGRect(x: 48, y: 500, width: 116, height: 64), color: purple)
node("둘 다 처리", "중복 마감·중복 메일", rect: CGRect(x: 250, y: 449, width: 130, height: 70), color: red)
arrow(CGPoint(x: 164, y: 433), CGPoint(x: 250, y: 471), color: blue)
arrow(CGPoint(x: 164, y: 532), CGPoint(x: 250, y: 497), color: purple)
callout("해결 ① 비관적 잠금", "SELECT ... FOR UPDATE로 먼저 행을 잠급니다. 명확하지만 잠금 대기와 deadlock을 관리해야 합니다.",
rect: CGRect(x: 408, y: 391, width: 143, height: 92), color: orange)
callout("해결 ② 조건부 갱신", "UPDATE ... WHERE status=OPEN 후 rowcount를 확인합니다. 상태 검사와 변경이 원자적으로 일어납니다.",
rect: CGRect(x: 408, y: 497, width: 143, height: 92), color: green)
callout("격리 수준과 잠금은 만능이 아님", "격리를 높이면 안전성은 커지지만 동시 처리량이 줄고 대기·교착 가능성이 커집니다. 업무 규칙에 맞는 최소 범위의 트랜잭션과 조건부 상태 전이가 실용적입니다.",
rect: CGRect(x: margin, y: 624, width: 507, height: 92), color: orange)
callout("COMMIT / ROLLBACK", "COMMIT은 변경 확정, ROLLBACK은 현재 트랜잭션의 미확정 변경 취소입니다. 외부 이메일 발송은 DB rollback으로 되돌릴 수 없다는 점도 중요합니다.",
rect: CGRect(x: margin, y: 720, width: 507, height: 72), color: red)
endPage(ctx)
// 7 transaction concept
beginPage(ctx, page: 7)
sectionTitle("4", "DB 트랜잭션과 동시성 제어", "여러 작업을 하나로 묶고, 동시에 온 요청 중 한 명만 통과시키는 기술", color: orange)
callout("트랜잭션이란?", "은행 이체에서 ‘내 계좌 차감’과 ‘상대 계좌 증가’가 둘 다 성공하거나 둘 다 취소되어야 하듯, 관련 DB 변경을 하나의 작업 단위로 묶는 것입니다. 중간에 실패하면 rollback합니다.",
rect: CGRect(x: margin, y: 130, width: 507, height: 92), color: orange)
drawText("동시 마감 문제", CGRect(x: margin, y: 252, width: 200, height: 24),
size: 14, color: navy, weight: .bold)
node("사용자 클릭", "마감 요청 A", rect: CGRect(x: 50, y: 300, width: 110, height: 66), color: blue)
node("스케줄러", "마감 요청 B", rect: CGRect(x: 50, y: 405, width: 110, height: 66), color: purple)
node("조건부 UPDATE", "status != CLOSED", rect: CGRect(x: 243, y: 350, width: 120, height: 74), color: orange)
node("PostgreSQL", "원자적으로 판정", rect: CGRect(x: 430, y: 350, width: 112, height: 74), color: green)
arrow(CGPoint(x: 160, y: 333), CGPoint(x: 243, y: 376), color: blue)
arrow(CGPoint(x: 160, y: 438), CGPoint(x: 243, y: 399), color: purple)
arrow(CGPoint(x: 363, y: 387), CGPoint(x: 430, y: 387), color: orange)
callout("승자", "영향받은 행 수(rowcount) = 1\n마감 판정 권한 획득",
rect: CGRect(x: 76, y: 518, width: 205, height: 88), color: green)
callout("패자/재요청", "rowcount = 0\n이미 닫혔으므로 추가 처리 중단",
rect: CGRect(x: 314, y: 518, width: 205, height: 88), color: red)
comparison("단순 SELECT 후 UPDATE", "두 요청이 동시에 OPEN을 읽으면 둘 다 마감·낙찰 로직을 실행할 수 있습니다. 이메일도 두 번 발송될 수 있습니다.",
"조건부 UPDATE", "DB가 상태 검사와 변경을 한 문장으로 처리합니다. 먼저 성공한 요청만 rowcount=1을 받습니다.",
y: 640, color: orange)
endPage(ctx)
// 8 transaction code
beginPage(ctx, page: 8)
sectionTitle("4", "트랜잭션·동시성 — 실제 코드", "애플리케이션의 if문보다 DB의 원자적 UPDATE가 강한 최종 방어선입니다.", color: orange)
codeBox("조건부 상태 전이: 마감 권한 선점",
"negodata/backend/crud/quotation_crud.py · lines 482–500",
"""
query = (
update(quotations)
.where(
quotations.qt_id == qt_id,
quotations.status != QuotationStatus.CLOSED.value,
quotations.deleted == False,
)
.values(
status=QuotationStatus.CLOSED.value,
updated_at=GTime.UTC(),
)
)
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
""",
rect: CGRect(x: margin, y: 130, width: 507, height: 235), accent: orange)
codeBox("트랜잭션 실패 시 rollback",
"negodata/backend/common/database/db_session_manager.py · lines 116–127",
"""
try:
await db.commit()
return ErrorType.SUCCESS
except IntegrityError:
await db.rollback()
return ErrorType.DB_ALREADY_SAME_KEY
except Exception:
await db.rollback()
raise
""",
rect: CGRect(x: margin, y: 390, width: 507, height: 175), accent: red)
callout("왜 rowcount를 보나요?", "UPDATE가 에러 없이 실행됐다는 사실만으로는 내가 상태를 바꿨는지 알 수 없습니다. WHERE 조건에 맞는 행이 없으면 SQL은 정상 실행되지만 변경 행은 0개입니다. 그래서 1이면 승자, 0이면 이미 다른 요청이 처리한 것으로 판단합니다.",
rect: CGRect(x: margin, y: 592, width: 507, height: 108), color: orange)
callout("실무 체크", "트랜잭션은 짧게 유지하고, 외부 HTTP·이메일처럼 오래 걸리는 작업을 DB 트랜잭션 안에 오래 붙잡아 두지 않습니다.",
rect: CGRect(x: margin, y: 720, width: 507, height: 65), color: purple)
endPage(ctx)
// 9 — Multitenancy: concept first
beginPage(ctx, page: 9)
sectionTitle("5", "멀티테넌시 — 개념부터", "하나의 애플리케이션을 여러 고객사가 공유하면서 논리적으로 격리하는 설계", color: purple)
callout("Tenant란?", "서비스를 사용하는 독립 고객 단위입니다. 이 프로젝트에서는 주로 ‘회사’가 tenant입니다. 같은 API와 서버를 쓰더라도 회사별 데이터, 설정, 권한, 협상 정책이 섞이면 안 됩니다.",
rect: CGRect(x: margin, y: 126, width: 507, height: 88), color: purple)
drawText("대표적인 데이터 격리 모델", CGRect(x: margin, y: 242, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
callout("DB 분리", "회사마다 별도 DB\n격리 강함 · 운영비 높음",
rect: CGRect(x: margin, y: 282, width: 159, height: 88), color: blue)
callout("Schema 분리", "한 DB 안에서 schema 분리\n중간 수준의 격리와 비용",
rect: CGRect(x: 218, y: 282, width: 159, height: 88), color: cyan)
callout("Row 공유", "같은 테이블 + tenant_id\n효율적 · 쿼리 누락 위험",
rect: CGRect(x: 392, y: 282, width: 159, height: 88), color: orange)
drawText("격리는 DB만의 문제가 아닙니다", CGRect(x: margin, y: 404, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
let tenantAxes: [(String, String)] = [
("식별", "이 요청이 어느 회사 것인지 신뢰할 수 있게 결정"),
("인가", "그 사용자가 해당 회사 자원에 접근 가능한지 확인"),
("데이터", "모든 조회·수정 쿼리에 회사 범위 적용"),
("설정", "회사별 정책·브랜딩·카드 선택"),
("캐시", "캐시 key에 tenant를 포함해 회사 간 충돌 방지"),
("자원", "한 회사의 과부하가 다른 회사에 미치는 영향 제한"),
]
for (i, t) in tenantAxes.enumerated() {
let x = margin + CGFloat(i % 2) * 260
let y = CGFloat(444 + (i / 2) * 74)
rounded(CGRect(x: x, y: y, width: 247, height: 58), radius: 9,
fill: purple.withAlphaComponent(0.06), stroke: purple.withAlphaComponent(0.25))
drawText(t.0, CGRect(x: x + 12, y: y + 10, width: 52, height: 18), size: 10,
color: purple, weight: .bold)
drawText(t.1, CGRect(x: x + 66, y: y + 9, width: 168, height: 38), size: 8.5, color: ink)
}
callout("가장 흔한 사고", "쿼리의 WHERE tenant_id 조건 누락, 공유 캐시 key에 tenant_id 누락, 사용자가 body로 보낸 tenant_id를 그대로 신뢰하는 경우입니다. 그래서 tenant context를 요청 초기에 확정하고 자동 전달하는 구조가 중요합니다.",
rect: CGRect(x: margin, y: 680, width: 507, height: 105), color: red)
endPage(ctx)
// 10 multitenancy concept
beginPage(ctx, page: 10)
sectionTitle("5", "멀티테넌시", "하나의 시스템을 여러 회사가 쓰되, 설정과 데이터의 경계를 지키는 구조", color: purple)
callout("쉬운 비유", "한 오피스 빌딩을 여러 회사가 함께 사용하지만 출입카드가 자기 회사 층만 열어주는 구조입니다. 서버는 공유하되, 요청마다 ‘어느 회사의 요청인지’를 먼저 확정해야 합니다.",
rect: CGRect(x: margin, y: 130, width: 507, height: 92), color: purple)
drawText("요청이 회사별 엔진을 찾는 과정", CGRect(x: margin, y: 252, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
node("HTTP 요청", "X-Tenant-ID", rect: CGRect(x: 45, y: 310, width: 100, height: 68), color: blue)
node("Middleware", "존재·등록 검증", rect: CGRect(x: 195, y: 310, width: 105, height: 68), color: purple)
node("request.state", "tenant_id 보관", rect: CGRect(x: 350, y: 310, width: 105, height: 68), color: cyan)
arrow(CGPoint(x: 145, y: 344), CGPoint(x: 195, y: 344), color: blue)
arrow(CGPoint(x: 300, y: 344), CGPoint(x: 350, y: 344), color: purple)
node("Registry", "회사별 엔진 선택", rect: CGRect(x: 195, y: 445, width: 105, height: 68), color: orange)
node("TenantEngine", "회사별 정책·카드", rect: CGRect(x: 350, y: 445, width: 105, height: 68), color: green)
arrow(CGPoint(x: 402, y: 378), CGPoint(x: 275, y: 445), color: cyan)
arrow(CGPoint(x: 300, y: 479), CGPoint(x: 350, y: 479), color: orange)
callout("보안 핵심", "tenant_id를 요청 body에서 받으면 사용자가 다른 회사 ID를 넣어 위조할 수 있습니다. 이 프로젝트는 헤더/경로에서 결정한 값을 middleware가 request.state에 넣고, 뒤의 코드가 그것만 사용합니다.",
rect: CGRect(x: margin, y: 558, width: 507, height: 105), color: red)
comparison("멀티테넌시 경계가 약하면", "A회사 요청이 B회사 카드·설정·협상 엔진을 사용할 수 있습니다. 이는 단순 버그가 아니라 데이터 유출 사고입니다.",
"현재 방식", "요청 시작점에서 tenant를 검증하고, Registry가 해당 회사의 설정과 엔진을 해석합니다.",
y: 687, color: purple)
endPage(ctx)
// 11 multitenancy code
beginPage(ctx, page: 11)
sectionTitle("5", "멀티테넌시 — 실제 코드", "식별 → 검증 → request.state 전달 → 회사별 엔진 선택의 4단계", color: purple)
codeBox("Middleware: tenant를 요청 경계에서 확정",
"agent/router/middleware/tenant_middleware.py · lines 35–69",
"""
tenant_id = request.headers.get("X-Tenant-ID")
if not tenant_id:
return JSONResponse(status_code=400, ...)
if not tenant_registry.is_registered(tenant_id):
return JSONResponse(status_code=404, ...)
request.state.tenant_id = tenant_id
return await call_next(request)
""",
rect: CGRect(x: margin, y: 130, width: 507, height: 205), accent: purple)
codeBox("Dependency: 검증된 tenant로 엔진 조회",
"agent/router/deps.py · lines 13–21",
"""
async def get_tenant_engine(request: Request) -> TenantEngine:
tenant_id = getattr(request.state, "tenant_id", None)
if not tenant_id:
raise EXCEPTION_TENANT_HEADER_MISSING
return await tenant_registry.get_engine(tenant_id)
""",
rect: CGRect(x: margin, y: 360, width: 507, height: 165), accent: cyan)
codeBox("Registry: 프로세스 메모리에서 회사별 엔진 재사용",
"agent/tenancy/registry.py · lines 85–98",
"""
cached = self._engines.get(tenant_id)
if cached is not None:
return cached
async with self._locks[tenant_id]:
cached = self._engines.get(tenant_id)
if cached is not None:
return cached
engine = await self._build(tenant_id)
self._engines[tenant_id] = engine
return engine
""",
rect: CGRect(x: margin, y: 550, width: 507, height: 205), accent: orange)
endPage(ctx)
// 12 — Cache consistency: concept first
beginPage(ctx, page: 12)
sectionTitle("8", "캐시 정합성 — 개념부터", "비싼 계산·DB·외부 호출의 결과를 가까운 곳에 복사해 재사용하는 기술", color: red)
drawText("기본 용어", CGRect(x: margin, y: 126, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
let cacheTerms: [(String, String, NSColor)] = [
("Hit", "캐시에 값이 있어 원본을 읽지 않음", green),
("Miss", "값이 없어 원본을 읽고 캐시를 채움", blue),
("TTL", "값이 자동 만료될 때까지의 시간", orange),
("Stale", "원본은 바뀌었지만 캐시는 옛 값인 상태", red),
]
for (i, t) in cacheTerms.enumerated() {
let x = margin + CGFloat(i % 2) * 260
let y = CGFloat(165 + (i / 2) * 75)
callout(t.0, t.1, rect: CGRect(x: x, y: y, width: 247, height: 60), color: t.2)
}
drawText("대표적인 읽기·쓰기 패턴", CGRect(x: margin, y: 338, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
callout("Cache-aside", "앱이 캐시를 먼저 조회하고 miss이면 DB를 읽어 캐시에 저장합니다. 단순하고 가장 흔하지만 무효화를 앱이 책임집니다.",
rect: CGRect(x: margin, y: 378, width: 247, height: 92), color: blue)
callout("Write-through", "쓰기 때 캐시와 원본을 함께 갱신합니다. 읽기는 안정적이지만 쓰기 지연과 두 저장소의 부분 실패를 다뤄야 합니다.",
rect: CGRect(x: 304, y: 378, width: 247, height: 92), color: purple)
callout("Write-behind", "캐시에 먼저 쓰고 DB는 나중에 반영합니다. 빠르지만 캐시 장애 시 데이터 유실 위험이 있어 업무 원본에는 신중해야 합니다.",
rect: CGRect(x: margin, y: 486, width: 247, height: 92), color: orange)
callout("Negative cache", "‘결과 없음’도 잠깐 저장합니다. 반복 실패 비용을 줄이지만 너무 긴 TTL은 새로 생긴 데이터를 늦게 발견하게 합니다.",
rect: CGRect(x: 304, y: 486, width: 247, height: 92), color: green)
drawText("캐시에서 자주 생기는 문제", CGRect(x: margin, y: 610, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
callout("Invalidation", "원본 변경 후 어떤 key를 언제 삭제·갱신할지 결정하기 어렵습니다.",
rect: CGRect(x: margin, y: 650, width: 159, height: 82), color: red)
callout("Stampede", "인기 key가 만료되는 순간 많은 요청이 동시에 DB로 몰립니다.",
rect: CGRect(x: 218, y: 650, width: 159, height: 82), color: orange)
callout("Key 설계", "tenant·버전 등이 빠지면 서로 다른 데이터가 같은 key를 공유합니다.",
rect: CGRect(x: 392, y: 650, width: 159, height: 82), color: purple)
endPage(ctx)
// 13 cache concept
beginPage(ctx, page: 13)
sectionTitle("8", "캐시 정합성과 무효화", "빠른 복사본이 원본과 다른 값을 갖지 않도록 관리하는 문제", color: red)
callout("캐시는 복사본", "도서관 검색대의 메모가 캐시이고, 원본 장부가 DB라고 생각하면 쉽습니다. 메모는 빠르지만 오래된 정보일 수 있습니다. 정합성이란 메모와 장부가 의미상 같은 상태를 유지하는 것입니다.",
rect: CGRect(x: margin, y: 130, width: 507, height: 95), color: red)
drawText("앵커링 값의 저장·조회 순서", CGRect(x: margin, y: 252, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
node("1. DB 저장", "조정 이력 COMMIT", rect: CGRect(x: 46, y: 310, width: 118, height: 70), color: purple)
node("2. Redis SET", "최신값 + TTL 7일", rect: CGRect(x: 238, y: 310, width: 118, height: 70), color: red)
node("3. 다음 조회", "Redis 우선", rect: CGRect(x: 430, y: 310, width: 118, height: 70), color: blue)
arrow(CGPoint(x: 164, y: 345), CGPoint(x: 238, y: 345), color: purple)
arrow(CGPoint(x: 356, y: 345), CGPoint(x: 430, y: 345), color: red)
drawText("Redis 실패", CGRect(x: 240, y: 417, width: 110, height: 18), size: 9, color: red, weight: .bold)
arrow(CGPoint(x: 297, y: 380), CGPoint(x: 297, y: 465), color: red)
node("DB Fallback", "업무는 계속", rect: CGRect(x: 238, y: 465, width: 118, height: 70), color: green)
callout("stale 데이터란?", "DB에는 새 값 60이 저장됐는데 Redis SET이 실패해 캐시에 옛 값 55가 남은 상태입니다. 캐시 miss와 달리 값이 존재하므로 더 위험합니다. TTL과 주간 re-SET으로 회복합니다.",
rect: CGRect(x: margin, y: 574, width: 507, height: 95), color: orange)
comparison("캐시만 믿으면", "Redis 장애가 업무 장애가 되고, 오래된 값이 실제 제안가를 왜곡할 수 있습니다. Redis 유실 시 원본도 사라집니다.",
"원본 DB + 파생 캐시", "Redis 장애 시 DB를 읽고, TTL과 reconciliation으로 오래된 복사본을 교정합니다.",
y: 693, color: red)
endPage(ctx)
// 14 cache code
beginPage(ctx, page: 14)
sectionTitle("8", "캐시 정합성 — 실제 코드", "Cache-aside, TTL, DB fallback, reconciliation이 한 세트로 작동합니다.", color: red)
codeBox("Cache-aside: Redis miss → DB → Redis backfill",
"schedules/anchoring/src/anchoring/reader.py · lines 33–42",
"""
cached = await get_value(company_id, supplier_type, price_range)
if cached is not None:
return cached
value = await get_latest_adjusted_value(
db, company_id, supplier_type, price_range
)
if value is None:
value = get_base_anchoring_value(price_range)
await set_value(..., value, nx=True)
return value
""",
rect: CGRect(x: margin, y: 130, width: 507, height: 225), accent: red)
codeBox("Redis 장애는 cache miss로 취급",
"schedules/anchoring/src/anchoring/redis_client.py · lines 69–84",
"""
try:
raw = await _client.get(anchor_key(...))
if raw is None:
return None
return int(raw)
except Exception as ex:
_note_failure("get", anchor_key(...), ex)
return None # 호출측이 DB fallback
""",
rect: CGRect(x: margin, y: 380, width: 507, height: 180), accent: orange)
callout("두 겹의 회복 장치", "TTL 7일은 오래된 키가 영원히 남는 것을 막습니다. 주간 reconciliation은 DB의 최신 조정값을 Redis에 다시 SET하여, DB commit 뒤 Redis 갱신에 실패했던 값도 교정합니다.",
rect: CGRect(x: margin, y: 585, width: 507, height: 100), color: red)
callout("현재 견적 생성 경로의 예외", "Negodata의 실제 견적 생성은 Redis를 사용하지 않고 PostgreSQL의 anchoring.current_values View를 일괄 조회합니다. Redis는 현재 anchoring 배치 내부 캐시입니다.",
rect: CGRect(x: margin, y: 705, width: 507, height: 72), color: cyan)
endPage(ctx)
// 15 — Scheduler and batch: concept first
beginPage(ctx, page: 15)
sectionTitle("9", "스케줄러·배치 — 개념부터", "시간 규칙으로 작업을 시작하고, 많은 데이터를 사용자 요청 밖에서 처리하는 방식", color: green)
callout("둘의 차이", "스케줄러는 ‘언제 실행할지’를 결정합니다. 배치 Job은 ‘무엇을 어떻게 처리할지’를 구현합니다. CronTrigger가 알람시계라면 close_expired_quotations는 알람이 울렸을 때 수행할 실제 업무입니다.",
rect: CGRect(x: margin, y: 126, width: 507, height: 92), color: green)
drawText("Job의 생명주기", CGRect(x: margin, y: 248, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
node("Trigger", "실행 시각 도달", rect: CGRect(x: 45, y: 297, width: 94, height: 66), color: green)
node("Select", "처리 대상 조회", rect: CGRect(x: 181, y: 297, width: 94, height: 66), color: blue)
node("Process", "개별 업무 수행", rect: CGRect(x: 317, y: 297, width: 94, height: 66), color: orange)
node("Checkpoint", "결과·진행점 기록", rect: CGRect(x: 453, y: 297, width: 94, height: 66), color: purple)
arrow(CGPoint(x: 139, y: 330), CGPoint(x: 181, y: 330), color: green)
arrow(CGPoint(x: 275, y: 330), CGPoint(x: 317, y: 330), color: blue)
arrow(CGPoint(x: 411, y: 330), CGPoint(x: 453, y: 330), color: orange)
drawText("운영에서 반드시 결정할 것", CGRect(x: margin, y: 405, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
let jobIssues: [(String, String, NSColor)] = [
("중복 실행", "이전 Job이 안 끝났는데 다음 시각이 오면?", red),
("Misfire", "서버가 꺼져 실행 시각을 놓쳤다면?", orange),
("부분 실패", "100건 중 73번째가 실패하면 어디부터 재개?", purple),
("재시도", "즉시 재시도, 다음 tick, 운영자 재처리 중 무엇?", blue),
("멱등성", "같은 대상을 다시 처리해도 중복 효과가 없는가?", green),
("관측성", "처리량·실패 대상·소요시간을 로그와 지표로 남기는가?", cyan),
]
for (i, j) in jobIssues.enumerated() {
let x = margin + CGFloat(i % 2) * 260
let y = CGFloat(445 + (i / 2) * 73)
callout(j.0, j.1, rect: CGRect(x: x, y: y, width: 247, height: 58), color: j.2)
}
callout("스케줄러만으로 정확성은 보장되지 않음", "max_instances=1은 한 프로세스 안의 중복을 막을 뿐입니다. 서버가 여러 대면 각 서버가 Job을 실행할 수 있으므로 DB 조건부 갱신, 분산 락, 전용 Worker 같은 추가 방어가 필요합니다.",
rect: CGRect(x: margin, y: 680, width: 507, height: 105), color: red)
endPage(ctx)
// 16 scheduler concept
beginPage(ctx, page: 16)
sectionTitle("9", "스케줄러와 배치 안정성", "사용자 요청 없이 정해진 시간마다 반복 업무를 수행하는 백그라운드 실행", color: green)
callout("쉬운 비유", "API가 손님이 주문할 때 움직이는 직원이라면, 스케줄러는 매 5분마다 마감 시간이 지난 주문을 확인하는 당직자입니다. 사람이 요청하지 않아도 시간이 되면 일을 시작합니다.",
rect: CGRect(x: margin, y: 130, width: 507, height: 92), color: green)
drawText("5분 tick의 세 가지 작업", CGRect(x: margin, y: 252, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
node("CronTrigger", "매 5분", rect: CGRect(x: 48, y: 315, width: 105, height: 68), color: green)
node("잡 ①", "기한 지난 견적 마감", rect: CGRect(x: 225, y: 280, width: 135, height: 62), color: orange)
node("잡 ②", "협상 완료 견적 마감", rect: CGRect(x: 225, y: 365, width: 135, height: 62), color: purple)
node("잡 ③", "LPS 결과 증분 반영", rect: CGRect(x: 225, y: 450, width: 135, height: 62), color: cyan)
arrow(CGPoint(x: 153, y: 349), CGPoint(x: 225, y: 311), color: green)
arrow(CGPoint(x: 153, y: 349), CGPoint(x: 225, y: 396), color: green)
arrow(CGPoint(x: 153, y: 349), CGPoint(x: 225, y: 481), color: green)
node("PostgreSQL", "조건부 처리·기록", rect: CGRect(x: 430, y: 365, width: 115, height: 72), color: blue)
arrow(CGPoint(x: 360, y: 311), CGPoint(x: 430, y: 385), color: orange)
arrow(CGPoint(x: 360, y: 396), CGPoint(x: 430, y: 401), color: purple)
arrow(CGPoint(x: 360, y: 481), CGPoint(x: 430, y: 420), color: cyan)
callout("중복 실행 방지 장치", "SCHEDULER_ENABLED=1인 프로세스 하나만 잡을 등록합니다. 각 잡은 max_instances=1이고, 밀린 실행은 coalesce=True로 한 번만 실행합니다. 그래도 다중 서버 가능성을 고려해 DB의 조건부 UPDATE가 마지막 방어선입니다.",
rect: CGRect(x: margin, y: 565, width: 507, height: 115), color: green)
comparison("안정 장치가 없으면", "서버 Worker 수만큼 같은 잡이 실행되고, 같은 견적을 여러 번 마감하거나 알림을 중복 발송할 수 있습니다.",
"현재 방식", "실행 프로세스 제한 + 잡 중복 제한 + DB 동시성 가드를 겹쳐 사용합니다.",
y: 704, color: green)
endPage(ctx)
// 17 scheduler code
beginPage(ctx, page: 17)
sectionTitle("9", "스케줄러·배치 — 실제 코드", "‘언제 실행할지’와 ‘무엇을 안전하게 처리할지’를 분리합니다.", color: green)
codeBox("APScheduler 등록: 5분, 중복 방지, 지연 허용",
"negodata/backend/scheduler/__init__.py · lines 37–69",
"""
_scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
_scheduler.add_job(
jobs.close_expired_quotations,
CronTrigger(minute="*/5"),
id="close_expired_quotations",
coalesce=True, # 밀린 실행은 1번만
misfire_grace_time=600, # 10분 내 지연 실행 허용
max_instances=1, # 같은 잡 동시 실행 금지
)
""",
rect: CGRect(x: margin, y: 130, width: 507, height: 215), accent: green)
codeBox("Job: 대상 조회와 개별 마감 처리를 분리",
"negodata/backend/scheduler/jobs.py · lines 39–61",
"""
err_type, qt_ids = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: crud.list_due_for_close(s, now),
)
if err_type != ErrorType.SUCCESS:
return 0
results = await _close_each(service, qt_ids)
return sum(results.values())
""",
rect: CGRect(x: margin, y: 370, width: 507, height: 190), accent: cyan)
callout("멱등성(idempotency)", "같은 잡을 두 번 실행해도 최종 결과가 한 번 실행한 것과 같도록 만드는 성질입니다. 대상 조회가 중복될 수 있어도 claim_for_close의 조건부 UPDATE가 두 번째 처리를 rowcount=0으로 막습니다.",
rect: CGRect(x: margin, y: 585, width: 507, height: 105), color: purple)
callout("실패한 tick은 어떻게 되나요?", "LPS 동기화는 watermark 기반 증분 처리라 실패한 회차의 데이터가 다음 5분 tick에서 다시 대상이 됩니다. 스케줄러 자체 재시도보다 데이터 설계를 통해 회복합니다.",
rect: CGRect(x: margin, y: 710, width: 507, height: 72), color: green)
endPage(ctx)
// 18 combined scenario
beginPage(ctx, page: 18)
drawText("다섯 기술이 한 장면에서 만나는 순간", CGRect(x: margin, y: 48, width: 507, height: 34),
size: 23, color: navy, weight: .bold)
drawText("예: 협상이 모두 끝난 견적을 스케줄러가 자동 마감하는 동안 담당자가 수동 마감을 클릭했다.",
CGRect(x: margin, y: 88, width: 507, height: 30), size: 10.5, color: muted)
let rows: [(String, String, NSColor)] = [
("1", "멀티테넌시: 요청의 X-Tenant-ID로 어느 회사의 협상 엔진과 데이터인지 결정", purple),
("2", "분산 시스템: Backend가 Agent를 HTTP로 호출할 때 timeout과 부분 실패를 구분", blue),
("3", "스케줄러: 5분 tick이 동일 견적을 마감 대상으로 발견", green),
("4", "동시성 제어: 수동 요청과 스케줄러 중 조건부 UPDATE를 먼저 성공한 쪽만 처리", orange),
("5", "트랜잭션: 관련 상태 변경을 commit하거나, 실패하면 rollback", cyan),
("6", "캐시 정합성: 원본 DB commit 후 파생 캐시를 갱신하고 실패 시 다음 회차에 회복", red),
]
for (i, item) in rows.enumerated() {
let y = CGFloat(145 + i * 91)
rounded(CGRect(x: margin, y: y, width: 507, height: 70), radius: 12,
fill: item.2.withAlphaComponent(0.08), stroke: item.2.withAlphaComponent(0.30))
rounded(CGRect(x: 58, y: y + 15, width: 40, height: 40), radius: 20,
fill: item.2, stroke: nil)
drawText(item.0, CGRect(x: 58, y: y + 24, width: 40, height: 20), size: 13,
color: .white, weight: .bold, align: .center)
drawText(item.1, CGRect(x: 116, y: y + 15, width: 415, height: 42), size: 10.2,
color: ink, weight: .medium, lineSpacing: 3)
if i < rows.count - 1 {
arrow(CGPoint(x: 78, y: y + 70), CGPoint(x: 78, y: y + 90), color: item.2)
}
}
callout("핵심 관점", "어려운 백엔드 기술은 ‘라이브러리 이름’보다 경계와 실패를 다루는 방법입니다. 네트워크 경계, 회사 경계, 트랜잭션 경계, 캐시의 원본 경계를 명확하게 설계하는 것이 핵심입니다.",
rect: CGRect(x: margin, y: 708, width: 507, height: 78), color: navy)
endPage(ctx)
// 19 glossary
beginPage(ctx, page: 19)
drawText("초보자를 위한 한 줄 사전", CGRect(x: margin, y: 48, width: 507, height: 34),
size: 24, color: navy, weight: .bold)
let glossary: [(String, String)] = [
("분산 시스템", "여러 프로세스·서버가 네트워크로 협력하는 시스템"),
("부분 실패", "전체 중 일부 서비스만 실패한 상태"),
("Timeout", "응답을 무한히 기다리지 않고 정해진 시간에 포기하는 제한"),
("Best-effort", "실패해도 핵심 업무는 성공시키는 보조 작업 정책"),
("트랜잭션", "여러 DB 변경을 모두 성공 또는 모두 취소하는 작업 단위"),
("Rollback", "실패했을 때 트랜잭션의 변경을 되돌리는 것"),
("Race condition", "실행 순서에 따라 결과가 달라지는 동시성 문제"),
("원자적 연산", "중간 상태가 보이지 않도록 한 번에 처리되는 연산"),
("멀티테넌시", "한 시스템을 여러 고객사가 격리된 상태로 공유하는 구조"),
("Cache-aside", "캐시를 먼저 보고, miss이면 원본 조회 후 캐시를 채우는 패턴"),
("TTL", "캐시 값이 자동 만료되기까지의 시간"),
("Stale", "원본보다 오래되어 현재와 맞지 않는 캐시 상태"),
("Invalidation", "원본 변경 시 캐시를 삭제하거나 무효화하는 것"),
("Reconciliation", "원본과 복사본을 비교·재적재해 다시 맞추는 작업"),
("Scheduler", "정해진 시간 규칙에 따라 작업을 실행하는 도구"),
("Batch", "사용자 요청과 별개로 데이터 묶음을 주기적으로 처리하는 작업"),
("멱등성", "같은 작업을 반복해도 최종 결과가 달라지지 않는 성질"),
]
for (i, g) in glossary.enumerated() {
let col = i < 9 ? 0 : 1
let row = col == 0 ? i : i - 9
let x = margin + CGFloat(col) * 260
let y = CGFloat(128 + row * 69)
drawText(g.0, CGRect(x: x, y: y, width: 230, height: 19), size: 10.5,
color: [blue, orange, purple, red, green][i % 5], weight: .bold)
drawText(g.1, CGRect(x: x, y: y + 23, width: 230, height: 36), size: 8.8,
color: ink, lineSpacing: 2)
}
callout("추천 복습 순서", "트랜잭션·동시성 → 캐시 정합성 → 스케줄러 → 멀티테넌시 → 분산 시스템 순으로 다시 보면, 작은 DB 작업에서 전체 서비스 구조로 이해가 확장됩니다.",
rect: CGRect(x: margin, y: 718, width: 507, height: 74), color: blue)
endPage(ctx)
ctx.closePDF()
print(outPath)

View File

@ -57,6 +57,8 @@ export interface RefreshTokenResponse {
export interface Branding { export interface Branding {
service_name?: string service_name?: string
logo_url?: string logo_url?: string
primary_color?: string
email_header?: string
helpdesk?: string[] // 헬프데스크 연락처 — 한 줄 = 담당자 한 명. 비면 연락처 영역을 렌더하지 않는다 helpdesk?: string[] // 헬프데스크 연락처 — 한 줄 = 담당자 한 명. 비면 연락처 영역을 렌더하지 않는다
} }
@ -65,6 +67,7 @@ export interface SessionBrandingResponse {
result: ApiResult result: ApiResult
service_name: string service_name: string
logo_url: string logo_url: string
primary_color: string
helpdesk?: string[] helpdesk?: string[]
} }

View File

@ -49,8 +49,6 @@ 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 {
@ -110,7 +108,5 @@ 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,
} }
} }

View File

@ -62,9 +62,6 @@ export interface SessionListItem {
renegotiation_status: number // 1=심사대기 2=승인 3=반려 4=철회, 이력 없으면 0 renegotiation_status: number // 1=심사대기 2=승인 3=반려 4=철회, 이력 없으면 0
renegotiation_memo: string // 담당자 심사 메모(반려 사유) renegotiation_memo: string // 담당자 심사 메모(반려 사유)
result: number // 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰) result: number // 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
has_chat: boolean // 대화 이력 존재 여부 — 종료된 협상의 '결과 보기' 노출 판단용
reject_reason: string // 협상 거부 시 제출한 사유. 거부 건이 아니면 ''
reject_price?: number | null // 거부와 함께 낸 공급 희망 가격(원). 미입력이면 null
} }
/** 공급사 관점 협상 결과 (sessions 파생) */ /** 공급사 관점 협상 결과 (sessions 파생) */
@ -121,13 +118,10 @@ export interface ParticipateResponse {
} }
// --- 거부 (POST /v1/negotiation/sessions/{id}/reject) --------------------- // --- 거부 (POST /v1/negotiation/sessions/{id}/reject) ---------------------
// 목록의 협상 거부와 채팅 중 협상 거부가 같은 폼(components/RejectPopup)·같은 엔드포인트를 쓴다. // reject_reason: 프리셋(단종/품절) 라벨 또는 '기타' 직접 입력 텍스트.
// reject_reason: 프리셋(단종/품절) 라벨 또는 '기타' 직접 입력 텍스트. 필수. // (백엔드 sessions.reject_reason 컬럼에 대응. 엔드포인트는 백엔드 추가 예정)
// reject_price/opinion: 선택 입력 — 빈 값이면 아예 보내지 않는다(opinion 은 sessions.custom 에 병합).
export interface RejectRequest { export interface RejectRequest {
reject_reason: string reject_reason: string
reject_price?: number
opinion?: string
} }
export interface RejectResponse { export interface RejectResponse {

View File

@ -1,81 +0,0 @@
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>
)
}

View File

@ -1,200 +0,0 @@
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>
)
}

View File

@ -7,7 +7,3 @@ 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'

View File

@ -43,6 +43,7 @@ export function usePreLoginBranding(): Branding | null {
const next: Branding = { const next: Branding = {
service_name: res.service_name || undefined, service_name: res.service_name || undefined,
logo_url: res.logo_url || undefined, logo_url: res.logo_url || undefined,
primary_color: res.primary_color || undefined,
helpdesk: res.helpdesk?.length ? res.helpdesk : undefined, helpdesk: res.helpdesk?.length ? res.helpdesk : undefined,
} }
if (!next.service_name && !next.logo_url && !next.helpdesk) return if (!next.service_name && !next.logo_url && !next.helpdesk) return

View File

@ -7,8 +7,6 @@ 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 = '아이마켓코리아 (구매담당자)'
@ -59,7 +57,6 @@ 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>
) )
@ -90,16 +87,11 @@ 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 ( return null
<div className="mb-5 animate-fade-in">
<RejectSummary script={message.script || ''} />
</div>
)
} }
} }

View File

@ -1,33 +1,10 @@
import { useState } from 'react'
import { SessionStatus } from '@/apis'
import { useChatStore } from '@/features/chat/stores/useChatStore' import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
import { ChatMessage } from '@/features/chat/components/ChatMessage' import { ChatMessage } from '@/features/chat/components/ChatMessage'
import { UserButton } from '@/features/chat/components/UserButton' import { UserButton } from '@/features/chat/components/UserButton'
import { RejectPopup } from '@/features/chat/components/popup/RejectPopup'
import { canRejectNegotiation, GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig'
import { MobileStepBar } from '@/features/chat/components/MobileStepBar' import { MobileStepBar } from '@/features/chat/components/MobileStepBar'
import type { UserButtonConfig } from '@/features/chat/types'
// 미참여·협상거부로 끝난 세션은 '결과 보기'(열람) 진입이라 입력을 잠근다.
// 마지막 봇 메시지가 입력을 요구하는 상태로 멈춰 있어도(중간 이탈) 서버가 전송을 막으므로 액션바는 복귀 버튼만 둔다.
const READ_ONLY_STATUSES: number[] = [SessionStatus.NOT_PARTICIPATED, SessionStatus.REJECTED]
const READ_ONLY_CONFIG: UserButtonConfig = { type: 'one-black', text: GO_TO_LIST_TEXT }
// 거부 가능한 세션 상태 — 백엔드 /reject 가 허용하는 범위(완료/미참여/거부는 불가)와 같다.
const REJECTABLE_STATUSES: number[] = [SessionStatus.CREATED, SessionStatus.IN_PROGRESS]
export function ChatSection() { export function ChatSection() {
const { messages, userButtonConfig } = useChatStore() const { userButtonConfig } = useChatStore()
const sessionStatus = useChatInitStore((s) => s.session_status)
const [isRejectOpen, setIsRejectOpen] = useState(false)
const isReadOnly = READ_ONLY_STATUSES.includes(sessionStatus)
const config = isReadOnly ? READ_ONLY_CONFIG : userButtonConfig
// 단종·품절처럼 대화로 풀 수 없는 사유는 봇의 결렬 선언을 기다릴 수 없다 — 진행 중에도 빠져나갈 길을 둔다.
const canReject =
!isReadOnly && REJECTABLE_STATUSES.includes(sessionStatus) && canRejectNegotiation(messages, userButtonConfig)
return ( return (
<div className="flex flex-1 flex-col w-full h-full min-h-0 bg-surface"> <div className="flex flex-1 flex-col w-full h-full min-h-0 bg-surface">
<MobileStepBar /> <MobileStepBar />
@ -35,9 +12,8 @@ export function ChatSection() {
{/* 하단 액션 덱 */} {/* 하단 액션 덱 */}
{/* safe-b: 홈 인디케이터에 입력 버튼이 가리지 않도록 하단 안전영역 확보 */} {/* safe-b: 홈 인디케이터에 입력 버튼이 가리지 않도록 하단 안전영역 확보 */}
<div className="shrink-0 safe-b border-t border-border bg-white shadow-[0_-4px_20px_rgba(0,0,0,0.03)]"> <div className="shrink-0 safe-b border-t border-border bg-white shadow-[0_-4px_20px_rgba(0,0,0,0.03)]">
<UserButton {...config} onReject={canReject ? () => setIsRejectOpen(true) : undefined} /> <UserButton {...userButtonConfig} />
</div> </div>
{isRejectOpen && <RejectPopup onClose={() => setIsRejectOpen(false)} />}
</div> </div>
) )
} }

View File

@ -110,8 +110,7 @@ function ItemInfo() {
<div className="flex flex-1 items-start self-stretch overflow-y-auto overflow-x-hidden px-6 min-h-0"> <div className="flex flex-1 items-start self-stretch overflow-y-auto overflow-x-hidden px-6 min-h-0">
<div className="flex flex-col items-start self-stretch flex-1 gap-2 min-w-0"> <div className="flex flex-col items-start self-stretch flex-1 gap-2 min-w-0">
{renderRow(fieldLabel('item.code', '상품코드'), item_code)} {renderRow(fieldLabel('item.code', '상품코드'), item_code)}
{/* 값은 회사가 고른 협상 기준가(item_price), 호칭은 공급사 화면 고정 용어 — 회사 용어 사전을 타지 않는다. */} {renderRow(fieldLabel('item.price', '단가'), formattedPrice)}
{renderRow('공급가', formattedPrice)}
{renderRow(fieldLabel('item.model_name', '모델명'), item_model_name)} {renderRow(fieldLabel('item.model_name', '모델명'), item_model_name)}
{renderRow(fieldLabel('item.manufacturer', '제조사'), item_maker_name)} {renderRow(fieldLabel('item.manufacturer', '제조사'), item_maker_name)}
{renderRow(fieldLabel('item.moq', '최소주문수량'), item_min_order_quantity)} {renderRow(fieldLabel('item.moq', '최소주문수량'), item_min_order_quantity)}

View File

@ -1,20 +1,23 @@
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 '@/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'
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 { import { findRestoreScript, restoreRejectRSP, extractPart } from '@/features/chat/lib/rejectForm'
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() {
@ -126,7 +129,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">
{REJECT_REASONS.map((r) => ( {REASONS.map((r) => (
<SelectRadio <SelectRadio
key={r.value} key={r.value}
text={r.text} text={r.text}

View File

@ -17,8 +17,7 @@ const style = {
'flex min-w-[120px] px-[28px] max-[1180px]:min-w-[100px] max-[1180px]:px-[20px] h-[46px] bg-white hover:bg-neutral-10 rounded-xl items-center justify-center text-sm font-bold text-neutral-80 border border-border cursor-pointer whitespace-nowrap transition-all ease-out active:scale-[0.98]', 'flex min-w-[120px] px-[28px] max-[1180px]:min-w-[100px] max-[1180px]:px-[20px] h-[46px] bg-white hover:bg-neutral-10 rounded-xl items-center justify-center text-sm font-bold text-neutral-80 border border-border cursor-pointer whitespace-nowrap transition-all ease-out active:scale-[0.98]',
} }
// onReject: 협상 거부 진입점. 넘어오면 버튼 덱 끝에 붙는다(입력 단계는 인풋이 넓어 좁은 화면에서 아랫줄로 wrap). export function UserButton({ type, text, textList, priceErrorMessage }: UserButtonConfig) {
export function UserButton({ type, text, textList, priceErrorMessage, onReject }: UserButtonConfig & { onReject?: () => void }) {
if (type === '') return null if (type === '') return null
// 부가정보 입력은 폼이라 가운데정렬 덱이 아니라 전체폭으로 편다. text = 저장 후 보낼 동의 문구. // 부가정보 입력은 폼이라 가운데정렬 덱이 아니라 전체폭으로 편다. text = 저장 후 보낼 동의 문구.
@ -42,13 +41,8 @@ export function UserButton({ type, text, textList, priceErrorMessage, onReject }
// 입력 단계는 에러 말풍선이 위로 삐져나가야 해서 overflow 클리핑 제외 (버튼 덱만 가로 스크롤 허용) // 입력 단계는 에러 말풍선이 위로 삐져나가야 해서 overflow 클리핑 제외 (버튼 덱만 가로 스크롤 허용)
const isInputStep = type === 'percent' || type === 'price' const isInputStep = type === 'percent' || type === 'price'
return ( return (
<div <div className={cn('flex w-full justify-center px-6 py-4 max-[1180px]:px-4', !isInputStep && 'overflow-x-auto')}>
className={cn( <div className="flex justify-center">
'relative flex w-full flex-wrap items-center justify-center gap-3 px-6 py-4 max-[1180px]:px-4',
!isInputStep && 'overflow-x-auto',
)}
>
<div className="flex items-center justify-center gap-3">
{type === 'one-black' && <OneBlack text={text || '확인'} />} {type === 'one-black' && <OneBlack text={text || '확인'} />}
{type === 'one-gray' && <OneGray text={text || '확인'} />} {type === 'one-gray' && <OneGray text={text || '확인'} />}
{type === 'black-white' && <BlackWhite textList={[textList?.[0] || '예', textList?.[1] || '아니오']} />} {type === 'black-white' && <BlackWhite textList={[textList?.[0] || '예', textList?.[1] || '아니오']} />}
@ -59,19 +53,6 @@ export function UserButton({ type, text, textList, priceErrorMessage, onReject }
{type === 'price' && <Price priceErrorMessage={priceErrorMessage} />} {type === 'price' && <Price priceErrorMessage={priceErrorMessage} />}
{type === 'loading' && <LoadingDots />} {type === 'loading' && <LoadingDots />}
</div> </div>
{/* 오클릭 방지: 주 CTA 와 붙이지 않는다. 넓은 화면은 우측 끝 고정(가운데 CTA 는 그대로),
좁은 화면은 인풋 폭 때문에 같은 줄이 안 나오므로 wrap 되어 아랫줄로 내려간다. */}
{onReject && (
<button
className={cn(
style.white,
'min-[1180px]:absolute min-[1180px]:right-8 min-[1180px]:top-1/2 min-[1180px]:-translate-y-1/2',
)}
onClick={onReject}
>
협상 거부
</button>
)}
</div> </div>
) )
} }

View File

@ -1,35 +0,0 @@
import { useNavigate } from 'react-router'
import { RejectPopup as RejectPopupForm } from '@/components'
import { getApiErrorMessage, useRejectMutation } from '@/apis'
import { toast } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
// 협상 진행 중 거부 — 폼은 목록 거부와 공용이고, 여기서는 대화 세션에 붙여 제출·이탈만 처리한다.
export function RejectPopup({ onClose }: { onClose: () => void }) {
const navigate = useNavigate()
const reject = useRejectMutation()
const sessionId = useChatStore((s) => s.sessionId)
const itemVatYn = useChatInitStore((s) => s.item_vat_yn)
return (
<RejectPopupForm
onClose={onClose}
isPending={reject.isPending}
isVatExcluded={itemVatYn === 'VAT별도'}
onSubmit={(request) =>
reject.mutate(
{ sessionId, request },
{
onSuccess: () => {
onClose()
toast.warning('협상 거부가 완료되었습니다.')
navigate('/list')
},
onError: (error) => toast.error(getApiErrorMessage(error, '거부 처리에 실패했습니다.')),
},
)
}
/>
)
}

View File

@ -1,5 +1,5 @@
import { CheckCircle2 } from 'lucide-react' import { CheckCircle2 } from 'lucide-react'
import { numberToKorean } from '@/lib' import { numberToKorean } from '@/features/chat/lib/koreanNumber'
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'

View File

@ -1,52 +0,0 @@
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>
)
}

View File

@ -1,56 +0,0 @@
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>
)
}

View File

@ -1,5 +1,5 @@
import { CheckCircle2 } from 'lucide-react' import { CheckCircle2 } from 'lucide-react'
import { numberToKorean } from '@/lib' import { numberToKorean } from '@/features/chat/lib/koreanNumber'
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'

View File

@ -24,9 +24,8 @@ const TERMINAL_CODES = new Set<number>([
ErrorCode.NEGO_NOT_FOUND, ErrorCode.NEGO_NOT_FOUND,
]) ])
// 진입 로드(init/messages) 시 '잘못된 접근'으로 볼 코드 → 권한 없음 / 없는(또는 남의) 세션 / 진입 불가 상태. // 진입 로드(init/messages) 시 '잘못된 접근'으로 볼 코드 → 권한 없음 / 없는(또는 남의) 세션 /
// 토스트 안내 후 목록으로 복귀시킨다. (인증≠인가 — 로그아웃하지 않는다) // 진입 불가 상태(미참여·거부). 토스트 안내 후 목록으로 복귀시킨다. (인증≠인가 — 로그아웃하지 않는다)
// 종료된 세션(완료·미참여·거부)은 열람 진입이 허용되므로 여기 걸리지 않는다 — 입력만 잠긴다(ChatSection).
const INVALID_ACCESS_CODES = new Set<number>([ const INVALID_ACCESS_CODES = new Set<number>([
ErrorCode.NEGO_FORBIDDEN, ErrorCode.NEGO_FORBIDDEN,
ErrorCode.NEGO_NOT_FOUND, ErrorCode.NEGO_NOT_FOUND,
@ -116,8 +115,8 @@ export function useChatController(sessionId: string) {
if (data.message) s.appendMessage(mapMessage(data.message)) if (data.message) s.appendMessage(mapMessage(data.message))
s.setIsLoading(false) s.setIsLoading(false)
// 협상 종료 전이(완료/거부 등): 목록 캐시만 무효화해 /list 복귀 시 최신 상태를 보장한다. // 협상 종료 전이(완료/거부 등): 목록 캐시만 무효화해 /list 복귀 시 최신 상태를 보장한다.
// init 은 '진입 메타'라 화면에서 재조회하지 않는다 — 방금 종료한 화면의 입력이 열람 모드로 // init 은 '진입 메타'라 화면에서 재조회하지 않는다 — 재조회하면 미참여/거부 진입 게이트(1301)에
// 갈아치워지지 않게 한다. 재진입 시 최신 status 는 언마운트의 removeQueries 가 보장한다. // 걸려 방금 정상 종료한 사용자가 튕겨난다. 재진입 시 최신 status 는 언마운트의 removeQueries 가 보장한다.
if (data.session_status !== SessionStatus.IN_PROGRESS || data.message?.chat_end) { if (data.session_status !== SessionStatus.IN_PROGRESS || data.message?.chat_end) {
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() }) queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
} }

View File

@ -1,13 +1,5 @@
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()
@ -30,28 +22,3 @@ 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 }
})
}

View File

@ -2,17 +2,6 @@ import type { ChatMessage, UserButtonConfig } from '@/features/chat/types'
export const GO_TO_LIST_TEXT = '상품 목록으로 가기' export const GO_TO_LIST_TEXT = '상품 목록으로 가기'
// 협상 거부 진입점을 숨기는 액션바 상태.
// reject=봇이 이미 결렬을 선언(그 폼이 최종가를 받는다) / extra-info=타결 후 동의 단계 / loading=응답 대기.
const NO_REJECT_TYPES = ['', 'loading', 'reject', 'extra-info']
// 채팅 안에서 협상을 거부할 수 있는 상태인지 — 대화가 끝나지 않고 협력사 입력을 기다리는 동안만.
export function canRejectNegotiation(messages: ChatMessage[], config: UserButtonConfig): boolean {
if (!messages || messages.length === 0) return false
if (messages[messages.length - 1].chat_end) return false
return !NO_REJECT_TYPES.includes(config.type)
}
// 마지막 봇 메시지의 next_input_mode 로 하단 입력 UI 구성을 결정한다. // 마지막 봇 메시지의 next_input_mode 로 하단 입력 UI 구성을 결정한다.
export function deriveUserButtonConfig( export function deriveUserButtonConfig(
messages: ChatMessage[], messages: ChatMessage[],

View File

@ -32,8 +32,6 @@ 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) => ({

View File

@ -86,6 +86,4 @@ 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 // 거부와 함께 낸 공급 희망 가격(원)
} }

View File

@ -29,7 +29,7 @@ export function GuidePopup({ onClose }: { onClose: () => void }) {
<Row badge="협상 중" tone="prog" text="가격을 조율하는 중입니다. 이어서 진행하세요." /> <Row badge="협상 중" tone="prog" text="가격을 조율하는 중입니다. 이어서 진행하세요." />
<Row badge="협상 완료" tone="done" text="가격 제출을 마쳤습니다. 최종 결과는 견적 마감 후 아래 '결과'로 표시됩니다." /> <Row badge="협상 완료" tone="done" text="가격 제출을 마쳤습니다. 최종 결과는 견적 마감 후 아래 '결과'로 표시됩니다." />
<Row badge="협상 미참여" tone="none" text="기한 내 참여하지 않아 종료된 건입니다." /> <Row badge="협상 미참여" tone="none" text="기한 내 참여하지 않아 종료된 건입니다." />
<Row badge="협상 거부" tone="reject" text="내가 거부한 건입니다." /> <Row badge="협상 거절" tone="reject" text="내가 참여를 거절한 건입니다." />
</Section> </Section>
<Section title="협상 결과" desc="견적이 마감된 뒤 정해지는 낙찰 결과입니다. 마감 전에는 표시되지 않습니다."> <Section title="협상 결과" desc="견적이 마감된 뒤 정해지는 낙찰 결과입니다. 마감 전에는 표시되지 않습니다.">

View File

@ -0,0 +1,124 @@
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>
)
}

View File

@ -2,7 +2,7 @@ import { Loader2, MessageSquareText } from 'lucide-react'
import { cn, formatKstDateTime } from '@/lib' import { cn, formatKstDateTime } from '@/lib'
import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type' import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type'
import type { ListItem } from '@/features/list/types' import type { ListItem } from '@/features/list/types'
import { statusMeta, canEnterChat, isEndedStatus, RESULT_META } from '@/features/list/lib/status' import { statusMeta, RESULT_META } from '@/features/list/lib/status'
interface WorkspaceCardsProps { interface WorkspaceCardsProps {
items: ListItem[] items: ListItem[]
@ -13,11 +13,10 @@ 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, onRejectDetail }: WorkspaceCardsProps) { export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo }: 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">
@ -32,7 +31,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} onRejectDetail={onRejectDetail} /> <Card key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} />
))} ))}
</div> </div>
) )
@ -46,7 +45,6 @@ function Card({
onExtraInfo, onExtraInfo,
onRenegotiate, onRenegotiate,
onMemo, onMemo,
onRejectDetail,
}: { }: {
item: ListItem item: ListItem
busy: boolean busy: boolean
@ -55,16 +53,12 @@ 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 canEnter = !['미참여', '협상거부'].includes(item.session_status)
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 = isDone ? '결과 보기' : '협상 입장'
// 재협상: 요청 가능하면 버튼, 이미 요청했으면 진행 상태를 보여준다. // 재협상: 요청 가능하면 버튼, 이미 요청했으면 진행 상태를 보여준다.
const renegoLabel = RENEGO_STATUS_LABEL[item.renegotiationStatus] ?? '' const renegoLabel = RENEGO_STATUS_LABEL[item.renegotiationStatus] ?? ''
@ -118,17 +112,8 @@ function Card({
</div> </div>
))} ))}
{(canEnter || canReject || isDone || isRejected || item.renegotiable) && ( {(canEnter || canReject || isDone || 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"
@ -153,7 +138,7 @@ function Card({
onClick={() => onReject(item)} onClick={() => onReject(item)}
className="flex-1 rounded-lg border border-border py-2 text-xs font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]" className="flex-1 rounded-lg border border-border py-2 text-xs font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]"
> >
협상 거부 거절
</button> </button>
)} )}
{canEnter && ( {canEnter && (

View File

@ -3,7 +3,7 @@ import { Loader2, MessageSquareText } from 'lucide-react'
import { cn, formatKstDateTime } from '@/lib' import { cn, formatKstDateTime } from '@/lib'
import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type' import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type'
import type { ListItem } from '@/features/list/types' import type { ListItem } from '@/features/list/types'
import { statusMeta, canEnterChat, isEndedStatus, RESULT_META } from '@/features/list/lib/status' import { statusMeta, RESULT_META } from '@/features/list/lib/status'
interface WorkspaceTableProps { interface WorkspaceTableProps {
items: ListItem[] items: ListItem[]
@ -14,14 +14,13 @@ 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, onRejectDetail }: WorkspaceTableProps) { export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo }: 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">
@ -47,7 +46,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} onRejectDetail={onRejectDetail} /> <Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} />
)) ))
)} )}
</tbody> </tbody>
@ -64,7 +63,6 @@ function Row({
onExtraInfo, onExtraInfo,
onRenegotiate, onRenegotiate,
onMemo, onMemo,
onRejectDetail,
}: { }: {
item: ListItem item: ListItem
busy: boolean busy: boolean
@ -73,16 +71,12 @@ 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 canEnter = !['미참여', '협상거부'].includes(item.session_status)
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 = isDone ? '결과 보기' : '협상 입장'
return ( return (
<tr className="border-b border-border/60 transition-colors last:border-0 hover:bg-table-hover/70"> <tr className="border-b border-border/60 transition-colors last:border-0 hover:bg-table-hover/70">
@ -150,22 +144,13 @@ 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"
onClick={() => onReject(item)} onClick={() => onReject(item)}
className="rounded-lg border border-border px-3 py-1.5 text-xs font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]" className="rounded-lg border border-border px-3 py-1.5 text-xs font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]"
> >
협상 거부 거절
</button> </button>
)} )}
{canEnter ? ( {canEnter ? (

View File

@ -10,7 +10,6 @@ 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'
@ -20,11 +19,11 @@ 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'
import { GuidePopup } from '@/features/list/components/GuidePopup' import { GuidePopup } from '@/features/list/components/GuidePopup'
import { isEndedStatus } from '@/features/list/lib/status'
import type { ListItem } from '@/features/list/types' import type { ListItem } from '@/features/list/types'
// 상태별 거부 불가 안내 // 상태별 거부 불가 안내
@ -68,15 +67,9 @@ 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) => {
// 종료 건(완료·미참여·거부)은 열람 전용 — 참여 API 는 미참여/거부를 막으므로 바로 채팅으로 보낸다.
if (isEndedStatus(item.session_status)) {
navigate(`/chat?session_id=${item.session_id}`)
return
}
setEnteringId(item.session_id) setEnteringId(item.session_id)
participate.mutate(item.session_id, { participate.mutate(item.session_id, {
onSuccess: () => navigate(`/chat?session_id=${item.session_id}`), onSuccess: () => navigate(`/chat?session_id=${item.session_id}`),
@ -96,16 +89,13 @@ export function ListWorkspace() {
setRejectTarget(item) setRejectTarget(item)
} }
const handleRejectSubmit = (request: RejectSubmitPayload) => { const handleRejectSubmit = (reason: string) => {
if (!rejectTarget) return if (!rejectTarget) return
reject.mutate( reject.mutate(
{ sessionId: rejectTarget.session_id, request }, { sessionId: rejectTarget.session_id, request: { reject_reason: reason } },
{ {
onSuccess: () => { onSuccess: () => toast.warning('참여 거절이 완료되었습니다.'),
setRejectTarget(null) onError: (error) => toast.error(getApiErrorMessage(error, '거절 처리에 실패했습니다.')),
toast.warning('협상 거부가 완료되었습니다.')
},
onError: (error) => toast.error(getApiErrorMessage(error, '거부 처리에 실패했습니다.')),
}, },
) )
} }
@ -194,7 +184,6 @@ 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">
@ -207,7 +196,6 @@ export function ListWorkspace() {
onExtraInfo={setExtraTarget} onExtraInfo={setExtraTarget}
onRenegotiate={setRenegoTarget} onRenegotiate={setRenegoTarget}
onMemo={setMemoTarget} onMemo={setMemoTarget}
onRejectDetail={setRejectDetailTarget}
/> />
</div> </div>
@ -219,11 +207,7 @@ export function ListWorkspace() {
{guideOpen && <GuidePopup onClose={() => setGuideOpen(false)} />} {guideOpen && <GuidePopup onClose={() => setGuideOpen(false)} />}
{rejectTarget && ( {rejectTarget && (
<RejectPopup <RejectPopup onClose={() => setRejectTarget(null)} onSubmit={handleRejectSubmit} />
onClose={() => setRejectTarget(null)}
onSubmit={handleRejectSubmit}
isPending={reject.isPending}
/>
)} )}
{extraTarget && ( {extraTarget && (
@ -238,16 +222,6 @@ 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)} />
)} )}

View File

@ -42,8 +42,5 @@ export function toListItem(api: SessionListItem): ListItem {
renegotiationStatus: api.renegotiation_status ?? 0, renegotiationStatus: api.renegotiation_status ?? 0,
renegotiationMemo: api.renegotiation_memo ?? '', renegotiationMemo: api.renegotiation_memo ?? '',
result: api.result ?? 0, result: api.result ?? 0,
hasChat: api.has_chat ?? false,
rejectReason: api.reject_reason ?? '',
rejectPrice: api.reject_price ?? null,
} }
} }

View File

@ -11,25 +11,13 @@ export const STATUS_META: Record<string, StatusMeta> = {
협상중: { display: '협상 중', badge: 'bg-[#FFF3E5] text-[#F5A623]', dot: 'bg-[#F5A623]' }, 협상중: { display: '협상 중', badge: 'bg-[#FFF3E5] text-[#F5A623]', dot: 'bg-[#F5A623]' },
협상완료: { display: '협상 완료', badge: 'bg-[#EAFDF3] text-success', dot: 'bg-success' }, 협상완료: { display: '협상 완료', badge: 'bg-[#EAFDF3] text-success', dot: 'bg-success' },
미참여: { display: '협상 미참여', badge: 'bg-neutral-20 text-neutral-60', dot: 'bg-neutral-60' }, 미참여: { display: '협상 미참여', badge: 'bg-neutral-20 text-neutral-60', dot: 'bg-neutral-60' },
협상거부: { display: '협상 거부', badge: 'bg-[#FFEBEB] text-[#FF4D4F]', dot: 'bg-[#FF4D4F]' }, 협상거부: { display: '협상 거절', badge: 'bg-[#FFEBEB] text-[#FF4D4F]', dot: 'bg-[#FF4D4F]' },
} }
export function statusMeta(label: string): StatusMeta { export function statusMeta(label: string): StatusMeta {
return STATUS_META[label] ?? { display: label || '-', badge: 'bg-neutral-20 text-neutral-60', dot: 'bg-neutral-60' } return STATUS_META[label] ?? { display: label || '-', badge: 'bg-neutral-20 text-neutral-60', dot: 'bg-neutral-60' }
} }
// 종료 상태 — 대화를 이어갈 수 없고 지난 이력만 '결과 보기'로 열람한다(백엔드 send 도 협상중만 허용).
const ENDED_STATUSES = ['협상완료', '미참여', '협상거부']
export function isEndedStatus(label: string): boolean {
return ENDED_STATUSES.includes(label)
}
// 채팅 진입 가능 여부. 진행 건은 항상, 종료 건은 볼 대화가 있을 때만 — 초청만 받고 끝난 건은 열어도 빈 화면이다.
export function canEnterChat(label: string, hasChat: boolean): boolean {
return !isEndedStatus(label) || hasChat
}
// 협상 결과(SessionResult 코드) → 완료 건에 붙는 결과 배지. 0(미정)은 표시하지 않는다. // 협상 결과(SessionResult 코드) → 완료 건에 붙는 결과 배지. 0(미정)은 표시하지 않는다.
// 결렬(3)은 재협상 요청 대상이라 눈에 띄게 노랑으로 둔다. // 결렬(3)은 재협상 요청 대상이라 눈에 띄게 노랑으로 둔다.
export const RESULT_META: Record<number, { label: string; badge: string }> = { export const RESULT_META: Record<number, { label: string; badge: string }> = {

View File

@ -13,7 +13,4 @@ export type ListItem = {
renegotiationStatus: number // 0=없음 1=심사중 2=승인 3=반려 4=철회 renegotiationStatus: number // 0=없음 1=심사중 2=승인 3=반려 4=철회
renegotiationMemo: string // 담당자 심사 메모(반려 사유) renegotiationMemo: string // 담당자 심사 메모(반려 사유)
result: number // 협상 결과: 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰) result: number // 협상 결과: 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
hasChat: boolean // 대화 이력 존재 — 종료된 협상의 '결과 보기' 노출 조건
rejectReason: string // 협상 거부로 끝난 건이 제출한 사유. 거부 건이 아니면 ''
rejectPrice: number | null // 거부와 함께 낸 공급 희망 가격(원)
} }

View File

@ -44,7 +44,7 @@ export function MainLayout({
header, header,
children, children,
}: MainLayoutProps) { }: MainLayoutProps) {
const { data: user } = useMeQuery() // 회사 브랜딩(서비스명/로고) 주입 — 색은 솔루션 고정 const { data: user } = useMeQuery() // 회사 브랜딩(서비스명/로고) 주입
const panes = ( const panes = (
<> <>
<aside className={cn(styles.sidebar, SIDEBAR_WIDTH[sidebarWidth])}> <aside className={cn(styles.sidebar, SIDEBAR_WIDTH[sidebarWidth])}>

View File

@ -4,4 +4,3 @@ 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'

View File

@ -4,7 +4,7 @@ import { Logo } from '@/components'
import { LoginForm, usePreLoginBranding } from '@/features/auth' import { LoginForm, usePreLoginBranding } from '@/features/auth'
export function LoginPage() { export function LoginPage() {
// 초청 링크의 session_id(없으면 직전 로그인 캐시)로 회사 브랜딩(서비스명·로고)을 먼저 그린다. // 초청 링크의 session_id(없으면 직전 로그인 캐시)로 회사 브랜딩을 먼저 그린다.
const branding = usePreLoginBranding() const branding = usePreLoginBranding()
// 이미 로그인된 상태면 목록으로 // 이미 로그인된 상태면 목록으로

1
landing/.gitignore vendored
View File

@ -1,4 +1,3 @@
node_modules node_modules
build build
.react-router .react-router
.vercel

View File

@ -1,114 +0,0 @@
import type { VercelRequest, VercelResponse } from "@vercel/node"
/*
* 리드 수집 엔드포인트 (Vercel 서버리스 함수).
*
* 랜딩은 ssr:false 정적 빌드라 서버 라우트가 없다. 그래서 /api 디렉터리의 함수로 받는다.
* 클라이언트는 같은 오리진으로 쏘므로 엔드포인트 환경변수도, CORS 설정도 필요 없다.
*
* ── 리드를 잃지 않는 것이 이 파일의 유일한 책임이다 ──────────────────────────
* 기존 문의 폼은 1.2초 뒤 성공 화면만 띄우고 아무 데도 보내지 않았다. 화면은
* "접수되었습니다"인데 영업이 받을 리드는 없고, 아무도 그 사실을 몰랐다.
*
* 그래서 순서를 이렇게 잡는다.
* 1) 먼저 구조화된 로그를 남긴다. 웹훅이 죽어도 Vercel 로그에 리드가 남는다.
* 2) LEAD_WEBHOOK_URL 이 있으면 그리로 전달한다(Slack Incoming Webhook·Zapier·자체 API 모두 POST 를 받는다).
* 3) 웹훅 전달이 실패해도 200 을 준다 — 1) 에서 이미 리드를 확보했으므로 방문자에게
* 다시 입력하라고 할 이유가 없다. 대신 실패는 로그에 크게 남긴다.
*
* 로그는 CRM 이 아니라 최후의 그물이다. LEAD_WEBHOOK_URL 은 반드시 설정해야 한다.
*/
const MAX_FIELD = 500
type LeadBody = {
source?: string
name?: string
email?: string
company?: string
phone?: string
message?: string
/* 봇 함정. 사람 눈에 안 보이는 필드라 값이 차 있으면 자동 제출이다. */
website?: string
}
const clean = (v: unknown) => (typeof v === "string" ? v.trim().slice(0, MAX_FIELD) : "")
/** 느슨한 검사. 정규식으로 이메일을 엄밀히 검증하려는 시도는 늘 진짜 주소를 막는다. */
const emailLike = (v: string) => v.length >= 5 && v.includes("@") && !v.startsWith("@") && !v.endsWith("@") && !/\s/.test(v)
export default async function handler(req: VercelRequest, res: VercelResponse) {
if (req.method !== "POST") {
res.setHeader("Allow", "POST")
return res.status(405).json({ ok: false, error: "method_not_allowed" })
}
const body = (typeof req.body === "string" ? safeParse(req.body) : req.body) as LeadBody | null
if (!body) return res.status(400).json({ ok: false, error: "invalid_json" })
// 봇은 조용히 돌려보낸다. 400 을 주면 어떤 필드가 함정인지 알려주는 셈이다.
if (clean(body.website)) return res.status(200).json({ ok: true })
const lead = {
source: clean(body.source) || "unknown",
name: clean(body.name),
email: clean(body.email),
company: clean(body.company),
phone: clean(body.phone),
message: clean(body.message),
submittedAt: new Date().toISOString(),
userAgent: clean(req.headers["user-agent"]),
}
if (!lead.name || !emailLike(lead.email)) {
return res.status(400).json({ ok: false, error: "invalid_input" })
}
// 1) 무슨 일이 있어도 먼저 남긴다.
console.log("[lead]", JSON.stringify(lead))
// 2) 사람이 실제로 보는 곳으로 전달.
const webhook = process.env.LEAD_WEBHOOK_URL
if (!webhook) {
console.warn("[lead] LEAD_WEBHOOK_URL 미설정 — 리드가 로그에만 남습니다. 웹훅을 설정하세요.")
return res.status(200).json({ ok: true, delivered: false })
}
try {
const upstream = await fetch(webhook, {
method: "POST",
headers: { "Content-Type": "application/json" },
// Slack Incoming Webhook 은 text 를 읽고, 나머지 수신처는 보통 원본 필드를 읽는다.
// 둘 다 담아 보내면 수신처를 바꿀 때 이 파일을 고칠 일이 없다.
body: JSON.stringify({ text: summarize(lead), ...lead }),
})
if (!upstream.ok) {
console.error("[lead] 웹훅 전달 실패", upstream.status, JSON.stringify(lead))
return res.status(200).json({ ok: true, delivered: false })
}
return res.status(200).json({ ok: true, delivered: true })
} catch (e) {
console.error("[lead] 웹훅 예외", e, JSON.stringify(lead))
return res.status(200).json({ ok: true, delivered: false })
}
}
function safeParse(s: string) {
try {
return JSON.parse(s)
} catch {
return null
}
}
function summarize(l: { source: string; name: string; email: string; company: string; phone: string; message: string }) {
const rows = [
`*새 리드* (${l.source})`,
`이름: ${l.name}`,
`이메일: ${l.email}`,
l.company && `회사: ${l.company}`,
l.phone && `연락처: ${l.phone}`,
l.message && `내용: ${l.message}`,
].filter(Boolean)
return rows.join("\n")
}

View File

@ -10,112 +10,38 @@
src: url('/fonts/PretendardVariable.woff2') format('woff2'); src: url('/fonts/PretendardVariable.woff2') format('woff2');
} }
/* 영문 디스플레이 서체 — 이탤릭 가변(400~900), latin 서브셋.
INFINITH 디자인 시스템과 같은 Playfair Display 를 쓴다. */
@font-face {
font-family: 'Playfair Display';
font-style: italic;
font-weight: 400 900;
font-display: swap;
src: url('/fonts/PlayfairDisplay-Italic.woff2') format('woff2');
}
/* /*
* 랜딩 디자인 토큰. * 랜딩 디자인 토큰 (Toss 스타일 라이트 팔레트).
* 색은 전부 여기서만 정의하고, 컴포넌트는 토큰 클래스(text-ink, bg-primary …)만 쓴다. * 색은 전부 여기서만 정의하고, 컴포넌트는 토큰 클래스(text-ink, bg-primary …)만 쓴다.
*
* 구조는 두 겹이다.
* - 다크 무대(stage) : 히어로·협상 데모. 협상이 벌어지는 순간에만 조명을 끈다.
* - 라이트 본문 : 나머지 섹션. 광고 유입 전환율을 위해 밝게 유지.
*/ */
@theme { @theme {
--font-sans: 'Pretendard Variable', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif; --font-sans: 'Pretendard Variable', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
/* 영문 디스플레이 — 라벨·강조 전용. 한글 본문은 절대 이걸로 쓰지 않는다
(Playfair 는 한글 글리프가 없어 Pretendard 로 폴백되며 톤이 깨진다). */
--font-display: 'Playfair Display', 'Pretendard Variable', Georgia, serif;
/* ── 브랜드 액센트 ────────────────────────────────────────────── /* 브랜드 */
여기 3줄이 브랜드 색상각의 단일 소스다. 딥틸(#005961 / #00434A / #E3F4F5)로 --color-primary: #476EFF;
되돌리려면 이 3줄만 갈아끼우면 되고, 나머지 토큰·컴포넌트는 손대지 않는다. */ --color-primary-deep: #2F52E0; /* hover */
--color-primary: #0101F3; /* 일렉트릭 블루 — 라이트 배경 전용. 레퍼런스 실측값 */ --color-primary-soft: #ECF0FF; /* 연한 파랑 배경 — 봇 말풍선, 배지 */
/* 다크 무대용 액센트. #0101F3 을 #070E24 위에 얹으면 명암비가 2.0:1 로,
WCAG 최소(4.5:1)의 절반도 안 된다 — 둘 다 어두운 색이라 그렇다.
아래 값은 무대 배경 대비 약 6:1 이다. 무대 위 파란 글자는 반드시 이걸 쓴다. */
--color-primary-on-stage: #7C8CFF;
--color-primary-deep: #0101C4; /* hover */
--color-primary-soft: #EAEAFF; /* 연한 배경 — 에이전트 말풍선, 배지 */
/* ── 상대편(공급사) ──────────────────────────────────────────── /* 텍스트. ink 는 다크 섹션에선 배경색으로도 쓴다 */
협상은 두 주체가 매 턴 구분돼야 읽힌다. 민트는 블루와 색상각 70° 차이라 --color-ink: #191F28;
구분되면서도 같은 한색 계열이라 화면이 따로 놀지 않는다. --color-ink-soft: #4E5968; /* 보조 본문 */
NEGO WIZ 로고 민트(#0FFFD6)에서 왔다. */ --color-ink-muted: #8B95A1; /* 캡션 */
--color-counter: #0FFFD6; /* 무대 위 상대측 텍스트·강조 */ --color-ink-faint: #B0B8C1; /* 플레이스홀더·각주 */
--color-counter-deep: #077A66; /* 라이트 배경 위 텍스트 (흰 바탕 대비 확보용) */
--color-counter-surface: #0A2F35; /* 무대 위 상대측 말풍선 바닥 */
/* ── 다크 무대 ────────────────────────────────────────────────── */
--color-stage: #070E24; /* 무대 바닥 (그라데이션의 중간값) */
--color-stage-deep: #03060F; /* 무대 가장자리 — 어두워지며 공간이 뒤로 물러난다 */
--color-stage-lift: #16205A; /* 무대 상단 광원 — 여기서 빛이 온다 */
--color-stage-raised: #101A3D; /* 무대 위 카드·상대측 말풍선 */
--color-stage-line: #1E2A52; /* 무대 위 테두리 */
--color-on-stage: #FFFFFF; /* 무대 위 1차 텍스트 */
/* 2·3차 텍스트는 중립 회색 대신 연보라 계열로 둔다. 네이비 위에 색상각이 다른
회색을 얹으면 탁해지는데, 같은 한색 가족이면 밝게 올려도 화면이 정돈된다.
#F5F3FF 는 ADO2 디자인 시스템의 연보라 패널 색이다. */
--color-on-stage-soft: #F5F3FF; /* 무대 위 2차 텍스트 — 2톤 헤드라인의 약한 쪽 */
--color-on-stage-muted: #C6C0E4; /* 무대 위 라벨·캡션 */
/* ── 라이트 본문 텍스트 ────────────────────────────────────────
먹색에 브랜드 색조를 섞어둔다. 순수 회색이면 페이지가 따로 논다. */
--color-ink: #0B1024;
--color-ink-soft: #454E6B; /* 보조 본문 */
--color-ink-muted: #838CAA; /* 캡션 */
--color-ink-faint: #AAB1C7; /* 플레이스홀더·각주 */
/* 면·선 */ /* 면·선 */
--color-surface: #F7F8FC; /* 회색 섹션 배경 */ --color-surface: #F9FAFB; /* 회색 섹션 배경 */
--color-surface-neu: #F1F3F9; /* 보조 섹션 배경 */ --color-surface-neu: #F2F4F7; /* 뉴모피즘 히어로 전용 배경 */
--color-fill: #F0F2F8; /* 회색 채움 — 탭 트랙, 보조 버튼 */ --color-fill: #F2F4F6; /* 회색 채움 — 탭 트랙, 보조 버튼 */
--color-fill-hover: #E6E9F2; --color-fill-hover: #EAECEF;
--color-line: #EEF0F6; /* 섹션 구분선 */ --color-line: #F2F4F6; /* 섹션 구분선 */
--color-line-strong: #E0E4EE; /* 카드 테두리 */ --color-line-strong: #E5E8EB; /* 카드 테두리 */
--color-positive: #0B8F57; /* 낙찰·성공 강조 */ --color-positive: #0B8F57; /* 낙찰·성공 강조 */
--color-positive-soft: #E8F7EF; /* 연한 초록 배경 — 낙찰 배지 */ --color-positive-soft: #E8F7EF; /* 연한 초록 배경 — 낙찰 배지 */
--color-accent: #8F52FF; /* 보라 강조 — 결과 탭·히어로 장식 */ --color-accent: #8F52FF; /* 보라 강조 — 결과 탭·히어로 장식 */
--color-accent-soft: #F4EFFF; /* 연한 보라 배경 */ --color-accent-soft: #F4EFFF; /* 연한 보라 배경 */
/* ── 모서리 ────────────────────────────────────────────────────
레퍼런스 실측은 3.5~7px 이다. 20~32px 짜리 뭉툭한 카드가 제네릭 SaaS 의
가장 강한 신호라, 여기서는 세 값으로만 고정한다.
card — 카드·패널·목업
control — 탭·배지·입력 등 작은 요소
full — 버튼·아바타 (Tailwind 기본 rounded-full 사용) */
--radius-card: 8px;
--radius-control: 4px;
}
/* 다크 무대 배경 그라데이션.
단색 배경은 아무리 입자에 원근을 줘도 평면으로 읽힌다. 위쪽에 광원을 두고
가장자리로 갈수록 어두워지게 하면 화면 자체에 안팎이 생겨 깊이가 성립한다. */
.stage-bg {
background:
radial-gradient(120% 78% at 50% 8%, var(--color-stage-lift) 0%, transparent 62%),
radial-gradient(100% 100% at 50% 42%, var(--color-stage) 0%, var(--color-stage-deep) 100%);
}
/* 드래그 선택 색.
루트에 걸린 selection:text-primary 는 라이트 본문 기준이다. 그 값(#0101F3)을 다크 무대
위에 얹으면 명암비가 2.0:1 이라 — 위 --color-primary-on-stage 주석이 경고하는 바로 그
상황 — 무대 섹션에서 텍스트를 드래그하면 글자가 사라진다.
무대 안에서는 무대용 액센트로 갈아끼운다. 라이트 본문은 기존 그대로. */
.stage-bg ::selection,
[data-stage-hero] ::selection {
background-color: color-mix(in srgb, var(--color-primary-on-stage) 30%, transparent);
color: var(--color-on-stage);
} }
html { html {

View File

@ -1,9 +1,9 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { AnimatePresence, motion } from "motion/react" import { AnimatePresence, motion } from "motion/react"
import { Clock, Database, Handshake, Scale, TrendingUp, UserRound, type LucideIcon } from "lucide-react" import { Bot, Clock, Database, Handshake, Scale, TrendingUp, UserRound, type LucideIcon } from "lucide-react"
import { Section } from "@/components/ui/section" import { Section } from "@/components/ui/section"
import { CONTROL_GAP, SectionHeading } from "@/components/ui/section-heading" import { SectionHeading } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography" import { Typography } from "@/components/ui/typography"
import { useFadeUp } from "@/lib/motion" import { useFadeUp } from "@/lib/motion"
@ -44,31 +44,28 @@ export function Comparison() {
return ( return (
<Section id="comparison"> <Section id="comparison">
<motion.div {...fadeUp}> <motion.div {...fadeUp} className="mb-12">
<SectionHeading <SectionHeading
align="center" align="center"
eyebrow="Before & After" eyebrow="WHY NEGOTIUM"
title="협상 방식을 바꾸면, 무엇이 달라질까요" title="협상 방식을 바꾸면, 무엇이 달라질까요"
description="기존 가격 협상의 문제는 사람이 아니라 구조입니다." description="기존 가격 협상의 문제는 사람이 아니라 구조입니다."
descriptionClassName="max-w-2xl" descriptionClassName="mt-4 text-[17px] max-w-2xl"
/> />
</motion.div> </motion.div>
{/* 모드 토글 — 이용 가이드 탭과 같은 세그먼트 문법. {/* 모드 토글 — 이용 가이드 탭과 같은 세그먼트 문법 */}
아래 간격(CONTROL_GAP)은 머리 간격보다 좁다. 토글은 자기가 조종하는 <motion.div {...toggleFadeUp} className="flex justify-center mb-14">
카드들과 한 덩어리로 읽혀야지, 제목에 붙으면 무엇을 바꾸는 스위치인지 흐려진다. */} <div className="p-1 bg-fill rounded-[14px] inline-flex gap-1">
<motion.div {...toggleFadeUp} className={`flex justify-center ${CONTROL_GAP}`}>
{/* 액티브를 bg-white 로 두면 트랙(bg-fill #F0F2F8) 과 거의 같은 밝기라
멀리서 어느 쪽이 켜졌는지 안 보인다. 채움색으로 확실히 갈라준다. */}
<div className="p-1.5 bg-fill rounded-full inline-flex gap-1.5">
{MODES.map((m) => ( {MODES.map((m) => (
<button <button
key={m.key} key={m.key}
onClick={() => selectMode(m.key)} onClick={() => selectMode(m.key)}
className={`px-7 py-3 rounded-full text-[15px] font-semibold transition-colors cursor-pointer ${ className={`px-5 py-2.5 rounded-[10px] text-xs sm:text-sm font-bold transition-all flex items-center gap-2 cursor-pointer ${
mode === m.key ? "bg-primary text-white" : "text-ink-muted hover:text-ink" mode === m.key ? "bg-white text-primary shadow-sm" : "text-ink-soft hover:text-ink"
}`} }`}
> >
{m.key === "after" && <Bot className="w-4 h-4" />}
<span>{m.label}</span> <span>{m.label}</span>
</button> </button>
))} ))}
@ -88,7 +85,7 @@ type Mode = "before" | "after"
const MODES: { key: Mode; label: string }[] = [ const MODES: { key: Mode; label: string }[] = [
{ key: "before", label: "기존 방식" }, { key: "before", label: "기존 방식" },
{ key: "after", label: "네고시움 도입 후" }, { key: "after", label: "negotium 도입 후" },
] ]
type CompareItem = { label: string; icon: LucideIcon; before: string; after: string } type CompareItem = { label: string; icon: LucideIcon; before: string; after: string }
@ -100,13 +97,13 @@ function CompareCard({ item, mode, index }: { item: CompareItem; mode: Mode; ind
return ( return (
<motion.div <motion.div
{...fadeUp} {...fadeUp}
className={`p-7 rounded-card flex flex-col gap-5 transition-colors duration-500 ${ className={`p-7 rounded-[28px] flex flex-col gap-5 transition-colors duration-500 ${
active ? "bg-primary-soft/60" : "bg-surface" active ? "bg-primary-soft/60" : "bg-surface"
}`} }`}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div <div
className={`w-11 h-11 rounded-card flex items-center justify-center transition-colors duration-500 ${ className={`w-11 h-11 rounded-2xl flex items-center justify-center transition-colors duration-500 ${
active ? "bg-primary/10 text-primary" : "bg-ink-muted/10 text-ink-muted" active ? "bg-primary/10 text-primary" : "bg-ink-muted/10 text-ink-muted"
}`} }`}
> >
@ -159,14 +156,14 @@ const ITEMS: CompareItem[] = [
{ {
label: "사람", label: "사람",
icon: UserRound, icon: UserRound,
before: "반복 협상에 시간을 뺏겨 전략 업무가 계속 밀립니다.", before: "반복 흥정과 감정 노동에 지쳐 고급 인력이 이탈합니다.",
after: "반복 협상은 에이전트가 맡고, 담당자는 전략 구매와 대형 건에 집중합니다.", after: "감정 소모는 봇이 맡고, 사람은 전략 업무와 대형 거래에 집중합니다.",
}, },
{ {
label: "파트너 관계", label: "파트너 관계",
icon: Handshake, icon: Handshake,
before: "담당자마다 기준이 달라 협력사가 조건을 예측하기 어렵습니다.", before: "감정 과열과 실랑이로 장기 파트너십이 흔들립니다.",
after: "에이전트가 같은 기준으로 상시 응대해 조건이 일관됩니다.", after: "악역은 봇이 — 압박 없는 상시 협상으로 관계가 개선됩니다.",
}, },
{ {
label: "의사 결정", label: "의사 결정",

View File

@ -1,6 +1,6 @@
import { useState } from "react" import { useState } from "react"
import { AnimatePresence, motion } from "motion/react" import { AnimatePresence, motion } from "motion/react"
import { Check, Loader2 } from "lucide-react" import { Building, CheckCircle2, Loader2, Mail, Phone, Send } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input, Textarea } from "@/components/ui/input" import { Input, Textarea } from "@/components/ui/input"
@ -8,7 +8,7 @@ import { Section } from "@/components/ui/section"
import { SectionHeading } from "@/components/ui/section-heading" import { SectionHeading } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography" import { Typography } from "@/components/ui/typography"
import { EASE_OUT_EXPO } from "@/lib/motion" import { EASE_OUT_EXPO } from "@/lib/motion"
import { submitLead } from "@/lib/lead" import type { LucideIcon } from "lucide-react"
const EMPTY_FORM = { const EMPTY_FORM = {
companyName: '', companyName: '',
@ -18,33 +18,19 @@ const EMPTY_FORM = {
message: '', message: '',
} }
/* /** 도입 문의 폼. 백엔드 미연결 — 제출은 데모 처리(1.2초 후 성공 화면). */
* 상담 신청 폼 — 고관여 경로. 저관여 리드 캡처는 데모 요청 모달이 맡는다.
*
* 예전에는 백엔드가 없어서 1.2초 뒤 성공 화면만 띄웠다. 화면은 "접수되었습니다"인데
* 영업이 받을 리드는 존재하지 않았고, 아무도 그 사실을 몰랐다. 지금은 데모 요청과
* 같은 서버리스 함수(api/lead.ts)로 보내고, 서버가 리드를 확보했을 때만 성공을 띄운다.
*/
export function Contact() { export function Contact() {
const [formData, setFormData] = useState(EMPTY_FORM) const [formData, setFormData] = useState(EMPTY_FORM)
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle') const [status, setStatus] = useState<'idle' | 'submitting' | 'success'>('idle')
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!formData.companyName || !formData.contactName || !formData.email || !formData.phone) { if (!formData.companyName || !formData.contactName || !formData.email || !formData.phone) {
return return
} }
setStatus('submitting') setStatus('submitting')
const result = await submitLead({ setTimeout(() => setStatus('success'), 1200)
source: 'contact',
name: formData.contactName,
email: formData.email,
company: formData.companyName,
phone: formData.phone,
message: formData.message,
})
setStatus(result.ok ? 'success' : 'error')
} }
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
@ -55,12 +41,22 @@ export function Contact() {
const submitting = status === 'submitting' const submitting = status === 'submitting'
return ( return (
<Section id="contact-section" bordered> <Section
id="contact-section"
bordered
decor={
<>
<div className="absolute top-[20%] right-[10%] w-72 h-72 bg-primary/5 blur-[100px] rounded-full pointer-events-none" />
<div className="absolute bottom-[10%] left-[5%] w-96 h-96 bg-primary/3 blur-[120px] rounded-full pointer-events-none" />
</>
}
>
<SectionHeading <SectionHeading
align="center" align="center"
eyebrow="Get in Touch" className="mb-16"
title="도입 문의" eyebrow="GET IN TOUCH"
description="품목과 협력사 규모를 알려주시면, 예상 절감 구간과 도입 절차를 정리해 드립니다." title="도입 문의 및 컨택하기"
description="사내 ERP 연동부터 우리 기업에 맞춘 흥정 시나리오 구성까지, negotium의 구매 혁신 컨설턴트가 상세히 안내해 드립니다."
descriptionClassName="max-w-xl" descriptionClassName="max-w-xl"
/> />
@ -74,10 +70,10 @@ export function Contact() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }} exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.5, ease: EASE_OUT_EXPO }} transition={{ duration: 0.5, ease: EASE_OUT_EXPO }}
className="space-y-9" className="space-y-6"
> >
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-9"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<FormField label="회사명" required> <FormField label="회사명" icon={Building} required>
<Input <Input
type="text" type="text"
name="companyName" name="companyName"
@ -102,8 +98,8 @@ export function Contact() {
</FormField> </FormField>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-9"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<FormField label="이메일 주소" required> <FormField label="이메일 주소" icon={Mail} required>
<Input <Input
type="email" type="email"
name="email" name="email"
@ -115,7 +111,7 @@ export function Contact() {
/> />
</FormField> </FormField>
<FormField label="연락처" required> <FormField label="연락처" icon={Phone} required>
<Input <Input
type="tel" type="tel"
name="phone" name="phone"
@ -132,34 +128,30 @@ export function Contact() {
<Textarea <Textarea
name="message" name="message"
rows={4} rows={4}
placeholder="협상 자동화를 검토 중인 품목이나 현재 겪는 어려움을 적어주세요." placeholder="현재 겪고 계신 구매 조율 상의 번거로움이나, 자동화를 원하시는 구체적인 부자재 품목 정보를 남겨주시면 더욱 맞춤화된 상담이 가능합니다."
value={formData.message} value={formData.message}
onChange={handleChange} onChange={handleChange}
disabled={submitting} disabled={submitting}
/> />
</FormField> </FormField>
{/* 대체 연락처(이메일 등)를 넣으려면 실제 영업 주소를 받아서 채워야 한다.
임의의 주소를 공개 랜딩에 박아둘 수는 없다. */}
{status === 'error' && (
<p role="alert" className="text-[13px] text-ink-soft leading-[1.6] break-keep">
지금은 접수가 어렵습니다. 잠시 후 다시 시도해 주세요.
</p>
)}
<Typography variant="caption" className="font-medium"> <Typography variant="caption" className="font-medium">
입력하신 정보는 문의 응답과 도입 검토 목적으로만 사용됩니다. * 제출 시 입력하신 정보는 문의 사항 응답 및 도입 검토 목적에 한하여 사용되며, 관련 법령에 의거하여 안전하게
보호됩니다.
</Typography> </Typography>
<div className="pt-2"> <div className="pt-2">
<Button type="submit" disabled={submitting} size="lg" className="w-full"> <Button type="submit" disabled={submitting} className="w-full shadow-sm hover:shadow-md">
{submitting ? ( {submitting ? (
<> <>
<Loader2 className="w-5 h-5 animate-spin" /> <Loader2 className="w-5 h-5 animate-spin" />
<span>신청서 전송 중...</span> <span>신청서 전송 중...</span>
</> </>
) : ( ) : (
<span>상담 신청</span> <>
<Send className="w-5 h-5" />
<span>도입 문의 및 상담 신청하기</span>
</>
)} )}
</Button> </Button>
</div> </div>
@ -170,14 +162,15 @@ export function Contact() {
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, ease: EASE_OUT_EXPO }} transition={{ duration: 0.6, ease: EASE_OUT_EXPO }}
className="text-center py-20 px-6 border border-line-strong rounded-card text-ink break-keep" className="text-center py-16 px-6 bg-primary/5 rounded-[32px] text-ink break-keep"
> >
<div className="w-12 h-12 bg-primary text-white rounded-full flex items-center justify-center mx-auto mb-6"> <div className="w-16 h-16 bg-primary/10 text-primary rounded-full flex items-center justify-center mx-auto mb-6">
<Check className="w-6 h-6" strokeWidth={2.5} /> <CheckCircle2 className="w-8 h-8" />
</div> </div>
<h3 className="text-[24px] font-semibold tracking-[-0.02em] text-ink mb-3">메일 앱에서 문의 내용을 전송해 주세요</h3> <h3 className="text-2xl font-extrabold text-ink mb-3">도입 문의 신청이 접수되었습니다</h3>
<p className="text-ink-soft text-[15px] leading-[1.6] max-w-md mx-auto mb-8"> <p className="text-ink-soft font-semibold text-[15px] leading-relaxed max-w-md mx-auto mb-8">
도입 문의 메일 작성 창을 열었습니다. 내용을 확인하고 전송해 주시면, {formData.contactName} 님께 영업일 기준 하루 안에 연락드리겠습니다. 작성해주신 담당자 정보({formData.contactName} 님)를 통해 24시간 이내에 전담 혁신 컨설턴트가 유선 혹은 이메일로
연락을 드리겠습니다.
</p> </p>
<Button <Button
type="button" type="button"
@ -197,14 +190,22 @@ export function Contact() {
) )
} }
/* 라벨 옆 작은 아이콘은 정보를 더하지 않는다 — "회사명" 옆의 빌딩 아이콘이 알려주는 건 function FormField({
이미 글자가 말한 것뿐이고, 필드마다 반복되면 폼이 산만해진다. 글자만 남긴다. */ label,
function FormField({ label, required = false, children }: { label: string; required?: boolean; children: React.ReactNode }) { icon: Icon,
required = false,
children,
}: {
label: string
icon?: LucideIcon
required?: boolean
children: React.ReactNode
}) {
return ( return (
<div className="space-y-2.5"> <div className="space-y-2">
<label className="block text-[14px] font-medium text-ink"> <label className="text-xs font-bold text-ink-soft flex items-center gap-1.5">
{label} {Icon && <Icon className="w-3.5 h-3.5 text-ink-muted" />}
{required && <span className="text-primary ml-1" aria-hidden>*</span>} {label} {required && <span className="text-primary">*</span>}
</label> </label>
{children} {children}
</div> </div>

View File

@ -12,12 +12,13 @@ export function CoreValues() {
return ( return (
<Section id="core-values"> <Section id="core-values">
<motion.div {...fadeUp}> <motion.div {...fadeUp} className="mb-24">
<SectionHeading <SectionHeading
align="center" align="center"
eyebrow="Beyond Price" eyebrow="BEYOND EXPECTED VALUE"
title="가격 그 이상의 이점" title="가격 그 이상의 이점"
description="절감액은 시작일 뿐 — 관계, 투명성, 그리고 사람의 시간까지 지킵니다." description="절감액은 시작일 뿐 — 관계, 투명성, 그리고 사람의 시간까지 지킵니다."
descriptionClassName="mt-4 text-[17px]"
/> />
</motion.div> </motion.div>
@ -36,7 +37,7 @@ function ValueStrip({ value, delay }: { value: Value; delay: number }) {
const fadeUp = useFadeUp(delay) const fadeUp = useFadeUp(delay)
return ( return (
<motion.div {...fadeUp} className="bg-surface p-8 rounded-card flex items-start gap-5 transition-all hover:translate-y-[-4px]"> <motion.div {...fadeUp} className="bg-surface p-8 rounded-[32px] flex items-start gap-5 transition-all hover:translate-y-[-4px]">
<div className="w-10 h-10 rounded-full bg-primary/5 flex items-center justify-center text-primary shrink-0 mt-1"> <div className="w-10 h-10 rounded-full bg-primary/5 flex items-center justify-center text-primary shrink-0 mt-1">
<value.icon className="w-5 h-5" /> <value.icon className="w-5 h-5" />
</div> </div>
@ -55,7 +56,7 @@ const VALUES: Value[] = [
icon: Users, icon: Users,
title: "파트너사 관계 수호", title: "파트너사 관계 수호",
description: description:
"단가 조율은 에이전트가 정해진 기준으로 반복합니다. 담당자는 협력사와의 신뢰와 동반 성장에 집중할 수 있습니다.", "단가를 깎는 악역과 감정 소모는 봇이 맡습니다. 담당자는 협력사와의 신뢰와 동반 성장, 큰 틀의 파트너십에만 집중하세요.",
}, },
{ {
icon: Lock, icon: Lock,
@ -67,7 +68,7 @@ const VALUES: Value[] = [
icon: Briefcase, icon: Briefcase,
title: "핵심 전략에만 집중", title: "핵심 전략에만 집중",
description: description:
"이메일·메신저로 오가던 단가 협의는 에이전트가 전담합니다. 인력은 공급망 위기 대응과 신규 공급처 발굴에 투입하세요.", "이메일·메신저로 반복되던 흥정 수작업은 봇이 전담합니다. 인력은 공급망 위기 대처, 우량 공급처 발굴 같은 전략 업무와 대형 거래에 투입하세요.",
}, },
{ {
icon: Clock, icon: Clock,

View File

@ -14,12 +14,13 @@ export function Faq() {
return ( return (
<Section id="faq" width="sm"> <Section id="faq" width="sm">
<motion.div {...fadeUp}> <motion.div {...fadeUp} className="mb-24">
<SectionHeading <SectionHeading
align="center" align="center"
eyebrow="FAQ" eyebrow="FAQ"
title="자주 묻는 질문" title="자주 묻는 질문"
description="도입 검토에서 가장 많이 받는 질문들입니다." description="도입 검토에서 가장 많이 받는 질문들입니다."
descriptionClassName="mt-4 text-[17px]"
/> />
</motion.div> </motion.div>
@ -54,7 +55,7 @@ function FaqItem({
const fadeUp = useFadeUp(delay) const fadeUp = useFadeUp(delay)
return ( return (
<motion.div {...fadeUp} className="bg-surface rounded-card overflow-hidden"> <motion.div {...fadeUp} className="bg-surface rounded-[28px] overflow-hidden">
<button <button
type="button" type="button"
onClick={onToggle} onClick={onToggle}
@ -95,14 +96,14 @@ function FaqItem({
const FAQS: FaqEntry[] = [ const FAQS: FaqEntry[] = [
{ {
question: "협력사가 에이전트와의 협상을 꺼리지 않을까요?", question: "공급사가 봇과의 협상을 싫어하지 않을까요?",
answer: answer:
"이 건들 대부분은 지금껏 협상 테이블에 오르지도 못하던 거래입니다. 에이전트는 협력사가 편한 시간에, 늘 같은 기준으로 응대합니다. 글로벌 동종 서비스의 공급사 만족도는 82%로 보고됩니다.", "오히려 반대입니다. 이 거래들 대부분은 지금껏 협상 테이블에 오르지도 못하던 건입니다. 봇은 24시간 원하는 시간에, 압박 없이, 늘 같은 기준으로 응대합니다. 글로벌 동종 서비스의 공급사 만족도는 82%에 이릅니다.",
}, },
{ {
question: "에이전트가 우리 기준을 벗어나 합의해 버리면요?", question: "봇이 우리 기준을 벗어나 합의해 버리면요?",
answer: answer:
"그럴 수 없습니다. 에이전트는 견적을 만들 때 정한 목표가와 낙찰 기준 밖으로 나가지 않고, 최종 낙찰 규칙도 사용자가 정합니다.", "그럴 수 없습니다. 봇은 견적을 만들 때 정한 목표가와 낙찰 기준 밖으로 나가지 않고, 최종 낙찰 규칙도 사용자가 정합니다.",
}, },
{ {
question: "기존 시스템과 연동되나요?", question: "기존 시스템과 연동되나요?",

View File

@ -2,71 +2,58 @@ import { motion } from "motion/react"
import { ArrowRight } from "lucide-react" import { ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { NegotiationReplay } from "@/components/ui/negotiation-replay"
import { Section } from "@/components/ui/section"
import { CTA_GAP, SectionHeading } from "@/components/ui/section-heading"
import { useFadeUp } from "@/lib/motion"
/** /** 최종 CTA — 다크 몰입 섹션. */
* 최종 CTA — 히어로와 같은 다크 무대로 페이지를 닫는다.
*
* 이 섹션은 원래 디자인 시스템을 우회하고 있었다: bg-ink(무대 색이 아님), font-extrabold,
* 알약 배지, blur(120px) 글로우 블롭, 다크 배경 위 라이트 토큰(text-ink-muted).
* 전부 토큰으로 돌렸다. 글로우 블롭은 AI 가 만든 히어로의 대표 장식이라 걷어냈다.
*
* 섹션 껍데기와 머리도 손으로 짜던 걸 Section/SectionHeading 으로 돌렸다.
* 우회하는 동안 아이브로우 간격이 여기만 20px 이었다(다른 섹션은 12px).
*
* 전환을 요청하는 자리에 결과 화면을 같이 둔다. 이용 가이드는 "실제 화면으로 보여드립니다"
* 라고 약속해서 재구성 목업을 넣을 수 없지만, 여기는 그 약속이 없어 마케팅 컴포짓이 맞는 자리다.
*/
export function FinalCTA() { export function FinalCTA() {
const fadeUp = useFadeUp()
const mediaFadeUp = useFadeUp(0.12)
return ( return (
/* 영상이 붙으면서 width 를 md(max-w-4xl) → lg(max-w-5xl) 로 넓혔다. <section className="relative bg-ink text-white py-36 md:py-48 overflow-hidden">
세로 목업과 카피를 4xl 안에 2단으로 밀어넣으면 양쪽 다 좁아진다. */ {/* 다크 섹션 중앙의 은은한 파란 글로우 */}
<Section bg="stage" width="lg"> <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] rounded-full bg-primary/15 blur-[120px] pointer-events-none" />
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16 items-center">
<motion.div {...fadeUp} className="lg:col-span-7 text-center lg:text-left">
<SectionHeading
align="left"
className="text-center lg:text-left"
tone="stage"
size="display"
gap="none"
eyebrow="Start Today"
/* 닫는 CTA 라 히어로(74px)보다는 낮추고 섹션 제목(64px)보다는 세운다.
2단이 되면서 한 줄이 짧아져 lg 부터 한 단계 더 줄인다. */
titleClassName="text-[32px] sm:text-[44px] md:text-[56px] lg:text-[48px]"
title={
<>
<span className="text-white">지금도 구매 예산은</span>
<br />
<span className="text-on-stage-soft/60">조용히 새고 있을지 모릅니다</span>
</>
}
description="협상 테이블에 오르지 못하고 그냥 넘어가던 건들이 있습니다. 네고시움 에이전트가 정해진 기준 안에서 그 건들을 대신 조율합니다."
descriptionClassName="max-w-xl text-on-stage-soft/75 mx-auto lg:mx-0"
/>
<div className={`${CTA_GAP} flex justify-center lg:justify-start`}> <div className="max-w-3xl mx-auto px-6 text-center relative z-10">
<Button href="#contact-section" variant="stage" size="xl" className="group"> <motion.div
<span>도입 상담 신청</span> initial={{ opacity: 0, scale: 0.95 }}
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" /> whileInView={{ opacity: 1, scale: 1 }}
</Button> viewport={{ once: true }}
</div> className="inline-flex items-center gap-2 px-4 py-1.5 bg-primary/10 border border-primary/20 rounded-full text-xs font-bold text-primary mb-8"
>
<span>START RECLAIMING YOUR BUDGET TODAY</span>
</motion.div> </motion.div>
{/* 결과 화면. 예전엔 negotiation_annotated_3d.mp4 였는데 컴포넌트로 바꿨다 — <motion.h2
영상은 화자("아이마켓 구매 MD")와 색이 픽셀에 구워져 있어 둘 다 못 고쳤다. initial={{ opacity: 0, y: 24 }}
다크 무대에 네이티브로 그리니 밝은 영상이 구멍처럼 뜨던 문제도 같이 사라져서, whileInView={{ opacity: 1, y: 0 }}
카드로 감싸고 그림자로 띄우던 처리가 더는 필요 없다. */} viewport={{ once: true }}
<motion.div {...mediaFadeUp} className="lg:col-span-5"> transition={{ duration: 0.8, delay: 0.1 }}
<NegotiationReplay className="mx-auto max-w-[360px] lg:max-w-none" /> className="text-3xl sm:text-4xl md:text-[54px] font-extrabold tracking-tighter mb-8 text-white leading-[1.2] break-keep"
>
지금도 구매 예산은 <br className="sm:hidden" />
조용히 새고 있을지 모릅니다
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.2 }}
className="text-[17px] text-ink-muted max-w-xl mx-auto mb-14 leading-relaxed font-semibold break-keep"
>
수작업으로 진행되어 수많은 단가 타협 기회를 흘려보내던 파트너사 흥정, 규칙 준수에 압도적으로 특화된 negotium 봇에
위임하고 재무 마진을 안전히 지키십시오.
</motion.p>
<motion.div
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.3 }}
>
<Button href="#contact-section" size="xl" className="gap-2.5 hover:-translate-y-px">
<span>도입 및 계약 상담 신청하기</span>
<ArrowRight className="w-5 h-5" />
</Button>
</motion.div> </motion.div>
</div> </div>
</Section> </section>
) )
} }

View File

@ -8,7 +8,7 @@ export function Footer() {
<div className="md:col-span-8"> <div className="md:col-span-8">
<Logo className="mb-6" /> <Logo className="mb-6" />
<Typography variant="caption" className="max-w-sm mb-6 font-medium"> <Typography variant="caption" className="max-w-sm mb-6 font-medium">
네고시움은 협상 에이전트가 공급사와 1:1로 단가를 조율하고 낙찰까지 처리하는 B2B 구매 협상 자동화 솔루션입니다. negotium은 협상 봇이 공급사와 1:1로 단가를 조율하고 낙찰까지 처리하는 B2B 구매 협상 자동화 솔루션입니다.
</Typography> </Typography>
<p className="text-xs text-ink-faint font-semibold"> <p className="text-xs text-ink-faint font-semibold">
© {new Date().getFullYear()} negotium Co., Ltd. All rights reserved. © {new Date().getFullYear()} negotium Co., Ltd. All rights reserved.

View File

@ -5,78 +5,36 @@ import { Button } from "@/components/ui/button"
import { Logo } from "@/components/ui/logo" import { Logo } from "@/components/ui/logo"
export function Header() { export function Header() {
// 두 상태는 조건이 다르다. 하나로 묶으면 다크 히어로 위에 흰 바가 떠버린다. const [scrolled, setScrolled] = useState(false)
// compact — 조금이라도 스크롤하면 높이를 줄인다.
// onStage — 다크 히어로를 벗어나기 전까지. 이때는 배경을 깔지 않고 로고를 반전시킨다.
const [compact, setCompact] = useState(false)
const [onStage, setOnStage] = useState(true)
// 스크롤 시 헤더를 반투명 블러 배경으로 전환
useEffect(() => { useEffect(() => {
const handleScroll = () => setCompact(window.scrollY > 40) const handleScroll = () => setScrolled(window.scrollY > 40)
window.addEventListener("scroll", handleScroll, { passive: true }) window.addEventListener("scroll", handleScroll, { passive: true })
handleScroll() handleScroll()
return () => window.removeEventListener("scroll", handleScroll) return () => window.removeEventListener("scroll", handleScroll)
}, []) }, [])
/* "다크 히어로 위에 있는가"는 스크롤 수치가 아니라 교차 상태다.
scrollY 로 계산하면 하이드레이션 전에 스크롤 위치가 잡힌 경우(ScrollRestoration,
새로고침, 뒤로가기) 초기 계산이 0 에서 끝나고 이후 스크롤 이벤트가 없으면
영영 갱신되지 않는다 — 흰 배경 위에 흰 글자 버튼이 남는다.
IntersectionObserver 는 관찰 시작 즉시 올바른 값을 준다. */
useEffect(() => {
const stage = document.querySelector("[data-stage-hero]")
if (!stage) {
setOnStage(false) // 다크 히어로가 없는 페이지에서는 항상 라이트
return
}
// 헤더 높이(약 80px)만큼 위에서 미리 전환되도록 상단 마진을 음수로 준다.
const io = new IntersectionObserver(([entry]) => setOnStage(entry.isIntersecting), {
rootMargin: "-80px 0px 0px 0px",
threshold: 0,
})
io.observe(stage)
return () => io.disconnect()
}, [])
return ( return (
<header <header
/* 배경은 "스크롤됐을 때"만 깔되, 무대 위에서는 흰색이 아니라 무대색으로 깐다. className={`fixed top-0 left-0 right-0 z-40 safe-t safe-x transition-all duration-300 ${
투명한 채로 두면 히어로 카피가 헤더 밑으로 지나가며 nav 글자와 겹쳐 읽힌다. */ scrolled ? "py-4 bg-white/80 backdrop-blur-xl border-b border-line" : "py-6 bg-transparent border-b border-transparent"
className={`fixed top-0 left-0 right-0 z-40 safe-t safe-x transition-all duration-300 ${compact ? "py-4" : "py-6"} ${
!compact
? "bg-transparent border-b border-transparent"
: onStage
? "bg-stage/80 backdrop-blur-xl border-b border-stage-line"
: "bg-white/80 backdrop-blur-xl border-b border-line"
}`} }`}
> >
<div className="max-w-5xl mx-auto px-6 flex items-center justify-between"> <div className="max-w-5xl mx-auto px-6 flex items-center justify-between">
<a href="#"> <a href="#">
<Logo className={onStage ? "brightness-0 invert" : undefined} /> <Logo />
</a> </a>
<nav <nav className="hidden md:flex items-center gap-8 text-[15px] font-semibold text-ink-soft">
className={`hidden md:flex items-center gap-8 text-[15px] font-semibold transition-colors ${
onStage ? "text-on-stage-soft" : "text-ink-soft"
}`}
>
{NAV_LINKS.map(({ href, label }) => ( {NAV_LINKS.map(({ href, label }) => (
<a <a key={href} href={href} className="hover:text-ink transition-colors">
key={href}
href={href}
className={`transition-colors ${onStage ? "hover:text-on-stage" : "hover:text-ink"}`}
>
{label} {label}
</a> </a>
))} ))}
</nav> </nav>
<Button <Button href="#contact-section" size="pill" className="gap-1.5">
href="#contact-section"
variant={onStage ? "stageGhost" : "primary"}
size={onStage ? "stageRound" : "pill"}
className="gap-1.5"
>
<span>상담 신청</span> <span>상담 신청</span>
<ArrowUpRight className="w-4 h-4" /> <ArrowUpRight className="w-4 h-4" />
</Button> </Button>

View File

@ -0,0 +1,51 @@
import { motion } from "motion/react"
import { ArrowDown, ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button"
/** 히어로 변형 — 실시간 협상 콘솔 시즐 영상을 전면에 세운 풀와이드 히어로. */
export function HeroConsole() {
return (
<section className="relative min-h-screen bg-surface-neu text-ink flex flex-col justify-center pt-32 pb-20 overflow-hidden">
<div className="mesh-bg absolute top-[-260px] left-[-160px] opacity-40 pointer-events-none" />
<div className="max-w-6xl mx-auto px-6 w-full relative z-10">
<motion.div
initial={{ opacity: 0, y: 28, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.8, delay: 0.1 }}
className="rounded-[28px] overflow-hidden border border-line-strong shadow-[0_30px_80px_rgba(20,25,45,0.12)] bg-white"
>
<video
src="/gifs/hero_console.mp4"
poster="/gifs/hero_console.jpg"
autoPlay
muted
loop
playsInline
className="w-full h-auto block"
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.35 }}
className="flex flex-col sm:flex-row justify-center gap-5 pt-10"
>
<Button
href="#contact-section"
className="w-full sm:w-auto group shadow-[0_8px_24px_rgba(49,130,246,0.15)] hover:scale-[1.02] active:scale-[0.98]"
>
<span>도입 문의 상담 받기</span>
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" />
</Button>
<Button href="#how-it-works" variant="glass" className="w-full sm:w-auto gap-1.5">
<span>작동 방식 보기</span>
<ArrowDown className="w-4 h-4 text-ink-soft" />
</Button>
</motion.div>
</div>
</section>
)
}

View File

@ -1,133 +0,0 @@
import { useState } from "react"
import { ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { DemoRequestModal } from "@/components/ui/demo-request-modal"
import { CTA_GAP, DISPLAY_LEAD_GAP } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography"
/**
* 다크 무대 히어로 — 배경 루프 영상.
*
* 예전에는 캔버스가 negotium 파이프라인을 직접 그렸고(입자 240개·6단계 서사·무리 라벨),
* 배경이 곧 설명이었다. 지금은 배경을 배경으로만 쓴다 — 설명은 카피와 아래 섹션이 맡는다.
* 그래서 무리 라벨(CLUSTER_ANCHORS)도 함께 걷어냈다. 좌표 소스가 사라진 라벨은
* 화면비가 바뀔 때마다 어긋나기만 하고 아무것도 설명하지 못한다.
*
* 배경이 의미를 지지 않으므로 카피 가독성이 1순위다. 그래서:
* - 영상 위에 스크림을 깐다. 예전 캔버스는 하단 밴드에만 입자가 있어 상단이 비었지만,
* 이 영상은 화면 전체에 밝은 요소가 흩어져 흰 글자가 묻힌다.
* - 카피는 화면 중앙 정렬. 밴드 개념이 없으니 상하로 나눌 이유도 없다.
*/
export function HeroDataFlow() {
const [demoOpen, setDemoOpen] = useState(false)
return (
<section
data-stage-hero
className="stage-bg relative min-h-screen text-on-stage flex flex-col overflow-hidden"
>
{/* 영상은 가로 화면에서만 튼다.
16:9 소스를 세로 화면에 object-cover 로 깔면 가로가 62% 잘리고 남은 38% 가
1.7배로 확대된다(848x1220 실측) — 데이터 입자가 거대한 보케로 뭉개져서
배경이 아니라 노이즈가 된다. 캔버스는 뷰포트에 맞춰 그렸으니 없던 문제다.
세로에서는 무대 그라데이션(stage-bg)만 남긴다. 그게 브랜드 배경이라 빈 화면이 아니다.
prefers-reduced-motion 에서도 같은 이유로 영상을 감춘다 — 포스터가 뒤를 받친다. */}
<div
aria-hidden
className="absolute inset-0 hidden landscape:block motion-reduce:hidden bg-cover bg-center"
style={{ backgroundImage: "url(/gifs/hero_loop.jpg)" }}
>
<video
className="w-full h-full object-cover"
src="/gifs/hero_loop.mp4"
poster="/gifs/hero_loop.jpg"
autoPlay
muted
loop
playsInline
preload="metadata"
/>
</div>
{/* 카피 가독성용 스크림.
색은 하나(stage-deep)로 두고 알파만 움직인다 — 중간 stop 에서 색이 바뀌면
그 지점이 가로선으로 읽힌다.
알파는 90/60/95 였다가 낮췄다. 그 값은 헤드라인 둘째 줄이 회색(/60)이던 시절
그 글자를 살리려고 깐 것인데, 가장 옅은 지점에서도 영상이 40% 만 남아서
배경 전체가 탁해졌다. 헤드라인이 흰색이 된 지금은 이만큼 덮을 이유가 없다.
구간별로 필요한 만큼만 준다 — 위는 헤더 nav(작은 회색 글자), 가운데는 흰
헤드라인이라 가장 옅게, 아래는 다음 섹션으로 넘어가는 암전(h-32)이 따로 있다. */}
<div
aria-hidden
className="absolute inset-0 pointer-events-none bg-linear-to-b from-stage-deep/50 via-transparent to-stage-deep/45"
/>
{/* 카피 뒤만 국소적으로 누른다.
전면을 균일하게 덮으면 글자는 살지만 영상이 통째로 탁해진다. 반대로 다 걷으면
헤드라인(흰색 74px)은 버텨도 리드 문단(17px)이 밝은 입자 위에서 읽히지 않는다.
둘 다 만족하는 유일한 방법은 덮는 범위를 글자가 있는 자리로 좁히는 것이다.
가장자리는 영상이 그대로 보이고 가운데만 어두워진다. */}
<div
aria-hidden
className="absolute inset-0 pointer-events-none"
style={{
background:
"radial-gradient(58% 42% at 50% 45%," +
" color-mix(in srgb, var(--color-stage-deep) 85%, transparent) 0%," +
" color-mix(in srgb, var(--color-stage-deep) 55%, transparent) 45%," +
" transparent 72%)",
}}
/>
{/* 아래 라이트 섹션과의 경계를 부드럽게 */}
<div aria-hidden className="absolute inset-x-0 bottom-0 h-32 bg-linear-to-b from-transparent to-stage pointer-events-none" />
{/* pt-[104px] 는 fixed 헤더 회피용 바닥. 그 아래 남은 공간에서 수직 중앙 정렬한다. */}
<div className="relative z-10 w-full flex-1 pt-[104px] pb-16 flex items-center">
<div className="max-w-4xl mx-auto px-6 w-full text-center">
{/* 히어로 카피에는 진입 애니메이션을 걸지 않는다.
여기가 LCP 요소라 opacity:0 으로 시작하면 하이드레이션 전까지 첫 화면이 비고,
광고 유입처럼 JS 가 느린 환경에서 그대로 손실이 된다. 모션은 배경 영상이 맡는다. */}
{/* 여기는 2톤을 쓰지 않는다. 예전 캔버스 배경은 하단 밴드에만 입자가 있어 헤드라인
뒤가 거의 단색이었고, 그때는 투명도 60% 로 낮춘 둘째 줄도 읽혔다. 배경이 영상으로
바뀌면서 밝은 요소가 글자 뒤로 지나가 그 대비가 무너졌다 — 배경이 움직이는 위에서는
가독성이 조판 장치보다 우선한다. 2톤은 최종 CTA 에 남아 있다(그쪽은 배경이 정적). */}
<Typography variant="stageDisplay">
<span className="text-white">협력사 전원과 1:1로,</span>
<br />
<span className="text-white">동시에 협상합니다.</span>
</Typography>
{/* 히어로는 h1 이어야 해서 SectionHeading(h2 고정)을 못 쓴다. 대신 간격만은
같은 상수를 물려 최종 CTA 와 리듬이 어긋나지 않게 한다. */}
<p className={`${DISPLAY_LEAD_GAP} text-base sm:text-lg text-on-stage-soft leading-relaxed break-keep max-w-2xl mx-auto`}>
품목·목표가·마감일만 정하면 됩니다. 협상 에이전트가 협력사마다 따로 붙어 단가를 조율하고, 마감 시각에
최저 투찰가로 낙찰까지 판정합니다.
</p>
{/* 2차 CTA 는 상담 신청(#contact-section)과 목적지가 겹쳐 사실상 같은 버튼이었다.
데모 경로로 갈라서, 고관여 폼(상담 신청) 앞단에 저관여 리드 캡처를 둔다.
문구는 "클릭하면 무슨 일이 일어나는가"에 맞춘다.
협상 예시 체험 — 페이지 안에서 바로 조작한다. 고정 데이터라 "예시"를 밝히되,
도착 섹션이 "직접 경험해 보세요"·"해보기" 라 "보기"로 낮추면 어긋난다.
실제 데모 받기 — 누르면 폼이 뜨고 이메일로 온다. "체험"이라 쓰면 눌렀을 때
바로 만질 줄 알았다가 입력을 요구받아 낚인 느낌이 된다. */}
<div className={`${CTA_GAP} flex flex-col sm:flex-row gap-3 justify-center`}>
<Button href="#how-it-works" variant="stage" size="stageRound" className="group">
<span>협상 예시 체험</span>
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
</Button>
<Button type="button" variant="stageGhost" size="stageRound" onClick={() => setDemoOpen(true)}>
실제 데모 받기
</Button>
</div>
</div>
</div>
<DemoRequestModal open={demoOpen} onClose={() => setDemoOpen(false)} />
</section>
)
}

View File

@ -0,0 +1,85 @@
import { motion } from "motion/react"
import { ArrowDown, ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Typography } from "@/components/ui/typography"
import { EASE_OUT_EXPO } from "@/lib/motion"
/** 기본 히어로 — 파스텔 블롭 + 그리드 + 떠다니는 와이어프레임 도형. */
export function HeroGlassmorphic() {
return (
<section className="relative min-h-screen bg-surface text-ink flex flex-col justify-center pt-32 overflow-hidden">
{/* 흐릿한 파스텔 블롭 배경 */}
<div className="absolute top-[10%] right-[15%] w-[480px] h-[480px] bg-primary/10 rounded-full filter blur-[120px] pointer-events-none" />
<div className="absolute bottom-[10%] left-[10%] w-[450px] h-[450px] bg-accent/10 rounded-full filter blur-[120px] pointer-events-none" />
<div className="absolute top-[30%] left-[30%] w-[380px] h-[380px] bg-primary/10 rounded-full filter blur-[100px] pointer-events-none" />
{/* 저채도 그리드 패턴 오버레이 */}
<div className="absolute inset-0 bg-[linear-gradient(rgba(0,0,0,0.01)_1px,transparent_1px),linear-gradient(90deg,rgba(0,0,0,0.01)_1px,transparent_1px)] bg-[size:40px_40px] pointer-events-none" />
{/* 떠다니는 와이어프레임 도형 — max-w-7xl 안에 가둠 */}
<div className="absolute inset-0 max-w-7xl mx-auto pointer-events-none z-0">
<motion.div
animate={{ y: [0, -18, 0], rotate: [12, 42, 12] }}
transition={{ duration: 8, repeat: Infinity, ease: "easeInOut" }}
className="absolute top-[15%] left-[8vw] lg:left-[10%] w-16 h-16 sm:w-28 sm:h-28 border-2 border-primary/40 bg-white/40 shadow-[0_8px_32px_rgba(49,130,246,0.05)] rounded-[28px] pointer-events-none z-0"
/>
<motion.div
animate={{ rotate: -360 }}
transition={{ duration: 80, repeat: Infinity, ease: "linear" }}
className="absolute bottom-[18%] left-[6vw] lg:left-[12%] w-28 h-28 sm:w-48 sm:h-48 border-2 border-dashed border-accent/30 rounded-full pointer-events-none z-0"
/>
<motion.div
animate={{ x: [0, 10, 0], y: [0, 10, 0] }}
transition={{ duration: 10, repeat: Infinity, ease: "easeInOut" }}
className="absolute top-[30%] right-[8vw] lg:right-[12%] w-20 h-20 sm:w-36 sm:h-36 border-2 border-primary/20 rounded-[40px] pointer-events-none z-0"
>
<div className="w-full h-full bg-gradient-to-tr from-primary/5 to-transparent rounded-[38px]" />
</motion.div>
</div>
<div className="max-w-4xl mx-auto px-6 w-full py-24 relative z-10 flex flex-col items-center text-center">
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, delay: 0.1, ease: EASE_OUT_EXPO }}
>
<Typography variant="display" className="mb-8">
구매 협상, 이제 <span className="text-primary">봇이 알아서</span> <br className="hidden sm:inline" />
흥정부터 낙찰까지 자동으로
</Typography>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, delay: 0.2, ease: EASE_OUT_EXPO }}
>
<Typography variant="lead" className="md:text-[20px] max-w-2xl mb-12">
협상할수록 강화학습으로 흥정 전략을 알아서 다듬어갑니다. <br className="hidden sm:inline" />
감정 없이, 지치지 않고, 24시간 연중무휴 — 결렬 대신 성사로 이끕니다.
</Typography>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, delay: 0.3, ease: EASE_OUT_EXPO }}
className="flex flex-col sm:flex-row gap-4 items-center justify-center w-full max-w-md z-20"
>
<Button
href="#contact-section"
className="w-full sm:w-auto group shadow-[0_8px_24px_rgba(49,130,246,0.15)] hover:scale-[1.02] active:scale-[0.98]"
>
<span>도입 문의 상담 받기</span>
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" />
</Button>
<Button href="#how-it-works" variant="glass" className="w-full sm:w-auto gap-1.5">
<span>작동 방식 보기</span>
<ArrowDown className="w-4 h-4 text-ink-soft" />
</Button>
</motion.div>
</div>
</section>
)
}

View File

@ -0,0 +1,73 @@
import { motion } from "motion/react"
import { ArrowDown, ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Typography } from "@/components/ui/typography"
/** 대안 히어로 — 뉴모피즘 배경 + 실제 모바일 협상 화면(GIF) 폰 목업. */
export function HeroNeumorphic() {
return (
<section className="relative min-h-screen bg-surface-neu text-ink flex flex-col justify-center pt-32 pb-20 overflow-hidden">
{/* 우측 상단 메시 제거 — 영상 배경(#F2F4F7)과 섹션 배경을 완전 동일하게 유지해 경계선 없이 블렌딩 */}
<div className="max-w-5xl mx-auto px-6 w-full relative z-10 grid grid-cols-1 lg:grid-cols-12 gap-16 items-center">
{/* 좌: 카피 + CTA */}
<div className="lg:col-span-7 space-y-8 text-left">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, delay: 0.1 }}>
<Typography variant="display" className="text-4xl sm:text-5xl lg:text-[54px] leading-[1.15]">
구매 협상, <br />
이제 <span className="text-primary">봇이 알아서</span> <br />
흥정부터 낙찰까지 자동으로
</Typography>
</motion.div>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, delay: 0.2 }}>
<Typography variant="lead" className="max-w-xl">
불필요한 실랑이와 감정 소모 없이, 예산 상한선 내에서 최적의 원가 절감을 달성하세요. 데이터 기반의 자율 흥정 봇이
24시간 실시간 대리 협상을 완료합니다.
</Typography>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.3 }}
className="flex flex-col sm:flex-row gap-5 pt-4"
>
<Button
href="#contact-section"
className="w-full sm:w-auto group shadow-[0_8px_24px_rgba(49,130,246,0.15)] hover:scale-[1.02] active:scale-[0.98]"
>
<span>도입 문의 상담 받기</span>
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" />
</Button>
<Button href="#how-it-works" variant="glass" className="w-full sm:w-auto gap-1.5">
<span>작동 방식 보기</span>
<ArrowDown className="w-4 h-4 text-ink-soft" />
</Button>
</motion.div>
</div>
{/* 우: 모바일 협상 채팅 + 외부 설명 UI(제안가·협상카드·낙찰 하이라이트) 합성 영상 */}
<div className="lg:col-span-5 flex justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="w-full flex justify-center"
>
<video
src="/gifs/negotiation_annotated_3d.mp4?v=w3"
poster="/gifs/negotiation_annotated_3d.jpg?v=w3"
autoPlay
muted
loop
playsInline
className="w-full max-w-[440px] h-auto"
/>
</motion.div>
</div>
</div>
</section>
)
}

View File

@ -1,10 +1,10 @@
import { useState } from "react" import { useState } from "react"
import { AnimatePresence, motion } from "motion/react" import { AnimatePresence, motion } from "motion/react"
import { Check, Monitor, Smartphone, type LucideIcon } from "lucide-react" import { Bot, Check, Monitor, Smartphone, Sparkles, type LucideIcon } from "lucide-react"
import { BrowserFrame, PhoneFrame } from "@/components/ui/device-mockups" import { BrowserFrame, PhoneFrame } from "@/components/ui/device-mockups"
import { Section } from "@/components/ui/section" import { Section } from "@/components/ui/section"
import { CONTROL_GAP, SectionHeading } from "@/components/ui/section-heading" import { SectionHeading } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography" import { Typography } from "@/components/ui/typography"
/** 3단계 이용 가이드 — 탭 전환식 GIF 시연 (데스크톱/모바일 목업). */ /** 3단계 이용 가이드 — 탭 전환식 GIF 시연 (데스크톱/모바일 목업). */
@ -17,20 +17,21 @@ export function HowItWorksDemo() {
<Section id="how-it-works-demo" width="lg" bordered className="overflow-hidden"> <Section id="how-it-works-demo" width="lg" bordered className="overflow-hidden">
<SectionHeading <SectionHeading
align="center" align="center"
eyebrow="How it Works" className="mb-20"
eyebrow="SERVICE DEMONSTRATION"
title="견적 생성부터 낙찰까지 한눈에 보기" title="견적 생성부터 낙찰까지 한눈에 보기"
description="견적을 열고 협상이 끝나기까지, 실제 화면으로 보여드립니다." description="복잡해 보이는 구매 과정이 어떻게 자동화되는지 실제 작동 화면(GIF)을 통해 쉽고 직관적으로 확인해 보세요."
descriptionClassName="max-w-2xl" descriptionClassName="text-[17px] max-w-2xl"
/> />
{/* 탭 스위치 — 비교 섹션 토글과 같은 간격 규칙(본문 쪽에 묶인다) */} {/* 탭 스위치 */}
<div className={`flex justify-center ${CONTROL_GAP}`}> <div className="flex justify-center mb-16">
<div className="p-1 bg-fill rounded-control inline-flex flex-wrap justify-center gap-1"> <div className="p-1 bg-fill rounded-[14px] inline-flex flex-wrap justify-center gap-1">
{DEMO_TABS.map((tab) => ( {DEMO_TABS.map((tab) => (
<button <button
key={tab.key} key={tab.key}
onClick={() => setActiveKey(tab.key)} onClick={() => setActiveKey(tab.key)}
className={`px-5 py-2.5 rounded-control text-xs sm:text-sm font-bold transition-all flex items-center gap-2 cursor-pointer ${ className={`px-5 py-2.5 rounded-[10px] text-xs sm:text-sm font-bold transition-all flex items-center gap-2 cursor-pointer ${
activeKey === tab.key ? "bg-white text-primary" : "text-ink-soft hover:text-ink" activeKey === tab.key ? "bg-white text-primary" : "text-ink-soft hover:text-ink"
}`} }`}
> >
@ -53,9 +54,10 @@ export function HowItWorksDemo() {
transition={{ duration: 0.4 }} transition={{ duration: 0.4 }}
className="space-y-6" className="space-y-6"
> >
{/* 알약 배경 + 아이콘을 뺀 글자만의 라벨. <div className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-lg text-xs font-bold ${activeTab.badge.className}`}>
레퍼런스 라벨은 11px/600/자간 normal 에 배경도 아이콘도 없다. */} <activeTab.badge.icon className="w-3.5 h-3.5" />
<div className={`text-[11px] font-semibold ${activeTab.badge.className}`}>{activeTab.badge.label}</div> {activeTab.badge.label}
</div>
<Typography variant="heading">{activeTab.heading}</Typography> <Typography variant="heading">{activeTab.heading}</Typography>
{activeTab.paragraphs.map((paragraph, idx) => ( {activeTab.paragraphs.map((paragraph, idx) => (
<Typography key={idx} variant="body"> <Typography key={idx} variant="body">
@ -157,7 +159,7 @@ type DemoTab = {
key: "create" | "negotiate" | "result" key: "create" | "negotiate" | "result"
tabIcon: LucideIcon tabIcon: LucideIcon
tabLabel: string tabLabel: string
badge: { label: string; className: string } badge: { icon: LucideIcon; label: string; className: string }
checkClassName: string checkClassName: string
heading: React.ReactNode heading: React.ReactNode
paragraphs: React.ReactNode[] paragraphs: React.ReactNode[]
@ -181,19 +183,19 @@ const DEMO_TABS: DemoTab[] = [
{ {
key: "create", key: "create",
tabIcon: Monitor, tabIcon: Monitor,
tabLabel: "1. 견적 · 기준 수립", tabLabel: "1단계: AI 견적 & 가이드 수립",
badge: { label: "구매 관리자 콘솔", className: "text-primary" }, badge: { icon: Sparkles, label: "구매 관리자 콘솔 (웹)", className: "bg-primary-soft text-primary" },
checkClassName: "text-primary", checkClassName: "text-primary",
heading: ( heading: (
<> <>
엑셀만 올리면 <br /> 엑셀 등록만으로 끝나는 <br />
목표 단가가 잡힙니다 초정밀 가이드라인 자율 수립
</> </>
), ),
paragraphs: [ paragraphs: [
<> <>
품목명과 시중 시장 가격을 입력하면, 네고시움이 <b>인터넷 최저가(LPS)</b> 데이터를 상시 반영해{" "} 품목명과 시중 시장 가격을 입력하면, negotium이 <b>인터넷 최저가(LPS)</b> 데이터를 상시 반영해{" "}
<b className="text-primary">시장 최저가를 기준으로 시작합니다.</b> <b className="text-primary">시장 바닥값을 쥐고 시작합니다.</b>
</>, </>,
<> <>
시중 최저가와 매입 이력, 목표 마진율을 종합해 — 사내 마진은 지키면서 공급사가 받아들일 수 있는{" "} 시중 최저가와 매입 이력, 목표 마진율을 종합해 — 사내 마진은 지키면서 공급사가 받아들일 수 있는{" "}
@ -209,7 +211,7 @@ const DEMO_TABS: DemoTab[] = [
gifAlt: "견적 생성 가이드라인 수립 시연", gifAlt: "견적 생성 가이드라인 수립 시연",
fallback: { fallback: {
icon: Monitor, icon: Monitor,
iconWrapClassName: "w-14 h-14 bg-primary-soft rounded-card text-primary", iconWrapClassName: "w-14 h-14 bg-primary-soft rounded-2xl text-primary",
codeClassName: "bg-white text-primary", codeClassName: "bg-white text-primary",
title: "견적 생성 시연 GIF 공간", title: "견적 생성 시연 GIF 공간",
hint: "실제 기동 GIF를 배치하시면 이 영역에 자동으로 재생됩니다.", hint: "실제 기동 GIF를 배치하시면 이 영역에 자동으로 재생됩니다.",
@ -218,8 +220,8 @@ const DEMO_TABS: DemoTab[] = [
{ {
key: "negotiate", key: "negotiate",
tabIcon: Smartphone, tabIcon: Smartphone,
tabLabel: "2. 협력사별 1:1 협상", tabLabel: "2단계: AI 자동 밀당 협상",
badge: { label: "협력사 협상 포털", className: "text-positive" }, badge: { icon: Bot, label: "협력사 협상 포털 (웹·모바일)", className: "bg-positive-soft border border-positive/15 text-positive" },
checkClassName: "text-positive", checkClassName: "text-positive",
heading: ( heading: (
<> <>
@ -232,18 +234,14 @@ const DEMO_TABS: DemoTab[] = [
각 파트너사 담당자는 초대 메일로 <b>비대면 협상 모바일 포털</b>에 접속해, 언제든 단가를 조율합니다. 각 파트너사 담당자는 초대 메일로 <b>비대면 협상 모바일 포털</b>에 접속해, 언제든 단가를 조율합니다.
</>, </>,
<> <>
단순히 깎는 게 아닙니다. 에이전트는 준비된 협상 카드를 상황에 맞게 꺼내, 협력사가 받아들일 조건을 단순 마진 깎기가 아닙니다. 봇은 준비된 협상 카드를 상황에 맞게 꺼내, 파트너사의 자발적인 마진 타협을 부드럽게
이끌어냅니다. 이끌어냅니다.
</>, </>,
], ],
checks: ["협력사별 1:1 비대면 협상 포털", "같은 기준으로 응대해 관계 부담 없음"], checks: ["1:1 비대면 협상 포털 제공", "불필요한 실랑이를 예방하여 파트너십 보호"],
mockup: "phone", mockup: "phone",
/* 현행 공급사 포털(토스풍 UI)에서 실제로 진행한 협상 스크린캐스트. 390x780 모바일 뷰포트로
인트로→가격 밀당(협상 카드 4장)→타결(18,480원 합의)까지, 1.5배속. gif 는 무JS 폴백(폭 228). */
gif: "/gifs/negotiation.gif", gif: "/gifs/negotiation.gif",
video: "/gifs/negotiation.mp4", gifAlt: "AI 모바일 자동 협상 시연",
poster: "/gifs/negotiation.jpg",
gifAlt: "협력사 포털 모바일 협상 화면",
fallback: { fallback: {
icon: Smartphone, icon: Smartphone,
iconWrapClassName: "w-12 h-12 bg-positive-soft border border-positive/15 rounded-full text-positive", iconWrapClassName: "w-12 h-12 bg-positive-soft border border-positive/15 rounded-full text-positive",
@ -256,7 +254,7 @@ const DEMO_TABS: DemoTab[] = [
key: "result", key: "result",
tabIcon: Monitor, tabIcon: Monitor,
tabLabel: "3단계: AI 협상 결과 확인", tabLabel: "3단계: AI 협상 결과 확인",
badge: { label: "구매 관리자 콘솔", className: "text-accent" }, badge: { icon: Monitor, label: "구매 관리자 콘솔 (웹)", className: "bg-accent-soft border border-accent/15 text-accent" },
checkClassName: "text-accent", checkClassName: "text-accent",
heading: ( heading: (
<> <>
@ -282,7 +280,7 @@ const DEMO_TABS: DemoTab[] = [
gifAlt: "협상 결과 분석 대시보드 시연", gifAlt: "협상 결과 분석 대시보드 시연",
fallback: { fallback: {
icon: Monitor, icon: Monitor,
iconWrapClassName: "w-14 h-14 bg-accent-soft rounded-card text-accent", iconWrapClassName: "w-14 h-14 bg-accent-soft rounded-2xl text-accent",
codeClassName: "bg-white text-accent", codeClassName: "bg-white text-accent",
title: "협상 결과 확인 시연 GIF 공간", title: "협상 결과 확인 시연 GIF 공간",
hint: "결과 보고 대시보드 기동 GIF를 배치하시면 이 영역에 자동으로 재생됩니다.", hint: "결과 보고 대시보드 기동 GIF를 배치하시면 이 영역에 자동으로 재생됩니다.",

View File

@ -0,0 +1,324 @@
import { useEffect, useRef, useState } from "react"
import { AnimatePresence, motion } from "motion/react"
import { Bot, Handshake, ShieldCheck } from "lucide-react"
import { PriceSparkline } from "@/components/ui/price-sparkline"
import { SlateRenderer } from "@/components/ui/slate-renderer"
import { Typography } from "@/components/ui/typography"
import { EASE_OUT_EXPO } from "@/lib/motion"
import type { DialogueStep } from "@/types"
/** 스크롤 연동 sticky 협상 데모 — 스크롤 진행률에 따라 대화가 쌓이고 단가가 내려간다. */
export function NegotiationConsole() {
// -1 = 아직 섹션 진입 전(온보딩 가이드 표시)
const [activeStep, setActiveStep] = useState(-1)
const [animatedPrice, setAnimatedPrice] = useState(1200000)
const sectionRef = useRef<HTMLDivElement>(null)
const chatContainerRef = useRef<HTMLDivElement>(null)
// 섹션 내 스크롤 진행률 → 대화 스텝 매핑
useEffect(() => {
const handleScroll = () => {
if (!sectionRef.current) return
const rect = sectionRef.current.getBoundingClientRect()
const totalScrollable = rect.height - window.innerHeight
if (totalScrollable <= 0) return
const scrolled = -rect.top
if (scrolled < 0) {
setActiveStep(-1)
return
}
const clampedRatio = Math.max(0, Math.min(scrolled / totalScrollable, 0.99))
setActiveStep(Math.floor(clampedRatio * NEGOTIATION_STEPS.length))
}
window.addEventListener("scroll", handleScroll, { passive: true })
handleScroll()
return () => window.removeEventListener("scroll", handleScroll)
}, [])
// 단가 오도미터 애니메이션 (500ms ease-out)
useEffect(() => {
const targetPrice = activeStep === -1 ? 1200000 : NEGOTIATION_STEPS[activeStep].price
if (animatedPrice === targetPrice) return
const start = animatedPrice
const duration = 500
const startTime = performance.now()
const animate = (currentTime: number) => {
const progress = Math.min((currentTime - startTime) / duration, 1)
const easeProgress = progress * (2 - progress)
setAnimatedPrice(Math.round(start + (targetPrice - start) * easeProgress))
if (progress < 1) requestAnimationFrame(animate)
}
requestAnimationFrame(animate)
}, [activeStep])
// 새 말풍선이 붙으면 채팅 영역을 바닥으로 스크롤
useEffect(() => {
chatContainerRef.current?.scrollTo({ top: chatContainerRef.current.scrollHeight, behavior: "smooth" })
}, [activeStep])
return (
<section
id="how-it-works"
ref={sectionRef}
className="relative bg-surface text-ink w-full font-sans border-t border-line h-[240vh] md:h-[280vh]"
>
<div className="sticky top-0 h-screen flex items-center justify-center w-full overflow-hidden px-6 md:px-8">
{/* 섹션 헤딩 — sticky 상단 고정 (모바일은 공간상 생략) */}
<div className="absolute top-24 left-0 right-0 text-center hidden md:block">
<Typography variant="eyebrow" className="block">
Live Replay
</Typography>
<Typography variant="heading" as="h2" className="mt-1">
봇은 물러서지 않습니다
</Typography>
</div>
<div className="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-12 gap-12 md:gap-16 items-center w-full">
{/* 좌: 실시간 단가 패널 */}
<div className="md:col-span-5 flex flex-col justify-center space-y-6 text-center md:text-left">
<div>
<span className="text-[11px] font-bold text-ink-muted uppercase tracking-wider block mb-1">실시간 협상 단가</span>
<div className="flex items-baseline gap-1.5 justify-center md:justify-start">
<span className="text-3xl font-bold text-primary">₩</span>
<span className="text-4xl md:text-5xl font-black tracking-tight text-primary">
{animatedPrice.toLocaleString()}
</span>
</div>
{/* 시작가 대비 누적 절감 델타 — 높이 고정으로 레이아웃 점프 방지 */}
<div className="h-6 mt-1.5">
{animatedPrice < START_PRICE && (
<span className="inline-flex items-baseline gap-1.5 text-positive font-bold text-sm">
▼ {(START_PRICE - animatedPrice).toLocaleString()}원
<span className="text-xs font-semibold">
(−{(((START_PRICE - animatedPrice) / START_PRICE) * 100).toFixed(1)}%)
</span>
</span>
)}
</div>
</div>
{/* 계단식 하락 스파크라인 + 목표가 기준선 */}
<div>
<PriceSparkline
prices={NEGOTIATION_STEPS.map((step) => step.price)}
targetPrice={TARGET_PRICE}
progress={activeStep < 0 ? 0 : (activeStep + 1) / NEGOTIATION_STEPS.length}
/>
<div className="flex justify-between mt-2">
<Typography variant="micro">시작가 {START_PRICE.toLocaleString()}</Typography>
<Typography variant="micro" className="text-primary">
목표가 {TARGET_PRICE.toLocaleString()}
</Typography>
</div>
<Typography variant="caption" className="mt-3">
실제 협상 화면을 재구성한 데모입니다.
</Typography>
</div>
<div className="h-10">
<AnimatePresence mode="wait">
{activeStep !== -1 && NEGOTIATION_STEPS[activeStep]?.badge && (
<motion.div
key={NEGOTIATION_STEPS[activeStep].badge}
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-positive-soft text-positive border border-positive/15 text-xs font-bold"
>
<ShieldCheck className="w-4 h-4 shrink-0" />
{NEGOTIATION_STEPS[activeStep].badge}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* 우: 대화 말풍선 (봇 좌 / 파트너 우) */}
<div className="md:col-span-7 flex flex-col justify-end space-y-5 h-[340px] md:h-[420px] relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-surface via-transparent to-transparent h-12 pointer-events-none z-10" />
<div
ref={chatContainerRef}
className="flex flex-col gap-5 overflow-y-auto pr-1 scrollbar-none w-full h-full justify-center"
>
<AnimatePresence mode="wait" initial={false}>
{activeStep === -1 ? (
<motion.div
key="onboarding-guide"
initial={{ opacity: 0, y: 15, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -15, scale: 0.97 }}
transition={{ duration: 0.4 }}
className="flex flex-col items-center justify-center text-center p-8 border border-dashed border-line-strong rounded-[28px] bg-white shadow-[0_4px_20px_rgba(0,0,0,0.01)] max-w-md mx-auto"
>
<div className="w-12 h-12 rounded-full bg-primary-soft flex items-center justify-center text-primary mb-4">
<Bot className="w-6 h-6 animate-pulse" />
</div>
<h3 className="text-base font-bold text-ink mb-1.5">실시간 AI 협상 대화</h3>
<p className="text-xs text-ink-soft font-semibold leading-relaxed break-keep">
화면을 스크롤하면 실시간 단가 조율 대화가 시작됩니다.
</p>
<div className="mt-4 flex items-center gap-1.5 text-[11px] font-bold text-primary animate-bounce">
<span>마우스 휠 스크롤하기</span>
<span>↓</span>
</div>
</motion.div>
) : (
<div className="flex flex-col gap-5 w-full mt-auto">
{NEGOTIATION_STEPS.slice(0, activeStep + 1).map((item, idx) => {
const isBot = item.editorNodes[0].sender === "bot"
return (
<motion.div
key={idx}
initial={{ opacity: 0, y: 15, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.97 }}
transition={{ duration: 0.4, ease: EASE_OUT_EXPO }}
className={`flex flex-col ${isBot ? "items-start" : "items-end"} w-full`}
>
<div className={`flex items-center gap-1.5 mb-1 text-[11px] font-bold text-ink-muted ${isBot ? "" : "flex-row-reverse"}`}>
<span className={`flex items-center gap-1 ${isBot ? "text-primary" : "text-ink-soft"}`}>
{isBot ? (
<>
<Bot className="w-3.5 h-3.5" />
<span>NEGOTIUM BOT</span>
</>
) : (
<>
<Handshake className="w-3.5 h-3.5" />
<span>PARTNER</span>
</>
)}
</span>
</div>
<div
className={`max-w-[85%] rounded-[20px] p-4 text-sm leading-relaxed font-semibold shadow-[0_2px_8px_rgba(0,0,0,0.02)] transition-all duration-300 ${
isBot ? "bg-primary-soft text-ink rounded-tl-none" : "bg-white text-ink rounded-tr-none"
}`}
>
<SlateRenderer nodes={item.editorNodes} />
</div>
</motion.div>
)
})}
</div>
)}
</AnimatePresence>
</div>
</div>
</div>
</div>
</section>
)
}
// 시작가(첫 제시가)·목표가 — 좌측 패널 델타·스파크라인 기준값
const START_PRICE = 1200000
const TARGET_PRICE = 1080000
// 데모 대화 시나리오 — 1,200,000원 제시에서 1,050,000원 낙찰까지 6스텝
const NEGOTIATION_STEPS: DialogueStep[] = [
{
step: 1,
price: 1200000,
editorNodes: [
{
type: 'paragraph',
sender: 'supplier',
children: [
{ text: '협력사', bold: true },
{ text: ' : ' },
{ text: '현재 글로벌 원재료 상승 요인으로 제안할 수 있는 최선의 단가는 1,200,000원입니다. 이 이하로는 마진 확보가 어렵습니다.' },
],
},
],
},
{
step: 2,
price: 1150000,
editorNodes: [
{
type: 'paragraph',
sender: 'bot',
children: [
{ text: 'AI 흥정 봇', bold: true },
{ text: ' : ' },
{ text: '제시해주신 1,200,000원은 당사 타 유사 품목 이력 및 시중 원가 인덱스 데이터 대비 약 8.3% 높게 책정되어 있습니다. ', italic: true },
{ text: '상호 호혜적 장기 계약 체결을 전제로 조율가 범위를 반영해 제안해 드립니다.', code: true },
],
},
],
},
{
step: 3,
price: 1150000,
editorNodes: [
{
type: 'paragraph',
sender: 'supplier',
children: [
{ text: '협력사', bold: true },
{ text: ' : ' },
{ text: '제조 공정상 급격한 인하는 무리가 있으나, 상생 협력 차원에서 1,150,000원까지는 즉시 조정해 드릴 용의가 있습니다.' },
],
},
],
},
{
step: 4,
price: 1080000,
editorNodes: [
{
type: 'paragraph',
sender: 'bot',
children: [
{ text: 'AI 흥정 봇', bold: true },
{ text: ' : ' },
{ text: '적극적인 협조에 감사드립니다. 만약 연간 최소 발주 수량을 보증하고 공급망 일정을 다소 유연화해주신다면, 목표가인 1,080,000원 선까지 맞출 수 있을까요?', italic: true },
],
},
],
},
{
step: 5,
price: 1050000,
editorNodes: [
{
type: 'paragraph',
sender: 'supplier',
children: [
{ text: '협력사', bold: true },
{ text: ' : ' },
{ text: '좋습니다. 제안하신 연간 개런티 확보 및 대금 현금 결제 기한 단축을 승인해 주시는 조건으로, ' },
{ text: '최종 조율가 1,050,000원으로 맞춰서 계약을 체결하겠습니다.', bold: true },
],
},
],
},
{
step: 6,
price: 1050000,
badge: '낙찰 성공 · 12.5% 예산 절감',
editorNodes: [
{
type: 'paragraph',
sender: 'bot',
children: [
{ text: 'AI 흥정 봇', bold: true },
{ text: ' : ' },
{ text: '최종 합의 접수 완료 — 가이드 상한가(1,200,000원) 대비 합의 낙찰가 1,050,000원으로 최종 계약 승인 처리 완료되었습니다. ', bold: true },
{ text: '본 흥정 마일스톤 및 단가 타결 히스토리는 사내 투명성 보증을 위해 보존 기록됩니다.', code: true },
],
},
],
},
]

View File

@ -1,466 +0,0 @@
import { useEffect, useRef, useState } from "react"
import { motion } from "motion/react"
import { ArrowRight, RotateCcw } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Section } from "@/components/ui/section"
import { SectionHeading } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography"
import { EASE_OUT_EXPO } from "@/lib/motion"
import {
DEMO_ITEMS,
MAX_CARDS,
respondToOffer,
round10,
savings,
simulateBuyerRun,
won,
type DemoItem,
type Outcome,
type Role,
type Turn,
} from "@/lib/negotiation-sim"
type Step = "role" | "play"
/**
* 랜딩 인터랙티브 협상 데모.
*
* 스크롤로 재생만 되던 기존 콘솔을 대체한다. 구경이 아니라 사용자가 한쪽을 맡아
* 실제로 값을 제시하고 판정을 받는다 — 광고로 들어온 잠재고객이 제품을 만져보는 자리다.
*
* 판정 규칙은 lib/negotiation-sim 에 있고 이 파일은 화면만 맡는다.
*/
export function NegotiationDemo() {
const [step, setStep] = useState<Step>("role")
const [role, setRole] = useState<Role>("seller")
const [item, setItem] = useState<DemoItem>(DEMO_ITEMS[0])
const [turns, setTurns] = useState<Turn[]>([])
const [cardsUsed, setCardsUsed] = useState(0)
const [outcome, setOutcome] = useState<Outcome>("running")
const [offer, setOffer] = useState(DEMO_ITEMS[0].listPrice)
/* 입력란의 원문. offer 와 따로 두는 이유는 타이핑 도중 상태다 — 숫자 하나로 묶으면
"1" 을 치는 순간 범위 밖이라 튕겨서 사용자와 싸우게 된다. 확정은 blur/Enter 에서 한다. */
const [offerText, setOfferText] = useState(won(DEMO_ITEMS[0].listPrice))
const [finalPrice, setFinalPrice] = useState(DEMO_ITEMS[0].listPrice)
const logRef = useRef<HTMLDivElement>(null)
const timers = useRef<number[]>([])
// 재생 중인 buyer 시나리오 타이머는 리셋·언마운트 때 반드시 걷어낸다.
const clearTimers = () => {
timers.current.forEach(clearTimeout)
timers.current = []
}
useEffect(() => clearTimers, [])
/** 역할 선택 화면으로 완전히 되돌린다. 같은 역할로만 다시 하면 나머지 절반은
영영 안 보게 되므로, 다시 해보기는 역할부터 고르게 하는 게 맞다. */
const restart = () => {
reset()
setStep("role")
}
const reset = (nextItem = item, nextRole = role) => {
clearTimers()
setTurns([])
setCardsUsed(0)
setOutcome("running")
setFinalPrice(nextItem.listPrice)
const start = nextRole === "seller" ? nextItem.listPrice : nextItem.marketLow
setOffer(start)
setOfferText(won(start))
}
const pickRole = (r: Role) => {
setRole(r)
reset(item, r)
setStep("play")
}
const pickItem = (it: DemoItem) => {
setItem(it)
reset(it, role)
}
// 새 말풍선이 붙으면 기록을 바닥으로 붙인다.
useEffect(() => {
logRef.current?.scrollTo({ top: logRef.current.scrollHeight, behavior: "smooth" })
}, [turns])
/** seller — 사용자가 공급사가 되어 값을 제시하고 에이전트가 응수한다. */
const submitOffer = () => {
if (outcome !== "running") return
const priced = round10(offer)
const mine: Turn = { id: turns.length, side: "counterpart", text: `${won(priced)}원까지는 맞춰드릴 수 있습니다.`, price: priced }
setTurns((prev) => [...prev, mine])
const res = respondToOffer(item, priced, cardsUsed)
const id = window.setTimeout(() => {
setTurns((prev) => [...prev, { id: prev.length, side: "agent", text: res.text, price: res.price }])
setFinalPrice(res.outcome === "award" ? priced : res.price)
setOutcome(res.outcome)
if (res.outcome === "running") {
setCardsUsed((c) => c + 1)
// 에이전트가 부른 값에서 다시 시작. 입력란도 같이 갱신해야 슬라이더와 숫자가 어긋나지 않는다.
setOffer(res.price)
setOfferText(won(res.price))
}
}, 620)
timers.current.push(id)
}
/** buyer — 목표가만 정하면 에이전트가 알아서 붙는 걸 지켜본다. */
const runBuyer = () => {
if (turns.length > 0) return
const target = round10(offer)
const run = simulateBuyerRun(item, target)
run.turns.forEach((turn, i) => {
const id = window.setTimeout(() => {
setTurns((prev) => [...prev, turn])
if (turn.price) setFinalPrice(turn.price)
if (i === run.turns.length - 1) setOutcome(run.outcome)
}, 500 + i * 900)
timers.current.push(id)
})
}
const done = outcome !== "running"
const { amount, rate } = savings(item, finalPrice)
const sliderMin = role === "seller" ? round10(item.anchor * 0.94) : round10(item.anchor * 0.94)
const sliderMax = role === "seller" ? item.listPrice : item.marketLow
/* 협상이 시작된 뒤 구매자는 목표가를 못 바꾼다 — 목표가를 정하고 위임하는 게 제품의 전제다.
공급사는 카드가 남아 있는 한 계속 제시할 수 있다. */
const inputLocked = role === "buyer" && turns.length > 0
/* 추천가는 시뮬레이터가 실제로 쓰는 수치에서 가져온다. 지어낸 숫자를 추천이라 부르면
사용자가 그대로 넣었을 때 결과가 설명되지 않는다. */
const recommended = role === "seller" ? item.marketLow : item.anchor
const recommendReason =
role === "seller"
? `같은 사양 시장 최저가 선입니다. 여기서부터 에이전트가 근거를 들고 응수합니다.`
: `결렬 없이 도달 가능한 최저선입니다. 더 낮추면 협상이 성사되지 않습니다.`
/** 슬라이더·직접입력·추천적용이 모두 거쳐가는 단일 확정 경로. 범위로 당기고 10원 단위로 맞춘다. */
const commitOffer = (raw: number) => {
if (inputLocked) return
const next = Number.isFinite(raw) && raw > 0 ? round10(Math.min(sliderMax, Math.max(sliderMin, raw))) : offer
setOffer(next)
/* 확정된 값은 콤마를 넣어 보여준다 — 화면의 다른 금액이 전부 won() 이라
여기만 1018800 으로 나오면 같은 값으로 안 읽힌다. 타이핑 중에는 숫자만 남긴다. */
setOfferText(won(next))
}
return (
<Section id="how-it-works" bg="stage" width="lg">
{/* 머리는 손으로 짜지 않고 SectionHeading 을 쓴다. 예전엔 여기서 직접 조판하느라
아이브로우 간격만 20px(다른 섹션은 12px)이었다 — 프리미티브를 우회하면
같은 관계에 두 번째 값이 생긴다. */}
{/* 두 문장은 의미 단위가 다르므로 줄을 명시적으로 나눈다.
자동 줄바꿈에 맡기면 어절이 어색하게 끊긴다.
예전 카피는 "에이전트는 정해진 기준을 벗어나지 않습니다" 였다. FAQ 2번
("에이전트가 우리 기준을 벗어나 합의해 버리면요?")을 헤드라인으로 끌어올린
것이었는데, 아직 생기지도 않은 반론에 가장 비싼 한 줄을 쓰고 있었다.
게다가 부정문이라 안 하는 일을 약속했다 — 체험을 권하는 자리에는 맞지 않는다.
방어는 FAQ 에 그대로 남아 있으니 여기서는 능력을 말한다. */}
<SectionHeading
align="center"
tone="stage"
eyebrow="Try it Yourself"
title={
<>
<span className="text-white">사용할수록 진화하는 에이전틱 협상,</span>
<br />
<span className="text-on-stage-soft/60">직접 경험해 보세요!</span>
</>
}
/>
{/* AnimatePresence mode="wait" 를 쓰지 않는다. 나가는 애니메이션이 끝나야 다음
화면이 마운트돼서 클릭 후 0.8초가 빈다 — 역할 선택처럼 즉시 반응해야 하는
전환에는 맞지 않는 도구다. 나가는 연출 없이 바로 갈아끼우고 짧게 들어온다. */}
{step === "role" ? (
<motion.div
key="role"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: EASE_OUT_EXPO }}
className="grid grid-cols-1 sm:grid-cols-2 gap-4"
>
<RoleCard
title="공급사로 해보기"
lead="직접 단가를 제시하면 에이전트가 응수합니다."
detail="협상 카드 3장 안에서 단가가 어디까지 조율되는지 확인하실 수 있습니다."
onClick={() => pickRole("seller")}
/>
<RoleCard
title="구매 담당자로 해보기"
lead="목표가만 정하면 에이전트가 협력사와 조율합니다."
detail="기준을 벗어난 목표가를 설정했을 때의 처리 방식도 함께 확인하실 수 있습니다."
onClick={() => pickRole("buyer")}
/>
</motion.div>
) : (
<motion.div
key="play"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: EASE_OUT_EXPO }}
/* 유리 패널로 띄운다. 컨트롤과 대화 로그가 섹션 배경 위에 그냥 얹혀 있으면
무대와 같은 색이라 경계가 없고, 조작할 수 있는 영역인지 읽히지 않는다.
재질은 무대 위 2차 CTA 와 같다 — 옅은 흰 채움 + backdrop-blur + 위쪽
안쪽 하이라이트 + 아래로 깔리는 그림자. 화면 안에 재질이 두 종류면 따로 논다.
테두리는 white/12 로 얕게 둔다. 이건 조작 요소가 아니라 면이라 버튼만큼
세울 필요가 없고, 세우면 오히려 카드가 버튼처럼 보인다. */
className={
"rounded-card border border-white/12 bg-white/[0.06] " +
"backdrop-blur-xl backdrop-saturate-150 " +
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),0_24px_60px_-24px_rgba(3,6,15,0.9)] " +
"p-6 sm:p-8 lg:p-10"
}
>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12">
{/* 좌 — 품목·컨트롤·현재가 */}
<div className="lg:col-span-5 space-y-8">
<div className="flex flex-wrap gap-2">
{DEMO_ITEMS.map((it) => (
<button
key={it.id}
type="button"
onClick={() => pickItem(it)}
className={`px-4 py-2 rounded-full text-[13px] font-semibold transition-colors ${
it.id === item.id
? "bg-white text-stage"
: "bg-white/5 text-on-stage-soft/75 hover:bg-white/10 hover:text-white"
}`}
>
{it.name}
</button>
))}
</div>
<div>
<div className="text-[13px] text-on-stage-soft/55 mb-2">{item.spec}</div>
<div className="flex items-baseline gap-2">
<span className="text-[24px] font-medium text-on-stage-soft/55">₩</span>
<span className="text-[52px] md:text-[64px] font-extrabold tracking-[-0.045em] leading-none tabular-nums">
{won(finalPrice)}
</span>
</div>
<div className="h-7 mt-2">
{amount > 0 && (
<span className="text-[15px] font-semibold text-counter">
▼ {won(amount)}원 · {rate.toFixed(1)}%
</span>
)}
</div>
</div>
{!done && (
<div className="space-y-4">
{/* 에이전트 추천가. 값을 지어내지 않고 시뮬레이터가 실제로 쓰는 수치를 쓴다 —
구매자는 anchor(결렬 없이 닿는 최저선, reachable = target >= anchor),
공급사는 marketLow(에이전트가 응수할 때 인용하는 값). 추천만 하고
확정은 사용자가 한다. 그게 이 데모가 보여줘야 할 관계다. */}
<div className="flex items-center justify-between gap-3 rounded-control border border-primary-on-stage/25 bg-primary-on-stage/10 px-4 py-3">
<div className="min-w-0">
<div className="text-[12px] font-semibold text-primary-on-stage mb-0.5">에이전트 추천</div>
<div className="text-[13px] text-on-stage-soft/75 break-keep">{recommendReason}</div>
</div>
<button
type="button"
onClick={() => commitOffer(recommended)}
disabled={inputLocked}
className="shrink-0 rounded-control bg-primary-on-stage/20 hover:bg-primary-on-stage/30 px-3 py-2 text-[13px] font-bold tabular-nums text-white transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
{won(recommended)}원 적용
</button>
</div>
<div className="flex items-baseline justify-between">
<label htmlFor="nego-offer" className="text-[14px] font-medium text-on-stage-soft/85">
{role === "seller" ? "제시 단가" : "목표 단가"}
</label>
{/* 직접 입력. 슬라이더만 두면 정확한 값을 못 넣는다 — 실제 구매 담당자는
"얼마까지"라는 숫자를 이미 들고 온다. 타이핑 중에는 clamp 하지 않고
blur/Enter 에서 확정한다. 입력 즉시 범위로 당기면 손가락과 싸운다. */}
<div className="flex items-baseline gap-1">
<input
id="nego-offer-text"
type="text"
inputMode="numeric"
aria-label={role === "seller" ? "제시 단가 직접 입력" : "목표 단가 직접 입력"}
value={offerText}
onChange={(e) => setOfferText(e.target.value.replace(/[^\d]/g, ""))}
onBlur={() => commitOffer(Number(offerText.replace(/[^\d]/g, "")))}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
commitOffer(Number(offerText.replace(/[^\d]/g, "")))
}
}}
disabled={inputLocked}
className="w-32 bg-transparent border-0 border-b-2 border-stage-line focus:border-primary-on-stage hover:border-on-stage-muted rounded-none px-0 py-1 text-right text-[18px] font-bold tabular-nums text-white outline-none transition-colors disabled:opacity-40"
/>
<span className="text-[15px] font-semibold text-on-stage-soft/70">원</span>
</div>
</div>
<input
id="nego-offer"
type="range"
aria-label={role === "seller" ? "제시 단가 조절" : "목표 단가 조절"}
min={sliderMin}
max={sliderMax}
step={10_000}
value={Math.min(sliderMax, Math.max(sliderMin, offer))}
onChange={(e) => commitOffer(Number(e.target.value))}
disabled={inputLocked}
className="w-full accent-primary disabled:opacity-40"
/>
<div className="flex justify-between text-[12px] text-on-stage-soft/45">
<span>{won(sliderMin)}</span>
<span>{won(sliderMax)}</span>
</div>
<Button
type="button"
variant="stage"
size="lg"
className="w-full"
onClick={role === "seller" ? submitOffer : runBuyer}
disabled={inputLocked}
>
{role === "seller" ? "이 값으로 제시하기" : "협상 시작"}
</Button>
</div>
)}
{role === "seller" && (
<div className="flex items-center gap-3">
<span className="text-[13px] text-on-stage-soft/55">협상 카드</span>
<div className="flex gap-1.5">
{Array.from({ length: MAX_CARDS }, (_, i) => (
<span
key={i}
className={`w-7 h-1.5 rounded-full ${i < MAX_CARDS - cardsUsed ? "bg-primary-on-stage" : "bg-white/15"}`}
/>
))}
</div>
</div>
)}
</div>
{/* 우 — 협상 기록 */}
<div className="lg:col-span-7">
<div
ref={logRef}
className="h-[360px] md:h-[440px] overflow-y-auto scrollbar-none flex flex-col gap-4 pr-1"
>
{turns.length === 0 && (
<div className="m-auto text-center text-[15px] text-on-stage-soft/45 max-w-xs break-keep">
{role === "seller"
? "단가를 정해 제시하면 에이전트가 근거와 함께 응수합니다."
: "목표가를 정하고 협상을 시작하면 턴마다 재생됩니다."}
</div>
)}
{turns.map((turn) => (
<motion.div
key={turn.id}
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: EASE_OUT_EXPO }}
className={`flex flex-col ${turn.side === "agent" ? "items-start" : "items-end"}`}
>
<span
className={`text-[12px] font-semibold mb-1.5 ${
turn.side === "agent" ? "text-primary-on-stage" : "text-counter"
}`}
>
{turn.side === "agent" ? "협상 에이전트" : role === "seller" ? "나 (협력사)" : "협력사"}
</span>
<div
className={`max-w-[88%] rounded-card px-5 py-4 text-[15px] leading-[1.6] break-keep ${
turn.side === "agent"
? "bg-primary text-white"
: "bg-counter-surface text-counter border border-counter/20"
}`}
>
{turn.text}
</div>
</motion.div>
))}
</div>
{done && (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: EASE_OUT_EXPO }}
className="mt-6 border-t border-stage-line pt-6"
>
<Typography variant="cardTitle" className="text-white mb-1.5">
{outcome === "award"
? `낙찰 · ${won(amount)}원 절감`
: "개찰 — 낙찰자 미정으로 마감했습니다"}
</Typography>
<p className="text-[15px] text-on-stage-soft/70 leading-[1.6] break-keep mb-6">
{outcome === "award"
? `최초 제시가 ${won(item.listPrice)}원에서 ${rate.toFixed(1)}% 내려왔습니다. 실제 운영에서는 협력사 수만큼 이 협상이 동시에 진행됩니다.`
: "기준을 벗어나는 값은 억지로 맞추지 않고 담당자에게 넘깁니다. 결렬이 아니라 사람이 판단할 자리를 남기는 것입니다."}
</p>
<div className="flex flex-col sm:flex-row gap-3">
<Button href="#contact-section" variant="stage" size="lg" className="group">
<span>우리 품목으로 상담받기</span>
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
</Button>
<Button type="button" variant="stageGhost" size="lg" onClick={restart}>
<RotateCcw className="w-4 h-4" />
<span>역할 바꿔서 다시</span>
</Button>
</div>
</motion.div>
)}
</div>
</div>
</motion.div>
)}
<p className="mt-12 text-center text-[13px] text-on-stage-soft/40">
실제 판정 규칙(앵커링가·협상 카드 3장·개찰)을 그대로 옮긴 데모입니다. 금액은 예시입니다.
</p>
</Section>
)
}
function RoleCard({
title,
lead,
detail,
onClick,
}: {
title: string
lead: string
detail: string
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className="group text-left rounded-card border border-stage-line bg-white/[0.03] hover:bg-white/[0.07] hover:border-white/25 transition-colors p-8 md:p-10"
>
<Typography variant="heading" as="h3" className="text-white mb-3">
{title}
</Typography>
<p className="text-[16px] text-on-stage-soft/85 leading-[1.6] break-keep mb-2">{lead}</p>
<p className="text-[14px] text-on-stage-soft/55 leading-[1.6] break-keep">{detail}</p>
<span className="mt-6 inline-flex items-center gap-1.5 text-[16px] font-semibold text-primary-on-stage">
시작하기
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
</span>
</button>
)
}

View File

@ -2,9 +2,8 @@ import { motion } from "motion/react"
import { GitMerge, RefreshCw, Scale, type LucideIcon } from "lucide-react" import { GitMerge, RefreshCw, Scale, type LucideIcon } from "lucide-react"
import { Section } from "@/components/ui/section" import { Section } from "@/components/ui/section"
import { HEADING_GAP, SectionHeading } from "@/components/ui/section-heading" import { SectionHeading } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography" import { Typography } from "@/components/ui/typography"
import { OrbitRing } from "@/components/ui/orbit-ring"
import { useFadeUp } from "@/lib/motion" import { useFadeUp } from "@/lib/motion"
/** 강화학습 섹션 — 협상할수록 좋아진다는 3개 필러 카드. */ /** 강화학습 섹션 — 협상할수록 좋아진다는 3개 필러 카드. */
@ -12,40 +11,22 @@ export function Reinforcement() {
const fadeUp = useFadeUp() const fadeUp = useFadeUp()
return ( return (
<Section id="reinforcement" bg="stage" width="lg"> <Section id="reinforcement" bg="surface" bordered className="border-b border-line">
{/* 순환 링은 원래 히어로에 있었는데, 루프가 의미상 속하는 자리는 여기다 — <motion.div {...fadeUp} className="mb-20">
"협상할수록 좋아진다"를 말하는 섹션이고, 히어로는 이미 세로 예산이 포화였다. */} <SectionHeading
{/* 머리가 그리드 셀 안에 들어가 있어서 여기만 gap="none" 이다. 그리드 아이템은 className="text-center md:text-left"
독립 서식 문맥이라 마진이 상쇄되지 않고, 머리에 mb 를 걸면 items-center 의 eyebrow="REINFORCEMENT LEARNING"
세로 정렬을 밀어버린다. 섹션 간격은 그리드 래퍼가 대신 진다. */} title="협상할수록, 더 좋은 조건으로"
<motion.div {...fadeUp} className={`grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16 items-center ${HEADING_GAP}`}> description={
<div className="lg:col-span-7"> <>
<SectionHeading 잘 깎는 노하우는 담당자 머릿속에만 남고, 성과는 그날의 감정과 컨디션에 흔들립니다. negotium은 진행된 모든 거래
className="text-center lg:text-left" 데이터와 파트너 거절 피드백을 <span className="font-bold text-primary">강화학습 기술</span>에 투입합니다.
gap="none" 무턱대고 가격을 후려쳐 상대방의 반발만 사고 결렬로 치닫는 경직된 협상이 아니라, 파트너사가 충분히 받아들일 수
tone="stage" 있는 범위 내에서 최선의 마진율을 뽑아내도록 조율 페이스를 학습합니다.
eyebrow="Reinforcement Learning" </>
/* 쉼표에서 줄을 명시적으로 끊는다. break-keep + text-balance 에 맡기면 }
두 줄 길이를 맞추려고 "협상할수록, 더 / 좋은 조건으로" 처럼 쉼표를 descriptionClassName="mt-8 max-w-3xl"
넘어가서 끊어진다 — 조건절과 결과절이 한 줄에 섞여 의미가 흐려진다. */ />
title={
<>
협상할수록,
<br />더 좋은 조건으로
</>
}
description={
<>
잘 깎는 요령은 담당자 머릿속에만 남고, 결과는 그날 컨디션에 흔들립니다. 네고시움은 지난 거래 기록과
협력사가 거절한 지점을 <span className="font-semibold text-on-stage">강화학습</span>에 넣습니다. 어디까지가
받아들여지는 선인지를 데이터로 익혀서, 결렬 없이 도달 가능한 최선을 찾아갑니다.
</>
}
/>
</div>
<div className="lg:col-span-5 flex justify-center">
<OrbitRing className="w-[280px] md:w-[340px] h-auto" />
</div>
</motion.div> </motion.div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
@ -63,15 +44,15 @@ function PillarCard({ pillar, delay }: { pillar: Pillar; delay: number }) {
const fadeUp = useFadeUp(delay) const fadeUp = useFadeUp(delay)
return ( return (
<motion.div {...fadeUp} className="bg-stage-raised border border-stage-line p-8 rounded-card flex flex-col justify-between h-[240px]"> <motion.div {...fadeUp} className="bg-white p-8 rounded-[28px] flex flex-col justify-between h-[240px]">
<div className="w-11 h-11 rounded-control bg-primary/15 text-on-stage flex items-center justify-center"> <div className="w-12 h-12 rounded-2xl bg-primary/5 text-primary flex items-center justify-center">
<pillar.icon className="w-5 h-5" /> <pillar.icon className="w-6 h-6" />
</div> </div>
<div> <div>
<Typography variant="cardTitle" className="mb-2 text-on-stage"> <Typography variant="cardTitle" className="mb-2">
{pillar.title} {pillar.title}
</Typography> </Typography>
<Typography variant="caption" className="text-on-stage-soft"> <Typography variant="caption" className="text-ink-soft">
{pillar.description} {pillar.description}
</Typography> </Typography>
</div> </div>
@ -87,12 +68,12 @@ const PILLARS: Pillar[] = [
}, },
{ {
icon: RefreshCw, icon: RefreshCw,
title: "쓸수록 올라가는 기준선", title: "지속적인 전략 최적화",
description: "협상이 쌓일수록 기준선이 올라갑니다. 운영 기간이 그대로 협상력이 됩니다.", description: "전략 강화 학습을 통해 기준선이 상승하고, 점진적으로 개선됩니다. 시간이 곧 협상력이 됩니다.",
}, },
{ {
icon: GitMerge, icon: GitMerge,
title: "결렬 없는 합의", title: "낙찰 성사율 극대화",
description: "성사되지 않을 선까지 밀지 않습니다. 합의 가능한 범위 안에서 최선을 찾습니다.", description: "무리하게 후려쳐 관계를 깨는 대신, 성사되는 선에서 최대한 끌어냅니다.",
}, },
] ]

View File

@ -0,0 +1,158 @@
import { useState } from "react"
import { ArrowRight, Clock, ShieldCheck } from "lucide-react"
import { RangeSlider } from "@/components/ui/range-slider"
import { Section } from "@/components/ui/section"
import { SectionHeading } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography"
/** 도입 ROI 시뮬레이터 — 예산·협력사 수 슬라이더로 예상 절감액을 즉시 계산. */
export function ROISimulator() {
// 연간 총 구매 예산(억 원)
const [budget, setBudget] = useState(50)
// 관리 중인 협력사 수
const [suppliers, setSuppliers] = useState(50)
// 보수적 절감율 2.8%~4.5% — 협력사가 많을수록 병렬 대안이 늘어 상향
const savingRate = Math.min(0.045, 0.028 + (suppliers / 300) * 0.015)
const estimatedSavingsValue = budget * savingRate
// 협력사당 왕복 흥정 수작업 절약분을 ~6.5시간으로 잡은 추산
const savedHours = Math.round(suppliers * 6.5)
return (
<Section id="benchmarks" width="lg" bordered className="border-b border-line">
<SectionHeading
align="center"
className="mb-24"
eyebrow="ROI SIMULATION"
title={
<>
negotium 도입 시 <br />
예상 절감액 확인하기
</>
}
description={
<>
아직 실제 도입 실증 사례가 없는 신규 카테고리여도 신뢰할 수 있습니다. <br />
연간 구매 예산과 공급사 수만 입력해 보수적인 시뮬레이션 성과를 즉시 시각화해 보세요.
</>
}
descriptionClassName="mt-4 text-[17px] max-w-2xl"
/>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 items-start">
{/* 좌: 입력 슬라이더 */}
<div className="lg:col-span-6 space-y-12">
<div className="space-y-4">
<div className="flex justify-between items-center">
<label className="text-[16px] font-bold text-ink">연간 총 구매 예산</label>
<span className="text-lg font-black text-primary font-mono">{formatCurrency(budget)}</span>
</div>
<Typography variant="caption">원자재, 부자재, 가공비, 운송비 등 협상에 연동할 총 연간 구매 규모입니다.</Typography>
<div className="pt-2">
<RangeSlider min={5} max={1000} step={5} value={budget} onChange={setBudget} />
<div className="flex justify-between text-[11px] text-ink-faint font-bold pt-2">
<span>5억 원</span>
<span>500억 원</span>
<span>1조 원</span>
</div>
</div>
</div>
<div className="space-y-4">
<div className="flex justify-between items-center">
<label className="text-[16px] font-bold text-ink">관리 중인 총 협력사 수</label>
<span className="text-lg font-black text-primary font-mono">{suppliers}개사</span>
</div>
<Typography variant="caption">AI 봇 "밀당"이 동시에 일대일로 자동 조율할 잠재 공급사 수량입니다.</Typography>
<div className="pt-2">
<RangeSlider min={5} max={300} step={5} value={suppliers} onChange={setSuppliers} />
<div className="flex justify-between text-[11px] text-ink-faint font-bold pt-2">
<span>5개사</span>
<span>150개사</span>
<span>300개사</span>
</div>
</div>
</div>
<div className="flex gap-3 bg-surface p-5 rounded-2xl border border-line">
<ShieldCheck className="w-5 h-5 text-positive flex-shrink-0 mt-0.5" />
<Typography variant="caption" className="text-ink-soft">
본 시뮬레이션은 글로벌 최저가 스캔(LPS) 시장 표준 및 다수 B2B 자율 협상 데이터의 보수적인 평균 수치(3.1% ~ 4.2%
절감)를 적용하여 산정되었습니다. 실사 수준의 검증은 파일 연동을 통해 무상 지원됩니다.
</Typography>
</div>
</div>
{/* 우: 결과 지표 */}
<div className="lg:col-span-6 lg:pl-10 space-y-12">
<div className="space-y-3">
<Typography variant="micro" className="text-primary block">
ESTIMATED ANNUAL SAVINGS
</Typography>
<h3 className="text-sm font-semibold text-ink-muted">매년 절감 가능한 구매 원가 (보수적 추산)</h3>
<div className="text-3xl sm:text-[44px] font-black text-primary font-mono tracking-tight leading-tight pt-1">
{formatSavings(estimatedSavingsValue)}
</div>
<Typography variant="caption" className="text-ink-soft font-medium pt-1">
사각지대에 방치되던 꼬리 거래(Tail Spend)의 상시 병렬 협상 및 인터넷 실시간 앵커가 시세 연동을 통해, 기존 수동
흥정으로 놓치고 있던 원가 이탈을 완벽히 방어해냅니다.
</Typography>
</div>
<div className="border-t border-line pt-8 space-y-3">
<div className="flex items-center gap-3">
<Clock className="w-5 h-5 text-ink-muted" />
<span className="text-xs font-bold text-ink-soft">
연간 실무 단축 시간: <b className="text-ink font-mono text-sm ml-1">{savedHours.toLocaleString()}시간</b>
</span>
</div>
<Typography variant="caption">
메일 전송, 유선 흥정, 엑셀 취합 등에 낭비되던 소모적인 절차가 실시간 링크 전송과 AI 봇의 24시간 자율 대응으로
완전히 생략되어 고부가가치 전략 구매 기획에 전념할 수 있습니다.
</Typography>
</div>
<div className="border-t border-line pt-8 flex items-center justify-between">
<div>
<h4 className="text-xs font-bold text-ink">예상 도입 ROI 회수 기간</h4>
<p className="text-[11px] text-ink-muted font-semibold mt-1">도입 즉시 마진 확보 개시</p>
</div>
<a
href="#contact-section"
className="inline-flex items-center gap-2 text-xs font-bold text-primary hover:gap-3 transition-all"
>
<span>무상 맞춤 검증 신청하기</span>
<ArrowRight className="w-4 h-4" />
</a>
</div>
</div>
</div>
</Section>
)
}
/** 억 원 단위 → "50억 원" / "1조 200억 원" 표기 */
function formatCurrency(val: number) {
if (val >= 100) {
const b = Math.floor(val / 100)
const m = val % 100
return m > 0 ? `${b}조 ${m}00억 원` : `${b}조 원`
}
return `${val}억 원`
}
/** 억 원 단위 → "1억 5,500만 원" 표기 */
function formatSavings(val: number) {
const rawWon = val * 100000000
if (rawWon >= 100000000) {
const eonPart = Math.floor(rawWon / 100000000)
const manPart = Math.floor((rawWon % 100000000) / 10000)
if (manPart > 0) {
return `${eonPart}억 ${manPart.toLocaleString()}만 원`
}
return `${eonPart}억 원`
}
return `${Math.floor(rawWon / 10000).toLocaleString()}만 원`
}

View File

@ -6,9 +6,7 @@ import { cn } from "@/lib/utils"
// CTA 버튼 토큰. 랜딩의 CTA 는 전부 앵커 스크롤이라 href 를 주면 <a> 로 렌더한다 // CTA 버튼 토큰. 랜딩의 CTA 는 전부 앵커 스크롤이라 href 를 주면 <a> 로 렌더한다
// (SSG 특성상 하이드레이션 전에도 동작). // (SSG 특성상 하이드레이션 전에도 동작).
const buttonVariants = cva( const buttonVariants = cva(
/* transition-colors 로 좁힌다. transition-all 이면 헤더 CTA 가 무대↔라이트로 variant 를 "inline-flex items-center justify-center gap-2 transition-all cursor-pointer disabled:opacity-50",
통째로 바꿀 때 배경이 투명에서 차오르며 플리커로 읽힌다. */
"inline-flex items-center justify-center gap-2 transition-colors duration-200 cursor-pointer disabled:opacity-50",
{ {
variants: { variants: {
variant: { variant: {
@ -16,40 +14,13 @@ const buttonVariants = cva(
secondary: "bg-fill hover:bg-fill-hover text-ink-soft hover:text-ink", secondary: "bg-fill hover:bg-fill-hover text-ink-soft hover:text-ink",
// 밝은 배경 위 반투명 보조 버튼 (글라스 히어로) // 밝은 배경 위 반투명 보조 버튼 (글라스 히어로)
glass: "bg-white/70 hover:bg-white text-ink-soft hover:text-ink border border-white shadow-sm", glass: "bg-white/70 hover:bg-white text-ink-soft hover:text-ink border border-white shadow-sm",
// 다크 무대 위 1차 CTA
stage: "bg-primary hover:bg-primary-deep text-white",
/* 다크 무대 위 2차 CTA — 흰 알약.
테두리를 stage-line(#1E2A52)으로 두면 안 된다. 그 색은 무대 바닥(#070E24) 위
카드용이라, 방사형 광원부(#16205A)에 앉는 GNB 자리에서는 배경과 대비가 1.09:1 로
사라진다. 채움도 없으면 남는 건 흰 글자뿐이라 옆 nav 링크와 구분되지 않는다.
그래서 무대색 토큰이 아니라 흰색 알파로 잡는다 — 무대 어디에 놓여도 같이 성립한다.
옅은 채움이 테두리보다 "누를 수 있는 것"을 더 강하게 전달하므로 둘 다 준다. */
/* 유리(glassmorphism). 배경 영상이 비쳐 보이되 글자는 읽히는 상태를 만든다.
구성은 넷이다 — 옅은 흰 채움, 뒤를 흐리는 backdrop-blur, 위쪽 1px 안쪽 하이라이트
(유리 모서리에 빛이 걸리는 부분), 아래로 깔리는 부드러운 그림자(떠 있는 느낌).
채도를 살짝 올리면(saturate) 뒤가 흐려지며 빠지는 색기가 돌아온다.
테두리는 white/40 에서 더 못 내린다. /30 으로 낮추면 무대 광원부(#16205A) 대비가
2.60:1 로 떨어져 WCAG 1.4.11(UI 경계 3:1)에 미달한다 — 유리 느낌 때문에 버튼이
안 보이던 원래 문제로 돌아간다. 투명감은 채움과 blur 로 내고 테두리는 지킨다.
hover 는 흰색 반전이 아니라 채움을 밝힌다. 유리는 뒤가 비치는 게 정체성이라
불투명하게 뒤집으면 그 순간 유리가 아니게 된다. */
stageGhost:
"bg-white/12 hover:bg-white/25 text-white border border-white/40 hover:border-white/60 " +
"backdrop-blur-md backdrop-saturate-150 " +
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.30),0_8px_24px_-10px_rgba(3,6,15,0.7)]",
}, },
/* 레퍼런스(statworx) 실측: 10.4px / 600 / 알약(radius 39px) / 패딩 12·24 / 높이 33px. // radius 는 사이즈 무관 rounded-2xl(16px) 로 통일 — 알약형 금지
작고 조밀한 버튼이 확신 있어 보인다 — 크고 굵은 버튼은 설득이 아니라 호소로 읽힌다.
한글 보정: 10.4px 는 판독이 어려워 12~13px 로 올리고 높이도 터치 타깃까지 확보한다.
(기존 주석의 "알약형 금지" 규칙은 레퍼런스 채택으로 폐기했다.) */
size: { size: {
pill: "px-5 py-2.5 text-[12px] font-semibold rounded-full", pill: "px-4.5 py-2.5 text-sm font-semibold rounded-2xl",
md: "px-6 py-3 text-[12px] font-semibold rounded-full", md: "px-6 py-2.5 text-xs font-bold rounded-2xl",
lg: "px-7 py-3.5 text-[13px] font-semibold rounded-full", lg: "px-8 py-4.5 text-base font-bold rounded-2xl",
xl: "px-8 py-4 text-[14px] font-semibold rounded-full", xl: "px-10 py-5 text-base font-bold rounded-2xl",
stageRound: "px-7 py-3.5 text-[13px] font-semibold rounded-full",
}, },
}, },
defaultVariants: { variant: "primary", size: "lg" }, defaultVariants: { variant: "primary", size: "lg" },

View File

@ -1,228 +0,0 @@
import { useEffect, useId, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, Loader2, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Typography } from "@/components/ui/typography"
import { EASE_OUT_EXPO } from "@/lib/motion"
import { isEmailLike, submitLead } from "@/lib/lead"
/*
* 데모 요청 모달 — 이름·이메일만 받는 리드 캡처.
*
* 상담 신청(contact.tsx)은 회사명·담당자·연락처·문의내용까지 받는 고관여 폼이다.
* 이건 그 앞단이다. 필드가 늘수록 이탈이 늘기 때문에 두 칸에서 멈춘다 — 영업이
* 첫 연락을 하는 데 필요한 최소가 이름과 이메일이고, 나머지는 통화에서 채운다.
*
* 전체 페이지로 보내지 않고 모달로 띄우는 이유도 같다. 히어로에서 관심이 생긴
* 순간에 그 자리에서 받아야 한다. 스크롤로 내려보내면 그 사이에 식는다.
*/
export function DemoRequestModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [website, setWebsite] = useState("") // 봇 함정
const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle")
const dialogRef = useRef<HTMLDivElement>(null)
const firstFieldRef = useRef<HTMLInputElement>(null)
const restoreFocusTo = useRef<HTMLElement | null>(null)
const reduce = useReducedMotion()
const titleId = useId()
/* 열릴 때마다 초기화. 남겨두면 성공 화면이 다시 뜨거나 이전 에러가 붙어 나온다. */
useEffect(() => {
if (!open) return
setStatus("idle")
restoreFocusTo.current = document.activeElement as HTMLElement | null
const t = setTimeout(() => firstFieldRef.current?.focus(), 60)
return () => clearTimeout(t)
}, [open])
/* 배경 스크롤 잠금. 모달 뒤에서 페이지가 움직이면 모달이 페이지의 일부처럼 읽힌다. */
useEffect(() => {
if (!open) return
const prev = document.body.style.overflow
document.body.style.overflow = "hidden"
return () => {
document.body.style.overflow = prev
restoreFocusTo.current?.focus?.()
}
}, [open])
/* Esc 로 닫기 + Tab 을 모달 안에 가둔다. 가두지 않으면 뒤 페이지의 링크로 포커스가
빠져나가고, 키보드·스크린리더 사용자는 자기가 어디 있는지 알 수 없게 된다. */
useEffect(() => {
if (!open) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose()
return
}
if (e.key !== "Tab") return
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(
'button:not([disabled]), input:not([disabled]), a[href]',
)
if (!focusables?.length) return
const first = focusables[0]
const last = focusables[focusables.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
document.addEventListener("keydown", onKeyDown)
return () => document.removeEventListener("keydown", onKeyDown)
}, [open, onClose])
const valid = name.trim().length > 0 && isEmailLike(email)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!valid || status === "submitting") return
setStatus("submitting")
const result = await submitLead({ source: "demo-request", name, email, website })
setStatus(result.ok ? "success" : "error")
}
return (
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-6">
<motion.div
className="absolute inset-0 bg-stage-deep/80 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
/>
<motion.div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="relative w-full max-w-md bg-white rounded-card p-8 sm:p-10 shadow-2xl"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 16, scale: 0.98 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.99 }}
transition={{ duration: 0.28, ease: EASE_OUT_EXPO }}
>
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="absolute top-4 right-4 p-2 text-ink-muted hover:text-ink transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
{status === "success" ? (
<div className="text-center py-4">
<div className="w-12 h-12 rounded-full bg-positive-soft text-positive flex items-center justify-center mx-auto mb-5">
<Check className="w-6 h-6" />
</div>
<Typography variant="cardTitle" as="h2" id={titleId} className="mb-3">
메일 앱에서 요청을 전송해 주세요
</Typography>
<Typography variant="small">
데모 요청 메일 작성 창을 열었습니다. 전송해 주시면 입력하신 이메일로 사용해보실 수 있는 Demo를 보내드립니다.
</Typography>
</div>
) : (
<>
{/* 제목은 여는 버튼과 같은 말을 쓴다. 버튼이 "받기"인데 여기가 "요청"이면
전환 순간에 다른 화면에 온 것처럼 읽힌다. */}
<Typography variant="cardTitle" as="h2" id={titleId} className="mb-2">
실제 데모 받기
</Typography>
<Typography variant="small" className="mb-8">
성함과 이메일만 남겨주시면, 사용해보실 수 있는 Demo를 보내드립니다!
</Typography>
<form onSubmit={handleSubmit} className="space-y-7">
{/* 봇 함정. 화면에서 감추되 display:none 은 쓰지 않는다 — 일부 봇은
숨겨진 필드를 걸러낸다. 스크린리더에는 aria-hidden + tabIndex 로 숨긴다. */}
<div className="absolute w-px h-px -left-[9999px] overflow-hidden" aria-hidden>
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
value={website}
onChange={(e) => setWebsite(e.target.value)}
/>
</div>
<div>
<label htmlFor="lead-name" className="block text-[13px] font-semibold text-ink-soft mb-2">
성함
</label>
<Input
ref={firstFieldRef}
id="lead-name"
name="name"
type="text"
required
autoComplete="name"
placeholder="예: 홍길동"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={status === "submitting"}
/>
</div>
<div>
<label htmlFor="lead-email" className="block text-[13px] font-semibold text-ink-soft mb-2">
이메일
</label>
<Input
id="lead-email"
name="email"
type="email"
required
autoComplete="email"
placeholder="예: hong@company.co.kr"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={status === "submitting"}
/>
</div>
{status === "error" && (
<p role="alert" className="text-[13px] text-ink-soft leading-[1.6] break-keep">
지금은 접수가 어렵습니다.{" "}
<a href="#contact-section" onClick={onClose} className="text-primary font-semibold underline">
상담 신청
</a>
으로 남겨주시면 동일하게 연락드립니다.
</p>
)}
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
disabled={!valid || status === "submitting"}
>
{status === "submitting" ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>접수 중</span>
</>
) : (
<span>데모 신청</span>
)}
</Button>
</form>
</>
)}
</motion.div>
</div>
)}
</AnimatePresence>
)
}

View File

@ -3,13 +3,13 @@ import type * as React from "react"
/** 데스크톱 브라우저 목업 프레임 — 신호등 버튼 + 주소창 + 16:10 워크스페이스. */ /** 데스크톱 브라우저 목업 프레임 — 신호등 버튼 + 주소창 + 16:10 워크스페이스. */
function BrowserFrame({ url, children }: { url: string; children: React.ReactNode }) { function BrowserFrame({ url, children }: { url: string; children: React.ReactNode }) {
return ( return (
<div className="bg-white rounded-card border border-line-strong shadow-[0_12px_40px_rgba(0,0,0,0.06)] overflow-hidden w-full max-w-2xl"> <div className="bg-white rounded-2xl border border-line-strong shadow-[0_12px_40px_rgba(0,0,0,0.06)] overflow-hidden w-full max-w-2xl">
<div className="bg-fill px-4 py-3.5 flex items-center justify-between border-b border-line-strong"> <div className="bg-fill px-4 py-3.5 flex items-center justify-between border-b border-line-strong">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-[#FF5F56] inline-block" /> <span className="w-3 h-3 rounded-full bg-[#FF5F56] inline-block" />
<span className="w-3 h-3 rounded-full bg-[#FFBD2E] inline-block" /> <span className="w-3 h-3 rounded-full bg-[#FFBD2E] inline-block" />
<span className="w-3 h-3 rounded-full bg-[#27C93F] inline-block" /> <span className="w-3 h-3 rounded-full bg-[#27C93F] inline-block" />
<span className="text-[11px] text-ink-muted font-mono ml-3 font-semibold bg-white px-3 py-1 rounded-control border border-line-strong truncate max-w-[180px] sm:max-w-none"> <span className="text-[11px] text-ink-muted font-mono ml-3 font-semibold bg-white px-3 py-1 rounded-md border border-line-strong truncate max-w-[180px] sm:max-w-none">
{url} {url}
</span> </span>
</div> </div>
@ -22,9 +22,9 @@ function BrowserFrame({ url, children }: { url: string; children: React.ReactNod
/** 모바일 폰 목업 프레임 — 노치 포함 9:18 스크린. */ /** 모바일 폰 목업 프레임 — 노치 포함 9:18 스크린. */
function PhoneFrame({ children }: { children: React.ReactNode }) { function PhoneFrame({ children }: { children: React.ReactNode }) {
return ( return (
<div className="relative bg-black rounded-card p-3 shadow-[0_20px_50px_rgba(0,0,0,0.15)] border-4 border-line-strong w-full max-w-[290px] aspect-[9/18] overflow-hidden flex flex-col"> <div className="relative bg-black rounded-[48px] p-3 shadow-[0_20px_50px_rgba(0,0,0,0.15)] border-4 border-line-strong w-full max-w-[290px] aspect-[9/18] overflow-hidden flex flex-col">
{/* 노치는 화면 '안쪽'에 작은 아일랜드로 — 베젤과 붙으면 검은 덩어리처럼 보인다 */} {/* 노치는 화면 '안쪽'에 작은 아일랜드로 — 베젤과 붙으면 검은 덩어리처럼 보인다 */}
<div className="bg-white rounded-card flex-1 overflow-hidden relative flex flex-col justify-center items-center"> <div className="bg-white rounded-[36px] flex-1 overflow-hidden relative flex flex-col justify-center items-center">
<div className="absolute top-1 left-1/2 -translate-x-1/2 bg-black/70 h-0.5 w-3.5 rounded-full z-20" /> <div className="absolute top-1 left-1/2 -translate-x-1/2 bg-black/70 h-0.5 w-3.5 rounded-full z-20" />
{children} {children}
</div> </div>

View File

@ -2,33 +2,16 @@ import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
/* // 문의 폼 필드 공통 스타일 — 테두리 없는 회색 채움, 포커스 시 흰 배경 + 파란 링.
* 문의 폼 필드.
*
* 회색 채움 박스는 어느 템플릿에나 있다. 밑줄 필드로 바꾸면 같은 기능인데 훨씬
* 정돈돼 보인다 — 폼이 "입력 상자 더미"가 아니라 한 장의 문서처럼 읽힌다.
*
* 밑줄 폼의 알려진 위험은 입력란으로 안 보이는 것(어포던스 손실)인데,
* 라벨을 항상 위에 두고 밑줄을 2px 로 두껍게 잡아 보완했다.
* 두께를 항상 2px 로 고정해서 포커스 때 레이아웃이 흔들리지 않는다.
*
* 글자 16px 은 취향이 아니라 필수다 — iOS 사파리는 16px 미만 입력에 포커스하면
* 화면을 자동 확대해서, 광고로 들어온 모바일 사용자의 폼 이탈을 만든다.
*/
const fieldClass = const fieldClass =
"w-full bg-transparent border-0 border-b-2 border-line-strong rounded-none px-0 py-3 " + "w-full px-5 py-3.5 rounded-2xl bg-surface hover:bg-fill focus:bg-white border-0 focus:ring-2 focus:ring-primary/20 text-sm font-semibold text-ink transition-all placeholder:text-ink-faint outline-none"
"text-[16px] font-normal text-ink placeholder:text-ink-faint placeholder:font-normal " +
"outline-none transition-colors hover:border-ink-muted focus:border-primary " +
"disabled:opacity-50 disabled:cursor-not-allowed"
/* ComponentPropsWithRef 로 두면 ref 가 그대로 전달된다 (React 19 는 forwardRef 불필요). function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
데모 요청 모달이 열릴 때 첫 필드로 포커스를 옮기려면 ref 가 필요하다. */
function Input({ className, ...props }: React.ComponentPropsWithRef<"input">) {
return <input className={cn(fieldClass, className)} {...props} /> return <input className={cn(fieldClass, className)} {...props} />
} }
function Textarea({ className, ...props }: React.ComponentPropsWithRef<"textarea">) { function Textarea({ className, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
return <textarea className={cn(fieldClass, "resize-none leading-[1.6]", className)} {...props} /> return <textarea className={cn(fieldClass, "resize-none", className)} {...props} />
} }
export { Input, Textarea } export { Input, Textarea }

View File

@ -1,179 +0,0 @@
import { useEffect, useRef, useState } from "react"
import { motion, useReducedMotion } from "motion/react"
import { Check } from "lucide-react"
import { DEMO_ITEMS, won } from "@/lib/negotiation-sim"
import { EASE_OUT_EXPO } from "@/lib/motion"
/*
* 협상 리플레이 — 자동 재생 결과 화면.
*
* 예전에는 negotiation_annotated_3d.mp4(677K)를 썼다. 영상이라 두 가지를 못 고쳤다.
* - 화자가 "아이마켓 구매 MD" 로 픽셀에 구워져 있었다. POC 고객사 이름이라
* 다른 MRO 회사에 영업할 때 쓸 수 없다.
* - 색이 브랜드 팔레트 밖이었다. 실측하면 말풍선 #5A68CD, 목표가 라벨 #B96175,
* 배지 #DB840B — 셋 다 토큰에 없는 색이고, 정작 브랜드의 핵심 대비 장치인
* 민트(#0FFFD6, 상대편 색)는 한 번도 안 쓰였다.
*
* 컴포넌트로 옮기면 둘 다 뿌리에서 사라진다. 이름은 상수고 색은 토큰이라,
* 팔레트를 바꾸면 여기도 같이 바뀐다. 에셋 677K 도 0 이 된다.
*
* 숫자는 지어내지 않고 DEMO_ITEMS[0] 을 그대로 쓴다 — 위쪽 인터랙티브 데모와 같은
* 품목·같은 가격이라, 둘을 다 본 사람에게 앞뒤가 맞는다.
*/
const ITEM = DEMO_ITEMS[0]
const FINAL = ITEM.target // 1,080,000 — 데모의 목표가와 동일
const SAVED = ITEM.listPrice - FINAL
const RATE = (SAVED / ITEM.listPrice) * 100
type Line = { side: "buyer" | "supplier"; text: string; price: number }
/* 화자는 "구매 담당자" 로 둔다. 회사명을 넣으면 그 회사 전용 자산이 되고,
특정 고객사 이름은 영업 대상이 바뀔 때마다 못 쓰게 된다. */
const LINES: Line[] = [
{ side: "supplier", text: `원자재가 올라서 이번 분기는 ${won(ITEM.listPrice)}원이 최선입니다.`, price: ITEM.listPrice },
{ side: "buyer", text: `같은 사양 시장 최저가가 ${won(ITEM.marketLow)}원입니다. 연간 물량을 보증하면 어느 선까지 가능하신가요?`, price: ITEM.marketLow },
{ side: "supplier", text: `공정상 한 번에 내리긴 어렵고, ${won(1_140_000)}원까지는 조정하겠습니다.`, price: 1_140_000 },
{ side: "buyer", text: `목표가까지 얼마 남지 않았습니다. ${won(FINAL)}원이면 지금 바로 낙찰 처리하겠습니다.`, price: FINAL },
{ side: "supplier", text: `좋습니다. ${won(FINAL)}원으로 맞추겠습니다.`, price: FINAL },
]
const STEP_MS = 1600
/** 마지막 줄 뒤 결과 카드가 머무는 시간. 짧으면 결론을 못 읽고 지나간다. */
const HOLD_MS = 3200
export function NegotiationReplay({ className }: { className?: string }) {
const reduce = useReducedMotion()
const [shown, setShown] = useState(reduce ? LINES.length : 0)
const rootRef = useRef<HTMLDivElement>(null)
const logRef = useRef<HTMLDivElement>(null)
const [inView, setInView] = useState(false)
/* 화면 밖에서는 돌리지 않는다. 페이지 최하단이라 대부분의 시간 동안 보이지 않는데
타이머만 계속 도는 건 배터리를 쓰는 일이다. */
useEffect(() => {
const el = rootRef.current
if (!el) return
const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), { threshold: 0.25 })
io.observe(el)
return () => io.disconnect()
}, [])
useEffect(() => {
if (reduce || !inView) return
const done = shown >= LINES.length
const id = window.setTimeout(() => setShown((n) => (n >= LINES.length ? 0 : n + 1)), done ? HOLD_MS : STEP_MS)
return () => clearTimeout(id)
}, [shown, inView, reduce])
// 새 줄이 붙으면 바닥으로 붙인다.
useEffect(() => {
logRef.current?.scrollTo({ top: logRef.current.scrollHeight, behavior: "smooth" })
}, [shown])
const current = shown > 0 ? LINES[shown - 1].price : ITEM.listPrice
const settled = shown >= LINES.length
/* 정가에서 목표가까지 얼마나 내려왔는지. 1 을 넘지 않게 자른다. */
const progress = Math.min(1, (ITEM.listPrice - current) / (ITEM.listPrice - FINAL))
return (
<div
ref={rootRef}
className={className}
role="img"
aria-label={`협상 리플레이. 최초 제시가 ${won(ITEM.listPrice)}원에서 ${won(FINAL)}원으로 낙찰되어 ${won(SAVED)}원 절감된 예시입니다.`}
>
<div className="rounded-card border border-stage-line bg-stage-raised/70 backdrop-blur-sm p-5 sm:p-6">
{/* 품목 + 목표가 — 구매 담당자가 위임한 기준이 무엇인지 먼저 보여준다. */}
<div className="flex items-start justify-between gap-3 mb-4">
<div className="min-w-0">
<div className="text-[14px] font-bold text-on-stage truncate">{ITEM.name}</div>
<div className="text-[12px] text-on-stage-muted">{ITEM.spec}</div>
</div>
<div className="text-right shrink-0">
<div className="text-[11px] font-semibold text-on-stage-muted">목표가</div>
<div className="text-[14px] font-bold tabular-nums text-on-stage">{won(FINAL)}원</div>
</div>
</div>
{/* 현재 제안가 + 진행 막대. 파랑에서 민트로 차오르며 양쪽이 만나는 지점을 그린다. */}
<div className="mb-5">
<div className="flex items-baseline justify-between mb-2">
<span className="text-[11px] font-semibold text-on-stage-muted">현재 제안가</span>
<span className="text-[22px] font-extrabold tabular-nums tracking-[-0.02em] text-on-stage">
{won(current)}
<span className="text-[14px] font-semibold text-on-stage-muted ml-0.5">원</span>
</span>
</div>
<div className="h-1.5 rounded-full bg-white/10 overflow-hidden">
<motion.div
className="h-full rounded-full bg-linear-to-r from-primary-on-stage to-counter"
animate={{ width: `${progress * 100}%` }}
transition={{ duration: 0.6, ease: EASE_OUT_EXPO }}
/>
</div>
</div>
{/* 대화. 높이를 고정해 줄이 늘어도 레이아웃이 튀지 않는다. */}
<div ref={logRef} className="h-[210px] overflow-hidden space-y-3 scrollbar-none">
{LINES.slice(0, shown).map((line, i) => (
<motion.div
key={i}
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: EASE_OUT_EXPO }}
className={`flex flex-col ${line.side === "buyer" ? "items-start" : "items-end"}`}
>
<span
className={`text-[11px] font-semibold mb-1 ${
line.side === "buyer" ? "text-primary-on-stage" : "text-counter"
}`}
>
{line.side === "buyer" ? "구매 담당자" : "협력사"}
</span>
<div
className={`max-w-[90%] rounded-card px-3.5 py-2.5 text-[13px] leading-[1.55] break-keep ${
line.side === "buyer"
? "bg-primary text-white"
: "bg-counter-surface text-counter border border-counter/20"
}`}
>
{line.text}
</div>
</motion.div>
))}
</div>
{/* 낙찰 카드. 자리를 항상 차지하게 두고 내용만 켜고 끈다 — 나타날 때 아래 요소가
밀리면 페이지 전체가 주기적으로 흔들린다. */}
<div className="mt-4 h-[74px]">
{settled && (
<motion.div
initial={reduce ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: EASE_OUT_EXPO }}
className="h-full rounded-card border border-counter/25 bg-counter-surface px-3.5 py-3 flex items-center gap-2.5"
>
<div className="w-8 h-8 rounded-full bg-counter/15 text-counter flex items-center justify-center shrink-0">
<Check className="w-4 h-4" />
</div>
{/* 금액은 줄바꿈을 막는다. 카드 높이가 74px 로 고정이라(등장할 때 아래가 밀리지
않게 하려고) 한 줄이라도 접히면 잘린다. 좁으면 접히는 게 아니라 붙어야 한다. */}
<div className="min-w-0">
<div className="text-[11px] font-semibold text-counter mb-0.5 whitespace-nowrap">협상 타결 → 낙찰</div>
<div className="text-[16px] font-extrabold tabular-nums text-on-stage whitespace-nowrap">{won(FINAL)}원</div>
</div>
<div className="text-right shrink-0 ml-auto">
<div className="text-[11px] font-semibold text-on-stage-muted mb-0.5 whitespace-nowrap">절감액</div>
<div className="text-[15px] font-extrabold tabular-nums text-counter whitespace-nowrap">
{won(SAVED)}원 <span className="text-[12px]">▼{RATE.toFixed(1)}%</span>
</div>
</div>
</motion.div>
)}
</div>
</div>
</div>
)
}

View File

@ -1,166 +0,0 @@
import { useEffect, useRef, useState, type RefObject } from "react"
/* 파이프라인 6단계. 원래 히어로 캔버스가 소유하던 타입인데, 히어로가 영상으로 바뀌면서
이리로 옮겼다 — 단계 이름을 실제로 쓰는 건 이제 이 링뿐이다. */
export type DataFlowPhase = "scatter" | "cluster" | "stream" | "card" | "negotiate" | "learn"
/**
* 파이프라인 순환 링 — 정원.
*
* 앞서 3D 로 눕힌 타원을 썼는데, 깊이는 생기지만 정원의 완결감이 사라진다.
* negotium 의 메시지는 "닫힌 루프"라서 완결감 쪽이 맞다.
*
* 구조는 레퍼런스(infinith)와 같다 — 점선 원만 천천히 돌고, 마디와 글자는 고정이다.
* 글자까지 같이 돌면 읽을 수가 없다.
*/
const NODES: { key: DataFlowPhase; label: string }[] = [
{ key: "scatter", label: "Collect" },
{ key: "cluster", label: "Classify" },
{ key: "stream", label: "Benchmark" },
{ key: "card", label: "Anchor" },
{ key: "negotiate", label: "Negotiate" },
{ key: "learn", label: "Learn" },
]
/* viewBox 는 정사각이 아니다. 라벨이 궤도 바깥 좌우로 뻗기 때문에 가로 여백이 더 필요한데,
정사각으로 두면 "Benchmark" 같은 긴 라벨이 잘린다(SVG 는 viewBox 밖을 그냥 자른다).
가로만 넓히면 링 크기는 유지한 채 라벨 자리를 확보할 수 있다. */
const VB_W = 176
const VB_H = 150
const CX = VB_W / 2
const CY = VB_H / 2
const R = 46 // 궤도 반지름
const R_LABEL = 58 // 라벨은 궤도 바깥에
const rad = (ratio: number) => ratio * Math.PI * 2 - Math.PI / 2 // 12시에서 시계방향
const at = (ratio: number, radius: number) => ({
x: CX + Math.cos(rad(ratio)) * radius,
y: CY + Math.sin(rad(ratio)) * radius,
})
const LOOP_MS = 10_500 // 히어로 캔버스와 같은 주기
export function OrbitRing({
phase,
progressRef,
className = "",
}: {
/** 밖에서 단계를 주면 그걸 따르고, 없으면 자체 시계로 돈다. */
phase?: DataFlowPhase
/** 캔버스가 매 프레임 써 넣는 루프 진행도 0..1. 없으면 스스로 센다. */
progressRef?: RefObject<number>
className?: string
}) {
const headRef = useRef<SVGCircleElement>(null)
const [ownIndex, setOwnIndex] = useState(0)
const activeIndex = phase ? Math.max(0, NODES.findIndex((n) => n.key === phase)) : ownIndex
/* 궤도를 도는 머리는 rAF 로 직접 갱신한다 — 60fps 로 setState 를 부르면 페이지가 매 프레임 리렌더된다.
단계 라벨만 바뀔 때(초당 0.5회) 리렌더한다. */
useEffect(() => {
let raf = 0
let last = -1
const startedAt = performance.now()
const tick = (now: number) => {
const p = progressRef ? (progressRef.current ?? 0) : ((now - startedAt) % LOOP_MS) / LOOP_MS
const { x, y } = at(p, R)
if (headRef.current) {
headRef.current.setAttribute("cx", String(x))
headRef.current.setAttribute("cy", String(y))
}
if (!phase) {
const i = Math.min(NODES.length - 1, Math.floor(p * NODES.length))
if (i !== last) {
last = i
setOwnIndex(i)
}
}
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [progressRef, phase])
return (
<svg viewBox={`0 0 ${VB_W} ${VB_H}`} className={className} role="img" aria-label="협상 파이프라인 순환">
<defs>
<linearGradient id="orbitGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="var(--color-primary-on-stage)" stopOpacity="0.9" />
<stop offset="55%" stopColor="var(--color-on-stage-soft)" stopOpacity="0.35" />
<stop offset="100%" stopColor="var(--color-counter)" stopOpacity="0.55" />
</linearGradient>
</defs>
{/* 점선 궤도 — 이 레이어만 돈다 */}
<g className="animate-[spin_22s_linear_infinite] motion-reduce:animate-none" style={{ transformOrigin: `${CX}px ${CY}px` }}>
<circle cx={CX} cy={CY} r={R} fill="none" stroke="url(#orbitGrad)" strokeWidth="0.6" strokeDasharray="2.2 2.4" />
</g>
{/* 바깥 보조 호 — 정지. 원이 하나면 심심하고, 둘이면 공간이 생긴다. */}
<circle
cx={CX}
cy={CY}
r={R + 9}
fill="none"
stroke="var(--color-on-stage-soft)"
strokeOpacity="0.1"
strokeWidth="0.4"
/>
{/* 마디 + 라벨 — 고정 레이어 */}
{NODES.map((n, i) => {
const ratio = i / NODES.length
const p = at(ratio, R)
const l = at(ratio, R_LABEL)
const cos = Math.cos(rad(ratio))
const anchor = cos > 0.3 ? "start" : cos < -0.3 ? "end" : "middle"
const active = i === activeIndex
return (
<g key={n.key}>
<circle
cx={p.x}
cy={p.y}
r={active ? 2.6 : 1.4}
fill={active ? "var(--color-primary-on-stage)" : "var(--color-on-stage-soft)"}
opacity={active ? 1 : 0.45}
style={{ transition: "r 240ms ease-out, opacity 240ms ease-out" }}
/>
<text
x={l.x}
y={l.y + 1.6}
textAnchor={anchor}
fontSize="5"
fontWeight={active ? 700 : 500}
fill={active ? "#FFFFFF" : "var(--color-on-stage-soft)"}
opacity={active ? 1 : 0.5}
style={{ transition: "opacity 240ms ease-out" }}
>
{n.label}
</text>
</g>
)
})}
{/* 궤도를 도는 머리 */}
<circle ref={headRef} cx={CX} cy={CY - R} r="2" fill="#FFFFFF" opacity="0.95" />
{/* 가운데 */}
<text x={CX} y={CY - 1} textAnchor="middle" fontSize="7" fontWeight="700" fill="#FFFFFF">
{NODES[activeIndex].label}
</text>
<text
x={CX}
y={CY + 8.5}
textAnchor="middle"
fontSize="4.2"
fontWeight="500"
fill="var(--color-on-stage-soft)"
opacity="0.5"
>
{String(activeIndex + 1).padStart(2, "0")} / {String(NODES.length).padStart(2, "0")}
</text>
</svg>
)
}

View File

@ -0,0 +1,28 @@
type RangeSliderProps = {
min: number
max: number
step: number
value: number
onChange: (value: number) => void
}
/** 채워진 트랙이 값을 따라가는 파란 슬라이더 (ROI 시뮬레이터·히어로 콘솔 공용). */
function RangeSlider({ min, max, step, value, onChange }: RangeSliderProps) {
const filled = ((value - min) / (max - min)) * 100
return (
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-1.5 rounded-lg appearance-none cursor-pointer accent-primary transition-all"
style={{
background: `linear-gradient(to right, var(--color-primary) 0%, var(--color-primary) ${filled}%, var(--color-line-strong) ${filled}%, var(--color-line-strong) 100%)`,
}}
/>
)
}
export { RangeSlider }

View File

@ -3,102 +3,25 @@ import * as React from "react"
import { Typography } from "@/components/ui/typography" import { Typography } from "@/components/ui/typography"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
/*
* 섹션 머리.
*
* eyebrow 는 선택이고, 기본적으로 쓰지 않는다. 작은 색 라벨을 섹션마다 반복하면
* 레퍼런스에서는 영문 대문자라 조판 요소로 기능하지만, 한글은 대문자가 없어서
* 그냥 "작은 글씨"가 되고 일곱 번 반복되면 벽지가 된다.
* 위계는 라벨이 아니라 헤드라인 크기(본문의 3.5~4배)와 2톤 대비로 만든다.
*
* ── 세로 리듬 ────────────────────────────────────────────────────────
* 머리 안팎의 간격은 전부 이 파일이 소유한다. 섹션 파일에서 각자 mb-12 / mb-16 /
* mb-20 / mb-24 를 붙이던 걸 걷어낸 결과다. 같은 관계(머리 → 본문)에 값이 다섯 개
* 있으면 스크롤할 때 섹션마다 호흡이 달라지고, 그게 "정돈이 안 됐다"는 인상의
* 실제 원인이었다.
*
* 규칙은 하나다 — 간격이 위계를 만든다. 가까울수록 한 덩어리로 읽힌다.
* 값이 흔들리면 "어디까지가 한 덩어리인가"가 섹션마다 달라져 보인다.
*
* eyebrow → title 16px 라벨은 제목에 붙는다
* title → description 20 / 32px 큰 활자는 더 벌린다. 64px 제목 아래 20px 은 붙어 보인다
* 머리 → 본문 56 / 80px 섹션 안에서 가장 큰 끊김
*
* 섹션 파일에서 이 관계들에 mb-· mt- 유틸리티를 직접 쓰지 않는다. 필요하면 아래 상수를
* import 해서 쓰고, 새 값이 필요하다고 느껴지면 여기에 이름을 붙여서 추가한다.
* 눈대중으로 한 칸씩 조정하기 시작하면 다시 지금 상태로 돌아온다.
*/
/** 머리 → 본문. 머리를 직접 조판하는 섹션(무대 데모)도 이 상수를 쓴다. */
export const HEADING_GAP = "mb-14 md:mb-20"
/** 탭·토글 행 → 본문. 머리 간격보다 좁아서 컨트롤이 위가 아니라 본문 쪽에 묶인다. */
export const CONTROL_GAP = "mb-12"
/** 리드 문단 → 버튼 행 (히어로·최종 CTA). */
export const CTA_GAP = "mt-12"
/** display 티어 제목 → 리드 문단. 히어로는 h1 이라 SectionHeading 을 못 쓰고 이 상수를 직접 쓴다. */
export const DISPLAY_LEAD_GAP = "mt-8"
/*
* 제목 조판 티어. 티어가 바뀌면 리드 문단까지의 간격도 같이 움직인다 —
* Typography 주석의 "크기·굵기·조임은 같이 움직인다"와 같은 이유로, 간격도 딸려간다.
*/
const SIZES = {
/** 섹션 표준 h2 (34 / 64px) */
title: { variant: "title", descriptionGap: "mt-5" },
/** 히어로·최종 CTA 급 디스플레이 (38~74px) */
display: { variant: "stageDisplay", descriptionGap: DISPLAY_LEAD_GAP },
} as const
type SectionHeadingProps = { type SectionHeadingProps = {
eyebrow?: string eyebrow: string
title: React.ReactNode title: React.ReactNode
description?: React.ReactNode description?: React.ReactNode
align?: "left" | "center" align?: "left" | "center"
/** 다크 무대 섹션(bg="stage")에서는 "stage" 를 준다. Typography 변형이 먹색을 물고 있어서 필요하다. */
tone?: "light" | "stage"
/** 제목 조판 티어. display 는 히어로·최종 CTA 전용. */
size?: keyof typeof SIZES
/** 머리 아래 표준 간격을 끈다. 뒤에 컨트롤 행이 붙는 섹션에서만 쓴다. */
gap?: "heading" | "none"
className?: string className?: string
/** 제목 크기 미세조정 (예: 최종 CTA 의 축소 디스플레이) */ /** 리드 문단 폭 제한 등 (예: "max-w-2xl") */
titleClassName?: string
/** 리드 문단 폭 제한·색 조정 (예: "max-w-2xl"). 세로 간격은 여기서 주지 않는다. */
descriptionClassName?: string descriptionClassName?: string
} }
function SectionHeading({ function SectionHeading({ eyebrow, title, description, align = "left", className, descriptionClassName }: SectionHeadingProps) {
eyebrow,
title,
description,
align = "left",
tone = "light",
size = "title",
gap = "heading",
className,
titleClassName,
descriptionClassName,
}: SectionHeadingProps) {
const onStage = tone === "stage"
const { variant, descriptionGap } = SIZES[size]
return ( return (
<div className={cn(align === "center" && "text-center", gap === "heading" && HEADING_GAP, className)}> <div className={cn(align === "center" && "text-center", className)}>
{/* eyebrow 가 없을 때 빈 span 을 렌더하면 mb + 30px 행간만큼 유령 여백이 생긴다. <Typography variant="eyebrow" className="mb-3 block">
지금은 모든 섹션이 eyebrow 를 넘기지만, 하나만 빼도 그 섹션만 제목이 내려앉는다. */} {eyebrow}
{eyebrow && (
<Typography variant="eyebrow" className={cn("mb-4 block", onStage && "text-primary-on-stage")}>
{eyebrow}
</Typography>
)}
<Typography variant={variant} as="h2" className={cn(onStage && "text-on-stage", titleClassName)}>
{title}
</Typography> </Typography>
<Typography variant="title">{title}</Typography>
{description && ( {description && (
<Typography <Typography variant="lead" className={cn("mt-5", align === "center" && "mx-auto", descriptionClassName)}>
variant="lead"
className={cn(descriptionGap, align === "center" && "mx-auto", onStage && "text-on-stage-soft", descriptionClassName)}
>
{description} {description}
</Typography> </Typography>
)} )}

View File

@ -6,8 +6,6 @@ const sectionBg = {
white: "bg-white", white: "bg-white",
surface: "bg-surface", surface: "bg-surface",
dark: "bg-ink text-white", dark: "bg-ink text-white",
/** 히어로와 같은 무대. 어두운 섹션은 이걸 쓴다 — 페이지 안에 어둠이 두 종류면 따로 논다. */
stage: "stage-bg text-on-stage",
} }
const sectionWidth = { const sectionWidth = {
@ -39,8 +37,7 @@ function Section({
}: SectionProps) { }: SectionProps) {
return ( return (
<section <section
/* 레퍼런스 섹션 패딩은 104~139px. 176px 은 과해서 "채울 내용이 없어" 보인다. */ className={cn("py-36 md:py-44", sectionBg[bg], bordered && "border-t border-line", decor && "relative overflow-hidden", className)}
className={cn("py-24 md:py-32", sectionBg[bg], bordered && "border-t border-line", decor && "relative overflow-hidden", className)}
{...props} {...props}
> >
{decor} {decor}

View File

@ -56,7 +56,7 @@ function SlateLeaf({ leaf }: { leaf: SlateText }) {
children = <em className="italic">{children}</em> children = <em className="italic">{children}</em>
} }
if (leaf.code) { if (leaf.code) {
children = <code className="bg-primary/10 px-1.5 py-0.5 rounded-control font-mono text-xs text-primary font-bold">{children}</code> children = <code className="bg-primary/10 px-1.5 py-0.5 rounded-lg font-mono text-xs text-primary font-bold">{children}</code>
} }
return <span>{children}</span> return <span>{children}</span>

View File

@ -3,60 +3,28 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
/* // 랜딩 텍스트 스케일 단일 소스. 페이지마다 text-[44px]/font-black/…을 직접 박지 말고
* 랜딩 텍스트 스케일 단일 소스. 페이지마다 text-[44px]/font-black/…을 직접 박지 말고 // variant 로 통일한다. 색·크기 미세조정은 className 으로 합성.
* variant 로 통일한다. 색·크기 미세조정은 className 으로 합성.
*
* 값은 레퍼런스(statworx.com) 실측에서 가져왔다. 핵심 규칙 셋:
*
* 1) 크기·굵기·조임은 같이 움직인다. 44px 에 900 은 뭉툭하지만, 72px 에 800 +
* 자간 -0.044em + 행간 1.0 은 확신으로 읽힌다(jitter.video 실측: 72px/800/-0.044em/0.95).
* 굵기만 올리면 둔해지고, 크기만 올리면 헐거워진다.
* 1-1) 크기 판단은 "글자가 화면 폭의 몇 %를 쓰는가"로 한다(레퍼런스 statworx 는 48%).
* 주의: h1 은 블록이라 getBoundingClientRect 로 재면 컨테이너 폭이 나온다.
* Range 로 텍스트 노드를 재야 실제 글자 폭이다 — 이걸 헷갈리면 엉뚱한 값으로 조정하게 된다.
* 지금 74px 에서 약 40%. 답답함의 원인은 가로 비율이 아니라 세로 간격이었다.
* 2) 한글 줄바꿈은 break-keep 만으로 부족하다. break-keep 은 어절 안에서 안 쪼개지게
* 할 뿐 어디서 넘어갈지는 못 정해서 "에이전트는 기준 / 밖으로 안 나갑니다" 같은
* 어색한 분절이 나온다. text-balance 를 같이 걸어 줄 길이를 고르게 맞춘다.
* 3) 크면 조인다. 디스플레이는 자간 -0.042em·행간 1.0 까지 조이고, 작아질수록 푼다.
* 단 한글은 행간 0.95 를 못 쓴다 — 라틴은 어센더·디센더가 여백을 만들지만
* 한글은 네모틀을 꽉 채워 줄이 붙는다. 라틴 기준 0.95~1.0 을 그대로 쓰면
* 두 줄짜리 한글 헤드라인이 서로 닿아 답답해진다. 1.07 이 붙지도 벌어지지도 않는 지점.
* 3) 라벨은 작고 자간은 normal. 레퍼런스 아이브로우가 10.4px/600/자간 normal 이다.
* 14px + tracking-wider + 알약 배경 + 반짝이 아이콘 조합이 전형적인 AI 생성 티다.
*
* 한글 보정: 본문은 레퍼런스(13.9px)보다 키운다. 한글은 14px 미만에서 판독성이 급격히
* 떨어진다. 대신 디스플레이를 더 키워 대비(디스플레이:본문 ≈ 4.5:1)를 만든다.
*/
const typographyVariants = cva("", { const typographyVariants = cva("", {
variants: { variants: {
variant: { variant: {
// 라이트 섹션 h1 // 히어로 h1
display: "text-[44px] sm:text-[58px] md:text-[68px] font-extrabold tracking-[-0.04em] leading-[1.14] text-ink break-keep text-balance", display: "text-3xl sm:text-4xl md:text-[50px] font-black tracking-tight leading-[1.2] text-ink break-keep",
// 다크 무대 히어로 h1. 색은 쓰는 쪽에서 준다(2톤 헤드라인).
stageDisplay:
"text-[38px] sm:text-[52px] md:text-[64px] lg:text-[74px] font-extrabold tracking-[-0.042em] leading-[1.1] break-keep text-balance",
// 섹션 제목 h2 // 섹션 제목 h2
/* 본문(15px)의 4.3배. 대비는 굵기가 아니라 크기로 만든다 — title: "text-3xl md:text-[44px] font-black tracking-tight leading-tight text-ink break-keep",
굵기를 올리면 뭉툭해지고, 크기를 올리면 확신이 생긴다.
커질수록 자간을 더 조인다: 52px 에서 적당한 -0.021em 도 64px 에선 헐거워 보인다. */
title: "text-[34px] md:text-[64px] font-extrabold tracking-[-0.042em] leading-[1.16] text-ink break-keep text-balance",
// 섹션 내 서브 제목 (데모 패널 등) // 섹션 내 서브 제목 (데모 패널 등)
heading: "text-[24px] md:text-[32px] font-bold tracking-[-0.034em] leading-[1.14] text-ink break-keep text-balance", heading: "text-2xl md:text-3xl font-bold tracking-tight text-ink break-keep",
// 카드 제목 // 카드 제목
cardTitle: "text-[20px] font-bold tracking-[-0.028em] leading-[1.3] text-ink break-keep", cardTitle: "text-lg font-bold text-ink break-keep",
/* 섹션 상단 라벨. 영문 전용 — 한글 내용은 title 이 맡고, 여기는 조판 요소다. // 섹션 상단 오버라인 라벨
작은 대문자 산세리프 라벨은 어느 SaaS 랜딩에나 있다. 세리프 이탤릭으로 두면 eyebrow: "text-sm font-semibold text-primary tracking-wider uppercase",
같은 자리에서 장식이 아니라 편집 디자인처럼 읽힌다. 한글은 넣지 않는다. */
eyebrow: "font-display italic text-[24px] md:text-[30px] font-normal text-primary tracking-[0.005em]",
// 섹션 리드 문단 // 섹션 리드 문단
lead: "text-[16px] sm:text-[17px] text-ink-soft font-normal leading-[1.6] break-keep", lead: "text-base sm:text-lg text-ink-soft font-medium leading-relaxed break-keep",
body: "text-[15px] text-ink-soft font-normal leading-[1.6] break-keep", body: "text-[15px] text-ink-soft font-medium leading-relaxed break-keep",
small: "text-[14px] text-ink-soft font-normal leading-[1.6] break-keep", small: "text-sm text-ink-soft font-medium leading-relaxed break-keep",
caption: "text-[12.5px] text-ink-muted font-normal leading-[1.55] break-keep", caption: "text-xs text-ink-muted font-semibold leading-relaxed break-keep",
// 메타 라벨 (단가 패널·푸터 컬럼 제목 등) // 11px 트래킹 대문자 메타 라벨 (단가 패널·푸터 컬럼 제목 등)
micro: "text-[11px] font-semibold text-ink-muted tracking-normal", micro: "text-[11px] font-bold text-ink-muted uppercase tracking-wider",
}, },
}, },
defaultVariants: { variant: "body" }, defaultVariants: { variant: "body" },
@ -64,7 +32,6 @@ const typographyVariants = cva("", {
const defaultTag: Record<NonNullable<VariantProps<typeof typographyVariants>["variant"]>, React.ElementType> = { const defaultTag: Record<NonNullable<VariantProps<typeof typographyVariants>["variant"]>, React.ElementType> = {
display: "h1", display: "h1",
stageDisplay: "h1",
title: "h2", title: "h2",
heading: "h3", heading: "h3",
cardTitle: "h3", cardTitle: "h3",

View File

@ -1,58 +0,0 @@
/*
* 리드 전송 — 데모 요청 모달과 상담 신청 폼이 함께 쓴다.
*
* [임시] 백엔드/서버리스 연결 전까지는 mailto 로 방문자의 메일 앱을 열어 문의를 보내게 한다.
* 메일 앱이 '열릴' 뿐 실제 전송 여부는 알 수 없으므로, UI 도 "접수 완료"가 아니라
* "메일 앱을 열었습니다 — 전송해 주세요"로 정직하게 안내한다(fake success 금지). 나중에 API/시트로 교체.
*/
// 리드 수신 주소 — 임시. 실제 영업 주소로 바꾸고, 이후 API 붙이면 mailto 자체를 교체할 것.
const LEAD_EMAIL = "o2odev@o2o.kr"
export type LeadSource = "demo-request" | "contact"
export type Lead = {
source: LeadSource
name: string
email: string
company?: string
phone?: string
message?: string
/** 봇 함정. 화면에서 감춘 필드라 값이 차 있으면 자동 제출이다. 서버가 조용히 버린다. */
website?: string
}
export type LeadResult = { ok: true } | { ok: false; reason: "invalid" | "network" | "rejected" }
/** 느슨한 검사. 정규식으로 이메일을 엄밀히 검증하려는 시도는 늘 진짜 주소를 막는다. */
export function isEmailLike(value: string) {
const v = value.trim()
return v.length >= 5 && v.includes("@") && !v.startsWith("@") && !v.endsWith("@") && !/\s/.test(v)
}
export async function submitLead(lead: Lead): Promise<LeadResult> {
if (!lead.name.trim() || !isEmailLike(lead.email)) return { ok: false, reason: "invalid" }
const label = lead.source === "demo-request" ? "데모 요청" : "도입 문의"
const body = [
`[${label}]`,
`회사명: ${lead.company || "-"}`,
`담당자: ${lead.name}`,
`이메일: ${lead.email}`,
`연락처: ${lead.phone || "-"}`,
`내용: ${lead.message || "-"}`,
].join("\n")
const href = `mailto:${LEAD_EMAIL}?subject=${encodeURIComponent(`[${label}] ${lead.company || lead.name}`)}&body=${encodeURIComponent(body)}`
// 방문자의 메일 앱을 연다 — 앵커 클릭이 location.href 할당보다 핸들러 발동이 확실하다.
// 실제 전송은 방문자가 하므로 성공을 단정하지 않는다(호출부 UI 는 안내 문구).
if (typeof document !== "undefined") {
const a = document.createElement("a")
a.href = href
a.style.display = "none"
document.body.appendChild(a)
a.click()
a.remove()
}
return { ok: true }
}

View File

@ -1,198 +0,0 @@
/**
* 랜딩 협상 데모의 규칙 엔진 (클라이언트 전용).
*
* 실제 negotium 엔진(agent 서비스)의 판정 규칙을 그대로 옮겼다. 데모가 제품과 다른
* 규칙으로 돌면 상담 자리에서 거짓말이 되기 때문에, 단순화는 하되 규칙은 바꾸지 않는다.
*
* - 앵커링가(anchor) 이하 제시는 무조건 수락한다.
* - 앵커 위면 협상 카드를 한 장 쓰고 역제안한다. 카드는 3장.
* - 카드를 다 쓰고도 앵커 위면 개찰(낙찰자 미정 마감)이다. 결렬이 아니다.
*
* React 를 모른다. 나중에 서버의 실엔진(/api/demo/negotiate)으로 갈아끼울 때
* 이 파일의 함수 시그니처만 유지하면 컴포넌트는 손대지 않아도 된다.
*/
export type Role = "buyer" | "seller"
export type DemoItem = {
id: string
name: string
spec: string
/** 협력사 최초 제시가 */
listPrice: number
/** LPS 인터넷 최저가 — 에이전트가 근거로 인용한다 */
marketLow: number
/** 앵커링가. 이 이하면 무조건 낙찰 */
anchor: number
/** 견적 생성 때 정한 목표가 */
target: number
}
export const MAX_CARDS = 3
export const DEMO_ITEMS: DemoItem[] = [
{
id: "glove",
name: "니트릴 코팅 안전장갑",
spec: "1,000켤레 · 월 정기",
listPrice: 1_200_000,
marketLow: 1_110_000,
anchor: 1_020_000,
target: 1_080_000,
},
{
id: "paper",
name: "A4 복사용지 80g",
spec: "500박스 · 분기 발주",
listPrice: 8_750_000,
marketLow: 8_200_000,
anchor: 7_900_000,
target: 8_100_000,
},
{
id: "oil",
name: "산업용 윤활유 20L",
spec: "300통 · 반기 발주",
listPrice: 5_400_000,
marketLow: 5_050_000,
anchor: 4_850_000,
target: 4_980_000,
},
]
export type Turn = {
id: number
side: "agent" | "counterpart"
text: string
/** 이 턴에서 테이블에 올라온 가격 */
price?: number
}
export type Outcome = "running" | "award" | "open"
/** 협상에서 실제로 오가는 조건들. 가격만 깎는 게 아니라는 걸 보여주는 장치. */
const LEVERS = ["연간 물량 보증", "납기 2주 연장", "대금 지급일 15일 단축"] as const
/** 10원 단위 반올림 — 실제 엔진의 앵커링 정돈 규칙과 같다. */
export const round10 = (n: number) => Math.round(n / 10) * 10
export const won = (n: number) => n.toLocaleString("ko-KR")
/**
* 에이전트의 양보 곡선 — 이 데모의 성격을 결정하는 단 하나의 숫자 뭉치다.
*
* 카드를 쓸수록 앵커에서 상대 제시가 쪽으로 얼마나 올라올지를 정한다.
* 0 이면 끝까지 앵커를 고수하고, 1 이면 즉시 상대 제시가를 받는다.
* 앞 카드를 낮게 잡을수록 강하게 버티는 에이전트가 된다.
*/
const CONCESSION = [0, 0.34, 0.62]
/** 협력사(사용자) 제시가에 대한 에이전트의 판정. seller 모드의 핵심. */
export function respondToOffer(
item: DemoItem,
offer: number,
cardsUsed: number,
): { outcome: Outcome; price: number; text: string } {
if (offer <= item.anchor) {
return {
outcome: "award",
price: offer,
text: `${won(offer)}원으로 확정하겠습니다. 목표가 ${won(item.target)}원 대비 ${won(
item.target - offer,
)}원 낮습니다. 주고받은 제안은 전부 기록에 남습니다.`,
}
}
if (cardsUsed >= MAX_CARDS) {
return {
outcome: "open",
price: offer,
text: `${won(offer)}원은 이번 견적의 낙찰 기준을 넘습니다. 협상 카드를 다 썼으니 이 건은 개찰로 마감하고 담당자에게 넘기겠습니다.`,
}
}
const ask = round10(item.anchor + (offer - item.anchor) * CONCESSION[cardsUsed])
const lever = LEVERS[cardsUsed]
/* 근거는 반드시 상대가 제시한 값을 물고 있어야 한다.
예전엔 첫 카드에서 marketLow 만 읊어서, 얼마를 제시하든 같은 문장이 나왔다.
CONCESSION[0] = 0 이라 부르는 값(ask)까지 앵커로 고정이라, 슬라이더를 움직여도
화면이 전혀 반응하지 않는 것처럼 보였다. 값은 고수하되(그게 이 에이전트의 성격이다)
무엇에 대한 응수인지는 매번 달라져야 한다. */
const gap = offer - ask
const reason =
cardsUsed > 0
? `${won(offer)}원과 저희 기준 사이가 아직 ${won(gap)}원 남았습니다.`
: offer >= item.listPrice
? `정가 그대로는 검토가 어렵습니다. 같은 사양 인터넷 최저가가 ${won(item.marketLow)}원입니다.`
: offer > item.marketLow
? `${won(offer)}원은 같은 사양 인터넷 최저가 ${won(item.marketLow)}원보다 ${won(
offer - item.marketLow,
)}원 높습니다.`
: `${won(offer)}원이면 시장 최저가 선까지 내려오셨습니다. 다만 이번 견적 기준까지 ${won(gap)}원 남았습니다.`
return {
outcome: "running",
price: ask,
text: `${reason} ${won(ask)}원까지 맞춰주시면 ${lever}으로 보전해 드리겠습니다.`,
}
}
/**
* buyer 모드 — 사용자가 목표가만 정하면 에이전트가 협력사와 알아서 붙는다.
* 전체 턴을 미리 만들어 두고 화면에서 하나씩 재생한다.
*/
export function simulateBuyerRun(item: DemoItem, target: number): { turns: Turn[]; outcome: Outcome; finalPrice: number } {
const turns: Turn[] = []
let id = 0
const push = (side: Turn["side"], text: string, price?: number) => turns.push({ id: id++, side, text, price })
push("counterpart", `원자재가 올라서 이번 분기는 ${won(item.listPrice)}원이 최선입니다.`, item.listPrice)
// 목표가가 앵커보다 낮으면 협력사가 받아들일 수 없는 구간이다.
// 에이전트는 기준 밖으로 나가지 않으므로 개찰로 끝난다 — 제품의 안전장치를 보여주는 케이스다.
const reachable = target >= item.anchor
/* 에이전트의 첫 수는 사용자가 준 목표가를 물고 있어야 한다. 예전엔 marketLow 만 읊어서
목표가를 얼마로 잡든 같은 문장이 나왔다 — 위임한 기준이 반영되는지 확인할 수가 없었다.
여는 말(협력사 정가 제시)은 아직 목표가를 모르는 시점이라 상수로 둔다. */
const stance =
target < item.marketLow
? `시장 최저가보다 낮은 목표라 근거부터 깔겠습니다.`
: `시장 최저가 선이라 무리 없이 접근하겠습니다.`
push(
"agent",
`목표가 ${won(target)}원 받았습니다. ${stance} 같은 사양 인터넷 최저가가 ${won(
item.marketLow,
)}원입니다. 연간 물량을 보증하면 어느 선까지 가능하신가요?`,
item.marketLow,
)
const mid = round10((item.listPrice + Math.max(target, item.anchor)) / 2)
push("counterpart", `공정상 한 번에 내리긴 어렵고, ${won(mid)}원까지는 조정하겠습니다.`, mid)
if (!reachable) {
push(
"agent",
`목표가 ${won(target)}원은 협력사가 받아들일 수 있는 선 아래입니다. 무리하게 밀지 않고 이 건은 개찰로 마감하겠습니다.`,
mid,
)
return { turns, outcome: "open", finalPrice: mid }
}
push("agent", `${won(target)}원이면 연 12회 정기 발주로 확정하겠습니다. 대신 납기는 2주 여유를 드리겠습니다.`, target)
push("counterpart", `정기 발주와 결제 조건을 지켜주신다면 ${won(target)}원으로 맞추겠습니다.`, target)
push(
"agent",
`${won(target)}원으로 확정했습니다. 최초 제시가 대비 ${won(item.listPrice - target)}원 절감입니다.`,
target,
)
return { turns, outcome: "award", finalPrice: target }
}
/** 절감액·절감률. 결과 화면과 상담 신청 연결에 쓴다. */
export function savings(item: DemoItem, finalPrice: number) {
const amount = Math.max(0, item.listPrice - finalPrice)
return { amount, rate: (amount / item.listPrice) * 100 }
}

View File

@ -8,9 +8,6 @@ export const links: Route.LinksFunction = () => [
{ rel: "icon", href: "/favicon.svg", type: "image/svg+xml" }, { rel: "icon", href: "/favicon.svg", type: "image/svg+xml" },
// 본문 서체 프리로드 — 프리렌더된 첫 화면의 FOUT 최소화 // 본문 서체 프리로드 — 프리렌더된 첫 화면의 FOUT 최소화
{ rel: "preload", href: "/fonts/PretendardVariable.woff2", as: "font", type: "font/woff2", crossOrigin: "anonymous" }, { rel: "preload", href: "/fonts/PretendardVariable.woff2", as: "font", type: "font/woff2", crossOrigin: "anonymous" },
/* 영문 디스플레이 서체는 self-host 한다(latin 서브셋 39KB).
구글 폰트 CDN 을 쓰면 서드파티 요청이 LCP 를 흔들고 방문자 IP 가 외부로 나간다.
preload 는 걸지 않는다 — 라벨 전용이라 폴드 위 렌더를 막을 이유가 없다. */
]; ];
export function Layout({ children }: { children: ReactNode }) { export function Layout({ children }: { children: ReactNode }) {

View File

@ -5,16 +5,14 @@ import { Faq } from "@/components/sections/faq";
import { FinalCTA } from "@/components/sections/final-cta"; import { FinalCTA } from "@/components/sections/final-cta";
import { Footer } from "@/components/sections/footer"; import { Footer } from "@/components/sections/footer";
import { Header } from "@/components/sections/header"; import { Header } from "@/components/sections/header";
import { HeroDataFlow } from "@/components/sections/hero-dataflow"; import { HeroNeumorphic } from "@/components/sections/hero-neumorphic";
import { HowItWorksDemo } from "@/components/sections/how-it-works-demo"; import { HowItWorksDemo } from "@/components/sections/how-it-works-demo";
import { NegotiationDemo } from "@/components/sections/negotiation-demo"; import { NegotiationConsole } from "@/components/sections/negotiation-console";
import { Reinforcement } from "@/components/sections/reinforcement"; import { Reinforcement } from "@/components/sections/reinforcement";
/* 한글·영문을 둘 다 담는다. 본문은 "네고시움"으로 갔지만 타이틀에서 영문을 빼면 const TITLE = "negotium — AI 구매 협상 자동화";
"negotium" 으로 검색해 들어오던 유입이 끊긴다 — 검색 질의는 사용자가 정하지 우리가 못 정한다. */
const TITLE = "네고시움(negotium) — AI 구매 협상 자동화";
const DESCRIPTION = const DESCRIPTION =
"품목·목표가·마감일만 정하면 협상 에이전트가 협력사마다 1:1로 단가를 조율하고 낙찰까지 판정합니다. 협상 기록이 쌓일수록 조건이 좋아지는 B2B 구매 협상 자동화 솔루션."; "가이드라인만 정하면 AI 흥정 봇이 여러 협력사와 단가를 대신 조율합니다. 흥정부터 낙찰까지 자동으로, 협상할수록 강화학습으로 더 좋은 조건을 만드는 B2B 구매 협상 자동화 솔루션.";
export function meta() { export function meta() {
return [ return [
@ -32,8 +30,8 @@ export default function Home() {
return ( return (
<div className="min-h-screen bg-white text-ink font-sans antialiased selection:bg-primary/10 selection:text-primary"> <div className="min-h-screen bg-white text-ink font-sans antialiased selection:bg-primary/10 selection:text-primary">
<Header /> <Header />
<HeroDataFlow /> <HeroNeumorphic />
<NegotiationDemo /> <NegotiationConsole />
<HowItWorksDemo /> <HowItWorksDemo />
<Reinforcement /> <Reinforcement />
<CoreValues /> <CoreValues />

View File

@ -1,69 +0,0 @@
# 히어로 CTA 문구 변경 결정 기록
- **일자**: 2026-08-07
- **상태**: 적용·배포 완료
- **범위**: 히어로 CTA 2종, 데모 요청 모달 제목
- **관련 커밋**: `77d96cd`
---
## 요약
히어로 상단 CTA 2종의 문구를 변경했다. 최초 지시안을 검토 과정에서 일부 수정했으며,
수정안으로 승인·적용·배포를 완료했다.
## 1. 변경 내역
| 위치 | 기존 | 최초 지시안 | **최종 적용** |
|---|---|---|---|
| 1차 CTA | 여기서 직접 체험 | 협상 예시 보기 | **협상 예시 체험** |
| 2차 CTA | 데모 요청 | 직접 체험 | **실제 데모 받기** |
| 모달 제목 | 데모 요청 | (미지정) | **실제 데모 받기** |
## 2. 최초 지시안을 수정한 사유
두 버튼은 클릭 후 경험이 전혀 다르다. 1차는 페이지 내에서 즉시 조작하는 인터랙티브
데모(`#how-it-works`)이고, 2차는 이름·이메일을 받는 리드 수집 폼(모달)이다.
| 지시안 | 확인된 문제 | 사업적 영향 |
|---|---|---|
| 협상 예시 **보기** | 도착 섹션이 "직접 경험해 보세요", 역할 카드가 "공급사로 해보기"로 조작을 요구함. 버튼은 관람을 약속하고 목적지는 참여를 요구 | 클릭 전 기대와 실제 요구 불일치 → 데모 진입 단계 이탈 |
| **직접 체험** | 클릭 시 실제로는 입력 폼이 노출됨. 체험은 이메일 수신 이후 시작 | 즉시 체험을 기대한 방문자의 신뢰 저하 → 리드 폼 이탈 |
## 3. 최종안이 지시 의도를 유지하는 방식
최초 지시의 핵심 의도는 **"페이지 내 데모는 시뮬레이션, 이메일 데모가 실제 제품"**
이라는 구분이었으며, 최종안은 이를 그대로 반영했다.
| 요소 | 반영 방식 |
|---|---|
| 시뮬레이션임을 명시 | "**예시**" 유지 — 고정 데이터 기반임을 표기 |
| 목적지와의 정합성 | "**체험**" 유지 — 섹션 헤드라인·역할 카드와 충돌 없음 |
| 실제 제품 구분 | "**실제** 데모" — 온페이지 예시와 명확히 분리 |
| 전달 경로 명시 | "**받기**" — 이메일 수신 방식임이 드러나 폼 노출이 자연스러움 |
## 4. 부수 조치
| 항목 | 조치 | 사유 |
|---|---|---|
| 모달 제목 | "데모 요청" → "실제 데모 받기" | 버튼과 창 제목 불일치 시 전환 순간 이탈 방지 |
| 모달 제출 버튼 | "데모 신청" **유지** | 해당 클릭의 실제 결과는 신청 접수이며, 데모 수령은 이메일 단계에서 발생 |
| 데모 섹션 헤드라인 | **변경 없음** | 최종안이 "체험"을 유지하여 수정 불필요 |
## 5. 적용 원칙
> **버튼 문구는 클릭 시 실제로 발생하는 동작을 기술한다.**
> 문구와 동작이 어긋나면 전환 손실로 직결된다.
이 원칙은 이후 CTA 문구를 손볼 때 같은 기준으로 적용한다. 특히 아래 두 경우를 주의한다.
- **목적지와 동사가 어긋나는 경우** — 버튼이 "보기"인데 도착지가 조작을 요구하면 안 된다.
- **폼을 여는 버튼에 결과를 약속하는 경우** — "체험"·"시작" 류는 즉시 그 일이 일어날 때만 쓴다.
## 6. 참고 — 관련 코드 위치
| 대상 | 파일 |
|---|---|
| 히어로 CTA 2종 | `app/components/sections/hero-dataflow.tsx` |
| 모달 제목·제출 버튼 | `app/components/ui/demo-request-modal.tsx` |
| 도착 섹션 헤드라인·역할 카드 | `app/components/sections/negotiation-demo.tsx` |

View File

@ -1,99 +0,0 @@
/*
* 리드 수신용 Google Apps Script — 데모 요청·상담 신청을 스프레드시트에 적재한다.
*
* 이 파일은 랜딩 번들에 포함되지 않는다. Google Apps Script 편집기에 붙여넣을 원본이고,
* 여기 두는 이유는 배포 후 이 코드가 어디에도 남지 않으면 나중에 아무도 손댈 수 없기 때문이다.
*
* ── 설치 (약 3분) ────────────────────────────────────────────────────────────
* 1. 새 Google 스프레드시트를 만든다. 시트 이름은 그대로 둬도 된다.
* 2. 확장 프로그램 → Apps Script → 기본 코드를 지우고 이 파일 전체를 붙여넣는다.
* 3. 배포 → 새 배포 → 유형 "웹 앱"
* 실행 계정 : 나
* 액세스 권한 : 모든 사용자 ← 이걸 "나"로 두면 랜딩에서 호출이 막힌다
* 4. 배포하면 나오는 URL(https://script.google.com/macros/s/…/exec)을 복사한다.
* 5. Vercel → negotium-landing → Settings → Environment Variables 에
* 이름 LEAD_WEBHOOK_URL / 값 복사한 URL / Production 체크
* 6. 다시 배포한다. 환경변수는 배포 시점에 주입되므로 재배포 전에는 적용되지 않는다.
*
* ── 동작 ────────────────────────────────────────────────────────────────────
* api/lead.ts 가 아래 형태로 POST 한다. 필드가 늘어도 헤더를 자동으로 확장하므로
* 이 스크립트를 다시 고칠 일은 거의 없다.
* { text, source, name, email, company, phone, message, submittedAt, userAgent }
*
* 주의: 웹 앱은 POST 에 302 를 돌려주고 script.googleusercontent.com 으로 넘긴다.
* api/lead.ts 의 fetch 는 리다이렉트를 따라가므로 최종 200 을 받는다 — 정상이다.
*/
/** 리드가 쌓일 시트 이름. 없으면 자동 생성한다. */
var SHEET_NAME = 'leads'
/** 항상 이 순서로 왼쪽부터 채운다. 나머지 필드는 뒤에 자동으로 붙는다. */
var PREFERRED = ['submittedAt', 'source', 'name', 'email', 'company', 'phone', 'message', 'userAgent']
function doPost(e) {
try {
var payload = JSON.parse((e && e.postData && e.postData.contents) || '{}')
// text 는 Slack 전용 요약이라 시트에는 넣지 않는다. 같은 내용이 개별 필드에 이미 있다.
delete payload.text
var lock = LockService.getScriptLock()
lock.waitLock(20000) // 동시 제출이 같은 행에 겹쳐 쓰는 것을 막는다
try {
var sheet = getSheet_()
var header = ensureHeader_(sheet, payload)
var row = header.map(function (key) {
return payload[key] === undefined ? '' : payload[key]
})
sheet.appendRow(row)
} finally {
lock.releaseLock()
}
return json_({ ok: true })
} catch (err) {
// 실패해도 랜딩 쪽 api/lead.ts 가 이미 로그를 남겼으므로 리드 자체는 보존된다.
return json_({ ok: false, error: String(err) })
}
}
/** 브라우저로 URL 을 열었을 때 살아있는지 확인용. 배포 직후 점검에 쓴다. */
function doGet() {
return json_({ ok: true, service: 'negotium lead sink' })
}
function getSheet_() {
var ss = SpreadsheetApp.getActiveSpreadsheet()
return ss.getSheetByName(SHEET_NAME) || ss.insertSheet(SHEET_NAME)
}
/** 헤더가 없으면 만들고, 처음 보는 필드가 오면 열을 덧붙인다. */
function ensureHeader_(sheet, payload) {
var lastCol = sheet.getLastColumn()
var header = lastCol ? sheet.getRange(1, 1, 1, lastCol).getValues()[0].filter(String) : []
if (!header.length) {
header = PREFERRED.filter(function (k) {
return k in payload
})
Object.keys(payload).forEach(function (k) {
if (header.indexOf(k) === -1) header.push(k)
})
sheet.getRange(1, 1, 1, header.length).setValues([header]).setFontWeight('bold')
sheet.setFrozenRows(1)
return header
}
var added = Object.keys(payload).filter(function (k) {
return header.indexOf(k) === -1
})
if (added.length) {
sheet.getRange(1, header.length + 1, 1, added.length).setValues([added]).setFontWeight('bold')
header = header.concat(added)
}
return header
}
function json_(obj) {
return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType(ContentService.MimeType.JSON)
}

2422
landing/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,13 +13,13 @@
"@react-router/node": "^7.17.0", "@react-router/node": "^7.17.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"isbot": "^5",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"react": "^19.0.1", "react": "^19.0.1",
"react-dom": "^19.0.1", "react-dom": "^19.0.1",
"react-router": "^7.17.0", "react-router": "^7.17.0",
"tailwind-merge": "^3.6.0" "tailwind-merge": "^3.6.0",
"isbot": "^5"
}, },
"devDependencies": { "devDependencies": {
"@react-router/dev": "^7.17.0", "@react-router/dev": "^7.17.0",
@ -27,7 +27,6 @@
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/react": "^19.0.1", "@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1", "@types/react-dom": "^19.0.1",
"@vercel/node": "^5.9.5",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"vite": "^6.2.3" "vite": "^6.2.3"

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More