[fix] negodata·frontend·backend: 회사별 브랜드 색(primary_color) 기능 전면 제거 — 편집 UI 없이 값만 남아 적용되던 구현 정리(포털·메일·사이드바 색 솔루션 고정, 시드·응답 필드 제거)

This commit is contained in:
Mina Choi 2026-08-07 13:45:23 +09:00
parent 4ed23c56b8
commit 11b29b45e3
15 changed files with 19 additions and 78 deletions

View File

@ -72,5 +72,4 @@ class Res_HidePopup(Res_WebPacketProtocol):
class Res_SessionBranding(Res_WebPacketProtocol): class Res_SessionBranding(Res_WebPacketProtocol):
service_name: str = Field("", description="회사 서비스명(companies.settings.branding.service_name). 미설정 시 빈 값") service_name: str = Field("", description="회사 서비스명(companies.settings.branding.service_name). 미설정 시 빈 값")
logo_url: str = Field("", description="회사 로고 URL") logo_url: str = Field("", description="회사 로고 URL")
primary_color: str = Field("", description="브랜드 색상(hex)")
helpdesk: list = Field(default_factory=list, description="헬프데스크 연락처 줄 목록(companies.settings.branding.helpdesk). 한 줄 = 담당자 한 명") helpdesk: list = Field(default_factory=list, description="헬프데스크 연락처 줄 목록(companies.settings.branding.helpdesk). 한 줄 = 담당자 한 명")

View File

@ -279,7 +279,6 @@ class AuthService:
branding = branding or {} branding = branding or {}
res.service_name = branding.get("service_name") or "" res.service_name = branding.get("service_name") or ""
res.logo_url = branding.get("logo_url") or "" res.logo_url = branding.get("logo_url") or ""
res.primary_color = branding.get("primary_color") or ""
res.helpdesk = branding.get("helpdesk") or [] res.helpdesk = branding.get("helpdesk") or []
return res return res

View File

@ -57,7 +57,6 @@ export interface RefreshTokenResponse {
export interface Branding { export interface Branding {
service_name?: string service_name?: string
logo_url?: string logo_url?: string
primary_color?: string
helpdesk?: string[] // 헬프데스크 연락처 — 한 줄 = 담당자 한 명. 비면 연락처 영역을 렌더하지 않는다 helpdesk?: string[] // 헬프데스크 연락처 — 한 줄 = 담당자 한 명. 비면 연락처 영역을 렌더하지 않는다
} }
@ -66,7 +65,6 @@ export interface SessionBrandingResponse {
result: ApiResult result: ApiResult
service_name: string service_name: string
logo_url: string logo_url: string
primary_color: string
helpdesk?: string[] helpdesk?: string[]
} }

View File

@ -43,10 +43,9 @@ export function usePreLoginBranding(): Branding | null {
const next: Branding = { const next: Branding = {
service_name: res.service_name || undefined, service_name: res.service_name || undefined,
logo_url: res.logo_url || undefined, logo_url: res.logo_url || undefined,
primary_color: res.primary_color || undefined,
helpdesk: res.helpdesk?.length ? res.helpdesk : undefined, helpdesk: res.helpdesk?.length ? res.helpdesk : undefined,
} }
if (!next.service_name && !next.logo_url && !next.primary_color && !next.helpdesk) return if (!next.service_name && !next.logo_url && !next.helpdesk) return
setBranding(next) setBranding(next)
writeCached(next) // 다음 진입에 session_id 가 없어도 이 회사로 보이게 한다 writeCached(next) // 다음 진입에 session_id 가 없어도 이 회사로 보이게 한다
}) })

View File

@ -1,7 +1,7 @@
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
import { Logo } from '@/components' import { Logo } from '@/components'
import { useMeQuery } from '@/apis' import { useMeQuery } from '@/apis'
import { cn, useBrandColor } from '@/lib' import { cn } from '@/lib'
// 좌측 폭: list=반응형 비율 / chat=고정폭 단계 축소 // 좌측 폭: list=반응형 비율 / chat=고정폭 단계 축소
const SIDEBAR_WIDTH = { const SIDEBAR_WIDTH = {
@ -44,8 +44,7 @@ export function MainLayout({
header, header,
children, children,
}: MainLayoutProps) { }: MainLayoutProps) {
const { data: user } = useMeQuery() // 회사 브랜딩(서비스명/로고/브랜드 색) 주입 const { data: user } = useMeQuery() // 회사 브랜딩(서비스명/로고) 주입 — 색은 솔루션 고정
useBrandColor(user?.branding?.primary_color)
const panes = ( const panes = (
<> <>
<aside className={cn(styles.sidebar, SIDEBAR_WIDTH[sidebarWidth])}> <aside className={cn(styles.sidebar, SIDEBAR_WIDTH[sidebarWidth])}>

View File

@ -4,4 +4,3 @@ export type { ClassValue } from '@/lib/cn'
export { interactive } from '@/lib/interactive' export { interactive } from '@/lib/interactive'
export { toast } from '@/lib/toast' export { toast } from '@/lib/toast'
export { formatKstDateTime, KST_TIME_ZONE } from '@/lib/datetime' export { formatKstDateTime, KST_TIME_ZONE } from '@/lib/datetime'
export { useBrandColor } from '@/lib/useBrandColor'

View File

@ -1,29 +0,0 @@
import { useEffect } from 'react'
// 회사 브랜드 색(companies.settings.branding.primary_color)을 브랜드 CSS 변수에 적용한다.
// hover/ring/연한 배경은 color-mix 파생으로 만들어 토스풍 톤 관계를 유지하고,
// 색이 없거나 hex 가 아니면(injection 방지) 기본 팔레트(Toss blue)를 그대로 둔다.
const HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/
const DERIVED = {
'--brand-600': (c: string) => c,
'--brand-700': (c: string) => `color-mix(in srgb, ${c} 85%, #000)`,
'--brand-500': (c: string) => `color-mix(in srgb, ${c} 70%, #fff)`,
'--brand-light': (c: string) => `color-mix(in srgb, ${c} 9%, #fff)`,
} as const
export function useBrandColor(color?: string) {
useEffect(() => {
const c = (color ?? '').trim()
if (!HEX_COLOR.test(c)) return
const root = document.documentElement.style
for (const [name, derive] of Object.entries(DERIVED)) {
root.setProperty(name, derive(c))
}
return () => {
for (const name of Object.keys(DERIVED)) {
root.removeProperty(name)
}
}
}, [color])
}

View File

@ -2,12 +2,10 @@ import { Navigate } from 'react-router'
import { tokenStorage } from '@/apis' import { tokenStorage } from '@/apis'
import { Logo } from '@/components' import { Logo } from '@/components'
import { LoginForm, usePreLoginBranding } from '@/features/auth' import { LoginForm, usePreLoginBranding } from '@/features/auth'
import { useBrandColor } from '@/lib'
export function LoginPage() { export function LoginPage() {
// 초청 링크의 session_id(없으면 직전 로그인 캐시)로 회사 브랜딩을 먼저 그린다. // 초청 링크의 session_id(없으면 직전 로그인 캐시)로 회사 브랜딩(서비스명·로고)을 먼저 그린다.
const branding = usePreLoginBranding() const branding = usePreLoginBranding()
useBrandColor(branding?.primary_color)
// 이미 로그인된 상태면 목록으로 // 이미 로그인된 상태면 목록으로
if (tokenStorage.hasToken()) { if (tokenStorage.hasToken()) {

View File

@ -28,8 +28,6 @@ _DEFAULT_EMAIL_COLOR = "#3182f6"
_DEFAULT_EMAIL_HEADER = "NEGODATA" _DEFAULT_EMAIL_HEADER = "NEGODATA"
_DEFAULT_EMAIL_GREETING = "아래 견적 건의 협상에 참여해 주세요." _DEFAULT_EMAIL_GREETING = "아래 견적 건의 협상에 참여해 주세요."
_HEX_COLOR = re.compile(r"^#[0-9a-fA-F]{3,8}$")
# HTML 본문은 코드에 박지 않고 파일에서 읽는다(모듈 로드 시 1회). $placeholder 는 string.Template 로 치환. # HTML 본문은 코드에 박지 않고 파일에서 읽는다(모듈 로드 시 1회). $placeholder 는 string.Template 로 치환.
_TEMPLATE_DIR = Path(__file__).parent / "email_templates" _TEMPLATE_DIR = Path(__file__).parent / "email_templates"
_INVITE_HTML = Template((_TEMPLATE_DIR / "invite_email.html").read_text(encoding="utf-8")) _INVITE_HTML = Template((_TEMPLATE_DIR / "invite_email.html").read_text(encoding="utf-8"))
@ -112,10 +110,10 @@ def build_invite_email(
목표가·앵커링가는 협상 전략 값이라 메일에 담지 않는다(공급사에게 노출 금지). 목표가·앵커링가는 협상 전략 값이라 메일에 담지 않는다(공급사에게 노출 금지).
공급사는 링크로 협상 화면에 진입해 입찰한다. 공급사는 링크로 협상 화면에 진입해 입찰한다.
branding = companies.settings.branding 헤더·색은 회사 CI(logo_url·service_name·primary_color) branding = companies.settings.branding 헤더 회사 CI(logo_url·service_name)
그대로 쓴다(메일 전용 이중 설정 금지). 메일 전용 키는 인사 문구(email_greeting) 하나뿐. 그대로 쓴다(메일 전용 이중 설정 금지). 메일 전용 키는 인사 문구(email_greeting) 하나뿐.
헤더는 로고 있으면 로고, 없으면 service_name 회사명(company_name) NEGODATA 텍스트 헤더는 로고 있으면 로고, 없으면 service_name 회사명(company_name) NEGODATA 텍스트
공급사에겐 솔루션명보다 발주사가 보여야 한다. 색은 primary_color 기본( 포털 브랜드 블루) . 공급사에겐 솔루션명보다 발주사가 보여야 한다. 색은 회사별 커스텀 없이 솔루션 기본색 고정.
레이아웃은 고정이고 값만 갈아끼우므로, 어떤 값을 넣어도 메일이 깨지지 않는다. 레이아웃은 고정이고 값만 갈아끼우므로, 어떤 값을 넣어도 메일이 깨지지 않는다.
""" """
b = branding or {} b = branding or {}
@ -133,7 +131,7 @@ def build_invite_email(
qt_number=escape(qt_number), qt_number=escape(qt_number),
deadline=escape(deadline), deadline=escape(deadline),
chat_url=escape(chat_url), chat_url=escape(chat_url),
brand_color=_safe_color(b.get("primary_color")), brand_color=_DEFAULT_EMAIL_COLOR,
header_content=_header_content(header_name, (b.get("logo_url") or "").strip()), header_content=_header_content(header_name, (b.get("logo_url") or "").strip()),
email_greeting=escape(greeting), email_greeting=escape(greeting),
) )
@ -149,12 +147,6 @@ def build_invite_email(
return subject, html, text return subject, html, text
def _safe_color(value: str | None) -> str:
"""브랜드 색은 hex 만 통과 — 이상값이 style 속성을 깨거나 CSS 를 주입하지 못하게 기본색으로 되돌린다."""
v = (value or "").strip()
return v if _HEX_COLOR.match(v) else _DEFAULT_EMAIL_COLOR
def _header_content(name: str, logo_url: str) -> str: def _header_content(name: str, logo_url: str) -> str:
"""헤더(흰 배경) 내용물 — 회사 CI 로고가 있으면 이미지(회사명은 alt 로), 없으면 회사명 텍스트. escape 는 여기서 끝낸다.""" """헤더(흰 배경) 내용물 — 회사 CI 로고가 있으면 이미지(회사명은 alt 로), 없으면 회사명 텍스트. escape 는 여기서 끝낸다."""
if logo_url: if logo_url:

View File

@ -1,7 +1,7 @@
"""협상 초청 메일 미리보기(/v1/company/settings/email-preview) 테스트 — 회사 CI 슬롯 렌더 확인. """협상 초청 메일 미리보기(/v1/company/settings/email-preview) 테스트 — 회사 CI 슬롯 렌더 확인.
- 실제 발송(services/quotation/invites.py) 같은 build_invite_email 타므로 미리보기 검증 = 발송물 검증. - 실제 발송(services/quotation/invites.py) 같은 build_invite_email 타므로 미리보기 검증 = 발송물 검증.
- 헤더·색은 회사 CI(logo_url·service_name·primary_color) 그대로 쓴다 메일 전용 설정은 인사 문구뿐. - 헤더회사 CI(logo_url·service_name) 그대로 쓰고 색은 솔루션 고정 메일 전용 설정은 인사 문구뿐.
- 색은 hex 통과한다(이상값이 style 깨거나 CSS 주입되는 방지). - 색은 hex 통과한다(이상값이 style 깨거나 CSS 주입되는 방지).
""" """
@ -32,13 +32,13 @@ async def test_preview_default_branding(client, auth_headers):
async def test_preview_ci_branding(client, auth_headers): async def test_preview_ci_branding(client, auth_headers):
"""검증: 회사 CI(서비스명·로고·브랜드 색)와 인사 문구를 채워 미리보기 호출. """검증: 회사 CI(서비스명·로고)와 인사 문구를 채워 미리보기 호출(브랜드 색을 넣어도 무시).
기대결과: 헤더는 로고 이미지(서비스명은 alt), 액센트·버튼은 브랜드 색으로 렌더되고 기본색은 사라진다.""" 기대결과: 헤더는 로고 이미지(서비스명은 alt), 색은 회사 값과 무관하게 솔루션 기본색으로 렌더."""
h = await auth_headers("previewer2") h = await auth_headers("previewer2")
branding = { branding = {
"service_name": "아이좋아네고", "service_name": "아이좋아네고",
"logo_url": "https://cdn.example.com/ci.png", "logo_url": "https://cdn.example.com/ci.png",
"primary_color": "#f551a0", "primary_color": "#f551a0", # 제거된 기능 — 값을 보내도 반영되지 않아야 한다
"email_greeting": "협상에 초대합니다.", "email_greeting": "협상에 초대합니다.",
} }
r = await client.post(PREVIEW_URL, json={"branding": branding}, headers=h) r = await client.post(PREVIEW_URL, json={"branding": branding}, headers=h)
@ -46,8 +46,8 @@ async def test_preview_ci_branding(client, auth_headers):
html = r.json()["html"] html = r.json()["html"]
assert '<img src="https://cdn.example.com/ci.png"' in html assert '<img src="https://cdn.example.com/ci.png"' in html
assert 'alt="아이좋아네고"' in html assert 'alt="아이좋아네고"' in html
assert "#f551a0" in html assert "#f551a0" not in html
assert "#3182f6" not in html assert "#3182f6" in html
assert "협상에 초대합니다." in html assert "협상에 초대합니다." in html

View File

@ -180,11 +180,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
{branding.logoUrl ? ( {branding.logoUrl ? (
<img src={branding.logoUrl} alt={branding.serviceName} className="h-4 max-w-24 object-contain" /> <img src={branding.logoUrl} alt={branding.serviceName} className="h-4 max-w-24 object-contain" />
) : ( ) : (
<span <span aria-hidden className="size-4 rounded-[5px] bg-primary" />
aria-hidden
className="size-4 rounded-[5px] bg-primary"
style={branding.primaryColor ? { backgroundColor: branding.primaryColor } : undefined}
/>
)} )}
{branding.serviceName} {branding.serviceName}
</Typography> </Typography>

View File

@ -56,7 +56,6 @@ const SETTINGS_TAB_ICON: Record<SettingsTab, ElementType> = {
type BrandingTextKey = type BrandingTextKey =
| 'service_name' | 'service_name'
| 'logo_url' | 'logo_url'
| 'primary_color'
| 'email_greeting'; | 'email_greeting';
export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly SettingsTab[] }) { export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly SettingsTab[] }) {
@ -240,11 +239,7 @@ export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly Setting
{draft.branding?.logo_url ? ( {draft.branding?.logo_url ? (
<img src={draft.branding.logo_url} alt="로고 미리보기" className="h-4 max-w-24 object-contain" /> <img src={draft.branding.logo_url} alt="로고 미리보기" className="h-4 max-w-24 object-contain" />
) : ( ) : (
<span <span aria-hidden className="size-4 rounded-[5px] bg-primary" />
aria-hidden
className="size-4 rounded-[5px]"
style={{ backgroundColor: draft.branding?.primary_color || 'var(--primary)' }}
/>
)} )}
{draft.branding?.service_name || 'NegoData'} {draft.branding?.service_name || 'NegoData'}
</div> </div>
@ -676,11 +671,10 @@ function Field({ label, hint, children }: { label: string; hint?: string; childr
// 초청 메일 미리보기 — 실제 발송과 같은 백엔드 템플릿을 샘플 견적으로 렌더해 iframe 에 띄운다. // 초청 메일 미리보기 — 실제 발송과 같은 백엔드 템플릿을 샘플 견적으로 렌더해 iframe 에 띄운다.
// 입력 중 매 타자마다 서버를 부르지 않도록 디바운스하고, 갱신 사이엔 직전 렌더를 유지해 깜빡임을 없앤다. // 입력 중 매 타자마다 서버를 부르지 않도록 디바운스하고, 갱신 사이엔 직전 렌더를 유지해 깜빡임을 없앤다.
function InviteEmailPreview({ branding }: { branding?: CompanySettings['branding'] }) { function InviteEmailPreview({ branding }: { branding?: CompanySettings['branding'] }) {
// 메일 헤더·색은 회사 CI(로고·서비스명·브랜드 색)를 그대로 쓰므로 CI 편집값을 같이 보낸다(미리보기 = 실발송). // 메일 헤더는 회사 CI(로고·서비스명)를 그대로 쓰므로 CI 편집값을 같이 보낸다(미리보기 = 실발송). 색은 솔루션 고정.
const emailBranding = JSON.stringify({ const emailBranding = JSON.stringify({
service_name: branding?.service_name ?? '', service_name: branding?.service_name ?? '',
logo_url: branding?.logo_url ?? '', logo_url: branding?.logo_url ?? '',
primary_color: branding?.primary_color ?? '',
email_greeting: branding?.email_greeting ?? '', email_greeting: branding?.email_greeting ?? '',
}); });
const debounced = useDebounced(emailBranding, 400); const debounced = useDebounced(emailBranding, 400);

View File

@ -18,8 +18,7 @@ export type CompanySettings = {
branding?: { branding?: {
service_name?: string; // 사이드바/타이틀 서비스명 (기본 NegoData) service_name?: string; // 사이드바/타이틀 서비스명 (기본 NegoData)
logo_url?: string; // 로고 이미지 URL. 없으면 색상 사각형+텍스트 logo_url?: string; // 로고 이미지 URL. 없으면 색상 사각형+텍스트
primary_color?: string; // 브랜드 색 (hex) — 공급사 포털·초청 메일 포인트 색 (기본 #3182f6) email_greeting?: string; // 초청 메일 인사 문구 ('OO 담당자님,' 다음 줄). 메일 헤더는 CI(logo_url·service_name)를 따르고 색은 솔루션 고정
email_greeting?: string; // 초청 메일 인사 문구 ('OO 담당자님,' 다음 줄). 메일 헤더·색은 CI(logo_url·service_name·primary_color)를 따른다
helpdesk?: string[]; // 헬프데스크 연락처 — 한 줄 = 담당자 한 명. 공급사 포털 3곳(로그인·메뉴·안내팝업)이 그대로 출력 helpdesk?: string[]; // 헬프데스크 연락처 — 한 줄 = 담당자 한 명. 공급사 포털 3곳(로그인·메뉴·안내팝업)이 그대로 출력
}; };
labels?: Record<string, string>; // 카탈로그 키 → 이 회사 용어 (없으면 기본값) labels?: Record<string, string>; // 카탈로그 키 → 이 회사 용어 (없으면 기본값)

View File

@ -41,12 +41,11 @@ export function useVatMode(): VatMode {
return settings.features?.vat_mode ?? DEFAULT_VAT_MODE; return settings.features?.vat_mode ?? DEFAULT_VAT_MODE;
} }
// 브랜딩 헬퍼. 서비스명·로고 — 미설정 시 기본 브랜드(NegoData). // 브랜딩 헬퍼. 서비스명·로고 — 미설정 시 기본 브랜드(NegoData). 색은 회사별 커스텀 없이 솔루션 고정.
export function useBranding() { export function useBranding() {
const { settings } = useCompanySettings(); const { settings } = useCompanySettings();
return { return {
serviceName: settings.branding?.service_name || 'NegoData', serviceName: settings.branding?.service_name || 'NegoData',
logoUrl: settings.branding?.logo_url || null, logoUrl: settings.branding?.logo_url || null,
primaryColor: settings.branding?.primary_color || null,
}; };
} }

View File

@ -12,8 +12,7 @@
}, },
"branding": { "branding": {
"logo_url": "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/negodata/a35152d2-db61-4760-9f1e-beb9736d957f/items/4ab23e35e4f14d888591ca0f6d343fc8.jpg", "logo_url": "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/negodata/a35152d2-db61-4760-9f1e-beb9736d957f/items/4ab23e35e4f14d888591ca0f6d343fc8.jpg",
"service_name": "아이좋아네고", "service_name": "아이좋아네고"
"primary_color": "#f551a0"
}, },
"item_fields": [ "item_fields": [
{ {