diff --git a/negodata/front/src/app/router.tsx b/negodata/front/src/app/router.tsx index 51eabf1..f2c8a6d 100644 --- a/negodata/front/src/app/router.tsx +++ b/negodata/front/src/app/router.tsx @@ -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, + }, ], }, { diff --git a/negodata/front/src/components/SlateRenderer.tsx b/negodata/front/src/components/SlateRenderer.tsx index da72cac..8ba6537 100644 --- a/negodata/front/src/components/SlateRenderer.tsx +++ b/negodata/front/src/components/SlateRenderer.tsx @@ -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) => { diff --git a/negodata/front/src/components/layout/AuthenticatedLayout.tsx b/negodata/front/src/components/layout/AuthenticatedLayout.tsx index abf8043..7f7c1b6 100644 --- a/negodata/front/src/components/layout/AuthenticatedLayout.tsx +++ b/negodata/front/src/components/layout/AuthenticatedLayout.tsx @@ -13,6 +13,7 @@ const PAGE_TO_PATH: Record = { CARDS: '/cards', RENEGOTIATION: '/renegotiation', MEMBERS: '/members', + DEV_SETTINGS: '/dev/settings', // /settings 보다 먼저 — startsWith 매칭이라 순서가 곧 우선순위 SETTINGS: '/settings', DESIGN: '/dev/design', NOTIFICATIONS: '/notifications', diff --git a/negodata/front/src/components/layout/Layout.tsx b/negodata/front/src/components/layout/Layout.tsx index d01ce9e..3ff0631 100644 --- a/negodata/front/src/components/layout/Layout.tsx +++ b/negodata/front/src/components/layout/Layout.tsx @@ -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 = { 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 && 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 ( diff --git a/negodata/front/src/features/cards/editor/variables.ts b/negodata/front/src/features/cards/editor/variables.ts index 2023f62..9aa84b9 100644 --- a/negodata/front/src/features/cards/editor/variables.ts +++ b/negodata/front/src/features/cards/editor/variables.ts @@ -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 = { + 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); +}; diff --git a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx index ae7f6f4..d42ddcf 100644 --- a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx @@ -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(null); @@ -156,7 +156,7 @@ export function QuotationCreateModal({ // ── 목표가 산정 (카드 게이팅이 목표가를 참조하므로 게이팅보다 먼저 계산한다) ── const { settingMarginPct, - negoCandidateKey, + negoToggleAvailable, targetBreakdown, autoTarget, activeCandidateKey, @@ -462,7 +462,7 @@ export function QuotationCreateModal({
- {/* 목표가 산정 후보 — 후보 택1로 목표가 결정(기본=최저). 매입가 후보 행의 체크박스로 네고율 차감 여부 조정. 숨김필드는 제외. */} + {/* 목표가 산정 후보 — 후보 택1로 목표가 결정(기본=최저). 네고율 차감 토글은 매입가·판매가 공통이라 리스트 상단에 둔다. 숨김필드는 제외. */} {productId && targetBreakdown.length > 0 && (
@@ -477,14 +477,29 @@ export function QuotationCreateModal({ 상품 상세에서 수정
- - 후보를 선택하면 그 값이 목표가로 정해집니다 (기본: 최저). - +
+ + 후보를 선택하면 그 값이 목표가로 정해집니다 (기본: 최저). + + {negoToggleAvailable && ( + + )} +
{targetBreakdown.map((c) => { const isMin = autoTarget != null && c.value === autoTarget; const isActive = c.key === activeCandidateKey; - const showNego = c.key === negoCandidateKey; return (
} {c.label} - {showNego && ( - - )} {c.raw.toLocaleString()} ₩{c.value.toLocaleString()} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx index 4817836..2b036ea 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx @@ -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({
) : usedCard.script ? ( - {maskPrices(usedCard.script)} + {renderCardScriptPreview(maskPrices(usedCard.script))} ) : null} diff --git a/negodata/front/src/features/quotations/hooks/useTargetPrice.ts b/negodata/front/src/features/quotations/hooks/useTargetPrice.ts index cc3c21f..d948558 100644 --- a/negodata/front/src/features/quotations/hooks/useTargetPrice.ts +++ b/negodata/front/src/features/quotations/hooks/useTargetPrice.ts @@ -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, diff --git a/negodata/front/src/features/settings/SettingsView.tsx b/negodata/front/src/features/settings/SettingsView.tsx index fe21c00..eff5eb9 100644 --- a/negodata/front/src/features/settings/SettingsView.tsx +++ b/negodata/front/src/features/settings/SettingsView.tsx @@ -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 = { 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 = { + 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() {
- - 브랜딩(CI) - - - 공급사 포털 안내 - - - 용어(라벨) - - - 커스텀 필드 - + {tabs.map((t) => { + const Icon = SETTINGS_TAB_ICON[t]; + return ( + + {SETTINGS_TAB_LABEL[t]} + + ); + })} {/* 저장 바 — 변경이 있을 때만 활성화 */} diff --git a/negodata/front/src/pages/dev-settings.tsx b/negodata/front/src/pages/dev-settings.tsx new file mode 100644 index 0000000..3db6f35 --- /dev/null +++ b/negodata/front/src/pages/dev-settings.tsx @@ -0,0 +1,13 @@ +import { PageContainer } from '@/components/layout/PageContainer'; +import { DEV_SETTINGS_TABS, SettingsView } from '@/features/settings/SettingsView'; + +// 고급 설정(개발자 전용) — 용어(라벨)·커스텀 필드. +// 용어를 바꾸면 협상 멘트 호칭까지 같이 바뀌고, 커스텀 필드 탭의 협상 기준가·상품 필드 숨김은 +// 목표가 산정과 학습 기준에 영향을 주므로 회사 관리자에게 열지 않는다. +export default function DevSettingsPage() { + return ( + + + + ); +} diff --git a/negodata/front/src/pages/settings.tsx b/negodata/front/src/pages/settings.tsx index e12238b..8d5a7b1 100644 --- a/negodata/front/src/pages/settings.tsx +++ b/negodata/front/src/pages/settings.tsx @@ -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 ( - + ); }