[feat] negodata·공급사포털: 상품 필드 숨김 설정·용어 카탈로그 확장·로그인 전 브랜딩·모바일 대응

- 회사 설정 hidden_fields 신설: 상품 목록·등록폼·엑셀 양식에서 감추고 목표가 후보에서도 제외
- 용어 카탈로그 9→37개(상품·협력사·견적 그룹), 목록·폼·엑셀 헤더 배선
- 엑셀 '최저한도' → '인터넷 최저가' 표기 정정(구양식 헤더는 alias 유지), 공급사 컬럼 검증 유지
- 목표가 산정내역에 적용 모드·숨김 필드 반영, 목표가 상한 라벨에 산식(상품단가×2) 표기
- 공급사 포털: 초청 링크 session_id 로 로그인 전 회사 브랜딩 조회(무인증 엔드포인트), 기본 브랜드 negotium 워드마크, negotium·AI O2O 크레딧 표기
- 목록 테이블 뷰포트 기준 전환·툴바 줄바꿈·협상현황 카드뷰로 모바일/태블릿 대응
- 이용안내 FAQ 탭 추가, 이용안내·회사설정 탭 URL 쿼리 동기화 및 빠른이동 등록
This commit is contained in:
Mina Choi 2026-07-23 11:29:35 +09:00
parent 4927c9184e
commit 0f10b6a70f
43 changed files with 896 additions and 143 deletions

View File

@ -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:

View File

@ -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))

View File

@ -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)")

View File

@ -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()

View File

@ -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<SessionBrandingResponse> => {
const res = await http.get<SessionBrandingResponse>(`/v1/auth/session-branding/${sessionId}`)
return res.data
},
/** GET /v1/auth/me — 현재 로그인 유저 정보 (access token 필요) */
me: async (): Promise<MeResponse> => {
const res = await http.get<MeResponse>('/v1/auth/me')

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View File

@ -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<LogoVariant, string> = {
color: logoColor,
white: logoWhite,
color: logoDefault,
white: logoDefault,
}
const sizes: Record<LogoSize, { img: string; text: string; gap: string }> = {
@ -17,6 +17,13 @@ const sizes: Record<LogoSize, { img: string; text: string; gap: string }> = {
lg: { img: 'h-10', text: 'text-2xl', gap: 'gap-2.5' },
}
// 기본 브랜드 로고는 가로로 긴 워드마크라 같은 높이로 두면 회사 심볼보다 훨씬 커 보인다(랜딩도 h-4 기준).
const wordmarkSizes: Record<LogoSize, string> = {
sm: 'h-3.5',
md: 'h-4',
lg: 'h-5',
}
export interface LogoProps extends Omit<ComponentProps<'div'>, '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 (
<div className={cn('flex items-center', s.gap, className)} {...props}>
<img
src={logoUrl || sources[variant]}
alt={withText ? '' : (serviceName || alt)}
className={cn('w-auto select-none', s.img)}
className={cn('w-auto select-none', logoUrl ? s.img : wordmarkSizes[size])}
/>
{withText && (
{withText && serviceName && (
<span className={cn('font-bold tracking-[-0.4px] text-foreground', s.text)}>
{name}
</span>

View File

@ -6,7 +6,10 @@ import { tokenStorage } from '@/apis'
// 토큰이 있으나 만료/폐기된 경우는 요청 시 인터셉터가 세션을 종료시킨다.
export function RequireAuth({ children }: { children: ReactNode }) {
if (!tokenStorage.hasToken()) {
return <Navigate to="/" replace />
// 초청 링크(/chat?session_id=...)로 들어온 미로그인 사용자 — session_id 를 넘겨야
// 로그인 화면이 그 회사 브랜딩으로 뜬다.
const sessionId = new URLSearchParams(window.location.search).get('session_id')
return <Navigate to={sessionId ? `/?session_id=${sessionId}` : '/'} replace />
}
return <>{children}</>
}

View File

@ -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<Branding | null>(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
}

View File

@ -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'

View File

@ -53,6 +53,9 @@ export function MainLayout({
{logoAction}
</div>
{sidebar}
<p className="mt-auto px-4 py-3 text-[10px] font-medium text-neutral-50">
© {new Date().getFullYear()} negotium · Made by AI O2O
</p>
</aside>
<main className={styles.main}>

View File

@ -8,6 +8,12 @@ export function ListPage() {
<main className="mx-auto w-full max-w-7xl flex-1 px-4 py-6 sm:px-6">
<ListWorkspace />
</main>
{/* 솔루션·제작사 표기 — 회사 브랜딩(헤더 로고)과 무관하게 고정 */}
<footer className="border-t border-border px-4 py-4 text-center sm:px-6">
<p className="text-[11px] font-medium text-neutral-50">
© {new Date().getFullYear()} negotium · Made by AI O2O
</p>
</footer>
</div>
)
}

View File

@ -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 <Navigate to="/list" replace />
@ -14,24 +17,29 @@ export function LoginPage() {
<div className="flex w-full max-w-sm flex-col items-center gap-6">
{/* 브랜드 락업 */}
<div className="flex flex-col items-center gap-2">
<Logo size="lg" />
<Logo size="lg" serviceName={branding?.service_name} logoUrl={branding?.logo_url} />
<p className="text-[13px] font-medium tracking-[-0.26px] text-neutral-60">
아이마켓코리아 B2B 구매협상 솔루션 · 공급사 포털
negotium B2B 구매협상 솔루션 · 공급사 포털
</p>
</div>
{/* 로그인 카드 */}
<div className="w-full rounded-3xl border border-border/60 bg-white p-8 shadow-[0_4px_24px_rgba(0,0,0,0.04)]">
<div className="w-full rounded-2xl border border-border bg-white p-8 shadow-sm">
<h1 className="mb-6 text-center text-xl font-bold tracking-[-0.4px] text-neutral-90">
로그인
</h1>
<LoginForm />
</div>
{/* 푸터 */}
<p className="text-xs font-medium text-neutral-60">
문의: 헬프데스크 010-0000-0000 · o2odev@o2o.kr
</p>
{/* 푸터 — 문의처 + 솔루션/제작사 표기(회사 브랜딩과 무관하게 고정) */}
<div className="flex flex-col items-center gap-1">
<p className="text-xs font-medium text-neutral-60">
문의: 헬프데스크 010-0000-0000 · o2odev@o2o.kr
</p>
<p className="text-[11px] font-medium text-neutral-50">
© {new Date().getFullYear()} negotium · Made by AI O2O
</p>
</div>
</div>
</main>
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

View File

@ -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:

View File

@ -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

View File

@ -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} "

View File

@ -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';

View File

@ -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;

View File

@ -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;

View File

@ -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<PageType, string> = {
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 && <ProfileSheet open onClose={() => 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -380,7 +408,7 @@ function CommandMenu({
/>
</div>
<div className="max-h-72 overflow-y-auto p-1.5">
{filtered.length === 0 ? (
{filtered.length === 0 && filteredGuides.length === 0 && filteredSettings.length === 0 ? (
<Typography variant="muted" className="px-3 py-4 text-xs">
일치하는 메뉴가 없습니다
</Typography>
@ -407,6 +435,32 @@ function CommandMenu({
);
})
)}
{filteredSettings.map((e) => (
<button
key={e.tab}
type="button"
onClick={() => onSelectSettings(e.tab)}
className="flex w-full cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-left transition-colors hover:bg-accent"
>
<Building size={15} className="shrink-0 text-muted-foreground" />
<Typography as="span" variant="small" className="flex-1 truncate">
{e.label}
</Typography>
</button>
))}
{filteredGuides.map((g) => (
<button
key={g.tab}
type="button"
onClick={() => onSelectGuide(g.tab)}
className="flex w-full cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-left transition-colors hover:bg-accent"
>
<BookOpen size={15} className="shrink-0 text-muted-foreground" />
<Typography as="span" variant="small" className="flex-1 truncate">
{g.label}
</Typography>
</button>
))}
</div>
</DialogContent>
</Dialog>

View File

@ -16,12 +16,12 @@ export function PageToolbar({
return (
<div
className={cn(
'flex flex-col gap-2 rounded-lg border border-border bg-card p-3 md:flex-row md:items-center md:justify-between',
'flex flex-col gap-2 rounded-lg border border-border bg-card p-3 lg:flex-row lg:items-center lg:justify-between',
className
)}
>
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center">{children}</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:flex-wrap">{children}</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div>
);
}

View File

@ -97,8 +97,8 @@ export function DataTable<T>({
const detailCols = mobileCols.filter((c) => c !== primaryCol)
return (
<div className={cn("@container border border-border rounded-lg bg-card overflow-hidden", className)}>
<div className="hidden @3xl:block">
<div className={cn("border border-border rounded-lg bg-card overflow-hidden", className)}>
<div className="hidden lg:block overflow-x-auto">
<Table className="w-full text-xs">
<TableHeader>
<TableRow className="bg-muted/50 border-b border-border hover:bg-transparent">
@ -182,7 +182,7 @@ export function DataTable<T>({
</Table>
</div>
<div className="@3xl:hidden divide-y divide-border">
<div className="lg:hidden divide-y divide-border">
{data.length > 0 ? (
data.map((row) => {
const key = rowKey(row)

View File

@ -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 (
<DataTable
className={className}
data={data}
rowKey={(m) => m.user_id}
onRowClick={onRowClick}

View File

@ -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<Tone, string> = {
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=<tab> 으로 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({
{/* 뷰 전환 탭 */}
<div className="flex w-fit gap-1 rounded-lg bg-muted p-1">
{(['flow', 'type', 'nego', 'example', 'terms'] as const).map((t) => (
{GUIDE_TABS.map((t) => (
<button
key={t}
type="button"
@ -423,6 +474,20 @@ export function OnboardingGuideModal({
</div>
)}
{tab === 'faq' && (
<div className="space-y-2">
<Typography variant="h4">자주 묻는 질문</Typography>
{FAQS.map((f) => (
<details key={f.q} className="rounded-lg border border-border bg-muted/30 p-3">
<summary className="cursor-pointer list-none">
<Typography as="span" variant="small" className="font-bold">Q. {f.q}</Typography>
</summary>
<Typography variant="caption" className="mt-1.5 block leading-relaxed">{f.a}</Typography>
</details>
))}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={openOnboardingPage}>온보딩 페이지에서 보기 ↗</Button>
<Button onClick={() => onOpenChange(false)}>시작하기</Button>

View File

@ -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({
<form onSubmit={handleSubmit(onValid)} className="mt-6 space-y-4 text-xs font-mono">
{/* Partner Name */}
<div className="space-y-1">
<Typography as="label" variant="label">협력사명</Typography>
<Typography as="label" variant="label">{label('supplier.name')}</Typography>
<Input
id="form-partner-name"
type="text"
@ -143,7 +145,7 @@ export function PartnerFormSheet({
<div className="grid grid-cols-2 gap-4">
{/* Code */}
<div className="space-y-1">
<Typography as="label" variant="label">협력사코드</Typography>
<Typography as="label" variant="label">{label('supplier.code')}</Typography>
<Input
id="form-partner-code"
type="text"
@ -155,7 +157,7 @@ export function PartnerFormSheet({
</div>
{/* 총매출액 */}
<div className="space-y-1">
<Typography as="label" variant="label">총매출액 (원, 선택)</Typography>
<Typography as="label" variant="label">{label('supplier.total_revenue')} (원, 선택)</Typography>
<Input
id="form-partner-revenue"
type="number"
@ -170,14 +172,14 @@ export function PartnerFormSheet({
{/* 작성자(등록자) — 읽기전용, 편집 시에만 */}
{mode === 'edit' && (
<div className="space-y-1">
<Typography as="label" variant="label">작성자</Typography>
<Typography as="label" variant="label">{label('creator')}</Typography>
<Typography as="p" variant="small" className="text-muted-foreground">{partner?.creator_name ?? '-'}</Typography>
</div>
)}
{/* Manager Name */}
<div className="space-y-1">
<Typography as="label" variant="label">담당자명</Typography>
<Typography as="label" variant="label">{label('supplier.manager_name')}</Typography>
<Input
id="form-partner-manager"
type="text"
@ -190,7 +192,7 @@ export function PartnerFormSheet({
{/* Manager Email */}
<div className="space-y-1">
<Typography as="label" variant="label">담당자 이메일</Typography>
<Typography as="label" variant="label">{label('supplier.manager_email')}</Typography>
<Input
id="form-partner-email"
type="email"
@ -203,7 +205,7 @@ export function PartnerFormSheet({
{/* Manager Phone */}
<div className="space-y-1">
<Typography as="label" variant="label">담당자 연락처</Typography>
<Typography as="label" variant="label">{label('supplier.manager_contact')}</Typography>
<Controller
control={control}
name="managerPhone"

View File

@ -1,4 +1,5 @@
import { DataTable } from '@/components/ui/data-table';
import { useLabels } from '@/features/settings/useCompanySettings';
import { TablePagination } from '@/components/ui/table-pagination';
import { formatPhoneKR } from '@/lib/phone';
import type { Partner } from '../types';
@ -29,6 +30,7 @@ export function PartnerTable({
onPageChange,
className,
}: PartnerTableProps) {
const label = useLabels(); // 회사 설정 용어
return (
<DataTable
className={className}
@ -50,19 +52,19 @@ export function PartnerTable({
}
columns={[
{
header: '협력사명',
header: label('supplier.name'),
align: 'left',
headClassName: 'w-2/5',
cell: (part) => <span className="font-bold text-sm text-foreground">{part.name}</span>,
},
{
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 ?? '-',

View File

@ -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<keyof RawRow, 'id' | 'rowNu
{ base: '공급사', key: 'suppliers' },
{ base: '원산지', key: 'made_in' },
{ base: '상품 단가', key: 'price', labelKey: 'item.price' },
{ base: '최저한도', key: 'minPrice' },
{ base: '최저한도', key: 'minPrice', labelKey: 'item.internet_lowest_price' },
{ base: '매입가', key: 'purchase_price' },
{ base: '판매가', key: 'selling_price' },
{ base: '이미지URL', key: 'image_url' },
@ -88,10 +88,14 @@ type UploadColumn = {
const NUMERIC_KEYS = new Set<string>(['price', 'minPrice', 'purchase_price', 'selling_price', 'lead_time']);
const REQUIRED_KEYS = new Set<string>(['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<string, string | number>[] = [
// 업로드 양식(.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<Record<string, string | number>>(
'상품_업로드_양식',
columns.map((c) => ({
@ -177,6 +181,7 @@ function validateRows(
priceLabel: string,
itemFields: CustomFieldDef[],
supplierIdByName: Map<string, string>,
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;

View File

@ -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({
<form onSubmit={handleSubmit(onValid)} className="mt-6 space-y-4 text-xs font-mono">
{/* Product Name */}
<div className="space-y-1">
<Typography as="label" variant="label">상품명</Typography>
<Typography as="label" variant="label">{label('item.name')}</Typography>
<Input
id="form-product-name"
type="text"
@ -265,14 +269,14 @@ export function ProductFormSheet({
{/* 작성자(등록자) — 읽기전용, 편집 시에만 */}
{mode === 'edit' && (
<div className="space-y-1">
<Typography as="label" variant="label">작성자</Typography>
<Typography as="label" variant="label">{label('creator')}</Typography>
<Typography as="p" variant="small" className="text-muted-foreground">{product?.creator_name ?? '-'}</Typography>
</div>
)}
<div className="grid grid-cols-2 gap-4">
{/* Price */}
<div className="space-y-1">
<div className={hideCls('price')}>
<Typography as="label" variant="label">{label('item.price')} (₩)</Typography>
<Input
id="form-product-price"
@ -284,8 +288,8 @@ export function ProductFormSheet({
{errors.price && <p className="text-[10px] text-rose-500">{errors.price.message}</p>}
</div>
{/* Min Price */}
<div className="space-y-1">
<Typography as="label" variant="label" className="text-rose-500">인터넷 최저가 (₩)</Typography>
<div className={hideCls('internet_lowest_price')}>
<Typography as="label" variant="label" className="text-rose-500">{label('item.internet_lowest_price')} (₩)</Typography>
<Input
id="form-product-minprice"
type="number"
@ -299,8 +303,8 @@ export function ProductFormSheet({
<div className="grid grid-cols-2 gap-4">
{/* 매입가 */}
<div className="space-y-1">
<Typography as="label" variant="label">매입가 (₩)</Typography>
<div className={hideCls('purchase_price')}>
<Typography as="label" variant="label">{label('item.purchase_price')}</Typography>
<Input
id="form-product-purchase-price"
type="number"
@ -311,8 +315,8 @@ export function ProductFormSheet({
{errors.purchasePrice && <p className="text-[10px] text-rose-500">{errors.purchasePrice.message}</p>}
</div>
{/* 판매가 */}
<div className="space-y-1">
<Typography as="label" variant="label">판매가 (₩)</Typography>
<div className={hideCls('selling_price')}>
<Typography as="label" variant="label">{label('item.selling_price')}</Typography>
<Input
id="form-product-selling-price"
type="number"
@ -326,7 +330,7 @@ export function ProductFormSheet({
<div className="grid grid-cols-2 gap-4">
{/* Model Name */}
<div className="space-y-1">
<div className={hideCls('model_name')}>
<Typography as="label" variant="label">{label('item.model_name')}</Typography>
<Input
id="form-product-model"
@ -336,8 +340,8 @@ export function ProductFormSheet({
/>
</div>
{/* Unit */}
<div className="space-y-1">
<Typography as="label" variant="label">취급 단위</Typography>
<div className={hideCls('quantity_unit')}>
<Typography as="label" variant="label">{label('item.quantity_unit')}</Typography>
<Input
type="text"
{...register('unit')}
@ -347,8 +351,8 @@ export function ProductFormSheet({
</div>
{/* Specification */}
<div className="space-y-1">
<Typography as="label" variant="label">상품 규격</Typography>
<div className={hideCls('spec')}>
<Typography as="label" variant="label">{label('item.spec')}</Typography>
<textarea
{...register('specification')}
rows={2}
@ -359,8 +363,8 @@ export function ProductFormSheet({
<div className="grid grid-cols-2 gap-4">
{/* MOQ */}
<div className="space-y-1">
<Typography as="label" variant="label">최소 주문 수량</Typography>
<div className={hideCls('moq')}>
<Typography as="label" variant="label">{label('item.moq')}</Typography>
<Input
type="text"
{...register('moq')}
@ -381,8 +385,8 @@ export function ProductFormSheet({
<div className="grid grid-cols-2 gap-4">
{/* Manufacturer */}
<div className="space-y-1">
<Typography as="label" variant="label">제조사</Typography>
<div className={hideCls('manufacturer')}>
<Typography as="label" variant="label">{label('item.manufacturer')}</Typography>
<Input
type="text"
{...register('manufacturer')}
@ -390,8 +394,8 @@ export function ProductFormSheet({
/>
</div>
{/* Origin */}
<div className="space-y-1">
<Typography as="label" variant="label">제조 국가</Typography>
<div className={hideCls('made_in')}>
<Typography as="label" variant="label">{label('item.made_in')}</Typography>
<Input
type="text"
{...register('origin')}
@ -402,7 +406,7 @@ export function ProductFormSheet({
{/* Shipping Type — 서버 enum(delivery_type) 코드 전송 */}
<div className="space-y-1">
<Typography as="label" variant="label">배송 형태</Typography>
<Typography as="label" variant="label">{label('item.delivery_type')}</Typography>
<Controller
control={control}
name="shippingType"
@ -425,14 +429,14 @@ export function ProductFormSheet({
</div>
{/* DB Schema Booleans (vat_yn, delivery_fee_yn) using Switches */}
<div className="p-3 bg-muted/35 border border-border/80 rounded-md space-y-3">
<div className={hideCls2(isHidden('vat_yn') && isHidden('delivery_fee_yn'), 'p-3 bg-muted/35 border border-border/80 rounded-md space-y-3')}>
<span className="text-[10px] uppercase tracking-wider font-bold text-muted-foreground block">부가 정보 설정</span>
<Controller
control={control}
name="vatYn"
render={({ field }) => (
<div className="flex items-center justify-between">
<div className={hideCls('vat_yn', 'flex items-center justify-between')}>
<span className="font-semibold text-foreground">VAT 포함 여부:</span>
<button
type="button"
@ -455,7 +459,7 @@ export function ProductFormSheet({
control={control}
name="deliveryFeeYn"
render={({ field }) => (
<div className="flex items-center justify-between border-t border-border/40 pt-2.5">
<div className={hideCls('delivery_fee_yn', 'flex items-center justify-between border-t border-border/40 pt-2.5')}>
<span className="font-semibold text-foreground">배송비 포함 여부:</span>
<button
type="button"

View File

@ -1,8 +1,8 @@
import { Image as ImageIcon } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { DataTable } from '@/components/ui/data-table';
import { DataTable, type Column } from '@/components/ui/data-table';
import { TablePagination } from '@/components/ui/table-pagination';
import { useLabels } from '@/features/settings/useCompanySettings';
import { useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
import { type Product } from '../types';
type ProductTableProps = {
@ -32,6 +32,7 @@ export function ProductTable({
className,
}: ProductTableProps) {
const label = useLabels(); // 회사 설정 용어(카테고리/상품 단가 등)
const isHidden = useHiddenFields(); // 회사 설정으로 감춘 기본필드(DB 값은 유지, 화면에서만 제외)
return (
<DataTable
className={className}
@ -51,9 +52,9 @@ export function ProductTable({
unit="건"
/>
}
columns={[
columns={([
{
header: '상품명',
header: label('item.name'),
align: 'left',
headClassName: 'w-2/5',
cell: (prod) => (
@ -67,7 +68,7 @@ export function ProductTable({
</div>
<div>
<div className="font-semibold text-foreground text-sm line-clamp-1">{prod.name}</div>
{prod.model_name && <span className="text-[10px] text-muted-foreground font-mono">{prod.model_name}</span>}
{prod.model_name && !isHidden('model_name') && <span className="text-[10px] text-muted-foreground font-mono">{prod.model_name}</span>}
</div>
</div>
),
@ -88,7 +89,7 @@ export function ProductTable({
),
},
{
header: '공급사',
header: label('item.suppliers'),
align: 'left',
cell: (prod) => {
const names = prod.supplier_names ?? [];
@ -102,24 +103,26 @@ export function ProductTable({
},
},
{
field: 'price',
header: label('item.price'),
align: 'right',
cellClassName: 'font-mono font-bold text-foreground',
cell: (prod) => `₩${(prod.price || 0).toLocaleString()}`,
},
{
header: '인터넷 최저가',
field: 'internet_lowest_price',
header: label('item.internet_lowest_price'),
align: 'right',
cellClassName: 'font-mono font-semibold text-rose-600 dark:text-rose-400',
cell: (prod) => (prod.internet_lowest_price != null ? `₩${Number(prod.internet_lowest_price).toLocaleString()}` : '-'),
},
{
header: '작성자',
header: label('creator'),
align: 'center',
cellClassName: 'text-muted-foreground whitespace-nowrap',
cell: (prod) => prod.creator_name ?? '-',
},
]}
] as (Column<Product> & { field?: string })[]).filter((c) => !c.field || !isHidden(c.field))}
/>
);
}

View File

@ -6,7 +6,7 @@ import { useListItems, useGetItem } from '@/api/generated/item/item';
import { useListSuppliers } from '@/api/generated/supplier/supplier';
import { useListCards } from '@/api/generated/card/card';
import { mapCardData } from '@/features/cards/types';
import { useLabels } from '@/features/settings/useCompanySettings';
import { useLabels, useCompanySettings } from '@/features/settings/useCompanySettings';
import { Button } from '@/components/ui/button';
import { Typography, typographyVariants } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
@ -217,9 +217,14 @@ export function QuotationCreateModal({
const d = cardDetails.get(id);
return { id, code: d?.code ?? '', title: d?.title ?? id, isWildcard: d?.isWildcard ?? false };
});
// 목표가 산정 모드(회사 설정). 'purchase' 면 매입가 × (1 − 네고율) 하나만 후보 — 백엔드 _candidates 와 같은 규칙.
const { settings: companySettings } = useCompanySettings();
const purchaseOnly = (companySettings.features as { target_price_mode?: string } | undefined)?.target_price_mode === 'purchase';
// 상품에 산정 후보가 있는지(인터넷=공통, 매입·판매=재 한정). 없으면 MD가가 유일한 후보 → 필수가 된다.
const mdNum = Number(mdPrice) || 0;
const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
const hasItemCandidate = purchaseOnly
? purchase != null
: internetLowest != null || (isReType && (purchase != null || selling != null));
const mdRequired = !!productId && !hasItemCandidate;
// 목표가 산정 가능 여부: MD가가 있으면 무조건 OK. 없으면 상품 후보 중 하나라도 있어야.
const targetReady = mdNum > 0 || hasItemCandidate;
@ -227,11 +232,13 @@ export function QuotationCreateModal({
const margin = parsePercent(selectedSetting?.target_margin);
const targetCandidates = mdNum > 0
? [mdNum]
: [
internetLowest != null ? internetLowest * (1 - INTERNET_AVERAGE_FEE) : null,
!isReType || purchase == null ? null : purchase,
!isReType || selling == null ? null : selling * (1 - margin),
].filter((v): v is number => v != null && v > 0);
: purchaseOnly
? [purchase == null ? null : purchase * (1 - margin)].filter((v): v is number => v != null && v > 0)
: [
internetLowest != null ? internetLowest * (1 - INTERNET_AVERAGE_FEE) : null,
!isReType || purchase == null ? null : purchase,
!isReType || selling == null ? null : selling * (1 - margin),
].filter((v): v is number => v != null && v > 0);
const estimatedTargetPrice = targetCandidates.length ? Math.trunc(Math.min(...targetCandidates)) : null;
const targetPriceLimit = unitPrice != null && unitPrice > 0
? unitPrice * TARGET_PRICE_UNIT_LIMIT_MULTIPLIER
@ -456,11 +463,11 @@ export function QuotationCreateModal({
상품 상세에서 수정
</button>
</div>
<div className={`grid ${isReType ? 'grid-cols-3' : 'grid-cols-1'} gap-2`}>
<div className={`grid ${purchaseOnly ? 'grid-cols-1' : isReType ? 'grid-cols-3' : 'grid-cols-1'} gap-2`}>
{[
{ label: '인터넷 최저가', value: internetLowest, show: true },
{ label: '매입가', value: purchase, show: isReType },
{ label: '판매가', value: selling, show: isReType },
{ label: '인터넷 최저가', value: internetLowest, show: !purchaseOnly },
{ label: '매입가', value: purchase, show: purchaseOnly || isReType },
{ label: '판매가', value: selling, show: !purchaseOnly && isReType },
]
.filter((r) => r.show)
.map((r) => (
@ -475,7 +482,7 @@ export function QuotationCreateModal({
<div className="grid grid-cols-2 gap-2 border-t border-border/60 pt-2">
{[
{ label: '상품단가', value: unitPrice },
{ label: '목표가 상한', value: targetPriceLimit },
{ label: `목표가 상한 (상품단가 × ${TARGET_PRICE_UNIT_LIMIT_MULTIPLIER})`, value: targetPriceLimit },
].map((r) => (
<div key={r.label} className="space-y-0.5">
<Typography as="span" variant="label" className="text-muted-foreground">{r.label}</Typography>
@ -487,7 +494,7 @@ export function QuotationCreateModal({
</div>
{!targetReady && (
<Typography as="p" variant="small" className="text-rose-600 leading-snug">
⚠ MD 제시가도 없고 상품에 산정할 값도 없습니다 — MD가를 입력하거나 위 ‘상품 상세에서 수정’으로 값을 채워야 목표가가 나옵니다.
⚠ MD 제시가도 없고 {purchaseOnly ? '상품에 매입가가' : '상품에 산정할 값이'} 없습니다 — MD가를 입력하거나 위 ‘상품 상세에서 수정’으로 값을 채워야 목표가가 나옵니다.
</Typography>
)}
{targetLimitExceeded && (

View File

@ -160,7 +160,7 @@ export function SessionsStatusTab({
</div>
)}
<div className="border border-border rounded-lg bg-card overflow-x-auto">
<div className="hidden lg:block border border-border rounded-lg bg-card overflow-x-auto">
<Table className="w-full text-left text-xs border-collapse font-mono min-w-[1250px]">
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
<TableRow>
@ -342,6 +342,112 @@ export function SessionsStatusTab({
</Table>
</div>
{/* 모바일·태블릿: 13컬럼 표 대신 세션당 카드 한 장(목록 화면들과 같은 전환 기준 lg) */}
<div className="lg:hidden divide-y divide-border rounded-lg border border-border bg-card">
{sessionViews.length === 0 && (
<Typography as="p" variant="small" className="p-8 text-center text-muted-foreground">
참여 중인 협상 세션이 없습니다.
</Typography>
)}
{sessionViews.map((sess) => {
const isCandidate = sess.status === SessionStatus.DONE && sess.bid_price != null;
const isRecommended = isCandidate && sess.bid_price === lowestBid;
const isWinner = !!winnerSupplierId && sess.supplier_id === winnerSupplierId;
const rows: { label: string; value: string }[] = [
{ label: '상품', value: sess.item_name || '-' },
{
label: '앵커링가',
value: sess.anchoring_price > 0 ? `₩${sess.anchoring_price.toLocaleString()}` : '-',
},
{ label: '투찰가', value: sess.bid_price ? `₩${sess.bid_price.toLocaleString()}` : '-' },
{ label: '투찰시각', value: sess.bid_at || '-' },
{ label: '마감시각', value: sess.end_time || '-' },
];
// 거절 정보는 값이 있을 때만(빈 줄로 카드가 길어지지 않게).
if (sess.reject_reason) rows.push({ label: '거절사유', value: sess.reject_reason });
if (sess.reject_price) rows.push({ label: '거절가격', value: `₩${sess.reject_price.toLocaleString()}` });
if (sess.reject_delivery_type) rows.push({ label: '거절배송방식', value: sess.reject_delivery_type });
return (
<div key={sess.session_id} className={cn('p-3', isWinner && 'bg-success/10')}>
<div className="flex items-center gap-2">
{showAward && (
isCandidate ? (
<input
type="radio"
name="award-winner-mobile"
checked={selectedWinnerId === sess.supplier_id}
onChange={() => setSelectedWinnerId(sess.supplier_id)}
className="h-4 w-4 shrink-0 accent-success cursor-pointer"
/>
) : null
)}
<Link
to={`/partners?detail=${sess.supplier_id}`}
className={cn(typographyVariants({ variant: 'link' }), 'min-w-0 flex-1 truncate font-bold font-sans')}
>
{sess.supplier_name}
</Link>
{isRecommended && (
<Typography as="span" variant="caption" className="shrink-0 text-[9px] font-bold text-success">
추천
</Typography>
)}
<StatusPill tone={sessionStatusTone(sess.status)}>{sessionStatusLabel(sess.status)}</StatusPill>
</div>
{/* 액션 — 표의 아이콘 열들을 한 줄로 모은다 */}
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<button
onClick={() => onOpenChat(sess.session_id)}
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] text-primary cursor-pointer"
>
<MessageSquare size={12} /> 대화방
</button>
{sess.status === SessionStatus.DONE && (
<button
onClick={() => setExtraSession(sess)}
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] text-emerald-600 cursor-pointer"
>
<ClipboardList size={12} /> 부가정보
</button>
)}
{sess.url && (
<button
onClick={() => {
void navigator.clipboard?.writeText(sess.url);
showToast('협상 URL을 복사했습니다.', 'success');
}}
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] text-muted-foreground cursor-pointer"
>
<Copy size={12} /> URL
</button>
)}
<button
onClick={() => handleOne(sess.session_id, sess.supplier_name, !!sess.email_sent_at)}
disabled={!canNotify || sendingId === sess.session_id}
className={cn(
'inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed',
sess.email_sent_at ? 'text-success' : 'text-primary',
)}
>
{sess.email_sent_at ? <MailCheck size={12} /> : <Mail size={12} />}
{sess.email_sent_at ? '메일 재발송' : '메일 발송'}
</button>
</div>
<dl className="mt-2.5 space-y-1.5 border-t border-border/40 pt-2.5 text-[11px] leading-tight">
{rows.map((r) => (
<div key={r.label} className="flex items-start justify-between gap-3">
<dt className="shrink-0 text-muted-foreground">{r.label}</dt>
<dd className="min-w-0 text-right font-mono text-foreground">{r.value}</dd>
</div>
))}
</dl>
</div>
);
})}
</div>
{extraSession && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs" onClick={() => setExtraSession(null)}>
<div className="w-full max-w-sm bg-card border border-border rounded-lg shadow-2xl p-5 animate-scale-up" onClick={(e) => e.stopPropagation()}>

View File

@ -39,6 +39,7 @@ export function TargetPriceModal({
const CANDIDATE_SUB = candidateSub(`${label('target_margin')}`);
const { data: bd, isLoading } = useGetTargetBreakdown(sessionId, { query: { enabled: !!sessionId } });
const candidates = bd?.candidates ?? [];
const hiddenPrice = bd?.hidden_price_fields ?? []; // 회사 설정으로 감춰 후보에서 뺀 가격 필드
return (
<div
@ -81,10 +82,28 @@ export function TargetPriceModal({
<div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1">
<Typography as="p" variant="small" className="font-bold text-foreground text-[11px]">목표가 선정방식</Typography>
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">1. MD 입력값 존재 시, 최우선 적용</Typography>
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">2. 다음 중 가장 작은 값 — 인터넷최저가×(1−수수료) | 매입가 | 판매가×(1−{label('target_margin')})</Typography>
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
* 인터넷 평균 수수료: {bd.fee} · {label('target_margin')}: {bd.margin}{bd.is_new ? ' · 신규견적이라 인터넷최저가만 적용' : ''}
</Typography>
{/* 설명 문구는 회사 설정(목표가 모드·숨김 필드)에 따라 실제 후보와 일치하게 바꾼다. */}
{bd.target_price_mode === 'purchase' ? (
<>
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">2. 매입가 × (1−{label('target_margin')})</Typography>
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
* {label('target_margin')}: {bd.margin}
</Typography>
</>
) : (
<>
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">
2. 다음 중 가장 작은 값 — {[
!hiddenPrice.includes('internet_lowest_price') && '인터넷최저가×(1−수수료)',
!hiddenPrice.includes('purchase_price') && '매입가',
!hiddenPrice.includes('selling_price') && `판매가×(1−${label('target_margin')})`,
].filter(Boolean).join(' | ')}
</Typography>
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
* 인터넷 평균 수수료: {bd.fee} · {label('target_margin')}: {bd.margin}{bd.is_new ? ' · 신규견적이라 인터넷최저가만 적용' : ''}
</Typography>
</>
)}
{bd.is_inherited && (
<Typography as="p" variant="small" className="text-[10px] text-amber-600">
* 저장된 목표가와 일치하는 현재 후보 없음 — 재생성 상속 또는 산정 후 상품·세팅 변경(아래 후보는 현재값 기준 참고용)

View File

@ -1,6 +1,7 @@
import { type ReactNode } from 'react';
import { Clock, Building2, Link2, CornerDownRight } from 'lucide-react';
import { DataTable } from '@/components/ui/data-table';
import { useLabels } from '@/features/settings/useCompanySettings';
import { Typography, typographyVariants } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
import { useQuotationChain } from '../hooks/useQuotationChain';
@ -53,6 +54,7 @@ const outcomeBadgeClass = (state: ChainRoundState) => {
};
export function QuotationTable({ data, products, onOpenDetail, onFilterChain, footer, className }: QuotationTableProps) {
const label = useLabels(); // 회사 설정 용어
return (
<DataTable
className={className}
@ -63,7 +65,7 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo
footer={footer}
columns={[
{
header: '견적건명',
header: label('quotation.title'),
cell: (est) => {
const product = products.find((p) => p.id === est.productId);
const productName = product?.name ?? est.productName; // 목록에 없으면 서버 조인 상품명으로 폴백
@ -83,7 +85,7 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo
},
},
{
header: '견적번호',
header: label('quotation.number'),
cellClassName: 'font-mono text-muted-foreground',
cell: (est) =>
onFilterChain && est.number ? (
@ -104,7 +106,7 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo
),
},
{
header: '유형',
header: label('quotation.type'),
align: 'center',
// 유형은 고정 속성이라 pill 대신 평문 — 협상(1:1)만 살짝 진하게, 경매(1:N)는 연하게.
cell: (est) => (
@ -118,7 +120,7 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo
),
},
{
header: '차수',
header: label('quotation.round'),
align: 'center',
cell: (est) => (
<Typography as="span" variant="small" className="text-sm font-mono font-bold text-foreground">
@ -127,7 +129,7 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo
),
},
{
header: '견적상태',
header: label('quotation.status'),
align: 'center',
cell: (est) => (
<Typography
@ -140,7 +142,7 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo
),
},
{
header: '마감결과',
header: label('quotation.close_reason'),
align: 'center',
// 상세 마감사유는 상세 드로어에서만. 목록은 거친 결과(낙찰/재생성/결렬/진행중)만,
// 낙찰이면 배지에 '낙찰 - 낙찰사명' 한 줄로 붙인다.
@ -163,7 +165,7 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo
},
},
{
header: '마감기한',
header: label('quotation.due_date'),
cellClassName: 'font-mono text-muted-foreground whitespace-nowrap',
cell: (est) => (
<div className="flex items-center gap-1.5">
@ -180,7 +182,7 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo
),
},
{
header: '작성자',
header: label('creator'),
align: 'center',
cellClassName: 'text-muted-foreground whitespace-nowrap',
cell: (est) => (
@ -188,7 +190,7 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo
),
},
{
header: '협력사수',
header: label('quotation.supplier_count'),
align: 'center',
cellClassName: 'font-mono font-bold text-foreground',
cell: (est) => (

View File

@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { Palette, Tags, ListPlus, Plus, Trash2, RotateCcw, Download, Upload } from 'lucide-react';
import { showToast } from '@/lib/notify';
import { Button } from '@/components/ui/button';
@ -12,6 +13,7 @@ import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import {
LABEL_CATALOG,
HIDEABLE_ITEM_FIELDS,
CUSTOM_FIELD_TYPE_LABEL,
type CompanySettings,
type CustomFieldDef,
@ -19,9 +21,29 @@ import {
} from './catalog';
import { useCompanySettings } from './useCompanySettings';
export const SETTINGS_TABS = ['branding', 'labels', 'fields'] as const;
export type SettingsTab = (typeof SETTINGS_TABS)[number];
export const SETTINGS_TAB_LABEL: Record<SettingsTab, string> = {
branding: '브랜딩(CI)',
labels: '용어(라벨)',
fields: '커스텀 필드',
};
export function SettingsView() {
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 ?? '')
? (tabParam as SettingsTab)
: 'branding';
const setTab = (next: string) => {
const params = new URLSearchParams(searchParams);
params.set('tab', next);
setSearchParams(params, { replace: true });
};
// 저장 전 편집본(draft). 서버 반영은 저장 버튼에서만.
const [draft, setDraft] = useState<CompanySettings>({});
const [saving, setSaving] = useState(false);
@ -40,8 +62,10 @@ export function SettingsView() {
const itemFields = (draft.item_fields ?? []).filter((f) => f.key.trim() && f.label.trim());
const supplierFields = (draft.supplier_fields ?? []).filter((f) => f.key.trim() && f.label.trim());
const sessionFields = (draft.session_fields ?? []).filter((f) => f.key.trim() && f.label.trim());
const hiddenFields = [...new Set((draft.hidden_fields ?? []).filter((k) => k.trim()))];
const next: CompanySettings = {
...draft,
hidden_fields: hiddenFields,
labels,
branding,
item_fields: itemFields,
@ -98,7 +122,7 @@ export function SettingsView() {
return (
<div className="space-y-4 font-mono text-xs">
<Tabs defaultValue="branding">
<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">
@ -230,10 +254,20 @@ export function SettingsView() {
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-border bg-background">
{LABEL_CATALOG.map((entry) => {
{LABEL_CATALOG.map((entry, i) => {
const value = draft.labels?.[entry.key] ?? '';
const isGroupHead = i === 0 || LABEL_CATALOG[i - 1].group !== entry.group;
return (
<TableRow key={entry.key} className="hover:bg-muted/30">
<Fragment key={entry.key}>
{/* 그룹(상품·협력사·견적) 구분 행 — 용어가 40개 가까워 한 덩어리면 찾기 어렵다 */}
{isGroupHead && (
<TableRow className="bg-muted/40 hover:bg-muted/40">
<TableCell colSpan={3} className="p-2 font-bold text-muted-foreground">
{entry.group}
</TableCell>
</TableRow>
)}
<TableRow className="hover:bg-muted/30">
<TableCell className="p-2 font-bold text-foreground">{entry.base}</TableCell>
<TableCell className="p-2">
<Input
@ -250,6 +284,7 @@ export function SettingsView() {
)}
</TableCell>
</TableRow>
</Fragment>
);
})}
</TableBody>
@ -260,6 +295,47 @@ export function SettingsView() {
{/* ---- 커스텀 필드 ---- */}
<TabsContent value="fields" className="space-y-4">
<SectionCard
title="상품 필드 숨김"
desc="체크한 항목은 상품 목록·등록 폼·엑셀 양식에서 감춰집니다. DB 컬럼과 기존 값은 그대로 남습니다."
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2">
{HIDEABLE_ITEM_FIELDS.map((f) => {
const checked = (draft.hidden_fields ?? []).includes(f.key);
return (
<label key={f.key} className="flex items-start gap-2 py-1 cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={checked}
onChange={(e) =>
setDraft((d) => {
const cur = new Set(d.hidden_fields ?? []);
if (e.target.checked) cur.add(f.key);
else cur.delete(f.key);
return { ...d, hidden_fields: [...cur] };
})
}
/>
<span className="min-w-0">
<Typography as="span" variant="small" className="block font-semibold">
{f.label}
</Typography>
<Typography as="span" variant="caption" className="block text-muted-foreground">
{f.where}
</Typography>
{f.calcNote && (
<Typography as="span" variant="caption" className="block text-amber-600">
⚠ {f.calcNote}
</Typography>
)}
</span>
</label>
);
})}
</div>
</SectionCard>
<CustomFieldsEditor
title="상품 커스텀 필드"
desc="상품 등록·수정 화면에 추가로 입력받을 항목입니다. (예: 발주배수, 하도급 여부)"
@ -301,6 +377,9 @@ function mergeSettings(base: CompanySettings, incoming: CompanySettings): Compan
base.branding as Record<string, string> | undefined,
incoming.branding as Record<string, string> | undefined,
) as CompanySettings['branding'],
hidden_fields: Array.isArray(incoming.hidden_fields) && incoming.hidden_fields.length > 0
? incoming.hidden_fields
: (base.hidden_fields ?? []),
item_fields: pickFields(base.item_fields, incoming.item_fields),
supplier_fields: pickFields(base.supplier_fields, incoming.supplier_fields),
session_fields: pickFields(base.session_fields, incoming.session_fields),

View File

@ -21,25 +21,105 @@ export type CompanySettings = {
item_fields?: CustomFieldDef[]; // 상품 커스텀필드 정의 → items.custom
supplier_fields?: CustomFieldDef[]; // 협력사 커스텀필드 정의 → suppliers.custom
session_fields?: CustomFieldDef[]; // 협상완료 부가정보 정의 → sessions.custom (공급사가 타결 후 입력)
hidden_fields?: string[]; // 이 회사 화면에서 감출 상품 기본필드 키 (DB 컬럼·값은 그대로 둔다)
};
// 숨김 가능한 상품 기본필드. key 는 items 컬럼명이며 상품 목록·등록폼·엑셀 양식 세 곳에서 동시에 감춰진다.
// calcNote 가 있는 필드는 목표가 산정에 쓰이므로, 숨기면 그 산정 근거가 화면에서 사라진다는 경고를 띄운다.
export type HideableFieldEntry = {
key: string;
label: string;
where: string;
calcNote?: string;
};
export const HIDEABLE_ITEM_FIELDS: HideableFieldEntry[] = [
{ key: 'made_in', label: '원산지(제조 국가)', where: '상품 등록, 엑셀 양식' },
{ key: 'vat_yn', label: '부가세포함', where: '상품 등록, 엑셀 양식' },
{ key: 'delivery_fee_yn', label: '배송비포함', where: '상품 등록, 엑셀 양식' },
{ key: 'spec', label: '규격', where: '상품 등록, 엑셀 양식' },
{ key: 'manufacturer', label: '제조사', where: '상품 등록, 엑셀 양식' },
{ key: 'quantity_unit', label: '단위', where: '상품 등록, 엑셀 양식' },
{ key: 'moq', label: '최소주문수량', where: '상품 등록, 엑셀 양식' },
{ key: 'model_name', label: '모델명', where: '상품 등록, 엑셀 양식' },
{ key: 'image_url', label: '이미지URL', where: '상품 등록, 엑셀 양식' },
{
key: 'selling_price',
label: '판매가',
where: '상품 목록·등록, 엑셀 양식',
calcNote: '재견적·재협상 목표가 후보(판매가 × (1 − 네고율))',
},
{
key: 'purchase_price',
label: '매입가',
where: '상품 목록·등록, 엑셀 양식',
calcNote: '재견적·재협상 목표가 후보(그대로)',
},
{
key: 'internet_lowest_price',
label: '인터넷 최저가(최저한도)',
where: '상품 목록·등록, 엑셀 양식',
calcNote: '신규·재 모두의 목표가 후보(최저가 × (1 − 수수료율)) — 숨기면 신규 견적 산정 근거가 사라진다',
},
{
key: 'price',
label: '상품 단가(공급가)',
where: '상품 목록·등록, 엑셀 양식',
calcNote: '목표가 산식엔 안 쓰이나 상품 등록 필수값 — 숨기면 신규 등록 시 입력 경로가 사라진다',
},
];
export type LabelCatalogEntry = {
key: string;
base: string; // 기본(우리 솔루션) 용어
where: string; // 적용 위치 안내 (설정 화면 표시용)
group: '상품' | '협력사' | '견적·협상'; // 설정 화면 묶음
};
// 용어 카탈로그. base 가 fallback 이므로 배선된 화면은 설정이 비어 있어도 기존과 동일하게 보인다.
export const LABEL_CATALOG: LabelCatalogEntry[] = [
{ key: 'target_margin', base: '목표 마진율', where: '견적 세팅, 목표가 산정내역, 견적 생성' },
{ key: 'item.price', base: '상품 단가', where: '상품 목록·등록, 엑셀 양식' },
{ key: 'item.code', base: '상품코드', where: '상품 목록·등록, 엑셀 양식' },
{ key: 'item.model_name', base: '모델번호', where: '상품 등록, 엑셀 양식' },
{ key: 'category', base: '카테고리', where: '상품 목록·등록·필터, 통계' },
{ key: 'lead_time', base: '리드타임', where: '상품 등록, 엑셀 양식' },
{ key: 'delivery_type.1', base: '협력사배송', where: '배송유형 선택지 1' },
{ key: 'delivery_type.2', base: '지정택배배송', where: '배송유형 선택지 2' },
{ key: 'delivery_type.3', base: '픽업배송', where: '배송유형 선택지 3' },
// ── 상품
{ key: 'item.name', base: '상품명', where: '상품 목록·등록, 엑셀 양식', group: '상품' },
{ key: 'item.code', base: '상품코드', where: '상품 목록·등록, 엑셀 양식', group: '상품' },
{ key: 'item.model_name', base: '모델번호', where: '상품 등록, 엑셀 양식', group: '상품' },
{ key: 'category', base: '카테고리', where: '상품 목록·등록·필터, 통계', group: '상품' },
{ key: 'item.price', base: '상품 단가', where: '상품 목록·등록, 엑셀 양식', group: '상품' },
{ key: 'item.purchase_price', base: '매입가', where: '상품 등록, 엑셀 양식', group: '상품' },
{ key: 'item.selling_price', base: '판매가', where: '상품 등록, 엑셀 양식', group: '상품' },
{ key: 'item.internet_lowest_price', base: '인터넷 최저가', where: '상품 목록·등록, 엑셀 양식', group: '상품' },
{ key: 'item.spec', base: '상품 규격', where: '상품 등록, 엑셀 양식', group: '상품' },
{ key: 'item.manufacturer', base: '제조사', where: '상품 등록, 엑셀 양식', group: '상품' },
{ key: 'item.made_in', base: '제조 국가', where: '상품 등록, 엑셀 양식', group: '상품' },
{ key: 'item.quantity_unit', base: '취급 단위', where: '상품 등록, 엑셀 양식', group: '상품' },
{ key: 'item.moq', base: '최소 주문 수량', where: '상품 등록, 엑셀 양식', group: '상품' },
{ key: 'lead_time', base: '리드타임', where: '상품 등록, 엑셀 양식', group: '상품' },
{ key: 'item.suppliers', base: '공급사', where: '상품 목록(협력사 매핑)', group: '상품' },
{ key: 'item.delivery_type', base: '배송 형태', where: '상품 등록, 엑셀 양식', group: '상품' },
{ key: 'delivery_type.1', base: '협력사배송', where: '배송유형 선택지 1', group: '상품' },
{ key: 'delivery_type.2', base: '지정택배배송', where: '배송유형 선택지 2', group: '상품' },
{ key: 'delivery_type.3', base: '픽업배송', where: '배송유형 선택지 3', group: '상품' },
// ── 협력사
{ key: 'supplier.name', base: '협력사명', where: '협력사 목록·등록, 엑셀 양식', group: '협력사' },
{ key: 'supplier.code', base: '협력사코드', where: '협력사 목록·등록, 엑셀 양식', group: '협력사' },
{ key: 'supplier.manager_name', base: '담당자명', where: '협력사 목록·등록, 엑셀 양식', group: '협력사' },
{ key: 'supplier.manager_email', base: '담당자 이메일', where: '협력사 목록·등록, 엑셀 양식', group: '협력사' },
{ key: 'supplier.manager_contact', base: '담당자 연락처', where: '협력사 목록·등록, 엑셀 양식', group: '협력사' },
{ key: 'supplier.total_revenue', base: '총매출액', where: '협력사 목록·등록', group: '협력사' },
{ key: 'supplier.chat_account', base: '채팅 계정', where: '협력사 목록·등록', group: '협력사' },
{ key: 'supplier.items', base: '취급상품', where: '협력사 등록(상품 매핑)', group: '협력사' },
// ── 견적·협상
{ key: 'quotation.title', base: '견적건명', where: '견적 목록·생성', group: '견적·협상' },
{ key: 'quotation.number', base: '견적번호', where: '견적 목록·상세', group: '견적·협상' },
{ key: 'quotation.type', base: '유형', where: '견적 목록·생성', group: '견적·협상' },
{ key: 'quotation.round', base: '차수', where: '견적 목록·상세', group: '견적·협상' },
{ key: 'quotation.status', base: '견적상태', where: '견적 목록·상세', group: '견적·협상' },
{ key: 'quotation.close_reason', base: '마감결과', where: '견적 목록·상세', group: '견적·협상' },
{ key: 'quotation.due_date', base: '마감기한', where: '견적 목록·생성·상세', group: '견적·협상' },
{ key: 'quotation.supplier_count', base: '협력사수', where: '견적 목록', group: '견적·협상' },
{ key: 'target_margin', base: '목표 마진율', where: '견적 세팅, 목표가 산정내역, 견적 생성', group: '견적·협상' },
{ key: 'creator', base: '작성자', where: '상품·협력사·견적 목록', group: '견적·협상' },
];
export const LABEL_DEFAULTS: Record<string, string> = Object.fromEntries(

View File

@ -25,6 +25,13 @@ export function useLabels() {
return (key: string): string => overrides[key] || LABEL_DEFAULTS[key] || key;
}
// 숨김필드 헬퍼. isHidden('made_in') → 이 회사에서 감출 필드인지.
export function useHiddenFields() {
const { settings } = useCompanySettings();
const hidden = settings.hidden_fields ?? [];
return (key: string): boolean => hidden.includes(key);
}
// 브랜딩 헬퍼. 서비스명·로고 — 미설정 시 기본 브랜드(NegoData).
export function useBranding() {
const { settings } = useCompanySettings();

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { PageContainer } from '@/components/layout/PageContainer';
import { Typography } from '@/components/ui/typography';
import { DashboardHero, ScopeSection } from '@/features/dashboard';
@ -13,14 +13,28 @@ const ONBOARDING_SEEN_KEY = 'negodata_onboarding_seen';
export default function DashboardPage() {
const navigate = useNavigate();
const { data, isLoading, isError } = useGetDashboardSummary();
const [guideOpen, setGuideOpen] = useState(false);
// 이용안내는 ?guide=<탭> 으로 연다 — 링크·새로고침·메뉴 빠른이동에서 같은 탭으로 바로 열리게.
const [searchParams, setSearchParams] = useSearchParams();
const guideOpen = searchParams.has('guide');
const setGuideOpen = (open: boolean) => {
const params = new URLSearchParams(searchParams);
if (open) params.set('guide', params.get('guide') ?? 'flow');
else params.delete('guide');
setSearchParams(params, { replace: true });
};
// 첫 방문 시 1회 자동 노출(localStorage). 이후엔 상단 "이용안내" 버튼으로만 연다.
// 첫 방문 시 1회 자동 노출(localStorage). 이후엔 상단 "이용안내" 버튼이나 ?guide= 로 연다.
useEffect(() => {
if (!localStorage.getItem(ONBOARDING_SEEN_KEY)) {
setGuideOpen(true);
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.set('guide', 'flow');
return params;
}, { replace: true });
localStorage.setItem(ONBOARDING_SEEN_KEY, '1');
}
// 최초 1회만 — searchParams 변화에 반응하면 닫자마자 다시 열린다.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const openQuotation = (qtId: string) => navigate(`/quotation?detail=${qtId}`);

View File

@ -52,7 +52,10 @@ export default function MembersPage() {
return (
<PageContainer>
{/* 검색/액션 바 + 테이블을 한 카드로 붙인다(포털형). */}
<div className="overflow-hidden rounded-lg border border-border bg-card">
<PageToolbar
className="rounded-none border-0 border-b border-border"
actions={
<Button onClick={openCreate}>
<Plus />
@ -71,6 +74,7 @@ export default function MembersPage() {
</PageToolbar>
<MemberTable
className="rounded-none border-0"
data={members}
onRowClick={openEdit}
page={list.page}
@ -79,6 +83,7 @@ export default function MembersPage() {
pageSize={list.pageSize}
onPageChange={list.setPage}
/>
</div>
{isFormOpen && (
<MemberFormSheet

View File

@ -4,7 +4,7 @@ import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
import { PageContainer } from '@/components/layout/PageContainer';
import { useCompanySettings, useLabels } from '@/features/settings/useCompanySettings';
import { useCompanySettings, useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
@ -22,6 +22,7 @@ import { type Product } from '@/features/products/types';
export default function ProductsPage() {
const label = useLabels(); // 회사 설정 용어
const { settings } = useCompanySettings(); // 엑셀 양식 커스텀필드(item_fields)
const isHidden = useHiddenFields(); // 회사 설정으로 감춘 기본필드(양식에서 제외)
// 검색/카테고리/페이지 상태(재사용 훅) → 서버 쿼리 파라미터로 변환.
const list = useServerList({ pageSize: 10, initialFilters: { category: 'ALL' } });
const categoryFilter = list.filters.category;
@ -129,7 +130,7 @@ export default function ProductsPage() {
<Upload />
일괄 업로드
</DropdownMenuItem>
<DropdownMenuItem onClick={() => downloadProductTemplate(label, settings.item_fields ?? [])}>
<DropdownMenuItem onClick={() => downloadProductTemplate(label, settings.item_fields ?? [], isHidden)}>
<Download />
양식 다운로드
</DropdownMenuItem>

View File

@ -0,0 +1,75 @@
-- IMK 요구(TO-BE 상품 업로드 양식 / #14 발주배수 / #17 협력사 분류카테고리 / #20 공급사) 기준 데모 데이터.
-- 기본필드는 items 컬럼, TO-BE 신규 항목(발주배수·하도급·납품대금연동)은 items.custom,
-- 협력사 분류카테고리·유통레벨은 suppliers.custom 에 넣는다 — 전부 회사 설정(item_fields/supplier_fields) 정의와 같은 키.
-- 코드 접두사 IMK- 만 지우고 다시 넣으므로 멱등하게 재실행 가능.
DO $$
DECLARE
v_company uuid;
v_user uuid;
BEGIN
SELECT company_id INTO v_company FROM company.companies WHERE name = '아이마켓코리아';
SELECT user_id INTO v_user FROM company.users WHERE company_id = v_company AND id = 'admin';
DELETE FROM partner.supplier_items
WHERE item_id IN (SELECT item_id FROM partner.items WHERE company_id = v_company AND code LIKE 'IMK-%');
DELETE FROM partner.items WHERE company_id = v_company AND code LIKE 'IMK-%';
DELETE FROM partner.suppliers WHERE company_id = v_company AND code LIKE 'IMKS-%';
-- 협력사: 분류카테고리(제조원 성격) + 유통레벨을 custom 으로. (#17)
INSERT INTO partner.suppliers (company_id, user_id, name, code, manager_name, manager_email, manager_contact_number, custom)
VALUES
(v_company, v_user, '한솔제지', 'IMKS-001', '김제지', 'paper@example.com', '02-1000-0001', '{"sourcing_category":"복사용지","distribution_level":"제조"}'),
(v_company, v_user, '대한오피스', 'IMKS-002', '박사무', 'office@example.com', '02-1000-0002', '{"sourcing_category":"사무용품","distribution_level":"유통"}'),
(v_company, v_user, '세이프코리아', 'IMKS-003', '이안전', 'safe@example.com', '02-1000-0003', '{"sourcing_category":"안전보호구","distribution_level":"총판"}'),
(v_company, v_user, '한국툴스', 'IMKS-004', '최공구', 'tools@example.com', '02-1000-0004', '{"sourcing_category":"절삭공구","distribution_level":"제조"}'),
(v_company, v_user, '광명전기자재', 'IMKS-005', '정전기', 'elec@example.com', '02-1000-0005', '{"sourcing_category":"전기자재","distribution_level":"유통"}');
-- 상품: category = SG명, lead_time = 표준납기, delivery_type 1=직납 2=IMK물류(배송) 3=IMK물류(집배송).
-- custom: order_multiple(발주배수) / subcontract_yn(하도급) / price_linked_yn(납품대금연동). (#14, TO-BE)
INSERT INTO partner.items (
company_id, user_id, name, code, model_name, category, spec, manufacturer, made_in,
price, purchase_price, selling_price, internet_lowest_price, internet_lowest_price_yn,
moq, lead_time, quantity_unit, delivery_type, vat_yn, delivery_fee_yn, custom
) VALUES
(v_company, v_user, 'A4 복사용지 80g', 'IMK-P-001', 'HS-A4-80', '복사용지', '210×297mm 2500매/박스', '한솔제지', '대한민국',
23000, 21000, 25000, 22500, true, '10 BOX', 3, 'BOX', 1, true, false, '{"order_multiple":10,"subcontract_yn":false,"price_linked_yn":true}'),
(v_company, v_user, '레이저 토너 (검정)', 'IMK-P-002', 'CF280A', '사무용품', '2700매 표준용량', 'HP', '중국',
98000, 86000, 105000, 91000, true, '5 EA', 5, 'EA', 1, true, false, '{"order_multiple":5,"subcontract_yn":false,"price_linked_yn":false}'),
(v_company, v_user, '안전화 (경작업용)', 'IMK-P-003', 'SK-450', '안전보호구', '265mm KCS 인증', '세이프코리아', '대한민국',
78000, 69000, 85000, 74000, true, '20 EA', 7, 'EA', 2, true, true, '{"order_multiple":20,"subcontract_yn":true,"price_linked_yn":true}'),
(v_company, v_user, '니트릴 장갑 (100매)', 'IMK-P-004', 'NG-100', '안전보호구', 'M 사이즈 100매/박스', '세이프코리아', '말레이시아',
12500, 10800, 14000, 11900, true, '50 BOX', 4, 'BOX', 2, true, true, '{"order_multiple":50,"subcontract_yn":false,"price_linked_yn":false}'),
(v_company, v_user, '초경 엔드밀 4날', 'IMK-P-005', 'EM-4F-10', '절삭공구', 'Φ10 × 75mm', '한국툴스', '대한민국',
34000, 29000, 38000, 32000, true, '30 EA', 10, 'EA', 1, true, false, '{"order_multiple":30,"subcontract_yn":true,"price_linked_yn":true}'),
(v_company, v_user, 'LED 평판등 50W', 'IMK-P-006', 'LP-640-50', '전기자재', '640×640 주광색', '광명전기자재', '대한민국',
46000, 39500, 52000, 44000, true, '20 EA', 14, 'EA', 3, true, true, '{"order_multiple":20,"subcontract_yn":false,"price_linked_yn":true}'),
(v_company, v_user, '산업용 베어링 6204', 'IMK-P-007', '6204-2RS', '기계부품', '내경 20mm 밀폐형', 'NSK', '일본',
8900, 7200, 9800, 8300, true, '100 EA', 12, 'EA', 1, true, false, '{"order_multiple":100,"subcontract_yn":false,"price_linked_yn":false}'),
(v_company, v_user, '다목적 세정제 4L', 'IMK-P-008', 'CL-4000', '청소용품', '4L × 4통/박스', '대한오피스', '대한민국',
31000, 26500, 35000, 29800, true, '15 BOX', 5, 'BOX', 2, true, true, '{"order_multiple":15,"subcontract_yn":false,"price_linked_yn":false}');
-- 실적(계약) 공급사 매핑. supply_type 1=유통 2=제조 3=총판. (#20)
INSERT INTO partner.supplier_items (supplier_id, item_id, supply_type)
SELECT s.supplier_id, i.item_id, m.supply_type
FROM (VALUES
('IMK-P-001', 'IMKS-001', 2::smallint),
('IMK-P-001', 'IMKS-002', 1::smallint),
('IMK-P-002', 'IMKS-002', 1::smallint),
('IMK-P-003', 'IMKS-003', 3::smallint),
('IMK-P-004', 'IMKS-003', 3::smallint),
('IMK-P-005', 'IMKS-004', 2::smallint),
('IMK-P-006', 'IMKS-005', 1::smallint),
('IMK-P-007', 'IMKS-004', 2::smallint),
('IMK-P-008', 'IMKS-002', 1::smallint)
) AS m(item_code, supplier_code, supply_type)
JOIN partner.items i ON i.code = m.item_code AND i.company_id = v_company
JOIN partner.suppliers s ON s.code = m.supplier_code AND s.company_id = v_company;
-- 협력사 커스텀필드 정의(분류카테고리·유통레벨)를 회사 설정에 등록 — 값만 있고 정의가 없으면 화면에 안 뜬다.
UPDATE company.companies
SET settings = settings || jsonb_build_object('supplier_fields', '[
{"key":"sourcing_category","type":"text","label":"분류카테고리"},
{"key":"distribution_level","type":"text","label":"유통레벨"}]'::jsonb)
WHERE company_id = v_company;
END $$;