[feat] negodata/front: 설정 화면 회사/개발자 분리 + 카드 변수 별칭·미리보기 정비 + 네고율 공통화

- 설정 분리: /settings(최고관리자)는 공급사에게 보이는 브랜딩·안내 문구만, /dev/settings(개발자 신설)는
  협상 동작·데이터 구조를 바꾸는 탭(용어·커스텀 필드 등) — 화면에 보이는 것과 동작을 바꾸는 것을 관리 주체로 가름
- 카드 변수: prev_customer_price(당사 직전가) 칩 추가, 시드/구버전 별칭(anchoring_price 등)은
  툴바 미노출·인식만 지원. SlateRenderer 는 평문에 박힌 미주입 토큰도 카탈로그에 있으면 한글 라벨로 표기
- 견적상세 협상대화의 사용 카드 미리보기를 서식 렌더(renderCardScriptPreview)로 — 마커 원문 노출 제거
- 네고율 차감을 매입가 전용 → 매입가·판매가 공통으로(문구·주석 정리)
This commit is contained in:
Mina Choi 2026-08-05 16:00:31 +09:00
parent 54f8f7a6a1
commit a416668256
11 changed files with 122 additions and 65 deletions

View File

@ -16,6 +16,7 @@ import QuotationPage from '../pages/quotation';
import CardsPage from '../pages/cards';
import MembersPage from '../pages/members';
import SettingsPage from '../pages/settings';
import DevSettingsPage from '../pages/dev-settings';
import NotificationsPage from '../pages/notifications';
import OnboardingPage from '../pages/onboarding';
@ -105,7 +106,7 @@ export const router = createBrowserRouter([
Component: DevDesignPage,
},
{
// 최고관리자 전용. 회사 브랜딩/용어/커스텀필드 설정. (자식 loader 는 부모와 병렬 → initAuth 대기 필수)
// 최고관리자 전용. 공급사에게 보이는 브랜딩·안내 문구. (자식 loader 는 부모와 병렬 → initAuth 대기 필수)
path: 'settings',
loader: async () => {
await initAuth();
@ -113,6 +114,15 @@ export const router = createBrowserRouter([
},
Component: SettingsPage,
},
{
// 개발자 전용 고급 설정. 용어·커스텀 필드는 협상 동작·목표가 산정에 영향을 줘 관리자에게 열지 않는다.
path: 'dev/settings',
loader: async () => {
await initAuth();
return hasRole('개발자') ? null : redirect('/forbidden');
},
Component: DevSettingsPage,
},
],
},
{

View File

@ -1,5 +1,5 @@
import React from 'react';
import { variableLabel } from '@/features/cards/editor/variables';
import { isKnownVariable, variableLabel } from '@/features/cards/editor/variables';
interface SlateLeaf {
text: string;
@ -46,7 +46,9 @@ export default function SlateRenderer({ nodes, variables = {} }: SlateRendererPr
}
}
});
return result;
// 값 미주입 토큰 — 변수 노드가 아닌 평문에 박힌 {name} 도 카탈로그에 있으면 {한글라벨} 로 표기(변수명 원문 노출 방지).
return result.replace(/\{(\w+)\}/g, (token, name) =>
isKnownVariable(name) ? `{${variableLabel(name)}}` : token);
};
const renderLeaf = (leaf: SlateLeaf, key: string) => {

View File

@ -13,6 +13,7 @@ const PAGE_TO_PATH: Record<PageType, string> = {
CARDS: '/cards',
RENEGOTIATION: '/renegotiation',
MEMBERS: '/members',
DEV_SETTINGS: '/dev/settings', // /settings 보다 먼저 — startsWith 매칭이라 순서가 곧 우선순위
SETTINGS: '/settings',
DESIGN: '/dev/design',
NOTIFICATIONS: '/notifications',

View File

@ -12,7 +12,9 @@ import { cn } from '@/lib/utils';
import { NotificationBell } from './NotificationBell';
import { ActionBanner } from './ActionBanner';
import { GUIDE_TABS, TAB_LABEL, type GuideTab } from '@/features/onboarding/OnboardingGuideModal';
import { SETTINGS_TABS, SETTINGS_TAB_LABEL, type SettingsTab } from '@/features/settings/SettingsView';
import {
DEV_SETTINGS_TABS, OWNER_SETTINGS_TABS, SETTINGS_TAB_LABEL, type SettingsTab,
} from '@/features/settings/SettingsView';
import {
LayoutDashboard,
BarChart3,
@ -33,6 +35,7 @@ import {
Menu,
X,
BookOpen,
SlidersHorizontal,
} from 'lucide-react';
interface LayoutProps {
@ -69,12 +72,13 @@ const menuGroups: { label?: string; items: MenuItem[] }[] = [
label: '관리',
items: [
{ type: 'MEMBERS', label: '회원관리', icon: UserCog, id: 'sidebar-members', ownerOnly: true },
{ type: 'SETTINGS', label: '회사 설정', icon: Building, id: 'sidebar-settings', ownerOnly: true },
],
},
{
label: '개발자',
items: [
{ type: 'SETTINGS', label: '회사 설정', icon: Building, id: 'sidebar-settings', devOnly: true },
{ type: 'DEV_SETTINGS', label: '고급 설정', icon: SlidersHorizontal, id: 'sidebar-dev-settings', devOnly: true },
{ type: 'DESIGN', label: '디자인 시스템', icon: Palette, id: 'sidebar-design', devOnly: true },
],
},
@ -99,6 +103,7 @@ const pageLabelMap: Record<PageType, string> = {
RENEGOTIATION: '재협상 요청',
MEMBERS: '회원관리',
SETTINGS: '회사 설정',
DEV_SETTINGS: '고급 설정',
DESIGN: '디자인 시스템',
NOTIFICATIONS: '알림',
};
@ -360,10 +365,14 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
setIsCmdOpen(false);
}}
onSelectSettings={(tab) => {
navigate(`/settings?tab=${tab}`);
// 탭이 어느 페이지 소속인지에 따라 경로가 갈린다(관리자=회사 설정, 개발자=고급 설정).
navigate(`${OWNER_SETTINGS_TABS.includes(tab) ? '/settings' : '/dev/settings'}?tab=${tab}`);
setIsCmdOpen(false);
}}
canSeeSettings={visibleItems.some((i) => i.type === 'SETTINGS')}
settingsTabs={[
...(visibleItems.some((i) => i.type === 'SETTINGS') ? OWNER_SETTINGS_TABS : []),
...(visibleItems.some((i) => i.type === 'DEV_SETTINGS') ? DEV_SETTINGS_TABS : []),
]}
/>
{isProfileOpen && <ProfileSheet open onClose={() => setIsProfileOpen(false)} />}
@ -380,7 +389,7 @@ function CommandMenu({
onSelect,
onSelectGuide,
onSelectSettings,
canSeeSettings,
settingsTabs,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -389,7 +398,7 @@ function CommandMenu({
onSelect: (type: PageType) => void;
onSelectGuide: (tab: GuideTab) => void;
onSelectSettings: (tab: SettingsTab) => void;
canSeeSettings: boolean;
settingsTabs: readonly SettingsTab[];
}) {
const [query, setQuery] = useState('');
@ -402,10 +411,11 @@ function CommandMenu({
// 이용안내 탭도 이동 대상 — 대시보드로 가면서 ?guide=<탭> 을 붙여 해당 탭으로 바로 연다.
const guideEntries = GUIDE_TABS.map((t) => ({ tab: t, label: `이용안내 · ${TAB_LABEL[t]}` }));
const filteredGuides = q ? guideEntries.filter((g) => g.label.toLowerCase().includes(q)) : guideEntries;
// 회사 설정 탭도 이동 대상(최고관리자만 — 메뉴와 같은 게이팅).
const settingsEntries = canSeeSettings
? SETTINGS_TABS.map((t) => ({ tab: t, label: `회사 설정 · ${SETTINGS_TAB_LABEL[t]}` }))
: [];
// 설정 탭도 이동 대상 — 볼 수 있는 페이지의 탭만(메뉴와 같은 게이팅).
const settingsEntries = settingsTabs.map((t) => ({
tab: t,
label: `${OWNER_SETTINGS_TABS.includes(t) ? '회사 설정' : '고급 설정'} · ${SETTINGS_TAB_LABEL[t]}`,
}));
const filteredSettings = q ? settingsEntries.filter((e) => e.label.toLowerCase().includes(q)) : settingsEntries;
return (

View File

@ -15,6 +15,7 @@ export const CARD_VARIABLES: CardVariable[] = [
{ name: 'product_name', label: '상품명' },
{ name: 'input_price', label: '제시가' }, // 협력사가 이번에 제시한 가격
{ name: 'prev_partner_price', label: '협력사 직전가' }, // 협력사의 직전 라운드 제시가
{ name: 'prev_customer_price', label: '당사 직전가' }, // 당사의 직전 제안가(갑의 최신 포지션) — WC-05 절충 산식 기준
{ name: 'counter_price', label: '제안가' }, // 당사가 이번에 제시하는 카운터 가격
{ name: 'target_mid_price', label: '중간가' }, // 앵커·목표 중간값(역제안용)
{ name: 'middle_price', label: '절충가' }, // 당사 직전가·협력사 제시가의 절충값
@ -29,11 +30,19 @@ export const CONDITION_LABEL = '조건 전략';
export const isConditionVariable = (name: string): boolean => name === CONDITION_VARIABLE;
// 시드/구버전 별칭 — 툴바엔 안 올리고 인식·라벨만 지원. chat_engine.vars_for 가 본명과 같은 값으로 채운다.
const VARIABLE_ALIASES: Record<string, string> = {
anchoring_price: 'anchor_price', // DB 시드 카드·sessions 컬럼 표기
};
const VARIABLE_NAMES = new Set([...CARD_VARIABLES.map((v) => v.name), CONDITION_VARIABLE]);
export const isKnownVariable = (name: string): boolean => VARIABLE_NAMES.has(name);
export const isKnownVariable = (name: string): boolean =>
VARIABLE_NAMES.has(name) || name in VARIABLE_ALIASES;
export const variableLabel = (name: string): string =>
name === CONDITION_VARIABLE
export const variableLabel = (name: string): string => {
const canonical = VARIABLE_ALIASES[name] ?? name;
return canonical === CONDITION_VARIABLE
? CONDITION_LABEL
: (CARD_VARIABLES.find((v) => v.name === name)?.label ?? name);
: (CARD_VARIABLES.find((v) => v.name === canonical)?.label ?? canonical);
};

View File

@ -73,9 +73,9 @@ export function QuotationCreateModal({
const [ceilingTouched, setCeilingTouched] = useState(false); // 상한율을 직접 건드렸는지 — 안 건드렸으면 세팅값 표시
const [submitting, setSubmitting] = useState(false);
const [mdTouched, setMdTouched] = useState(false); // 담당자가 제시가를 직접 건드렸는지 — 안 건드렸으면 자동 산출값을 채운다
// 매입가 네고율 차감 — 이 견적에서만 조정. 안 건드리면 세팅 기본값을 따른다(negoTouched=false). 네고율 값 자체는 세팅값 고정.
// 네고율 차감(매입가·판매가 공통) — 이 견적에서만 조정. 안 건드리면 세팅 기본값을 따른다(negoTouched=false). 네고율 값 자체는 세팅값 고정.
const [negoTouched, setNegoTouched] = useState(false);
const [applyNego, setApplyNego] = useState(false); // 매입가에서 네고율 차감 여부
const [applyNego, setApplyNego] = useState(false); // 네고율 차감 여부
// 목표가로 채택한 후보 키 — null이면 최저 후보를 기본 채택.
const [selectedCandidateKey, setSelectedCandidateKey] = useState<string | null>(null);
@ -156,7 +156,7 @@ export function QuotationCreateModal({
// ── 목표가 산정 (카드 게이팅이 목표가를 참조하므로 게이팅보다 먼저 계산한다) ──
const {
settingMarginPct,
negoCandidateKey,
negoToggleAvailable,
targetBreakdown,
autoTarget,
activeCandidateKey,
@ -462,7 +462,7 @@ export function QuotationCreateModal({
</div>
<div className="p-3 space-y-4">
{/* 목표가 산정 후보 — 후보 택1로 목표가 결정(기본=최저). 매입가 후보 행의 체크박스로 네고율 차감 여부 조정. 숨김필드는 제외. */}
{/* 목표가 산정 후보 — 후보 택1로 목표가 결정(기본=최저). 네고율 차감 토글은 매입가·판매가 공통이라 리스트 상단에 둔다. 숨김필드는 제외. */}
{productId && targetBreakdown.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
@ -477,14 +477,29 @@ export function QuotationCreateModal({
상품 상세에서 수정
</button>
</div>
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
후보를 선택하면 그 값이 목표가로 정해집니다 (기본: 최저).
</Typography>
<div className="flex items-center justify-between gap-2">
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
후보를 선택하면 그 값이 목표가로 정해집니다 (기본: 최저).
</Typography>
{negoToggleAvailable && (
<label className="flex items-center gap-1 shrink-0 cursor-pointer">
<input
type="checkbox"
className="h-3.5 w-3.5 accent-primary cursor-pointer"
checked={effectiveApplyNego}
onChange={(e) => {
setNegoTouched(true);
setApplyNego(e.target.checked);
}}
/>
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground whitespace-nowrap">네고율 {settingMarginPct}% 차감</Typography>
</label>
)}
</div>
<div className="space-y-1" role="radiogroup">
{targetBreakdown.map((c) => {
const isMin = autoTarget != null && c.value === autoTarget;
const isActive = c.key === activeCandidateKey;
const showNego = c.key === negoCandidateKey;
return (
<div
key={c.key}
@ -508,20 +523,6 @@ export function QuotationCreateModal({
{isActive && <span className="h-1.5 w-1.5 rounded-full bg-primary" />}
</span>
<Typography as="span" variant="small" className="flex-1 min-w-0 truncate text-[11px] text-muted-foreground">{c.label}</Typography>
{showNego && (
<label className="flex items-center gap-1 shrink-0 cursor-pointer" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
className="h-3.5 w-3.5 accent-primary cursor-pointer"
checked={effectiveApplyNego}
onChange={(e) => {
setNegoTouched(true);
setApplyNego(e.target.checked);
}}
/>
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground whitespace-nowrap">네고율 {settingMarginPct}% 차감</Typography>
</label>
)}
<Typography as="span" variant="small" className="w-16 shrink-0 text-right font-mono text-[10px] tabular-nums text-muted-foreground/70">{c.raw.toLocaleString()}</Typography>
<Typography as="span" variant="small" className={cn('w-20 shrink-0 text-right font-mono font-bold tabular-nums', isActive && 'text-primary')}>₩{c.value.toLocaleString()}</Typography>
<span className="w-9 shrink-0 text-right">

View File

@ -11,6 +11,7 @@ import { type Product, type Partner, sessionStatusLabel } from '../../types';
import { maskPrices } from '@/lib/utils';
import { useCompanySettings } from '@/features/settings/useCompanySettings';
import { renderEmphasis } from '@/lib/emphasis';
import { renderCardScriptPreview } from '@/features/cards/editor';
// 협상로그 JSON 다운로드(IMK #9). 가격·비율 숫자는 maskPrices 로 가려 내보낸다(화면 표기와 동일 규칙).
// target_price 등 숫자 필드는 아예 제외 — 양식은 대화 흐름(순번/발화자/스텝/멘트/카드사용) 중심.
@ -386,7 +387,7 @@ function UsedCardBox({
</div>
) : usedCard.script ? (
<Typography as="p" variant="small" className="mt-1.5 text-xs leading-relaxed whitespace-pre-line text-foreground/85">
{maskPrices(usedCard.script)}
{renderCardScriptPreview(maskPrices(usedCard.script))}
</Typography>
) : null}

View File

@ -48,7 +48,7 @@ export function useTargetPrice({
const selectedSetting = quotationSettings.find((s) => s.qt_setting_id === settingId);
const settingMargin = parsePercent(selectedSetting?.target_margin); // 세팅 네고율(비율)
const settingMarginPct = +(settingMargin * 100).toFixed(1);
// 매입가 네고율 차감 여부 — 안 건드리면 세팅값(>0이면 차감), 건드리면 체크박스값. 네고율 값은 세팅값 고정.
// 네고율 차감 여부(매입가·판매가 공통) — 안 건드리면 세팅값(>0이면 차감), 건드리면 체크박스값. 네고율 값은 세팅값 고정.
const negoAvailable = settingMargin > 0;
const effectiveApplyNego = negoTouched ? applyNego : negoAvailable;
const margin = effectiveApplyNego ? settingMargin : 0; // 미적용이면 0 → 매입가/판매가 그대로
@ -64,11 +64,11 @@ export function useTargetPrice({
// 서버 _candidates 와 동일하게 10원 단위 반올림(IMK #11) — 후보·목표가·저장값이 다 일치.
.map((c) => ({ key: c.key, label: c.label, raw: c.raw as number, value: Math.round(((c.raw as number) * (1 - c.rate)) / 10) * 10 }));
const autoTarget = targetBreakdown.length ? Math.min(...targetBreakdown.map((c) => c.value)) : null;
// 기본 채택 후보 = 최저(동률이면 첫 후보). 네고율 체크박스는 매입가 후보(없으면 판매가)에 붙인다.
// 기본 채택 후보 = 최저(동률이면 첫 후보).
const minCandidateKey = targetBreakdown.find((c) => c.value === autoTarget)?.key ?? null;
const negoCandidateKey = negoAvailable
? (targetBreakdown.find((c) => c.key === 'purchase_price')?.key ?? targetBreakdown.find((c) => c.key === 'selling_price')?.key ?? null)
: null;
// 네고율 토글 노출 여부 — 세팅 네고율이 있고, 네고율이 걸리는 후보(매입가/판매가)가 하나라도 보일 때. 두 후보에 공통 적용이라 행이 아닌 리스트 상단에 노출.
const negoToggleAvailable = negoAvailable
&& targetBreakdown.some((c) => c.key === 'purchase_price' || c.key === 'selling_price');
// 채택 후보 — 사용자가 고르면 그 후보, 아니면 최저. 상품 변경 등으로 선택 키가 사라지면 최저로 폴백.
const pickedCandidate = selectedCandidateKey ? targetBreakdown.find((c) => c.key === selectedCandidateKey) : undefined;
const activeCandidateKey = pickedCandidate ? pickedCandidate.key : minCandidateKey;
@ -104,7 +104,7 @@ export function useTargetPrice({
return {
settingMarginPct,
negoCandidateKey,
negoToggleAvailable,
targetBreakdown,
autoTarget,
activeCandidateKey,

View File

@ -1,4 +1,4 @@
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { Fragment, useEffect, useMemo, useRef, useState, type ElementType } from 'react';
import { useSearchParams } from 'react-router';
import { Palette, Tags, ListPlus, MessageSquareText, Plus, Trash2, RotateCcw, Download, Upload, X } from 'lucide-react';
import { showToast } from '@/lib/notify';
@ -35,15 +35,28 @@ export const SETTINGS_TAB_LABEL: Record<SettingsTab, string> = {
fields: '커스텀 필드',
};
export function SettingsView() {
// 화면에 보이는 것(로고·안내 문구)은 회사 관리자가, 협상 동작·데이터 구조를 바꾸는 것은 개발자가 다룬다.
// 용어를 바꾸면 협상 멘트 호칭까지 같이 바뀌고, 커스텀 필드 탭의 협상 기준가·필드 숨김은 목표가 산정과
// 학습 기준에 영향을 주므로 개발자 쪽에 둔다.
export const OWNER_SETTINGS_TABS: readonly SettingsTab[] = ['branding', 'portal'];
export const DEV_SETTINGS_TABS: readonly SettingsTab[] = ['labels', 'fields'];
const SETTINGS_TAB_ICON: Record<SettingsTab, ElementType> = {
branding: Palette,
portal: MessageSquareText,
labels: Tags,
fields: ListPlus,
};
export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly SettingsTab[] }) {
const { settings, isLoading, save } = useCompanySettings();
// 탭은 ?tab=<탭> 으로 URL 에 남긴다 — 링크 공유·새로고침·메뉴 빠른이동에서 같은 탭으로 열리게.
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab');
const tab: SettingsTab = (SETTINGS_TABS as readonly string[]).includes(tabParam ?? '')
const tab: SettingsTab = tabs.includes((tabParam ?? '') as SettingsTab)
? (tabParam as SettingsTab)
: 'branding';
: tabs[0];
const setTab = (next: string) => {
const params = new URLSearchParams(searchParams);
params.set('tab', next);
@ -140,18 +153,14 @@ export function SettingsView() {
<Tabs value={tab} onValueChange={setTab}>
<div className="flex items-center justify-between gap-3 flex-wrap">
<TabsList>
<TabsTrigger value="branding" className="gap-1.5 px-3">
<Palette size={13} /> 브랜딩(CI)
</TabsTrigger>
<TabsTrigger value="portal" className="gap-1.5 px-3">
<MessageSquareText size={13} /> 공급사 포털 안내
</TabsTrigger>
<TabsTrigger value="labels" className="gap-1.5 px-3">
<Tags size={13} /> 용어(라벨)
</TabsTrigger>
<TabsTrigger value="fields" className="gap-1.5 px-3">
<ListPlus size={13} /> 커스텀 필드
</TabsTrigger>
{tabs.map((t) => {
const Icon = SETTINGS_TAB_ICON[t];
return (
<TabsTrigger key={t} value={t} className="gap-1.5 px-3">
<Icon size={13} /> {SETTINGS_TAB_LABEL[t]}
</TabsTrigger>
);
})}
</TabsList>
{/* 저장 바 — 변경이 있을 때만 활성화 */}

View File

@ -0,0 +1,13 @@
import { PageContainer } from '@/components/layout/PageContainer';
import { DEV_SETTINGS_TABS, SettingsView } from '@/features/settings/SettingsView';
// 고급 설정(개발자 전용) — 용어(라벨)·커스텀 필드.
// 용어를 바꾸면 협상 멘트 호칭까지 같이 바뀌고, 커스텀 필드 탭의 협상 기준가·상품 필드 숨김은
// 목표가 산정과 학습 기준에 영향을 주므로 회사 관리자에게 열지 않는다.
export default function DevSettingsPage() {
return (
<PageContainer>
<SettingsView tabs={DEV_SETTINGS_TABS} />
</PageContainer>
);
}

View File

@ -1,11 +1,12 @@
import { PageContainer } from '@/components/layout/PageContainer';
import { SettingsView } from '@/features/settings/SettingsView';
import { OWNER_SETTINGS_TABS, SettingsView } from '@/features/settings/SettingsView';
// 회사 설정(최고관리자 전용) — 브랜딩(CI)/용어(라벨)/커스텀 필드. 라우트 loader 가 OWNER 를 게이트한다.
// 회사 설정(최고관리자) — 공급사에게 보이는 브랜딩·안내 문구만 다룬다.
// 협상 동작·데이터 구조를 바꾸는 용어/커스텀 필드는 개발자 설정(pages/dev-settings)으로 분리.
export default function SettingsPage() {
return (
<PageContainer>
<SettingsView />
<SettingsView tabs={OWNER_SETTINGS_TABS} />
</PageContainer>
);
}