[feat] negodata/front: 설정 화면 회사/개발자 분리 + 카드 변수 별칭·미리보기 정비 + 네고율 공통화
- 설정 분리: /settings(최고관리자)는 공급사에게 보이는 브랜딩·안내 문구만, /dev/settings(개발자 신설)는 협상 동작·데이터 구조를 바꾸는 탭(용어·커스텀 필드 등) — 화면에 보이는 것과 동작을 바꾸는 것을 관리 주체로 가름 - 카드 변수: prev_customer_price(당사 직전가) 칩 추가, 시드/구버전 별칭(anchoring_price 등)은 툴바 미노출·인식만 지원. SlateRenderer 는 평문에 박힌 미주입 토큰도 카탈로그에 있으면 한글 라벨로 표기 - 견적상세 협상대화의 사용 카드 미리보기를 서식 렌더(renderCardScriptPreview)로 — 마커 원문 노출 제거 - 네고율 차감을 매입가 전용 → 매입가·판매가 공통으로(문구·주석 정리)
This commit is contained in:
parent
54f8f7a6a1
commit
a416668256
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
{/* 저장 바 — 변경이 있을 때만 활성화 */}
|
||||
|
||||
13
negodata/front/src/pages/dev-settings.tsx
Normal file
13
negodata/front/src/pages/dev-settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user