[fix] agent·negodata/front: 중간값 멘트 모순 수정(갑 포지션 갱신을 치환 뒤로·원값 인용가 반올림 제외·1% 카드 역행 금지) + 계정 전환 쿼리캐시 초기화·ChatTab 카드 렌더 정리 + font-mono 한글 폴백(Windows 한글깨짐)
This commit is contained in:
parent
7dc925bcd4
commit
a405e00bf9
@ -145,7 +145,12 @@ 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 # 타결 상한 초과 — 받아줄 수 없는 금액이라 지금 못 쓴다
|
||||||
offer = int(value / 10 + 0.5) * 10 # 10원 단위 반올림 — 앵커가·목표가 산정과 표기 통일
|
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:
|
||||||
|
|||||||
@ -152,7 +152,13 @@ class ChatEngine:
|
|||||||
pending = session.context.pop("pending_counter_price", None)
|
pending = session.context.pop("pending_counter_price", 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)
|
view = self._render(session, nxt)
|
||||||
|
# 갑의 포지션 갱신은 멘트 치환 뒤 — 치환 전에 덮으면 {prev_customer_price}(당사 직전 제안)가
|
||||||
|
# 이번 카운터 값으로 찍혀 "당사 7700과 귀사 7900의 절반 = 7700" 같은 모순 멘트가 된다.
|
||||||
|
new_pos = session.context.pop("_customer_position_after_render", None)
|
||||||
|
if new_pos is not None:
|
||||||
|
session.context["prev_customer_price"] = new_pos
|
||||||
|
return view
|
||||||
|
|
||||||
# ---- transition ----------------------------------------------------
|
# ---- transition ----------------------------------------------------
|
||||||
def _default_next(self, node: dict) -> Optional[str]:
|
def _default_next(self, node: dict) -> Optional[str]:
|
||||||
@ -268,15 +274,16 @@ 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 등 극단 데이터를 방어한다.
|
# 동일하게 건다. 기본 앵커 밴드에선 수학적으로 항상 통과하지만 극단 데이터를 방어한다.
|
||||||
if 0 < offer_1pct < price and (target <= 0 or offer_1pct <= target):
|
prev_customer = ctx.get("prev_customer_price") or 0
|
||||||
|
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
|
||||||
ctx["pending_counter_price"] = offer_1pct # 수락 시 이 가격으로 타결
|
ctx["pending_counter_price"] = offer_1pct # 수락 시 이 가격으로 타결
|
||||||
ctx["prev_customer_price"] = offer_1pct # 갑의 최신 포지션 — 이후 절충가 계산 기준
|
ctx["_customer_position_after_render"] = offer_1pct # 갑의 최신 포지션 — 렌더 뒤 반영(advance 말미)
|
||||||
return "wild_card_1pct"
|
return "wild_card_1pct"
|
||||||
# 1.02 초과 ~ entry(1.05) 구간: 견적에서 선택한 와일드카드의 전술로 카운터 제시.
|
# 1.02 초과 ~ entry(1.05) 구간: 견적에서 선택한 와일드카드의 전술로 카운터 제시.
|
||||||
# (구현 전에는 이 구간이 일반 가격협상으로 회귀해 선택형 WC 가 영영 발동하지 않던 갭.)
|
# (구현 전에는 이 구간이 일반 가격협상으로 회귀해 선택형 WC 가 영영 발동하지 않던 갭.)
|
||||||
@ -292,7 +299,7 @@ class ChatEngine:
|
|||||||
if offer is not None:
|
if offer is not None:
|
||||||
ctx["wildcard_used"] = True
|
ctx["wildcard_used"] = True
|
||||||
ctx["pending_counter_price"] = offer
|
ctx["pending_counter_price"] = offer
|
||||||
ctx["prev_customer_price"] = offer # 갑의 최신 포지션 — "당사 제안 ○원" 멘트가 실제 이력과 일치
|
ctx["_customer_position_after_render"] = offer # 갑의 최신 포지션 — 렌더 뒤 반영(advance 말미)
|
||||||
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"
|
||||||
|
|||||||
@ -312,7 +312,6 @@ class ChatService:
|
|||||||
counter = compute_offer(spec, session.context) if available(spec, session.context) else None
|
counter = compute_offer(spec, session.context) if available(spec, session.context) else None
|
||||||
if counter is not None:
|
if counter is not None:
|
||||||
session.context["pending_counter_price"] = counter
|
session.context["pending_counter_price"] = counter
|
||||||
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)
|
||||||
@ -359,6 +358,9 @@ class ChatService:
|
|||||||
res.input_mode, res.input_options = view2.input_mode, view2.input_options
|
res.input_mode, res.input_options = view2.input_mode, view2.input_options
|
||||||
if not card_script:
|
if not card_script:
|
||||||
res.script = view2.script # 카드 멘트 없으면 스텝 기본 카운터 멘트
|
res.script = view2.script # 카드 멘트 없으면 스텝 기본 카운터 멘트
|
||||||
|
# 갑의 최신 포지션(절충가 계산 기준) — 멘트 치환 뒤에 갱신해야 {prev_customer_price}가
|
||||||
|
# 이번 카운터가 아니라 직전 제안으로 나간다.
|
||||||
|
session.context["prev_customer_price"] = counter
|
||||||
|
|
||||||
async def _play_closing_tactic(self, engine: TenantEngine, chat_engine: ChatEngine,
|
async def _play_closing_tactic(self, engine: TenantEngine, chat_engine: ChatEngine,
|
||||||
scripts: ScriptRepository, session: ChatSession, res: Res_Chat):
|
scripts: ScriptRepository, session: ChatSession, res: Res_Chat):
|
||||||
@ -388,7 +390,6 @@ class ChatService:
|
|||||||
return # 컨텍스트 이상 — 기존 가격협상 스텝 그대로(재제안 요구)
|
return # 컨텍스트 이상 — 기존 가격협상 스텝 그대로(재제안 요구)
|
||||||
mark_played(ctx, closing_number) # None(폴백 최후통첩)이면 no-op
|
mark_played(ctx, closing_number) # None(폴백 최후통첩)이면 no-op
|
||||||
ctx["pending_counter_price"] = counter
|
ctx["pending_counter_price"] = counter
|
||||||
ctx["prev_customer_price"] = counter
|
|
||||||
|
|
||||||
template = None
|
template = None
|
||||||
if closing_number:
|
if closing_number:
|
||||||
@ -400,6 +401,9 @@ class ChatService:
|
|||||||
res.step, res.client_step = view2.step, view2.client_step
|
res.step, res.client_step = view2.step, view2.client_step
|
||||||
res.input_mode, res.input_options = view2.input_mode, view2.input_options
|
res.input_mode, res.input_options = view2.input_mode, view2.input_options
|
||||||
res.script = scripts.format_script(template, chat_engine.vars_for(session)) if template else view2.script
|
res.script = scripts.format_script(template, chat_engine.vars_for(session)) if template else view2.script
|
||||||
|
# 갑의 최신 포지션 갱신은 멘트 치환 뒤 — WC-05 중간값 멘트의 {prev_customer_price}(당사 직전 제안)가
|
||||||
|
# 이번 절충가로 찍히던 버그(IMK 0807) 방지.
|
||||||
|
ctx["prev_customer_price"] = counter
|
||||||
res.card_id = closing_number
|
res.card_id = closing_number
|
||||||
|
|
||||||
async def _terminal_learn(self, engine: TenantEngine, session: ChatSession, outcome: str, res: Res_Chat):
|
async def _terminal_learn(self, engine: TenantEngine, session: ChatSession, outcome: str, res: Res_Chat):
|
||||||
|
|||||||
@ -1,21 +1,10 @@
|
|||||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
import {QueryClientProvider} from '@tanstack/react-query';
|
||||||
import {useState, type ReactNode} from 'react';
|
import {type ReactNode} from 'react';
|
||||||
import {Toaster} from 'sonner';
|
import {Toaster} from 'sonner';
|
||||||
import {ConfirmHost} from '@/lib/confirm';
|
import {ConfirmHost} from '@/lib/confirm';
|
||||||
|
import {queryClient} from '@/lib/query-client';
|
||||||
|
|
||||||
export function Providers({children}: {children: ReactNode}) {
|
export function Providers({children}: {children: ReactNode}) {
|
||||||
const [queryClient] = useState(
|
|
||||||
() =>
|
|
||||||
new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
retry: 1,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -49,14 +49,11 @@ export function ActionBanner() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const readOne = useReadOne();
|
const readOne = useReadOne();
|
||||||
|
|
||||||
const { data: notif } = useListNotifications(
|
const { data: notif } = useListNotifications({ size: 20 }, { query: { refetchInterval: 30_000 } });
|
||||||
{ size: 20 },
|
|
||||||
{ query: { refetchInterval: 30_000, staleTime: 10_000 } },
|
|
||||||
);
|
|
||||||
// 재협상 요청 토스트는 '아직 완료 안 된(대기중)' 것만 — 내가 처리 가능한(can_act) 대기 세션 집합.
|
// 재협상 요청 토스트는 '아직 완료 안 된(대기중)' 것만 — 내가 처리 가능한(can_act) 대기 세션 집합.
|
||||||
const { data: renego } = useListRequests(
|
const { data: renego } = useListRequests(
|
||||||
{ status: 1, page: 1, size: 50 },
|
{ status: 1, page: 1, size: 50 },
|
||||||
{ query: { refetchInterval: 30_000, staleTime: 10_000 } },
|
{ query: { refetchInterval: 30_000 } },
|
||||||
);
|
);
|
||||||
const pendingSessions = new Set(
|
const pendingSessions = new Set(
|
||||||
(renego?.requests ?? []).filter((r) => r.can_act !== false).map((r) => r.session_id),
|
(renego?.requests ?? []).filter((r) => r.can_act !== false).map((r) => r.session_id),
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {useAuthStore, type AuthUser, type UserRole} from '../../stores/auth';
|
|||||||
import {UserRole as UserRoleCode} from '../../api/generated/model';
|
import {UserRole as UserRoleCode} from '../../api/generated/model';
|
||||||
import {USER_ROLE_LABEL} from '../../lib/enumLabels';
|
import {USER_ROLE_LABEL} from '../../lib/enumLabels';
|
||||||
import {toMessage, resultMessage} from '../../lib/apiError';
|
import {toMessage, resultMessage} from '../../lib/apiError';
|
||||||
|
import {queryClient} from '../../lib/query-client';
|
||||||
|
|
||||||
const ACCESS_KEY = 'negodata.accessToken';
|
const ACCESS_KEY = 'negodata.accessToken';
|
||||||
const REFRESH_KEY = 'negodata.refreshToken';
|
const REFRESH_KEY = 'negodata.refreshToken';
|
||||||
@ -69,6 +70,8 @@ export function initAuth(): Promise<void> {
|
|||||||
|
|
||||||
export async function login(loginId: string, password: string): Promise<ResMe> {
|
export async function login(loginId: string, password: string): Promise<ResMe> {
|
||||||
const tokens = ensureOk(await loginRequest({id: loginId, password}));
|
const tokens = ensureOk(await loginRequest({id: loginId, password}));
|
||||||
|
// 직전 계정(다른 회사)의 캐시가 남아 브랜딩·목록이 그대로 보이는 것 방지
|
||||||
|
queryClient.clear();
|
||||||
localStorage.setItem(ACCESS_KEY, tokens.access_token ?? '');
|
localStorage.setItem(ACCESS_KEY, tokens.access_token ?? '');
|
||||||
localStorage.setItem(REFRESH_KEY, tokens.refresh_token ?? '');
|
localStorage.setItem(REFRESH_KEY, tokens.refresh_token ?? '');
|
||||||
setAccessToken(tokens.access_token ?? null);
|
setAccessToken(tokens.access_token ?? null);
|
||||||
@ -105,4 +108,5 @@ export async function logout(): Promise<void> {
|
|||||||
localStorage.removeItem(REFRESH_KEY);
|
localStorage.removeItem(REFRESH_KEY);
|
||||||
setAccessToken(null);
|
setAccessToken(null);
|
||||||
useAuthStore.getState().setUser(null);
|
useAuthStore.getState().setUser(null);
|
||||||
|
queryClient.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,13 +4,11 @@ import type { SessionData } from '@/api/generated/model/sessionData';
|
|||||||
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
|
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
|
||||||
import type { ChatMessageData } from '@/api/generated/model/chatMessageData';
|
import type { ChatMessageData } from '@/api/generated/model/chatMessageData';
|
||||||
import { ChatSender, CardType } from '@/api/generated/model';
|
import { ChatSender, CardType } from '@/api/generated/model';
|
||||||
import SlateRenderer from '@/components/SlateRenderer';
|
|
||||||
import { Typography } from '@/components/ui/typography';
|
import { Typography } from '@/components/ui/typography';
|
||||||
import { StatusPill, sessionStatusTone } from './StatusPill';
|
import { StatusPill, sessionStatusTone } from './StatusPill';
|
||||||
import { type Product, type Partner, sessionStatusLabel } from '../../types';
|
import { type Product, type Partner, sessionStatusLabel } from '../../types';
|
||||||
import { useCompanySettings } from '@/features/settings/useCompanySettings';
|
import { useCompanySettings } from '@/features/settings/useCompanySettings';
|
||||||
import { renderEmphasis } from '@/lib/emphasis';
|
import { renderEmphasis } from '@/lib/emphasis';
|
||||||
import { renderCardScriptPreview } from '@/features/cards/editor';
|
|
||||||
|
|
||||||
// 협상로그 JSON 다운로드(IMK #9). 가격·인하율은 실값 그대로 내보낸다 — 이 탭을 열 수 있는 사람이
|
// 협상로그 JSON 다운로드(IMK #9). 가격·인하율은 실값 그대로 내보낸다 — 이 탭을 열 수 있는 사람이
|
||||||
// 이미 공개 대상(해당 견적 담당자∪최고관리자, 조회 게이팅)뿐이라 마스킹이 하던 차단 역할이 없다.
|
// 이미 공개 대상(해당 견적 담당자∪최고관리자, 조회 게이팅)뿐이라 마스킹이 하던 차단 역할이 없다.
|
||||||
@ -186,19 +184,12 @@ export function ChatTab({
|
|||||||
) : (
|
) : (
|
||||||
chatMessages.map((m) =>
|
chatMessages.map((m) =>
|
||||||
m.sender === ChatSender.BOT ? (
|
m.sender === ChatSender.BOT ? (
|
||||||
<BotBubble
|
<BotBubble key={m.chat_id} message={m} serverCards={serverCards} />
|
||||||
key={m.chat_id}
|
|
||||||
message={m}
|
|
||||||
currentSupplierName={currentSupplierName}
|
|
||||||
currentProduct={currentProduct}
|
|
||||||
serverCards={serverCards}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<PartnerBubble
|
<PartnerBubble
|
||||||
key={m.chat_id}
|
key={m.chat_id}
|
||||||
message={m}
|
message={m}
|
||||||
currentSupplierName={currentSupplierName}
|
currentSupplierName={currentSupplierName}
|
||||||
currentProduct={currentProduct}
|
|
||||||
serverCards={serverCards}
|
serverCards={serverCards}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@ -211,22 +202,16 @@ export function ChatTab({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 봇(좌측) 말풍선. 진행 단계·협상 스크립트(가격 마스킹)·사용 협상카드를 보여준다.
|
// 봇(좌측) 말풍선. 진행 단계·협상 스크립트·사용 협상카드를 보여준다.
|
||||||
// 간격은 부모(flex flex-col gap)에서 주고, 말풍선 박스는 block 으로 둬 긴 텍스트 줄바꿈이 깨지지 않게 한다.
|
// 간격은 부모(flex flex-col gap)에서 주고, 말풍선 박스는 block 으로 둬 긴 텍스트 줄바꿈이 깨지지 않게 한다.
|
||||||
function BotBubble({
|
function BotBubble({
|
||||||
message,
|
message,
|
||||||
currentSupplierName,
|
|
||||||
currentProduct,
|
|
||||||
serverCards,
|
serverCards,
|
||||||
}: {
|
}: {
|
||||||
message: ChatMessageData;
|
message: ChatMessageData;
|
||||||
currentSupplierName: string;
|
|
||||||
currentProduct: Product | undefined;
|
|
||||||
serverCards: QuotationCardData[];
|
serverCards: QuotationCardData[];
|
||||||
}) {
|
}) {
|
||||||
const m = message;
|
const m = message;
|
||||||
// 카드 사용 메시지는 같은 멘트가 아래 카드 박스에 그대로 나오므로 본문 script 는 중복 → 카드 없을 때만 노출.
|
|
||||||
const usedCard = findUsedCard(m, serverCards);
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-start">
|
<div className="flex justify-start">
|
||||||
<div className="flex flex-col gap-1.5 max-w-[85%]">
|
<div className="flex flex-col gap-1.5 max-w-[85%]">
|
||||||
@ -241,18 +226,13 @@ function BotBubble({
|
|||||||
{m.step}
|
{m.step}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{m.script && !usedCard && (
|
{/* 본문은 실제 발화(chats.meta.script) — 카드 멘트도 변수 치환·자연화가 끝난 최종본이 여기 담긴다. */}
|
||||||
|
{m.script && (
|
||||||
<Typography as="p" variant="small" className="text-xs whitespace-pre-line leading-relaxed text-inherit">
|
<Typography as="p" variant="small" className="text-xs whitespace-pre-line leading-relaxed text-inherit">
|
||||||
{renderEmphasis(m.script)}
|
{renderEmphasis(m.script)}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<UsedCardBox
|
<UsedCardBox message={m} isBot serverCards={serverCards} />
|
||||||
message={m}
|
|
||||||
isBot
|
|
||||||
currentSupplierName={currentSupplierName}
|
|
||||||
currentProduct={currentProduct}
|
|
||||||
serverCards={serverCards}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -263,12 +243,10 @@ function BotBubble({
|
|||||||
function PartnerBubble({
|
function PartnerBubble({
|
||||||
message,
|
message,
|
||||||
currentSupplierName,
|
currentSupplierName,
|
||||||
currentProduct,
|
|
||||||
serverCards,
|
serverCards,
|
||||||
}: {
|
}: {
|
||||||
message: ChatMessageData;
|
message: ChatMessageData;
|
||||||
currentSupplierName: string;
|
currentSupplierName: string;
|
||||||
currentProduct: Product | undefined;
|
|
||||||
serverCards: QuotationCardData[];
|
serverCards: QuotationCardData[];
|
||||||
}) {
|
}) {
|
||||||
const m = message;
|
const m = message;
|
||||||
@ -291,13 +269,7 @@ function PartnerBubble({
|
|||||||
{m.script}
|
{m.script}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<UsedCardBox
|
<UsedCardBox message={m} isBot={false} serverCards={serverCards} />
|
||||||
message={m}
|
|
||||||
isBot={false}
|
|
||||||
currentSupplierName={currentSupplierName}
|
|
||||||
currentProduct={currentProduct}
|
|
||||||
serverCards={serverCards}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -305,24 +277,20 @@ function PartnerBubble({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 말풍선에 붙는 협상카드 박스(봇/협력사 공용). 카드 미사용 메시지면 아무것도 렌더하지 않는다.
|
// 말풍선에 붙는 협상카드 박스(봇/협력사 공용). 카드 미사용 메시지면 아무것도 렌더하지 않는다.
|
||||||
// 톤(amber/primary)만 isBot 으로 가르고, 멘트/조건/메모 렌더 로직은 공유한다.
|
// 멘트 본문은 위 말풍선(실발화 m.script)이 담당 — 여기선 카드 메타(번호·이름·종류·와일드 조건/메모)만.
|
||||||
|
// 톤은 카드 종류(와일드=amber, 협상=primary)로 가른다.
|
||||||
function UsedCardBox({
|
function UsedCardBox({
|
||||||
message,
|
message,
|
||||||
isBot,
|
isBot,
|
||||||
currentSupplierName,
|
|
||||||
currentProduct,
|
|
||||||
serverCards,
|
serverCards,
|
||||||
}: {
|
}: {
|
||||||
message: ChatMessageData;
|
message: ChatMessageData;
|
||||||
isBot: boolean;
|
isBot: boolean;
|
||||||
currentSupplierName: string;
|
|
||||||
currentProduct: Product | undefined;
|
|
||||||
serverCards: QuotationCardData[];
|
serverCards: QuotationCardData[];
|
||||||
}) {
|
}) {
|
||||||
const m = message;
|
const m = message;
|
||||||
const usedCard = findUsedCard(m, serverCards);
|
const usedCard = findUsedCard(m, serverCards);
|
||||||
if (!usedCard) return null;
|
if (!usedCard) return null;
|
||||||
const cardNodes = Array.isArray(usedCard.edit_script) ? (usedCard.edit_script as unknown[]) : null;
|
|
||||||
const isWildCard = usedCard.type === CardType.WILD;
|
const isWildCard = usedCard.type === CardType.WILD;
|
||||||
const cardDetailId = usedCard.nego_card_id ?? usedCard.wild_card_id ?? null; // 협상카드 상세(/cards?detail=) 링크용
|
const cardDetailId = usedCard.nego_card_id ?? usedCard.wild_card_id ?? null; // 협상카드 상세(/cards?detail=) 링크용
|
||||||
|
|
||||||
@ -330,12 +298,20 @@ function UsedCardBox({
|
|||||||
<div
|
<div
|
||||||
className={`rounded border p-2 ${
|
className={`rounded border p-2 ${
|
||||||
isBot
|
isBot
|
||||||
? 'bg-amber-50/70 border-amber-200 dark:bg-amber-950/20 dark:border-amber-900/40'
|
? isWildCard
|
||||||
: 'bg-white/10 border-white/20'
|
? 'bg-amber-50/70 border-amber-200 dark:bg-amber-950/20 dark:border-amber-900/40'
|
||||||
|
: 'bg-primary/5 border-primary/25 dark:bg-primary/10 dark:border-primary/30'
|
||||||
|
: isWildCard
|
||||||
|
? 'bg-amber-300/15 border-amber-200/40'
|
||||||
|
: 'bg-white/10 border-white/20'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* 헤더: 어떤 카드인지(번호·이름·종류) */}
|
{/* 헤더: 어떤 카드인지(번호·이름·종류) */}
|
||||||
<div className={`flex items-center gap-1 ${isBot ? 'text-amber-800 dark:text-amber-300' : 'text-primary-foreground'}`}>
|
<div
|
||||||
|
className={`flex items-center gap-1 ${
|
||||||
|
isBot ? (isWildCard ? 'text-amber-800 dark:text-amber-300' : 'text-primary') : 'text-primary-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<Sparkles size={10} />
|
<Sparkles size={10} />
|
||||||
<Typography as="span" variant="small" className="text-[10px] font-semibold text-inherit">협상카드</Typography>
|
<Typography as="span" variant="small" className="text-[10px] font-semibold text-inherit">협상카드</Typography>
|
||||||
{cardDetailId ? (
|
{cardDetailId ? (
|
||||||
@ -374,23 +350,6 @@ function UsedCardBox({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script. 가격 변수는 매핑하지 않아(값 미주입) 실가격이 노출되지 않는다. */}
|
|
||||||
{cardNodes ? (
|
|
||||||
<div className="mt-1.5">
|
|
||||||
<SlateRenderer
|
|
||||||
nodes={cardNodes}
|
|
||||||
variables={{
|
|
||||||
partner_name: currentSupplierName,
|
|
||||||
product_name: currentProduct?.name ?? '',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : usedCard.script ? (
|
|
||||||
<Typography as="p" variant="small" className="mt-1.5 text-xs leading-relaxed whitespace-pre-line text-foreground/85">
|
|
||||||
{renderCardScriptPreview(usedCard.script)}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* 와일드카드 부가 정보: 사용 조건 / 메모 */}
|
{/* 와일드카드 부가 정보: 사용 조건 / 메모 */}
|
||||||
{isWildCard && (usedCard.condition || usedCard.memo) && (
|
{isWildCard && (usedCard.condition || usedCard.memo) && (
|
||||||
<div className="mt-1.5 pt-1.5 border-t border-amber-200/60 dark:border-amber-900/40 space-y-0.5 text-muted-foreground">
|
<div className="mt-1.5 pt-1.5 border-t border-amber-200/60 dark:border-amber-900/40 space-y-0.5 text-muted-foreground">
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { LABEL_DEFAULTS, DEFAULT_VAT_MODE, type CompanySettings, type VatMode }
|
|||||||
// 조회는 전 유저(브랜딩/라벨 렌더용), 저장은 백엔드가 OWNER 로 게이트한다.
|
// 조회는 전 유저(브랜딩/라벨 렌더용), 저장은 백엔드가 OWNER 로 게이트한다.
|
||||||
export function useCompanySettings() {
|
export function useCompanySettings() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const query = useGetSettings({ query: { staleTime: 5 * 60 * 1000 } });
|
const query = useGetSettings();
|
||||||
const settings: CompanySettings = (query.data?.settings as CompanySettings) ?? {};
|
const settings: CompanySettings = (query.data?.settings as CompanySettings) ?? {};
|
||||||
|
|
||||||
const save = async (next: CompanySettings) => {
|
const save = async (next: CompanySettings) => {
|
||||||
|
|||||||
16
negodata/front/src/lib/query-client.ts
Normal file
16
negodata/front/src/lib/query-client.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import {QueryClient} from '@tanstack/react-query';
|
||||||
|
|
||||||
|
// negodata 는 서버 응답을 캐시하지 않는다 — 화면에 뜨는 값은 항상 그 시점의 서버 값이다.
|
||||||
|
// (캐시를 두면 계정을 갈아탔을 때 앞 회사 CI·라벨·목록이 그대로 그려진다.)
|
||||||
|
// staleTime 0 = 받는 즉시 stale 로 보고 다시 읽는다, gcTime 0 = 화면에서 내려가면 바로 버린다.
|
||||||
|
// 페이지네이션 깜빡임은 각 훅의 placeholderData(keepPreviousData)가 막는다 — 캐시가 아니라 직전 렌더값이다.
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 0,
|
||||||
|
gcTime: 0,
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -36,7 +36,8 @@
|
|||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
|
|
||||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-mono: "JetBrains Mono", Consolas, "Segoe UI Mono", ui-monospace, SFMono-Regular, "Menlo", monospace;
|
/* 모노 폰트엔 한글 글리프가 없다 — Pretendard 를 넣어야 한글이 OS 기본(Windows 굴림)으로 떨어지지 않는다 */
|
||||||
|
--font-mono: "JetBrains Mono", Consolas, "Segoe UI Mono", ui-monospace, SFMono-Regular, "Menlo", "Pretendard Variable", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user