[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):
service_name: str = Field("", description="회사 서비스명(companies.settings.branding.service_name). 미설정 시 빈 값")
logo_url: str = Field("", description="회사 로고 URL")
primary_color: str = Field("", description="브랜드 색상(hex)")
helpdesk: list = Field(default_factory=list, description="헬프데스크 연락처 줄 목록(companies.settings.branding.helpdesk). 한 줄 = 담당자 한 명")

View File

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

View File

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

View File

@ -43,10 +43,9 @@ export function usePreLoginBranding(): Branding | null {
const next: Branding = {
service_name: res.service_name || undefined,
logo_url: res.logo_url || undefined,
primary_color: res.primary_color || 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)
writeCached(next) // 다음 진입에 session_id 가 없어도 이 회사로 보이게 한다
})

View File

@ -1,7 +1,7 @@
import { type ReactNode } from 'react'
import { Logo } from '@/components'
import { useMeQuery } from '@/apis'
import { cn, useBrandColor } from '@/lib'
import { cn } from '@/lib'
// 좌측 폭: list=반응형 비율 / chat=고정폭 단계 축소
const SIDEBAR_WIDTH = {
@ -44,8 +44,7 @@ export function MainLayout({
header,
children,
}: MainLayoutProps) {
const { data: user } = useMeQuery() // 회사 브랜딩(서비스명/로고/브랜드 색) 주입
useBrandColor(user?.branding?.primary_color)
const { data: user } = useMeQuery() // 회사 브랜딩(서비스명/로고) 주입 — 색은 솔루션 고정
const panes = (
<>
<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 { toast } from '@/lib/toast'
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 { Logo } from '@/components'
import { LoginForm, usePreLoginBranding } from '@/features/auth'
import { useBrandColor } from '@/lib'
export function LoginPage() {
// 초청 링크의 session_id(없으면 직전 로그인 캐시)로 회사 브랜딩을 먼저 그린다.
// 초청 링크의 session_id(없으면 직전 로그인 캐시)로 회사 브랜딩(서비스명·로고)을 먼저 그린다.
const branding = usePreLoginBranding()
useBrandColor(branding?.primary_color)
// 이미 로그인된 상태면 목록으로
if (tokenStorage.hasToken()) {

View File

@ -28,8 +28,6 @@ _DEFAULT_EMAIL_COLOR = "#3182f6"
_DEFAULT_EMAIL_HEADER = "NEGODATA"
_DEFAULT_EMAIL_GREETING = "아래 견적 건의 협상에 참여해 주세요."
_HEX_COLOR = re.compile(r"^#[0-9a-fA-F]{3,8}$")
# HTML 본문은 코드에 박지 않고 파일에서 읽는다(모듈 로드 시 1회). $placeholder 는 string.Template 로 치환.
_TEMPLATE_DIR = Path(__file__).parent / "email_templates"
_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) 하나뿐.
헤더는 로고 있으면 로고, 없으면 service_name → 회사명(company_name) → NEGODATA 텍스트 —
공급사에겐 솔루션명보다 발주사가 보여야 한다. 색은 primary_color → 기본(챗 포털 브랜드 블루) 순.
공급사에겐 솔루션명보다 발주사가 보여야 한다. 색은 회사별 커스텀 없이 솔루션 기본색 고정.
레이아웃은 고정이고 값만 갈아끼우므로, 어떤 값을 넣어도 메일이 깨지지 않는다.
"""
b = branding or {}
@ -133,7 +131,7 @@ def build_invite_email(
qt_number=escape(qt_number),
deadline=escape(deadline),
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()),
email_greeting=escape(greeting),
)
@ -149,12 +147,6 @@ def build_invite_email(
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:
"""헤더(흰 배경) 내용물 — 회사 CI 로고가 있으면 이미지(회사명은 alt 로), 없으면 회사명 텍스트. escape 는 여기서 끝낸다."""
if logo_url:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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