[fix] agent·negodata/front: 중간값 멘트 모순 수정(갑 포지션 갱신을 치환 뒤로·원값 인용가 반올림 제외·1% 카드 역행 금지) + 계정 전환 쿼리캐시 초기화·ChatTab 카드 렌더 정리 + font-mono 한글 폴백(Windows 한글깨짐)

This commit is contained in:
Mina Choi 2026-08-07 15:48:48 +09:00
parent 7dc925bcd4
commit a405e00bf9
10 changed files with 72 additions and 90 deletions

View File

@ -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:

View File

@ -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"

View File

@ -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):

View File

@ -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}

View File

@ -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),

View File

@ -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();
} }

View File

@ -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">

View File

@ -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) => {

View 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,
},
},
});

View File

@ -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 {