[feat] negodata: 회사 커스터마이징(설정·라벨·커스텀필드·엑셀·상품공급사#20·분류카테고리#17)·일괄삭제·로그export·구분·카드 전체선택·부가정보 표시
This commit is contained in:
parent
1e4726f4cc
commit
88edcfed63
@ -5,7 +5,7 @@ from sqlalchemy import select, func, and_, or_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import items, users
|
||||
from common.database.model.models import items, users, supplier_items, suppliers
|
||||
from common.enums import ErrorType
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
@ -49,6 +49,10 @@ class IItemCRUD(ABC):
|
||||
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def supplier_name_map(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]:
|
||||
pass
|
||||
|
||||
|
||||
class ItemCRUD(IItemCRUD):
|
||||
async def search(
|
||||
@ -184,3 +188,29 @@ class ItemCRUD(IItemCRUD):
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, {}
|
||||
|
||||
async def supplier_name_map(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]:
|
||||
"""item_id 목록 → {item_id: [공급사명...]}. 상품 목록 '공급사' 컬럼용(supplier_items→suppliers 배치 조인)."""
|
||||
try:
|
||||
if not item_ids:
|
||||
return ErrorType.SUCCESS, {}
|
||||
query = (
|
||||
select(supplier_items.item_id, suppliers.name)
|
||||
.join(suppliers, suppliers.supplier_id == supplier_items.supplier_id)
|
||||
.where(
|
||||
supplier_items.item_id.in_(item_ids),
|
||||
supplier_items.deleted == False, # noqa: E712
|
||||
suppliers.deleted == False, # noqa: E712
|
||||
)
|
||||
.order_by(suppliers.name)
|
||||
)
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, {}
|
||||
out: dict = {}
|
||||
for item_id, name in rows:
|
||||
out.setdefault(item_id, []).append(name)
|
||||
return ErrorType.SUCCESS, out
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, {}
|
||||
|
||||
@ -5,7 +5,7 @@ from sqlalchemy import select, func, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import supplier_items, items
|
||||
from common.database.model.models import supplier_items, items, suppliers
|
||||
from common.enums import ErrorType
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
@ -48,7 +48,8 @@ class ISupplierItemCRUD(ABC):
|
||||
|
||||
class SupplierItemCRUD(ISupplierItemCRUD):
|
||||
async def list_by_supplier(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, list]:
|
||||
# 협력사 상세용: 매핑 + 상품명/코드 조인. Row(supplier_item_id, item_id, name, code, supply_type)
|
||||
# 협력사 상세용: 매핑 + 상품명/코드/카테고리/제조사 조인.
|
||||
# Row(supplier_item_id, item_id, name, code, supply_type, category, manufacturer)
|
||||
try:
|
||||
query = (
|
||||
select(
|
||||
@ -57,6 +58,8 @@ class SupplierItemCRUD(ISupplierItemCRUD):
|
||||
items.name,
|
||||
items.code,
|
||||
supplier_items.supply_type,
|
||||
items.category,
|
||||
items.manufacturer,
|
||||
)
|
||||
.join(items, items.item_id == supplier_items.item_id)
|
||||
.where(
|
||||
@ -75,11 +78,22 @@ class SupplierItemCRUD(ISupplierItemCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, []
|
||||
|
||||
async def list_by_item(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, list]:
|
||||
# 견적생성 모달용: 이 상품을 취급하는 협력사별 공급유형. Row(supplier_id, supply_type)
|
||||
# 견적생성 모달·상품 상세 공용: 이 상품을 취급하는 협력사별 공급유형.
|
||||
# Row(supplier_id, supply_type, name, supplier_item_id)
|
||||
try:
|
||||
query = select(supplier_items.supplier_id, supplier_items.supply_type).where(
|
||||
supplier_items.item_id == item_id,
|
||||
supplier_items.deleted == False, # noqa: E712
|
||||
query = (
|
||||
select(
|
||||
supplier_items.supplier_id,
|
||||
supplier_items.supply_type,
|
||||
suppliers.name,
|
||||
supplier_items.supplier_item_id,
|
||||
)
|
||||
.join(suppliers, suppliers.supplier_id == supplier_items.supplier_id)
|
||||
.where(
|
||||
supplier_items.item_id == item_id,
|
||||
supplier_items.deleted == False, # noqa: E712
|
||||
suppliers.deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
|
||||
@ -35,6 +35,10 @@ class IUserCRUD(ABC):
|
||||
async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update_company_settings(self, cdb: AsyncSession, company_id, settings: dict) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def list_by_company(self, cdb: AsyncSession, company_id, search, skip, limit) -> Tuple[ErrorType, list, int]:
|
||||
pass
|
||||
@ -103,6 +107,18 @@ class UserCRUD(IUserCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
async def update_company_settings(self, cdb: AsyncSession, company_id, settings: dict) -> ErrorType:
|
||||
try:
|
||||
query = (
|
||||
update(companies)
|
||||
.where(companies.company_id == company_id, companies.deleted == False) # noqa: E712
|
||||
.values(settings=settings, updated_at=GTime.UTC())
|
||||
)
|
||||
return await DB_SESSION_MNG.add(cdb, query)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
async def list_by_company(
|
||||
self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int
|
||||
) -> Tuple[ErrorType, list, int]:
|
||||
|
||||
@ -12,6 +12,7 @@ from config.server_configs import web_server_config
|
||||
from scheduler import shutdown_scheduler, start_scheduler
|
||||
import router.v1.auth.account
|
||||
import router.v1.company.user
|
||||
import router.v1.company.settings
|
||||
import router.v1.item.item
|
||||
import router.v1.supplier.supplier
|
||||
import router.v1.supplier_item.supplier_item
|
||||
@ -68,6 +69,7 @@ async def healthz():
|
||||
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
|
||||
app.include_router(router.v1.auth.account.router)
|
||||
app.include_router(router.v1.company.user.router)
|
||||
app.include_router(router.v1.company.settings.router)
|
||||
app.include_router(router.v1.item.item.router)
|
||||
app.include_router(router.v1.supplier.supplier.router)
|
||||
app.include_router(router.v1.supplier_item.supplier_item.router)
|
||||
|
||||
@ -56,3 +56,16 @@ class Res_CompanyUserList(Res_PageProtocol):
|
||||
|
||||
class Res_DeleteCompanyUser(Res_WebPacketProtocol):
|
||||
pass
|
||||
|
||||
|
||||
class CompanySettingsProtocol(WebPacketProtocol):
|
||||
pass
|
||||
|
||||
|
||||
class Req_UpdateCompanySettings(CompanySettingsProtocol):
|
||||
# settings 전체 치환. 서브키: branding/labels/features/item_fields/supplier_fields/session_fields
|
||||
settings: dict = {}
|
||||
|
||||
|
||||
class Res_CompanySettings(Res_WebPacketProtocol):
|
||||
settings: Optional[dict] = None
|
||||
|
||||
21
negodata/backend/router/v1/company/settings.py
Normal file
21
negodata/backend/router/v1/company/settings.py
Normal file
@ -0,0 +1,21 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from common.models.gmodel import UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, RequireOwner
|
||||
from services.company_settings_service import CompanySettingsService
|
||||
from .protocol import Req_UpdateCompanySettings, Res_CompanySettings
|
||||
|
||||
# 회사별 커스터마이징 설정. 조회=로그인 유저 전원(앱 부팅 시 브랜딩/라벨 로드), 수정=최고관리자(OWNER) 전용.
|
||||
router = APIRouter(prefix="/v1/company/settings", tags=["CompanySettings"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
@router.get(path="", response_model=Res_CompanySettings, summary="회사 커스터마이징 설정 조회")
|
||||
async def get_settings(service: CompanySettingsService = Depends(), user: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.get_settings(user.company_id))
|
||||
|
||||
|
||||
@router.put(path="/update", response_model=Res_CompanySettings, summary="회사 커스터마이징 설정 수정(최고관리자)")
|
||||
async def update_settings(
|
||||
req: Req_UpdateCompanySettings, service: CompanySettingsService = Depends(), owner: UserInfo = Depends(RequireOwner)
|
||||
):
|
||||
return RemoveNoneResponse(await service.update_settings(owner.company_id, req))
|
||||
@ -33,6 +33,7 @@ class Req_CreateItem(ItemProtocol):
|
||||
delivery_type: Optional[int] = None
|
||||
vat_yn: Optional[bool] = None
|
||||
delivery_fee_yn: Optional[bool] = None
|
||||
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value} (정의는 companies.settings.item_fields)
|
||||
|
||||
|
||||
class Req_UpdateItem(ItemProtocol):
|
||||
@ -56,6 +57,7 @@ class Req_UpdateItem(ItemProtocol):
|
||||
delivery_type: Optional[int] = None
|
||||
vat_yn: Optional[bool] = None
|
||||
delivery_fee_yn: Optional[bool] = None
|
||||
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value}
|
||||
|
||||
|
||||
class ItemData(WebPacketProtocol):
|
||||
@ -85,6 +87,8 @@ class ItemData(WebPacketProtocol):
|
||||
delivery_type: Optional[DeliveryType] = None
|
||||
vat_yn: Optional[bool] = None
|
||||
delivery_fee_yn: Optional[bool] = None
|
||||
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value}
|
||||
supplier_names: list[str] = [] # 이 상품을 취급(계약)하는 공급사명 목록(목록 컬럼용, 배치 조인)
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
@ -111,6 +111,7 @@ class SessionData(WebPacketProtocol):
|
||||
reject_price: Optional[int] = None
|
||||
reject_delivery_type: Optional[DeliveryType] = None
|
||||
email_sent_at: Optional[datetime] = None # 협상 초청 메일 발송 시각(None=미발송). 프론트 발송배지/재발송 판단
|
||||
custom: Optional[dict] = None # 협상완료 부가정보 값 {key: value} (공급사가 타결 후 입력, 정의는 companies.settings.session_fields)
|
||||
url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성
|
||||
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ class Req_CreateSupplier(SupplierProtocol):
|
||||
manager_email: Optional[str] = None
|
||||
manager_contact_number: Optional[str] = None
|
||||
total_revenue: Optional[int] = None # 총매출액(원)
|
||||
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value} (정의는 companies.settings.supplier_fields)
|
||||
|
||||
|
||||
class Req_UpdateSupplier(SupplierProtocol):
|
||||
@ -27,6 +28,7 @@ class Req_UpdateSupplier(SupplierProtocol):
|
||||
manager_email: Optional[str] = None
|
||||
manager_contact_number: Optional[str] = None
|
||||
total_revenue: Optional[int] = None
|
||||
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value}
|
||||
|
||||
|
||||
class SupplierData(WebPacketProtocol):
|
||||
@ -42,6 +44,7 @@ class SupplierData(WebPacketProtocol):
|
||||
manager_email: Optional[str] = None
|
||||
manager_contact_number: Optional[str] = None
|
||||
total_revenue: Optional[int] = None # 총매출액(원)
|
||||
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value}
|
||||
account_login_id: Optional[str] = None # 채팅(협상) 계정 로그인 ID. None=미발급
|
||||
account_status: Optional[int] = None # SupplierUserStatus. None=미발급
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
@ -33,6 +33,8 @@ class SupplierItemData(WebPacketProtocol):
|
||||
item_name: str
|
||||
item_code: Optional[str] = None
|
||||
supply_type: int
|
||||
item_category: Optional[str] = None
|
||||
item_manufacturer: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
@ -40,6 +42,8 @@ class SupplierItemData(WebPacketProtocol):
|
||||
class ItemSupplyType(WebPacketProtocol):
|
||||
supplier_id: uuid.UUID
|
||||
supply_type: int
|
||||
supplier_name: Optional[str] = None
|
||||
supplier_item_id: Optional[uuid.UUID] = None # 상품측 매핑 편집(수정/삭제)용
|
||||
|
||||
|
||||
class Res_SupplierItem(Res_WebPacketProtocol):
|
||||
|
||||
45
negodata/backend/services/company_settings_service.py
Normal file
45
negodata/backend/services/company_settings_service.py
Normal file
@ -0,0 +1,45 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import companies
|
||||
from common.enums import DBWRType, ErrorType
|
||||
from crud.user_crud import IUserCRUD, UserCRUD
|
||||
from router.v1.company.protocol import Req_UpdateCompanySettings, Res_CompanySettings
|
||||
|
||||
|
||||
class CompanySettingsService:
|
||||
"""회사별 커스터마이징 설정(companies.settings JSONB) 조회/수정.
|
||||
|
||||
- 조회는 로그인 유저 전원(브랜딩/라벨을 앱 부팅 시 로드), 수정은 라우터에서 RequireOwner 로 게이트.
|
||||
- company_id 는 토큰값만 쓴다 → 남의 회사 설정 접근 불가.
|
||||
"""
|
||||
|
||||
def __init__(self, user_crud: IUserCRUD = Depends(UserCRUD)):
|
||||
self.user_crud = user_crud
|
||||
|
||||
async def get_settings(self, company_id: str) -> Res_CompanySettings:
|
||||
res = Res_CompanySettings()
|
||||
err_type, company = await DB_SESSION_MNG.execute_lambda(
|
||||
companies.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.user_crud.get_company(s, uuid.UUID(company_id)),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS or company is None:
|
||||
res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.ACCOUNT_NOT_FOUND)
|
||||
return res
|
||||
res.settings = company.settings
|
||||
return res
|
||||
|
||||
async def update_settings(self, company_id: str, req: Req_UpdateCompanySettings) -> Res_CompanySettings:
|
||||
res = Res_CompanySettings()
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[companies.DBType()],
|
||||
[lambda s: self.user_crud.update_company_settings(s, uuid.UUID(company_id), req.settings)],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
res.settings = req.settings
|
||||
return res
|
||||
@ -67,6 +67,16 @@ class ItemService:
|
||||
if nm_err == ErrorType.SUCCESS:
|
||||
for d in res.items:
|
||||
d.creator_name = name_map.get(d.user_id)
|
||||
# 공급사명 배치 조인 — 페이지 상품의 item_id를 모아 IN 쿼리 1회로 {item_id:[공급사명]} 맵을 만들어 매핑.
|
||||
item_ids = [r.item_id for r in rows]
|
||||
if item_ids:
|
||||
sn_err, supplier_map = await DB_SESSION_MNG.execute_lambda(
|
||||
items.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.item_crud.supplier_name_map(s, item_ids),
|
||||
)
|
||||
if sn_err == ErrorType.SUCCESS:
|
||||
for d in res.items:
|
||||
d.supplier_names = supplier_map.get(d.item_id, [])
|
||||
res.total = total
|
||||
return res
|
||||
|
||||
|
||||
@ -877,6 +877,7 @@ class QuotationService:
|
||||
reject_price=r.reject_price,
|
||||
reject_delivery_type=r.reject_delivery_type,
|
||||
email_sent_at=r.email_sent_at,
|
||||
custom=r.custom,
|
||||
url=self._session_chat_url(r.session_id),
|
||||
)
|
||||
for r in rows
|
||||
|
||||
@ -85,10 +85,11 @@ class SupplierItemService:
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# Row(supplier_item_id, item_id, name, code, supply_type)
|
||||
# Row(supplier_item_id, item_id, name, code, supply_type, category, manufacturer)
|
||||
res.supplier_items = [
|
||||
SupplierItemData(
|
||||
supplier_item_id=r[0], item_id=r[1], item_name=r[2], item_code=r[3], supply_type=r[4]
|
||||
supplier_item_id=r[0], item_id=r[1], item_name=r[2], item_code=r[3], supply_type=r[4],
|
||||
item_category=r[5], item_manufacturer=r[6],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
@ -109,8 +110,10 @@ class SupplierItemService:
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# Row(supplier_id, supply_type)
|
||||
res.suppliers = [ItemSupplyType(supplier_id=r[0], supply_type=r[1]) for r in rows]
|
||||
# Row(supplier_id, supply_type, name, supplier_item_id)
|
||||
res.suppliers = [
|
||||
ItemSupplyType(supplier_id=r[0], supply_type=r[1], supplier_name=r[2], supplier_item_id=r[3]) for r in rows
|
||||
]
|
||||
return res
|
||||
|
||||
async def create(self, company_id: str, req: Req_CreateSupplierItem) -> Res_SupplierItem:
|
||||
|
||||
@ -170,6 +170,7 @@ class SupplierService:
|
||||
manager_email=req.manager_email,
|
||||
manager_contact_number=req.manager_contact_number,
|
||||
total_revenue=req.total_revenue,
|
||||
custom=req.custom,
|
||||
)
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[suppliers.DBType()],
|
||||
|
||||
@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import {
|
||||
useMutation,
|
||||
useQuery
|
||||
} from '@tanstack/react-query';
|
||||
import type {
|
||||
DataTag,
|
||||
DefinedInitialDataOptions,
|
||||
DefinedUseQueryResult,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UndefinedInitialDataOptions,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import type {
|
||||
HTTPValidationError,
|
||||
ReqUpdateCompanySettings,
|
||||
ResCompanySettings
|
||||
} from '.././model';
|
||||
|
||||
import { customFetch } from '../../mutator/custom-fetch';
|
||||
|
||||
|
||||
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @summary 회사 커스터마이징 설정 조회
|
||||
*/
|
||||
export const getSettings = (
|
||||
|
||||
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customFetch<ResCompanySettings>(
|
||||
{url: `/v1/company/settings`, method: 'GET', signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export const getGetSettingsQueryKey = () => {
|
||||
return [
|
||||
`/v1/company/settings`
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
export const getGetSettingsQueryOptions = <TData = Awaited<ReturnType<typeof getSettings>>, TError = void>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSettings>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
) => {
|
||||
|
||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetSettingsQueryKey();
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getSettings>>> = ({ signal }) => getSettings(requestOptions, signal);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getSettings>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
}
|
||||
|
||||
export type GetSettingsQueryResult = NonNullable<Awaited<ReturnType<typeof getSettings>>>
|
||||
export type GetSettingsQueryError = void
|
||||
|
||||
|
||||
export function useGetSettings<TData = Awaited<ReturnType<typeof getSettings>>, TError = void>(
|
||||
options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSettings>>, TError, TData>> & Pick<
|
||||
DefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getSettings>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getSettings>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetSettings<TData = Awaited<ReturnType<typeof getSettings>>, TError = void>(
|
||||
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSettings>>, TError, TData>> & Pick<
|
||||
UndefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getSettings>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getSettings>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetSettings<TData = Awaited<ReturnType<typeof getSettings>>, TError = void>(
|
||||
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSettings>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
/**
|
||||
* @summary 회사 커스터마이징 설정 조회
|
||||
*/
|
||||
|
||||
export function useGetSettings<TData = Awaited<ReturnType<typeof getSettings>>, TError = void>(
|
||||
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSettings>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||
|
||||
const queryOptions = getGetSettingsQueryOptions(options)
|
||||
|
||||
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
|
||||
query.queryKey = queryOptions.queryKey ;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @summary 회사 커스터마이징 설정 수정(최고관리자)
|
||||
*/
|
||||
export const updateSettings = (
|
||||
reqUpdateCompanySettings: ReqUpdateCompanySettings,
|
||||
options?: SecondParameter<typeof customFetch>,) => {
|
||||
|
||||
|
||||
return customFetch<ResCompanySettings>(
|
||||
{url: `/v1/company/settings/update`, method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json', },
|
||||
data: reqUpdateCompanySettings
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const getUpdateSettingsMutationOptions = <TError = void | HTTPValidationError,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateSettings>>, TError,{data: ReqUpdateCompanySettings}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof updateSettings>>, TError,{data: ReqUpdateCompanySettings}, TContext> => {
|
||||
|
||||
const mutationKey = ['updateSettings'];
|
||||
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||
options
|
||||
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||
: {mutation: { mutationKey, }, request: undefined};
|
||||
|
||||
|
||||
|
||||
|
||||
const mutationFn: MutationFunction<Awaited<ReturnType<typeof updateSettings>>, {data: ReqUpdateCompanySettings}> = (props) => {
|
||||
const {data} = props ?? {};
|
||||
|
||||
return updateSettings(data,requestOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type UpdateSettingsMutationResult = NonNullable<Awaited<ReturnType<typeof updateSettings>>>
|
||||
export type UpdateSettingsMutationBody = ReqUpdateCompanySettings
|
||||
export type UpdateSettingsMutationError = void | HTTPValidationError
|
||||
|
||||
/**
|
||||
* @summary 회사 커스터마이징 설정 수정(최고관리자)
|
||||
*/
|
||||
export const useUpdateSettings = <TError = void | HTTPValidationError,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateSettings>>, TError,{data: ReqUpdateCompanySettings}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateSettings>>,
|
||||
TError,
|
||||
{data: ReqUpdateCompanySettings},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
const mutationOptions = getUpdateSettingsMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
|
||||
@ -55,6 +55,8 @@ export * from './itemDataCategory';
|
||||
export * from './itemDataCode';
|
||||
export * from './itemDataCreatedAt';
|
||||
export * from './itemDataCreatorName';
|
||||
export * from './itemDataCustom';
|
||||
export * from './itemDataCustomAnyOf';
|
||||
export * from './itemDataDeliveryFeeYn';
|
||||
export * from './itemDataDeliveryType';
|
||||
export * from './itemDataImageUrl';
|
||||
@ -72,6 +74,8 @@ export * from './itemDataSpec';
|
||||
export * from './itemDataUpdatedAt';
|
||||
export * from './itemDataVatYn';
|
||||
export * from './itemSupplyType';
|
||||
export * from './itemSupplyTypeSupplierItemId';
|
||||
export * from './itemSupplyTypeSupplierName';
|
||||
export * from './listCardsParams';
|
||||
export * from './listItemsParams';
|
||||
export * from './listNotificationsParams';
|
||||
@ -139,6 +143,8 @@ export * from './reqCreateCompanyUser';
|
||||
export * from './reqCreateItem';
|
||||
export * from './reqCreateItemCategory';
|
||||
export * from './reqCreateItemCode';
|
||||
export * from './reqCreateItemCustom';
|
||||
export * from './reqCreateItemCustomAnyOf';
|
||||
export * from './reqCreateItemDeliveryFeeYn';
|
||||
export * from './reqCreateItemDeliveryType';
|
||||
export * from './reqCreateItemImageUrl';
|
||||
@ -168,6 +174,8 @@ export * from './reqCreateQuotationVersionId';
|
||||
export * from './reqCreateSupplier';
|
||||
export * from './reqCreateSupplierAccount';
|
||||
export * from './reqCreateSupplierCode';
|
||||
export * from './reqCreateSupplierCustom';
|
||||
export * from './reqCreateSupplierCustomAnyOf';
|
||||
export * from './reqCreateSupplierItem';
|
||||
export * from './reqCreateSupplierManagerContactNumber';
|
||||
export * from './reqCreateSupplierManagerEmail';
|
||||
@ -186,6 +194,8 @@ export * from './reqUpdateCardNumber';
|
||||
export * from './reqUpdateCardScript';
|
||||
export * from './reqUpdateCardStatus';
|
||||
export * from './reqUpdateCardUsageType';
|
||||
export * from './reqUpdateCompanySettings';
|
||||
export * from './reqUpdateCompanySettingsSettings';
|
||||
export * from './reqUpdateCompanyUser';
|
||||
export * from './reqUpdateCompanyUserContactNumber';
|
||||
export * from './reqUpdateCompanyUserEmail';
|
||||
@ -196,6 +206,8 @@ export * from './reqUpdateItem';
|
||||
export * from './reqUpdateItemCategory';
|
||||
export * from './reqUpdateItemCategoryType';
|
||||
export * from './reqUpdateItemCode';
|
||||
export * from './reqUpdateItemCustom';
|
||||
export * from './reqUpdateItemCustomAnyOf';
|
||||
export * from './reqUpdateItemDeliveryFeeYn';
|
||||
export * from './reqUpdateItemDeliveryType';
|
||||
export * from './reqUpdateItemImageUrl';
|
||||
@ -224,6 +236,8 @@ export * from './reqUpdateQuotationSettingTargetMarginRate';
|
||||
export * from './reqUpdateSupplier';
|
||||
export * from './reqUpdateSupplierAccountStatus';
|
||||
export * from './reqUpdateSupplierCode';
|
||||
export * from './reqUpdateSupplierCustom';
|
||||
export * from './reqUpdateSupplierCustomAnyOf';
|
||||
export * from './reqUpdateSupplierManagerContactNumber';
|
||||
export * from './reqUpdateSupplierManagerEmail';
|
||||
export * from './reqUpdateSupplierManagerName';
|
||||
@ -239,6 +253,10 @@ export * from './resCardListMsg';
|
||||
export * from './resCardMsg';
|
||||
export * from './resCheckCodes';
|
||||
export * from './resCheckCodesMsg';
|
||||
export * from './resCompanySettings';
|
||||
export * from './resCompanySettingsMsg';
|
||||
export * from './resCompanySettingsSettings';
|
||||
export * from './resCompanySettingsSettingsAnyOf';
|
||||
export * from './resCompanyUser';
|
||||
export * from './resCompanyUserList';
|
||||
export * from './resCompanyUserListMsg';
|
||||
@ -361,6 +379,8 @@ export * from './sessionData';
|
||||
export * from './sessionDataAnchoringPrice';
|
||||
export * from './sessionDataBidAt';
|
||||
export * from './sessionDataBidPrice';
|
||||
export * from './sessionDataCustom';
|
||||
export * from './sessionDataCustomAnyOf';
|
||||
export * from './sessionDataEmailSentAt';
|
||||
export * from './sessionDataRejectDeliveryType';
|
||||
export * from './sessionDataRejectPrice';
|
||||
@ -369,6 +389,7 @@ export * from './sessionStatus';
|
||||
export * from './statCardUsage';
|
||||
export * from './statCategory';
|
||||
export * from './statKpi';
|
||||
export * from './statMarkupPoint';
|
||||
export * from './statMonthPoint';
|
||||
export * from './statOutcome';
|
||||
export * from './statParticipation';
|
||||
@ -382,6 +403,8 @@ export * from './supplierDataAccountStatus';
|
||||
export * from './supplierDataCode';
|
||||
export * from './supplierDataCreatedAt';
|
||||
export * from './supplierDataCreatorName';
|
||||
export * from './supplierDataCustom';
|
||||
export * from './supplierDataCustomAnyOf';
|
||||
export * from './supplierDataManagerContactNumber';
|
||||
export * from './supplierDataManagerEmail';
|
||||
export * from './supplierDataManagerName';
|
||||
@ -389,7 +412,9 @@ export * from './supplierDataTotalRevenue';
|
||||
export * from './supplierDataUpdatedAt';
|
||||
export * from './supplierItemData';
|
||||
export * from './supplierItemDataCreatedAt';
|
||||
export * from './supplierItemDataItemCategory';
|
||||
export * from './supplierItemDataItemCode';
|
||||
export * from './supplierItemDataItemManufacturer';
|
||||
export * from './supplierItemDataUpdatedAt';
|
||||
export * from './targetCandidate';
|
||||
export * from './userRole';
|
||||
|
||||
@ -22,6 +22,7 @@ import type { ItemDataQuantityUnit } from './itemDataQuantityUnit';
|
||||
import type { ItemDataDeliveryType } from './itemDataDeliveryType';
|
||||
import type { ItemDataVatYn } from './itemDataVatYn';
|
||||
import type { ItemDataDeliveryFeeYn } from './itemDataDeliveryFeeYn';
|
||||
import type { ItemDataCustom } from './itemDataCustom';
|
||||
import type { ItemDataCreatedAt } from './itemDataCreatedAt';
|
||||
import type { ItemDataUpdatedAt } from './itemDataUpdatedAt';
|
||||
|
||||
@ -50,6 +51,8 @@ export interface ItemData {
|
||||
delivery_type?: ItemDataDeliveryType;
|
||||
vat_yn?: ItemDataVatYn;
|
||||
delivery_fee_yn?: ItemDataDeliveryFeeYn;
|
||||
custom?: ItemDataCustom;
|
||||
supplier_names?: string[];
|
||||
created_at?: ItemDataCreatedAt;
|
||||
updated_at?: ItemDataUpdatedAt;
|
||||
}
|
||||
|
||||
9
negodata/front/src/api/generated/model/itemDataCustom.ts
Normal file
9
negodata/front/src/api/generated/model/itemDataCustom.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ItemDataCustomAnyOf } from './itemDataCustomAnyOf';
|
||||
|
||||
export type ItemDataCustom = ItemDataCustomAnyOf | null;
|
||||
@ -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 ItemDataCustomAnyOf = { [key: string]: unknown };
|
||||
@ -4,8 +4,12 @@
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ItemSupplyTypeSupplierName } from './itemSupplyTypeSupplierName';
|
||||
import type { ItemSupplyTypeSupplierItemId } from './itemSupplyTypeSupplierItemId';
|
||||
|
||||
export interface ItemSupplyType {
|
||||
supplier_id: string;
|
||||
supply_type: number;
|
||||
supplier_name?: ItemSupplyTypeSupplierName;
|
||||
supplier_item_id?: ItemSupplyTypeSupplierItemId;
|
||||
}
|
||||
|
||||
@ -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 ItemSupplyTypeSupplierItemId = string | null;
|
||||
@ -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 ItemSupplyTypeSupplierName = string | null;
|
||||
@ -21,6 +21,7 @@ import type { ReqCreateItemQuantityUnit } from './reqCreateItemQuantityUnit';
|
||||
import type { ReqCreateItemDeliveryType } from './reqCreateItemDeliveryType';
|
||||
import type { ReqCreateItemVatYn } from './reqCreateItemVatYn';
|
||||
import type { ReqCreateItemDeliveryFeeYn } from './reqCreateItemDeliveryFeeYn';
|
||||
import type { ReqCreateItemCustom } from './reqCreateItemCustom';
|
||||
|
||||
export interface ReqCreateItem {
|
||||
name?: string;
|
||||
@ -43,4 +44,5 @@ export interface ReqCreateItem {
|
||||
delivery_type?: ReqCreateItemDeliveryType;
|
||||
vat_yn?: ReqCreateItemVatYn;
|
||||
delivery_fee_yn?: ReqCreateItemDeliveryFeeYn;
|
||||
custom?: ReqCreateItemCustom;
|
||||
}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ReqCreateItemCustomAnyOf } from './reqCreateItemCustomAnyOf';
|
||||
|
||||
export type ReqCreateItemCustom = ReqCreateItemCustomAnyOf | null;
|
||||
@ -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 ReqCreateItemCustomAnyOf = { [key: string]: unknown };
|
||||
@ -9,6 +9,7 @@ import type { ReqCreateSupplierManagerName } from './reqCreateSupplierManagerNam
|
||||
import type { ReqCreateSupplierManagerEmail } from './reqCreateSupplierManagerEmail';
|
||||
import type { ReqCreateSupplierManagerContactNumber } from './reqCreateSupplierManagerContactNumber';
|
||||
import type { ReqCreateSupplierTotalRevenue } from './reqCreateSupplierTotalRevenue';
|
||||
import type { ReqCreateSupplierCustom } from './reqCreateSupplierCustom';
|
||||
|
||||
export interface ReqCreateSupplier {
|
||||
name?: string;
|
||||
@ -17,4 +18,5 @@ export interface ReqCreateSupplier {
|
||||
manager_email?: ReqCreateSupplierManagerEmail;
|
||||
manager_contact_number?: ReqCreateSupplierManagerContactNumber;
|
||||
total_revenue?: ReqCreateSupplierTotalRevenue;
|
||||
custom?: ReqCreateSupplierCustom;
|
||||
}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ReqCreateSupplierCustomAnyOf } from './reqCreateSupplierCustomAnyOf';
|
||||
|
||||
export type ReqCreateSupplierCustom = ReqCreateSupplierCustomAnyOf | null;
|
||||
@ -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 ReqCreateSupplierCustomAnyOf = { [key: string]: unknown };
|
||||
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ReqUpdateCompanySettingsSettings } from './reqUpdateCompanySettingsSettings';
|
||||
|
||||
export interface ReqUpdateCompanySettings {
|
||||
settings?: ReqUpdateCompanySettingsSettings;
|
||||
}
|
||||
@ -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 ReqUpdateCompanySettingsSettings = { [key: string]: unknown };
|
||||
@ -24,6 +24,7 @@ import type { ReqUpdateItemQuantityUnit } from './reqUpdateItemQuantityUnit';
|
||||
import type { ReqUpdateItemDeliveryType } from './reqUpdateItemDeliveryType';
|
||||
import type { ReqUpdateItemVatYn } from './reqUpdateItemVatYn';
|
||||
import type { ReqUpdateItemDeliveryFeeYn } from './reqUpdateItemDeliveryFeeYn';
|
||||
import type { ReqUpdateItemCustom } from './reqUpdateItemCustom';
|
||||
|
||||
export interface ReqUpdateItem {
|
||||
name?: ReqUpdateItemName;
|
||||
@ -46,4 +47,5 @@ export interface ReqUpdateItem {
|
||||
delivery_type?: ReqUpdateItemDeliveryType;
|
||||
vat_yn?: ReqUpdateItemVatYn;
|
||||
delivery_fee_yn?: ReqUpdateItemDeliveryFeeYn;
|
||||
custom?: ReqUpdateItemCustom;
|
||||
}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ReqUpdateItemCustomAnyOf } from './reqUpdateItemCustomAnyOf';
|
||||
|
||||
export type ReqUpdateItemCustom = ReqUpdateItemCustomAnyOf | null;
|
||||
@ -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 ReqUpdateItemCustomAnyOf = { [key: string]: unknown };
|
||||
@ -10,6 +10,7 @@ import type { ReqUpdateSupplierManagerName } from './reqUpdateSupplierManagerNam
|
||||
import type { ReqUpdateSupplierManagerEmail } from './reqUpdateSupplierManagerEmail';
|
||||
import type { ReqUpdateSupplierManagerContactNumber } from './reqUpdateSupplierManagerContactNumber';
|
||||
import type { ReqUpdateSupplierTotalRevenue } from './reqUpdateSupplierTotalRevenue';
|
||||
import type { ReqUpdateSupplierCustom } from './reqUpdateSupplierCustom';
|
||||
|
||||
export interface ReqUpdateSupplier {
|
||||
name?: ReqUpdateSupplierName;
|
||||
@ -18,4 +19,5 @@ export interface ReqUpdateSupplier {
|
||||
manager_email?: ReqUpdateSupplierManagerEmail;
|
||||
manager_contact_number?: ReqUpdateSupplierManagerContactNumber;
|
||||
total_revenue?: ReqUpdateSupplierTotalRevenue;
|
||||
custom?: ReqUpdateSupplierCustom;
|
||||
}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ReqUpdateSupplierCustomAnyOf } from './reqUpdateSupplierCustomAnyOf';
|
||||
|
||||
export type ReqUpdateSupplierCustom = ReqUpdateSupplierCustomAnyOf | null;
|
||||
@ -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 ReqUpdateSupplierCustomAnyOf = { [key: string]: unknown };
|
||||
15
negodata/front/src/api/generated/model/resCompanySettings.ts
Normal file
15
negodata/front/src/api/generated/model/resCompanySettings.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ErrorInfo } from './errorInfo';
|
||||
import type { ResCompanySettingsMsg } from './resCompanySettingsMsg';
|
||||
import type { ResCompanySettingsSettings } from './resCompanySettingsSettings';
|
||||
|
||||
export interface ResCompanySettings {
|
||||
result?: ErrorInfo;
|
||||
msg?: ResCompanySettingsMsg;
|
||||
settings?: ResCompanySettingsSettings;
|
||||
}
|
||||
@ -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 ResCompanySettingsMsg = string | null;
|
||||
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ResCompanySettingsSettingsAnyOf } from './resCompanySettingsSettingsAnyOf';
|
||||
|
||||
export type ResCompanySettingsSettings = ResCompanySettingsSettingsAnyOf | null;
|
||||
@ -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 ResCompanySettingsSettingsAnyOf = { [key: string]: unknown };
|
||||
@ -13,6 +13,7 @@ import type { SessionDataRejectReason } from './sessionDataRejectReason';
|
||||
import type { SessionDataRejectPrice } from './sessionDataRejectPrice';
|
||||
import type { SessionDataRejectDeliveryType } from './sessionDataRejectDeliveryType';
|
||||
import type { SessionDataEmailSentAt } from './sessionDataEmailSentAt';
|
||||
import type { SessionDataCustom } from './sessionDataCustom';
|
||||
|
||||
export interface SessionData {
|
||||
session_id: string;
|
||||
@ -32,5 +33,6 @@ export interface SessionData {
|
||||
reject_price?: SessionDataRejectPrice;
|
||||
reject_delivery_type?: SessionDataRejectDeliveryType;
|
||||
email_sent_at?: SessionDataEmailSentAt;
|
||||
custom?: SessionDataCustom;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { SessionDataCustomAnyOf } from './sessionDataCustomAnyOf';
|
||||
|
||||
export type SessionDataCustom = SessionDataCustomAnyOf | null;
|
||||
@ -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 SessionDataCustomAnyOf = { [key: string]: unknown };
|
||||
@ -10,6 +10,7 @@ import type { SupplierDataManagerName } from './supplierDataManagerName';
|
||||
import type { SupplierDataManagerEmail } from './supplierDataManagerEmail';
|
||||
import type { SupplierDataManagerContactNumber } from './supplierDataManagerContactNumber';
|
||||
import type { SupplierDataTotalRevenue } from './supplierDataTotalRevenue';
|
||||
import type { SupplierDataCustom } from './supplierDataCustom';
|
||||
import type { SupplierDataAccountLoginId } from './supplierDataAccountLoginId';
|
||||
import type { SupplierDataAccountStatus } from './supplierDataAccountStatus';
|
||||
import type { SupplierDataCreatedAt } from './supplierDataCreatedAt';
|
||||
@ -26,6 +27,7 @@ export interface SupplierData {
|
||||
manager_email?: SupplierDataManagerEmail;
|
||||
manager_contact_number?: SupplierDataManagerContactNumber;
|
||||
total_revenue?: SupplierDataTotalRevenue;
|
||||
custom?: SupplierDataCustom;
|
||||
account_login_id?: SupplierDataAccountLoginId;
|
||||
account_status?: SupplierDataAccountStatus;
|
||||
created_at?: SupplierDataCreatedAt;
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { SupplierDataCustomAnyOf } from './supplierDataCustomAnyOf';
|
||||
|
||||
export type SupplierDataCustom = SupplierDataCustomAnyOf | null;
|
||||
@ -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 SupplierDataCustomAnyOf = { [key: string]: unknown };
|
||||
@ -5,6 +5,8 @@
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { SupplierItemDataItemCode } from './supplierItemDataItemCode';
|
||||
import type { SupplierItemDataItemCategory } from './supplierItemDataItemCategory';
|
||||
import type { SupplierItemDataItemManufacturer } from './supplierItemDataItemManufacturer';
|
||||
import type { SupplierItemDataCreatedAt } from './supplierItemDataCreatedAt';
|
||||
import type { SupplierItemDataUpdatedAt } from './supplierItemDataUpdatedAt';
|
||||
|
||||
@ -14,6 +16,8 @@ export interface SupplierItemData {
|
||||
item_name: string;
|
||||
item_code?: SupplierItemDataItemCode;
|
||||
supply_type: number;
|
||||
item_category?: SupplierItemDataItemCategory;
|
||||
item_manufacturer?: SupplierItemDataItemManufacturer;
|
||||
created_at?: SupplierItemDataCreatedAt;
|
||||
updated_at?: SupplierItemDataUpdatedAt;
|
||||
}
|
||||
|
||||
@ -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 SupplierItemDataItemCategory = string | null;
|
||||
@ -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 SupplierItemDataItemManufacturer = string | null;
|
||||
@ -13,6 +13,7 @@ import PartnersPage from '../pages/partners';
|
||||
import QuotationPage from '../pages/quotation';
|
||||
import CardsPage from '../pages/cards';
|
||||
import MembersPage from '../pages/members';
|
||||
import SettingsPage from '../pages/settings';
|
||||
import NotificationsPage from '../pages/notifications';
|
||||
import OnboardingPage from '../pages/onboarding';
|
||||
|
||||
@ -88,6 +89,12 @@ export const router = createBrowserRouter([
|
||||
loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')),
|
||||
Component: MembersPage,
|
||||
},
|
||||
{
|
||||
// 최고관리자 전용. 회사 브랜딩/용어/커스텀필드 설정.
|
||||
path: 'settings',
|
||||
loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')),
|
||||
Component: SettingsPage,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -12,6 +12,7 @@ const PAGE_TO_PATH: Record<PageType, string> = {
|
||||
QUOTATION: '/quotation',
|
||||
CARDS: '/cards',
|
||||
MEMBERS: '/members',
|
||||
SETTINGS: '/settings',
|
||||
NOTIFICATIONS: '/notifications',
|
||||
};
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, type ReactNode, type ElementType } from 'react';
|
||||
import { PageType } from '@/types';
|
||||
import { useAuth } from '@/features/auth/useAuth';
|
||||
import { useBranding } from '@/features/settings/useCompanySettings';
|
||||
import { ProfileSheet } from '@/features/auth/components/ProfileSheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@ -58,7 +59,10 @@ const menuGroups: { label?: string; items: MenuItem[] }[] = [
|
||||
},
|
||||
{
|
||||
label: '관리',
|
||||
items: [{ type: 'MEMBERS', label: '회원관리', icon: UserCog, id: 'sidebar-members', ownerOnly: true }],
|
||||
items: [
|
||||
{ type: 'MEMBERS', label: '회원관리', icon: UserCog, id: 'sidebar-members', ownerOnly: true },
|
||||
{ type: 'SETTINGS', label: '회사 설정', icon: Building, id: 'sidebar-settings', ownerOnly: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@ -72,11 +76,13 @@ const pageLabelMap: Record<PageType, string> = {
|
||||
QUOTATION: '견적관리',
|
||||
CARDS: '협상카드관리',
|
||||
MEMBERS: '회원관리',
|
||||
SETTINGS: '회사 설정',
|
||||
NOTIFICATIONS: '알림',
|
||||
};
|
||||
|
||||
export default function Layout({ children, currentPage, setPage, onLogout }: LayoutProps) {
|
||||
const { user } = useAuth();
|
||||
const branding = useBranding(); // 회사 설정 브랜딩(서비스명/로고). 미설정 시 기본 NegoData
|
||||
// 기준일시(오늘) — 로컬 타임존 기준 YYYY-MM-DD
|
||||
const today = new Date().toLocaleDateString('sv-SE');
|
||||
|
||||
@ -142,8 +148,16 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
|
||||
<div className="h-14 shrink-0 flex items-center justify-between px-4 border-b border-sidebar-border">
|
||||
{expanded && (
|
||||
<Typography as="div" variant="body" className="flex items-center gap-2 font-extrabold tracking-tight text-foreground">
|
||||
<span aria-hidden className="size-4 rounded-[5px] bg-primary" />
|
||||
NegoData
|
||||
{branding.logoUrl ? (
|
||||
<img src={branding.logoUrl} alt={branding.serviceName} className="h-4 max-w-24 object-contain" />
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-4 rounded-[5px] bg-primary"
|
||||
style={branding.primaryColor ? { backgroundColor: branding.primaryColor } : undefined}
|
||||
/>
|
||||
)}
|
||||
{branding.serviceName}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
|
||||
@ -12,6 +12,8 @@ import { Input } from '@/components/ui/input';
|
||||
import { PhoneInput } from '@/components/ui/phone-input';
|
||||
import { Sheet } from '@/components/ui/sheet';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useCompanySettings } from '@/features/settings/useCompanySettings';
|
||||
import { CustomFieldInputs, useCustomFieldValues } from '@/features/settings/CustomFieldInputs';
|
||||
import { SupplierItemsManager } from './SupplierItemsManager';
|
||||
import { SupplierAccountManager } from './SupplierAccountManager';
|
||||
import { type Partner } from '../types';
|
||||
@ -83,6 +85,11 @@ export function PartnerFormSheet({
|
||||
// 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(백엔드 RequireOwner 와 동일 규칙).
|
||||
const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자');
|
||||
|
||||
// 회사 협력사 커스텀필드(정의=companies.settings.supplier_fields, 값=suppliers.custom)
|
||||
const { settings } = useCompanySettings();
|
||||
const supplierFields = settings.supplier_fields ?? [];
|
||||
const customValues = useCustomFieldValues(supplierFields, partner?.custom);
|
||||
|
||||
const onValid = async (v: FormValues) => {
|
||||
const common = {
|
||||
name: v.name,
|
||||
@ -91,6 +98,7 @@ export function PartnerFormSheet({
|
||||
manager_email: v.managerEmail,
|
||||
manager_contact_number: v.managerPhone,
|
||||
total_revenue: v.totalRevenue?.trim() ? Number(v.totalRevenue.replace(/[^0-9]/g, '')) : undefined,
|
||||
...(supplierFields.length > 0 ? { custom: customValues.values } : {}),
|
||||
};
|
||||
|
||||
if (mode === 'create') {
|
||||
@ -213,6 +221,9 @@ export function PartnerFormSheet({
|
||||
{errors.managerPhone && <p className="text-[10px] text-rose-500">{errors.managerPhone.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* 회사 커스텀 필드 — companies.settings.supplier_fields 정의대로 렌더, suppliers.custom 에 저장 */}
|
||||
<CustomFieldInputs fields={supplierFields} state={customValues} title="회사 추가 항목" />
|
||||
|
||||
{/* 취급상품 관리 — 수정 모드(협력사 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */}
|
||||
{mode === 'edit' && partner && <SupplierItemsManager supplierId={partner.supplier_id} />}
|
||||
|
||||
|
||||
@ -5,6 +5,8 @@ import type { Partner } from '../types';
|
||||
|
||||
type PartnerTableProps = {
|
||||
data: Partner[];
|
||||
selectedIds: string[];
|
||||
onSelectionChange: (ids: string[]) => void;
|
||||
onRowClick: (part: Partner) => void;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
@ -15,6 +17,8 @@ type PartnerTableProps = {
|
||||
|
||||
export function PartnerTable({
|
||||
data,
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
onRowClick,
|
||||
page,
|
||||
totalPages,
|
||||
@ -27,6 +31,7 @@ export function PartnerTable({
|
||||
data={data}
|
||||
rowKey={(part) => part.supplier_id}
|
||||
onRowClick={onRowClick}
|
||||
selection={{ selectedKeys: selectedIds, onSelectionChange }}
|
||||
empty="협약된 가용 B2B 파트너사가 존재하지 않습니다."
|
||||
footer={
|
||||
<TablePagination
|
||||
|
||||
@ -62,6 +62,7 @@ export function SupplierItemsManager({ supplierId }: { supplierId: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
// 분류카테고리 파생(IMK #17, B안) — 취급상품에서 (카테고리-제조원, 공급유형) distinct 집계.
|
||||
return (
|
||||
<div className="pt-4 border-t border-border space-y-2">
|
||||
<Typography as="label" variant="label">취급상품 ({items.length})</Typography>
|
||||
@ -110,7 +111,9 @@ export function SupplierItemsManager({ supplierId }: { supplierId: string }) {
|
||||
items.map((m) => (
|
||||
<div key={m.supplier_item_id} className="flex items-center justify-between gap-2 p-2">
|
||||
<div className="min-w-0">
|
||||
<Typography as="span" variant="small" className="font-semibold block truncate">{m.item_name}</Typography>
|
||||
<Typography as="span" variant="small" className="font-semibold block truncate">
|
||||
{m.item_category ? `${m.item_category} - ${m.item_name}` : m.item_name}
|
||||
</Typography>
|
||||
{m.item_code && (
|
||||
<Typography as="span" variant="small" className="text-muted-foreground text-[10px]">{m.item_code}</Typography>
|
||||
)}
|
||||
|
||||
@ -8,6 +8,8 @@ import { customFetch } from '@/api/mutator/custom-fetch';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { useCompanySettings, useLabels } from '@/features/settings/useCompanySettings';
|
||||
import type { CustomFieldDef } from '@/features/settings/catalog';
|
||||
import type { Product } from '../types';
|
||||
|
||||
// 엑셀에서 읽어온 원본 행(입력값만). status/message는 저장하지 않고 검증에서 파생한다.
|
||||
@ -29,20 +31,16 @@ type RawRow = {
|
||||
moq: string;
|
||||
lead_time: number;
|
||||
quantity_unit: string;
|
||||
delivery_type: string; // 한글 라벨로 입력 → 전송 시 코드 변환
|
||||
delivery_type: string; // 라벨로 입력 → 전송 시 코드 변환
|
||||
vat_yn: string; // Y/N
|
||||
delivery_fee_yn: string; // Y/N
|
||||
custom: Record<string, string>; // 회사 커스텀필드(item_fields) 값 — 전송 시 타입 변환
|
||||
};
|
||||
|
||||
// 검증 결과가 붙은 행. UI는 이걸 그린다.
|
||||
type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string };
|
||||
|
||||
// 배송형태 라벨 ↔ delivery_type 코드(공용 enum 과 동일 집합)
|
||||
const DELIVERY_LABEL_TO_CODE: Record<string, number> = {
|
||||
협력사배송: 1,
|
||||
지정택배배송: 2,
|
||||
픽업배송: 3,
|
||||
};
|
||||
type LabelFn = (key: string) => string;
|
||||
|
||||
// 자유로운 Y/N 표기 → boolean (Y·예·true·O·포함·1 = true)
|
||||
const parseYn = (s: string): boolean => /^(y|yes|true|1|예|o|포함)$/i.test(s.trim());
|
||||
@ -51,31 +49,71 @@ const parseYn = (s: string): boolean => /^(y|yes|true|1|예|o|포함)$/i.test(s.
|
||||
const isYnToken = (s: string): boolean =>
|
||||
/^(y|yes|true|1|예|o|포함|n|no|false|0|아니오|x|미포함)$/i.test(s.trim());
|
||||
|
||||
// 업로드 양식(.csv) 컬럼 정의 — 헤더 ↔ RawRow 필드. 양식/예시/파싱이 이 한 곳을 공유한다.
|
||||
const UPLOAD_COLUMNS: { header: string; key: keyof RawRow }[] = [
|
||||
{ header: '상품명', key: 'name' },
|
||||
{ header: '상품코드', key: 'code' },
|
||||
{ header: '모델번호', key: 'model_name' },
|
||||
{ header: '카테고리', key: 'category' },
|
||||
{ header: '규격', key: 'spec' },
|
||||
{ header: '제조사', key: 'manufacturer' },
|
||||
{ header: '원산지', key: 'made_in' },
|
||||
{ header: '상품 단가', key: 'price' },
|
||||
{ header: '최저한도', key: 'minPrice' },
|
||||
{ header: '매입가', key: 'purchase_price' },
|
||||
{ header: '판매가', key: 'selling_price' },
|
||||
{ header: '이미지URL', key: 'image_url' },
|
||||
{ header: '최소주문수량', key: 'moq' },
|
||||
{ header: '리드타임(일)', key: 'lead_time' },
|
||||
{ header: '단위', key: 'quantity_unit' },
|
||||
{ header: '배송형태', key: 'delivery_type' },
|
||||
{ header: '부가세포함(Y/N)', key: 'vat_yn' },
|
||||
{ header: '배송비포함(Y/N)', key: 'delivery_fee_yn' },
|
||||
// 표준 컬럼(고정 18개 — 삭제 없음). labelKey 가 있으면 헤더를 회사 설정 용어로 치환하고,
|
||||
// base 헤더는 구양식 파일 호환용 별칭으로 계속 인식한다.
|
||||
const STANDARD_COLUMNS: { base: string; key: Exclude<keyof RawRow, 'id' | 'rowNum' | 'custom'>; labelKey?: string; suffix?: string }[] = [
|
||||
{ base: '상품명', key: 'name' },
|
||||
{ base: '상품코드', key: 'code' },
|
||||
{ base: '모델번호', key: 'model_name' },
|
||||
{ base: '카테고리', key: 'category', labelKey: 'category' },
|
||||
{ base: '규격', key: 'spec' },
|
||||
{ base: '제조사', key: 'manufacturer' },
|
||||
{ base: '원산지', key: 'made_in' },
|
||||
{ base: '상품 단가', key: 'price', labelKey: 'item.price' },
|
||||
{ base: '최저한도', key: 'minPrice' },
|
||||
{ base: '매입가', key: 'purchase_price' },
|
||||
{ base: '판매가', key: 'selling_price' },
|
||||
{ base: '이미지URL', key: 'image_url' },
|
||||
{ base: '최소주문수량', key: 'moq' },
|
||||
{ base: '리드타임(일)', key: 'lead_time', labelKey: 'lead_time', suffix: '(일)' },
|
||||
{ base: '단위', key: 'quantity_unit' },
|
||||
{ base: '배송형태', key: 'delivery_type' },
|
||||
{ base: '부가세포함(Y/N)', key: 'vat_yn' },
|
||||
{ base: '배송비포함(Y/N)', key: 'delivery_fee_yn' },
|
||||
];
|
||||
|
||||
// 숫자 입력 컬럼 / 필수 컬럼(헤더에 * 표기)
|
||||
const NUMERIC_KEYS = new Set<keyof RawRow>(['price', 'minPrice', 'purchase_price', 'selling_price', 'lead_time']);
|
||||
const REQUIRED_KEYS = new Set<keyof RawRow>(['name', 'code', 'price']);
|
||||
// 렌더/파싱/양식이 공유하는 컬럼(헤더는 회사 설정 반영). customKey 있으면 커스텀필드 컬럼.
|
||||
type UploadColumn = {
|
||||
header: string;
|
||||
aliases: string[]; // 파싱 시 인식할 헤더 후보(회사 라벨 + 기본 헤더)
|
||||
key?: Exclude<keyof RawRow, 'id' | 'rowNum' | 'custom'>;
|
||||
customKey?: string;
|
||||
numeric: boolean;
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
const NUMERIC_KEYS = new Set<string>(['price', 'minPrice', 'purchase_price', 'selling_price', 'lead_time']);
|
||||
const REQUIRED_KEYS = new Set<string>(['name', 'code', 'price']);
|
||||
|
||||
// 회사 설정(용어 라벨 + item_fields 커스텀필드)으로 업로드 컬럼을 만든다.
|
||||
// 기존 18컬럼은 전부 유지, 커스텀필드는 뒤에 추가된다.
|
||||
function buildColumns(label: LabelFn, itemFields: CustomFieldDef[]): UploadColumn[] {
|
||||
const standard: UploadColumn[] = STANDARD_COLUMNS.map((c) => {
|
||||
const header = c.labelKey ? `${label(c.labelKey)}${c.suffix ?? ''}` : c.base;
|
||||
return {
|
||||
header,
|
||||
aliases: [...new Set([header, c.base])],
|
||||
key: c.key,
|
||||
numeric: NUMERIC_KEYS.has(c.key),
|
||||
required: REQUIRED_KEYS.has(c.key),
|
||||
};
|
||||
});
|
||||
const custom: UploadColumn[] = itemFields.map((f) => ({
|
||||
header: f.type === 'boolean' ? `${f.label}(Y/N)` : f.label,
|
||||
aliases: [f.type === 'boolean' ? `${f.label}(Y/N)` : f.label, f.label],
|
||||
customKey: f.key,
|
||||
numeric: f.type === 'number',
|
||||
required: false,
|
||||
}));
|
||||
return [...standard, ...custom];
|
||||
}
|
||||
|
||||
// 배송형태 라벨 → 코드. 기본 라벨과 회사 설정 라벨(직납 등)을 모두 인식한다.
|
||||
function buildDeliveryMap(label: LabelFn): Record<string, number> {
|
||||
const map: Record<string, number> = { 협력사배송: 1, 지정택배배송: 2, 픽업배송: 3 };
|
||||
for (const code of [1, 2, 3]) map[label(`delivery_type.${code}`)] = code;
|
||||
return map;
|
||||
}
|
||||
|
||||
// 양식에 채워 넣는 예시 행(시드 상품과 동일 셋). 다운로드 양식에 그대로 들어간다.
|
||||
const EXAMPLE_ROWS: Record<string, string | number>[] = [
|
||||
@ -83,23 +121,32 @@ const EXAMPLE_ROWS: Record<string, string | number>[] = [
|
||||
name: '리튬인산철 배터리 모듈', code: 'BAT-LFP-100', model_name: 'LFP-100A',
|
||||
category: '에너지/배터리', spec: '3.2V 100Ah', manufacturer: '한성에너지', made_in: '대한민국',
|
||||
price: 1250000, minPrice: 1037500, purchase_price: 1000000, selling_price: 1250000, image_url: 'https://example.com/img/lfp-100a.jpg',
|
||||
moq: '10 EA', lead_time: 14, quantity_unit: 'EA', delivery_type: '협력사배송',
|
||||
moq: '10 EA', lead_time: 14, quantity_unit: 'EA', delivery_type: 1,
|
||||
vat_yn: 'Y', delivery_fee_yn: 'N',
|
||||
},
|
||||
{
|
||||
name: '산업용 6축 로봇암', code: 'ROB-6AX-22', model_name: 'RX-6A',
|
||||
category: '자동화설비', spec: '가반하중 12kg', manufacturer: '오토메카', made_in: '일본',
|
||||
price: 18900000, minPrice: 16065000, purchase_price: 15000000, selling_price: 18900000, image_url: 'https://example.com/img/rx-6a.jpg',
|
||||
moq: '1 EA', lead_time: 30, quantity_unit: 'EA', delivery_type: '지정택배배송',
|
||||
moq: '1 EA', lead_time: 30, quantity_unit: 'EA', delivery_type: 2,
|
||||
vat_yn: 'Y', delivery_fee_yn: 'N',
|
||||
},
|
||||
];
|
||||
|
||||
// 업로드 양식(.csv) 다운로드 — 전체 컬럼 헤더 + 예시 행(시드 상품 셋). UPLOAD_COLUMNS 단일 정의 공유. 툴바·모달이 공유한다.
|
||||
export function downloadProductTemplate() {
|
||||
// 업로드 양식(.csv) 다운로드 — 회사 설정 헤더(라벨 치환) + 커스텀필드 컬럼 + 예시 행.
|
||||
// 파싱(buildColumns)과 같은 정의를 공유하므로 받은 양식이 그대로 다시 업로드된다. 툴바·모달이 공유한다.
|
||||
export function downloadProductTemplate(label: LabelFn, itemFields: CustomFieldDef[]) {
|
||||
const columns = buildColumns(label, itemFields);
|
||||
downloadExcel<Record<string, string | number>>(
|
||||
'상품_업로드_양식',
|
||||
UPLOAD_COLUMNS.map((c) => ({ header: c.header, value: (r) => r[c.key] })),
|
||||
columns.map((c) => ({
|
||||
header: c.header,
|
||||
value: (r) => {
|
||||
if (c.customKey) return c.numeric ? 10 : ''; // 커스텀 예시값(숫자=10, 그 외 빈칸)
|
||||
if (c.key === 'delivery_type') return label(`delivery_type.${r.delivery_type}`); // 예시도 회사 라벨로
|
||||
return r[c.key as string];
|
||||
},
|
||||
})),
|
||||
EXAMPLE_ROWS,
|
||||
);
|
||||
}
|
||||
@ -117,6 +164,10 @@ function validateRows(
|
||||
rows: RawRow[],
|
||||
products: Product[],
|
||||
serverErrors: Record<string, string>,
|
||||
deliveryMap: Record<string, number>,
|
||||
deliveryLabels: string[],
|
||||
priceLabel: string,
|
||||
itemFields: CustomFieldDef[],
|
||||
): ValidatedRow[] {
|
||||
return rows.map((row) => {
|
||||
const fail = (message: string): ValidatedRow => ({ ...row, status: '오류', message });
|
||||
@ -128,12 +179,12 @@ 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('유효성 위반 - 상품 단가는 0보다 커야 합니다.');
|
||||
if (row.minPrice > row.price) return fail('유효성 위반 - 최저 한도가 상품 단가보다 큽니다.');
|
||||
if (row.price <= 0) return fail(`유효성 위반 - ${priceLabel}는 0보다 커야 합니다.`);
|
||||
if (row.minPrice > row.price) return fail(`유효성 위반 - 최저 한도가 ${priceLabel}보다 큽니다.`);
|
||||
|
||||
// 선택 필드 형식 검증(값이 있을 때만). 배송형태/부가세/배송비/이미지URL.
|
||||
if (row.delivery_type.trim() && !(row.delivery_type.trim() in DELIVERY_LABEL_TO_CODE)) {
|
||||
return fail('배송형태 - 협력사배송 / 지정택배배송 / 픽업배송 중 하나여야 합니다.');
|
||||
if (row.delivery_type.trim() && !(row.delivery_type.trim() in deliveryMap)) {
|
||||
return fail(`배송형태 - ${deliveryLabels.join(' / ')} 중 하나여야 합니다.`);
|
||||
}
|
||||
if (row.vat_yn.trim() && !isYnToken(row.vat_yn)) {
|
||||
return fail('부가세포함 - Y 또는 N(예/아니오)으로 입력해 주십시오.');
|
||||
@ -144,6 +195,13 @@ function validateRows(
|
||||
if (row.image_url.trim() && !/^https?:\/\//i.test(row.image_url.trim())) {
|
||||
return fail('이미지URL - http:// 또는 https:// 로 시작하는 주소여야 합니다.');
|
||||
}
|
||||
// 커스텀필드 형식 검증(값이 있을 때만). boolean=Y/N 토큰, number=숫자.
|
||||
for (const f of itemFields) {
|
||||
const v = (row.custom[f.key] ?? '').trim();
|
||||
if (!v) continue;
|
||||
if (f.type === 'boolean' && !isYnToken(v)) return fail(`${f.label} - Y 또는 N(예/아니오)으로 입력해 주십시오.`);
|
||||
if (f.type === 'number' && Number.isNaN(Number(v))) return fail(`${f.label} - 숫자로 입력해 주십시오.`);
|
||||
}
|
||||
|
||||
// 프론트 검증 통과 후, 직전 전송에서 서버가 거부한 코드면 그 사유로 오류 처리.
|
||||
if (serverErrors[row.code]) return fail(serverErrors[row.code]);
|
||||
@ -153,7 +211,14 @@ function validateRows(
|
||||
}
|
||||
|
||||
// 검증된(정상) 행 → 서버 생성 payload. 엑셀 업로드 공통 기본값 적용.
|
||||
function toItemCreate(row: RawRow): ItemCreate {
|
||||
function toItemCreate(row: RawRow, deliveryMap: Record<string, number>, itemFields: CustomFieldDef[]): ItemCreate {
|
||||
// 커스텀필드 문자열 → 정의 타입대로 변환(빈 값은 제외)
|
||||
const custom: Record<string, unknown> = {};
|
||||
for (const f of itemFields) {
|
||||
const v = (row.custom[f.key] ?? '').trim();
|
||||
if (!v) continue;
|
||||
custom[f.key] = f.type === 'number' ? Number(v) : f.type === 'boolean' ? parseYn(v) : v;
|
||||
}
|
||||
return {
|
||||
name: row.name,
|
||||
code: row.code,
|
||||
@ -169,19 +234,27 @@ function toItemCreate(row: RawRow): ItemCreate {
|
||||
moq: row.moq || undefined,
|
||||
lead_time: row.lead_time || undefined,
|
||||
quantity_unit: row.quantity_unit || undefined,
|
||||
delivery_type: DELIVERY_LABEL_TO_CODE[row.delivery_type.trim()] ?? undefined,
|
||||
delivery_type: deliveryMap[row.delivery_type.trim()] ?? undefined,
|
||||
vat_yn: row.vat_yn.trim() ? parseYn(row.vat_yn) : undefined,
|
||||
delivery_fee_yn: row.delivery_fee_yn.trim() ? parseYn(row.delivery_fee_yn) : undefined,
|
||||
// minPrice(최저한도) = 인터넷 최저가(실값) → internet_lowest_price 로 저장(수기 폼과 동일).
|
||||
internet_lowest_price: row.minPrice,
|
||||
internet_lowest_price_yn: row.minPrice > 0,
|
||||
...(Object.keys(custom).length > 0 ? { custom } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// 상품 엑셀 일괄 업로드 모달. 파일 파싱(목업)·원본 행 state는 이 컴포넌트가 소유하고,
|
||||
// 상품 엑셀 일괄 업로드 모달. 파일 파싱·원본 행 state는 이 컴포넌트가 소유하고,
|
||||
// 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임.
|
||||
// 컬럼(헤더 라벨·커스텀필드)은 회사 설정을 따른다 — 양식 다운로드와 동일 정의.
|
||||
export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUploadModalProps) {
|
||||
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
||||
const label = useLabels();
|
||||
const { settings } = useCompanySettings();
|
||||
const itemFields = useMemo(() => settings.item_fields ?? [], [settings.item_fields]);
|
||||
const columns = useMemo(() => buildColumns(label, itemFields), [label, itemFields]);
|
||||
const deliveryMap = useMemo(() => buildDeliveryMap(label), [label]);
|
||||
|
||||
const [excelFile, setExcelFile] = useState<string | null>(null);
|
||||
const [rows, setRows] = useState<RawRow[]>([]);
|
||||
const [serverErrors, setServerErrors] = useState<Record<string, string>>({}); // 서버(DB) 거부 code→사유
|
||||
@ -190,8 +263,12 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
||||
|
||||
// 파생: 검증 결과 + 카운트 (state로 저장하지 않음)
|
||||
const validated = useMemo(
|
||||
() => validateRows(rows, products, serverErrors),
|
||||
[rows, products, serverErrors],
|
||||
() => validateRows(
|
||||
rows, products, serverErrors, deliveryMap,
|
||||
[1, 2, 3].map((c) => label(`delivery_type.${c}`)),
|
||||
label('item.price'), itemFields,
|
||||
),
|
||||
[rows, products, serverErrors, deliveryMap, label, itemFields],
|
||||
);
|
||||
const validRows = validated.filter((r) => r.status === '정상');
|
||||
const validCount = validRows.length;
|
||||
@ -206,31 +283,31 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). 헤더는 양식과 동일해야 함.
|
||||
// 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생).
|
||||
// 헤더는 회사 라벨과 기본 헤더(구양식) 둘 다 인식한다(aliases).
|
||||
const handleFile = async (file: File) => {
|
||||
const parsed = parseCsv(await file.text());
|
||||
const loaded: RawRow[] = parsed.map((r, i) => ({
|
||||
id: `row-${i + 1}`,
|
||||
rowNum: i + 2,
|
||||
name: r['상품명'] ?? '',
|
||||
code: r['상품코드'] ?? '',
|
||||
model_name: r['모델번호'] ?? '',
|
||||
category: r['카테고리'] ?? '',
|
||||
spec: r['규격'] ?? '',
|
||||
manufacturer: r['제조사'] ?? '',
|
||||
made_in: r['원산지'] ?? '',
|
||||
price: Number(r['상품 단가']) || 0,
|
||||
minPrice: Number(r['최저한도']) || 0,
|
||||
purchase_price: Number(r['매입가']) || 0,
|
||||
selling_price: Number(r['판매가']) || 0,
|
||||
image_url: r['이미지URL'] ?? '',
|
||||
moq: r['최소주문수량'] ?? '',
|
||||
lead_time: Number(r['리드타임(일)']) || 0,
|
||||
quantity_unit: r['단위'] ?? '',
|
||||
delivery_type: r['배송형태'] ?? '',
|
||||
vat_yn: r['부가세포함(Y/N)'] ?? '',
|
||||
delivery_fee_yn: r['배송비포함(Y/N)'] ?? '',
|
||||
}));
|
||||
const pick = (r: Record<string, string>, c: UploadColumn): string => {
|
||||
for (const a of c.aliases) if (r[a] !== undefined) return r[a];
|
||||
return '';
|
||||
};
|
||||
const loaded: RawRow[] = parsed.map((r, i) => {
|
||||
const row: RawRow = {
|
||||
id: `row-${i + 1}`,
|
||||
rowNum: i + 2,
|
||||
name: '', code: '', model_name: '', category: '', spec: '', manufacturer: '', made_in: '',
|
||||
price: 0, minPrice: 0, purchase_price: 0, selling_price: 0,
|
||||
image_url: '', moq: '', lead_time: 0, quantity_unit: '', delivery_type: '', vat_yn: '', delivery_fee_yn: '',
|
||||
custom: {},
|
||||
};
|
||||
for (const c of columns) {
|
||||
const v = pick(r, c);
|
||||
if (c.customKey) row.custom[c.customKey] = v;
|
||||
else if (c.numeric) (row[c.key!] as number) = Number(v) || 0;
|
||||
else (row[c.key!] as string) = v;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
setExcelFile(file.name);
|
||||
setRows(loaded);
|
||||
setServerErrors({}); // 새 파일 → 직전 서버사유 초기화
|
||||
@ -252,15 +329,14 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
||||
}
|
||||
};
|
||||
|
||||
// 인라인 편집 — 원본 필드만 갱신(재검증은 파생이 처리). 숫자 컬럼은 정수화.
|
||||
const handleUpdateField = (id: string, field: keyof RawRow, value: string) => {
|
||||
// 인라인 편집 — 원본 필드만 갱신(재검증은 파생이 처리). 표준 숫자 컬럼은 정수화, 커스텀은 문자열 보관.
|
||||
const handleUpdateField = (id: string, c: UploadColumn, value: string) => {
|
||||
setRows((cur) =>
|
||||
cur.map((row) => {
|
||||
if (row.id !== id) return row;
|
||||
if (NUMERIC_KEYS.has(field)) {
|
||||
return { ...row, [field]: Math.max(0, parseInt(value, 10) || 0) };
|
||||
}
|
||||
return { ...row, [field]: value };
|
||||
if (c.customKey) return { ...row, custom: { ...row.custom, [c.customKey]: value } };
|
||||
if (c.numeric) return { ...row, [c.key!]: Math.max(0, parseInt(value, 10) || 0) };
|
||||
return { ...row, [c.key!]: value };
|
||||
}),
|
||||
);
|
||||
};
|
||||
@ -276,7 +352,7 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const failures = await onConfirm(validRows.map(toItemCreate));
|
||||
const failures = await onConfirm(validRows.map((r) => toItemCreate(r, deliveryMap, itemFields)));
|
||||
const okCount = validRows.length - failures.length;
|
||||
if (failures.length === 0) {
|
||||
showToast(`총 ${okCount}개 상품이 서버에 일괄 등록되었습니다.`, 'success');
|
||||
@ -381,12 +457,12 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
||||
<TableHead className="p-2 font-semibold text-center w-12">삭제</TableHead>
|
||||
<TableHead className="p-2 font-semibold text-center whitespace-nowrap">자격</TableHead>
|
||||
<TableHead className="p-2 font-semibold whitespace-nowrap">진단 내용</TableHead>
|
||||
{UPLOAD_COLUMNS.map((c) => (
|
||||
{columns.map((c) => (
|
||||
<TableHead
|
||||
key={c.key}
|
||||
className={`p-2 font-semibold whitespace-nowrap ${NUMERIC_KEYS.has(c.key) ? 'text-right' : ''}`}
|
||||
key={c.header}
|
||||
className={`p-2 font-semibold whitespace-nowrap ${c.numeric ? 'text-right' : ''}`}
|
||||
>
|
||||
{c.header}{REQUIRED_KEYS.has(c.key) ? ' *' : ''}
|
||||
{c.header}{c.required ? ' *' : ''}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
@ -417,16 +493,16 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
||||
<TableCell className={`p-2 font-mono text-[10px] whitespace-nowrap ${row.status === '오류' ? 'text-rose-500' : 'text-emerald-600'}`}>
|
||||
{row.message}
|
||||
</TableCell>
|
||||
{UPLOAD_COLUMNS.map((c) => {
|
||||
const isNum = NUMERIC_KEYS.has(c.key);
|
||||
{columns.map((c) => {
|
||||
const value = c.customKey ? row.custom[c.customKey] ?? '' : row[c.key!];
|
||||
return (
|
||||
<TableCell key={c.key} className={`p-2 ${isNum ? 'text-right' : ''}`}>
|
||||
<TableCell key={c.header} className={`p-2 ${c.numeric ? 'text-right' : ''}`}>
|
||||
<Input
|
||||
type={isNum ? 'number' : 'text'}
|
||||
type={c.numeric && !c.customKey ? 'number' : 'text'}
|
||||
placeholder={c.header}
|
||||
className={`bg-muted/20 hover:bg-muted/50 ${isNum ? 'w-24 text-right font-mono' : 'min-w-[110px] font-mono'}`}
|
||||
value={String(row[c.key] ?? '')}
|
||||
onChange={(e) => handleUpdateField(row.id, c.key, e.target.value)}
|
||||
className={`bg-muted/20 hover:bg-muted/50 ${c.numeric ? 'w-24 text-right font-mono' : 'min-w-[110px] font-mono'}`}
|
||||
value={String(value ?? '')}
|
||||
onChange={(e) => handleUpdateField(row.id, c, e.target.value)}
|
||||
/>
|
||||
</TableCell>
|
||||
);
|
||||
|
||||
@ -0,0 +1,146 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Combobox, type ComboOption } from '@/components/ui/combobox';
|
||||
import { SUPPLIER_TYPE_OPTIONS, SupplierType, supplierTypeLabel } from '@/lib/enumLabels';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { useItemSuppliers } from '../hooks/useItemSuppliers';
|
||||
|
||||
// 상품 상세의 공급사 관리 섹션(IMK #20) — SupplierItemsManager(협력사측)의 상품측 미러.
|
||||
// 공급사 추가/삭제 + 공급유형(제조/유통/총판/없음) 수정. 각 조작은 즉시 서버 반영(상품 기본정보 저장과 독립).
|
||||
export function ItemSuppliersManager({ itemId }: { itemId: string }) {
|
||||
const { suppliers, isLoading, addSupplier, changeType, removeSupplier } = useItemSuppliers(itemId);
|
||||
const [q, setQ] = useState('');
|
||||
const catalogQuery = useListSuppliers({ search: q || undefined, size: 30 }); // 서버검색
|
||||
|
||||
const [pickSupplierId, setPickSupplierId] = useState('');
|
||||
const [pickLabel, setPickLabel] = useState('');
|
||||
const [pickType, setPickType] = useState(String(SupplierType.NONE)); // 기본 없음(0)
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const mappedIds = new Set(suppliers.map((m) => m.supplier_id));
|
||||
const options: ComboOption[] = (catalogQuery.data?.suppliers ?? [])
|
||||
.filter((s) => !mappedIds.has(s.supplier_id))
|
||||
.map((s) => ({ id: s.supplier_id, label: `${s.name}${s.code ? ` [${s.code}]` : ''}` }));
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!pickSupplierId) {
|
||||
showToast('추가할 공급사를 선택하세요.', 'error');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await addSupplier(pickSupplierId, Number(pickType));
|
||||
setPickSupplierId('');
|
||||
setPickLabel('');
|
||||
setQ('');
|
||||
showToast('공급사가 추가되었습니다.', 'success');
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : '공급사 추가 실패', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeType = async (supplierItemId: string, v: string) => {
|
||||
try {
|
||||
await changeType(supplierItemId, Number(v));
|
||||
} catch {
|
||||
showToast('공급유형 변경에 실패했습니다.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (supplierItemId: string) => {
|
||||
try {
|
||||
await removeSupplier(supplierItemId);
|
||||
showToast('공급사가 삭제되었습니다.', 'info');
|
||||
} catch {
|
||||
showToast('공급사 삭제에 실패했습니다.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-4 border-t border-border space-y-2">
|
||||
<Typography as="label" variant="label">공급사 ({suppliers.length})</Typography>
|
||||
|
||||
{/* 추가 행 — 공급사 + 공급유형 선택 후 추가 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Combobox
|
||||
id="item-supplier-pick"
|
||||
options={options}
|
||||
loading={catalogQuery.isLoading}
|
||||
onQueryChange={setQ}
|
||||
value={pickSupplierId || undefined}
|
||||
selectedLabel={pickLabel}
|
||||
onSelect={(opt) => { setPickSupplierId(opt.id); setPickLabel(opt.label); }}
|
||||
placeholder="공급사로 추가할 협력사 검색..."
|
||||
searchPlaceholder="협력사명·코드로 검색..."
|
||||
emptyText="일치하는 협력사가 없습니다"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-24 shrink-0">
|
||||
<Select value={pickType} onValueChange={(v) => setPickType(v ?? String(SupplierType.NONE))}>
|
||||
<SelectTrigger id="item-supplier-pick-type" className="w-full">
|
||||
<SelectValue>{(value) => supplierTypeLabel(Number(value))}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPLIER_TYPE_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={handleAdd} disabled={busy || !pickSupplierId}>
|
||||
<Plus />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 현재 공급사 목록 */}
|
||||
<div className="border border-border rounded divide-y divide-border max-h-48 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<Typography as="p" variant="small" className="p-3 text-muted-foreground text-[11px]">불러오는 중…</Typography>
|
||||
) : suppliers.length === 0 ? (
|
||||
<Typography as="p" variant="small" className="p-3 text-muted-foreground text-[11px]">등록된 공급사가 없습니다.</Typography>
|
||||
) : (
|
||||
suppliers.map((m) => (
|
||||
<div key={m.supplier_id} className="flex items-center gap-2 p-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Typography as="span" variant="small" className="font-semibold block truncate">
|
||||
{m.supplier_name ?? '-'}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="w-24 shrink-0">
|
||||
<Select
|
||||
value={String(m.supply_type)}
|
||||
onValueChange={(v) => v != null && m.supplier_item_id && handleChangeType(String(m.supplier_item_id), v)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>{(value) => supplierTypeLabel(Number(value))}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPLIER_TYPE_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => m.supplier_item_id && handleRemove(String(m.supplier_item_id))}
|
||||
title="공급사 삭제"
|
||||
className="p-1 rounded text-muted-foreground hover:text-rose-600 hover:bg-rose-500/10 cursor-pointer"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -13,6 +13,9 @@ 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 { CustomFieldInputs, useCustomFieldValues } from '@/features/settings/CustomFieldInputs';
|
||||
import { ItemSuppliersManager } from './ItemSuppliersManager';
|
||||
import { type Product } from '../types';
|
||||
|
||||
// 폼 검증 스키마. 필수: 상품명/상품코드/단가/최저가. 나머지는 선택.
|
||||
@ -128,7 +131,13 @@ export function ProductFormSheet({
|
||||
defaultValues: buildDefaults(mode, product),
|
||||
});
|
||||
|
||||
const deliveryTypes = DELIVERY_TYPE_OPTIONS;
|
||||
const label = useLabels(); // 회사 설정 용어
|
||||
// 배송유형 선택지 — 회사 설정 용어(delivery_type.N)로 라벨만 치환(코드값 불변)
|
||||
const deliveryTypes = DELIVERY_TYPE_OPTIONS.map((o) => ({ ...o, label: label(`delivery_type.${o.value}`) }));
|
||||
// 회사 상품 커스텀필드(정의=companies.settings.item_fields, 값=items.custom)
|
||||
const { settings } = useCompanySettings();
|
||||
const itemFields = settings.item_fields ?? [];
|
||||
const customValues = useCustomFieldValues(itemFields, product?.custom);
|
||||
|
||||
// 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정·삭제(프론트 1차 차단, 백엔드도 강제).
|
||||
const myUserId = useAuthStore((s) => s.user?.userId);
|
||||
@ -162,6 +171,7 @@ export function ProductFormSheet({
|
||||
internet_lowest_price: v.minPrice,
|
||||
purchase_price: v.purchasePrice,
|
||||
selling_price: v.sellingPrice,
|
||||
...(itemFields.length > 0 ? { custom: customValues.values } : {}),
|
||||
};
|
||||
|
||||
if (mode === 'create') {
|
||||
@ -235,7 +245,7 @@ export function ProductFormSheet({
|
||||
</div>
|
||||
{/* Category — 기존(서버 items distinct) 선택 또는 새 카테고리 직접 입력(datalist 콤보) */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">분류 카테고리</Typography>
|
||||
<Typography as="label" variant="label">{label('category')}</Typography>
|
||||
<Input
|
||||
id="form-product-category"
|
||||
list="form-product-category-options"
|
||||
@ -263,7 +273,7 @@ export function ProductFormSheet({
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Price */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">상품 단가 (₩)</Typography>
|
||||
<Typography as="label" variant="label">{label('item.price')} (₩)</Typography>
|
||||
<Input
|
||||
id="form-product-price"
|
||||
type="number"
|
||||
@ -359,7 +369,7 @@ export function ProductFormSheet({
|
||||
</div>
|
||||
{/* Lead Time */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">배송 리드타임 (일)</Typography>
|
||||
<Typography as="label" variant="label">{label('lead_time')} (일)</Typography>
|
||||
<Input
|
||||
type="number"
|
||||
{...register('leadTime', { valueAsNumber: true })}
|
||||
@ -465,6 +475,12 @@ export function ProductFormSheet({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공급사 관리(IMK #20) — 수정 모드(상품 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */}
|
||||
{mode === 'edit' && product && <ItemSuppliersManager itemId={product.item_id} />}
|
||||
|
||||
{/* 회사 커스텀 필드 — companies.settings.item_fields 정의대로 렌더, items.custom 에 저장 */}
|
||||
<CustomFieldInputs fields={itemFields} state={customValues} title="회사 추가 항목" />
|
||||
|
||||
{/* Drag-and-Drop Image Dropzone */}
|
||||
<Controller
|
||||
control={control}
|
||||
|
||||
@ -2,6 +2,7 @@ import { Image as ImageIcon } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DataTable } from '@/components/ui/data-table';
|
||||
import { TablePagination } from '@/components/ui/table-pagination';
|
||||
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||
import { type Product } from '../types';
|
||||
|
||||
type ProductTableProps = {
|
||||
@ -27,6 +28,7 @@ export function ProductTable({
|
||||
pageSize,
|
||||
onPageChange,
|
||||
}: ProductTableProps) {
|
||||
const label = useLabels(); // 회사 설정 용어(카테고리/상품 단가 등)
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
@ -73,7 +75,7 @@ export function ProductTable({
|
||||
cell: (prod) => prod.code,
|
||||
},
|
||||
{
|
||||
header: '카테고리',
|
||||
header: label('category'),
|
||||
align: 'left',
|
||||
cell: (prod) => (
|
||||
<Badge variant="outline" className="text-[10.5px] border-border text-foreground font-medium bg-muted py-0.5 px-1.5 rounded-full">
|
||||
@ -82,7 +84,21 @@ export function ProductTable({
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '상품 단가',
|
||||
header: '공급사',
|
||||
align: 'left',
|
||||
cell: (prod) => {
|
||||
const names = prod.supplier_names ?? [];
|
||||
if (names.length === 0) return <span className="text-muted-foreground">-</span>;
|
||||
return (
|
||||
<span title={names.join(', ')} className="whitespace-nowrap">
|
||||
{names[0]}
|
||||
{names.length > 1 && <span className="text-muted-foreground"> 외 {names.length - 1}</span>}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: label('item.price'),
|
||||
align: 'right',
|
||||
cellClassName: 'font-mono font-bold text-foreground',
|
||||
cell: (prod) => `₩${(prod.price || 0).toLocaleString()}`,
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
useListItemSupplyTypes,
|
||||
createSupplierItem,
|
||||
updateSupplyType,
|
||||
deleteSupplierItem,
|
||||
getListItemSupplyTypesQueryKey,
|
||||
} from '@/api/generated/supplier-item/supplier-item';
|
||||
import type { ItemSupplyType } from '@/api/generated/model/itemSupplyType';
|
||||
|
||||
// 상품 취급 공급사(매핑) 서버 데이터 + CRUD — useSupplierItems(협력사측)의 상품측 미러.
|
||||
// 같은 partner.supplier_items 매핑을 상품 상세(ProductFormSheet)에서 편집한다.
|
||||
// itemId 가 없으면(신규 등록 폼) 쿼리는 비활성.
|
||||
export function useItemSuppliers(itemId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
const listQuery = useListItemSupplyTypes(itemId ?? '', { query: { enabled: !!itemId } });
|
||||
|
||||
const refresh = () =>
|
||||
itemId
|
||||
? queryClient.invalidateQueries({ queryKey: getListItemSupplyTypesQueryKey(itemId) })
|
||||
: Promise.resolve();
|
||||
|
||||
const suppliers: ItemSupplyType[] = listQuery.data?.suppliers ?? [];
|
||||
|
||||
const addSupplier = async (supplierId: string, supplyType: number) => {
|
||||
if (!itemId) return;
|
||||
const res = await createSupplierItem({ supplier_id: supplierId, item_id: itemId, supply_type: supplyType });
|
||||
const r = res.result;
|
||||
if (r && r.success === false) {
|
||||
throw new Error(r.desc === 'DB_ALREADY_SAME_KEY' ? '이미 등록된 공급사입니다.' : r.desc || '공급사 추가 실패');
|
||||
}
|
||||
await refresh();
|
||||
};
|
||||
|
||||
const changeType = async (supplierItemId: string, supplyType: number) => {
|
||||
await updateSupplyType(supplierItemId, { supply_type: supplyType });
|
||||
await refresh();
|
||||
};
|
||||
|
||||
const removeSupplier = async (supplierItemId: string) => {
|
||||
await deleteSupplierItem(supplierItemId);
|
||||
await refresh();
|
||||
};
|
||||
|
||||
return { suppliers, isLoading: listQuery.isLoading, addSupplier, changeType, removeSupplier };
|
||||
}
|
||||
@ -1,11 +1,12 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { X, PlusSquare, ArrowRight, Loader2, Gavel } from 'lucide-react';
|
||||
import { X, PlusSquare, ArrowRight, Loader2, Gavel, CheckCheck } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item';
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import { Typography, typographyVariants } from '@/components/ui/typography';
|
||||
import { cn } from '@/lib/utils';
|
||||
@ -125,6 +126,7 @@ export function QuotationCreateModal({
|
||||
|
||||
// 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함).
|
||||
const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } });
|
||||
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
|
||||
const supplyTypeBySupplier = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
(supplyTypeQuery.data?.suppliers ?? []).forEach((s) => m.set(s.supplier_id, s.supply_type));
|
||||
@ -232,6 +234,17 @@ export function QuotationCreateModal({
|
||||
if (row) setCardDetails((m) => new Map(m).set(id, { code: row.code, title: row.title, isWildcard: row.isWildcard }));
|
||||
setSelectedCardIds((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]));
|
||||
};
|
||||
// 일괄 선택(IMK #22) — 현재 목록(검색 결과)의 카드를 전부 담는다. 이미 담긴 카드는 유지.
|
||||
const selectAllCards = () => {
|
||||
const rows = cardRows.filter((c) => !c.isWildcard || c.status === 'ACTIVE');
|
||||
setCardDetails((m) => {
|
||||
const next = new Map(m);
|
||||
rows.forEach((r) => next.set(r.id, { code: r.code, title: r.title, isWildcard: r.isWildcard }));
|
||||
return next;
|
||||
});
|
||||
setSelectedCardIds((prev) => [...new Set([...prev, ...rows.map((r) => r.id)])]);
|
||||
};
|
||||
const clearAllCards = () => setSelectedCardIds([]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (submitting) return;
|
||||
@ -496,7 +509,7 @@ export function QuotationCreateModal({
|
||||
{(value) => {
|
||||
const qs = quotationSettings.find((s) => s.qt_setting_id === value);
|
||||
return qs
|
||||
? `[목표 마진: ${qs.target_margin}] 카드 ${qs.card_use_count}`
|
||||
? `[${label('target_margin')}: ${qs.target_margin}] 카드 ${qs.card_use_count}`
|
||||
: '';
|
||||
}}
|
||||
</SelectValue>
|
||||
@ -504,7 +517,7 @@ export function QuotationCreateModal({
|
||||
<SelectContent>
|
||||
{quotationSettings.map((qs) => (
|
||||
<SelectItem key={qs.qt_setting_id} value={qs.qt_setting_id}>
|
||||
[목표 마진: {qs.target_margin}] 카드 {qs.card_use_count}
|
||||
[{label('target_margin')}: {qs.target_margin}] 카드 {qs.card_use_count}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@ -559,7 +572,19 @@ export function QuotationCreateModal({
|
||||
{/* Step 4 — 협상카드(1:1 협상 전용, 별도 스텝으로 분리해 과밀 방지) */}
|
||||
{step === 4 && oneToOne && (
|
||||
<div className="space-y-2">
|
||||
<Typography as="span" variant="label" className="block">협상카드 및 와일드카드 선택 (선택)</Typography>
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography as="span" variant="label" className="block">협상카드 및 와일드카드 선택 (선택)</Typography>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button type="button" variant="outline" size="sm" className="h-7 px-2.5 text-[11px] gap-1" onClick={selectAllCards}>
|
||||
<CheckCheck size={13} />
|
||||
현재 목록 전체선택
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" className="h-7 px-2.5 text-[11px] gap-1 text-muted-foreground" onClick={clearAllCards} disabled={selectedCardIds.length === 0}>
|
||||
<X size={13} />
|
||||
전체해제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Typography as="span" variant="small" className="block text-[10px] text-muted-foreground">
|
||||
1:1 협상에서 AI 협상봇이 발동할 카드입니다.
|
||||
</Typography>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { Download, Sparkles } from 'lucide-react';
|
||||
import type { SessionData } from '@/api/generated/model/sessionData';
|
||||
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
|
||||
import type { ChatMessageData } from '@/api/generated/model/chatMessageData';
|
||||
@ -8,8 +8,53 @@ import { Typography } from '@/components/ui/typography';
|
||||
import { StatusPill, sessionStatusTone } from './StatusPill';
|
||||
import { type Product, type Partner, sessionStatusLabel } from '../../types';
|
||||
import { maskPrices } from '@/lib/utils';
|
||||
import { useCompanySettings } from '@/features/settings/useCompanySettings';
|
||||
import { renderEmphasis } from '@/lib/emphasis';
|
||||
|
||||
// 협상로그 JSON 다운로드(IMK #9). 가격·비율 숫자는 maskPrices 로 가려 내보낸다(화면 표기와 동일 규칙).
|
||||
// target_price 등 숫자 필드는 아예 제외 — 양식은 대화 흐름(순번/발화자/스텝/멘트/카드사용) 중심.
|
||||
function exportChatJson(
|
||||
session: SessionData | undefined,
|
||||
supplierName: string,
|
||||
productName: string | undefined,
|
||||
messages: ChatMessageData[],
|
||||
serverCards: QuotationCardData[],
|
||||
) {
|
||||
const payload = {
|
||||
exported_at: new Date().toISOString(),
|
||||
session_id: session?.session_id ?? null,
|
||||
qt_number: session?.qt_number ?? null,
|
||||
supplier: supplierName || null,
|
||||
product: productName ?? null,
|
||||
status: session ? sessionStatusLabel(session.status) : null,
|
||||
message_count: messages.length,
|
||||
messages: messages.map((m) => {
|
||||
// 사용 카드는 화면 버블과 동일하게 chat_id 로 매칭(card_type 만으론 어느 카드인지 알 수 없음).
|
||||
const card = m.card_used_yn ? serverCards.find((c) => c.session_card_id === m.chat_id) : undefined;
|
||||
return {
|
||||
seq: m.index,
|
||||
sender: m.sender === ChatSender.BOT ? 'BOT' : 'PARTNER',
|
||||
step: m.step ?? null,
|
||||
script: maskPrices(String(m.script ?? '')),
|
||||
card: card
|
||||
? {
|
||||
number: card.number ?? null,
|
||||
name: card.name ?? null,
|
||||
type: card.type === CardType.WILD ? 'wild' : 'nego',
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `협상로그_${session?.qt_number ?? 'session'}_${(supplierName || '').replace(/\s+/g, '')}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function ChatTab({
|
||||
serverSessions,
|
||||
partners,
|
||||
@ -32,6 +77,13 @@ export function ChatTab({
|
||||
// 목표가는 협상(세션) 단위 고정값(sessions.target_price)이라 메시지마다가 아니라 헤더에 한 번만 표시한다.
|
||||
const currentSession = serverSessions.find((s) => s.session_id === effectiveSessionId);
|
||||
const targetPrice = currentSession?.target_price;
|
||||
// 협상완료 부가정보(sessions.custom) — 공급사가 타결 후 입력. 라벨은 회사 설정(session_fields)에서.
|
||||
const { settings } = useCompanySettings();
|
||||
const sessionFields = settings.session_fields ?? [];
|
||||
const custom = (currentSession?.custom ?? {}) as Record<string, unknown>;
|
||||
const extraRows = sessionFields
|
||||
.map((f) => ({ label: f.label, value: custom[f.key] }))
|
||||
.filter((r) => r.value !== undefined && r.value !== null && r.value !== '');
|
||||
return (
|
||||
<div className="h-[500px] border border-border rounded-lg overflow-hidden bg-card flex">
|
||||
{/* Sessions list */}
|
||||
@ -90,9 +142,31 @@ export function ChatTab({
|
||||
<Typography as="span" variant="small" className="text-xs font-mono text-muted-foreground">
|
||||
기록: <Typography as="span" variant="small" className="text-xs font-mono font-semibold text-foreground">{chatMessages.length}</Typography> 메시지
|
||||
</Typography>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exportChatJson(currentSession, currentSupplierName, currentProduct?.name, chatMessages, serverCards)}
|
||||
disabled={chatMessages.length === 0}
|
||||
title="협상로그 JSON 내보내기 (금액 숫자는 가려서 저장)"
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-border text-[10px] font-mono text-muted-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
<Download size={11} />
|
||||
JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 협상완료 부가정보(공급사 입력) — 값이 있을 때만 노출 */}
|
||||
{extraRows.length > 0 && (
|
||||
<div className="px-3 py-2 bg-emerald-500/5 border-b border-border flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<Typography as="span" variant="small" className="text-[10px] font-mono font-bold text-emerald-700 dark:text-emerald-400">협상완료 부가정보</Typography>
|
||||
{extraRows.map((r) => (
|
||||
<Typography key={r.label} as="span" variant="small" className="text-[11px] font-mono text-muted-foreground">
|
||||
{r.label}: <span className="font-semibold text-foreground">{String(r.value)}</span>
|
||||
</Typography>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0 p-4 overflow-y-auto space-y-4">
|
||||
{!effectiveSessionId ? (
|
||||
<Typography as="div" variant="small" className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs">
|
||||
|
||||
@ -8,6 +8,7 @@ import { InfoField } from './InfoField';
|
||||
import { QuotationStatusBadge } from './StatusPill';
|
||||
import { ResultSummaryBand } from './ResultSummaryBand';
|
||||
import type { QuotationData } from '@/api/generated/model/quotationData';
|
||||
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||
import {
|
||||
type Product,
|
||||
type QuotationSetting,
|
||||
@ -50,6 +51,7 @@ export function DrawerHeaderCards({
|
||||
onShowTarget,
|
||||
collapsed = false,
|
||||
}: DrawerHeaderCardsProps) {
|
||||
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
|
||||
// 접으면 결과 요약 밴드만 노출(목표가·낙찰·절감). 상세 그리드 계산은 건너뛴다.
|
||||
if (collapsed) {
|
||||
return (
|
||||
@ -178,7 +180,7 @@ export function DrawerHeaderCards({
|
||||
{selectedSettingObj ? (
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
|
||||
<InfoField
|
||||
label="목표 마진율"
|
||||
label={label('target_margin')}
|
||||
value={selectedSettingObj.target_margin}
|
||||
valueClassName="font-bold text-emerald-600 dark:text-emerald-400 font-sans"
|
||||
/>
|
||||
|
||||
@ -2,6 +2,7 @@ import { X, Check } from 'lucide-react';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { useGetTargetBreakdown } from '@/api/generated/quotation/quotation';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||
|
||||
// 세션 목표가 산정내역 모달. 후보·채택·앵커링가는 백엔드 /target-breakdown 이 산정한 값을 '표시만' 한다.
|
||||
// (프론트 재계산 없음 → 저장된 목표가와 항상 일치. 산정 로직은 백엔드 _candidates 단일 출처.)
|
||||
@ -18,10 +19,11 @@ type TargetPriceModalProps = {
|
||||
const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-');
|
||||
|
||||
// 후보 basis 별 부가 설명(수수료/마진 적용 표시). 백엔드 라벨에 없는 보조 문구만 프론트가 덧붙인다.
|
||||
const CANDIDATE_SUB: Record<string, string> = {
|
||||
// 회사 설정 용어(목표 마진)를 반영해 컴포넌트 안에서 만든다.
|
||||
const candidateSub = (marginLabel: string): Record<string, string> => ({
|
||||
internet: '인터넷 평균 수수료 적용',
|
||||
selling: '목표 마진율 적용',
|
||||
};
|
||||
selling: `${marginLabel} 적용`,
|
||||
});
|
||||
|
||||
export function TargetPriceModal({
|
||||
onClose,
|
||||
@ -33,6 +35,8 @@ export function TargetPriceModal({
|
||||
category,
|
||||
}: TargetPriceModalProps) {
|
||||
useScrollLock(); // 모달은 열릴 때만 마운트(부모 게이트) → 배경 스크롤 잠금
|
||||
const label = useLabels(); // 회사 설정 용어
|
||||
const CANDIDATE_SUB = candidateSub(`${label('target_margin')}`);
|
||||
const { data: bd, isLoading } = useGetTargetBreakdown(sessionId, { query: { enabled: !!sessionId } });
|
||||
const candidates = bd?.candidates ?? [];
|
||||
|
||||
@ -77,9 +81,9 @@ 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−목표 마진율)</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} · 목표 마진율: {bd.margin}{bd.is_new ? ' · 신규견적이라 인터넷최저가만 적용' : ''}
|
||||
* 인터넷 평균 수수료: {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">
|
||||
|
||||
@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||
import { type QuotationSetting } from '../types';
|
||||
import type { SettingInput } from '../hooks/useQuotations';
|
||||
|
||||
@ -24,6 +25,7 @@ export function QuotationSettingsModal({
|
||||
onClose,
|
||||
}: QuotationSettingsModalProps) {
|
||||
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
||||
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
|
||||
const [targetMargin, setTargetMargin] = useState('');
|
||||
const [cardUseCount, setCardUseCount] = useState('');
|
||||
|
||||
@ -61,7 +63,7 @@ export function QuotationSettingsModal({
|
||||
<Table className="w-full text-left font-mono text-[11px] divide-y divide-border">
|
||||
<TableHeader className="bg-muted text-muted-foreground">
|
||||
<TableRow>
|
||||
<TableHead className="p-2">목표 마진율</TableHead>
|
||||
<TableHead className="p-2">{label('target_margin')}</TableHead>
|
||||
<TableHead className="p-2">카드 사용 횟수</TableHead>
|
||||
<TableHead className="p-2 text-center w-12">삭제</TableHead>
|
||||
</TableRow>
|
||||
@ -100,7 +102,7 @@ export function QuotationSettingsModal({
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="muted" className="text-[10px] font-semibold">목표 마진율 (%)</Typography>
|
||||
<Typography as="label" variant="muted" className="text-[10px] font-semibold">{label('target_margin')} (%)</Typography>
|
||||
<Input type="number" step="0.1" value={targetMargin} onChange={(e) => setTargetMargin(e.target.value)} placeholder="예: 12" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
|
||||
@ -26,6 +26,7 @@ import type { ReqCreateQuotation } from '@/api/generated/model/reqCreateQuotatio
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { confirm } from '@/lib/confirm';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||
import type { Estimate } from '../types';
|
||||
import { mapItem, mapSupplier, mapSetting, mapQuotation } from '../types';
|
||||
import { QuotationStatus } from '@/api/generated/model';
|
||||
@ -54,6 +55,7 @@ export type SettingInput = {
|
||||
// 상품/협력사/세팅/견적은 서버(orval)에서 읽고, 견적·세팅·채팅은 로컬 state로 낙관적 갱신한다.
|
||||
// (협상카드 카탈로그/채팅은 백엔드 미연동 → 빈 상태)
|
||||
export function useQuotations(params: ListQuotationsParams) {
|
||||
const label = useLabels(); // 회사 설정 용어(목표 마진율 등)
|
||||
const queryClient = useQueryClient();
|
||||
const itemsQuery = useListItems({ size: 100 });
|
||||
const suppliersQuery = useListSuppliers({ size: 100 });
|
||||
@ -149,7 +151,7 @@ export function useQuotations(params: ListQuotationsParams) {
|
||||
const marginPct = Number(String(input.targetMargin).replace('%', '').trim());
|
||||
const cardCount = parseInt(String(input.cardUseCount).replace(/[^0-9-]/g, ''), 10);
|
||||
if (!Number.isFinite(marginPct) || !Number.isInteger(cardCount)) {
|
||||
showToast('목표 마진율·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error');
|
||||
showToast(`${label('target_margin')}·카드 사용 횟수를 숫자로 입력해야 합니다.`, 'error');
|
||||
return false;
|
||||
}
|
||||
createSettingMutation.mutate(
|
||||
|
||||
70
negodata/front/src/features/settings/CustomFieldInputs.tsx
Normal file
70
negodata/front/src/features/settings/CustomFieldInputs.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { useState } from 'react';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { CustomFieldDef } from './catalog';
|
||||
|
||||
// 커스텀필드 값 상태. 정의(fields)와 기존 값(initial=엔티티의 custom JSONB)으로 초기화하고,
|
||||
// 저장 시 state.values 를 payload 의 custom 으로 보낸다.
|
||||
export function useCustomFieldValues(fields: CustomFieldDef[], initial?: Record<string, unknown> | null) {
|
||||
const [values, setValues] = useState<Record<string, unknown>>(() => {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const f of fields) out[f.key] = initial?.[f.key] ?? (f.type === 'boolean' ? false : '');
|
||||
return out;
|
||||
});
|
||||
const set = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value }));
|
||||
return { values, set };
|
||||
}
|
||||
|
||||
type CustomFieldInputsProps = {
|
||||
fields: CustomFieldDef[];
|
||||
state: ReturnType<typeof useCustomFieldValues>;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
// 회사 설정(item_fields/supplier_fields) 정의대로 입력 UI 를 렌더한다. 정의가 없으면 아무것도 그리지 않는다.
|
||||
export function CustomFieldInputs({ fields, state, title = '회사 추가 항목' }: CustomFieldInputsProps) {
|
||||
if (fields.length === 0) return null;
|
||||
return (
|
||||
<div className="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">{title}</span>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{fields.map((f) => (
|
||||
<div key={f.key} className={f.type === 'boolean' ? 'col-span-2' : 'space-y-1'}>
|
||||
{f.type === 'boolean' ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-foreground">{f.label}:</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={!!state.values[f.key]}
|
||||
onClick={() => state.set(f.key, !state.values[f.key])}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-all duration-200 focus:outline-none ${
|
||||
state.values[f.key] ? 'bg-indigo-600 dark:bg-indigo-500' : 'bg-zinc-300 dark:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow transition-all duration-200 ${
|
||||
state.values[f.key] ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Typography as="label" variant="label">{f.label}</Typography>
|
||||
<Input
|
||||
type={f.type === 'number' ? 'number' : 'text'}
|
||||
className={f.type === 'number' ? 'font-mono text-xs' : 'text-xs'}
|
||||
value={String(state.values[f.key] ?? '')}
|
||||
onChange={(e) =>
|
||||
state.set(f.key, f.type === 'number' ? (e.target.value === '' ? '' : Number(e.target.value)) : e.target.value)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
367
negodata/front/src/features/settings/SettingsView.tsx
Normal file
367
negodata/front/src/features/settings/SettingsView.tsx
Normal file
@ -0,0 +1,367 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Palette, Tags, ListPlus, Plus, Trash2, RotateCcw } from 'lucide-react';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import ImageDropzone from '@/components/ImageDropzone';
|
||||
import { uploadItemImage } from '@/api/generated/item/item';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/select';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
LABEL_CATALOG,
|
||||
CUSTOM_FIELD_TYPE_LABEL,
|
||||
type CompanySettings,
|
||||
type CustomFieldDef,
|
||||
type CustomFieldType,
|
||||
} from './catalog';
|
||||
import { useCompanySettings } from './useCompanySettings';
|
||||
|
||||
export function SettingsView() {
|
||||
const { settings, isLoading, save } = useCompanySettings();
|
||||
|
||||
// 저장 전 편집본(draft). 서버 반영은 저장 버튼에서만.
|
||||
const [draft, setDraft] = useState<CompanySettings>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isLoading) setDraft(structuredClone(settings));
|
||||
// settings 객체는 쿼리 캐시 무효화 때만 바뀐다(참조 비교로 충분).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoading, JSON.stringify(settings)]);
|
||||
|
||||
const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(settings), [draft, settings]);
|
||||
|
||||
const handleSave = async () => {
|
||||
// 빈 문자열 라벨/브랜딩은 "기본값 사용"이므로 저장 전 제거해 문서를 깨끗하게 유지한다.
|
||||
const labels = Object.fromEntries(Object.entries(draft.labels ?? {}).filter(([, v]) => v.trim()));
|
||||
const branding = Object.fromEntries(Object.entries(draft.branding ?? {}).filter(([, v]) => (v ?? '').trim()));
|
||||
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 next: CompanySettings = {
|
||||
...draft,
|
||||
labels,
|
||||
branding,
|
||||
item_fields: itemFields,
|
||||
supplier_fields: supplierFields,
|
||||
session_fields: sessionFields,
|
||||
};
|
||||
setSaving(true);
|
||||
try {
|
||||
await save(next);
|
||||
showToast('회사 설정이 저장되었습니다.', 'success');
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : '설정 저장 실패', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 로고 파일 업로드 — 상품 이미지와 동일한 스토리지 엔드포인트(/v1/item/image, Azure Blob)를 재사용해 URL 을 받는다.
|
||||
const handleUploadLogo = async (file: File): Promise<string> => {
|
||||
const res = await uploadItemImage({ file: file as unknown as string });
|
||||
if (res.result?.success === false) throw new Error(res.result.desc || '로고 업로드에 실패했습니다.');
|
||||
if (!res.image_url) throw new Error('업로드 응답에 URL 이 없습니다.');
|
||||
return res.image_url;
|
||||
};
|
||||
|
||||
const setBranding = (key: keyof NonNullable<CompanySettings['branding']>, value: string) =>
|
||||
setDraft((d) => ({ ...d, branding: { ...d.branding, [key]: value } }));
|
||||
const setLabel = (key: string, value: string) =>
|
||||
setDraft((d) => ({ ...d, labels: { ...d.labels, [key]: value } }));
|
||||
|
||||
return (
|
||||
<div className="space-y-4 font-mono text-xs">
|
||||
<Tabs defaultValue="branding">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<TabsList>
|
||||
<TabsTrigger value="branding" className="gap-1.5 px-3">
|
||||
<Palette size={13} /> 브랜딩(CI)
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="labels" className="gap-1.5 px-3">
|
||||
<Tags size={13} /> 용어(라벨)
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="fields" className="gap-1.5 px-3">
|
||||
<ListPlus size={13} /> 커스텀 필드
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 저장 바 — 변경이 있을 때만 활성화 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{dirty && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setDraft(structuredClone(settings))}>
|
||||
<RotateCcw size={13} /> 되돌리기
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={handleSave} disabled={!dirty || saving}>
|
||||
{saving ? '저장 중...' : '변경사항 저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- 브랜딩(CI) ---- */}
|
||||
<TabsContent value="branding" className="space-y-4">
|
||||
<SectionCard
|
||||
title="서비스 브랜딩"
|
||||
desc="사이드바·타이틀에 노출되는 서비스명과 로고입니다. 비워두면 기본 브랜드(NegoData)가 사용됩니다."
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Field label="서비스명" hint="예: iMarketKorea 구매협상 콘솔">
|
||||
<Input
|
||||
value={draft.branding?.service_name ?? ''}
|
||||
onChange={(e) => setBranding('service_name', e.target.value)}
|
||||
placeholder="NegoData (기본값)"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="브랜드 색상" hint="브랜드 마크 색 (hex)">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
aria-label="브랜드 색상 선택"
|
||||
className="size-8 rounded border border-border bg-transparent p-0.5 cursor-pointer"
|
||||
value={draft.branding?.primary_color || '#5E6AD2'}
|
||||
onChange={(e) => setBranding('primary_color', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
className="w-32"
|
||||
value={draft.branding?.primary_color ?? ''}
|
||||
onChange={(e) => setBranding('primary_color', e.target.value)}
|
||||
placeholder="#5E6AD2"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="이메일 헤더 문구" hint="협상 초청 메일 상단 브랜드 문구">
|
||||
<Input
|
||||
value={draft.branding?.email_header ?? ''}
|
||||
onChange={(e) => setBranding('email_header', e.target.value)}
|
||||
placeholder="NEGODATA (기본값)"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* 로고 이미지 — 업로드(드롭/선택) 또는 URL 직접 입력. 비우면 색상 마크+서비스명 텍스트. */}
|
||||
<div className="mt-4">
|
||||
<Field label="로고 이미지" hint="비우면 색상 마크 + 서비스명 텍스트로 표시됩니다.">
|
||||
<ImageDropzone
|
||||
value={draft.branding?.logo_url ?? ''}
|
||||
onChange={(url) => setBranding('logo_url', url)}
|
||||
onClear={() => setBranding('logo_url', '')}
|
||||
onUpload={handleUploadLogo}
|
||||
label="로고 이미지 업로드"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* 라이브 미리보기 */}
|
||||
<div className="mt-4 border border-border rounded-md p-3 bg-muted/30">
|
||||
<Typography variant="muted" className="text-[10px] block mb-2">미리보기 — 사이드바 브랜드</Typography>
|
||||
<div className="inline-flex items-center gap-2 font-extrabold tracking-tight text-foreground text-sm bg-background border border-border rounded px-3 py-2">
|
||||
{draft.branding?.logo_url ? (
|
||||
<img src={draft.branding.logo_url} alt="로고 미리보기" className="h-4 max-w-24 object-contain" />
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-4 rounded-[5px]"
|
||||
style={{ backgroundColor: draft.branding?.primary_color || 'var(--primary)' }}
|
||||
/>
|
||||
)}
|
||||
{draft.branding?.service_name || 'NegoData'}
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</TabsContent>
|
||||
|
||||
{/* ---- 용어(라벨) ---- */}
|
||||
<TabsContent value="labels" className="space-y-4">
|
||||
<SectionCard
|
||||
title="용어 커스터마이징"
|
||||
desc="우리 회사에서 쓰는 용어로 화면 표기를 바꿉니다. 비워두면 기본 용어가 사용됩니다."
|
||||
>
|
||||
<div className="border border-border rounded overflow-hidden">
|
||||
<Table className="w-full text-left font-mono text-[11px]">
|
||||
<TableHeader className="bg-muted text-muted-foreground">
|
||||
<TableRow>
|
||||
<TableHead className="p-2 w-40">기본 용어</TableHead>
|
||||
<TableHead className="p-2 w-56">우리 회사 용어</TableHead>
|
||||
<TableHead className="p-2">적용 위치</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="divide-y divide-border bg-background">
|
||||
{LABEL_CATALOG.map((entry) => {
|
||||
const value = draft.labels?.[entry.key] ?? '';
|
||||
return (
|
||||
<TableRow key={entry.key} className="hover:bg-muted/30">
|
||||
<TableCell className="p-2 font-bold text-foreground">{entry.base}</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Input
|
||||
className="h-7 text-[11px]"
|
||||
value={value}
|
||||
onChange={(e) => setLabel(entry.key, e.target.value)}
|
||||
placeholder={`${entry.base} (기본값)`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-2 text-muted-foreground">
|
||||
{entry.where}
|
||||
{value.trim() && (
|
||||
<Badge variant="secondary" className="ml-2 text-[9px] px-1 py-0">변경됨</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</TabsContent>
|
||||
|
||||
{/* ---- 커스텀 필드 ---- */}
|
||||
<TabsContent value="fields" className="space-y-4">
|
||||
<CustomFieldsEditor
|
||||
title="상품 커스텀 필드"
|
||||
desc="상품 등록·수정 화면에 추가로 입력받을 항목입니다. (예: 발주배수, 하도급 여부)"
|
||||
fields={draft.item_fields ?? []}
|
||||
onChange={(fields) => setDraft((d) => ({ ...d, item_fields: fields }))}
|
||||
/>
|
||||
<CustomFieldsEditor
|
||||
title="협력사 커스텀 필드"
|
||||
desc="협력사 등록·수정 화면에 추가로 입력받을 항목입니다. (예: 분류카테고리, 유통레벨)"
|
||||
fields={draft.supplier_fields ?? []}
|
||||
onChange={(fields) => setDraft((d) => ({ ...d, supplier_fields: fields }))}
|
||||
/>
|
||||
<CustomFieldsEditor
|
||||
title="협상완료 부가정보 필드"
|
||||
desc="공급사가 협상 타결 후 입력할 항목입니다. (예: 표준납기, 최소주문수량, 발주배수, 배송유형)"
|
||||
fields={draft.session_fields ?? []}
|
||||
onChange={(fields) => setDraft((d) => ({ ...d, session_fields: fields }))}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({ title, desc, children }: { title: string; desc: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-5">
|
||||
<Typography variant="h3" className="mb-1">{title}</Typography>
|
||||
<Typography variant="muted" className="text-[11px] block mb-4">{desc}</Typography>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="muted" className="text-[10px] font-semibold">{label}</Typography>
|
||||
{children}
|
||||
{hint && <Typography variant="muted" className="text-[10px] block">{hint}</Typography>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 커스텀필드 정의 편집 — 표시명을 입력하면 key 를 자동 제안하되 직접 수정도 가능.
|
||||
function CustomFieldsEditor({
|
||||
title,
|
||||
desc,
|
||||
fields,
|
||||
onChange,
|
||||
}: {
|
||||
title: string;
|
||||
desc: string;
|
||||
fields: CustomFieldDef[];
|
||||
onChange: (fields: CustomFieldDef[]) => void;
|
||||
}) {
|
||||
const update = (i: number, patch: Partial<CustomFieldDef>) =>
|
||||
onChange(fields.map((f, idx) => (idx === i ? { ...f, ...patch } : f)));
|
||||
|
||||
return (
|
||||
<SectionCard title={title} desc={desc}>
|
||||
<div className="border border-border rounded overflow-hidden">
|
||||
<Table className="w-full text-left font-mono text-[11px]">
|
||||
<TableHeader className="bg-muted text-muted-foreground">
|
||||
<TableRow>
|
||||
<TableHead className="p-2 w-52">표시명</TableHead>
|
||||
<TableHead className="p-2 w-52">키 (영문)</TableHead>
|
||||
<TableHead className="p-2 w-36">유형</TableHead>
|
||||
<TableHead className="p-2 text-center w-12">삭제</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="divide-y divide-border bg-background">
|
||||
{fields.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="p-6 text-center text-muted-foreground">
|
||||
추가된 커스텀 필드가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{fields.map((f, i) => (
|
||||
<TableRow key={i} className="hover:bg-muted/30">
|
||||
<TableCell className="p-2">
|
||||
<Input
|
||||
className="h-7 text-[11px]"
|
||||
value={f.label}
|
||||
onChange={(e) => update(i, { label: e.target.value })}
|
||||
placeholder="예: 발주배수"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Input
|
||||
className="h-7 text-[11px]"
|
||||
value={f.key}
|
||||
onChange={(e) => update(i, { key: sanitizeKey(e.target.value) })}
|
||||
placeholder="예: order_multiple"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Select<CustomFieldType>
|
||||
value={f.type}
|
||||
onValueChange={(v) => v && update(i, { type: v })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[11px] w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(CUSTOM_FIELD_TYPE_LABEL) as CustomFieldType[]).map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{CUSTOM_FIELD_TYPE_LABEL[t]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="p-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(fields.filter((_, idx) => idx !== i))}
|
||||
className="text-red-500 hover:text-red-700 p-1 rounded hover:bg-red-50 cursor-pointer"
|
||||
title="필드 삭제"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex justify-end pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onChange([...fields, { key: '', label: '', type: 'text' }])}
|
||||
>
|
||||
<Plus size={13} /> 필드 추가
|
||||
</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
// key 입력 정리 — 영문/숫자/언더스코어만 허용(소문자화).
|
||||
function sanitizeKey(raw: string): string {
|
||||
return raw.toLowerCase().replace(/[^a-z0-9_]/g, '');
|
||||
}
|
||||
51
negodata/front/src/features/settings/catalog.ts
Normal file
51
negodata/front/src/features/settings/catalog.ts
Normal file
@ -0,0 +1,51 @@
|
||||
// 회사 커스터마이징 설정(companies.settings JSONB) 문서 타입 + 용어 라벨 카탈로그.
|
||||
// 라벨 키는 여기 한 곳에만 추가한다 — 설정 화면(용어 탭)과 화면 배선(useLabel)이 같은 카탈로그를 읽는다.
|
||||
|
||||
export type CustomFieldType = 'text' | 'number' | 'boolean';
|
||||
|
||||
export type CustomFieldDef = {
|
||||
key: string; // custom JSONB 의 키 (영문 snake_case)
|
||||
label: string; // 화면 표시명
|
||||
type: CustomFieldType;
|
||||
};
|
||||
|
||||
export type CompanySettings = {
|
||||
branding?: {
|
||||
service_name?: string; // 사이드바/타이틀 서비스명 (기본 NegoData)
|
||||
logo_url?: string; // 로고 이미지 URL. 없으면 색상 사각형+텍스트
|
||||
primary_color?: string; // 브랜드 색 (hex)
|
||||
email_header?: string; // 초청 메일 헤더 문구 (기본 NEGODATA)
|
||||
};
|
||||
labels?: Record<string, string>; // 카탈로그 키 → 이 회사 용어 (없으면 기본값)
|
||||
features?: Record<string, unknown>; // 회사별 동작 플래그 (예: target_price_mode)
|
||||
item_fields?: CustomFieldDef[]; // 상품 커스텀필드 정의 → items.custom
|
||||
supplier_fields?: CustomFieldDef[]; // 협력사 커스텀필드 정의 → suppliers.custom
|
||||
session_fields?: CustomFieldDef[]; // 협상완료 부가정보 정의 → sessions.custom (공급사가 타결 후 입력)
|
||||
};
|
||||
|
||||
export type LabelCatalogEntry = {
|
||||
key: string;
|
||||
base: string; // 기본(우리 솔루션) 용어
|
||||
where: string; // 적용 위치 안내 (설정 화면 표시용)
|
||||
};
|
||||
|
||||
// 용어 카탈로그. base 가 fallback 이므로 배선된 화면은 설정이 비어 있어도 기존과 동일하게 보인다.
|
||||
export const LABEL_CATALOG: LabelCatalogEntry[] = [
|
||||
{ key: 'target_margin', base: '목표 마진율', where: '견적 세팅, 목표가 산정내역, 견적 생성' },
|
||||
{ key: 'item.price', 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' },
|
||||
];
|
||||
|
||||
export const LABEL_DEFAULTS: Record<string, string> = Object.fromEntries(
|
||||
LABEL_CATALOG.map((e) => [e.key, e.base]),
|
||||
);
|
||||
|
||||
export const CUSTOM_FIELD_TYPE_LABEL: Record<CustomFieldType, string> = {
|
||||
text: '텍스트',
|
||||
number: '숫자',
|
||||
boolean: '예/아니오',
|
||||
};
|
||||
36
negodata/front/src/features/settings/useCompanySettings.ts
Normal file
36
negodata/front/src/features/settings/useCompanySettings.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useGetSettings, updateSettings, getGetSettingsQueryKey } from '@/api/generated/company-settings/company-settings';
|
||||
import { LABEL_DEFAULTS, type CompanySettings } from './catalog';
|
||||
|
||||
// 회사 커스터마이징 설정 조회 + 저장.
|
||||
// 조회는 전 유저(브랜딩/라벨 렌더용), 저장은 백엔드가 OWNER 로 게이트한다.
|
||||
export function useCompanySettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const query = useGetSettings({ query: { staleTime: 5 * 60 * 1000 } });
|
||||
const settings: CompanySettings = (query.data?.settings as CompanySettings) ?? {};
|
||||
|
||||
const save = async (next: CompanySettings) => {
|
||||
const res = await updateSettings({ settings: next as Record<string, unknown> });
|
||||
if (res.result?.success === false) throw new Error(res.result.desc || '설정 저장에 실패했습니다.');
|
||||
await queryClient.invalidateQueries({ queryKey: getGetSettingsQueryKey() });
|
||||
};
|
||||
|
||||
return { settings, isLoading: query.isLoading, save };
|
||||
}
|
||||
|
||||
// 용어 라벨 헬퍼. label('item.price') → 회사 설정 용어, 없으면 카탈로그 기본값.
|
||||
export function useLabels() {
|
||||
const { settings } = useCompanySettings();
|
||||
const overrides = settings.labels ?? {};
|
||||
return (key: string): string => overrides[key] || LABEL_DEFAULTS[key] || key;
|
||||
}
|
||||
|
||||
// 브랜딩 헬퍼. 서비스명·로고 — 미설정 시 기본 브랜드(NegoData).
|
||||
export function useBranding() {
|
||||
const { settings } = useCompanySettings();
|
||||
return {
|
||||
serviceName: settings.branding?.service_name || 'NegoData',
|
||||
logoUrl: settings.branding?.logo_url || null,
|
||||
primaryColor: settings.branding?.primary_color || null,
|
||||
};
|
||||
}
|
||||
@ -1,10 +1,13 @@
|
||||
import { Plus, Upload, Download, FileSpreadsheet, ChevronDown } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Plus, Upload, Download, FileSpreadsheet, ChevronDown, Trash2 } from 'lucide-react';
|
||||
import { useOverlayRouter } from '@/lib/useOverlayRouter';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { confirm } from '@/lib/confirm';
|
||||
import { PageContainer } from '@/components/layout/PageContainer';
|
||||
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||
import { useServerList } from '@/lib/useServerList';
|
||||
import { usePartners } from '@/features/partners/hooks/usePartners';
|
||||
@ -36,6 +39,27 @@ export default function PartnersPage() {
|
||||
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
|
||||
const isFormOpen = overlay.has('new') || !!editing;
|
||||
|
||||
// 협력사 삭제는 최고관리자 전용(단건 삭제와 동일 규칙) — 일괄삭제 버튼도 최고관리자에게만 노출.
|
||||
const isSuperAdmin = useAuthStore((st) => st.user?.role === '최고관리자');
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
if (!(await confirm({ title: '선택 협력사 일괄 삭제', description: `선택한 ${selectedIds.length}개 협력사를 명부에서 삭제하시겠습니까?`, confirmText: '삭제', destructive: true }))) return;
|
||||
let ok = 0;
|
||||
let fail = 0;
|
||||
for (const id of selectedIds) {
|
||||
try {
|
||||
await deletePartner(id);
|
||||
ok += 1;
|
||||
} catch {
|
||||
fail += 1;
|
||||
}
|
||||
}
|
||||
setSelectedIds([]);
|
||||
showToast(fail === 0 ? `${ok}개 협력사가 삭제되었습니다.` : `${ok}개 삭제 · ${fail}개 실패`, fail === 0 ? 'info' : 'error');
|
||||
};
|
||||
|
||||
const openCreate = () => overlay.open('new');
|
||||
const openEdit = (part: Partner) => overlay.open('detail', part.supplier_id);
|
||||
|
||||
@ -55,6 +79,19 @@ export default function PartnersPage() {
|
||||
<PageToolbar
|
||||
actions={
|
||||
<>
|
||||
{isSuperAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={selectedIds.length === 0}
|
||||
onClick={handleBulkDelete}
|
||||
className="text-rose-600 hover:text-rose-700"
|
||||
>
|
||||
<Trash2 />
|
||||
선택 삭제
|
||||
{selectedIds.length > 0 && <Badge variant="destructive">{selectedIds.length}</Badge>}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="outline" />}>
|
||||
<FileSpreadsheet />
|
||||
@ -92,6 +129,8 @@ export default function PartnersPage() {
|
||||
|
||||
<PartnerTable
|
||||
data={partners}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
onRowClick={openEdit}
|
||||
page={list.page}
|
||||
totalPages={totalPages}
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Upload, TrendingDown, Download, FileSpreadsheet, ChevronDown } from 'lucide-react';
|
||||
import { Plus, Upload, TrendingDown, Download, FileSpreadsheet, ChevronDown, Trash2 } from 'lucide-react';
|
||||
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 { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -19,6 +20,8 @@ import { ExcelUploadModal, downloadProductTemplate } from '@/features/products/c
|
||||
import { type Product } from '@/features/products/types';
|
||||
|
||||
export default function ProductsPage() {
|
||||
const label = useLabels(); // 회사 설정 용어
|
||||
const { settings } = useCompanySettings(); // 엑셀 양식 커스텀필드(item_fields)
|
||||
// 검색/카테고리/페이지 상태(재사용 훅) → 서버 쿼리 파라미터로 변환.
|
||||
const list = useServerList({ pageSize: 10, initialFilters: { category: 'ALL' } });
|
||||
const categoryFilter = list.filters.category;
|
||||
@ -57,6 +60,24 @@ export default function ProductsPage() {
|
||||
const openCreate = () => overlay.open('new');
|
||||
const openEdit = (prod: Product) => overlay.open('detail', prod.item_id);
|
||||
|
||||
// 선택 일괄삭제(IMK #8) — 단건 삭제 API 루프(엑셀 일괄등록과 동일 패턴). 소유자 아닌 행은 서버가 거부 → 실패 집계.
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
if (!(await confirm({ title: '선택 상품 일괄 삭제', description: `선택한 ${selectedIds.length}개 상품을 삭제하시겠습니까?`, confirmText: '삭제', destructive: true }))) return;
|
||||
let ok = 0;
|
||||
let fail = 0;
|
||||
for (const id of selectedIds) {
|
||||
try {
|
||||
await deleteProduct(id);
|
||||
ok += 1;
|
||||
} catch {
|
||||
fail += 1;
|
||||
}
|
||||
}
|
||||
setSelectedIds([]);
|
||||
showToast(fail === 0 ? `${ok}개 상품이 삭제되었습니다.` : `${ok}개 삭제 · ${fail}개 실패(권한 등)`, fail === 0 ? 'info' : 'error');
|
||||
};
|
||||
|
||||
const handleDeleteProduct = async (id: string, prodName: string) => {
|
||||
if (await confirm({ title: '상품 삭제', description: `[${prodName}] 상품 정보를 완전 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
|
||||
try {
|
||||
@ -73,6 +94,17 @@ export default function ProductsPage() {
|
||||
<PageToolbar
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={selectedIds.length === 0}
|
||||
onClick={handleBulkDelete}
|
||||
className="text-rose-600 hover:text-rose-700"
|
||||
>
|
||||
<Trash2 />
|
||||
선택 삭제
|
||||
{selectedIds.length > 0 && <Badge variant="destructive">{selectedIds.length}</Badge>}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={selectedIds.length === 0}
|
||||
@ -94,7 +126,7 @@ export default function ProductsPage() {
|
||||
<Upload />
|
||||
일괄 업로드
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={downloadProductTemplate}>
|
||||
<DropdownMenuItem onClick={() => downloadProductTemplate(label, settings.item_fields ?? [])}>
|
||||
<Download />
|
||||
양식 다운로드
|
||||
</DropdownMenuItem>
|
||||
@ -120,13 +152,13 @@ export default function ProductsPage() {
|
||||
<Select value={categoryFilter} onValueChange={(v) => list.setFilter('category', v as string)}>
|
||||
<SelectTrigger id="product-category-filter" className="w-full sm:w-48">
|
||||
<SelectValue>
|
||||
{(value) => (value === 'ALL' ? '품목 카테고리 (전체)' : value)}
|
||||
{(value) => (value === 'ALL' ? `품목 ${label('category')} (전체)` : value)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['ALL', ...categories].map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
{cat === 'ALL' ? '품목 카테고리 (전체)' : cat}
|
||||
{cat === 'ALL' ? `품목 ${label('category')} (전체)` : cat}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
11
negodata/front/src/pages/settings.tsx
Normal file
11
negodata/front/src/pages/settings.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { PageContainer } from '@/components/layout/PageContainer';
|
||||
import { SettingsView } from '@/features/settings/SettingsView';
|
||||
|
||||
// 회사 설정(최고관리자 전용) — 브랜딩(CI)/용어(라벨)/커스텀 필드. 라우트 loader 가 OWNER 를 게이트한다.
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<SettingsView />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@ -35,4 +35,4 @@ export interface NegotiationCard {
|
||||
creatorName?: string; // 등록자(작성자) 이름. 공용(user_id NULL) 카드는 없음
|
||||
}
|
||||
|
||||
export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS' | 'NOTIFICATIONS';
|
||||
export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS' | 'SETTINGS' | 'NOTIFICATIONS';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user