diff --git a/backend/crud/user_crud.py b/backend/crud/user_crud.py index 04db9a7..558ea15 100644 --- a/backend/crud/user_crud.py +++ b/backend/crud/user_crud.py @@ -5,7 +5,7 @@ from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import supplier_user_tokens, supplier_users, suppliers, companies +from common.database.model.models import supplier_user_tokens, supplier_users, suppliers, companies, sessions from common.enums import ErrorType, TokenType from common.logger import LOG from common.utils.gtime import GTime @@ -32,6 +32,10 @@ class IUserCRUD(ABC): async def get_company_settings(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, dict]: pass + @abstractmethod + async def get_branding_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, dict]: + pass + @abstractmethod async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType: pass @@ -139,6 +143,24 @@ class UserCRUD(IUserCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, {} + async def get_branding_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, dict]: + """세션이 속한 회사의 브랜딩(companies.settings.branding). 로그인 전 화면이 쓰므로 branding 만 꺼낸다.""" + try: + query = ( + select(companies.settings) + .join(suppliers, suppliers.company_id == companies.company_id) + .join(sessions, sessions.supplier_id == suppliers.supplier_id) + .where(sessions.session_id == session_id, sessions.deleted == False, companies.deleted == False) # noqa: E712 + .limit(1) + ) + err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_branding_by_session(session_id:{session_id}) failed.") + if err_type != ErrorType.SUCCESS: + return err_type, {} + settings = (row_list[0] if row_list else None) or {} + return ErrorType.SUCCESS, settings.get("branding") or {} + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, {} async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType: try: diff --git a/backend/router/v1/auth/account.py b/backend/router/v1/auth/account.py index b894c65..9dff71f 100644 --- a/backend/router/v1/auth/account.py +++ b/backend/router/v1/auth/account.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Path, Request from fastapi.security import HTTPAuthorizationCredentials from common.models.gmodel import UserInfo @@ -20,6 +20,7 @@ from .protocol import ( Res_Me, Res_PopupStatus, Res_RefreshToken, + Res_SessionBranding, ) # 라우터(MVC 의 컨트롤러). 요청 검증 -> service 호출 -> RemoveNoneResponse 반환만 담당. @@ -106,3 +107,16 @@ async def hide_popup( service: AuthService = Depends(), ): return RemoveNoneResponse(await service.hide_popup(user_info, credentials.credentials, req.popup_type)) + + +@router.get( + path="/session-branding/{session_id}", + response_model=Res_SessionBranding, + summary="세션 브랜딩(무인증)", + description="초청 링크로 진입한 로그인 전 화면에서 회사 서비스명·로고·색상만 조회한다. 인증 없이 열려 있으므로 브랜딩 외 정보는 내리지 않는다.", +) +async def session_branding( + session_id: str = Path(..., description="협상 세션 uuid (초청 링크의 session_id)"), + service: AuthService = Depends(), +): + return RemoveNoneResponse(await service.session_branding(session_id)) diff --git a/backend/router/v1/auth/protocol.py b/backend/router/v1/auth/protocol.py index 4367aa1..f56476a 100644 --- a/backend/router/v1/auth/protocol.py +++ b/backend/router/v1/auth/protocol.py @@ -66,3 +66,9 @@ class Req_HidePopup(AuthProtocol): class Res_HidePopup(Res_WebPacketProtocol): pass + + +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)") diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py index d06511d..3233e33 100644 --- a/backend/services/auth_service.py +++ b/backend/services/auth_service.py @@ -18,6 +18,7 @@ from router.v1.auth.protocol import ( Res_Me, Res_PopupStatus, Res_RefreshToken, + Res_SessionBranding, ) from router.v1.validator.dependencies import CreateAccessToken, CreateRefreshToken, GetHashedPW, VerifyPW @@ -261,6 +262,25 @@ class AuthService: res.session_fields = settings.get("session_fields") or [] return res + async def session_branding(self, session_id: str) -> Res_SessionBranding: + """로그인 전(초청 링크 진입) 화면용 브랜딩. 인증 없이 session_id 로만 조회하며 브랜딩 외 정보는 내리지 않는다.""" + res = Res_SessionBranding() + try: + sid = uuid.UUID(session_id) + except ValueError: + res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) + return res + _e, branding = await DB_SESSION_MNG.execute_lambda( + suppliers.DBType(), + DBWRType.DB_READ.value, + lambda s: self.user_crud.get_branding_by_session(s, sid), + ) + 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 "" + return res + async def popup_status(self, user_info: UserInfo, access_token: str) -> Res_PopupStatus: # 유저별 팝업 숨김 상태 조회. 현재는 서비스 안내(service_info) 팝업 하나만 관리한다. res = Res_PopupStatus() diff --git a/frontend/src/apis/auth/auth.api.ts b/frontend/src/apis/auth/auth.api.ts index af16604..d7a52b8 100644 --- a/frontend/src/apis/auth/auth.api.ts +++ b/frontend/src/apis/auth/auth.api.ts @@ -11,6 +11,7 @@ import type { LogoutResponse, MeResponse, PopupStatusResponse, + SessionBrandingResponse, } from './auth.type' export const authApi = { @@ -26,6 +27,12 @@ export const authApi = { return res.data }, + /** GET /v1/auth/session-branding/{sessionId} — 로그인 전 화면용 브랜딩(인증 불필요) */ + sessionBranding: async (sessionId: string): Promise => { + const res = await http.get(`/v1/auth/session-branding/${sessionId}`) + return res.data + }, + /** GET /v1/auth/me — 현재 로그인 유저 정보 (access token 필요) */ me: async (): Promise => { const res = await http.get('/v1/auth/me') diff --git a/frontend/src/apis/auth/auth.type.ts b/frontend/src/apis/auth/auth.type.ts index ba607bd..b562e66 100644 --- a/frontend/src/apis/auth/auth.type.ts +++ b/frontend/src/apis/auth/auth.type.ts @@ -61,6 +61,14 @@ export interface Branding { email_header?: string } +// 로그인 전(초청 링크 진입) 브랜딩 조회 — GET /v1/auth/session-branding/{session_id}, 인증 불필요 +export interface SessionBrandingResponse { + result: ApiResult + service_name: string + logo_url: string + primary_color: string +} + // 협상완료 부가정보 필드 정의(companies.settings.session_fields) export interface SessionField { key: string diff --git a/frontend/src/assets/negotium-logo.png b/frontend/src/assets/negotium-logo.png new file mode 100644 index 0000000..b7a2118 Binary files /dev/null and b/frontend/src/assets/negotium-logo.png differ diff --git a/frontend/src/components/Logo.tsx b/frontend/src/components/Logo.tsx index ee6b3db..47247d4 100644 --- a/frontend/src/components/Logo.tsx +++ b/frontend/src/components/Logo.tsx @@ -1,14 +1,14 @@ import { type ComponentProps } from 'react' import { cn } from '@/lib' -import logoColor from '@/assets/imarketkorea-logo.png' -import logoWhite from '@/assets/imarketkorea-logo-white.png' +import logoDefault from '@/assets/negotium-logo.png' export type LogoVariant = 'color' | 'white' export type LogoSize = 'sm' | 'md' | 'lg' +// 기본 브랜드(회사 설정 미지정 시). 회사 로고가 오면 logoUrl 이 이 값을 덮는다. const sources: Record = { - color: logoColor, - white: logoWhite, + color: logoDefault, + white: logoDefault, } const sizes: Record = { @@ -17,6 +17,13 @@ const sizes: Record = { lg: { img: 'h-10', text: 'text-2xl', gap: 'gap-2.5' }, } +// 기본 브랜드 로고는 가로로 긴 워드마크라 같은 높이로 두면 회사 심볼보다 훨씬 커 보인다(랜딩도 h-4 기준). +const wordmarkSizes: Record = { + sm: 'h-3.5', + md: 'h-4', + lg: 'h-5', +} + export interface LogoProps extends Omit, 'children'> { variant?: LogoVariant size?: LogoSize @@ -31,23 +38,23 @@ export function Logo({ variant = 'color', size = 'md', withText = true, - alt = 'iMarket Korea', + alt = 'NEGOTIUM', serviceName, logoUrl, className, ...props }: LogoProps) { const s = sizes[size] - const name = serviceName || 'iMarket Korea' + const name = serviceName || 'negotium' return (
{withText - {withText && ( + {withText && serviceName && ( {name} diff --git a/frontend/src/features/auth/components/RequireAuth.tsx b/frontend/src/features/auth/components/RequireAuth.tsx index fc4608c..7cc5490 100644 --- a/frontend/src/features/auth/components/RequireAuth.tsx +++ b/frontend/src/features/auth/components/RequireAuth.tsx @@ -6,7 +6,10 @@ import { tokenStorage } from '@/apis' // 토큰이 있으나 만료/폐기된 경우는 요청 시 인터셉터가 세션을 종료시킨다. export function RequireAuth({ children }: { children: ReactNode }) { if (!tokenStorage.hasToken()) { - return + // 초청 링크(/chat?session_id=...)로 들어온 미로그인 사용자 — session_id 를 넘겨야 + // 로그인 화면이 그 회사 브랜딩으로 뜬다. + const sessionId = new URLSearchParams(window.location.search).get('session_id') + return } return <>{children} } diff --git a/frontend/src/features/auth/hooks/usePreLoginBranding.ts b/frontend/src/features/auth/hooks/usePreLoginBranding.ts new file mode 100644 index 0000000..1c0051a --- /dev/null +++ b/frontend/src/features/auth/hooks/usePreLoginBranding.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from 'react' +import { useSearchParams } from 'react-router' +import { authApi } from '@/apis' +import type { Branding } from '@/apis/auth/auth.type' + +// 로그인 전 화면(로그인 페이지)의 회사 브랜딩. +// 협력사는 초청 메일의 /chat?session_id=... 로 들어오므로, 그 session_id 로 인증 없이 브랜딩만 조회한다. +// session_id 가 없으면 회사를 특정할 수 없으므로 기본 브랜드(Negosium)로 둔다. +export function usePreLoginBranding(): Branding | null { + const [searchParams] = useSearchParams() + const [branding, setBranding] = useState(null) + + const sessionId = searchParams.get('session_id') ?? '' + + useEffect(() => { + if (!sessionId) return + let alive = true + void authApi + .sessionBranding(sessionId) + .then((res) => { + if (!alive || res.result?.success === false) return + const next: Branding = { + service_name: res.service_name || undefined, + logo_url: res.logo_url || undefined, + primary_color: res.primary_color || undefined, + } + if (!next.service_name && !next.logo_url) return + setBranding(next) + }) + .catch(() => { + // 조회 실패 시 기본 브랜드로 그대로 둔다 + }) + return () => { + alive = false + } + }, [sessionId]) + + return branding +} diff --git a/frontend/src/features/auth/index.ts b/frontend/src/features/auth/index.ts index e940c43..c867728 100644 --- a/frontend/src/features/auth/index.ts +++ b/frontend/src/features/auth/index.ts @@ -1,3 +1,4 @@ export { LoginForm } from '@/features/auth/components/LoginForm' export { SidebarFooter } from '@/features/auth/components/SidebarFooter' export { RequireAuth } from '@/features/auth/components/RequireAuth' +export { usePreLoginBranding } from '@/features/auth/hooks/usePreLoginBranding' diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 65da253..ee25df1 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -53,6 +53,9 @@ export function MainLayout({ {logoAction}
{sidebar} +

+ © {new Date().getFullYear()} negotium · Made by AI O2O +

diff --git a/frontend/src/pages/ListPage.tsx b/frontend/src/pages/ListPage.tsx index bc2c1d2..311b4b3 100644 --- a/frontend/src/pages/ListPage.tsx +++ b/frontend/src/pages/ListPage.tsx @@ -8,6 +8,12 @@ export function ListPage() {
+ {/* 솔루션·제작사 표기 — 회사 브랜딩(헤더 로고)과 무관하게 고정 */} +
+

+ © {new Date().getFullYear()} negotium · Made by AI O2O +

+
) } diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 5b2ce89..8362f6b 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,9 +1,12 @@ import { Navigate } from 'react-router' import { tokenStorage } from '@/apis' import { Logo } from '@/components' -import { LoginForm } from '@/features/auth' +import { LoginForm, usePreLoginBranding } from '@/features/auth' export function LoginPage() { + // 초청 링크의 session_id(없으면 직전 로그인 캐시)로 회사 브랜딩을 먼저 그린다. + const branding = usePreLoginBranding() + // 이미 로그인된 상태면 목록으로 if (tokenStorage.hasToken()) { return @@ -14,24 +17,29 @@ export function LoginPage() {
{/* 브랜드 락업 */}
- +

- 아이마켓코리아 B2B 구매협상 솔루션 · 공급사 포털 + negotium B2B 구매협상 솔루션 · 공급사 포털

{/* 로그인 카드 */} -
+

로그인

- {/* 푸터 */} -

- 문의: 헬프데스크 010-0000-0000 · o2odev@o2o.kr -

+ {/* 푸터 — 문의처 + 솔루션/제작사 표기(회사 브랜딩과 무관하게 고정) */} +
+

+ 문의: 헬프데스크 010-0000-0000 · o2odev@o2o.kr +

+

+ © {new Date().getFullYear()} negotium · Made by AI O2O +

+
) diff --git a/landing/public/gifs/negotiation_annotated_3d.jpg b/landing/public/gifs/negotiation_annotated_3d.jpg new file mode 100644 index 0000000..54dfd87 Binary files /dev/null and b/landing/public/gifs/negotiation_annotated_3d.jpg differ diff --git a/landing/public/gifs/negotiation_annotated_3d.mp4 b/landing/public/gifs/negotiation_annotated_3d.mp4 new file mode 100644 index 0000000..db711ee Binary files /dev/null and b/landing/public/gifs/negotiation_annotated_3d.mp4 differ diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index aa1f6ec..0dd50c9 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -384,6 +384,30 @@ class QuotationCRUD(IQuotationCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, {} + async def get_target_price_mode(self, cdb: AsyncSession, user_id): + # 견적 소유 유저 → 회사 설정에서 목표가 산정에 영향을 주는 두 가지를 함께 읽는다. + # features.target_price_mode: 'purchase' 면 매입가만 후보(IMK #10) + # hidden_fields: 화면에서 감춘 가격 필드는 후보에서도 뺀다(감춘 값이 목표가를 정하면 설명이 안 된다) + # 반환: (mode, hidden_fields set). 미설정이면 (None, set()) → 기존 로직 그대로. + try: + query = ( + select(companies.settings) + .select_from(users) + .join(companies, companies.company_id == users.company_id) + .where(users.user_id == user_id, companies.deleted == False) # noqa: E712 + .limit(1) + ) + err, rows = await DB_SESSION_MNG.execute(cdb, query) + if err != ErrorType.SUCCESS or not rows: + return None, set() + settings = rows[0] or {} + mode = (settings.get("features") or {}).get("target_price_mode") + hidden = set(settings.get("hidden_fields") or []) + return mode, hidden + except Exception as ex: + LOG.e_no_callstack(ex) + return None, set() + async def get_email_header(self, cdb: AsyncSession, user_id): # 견적 소유 유저 → 회사 → companies.settings.branding.email_header. 초청 메일 헤더 브랜딩용. 없으면 None. try: diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py index b794017..16225f9 100644 --- a/negodata/backend/router/v1/quotation/protocol.py +++ b/negodata/backend/router/v1/quotation/protocol.py @@ -205,6 +205,8 @@ class Res_TargetBreakdown(Res_WebPacketProtocol): margin: float = 0.0 anchoring_value: float = 0.0 # 앵커링율(비율). 세션 앵커링값(‰)을 /1000 환산 — main 프론트 표시용 candidates: list[TargetCandidate] = [] + target_price_mode: Optional[str] = None # 회사 설정 목표가 산정 모드('purchase' = 매입가만). 화면 설명 문구용 + hidden_price_fields: list[str] = [] # 회사 설정으로 감춰 후보에서 뺀 가격 필드 chosen_basis: Optional[str] = None target_price: int = 0 anchoring_price: Optional[int] = None diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index a921b3f..a551107 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -76,12 +76,26 @@ class QuotationService: return f"{base}/chat?session_id={session_id}" @staticmethod - def _candidates(md_price=None, internet_lowest=None, purchase=None, selling=None, fee=0.0, margin=0.0, is_new=False): + def _candidates(md_price=None, internet_lowest=None, purchase=None, selling=None, fee=0.0, margin=0.0, is_new=False, mode=None, hidden=None): """목표가 후보 [(basis, value_float)] 목록(빈 값/0 은 제외). md 있으면 md 단독. 값은 float(인터넷=가격×(1−수수료), 판매가=가격×(1−마진))이며 채택 시 int() 절삭한다. - _calc_target_price(생성)와 get_target_breakdown(표시)가 공유하는 단일 산정 로직.""" + _calc_target_price(생성)와 get_target_breakdown(표시)가 공유하는 단일 산정 로직. + + mode='purchase' (회사 설정 features.target_price_mode) 면 매입가만 후보로 쓴다 — + 인터넷최저가·판매가는 신규/재 구분 없이 제외하고 매입가 × (1 − 네고율) 하나로 잡는다(IMK #10). + hidden (회사 설정 hidden_fields) 에 든 가격 필드는 후보에서 뺀다 — 화면에서 감춘 값이 + 목표가를 결정하면 담당자가 산정 근거를 확인할 수 없기 때문.""" if md_price: return [("md", float(int(md_price)))] + hidden = hidden or set() + if "internet_lowest_price" in hidden: + internet_lowest = None + if "purchase_price" in hidden: + purchase = None + if "selling_price" in hidden: + selling = None + if mode == "purchase": + return [("purchase", int(purchase) * (1 - (margin or 0.0)))] if purchase else [] out = [] if internet_lowest: out.append(("internet", int(internet_lowest) * (1 - (fee or 0.0)))) @@ -93,7 +107,7 @@ class QuotationService: return out @staticmethod - def _calc_target_price(md_price=None, internet_lowest=None, purchase=None, selling=None, fee=0.0, margin=0.0, is_new=False) -> int: + def _calc_target_price(md_price=None, internet_lowest=None, purchase=None, selling=None, fee=0.0, margin=0.0, is_new=False, mode=None, hidden=None) -> int: """세션 목표가 (KTC 신규/재 분리 로직, 회사 데이터 풍부도에 graceful 적응) ① md_price 있으면 → 그대로 ② 없으면: @@ -102,15 +116,18 @@ class QuotationService: - 인터넷최저가 × (1 − fee) ← fee=quotation_settings.internet_average_fee - 매입가 (그대로) - 판매가 × (1 − margin) ← margin=quotation_settings.target_margin_rate - ③ 후보 0개 → 견적 생성 불가(ValueError).""" + ③ 후보 0개 → 견적 생성 불가(ValueError). + mode='purchase' 면 ②를 무시하고 매입가 × (1 − 네고율) 하나만 후보로 쓴다(IMK #10).""" if not md_price: # 율은 비율(0~1 미만)이어야 한다. 1 이상이면 (1−율)≤0 → 목표가가 0/음수가 되므로 설정 오류로 막는다. if not 0.0 <= (fee or 0.0) < 1.0: raise ValueError(f"인터넷 수수료율은 0 이상 1 미만이어야 합니다: fee={fee}") - if not is_new and not 0.0 <= (margin or 0.0) < 1.0: + if (mode == "purchase" or not is_new) and not 0.0 <= (margin or 0.0) < 1.0: raise ValueError(f"목표 마진율은 0 이상 1 미만이어야 합니다: margin={margin}") - cands = QuotationService._candidates(md_price, internet_lowest, purchase, selling, fee, margin, is_new) + cands = QuotationService._candidates(md_price, internet_lowest, purchase, selling, fee, margin, is_new, mode, hidden) if not cands: + if mode == "purchase": + raise ValueError("타겟 가격 계산 불가: md_price·매입가 모두 없음") raise ValueError("타겟 가격 계산 불가: md_price·인터넷최저가" + ("" if is_new else "·매입가·판매가") + " 모두 없음") return int(min(v for _, v in cands)) @@ -169,8 +186,13 @@ class QuotationService: margin = rates.get("margin") or 0.0 is_new = QuotationType.is_new(quotation.type) md = quotation.md_price + mode, hidden = await DB_SESSION_MNG.execute_lambda( + quotations.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.get_target_price_mode(s, quotation.user_id), + ) - cands = self._candidates(md, internet, purchase, selling, fee, margin, is_new) + cands = self._candidates(md, internet, purchase, selling, fee, margin, is_new, mode, hidden) chosen_basis = next((b for b, v in cands if int(v) == sess.target_price), None) is_inherited = chosen_basis is None @@ -183,6 +205,8 @@ class QuotationService: res.fee = fee res.margin = margin res.candidates = [TargetCandidate(basis=b, label=self._CANDIDATE_LABELS.get(b, b), value=int(v)) for b, v in cands] + res.target_price_mode = mode + res.hidden_price_fields = sorted(hidden & {"internet_lowest_price", "purchase_price", "selling_price"}) res.chosen_basis = chosen_basis res.target_price = sess.target_price res.anchoring_price = sess.anchoring_price @@ -453,6 +477,13 @@ class QuotationService: # 신규(NEW_NEGO/NEW_QUOTE)는 인터넷최저가만, 재(RENEGO/REQUOTE)는 매입가·판매가까지 후보(KTC 신규/재 분리). is_new = QuotationType.is_new(type_) + # 회사별 목표가 산정 모드(features.target_price_mode). 'purchase' 면 매입가 × 네고율만 후보(IMK #10). + mode, hidden = await DB_SESSION_MNG.execute_lambda( + quotations.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.get_target_price_mode(s, uuid.UUID(user_id)), + ) + # ① 목표가 산정 — 재생성(inherited)은 직전 라운드 값 그대로 상속(KTC), 그 외엔 후보 min. target_prices = {} try: @@ -461,7 +492,7 @@ class QuotationService: target_prices[iid] = inherited[iid] else: internet, purchase, selling = prices.get(iid) or (None, None, None) - target_prices[iid] = self._calc_target_price(md_price, internet, purchase, selling, fee, margin, is_new=is_new) + target_prices[iid] = self._calc_target_price(md_price, internet, purchase, selling, fee, margin, is_new=is_new, mode=mode, hidden=hidden) except ValueError as ex: LOG.w( f"[목표가 산정불가] qt_id={qt_id} item={iid} is_new={is_new} " diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 2079843..ce5ed74 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -373,6 +373,7 @@ export * from './resTargetBreakdownMdPrice'; export * from './resTargetBreakdownMsg'; export * from './resTargetBreakdownPurchase'; export * from './resTargetBreakdownSelling'; +export * from './resTargetBreakdownTargetPriceMode'; export * from './resWebPacketProtocol'; export * from './resWebPacketProtocolMsg'; export * from './sessionData'; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdown.ts b/negodata/front/src/api/generated/model/resTargetBreakdown.ts index 4046b68..5a7eb00 100644 --- a/negodata/front/src/api/generated/model/resTargetBreakdown.ts +++ b/negodata/front/src/api/generated/model/resTargetBreakdown.ts @@ -11,6 +11,7 @@ import type { ResTargetBreakdownInternetLowest } from './resTargetBreakdownInter import type { ResTargetBreakdownPurchase } from './resTargetBreakdownPurchase'; import type { ResTargetBreakdownSelling } from './resTargetBreakdownSelling'; import type { TargetCandidate } from './targetCandidate'; +import type { ResTargetBreakdownTargetPriceMode } from './resTargetBreakdownTargetPriceMode'; import type { ResTargetBreakdownChosenBasis } from './resTargetBreakdownChosenBasis'; import type { ResTargetBreakdownAnchoringPrice } from './resTargetBreakdownAnchoringPrice'; @@ -27,6 +28,8 @@ export interface ResTargetBreakdown { margin?: number; anchoring_value?: number; candidates?: TargetCandidate[]; + target_price_mode?: ResTargetBreakdownTargetPriceMode; + hidden_price_fields?: string[]; chosen_basis?: ResTargetBreakdownChosenBasis; target_price?: number; anchoring_price?: ResTargetBreakdownAnchoringPrice; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdownTargetPriceMode.ts b/negodata/front/src/api/generated/model/resTargetBreakdownTargetPriceMode.ts new file mode 100644 index 0000000..baece13 --- /dev/null +++ b/negodata/front/src/api/generated/model/resTargetBreakdownTargetPriceMode.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ResTargetBreakdownTargetPriceMode = string | null; diff --git a/negodata/front/src/components/layout/Layout.tsx b/negodata/front/src/components/layout/Layout.tsx index 858bcbe..e3ab48a 100644 --- a/negodata/front/src/components/layout/Layout.tsx +++ b/negodata/front/src/components/layout/Layout.tsx @@ -7,8 +7,11 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Typography } from '@/components/ui/typography'; import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; +import { useNavigate } from 'react-router'; import { cn } from '@/lib/utils'; import { NotificationBell } from './NotificationBell'; +import { GUIDE_TABS, TAB_LABEL, type GuideTab } from '@/features/onboarding/OnboardingGuideModal'; +import { SETTINGS_TABS, SETTINGS_TAB_LABEL, type SettingsTab } from '@/features/settings/SettingsView'; import { LayoutDashboard, BarChart3, @@ -26,6 +29,7 @@ import { ChevronRight, Menu, X, + BookOpen, } from 'lucide-react'; interface LayoutProps { @@ -82,6 +86,7 @@ const pageLabelMap: Record = { export default function Layout({ children, currentPage, setPage, onLogout }: LayoutProps) { const { user } = useAuth(); + const navigate = useNavigate(); const branding = useBranding(); // 회사 설정 브랜딩(서비스명/로고). 미설정 시 기본 NegoData // 기준일시(오늘) — 로컬 타임존 기준 YYYY-MM-DD const today = new Date().toLocaleDateString('sv-SE'); @@ -328,6 +333,15 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay setPage(type); setIsCmdOpen(false); }} + onSelectGuide={(tab) => { + navigate(`/dashboard?guide=${tab}`); + setIsCmdOpen(false); + }} + onSelectSettings={(tab) => { + navigate(`/settings?tab=${tab}`); + setIsCmdOpen(false); + }} + canSeeSettings={visibleItems.some((i) => i.type === 'SETTINGS')} /> {isProfileOpen && setIsProfileOpen(false)} />} @@ -342,12 +356,18 @@ function CommandMenu({ items, currentPage, onSelect, + onSelectGuide, + onSelectSettings, + canSeeSettings, }: { open: boolean; onOpenChange: (open: boolean) => void; items: MenuItem[]; currentPage: PageType; onSelect: (type: PageType) => void; + onSelectGuide: (tab: GuideTab) => void; + onSelectSettings: (tab: SettingsTab) => void; + canSeeSettings: boolean; }) { const [query, setQuery] = useState(''); @@ -357,6 +377,14 @@ function CommandMenu({ const q = query.trim().toLowerCase(); const filtered = q ? items.filter((i) => i.label.toLowerCase().includes(q)) : items; + // 이용안내 탭도 이동 대상 — 대시보드로 가면서 ?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 filteredSettings = q ? settingsEntries.filter((e) => e.label.toLowerCase().includes(q)) : settingsEntries; return ( @@ -380,7 +408,7 @@ function CommandMenu({ />
- {filtered.length === 0 ? ( + {filtered.length === 0 && filteredGuides.length === 0 && filteredSettings.length === 0 ? ( 일치하는 메뉴가 없습니다 @@ -407,6 +435,32 @@ function CommandMenu({ ); }) )} + {filteredSettings.map((e) => ( + + ))} + {filteredGuides.map((g) => ( + + ))}
diff --git a/negodata/front/src/components/layout/PageToolbar.tsx b/negodata/front/src/components/layout/PageToolbar.tsx index 52addc2..e7ac006 100644 --- a/negodata/front/src/components/layout/PageToolbar.tsx +++ b/negodata/front/src/components/layout/PageToolbar.tsx @@ -16,12 +16,12 @@ export function PageToolbar({ return (
-
{children}
- {actions &&
{actions}
} +
{children}
+ {actions &&
{actions}
}
); } diff --git a/negodata/front/src/components/ui/data-table.tsx b/negodata/front/src/components/ui/data-table.tsx index 3bd5b21..bbb4eff 100644 --- a/negodata/front/src/components/ui/data-table.tsx +++ b/negodata/front/src/components/ui/data-table.tsx @@ -97,8 +97,8 @@ export function DataTable({ const detailCols = mobileCols.filter((c) => c !== primaryCol) return ( -
-
+
+
@@ -182,7 +182,7 @@ export function DataTable({
-
+
{data.length > 0 ? ( data.map((row) => { const key = rowKey(row) diff --git a/negodata/front/src/features/members/components/MemberTable.tsx b/negodata/front/src/features/members/components/MemberTable.tsx index 7d7ac1e..665ac4e 100644 --- a/negodata/front/src/features/members/components/MemberTable.tsx +++ b/negodata/front/src/features/members/components/MemberTable.tsx @@ -13,6 +13,8 @@ type MemberTableProps = { totalCount: number; pageSize: number; onPageChange: (page: number) => void; + /** 툴바와 한 카드로 붙일 때 테이블 자체 테두리/라운드를 죽이는 용도 */ + className?: string; }; const statusBadgeClass = (status: number) => @@ -30,9 +32,11 @@ export function MemberTable({ totalCount, pageSize, onPageChange, + className, }: MemberTableProps) { return ( m.user_id} onRowClick={onRowClick} diff --git a/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx b/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx index 3f6f02b..a9ebeaa 100644 --- a/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx +++ b/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx @@ -1,5 +1,5 @@ import { useState, type ElementType } from 'react'; -import { useNavigate } from 'react-router'; +import { useNavigate, useSearchParams } from 'react-router'; import { LayoutDashboard, FileText, Mail, Users, Clock, Award } from 'lucide-react'; import { Dialog, @@ -171,7 +171,50 @@ const TONE_CHIP: Record = { choice: 'bg-primary/10 text-primary', }; -const TAB_LABEL = { flow: '흐름 설명', type: '견적 유형', nego: '협상 진행', example: '마감·낙찰', terms: '용어 정리' } as const; +export const GUIDE_TABS = ['flow', 'type', 'nego', 'example', 'terms', 'faq'] as const; +export type GuideTab = (typeof GUIDE_TABS)[number]; + +export const TAB_LABEL = { flow: '흐름 설명', type: '견적 유형', nego: '협상 진행', example: '마감·낙찰', terms: '용어 정리', faq: '자주 묻는 질문' } as const; + +// 자주 묻는 질문 — "어디서 하는지"를 찾는 용도라 답에 경로(메뉴 › 화면 › 버튼)를 반드시 적는다. +const FAQS: { q: string; a: string }[] = [ + { + q: '협상 채팅 내역은 어디서 다운로드하나요?', + a: '견적관리 › 견적 클릭 › 「채팅」 탭 › 우측 상단 [JSON] 버튼을 누르면 파일로 저장돼요. 협력사가 여러 곳이면 좌측에서 협력사를 고른 뒤 받으면 그 협력사와의 대화만 내려받아요. 대화가 한 건도 없으면 버튼이 비활성이에요.', + }, + { + q: '다운로드한 채팅 파일에는 뭐가 들어 있나요?', + a: '견적번호·협력사·상품·상태와 대화 전체(순번, 발화자(BOT/협력사), 단계, 멘트, 사용한 협상카드)가 들어 있어요. 금액 숫자는 화면과 같은 규칙으로 가려서 저장되니 외부 공유용으로 써도 돼요.', + }, + { + q: '초청메일을 다시 보내려면?', + a: '견적 상세 › 「협상현황」 탭 › 협력사 줄 오른쪽의 발송 아이콘을 누르면 돼요. 메일을 보내야 협상이 시작되니, 만들어만 두고 안 보낸 건은 대시보드에서 짚어줘요.', + }, + { + q: '협상이 끝난 뒤 협력사에게 납기·수량 같은 정보를 더 받을 수 있나요?', + a: '회사 설정 › 커스텀 필드 › 「협상완료 부가정보 필드」에 받을 항목을 먼저 정의해야 써요. 정의해 두면 협력사가 타결 직후 그 항목을 입력하고, 견적 상세 › 「협상현황」 탭 › 협력사 줄의 부가정보 아이콘에서 확인할 수 있어요. 정의가 비어 있으면 입력 화면도 뜨지 않아요.', + }, + { + q: '목표가는 어떻게 정해지나요?', + a: 'MD 제시가를 넣으면 그 값이 그대로 목표가예요. 안 넣으면 신규는 인터넷 최저가에서 수수료를 뺀 값, 재견적·재협상은 거기에 매입가와 판매가(마진 차감)까지 후보로 놓고 그중 가장 낮은 값을 써요.', + }, + { + q: '상품·협력사를 한 번에 등록하려면?', + a: '상품관리(또는 협력사관리) › [⋯] › 양식 다운로드로 파일을 받아 채운 뒤 엑셀 업로드로 올려요. 양식 헤더는 회사 설정 용어를 따라가고, 업로드 전 미리보기에서 오류 행을 표시해 줘요.', + }, + { + q: '여러 건을 한꺼번에 지우려면?', + a: '목록 왼쪽 체크박스로 여러 건을 고르면 상단에 삭제 버튼이 나와요. 상품·협력사 모두 같은 방식이에요.', + }, + { + q: '협상카드는 어떤 순서로 보이나요?', + a: '견적 생성의 카드 선택에서 성공률(카드를 쓴 협상 중 타결된 비율) 높은 순으로 정렬되고, 상위 3개에는 순위 배지가 붙어요. 아직 쓰인 적 없는 카드는 표본이 없어 뒤로 밀려요.', + }, + { + q: '화면에 나오는 용어나 로고를 우리 회사 것으로 바꾸려면?', + a: '최고관리자 계정으로 회사 설정에 들어가면 서비스명·로고·색상(브랜딩), 화면 용어(라벨), 추가로 입력받을 항목(커스텀 필드), 감출 항목을 바꿀 수 있어요. 설정을 JSON으로 내보내고 불러올 수도 있어요.', + }, +]; // 핵심 용어 사전 — 사전이므로 다른 탭과 겹쳐도 전부 싣는다. const TERMS: { term: string; desc: string }[] = [ @@ -202,7 +245,15 @@ export function OnboardingGuideModal({ onOpenChange: (open: boolean) => void; }) { const navigate = useNavigate(); - const [tab, setTab] = useState<'flow' | 'type' | 'nego' | 'example' | 'terms'>('flow'); + // 탭은 ?guide= 으로 URL 에 남긴다 — 링크 공유·새로고침·메뉴 빠른이동에서 같은 탭으로 열리게. + const [searchParams, setSearchParams] = useSearchParams(); + const tabParam = searchParams.get('guide'); + const tab: GuideTab = (GUIDE_TABS as readonly string[]).includes(tabParam ?? '') ? (tabParam as GuideTab) : 'flow'; + const setTab = (next: GuideTab) => { + const params = new URLSearchParams(searchParams); + params.set('guide', next); + setSearchParams(params, { replace: true }); + }; const [activeNum, setActiveNum] = useState(1); const active = STEPS.find((s) => s.num === activeNum) ?? STEPS[0]; @@ -229,7 +280,7 @@ export function OnboardingGuideModal({ {/* 뷰 전환 탭 */}
- {(['flow', 'type', 'nego', 'example', 'terms'] as const).map((t) => ( + {GUIDE_TABS.map((t) => ( diff --git a/negodata/front/src/features/partners/components/PartnerFormSheet.tsx b/negodata/front/src/features/partners/components/PartnerFormSheet.tsx index a9895b7..cef7b74 100644 --- a/negodata/front/src/features/partners/components/PartnerFormSheet.tsx +++ b/negodata/front/src/features/partners/components/PartnerFormSheet.tsx @@ -7,6 +7,7 @@ import type { ReqUpdateSupplier as SupplierUpdate } from '@/api/generated/model/ import { showToast } from '@/lib/notify'; import { normalizePhone } from '@/lib/phone'; import { Typography } from '@/components/ui/typography'; +import { useLabels } from '@/features/settings/useCompanySettings'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { PhoneInput } from '@/components/ui/phone-input'; @@ -72,6 +73,7 @@ export function PartnerFormSheet({ onDelete, onClose, }: PartnerFormSheetProps) { + const label = useLabels(); // 회사 설정 용어 const { register, control, @@ -129,7 +131,7 @@ export function PartnerFormSheet({
{/* Partner Name */}
- 협력사명 + {label('supplier.name')} {/* Code */}
- 협력사코드 + {label('supplier.code')} {/* 총매출액 */}
- 총매출액 (원, 선택) + {label('supplier.total_revenue')} (원, 선택) - 작성자 + {label('creator')} {partner?.creator_name ?? '-'}
)} {/* Manager Name */}
- 담당자명 + {label('supplier.manager_name')} - 담당자 이메일 + {label('supplier.manager_email')} - 담당자 연락처 + {label('supplier.manager_contact')} {part.name}, }, { - header: '협력사코드', + header: label('supplier.code'), align: 'left', cellClassName: 'font-mono font-medium text-muted-foreground', cell: (part) => part.code, }, { - header: '담당자명 / 담당자 이메일 / 연락처', + header: `${label('supplier.manager_name')} / ${label('supplier.manager_email')} / ${label('supplier.manager_contact')}`, align: 'left', mobileBlock: true, // 여러 줄 블록 → 모바일 카드뷰에서 라벨 아래 풀폭 cell: (part) => ( @@ -75,13 +77,13 @@ export function PartnerTable({ ), }, { - header: '총매출액', + header: label('supplier.total_revenue'), align: 'right', cellClassName: 'font-mono text-muted-foreground', cell: (part) => (part.total_revenue != null ? `₩${Number(part.total_revenue).toLocaleString()}` : '-'), }, { - header: '채팅 계정', + header: label('supplier.chat_account'), align: 'center', cellClassName: 'font-mono whitespace-nowrap', cell: (part) => @@ -95,7 +97,7 @@ export function PartnerTable({ ), }, { - header: '작성자', + header: label('creator'), align: 'center', cellClassName: 'text-muted-foreground whitespace-nowrap', cell: (part) => part.creator_name ?? '-', diff --git a/negodata/front/src/features/products/components/ExcelUploadModal.tsx b/negodata/front/src/features/products/components/ExcelUploadModal.tsx index 49b1f79..a9b1993 100644 --- a/negodata/front/src/features/products/components/ExcelUploadModal.tsx +++ b/negodata/front/src/features/products/components/ExcelUploadModal.tsx @@ -9,7 +9,7 @@ import { Typography } from '@/components/ui/typography'; import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { useListSuppliers } from '@/api/generated/supplier/supplier'; -import { useCompanySettings, useLabels } from '@/features/settings/useCompanySettings'; +import { useCompanySettings, useLabels, useHiddenFields } from '@/features/settings/useCompanySettings'; import type { CustomFieldDef } from '@/features/settings/catalog'; import type { Product } from '../types'; @@ -63,7 +63,7 @@ const STANDARD_COLUMNS: { base: string; key: Exclude(['price', 'minPrice', 'purchase_price', 'selling_price', 'lead_time']); const REQUIRED_KEYS = new Set(['name', 'code', 'price']); +// 업로드 컬럼 key → 숨김 설정 키. 최저한도만 items 컬럼명(internet_lowest_price)과 다르다. +const hiddenKeyOf = (key: string): string => (key === 'minPrice' ? 'internet_lowest_price' : key); + // 회사 설정(용어 라벨 + item_fields 커스텀필드)으로 업로드 컬럼을 만든다. // 기존 18컬럼은 전부 유지, 커스텀필드는 뒤에 추가된다. -function buildColumns(label: LabelFn, itemFields: CustomFieldDef[]): UploadColumn[] { - const standard: UploadColumn[] = STANDARD_COLUMNS.map((c) => { +function buildColumns(label: LabelFn, itemFields: CustomFieldDef[], isHidden: (key: string) => boolean): UploadColumn[] { + // 회사 설정에서 숨긴 기본필드는 양식·파싱 양쪽에서 통째로 뺀다(파일에 남아 있어도 무시). + const standard: UploadColumn[] = STANDARD_COLUMNS.filter((c) => !isHidden(hiddenKeyOf(c.key))).map((c) => { const header = c.labelKey ? `${label(c.labelKey)}${c.suffix ?? ''}` : c.base; return { header, @@ -142,8 +146,8 @@ const EXAMPLE_ROWS: Record[] = [ // 업로드 양식(.csv) 다운로드 — 회사 설정 헤더(라벨 치환) + 커스텀필드 컬럼 + 예시 행. // 파싱(buildColumns)과 같은 정의를 공유하므로 받은 양식이 그대로 다시 업로드된다. 툴바·모달이 공유한다. -export function downloadProductTemplate(label: LabelFn, itemFields: CustomFieldDef[]) { - const columns = buildColumns(label, itemFields); +export function downloadProductTemplate(label: LabelFn, itemFields: CustomFieldDef[], isHidden: (key: string) => boolean = () => false) { + const columns = buildColumns(label, itemFields, isHidden); downloadExcel>( '상품_업로드_양식', columns.map((c) => ({ @@ -177,6 +181,7 @@ function validateRows( priceLabel: string, itemFields: CustomFieldDef[], supplierIdByName: Map, + isHidden: (key: string) => boolean, ): ValidatedRow[] { return rows.map((row) => { const fail = (message: string): ValidatedRow => ({ ...row, status: '오류', message }); @@ -188,8 +193,13 @@ function validateRows( const dupInExcel = rows.some((other) => other.id !== row.id && other.code === row.code); if (dupInProducts || dupInExcel) return fail('코드 중복 - 이미 존재하거나 목록 내 중복된 코드입니다.'); - if (row.price <= 0) return fail(`유효성 위반 - ${priceLabel}는 0보다 커야 합니다.`); - if (row.minPrice > row.price) return fail(`유효성 위반 - 최저 한도가 ${priceLabel}보다 큽니다.`); + // 숨긴 필드는 양식에 아예 없으므로 값이 비어 있는 게 정상 — 검증에서 제외한다. + if (!isHidden('price')) { + if (row.price <= 0) return fail(`유효성 위반 - ${priceLabel}는 0보다 커야 합니다.`); + if (!isHidden('internet_lowest_price') && row.minPrice > row.price) { + return fail(`유효성 위반 - 인터넷 최저가가 ${priceLabel}보다 큽니다.`); + } + } // 선택 필드 형식 검증(값이 있을 때만). 배송형태/부가세/배송비/이미지URL. if (row.delivery_type.trim() && !(row.delivery_type.trim() in deliveryMap)) { @@ -264,7 +274,8 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp const label = useLabels(); const { settings } = useCompanySettings(); const itemFields = useMemo(() => settings.item_fields ?? [], [settings.item_fields]); - const columns = useMemo(() => buildColumns(label, itemFields), [label, itemFields]); + const isHidden = useHiddenFields(); + const columns = useMemo(() => buildColumns(label, itemFields, isHidden), [label, itemFields, isHidden]); const deliveryMap = useMemo(() => buildDeliveryMap(label), [label]); // 공급사 컬럼 검증·매핑용 협력사 전체 목록(이름 → id). const supplierList = useListSuppliers({ size: 1000 }); @@ -285,9 +296,9 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp () => validateRows( rows, products, serverErrors, deliveryMap, [1, 2, 3].map((c) => label(`delivery_type.${c}`)), - label('item.price'), itemFields, supplierIdByName, + label('item.price'), itemFields, supplierIdByName, isHidden, ), - [rows, products, serverErrors, deliveryMap, label, itemFields, supplierIdByName], + [rows, products, serverErrors, deliveryMap, label, itemFields, supplierIdByName, isHidden], ); const validRows = validated.filter((r) => r.status === '정상'); const validCount = validRows.length; diff --git a/negodata/front/src/features/products/components/ProductFormSheet.tsx b/negodata/front/src/features/products/components/ProductFormSheet.tsx index a995521..fce395d 100644 --- a/negodata/front/src/features/products/components/ProductFormSheet.tsx +++ b/negodata/front/src/features/products/components/ProductFormSheet.tsx @@ -13,7 +13,7 @@ import { Input } from '@/components/ui/input'; import { Sheet } from '@/components/ui/sheet'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { useAuthStore } from '@/stores/auth'; -import { useCompanySettings, useLabels } from '@/features/settings/useCompanySettings'; +import { useCompanySettings, useLabels, useHiddenFields } from '@/features/settings/useCompanySettings'; import { CustomFieldInputs, useCustomFieldValues } from '@/features/settings/CustomFieldInputs'; import { ItemSuppliersManager } from './ItemSuppliersManager'; import { type Product } from '../types'; @@ -132,6 +132,10 @@ export function ProductFormSheet({ }); const label = useLabels(); // 회사 설정 용어 + const isHidden = useHiddenFields(); + // 숨김 필드는 DOM 에서 제거하지 않고 감추기만 한다 — 수정 시 기존 값이 그대로 유지·전송되도록. + const hideCls = (key: string, base = 'space-y-1') => (isHidden(key) ? `${base} hidden` : base); + const hideCls2 = (hidden: boolean, base: string) => (hidden ? `${base} hidden` : base); // 배송유형 선택지 — 회사 설정 용어(delivery_type.N)로 라벨만 치환(코드값 불변) const deliveryTypes = DELIVERY_TYPE_OPTIONS.map((o) => ({ ...o, label: label(`delivery_type.${o.value}`) })); // 회사 상품 커스텀필드(정의=companies.settings.item_fields, 값=items.custom) @@ -219,7 +223,7 @@ export function ProductFormSheet({ {/* Product Name */}
- 상품명 + {label('item.name')} - 작성자 + {label('creator')} {product?.creator_name ?? '-'}
)}
{/* Price */} -
+
{label('item.price')} (₩) {errors.price.message}

}
{/* Min Price */} -
- 인터넷 최저가 (₩) +
+ {label('item.internet_lowest_price')} (₩) {/* 매입가 */} -
- 매입가 (₩) +
+ {label('item.purchase_price')} {errors.purchasePrice.message}

}
{/* 판매가 */} -
- 판매가 (₩) +
+ {label('item.selling_price')} {/* Model Name */} -
+
{label('item.model_name')}
{/* Unit */} -
- 취급 단위 +
+ {label('item.quantity_unit')} {/* Specification */} -
- 상품 규격 +
+ {label('item.spec')}