[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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
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.enums import ErrorType
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from common.utils.gtime import GTime
|
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]:
|
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def supplier_name_map(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ItemCRUD(IItemCRUD):
|
class ItemCRUD(IItemCRUD):
|
||||||
async def search(
|
async def search(
|
||||||
@ -184,3 +188,29 @@ class ItemCRUD(IItemCRUD):
|
|||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, {}
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
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.enums import ErrorType
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
@ -48,7 +48,8 @@ class ISupplierItemCRUD(ABC):
|
|||||||
|
|
||||||
class SupplierItemCRUD(ISupplierItemCRUD):
|
class SupplierItemCRUD(ISupplierItemCRUD):
|
||||||
async def list_by_supplier(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, list]:
|
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:
|
try:
|
||||||
query = (
|
query = (
|
||||||
select(
|
select(
|
||||||
@ -57,6 +58,8 @@ class SupplierItemCRUD(ISupplierItemCRUD):
|
|||||||
items.name,
|
items.name,
|
||||||
items.code,
|
items.code,
|
||||||
supplier_items.supply_type,
|
supplier_items.supply_type,
|
||||||
|
items.category,
|
||||||
|
items.manufacturer,
|
||||||
)
|
)
|
||||||
.join(items, items.item_id == supplier_items.item_id)
|
.join(items, items.item_id == supplier_items.item_id)
|
||||||
.where(
|
.where(
|
||||||
@ -75,11 +78,22 @@ class SupplierItemCRUD(ISupplierItemCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, []
|
return ErrorType.DB_RUN_FAILED, []
|
||||||
|
|
||||||
async def list_by_item(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, list]:
|
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:
|
try:
|
||||||
query = select(supplier_items.supplier_id, supplier_items.supply_type).where(
|
query = (
|
||||||
supplier_items.item_id == item_id,
|
select(
|
||||||
supplier_items.deleted == False, # noqa: E712
|
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)
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||||
if err_type != ErrorType.SUCCESS:
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
|||||||
@ -35,6 +35,10 @@ class IUserCRUD(ABC):
|
|||||||
async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]:
|
async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update_company_settings(self, cdb: AsyncSession, company_id, settings: dict) -> ErrorType:
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def list_by_company(self, cdb: AsyncSession, company_id, search, skip, limit) -> Tuple[ErrorType, list, int]:
|
async def list_by_company(self, cdb: AsyncSession, company_id, search, skip, limit) -> Tuple[ErrorType, list, int]:
|
||||||
pass
|
pass
|
||||||
@ -103,6 +107,18 @@ class UserCRUD(IUserCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, None
|
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(
|
async def list_by_company(
|
||||||
self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int
|
self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int
|
||||||
) -> Tuple[ErrorType, list, int]:
|
) -> Tuple[ErrorType, list, int]:
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from config.server_configs import web_server_config
|
|||||||
from scheduler import shutdown_scheduler, start_scheduler
|
from scheduler import shutdown_scheduler, start_scheduler
|
||||||
import router.v1.auth.account
|
import router.v1.auth.account
|
||||||
import router.v1.company.user
|
import router.v1.company.user
|
||||||
|
import router.v1.company.settings
|
||||||
import router.v1.item.item
|
import router.v1.item.item
|
||||||
import router.v1.supplier.supplier
|
import router.v1.supplier.supplier
|
||||||
import router.v1.supplier_item.supplier_item
|
import router.v1.supplier_item.supplier_item
|
||||||
@ -68,6 +69,7 @@ async def healthz():
|
|||||||
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
|
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
|
||||||
app.include_router(router.v1.auth.account.router)
|
app.include_router(router.v1.auth.account.router)
|
||||||
app.include_router(router.v1.company.user.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.item.item.router)
|
||||||
app.include_router(router.v1.supplier.supplier.router)
|
app.include_router(router.v1.supplier.supplier.router)
|
||||||
app.include_router(router.v1.supplier_item.supplier_item.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):
|
class Res_DeleteCompanyUser(Res_WebPacketProtocol):
|
||||||
pass
|
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
|
delivery_type: Optional[int] = None
|
||||||
vat_yn: Optional[bool] = None
|
vat_yn: Optional[bool] = None
|
||||||
delivery_fee_yn: Optional[bool] = None
|
delivery_fee_yn: Optional[bool] = None
|
||||||
|
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value} (정의는 companies.settings.item_fields)
|
||||||
|
|
||||||
|
|
||||||
class Req_UpdateItem(ItemProtocol):
|
class Req_UpdateItem(ItemProtocol):
|
||||||
@ -56,6 +57,7 @@ class Req_UpdateItem(ItemProtocol):
|
|||||||
delivery_type: Optional[int] = None
|
delivery_type: Optional[int] = None
|
||||||
vat_yn: Optional[bool] = None
|
vat_yn: Optional[bool] = None
|
||||||
delivery_fee_yn: Optional[bool] = None
|
delivery_fee_yn: Optional[bool] = None
|
||||||
|
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value}
|
||||||
|
|
||||||
|
|
||||||
class ItemData(WebPacketProtocol):
|
class ItemData(WebPacketProtocol):
|
||||||
@ -85,6 +87,8 @@ class ItemData(WebPacketProtocol):
|
|||||||
delivery_type: Optional[DeliveryType] = None
|
delivery_type: Optional[DeliveryType] = None
|
||||||
vat_yn: Optional[bool] = None
|
vat_yn: Optional[bool] = None
|
||||||
delivery_fee_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
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|||||||
@ -111,6 +111,7 @@ class SessionData(WebPacketProtocol):
|
|||||||
reject_price: Optional[int] = None
|
reject_price: Optional[int] = None
|
||||||
reject_delivery_type: Optional[DeliveryType] = None
|
reject_delivery_type: Optional[DeliveryType] = None
|
||||||
email_sent_at: Optional[datetime] = None # 협상 초청 메일 발송 시각(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 로 구성
|
url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ class Req_CreateSupplier(SupplierProtocol):
|
|||||||
manager_email: Optional[str] = None
|
manager_email: Optional[str] = None
|
||||||
manager_contact_number: Optional[str] = None
|
manager_contact_number: Optional[str] = None
|
||||||
total_revenue: Optional[int] = None # 총매출액(원)
|
total_revenue: Optional[int] = None # 총매출액(원)
|
||||||
|
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value} (정의는 companies.settings.supplier_fields)
|
||||||
|
|
||||||
|
|
||||||
class Req_UpdateSupplier(SupplierProtocol):
|
class Req_UpdateSupplier(SupplierProtocol):
|
||||||
@ -27,6 +28,7 @@ class Req_UpdateSupplier(SupplierProtocol):
|
|||||||
manager_email: Optional[str] = None
|
manager_email: Optional[str] = None
|
||||||
manager_contact_number: Optional[str] = None
|
manager_contact_number: Optional[str] = None
|
||||||
total_revenue: Optional[int] = None
|
total_revenue: Optional[int] = None
|
||||||
|
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value}
|
||||||
|
|
||||||
|
|
||||||
class SupplierData(WebPacketProtocol):
|
class SupplierData(WebPacketProtocol):
|
||||||
@ -42,6 +44,7 @@ class SupplierData(WebPacketProtocol):
|
|||||||
manager_email: Optional[str] = None
|
manager_email: Optional[str] = None
|
||||||
manager_contact_number: Optional[str] = None
|
manager_contact_number: Optional[str] = None
|
||||||
total_revenue: Optional[int] = None # 총매출액(원)
|
total_revenue: Optional[int] = None # 총매출액(원)
|
||||||
|
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value}
|
||||||
account_login_id: Optional[str] = None # 채팅(협상) 계정 로그인 ID. None=미발급
|
account_login_id: Optional[str] = None # 채팅(협상) 계정 로그인 ID. None=미발급
|
||||||
account_status: Optional[int] = None # SupplierUserStatus. None=미발급
|
account_status: Optional[int] = None # SupplierUserStatus. None=미발급
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
|
|||||||
@ -33,6 +33,8 @@ class SupplierItemData(WebPacketProtocol):
|
|||||||
item_name: str
|
item_name: str
|
||||||
item_code: Optional[str] = None
|
item_code: Optional[str] = None
|
||||||
supply_type: int
|
supply_type: int
|
||||||
|
item_category: Optional[str] = None
|
||||||
|
item_manufacturer: Optional[str] = None
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
@ -40,6 +42,8 @@ class SupplierItemData(WebPacketProtocol):
|
|||||||
class ItemSupplyType(WebPacketProtocol):
|
class ItemSupplyType(WebPacketProtocol):
|
||||||
supplier_id: uuid.UUID
|
supplier_id: uuid.UUID
|
||||||
supply_type: int
|
supply_type: int
|
||||||
|
supplier_name: Optional[str] = None
|
||||||
|
supplier_item_id: Optional[uuid.UUID] = None # 상품측 매핑 편집(수정/삭제)용
|
||||||
|
|
||||||
|
|
||||||
class Res_SupplierItem(Res_WebPacketProtocol):
|
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:
|
if nm_err == ErrorType.SUCCESS:
|
||||||
for d in res.items:
|
for d in res.items:
|
||||||
d.creator_name = name_map.get(d.user_id)
|
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
|
res.total = total
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|||||||
@ -877,6 +877,7 @@ class QuotationService:
|
|||||||
reject_price=r.reject_price,
|
reject_price=r.reject_price,
|
||||||
reject_delivery_type=r.reject_delivery_type,
|
reject_delivery_type=r.reject_delivery_type,
|
||||||
email_sent_at=r.email_sent_at,
|
email_sent_at=r.email_sent_at,
|
||||||
|
custom=r.custom,
|
||||||
url=self._session_chat_url(r.session_id),
|
url=self._session_chat_url(r.session_id),
|
||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
|
|||||||
@ -85,10 +85,11 @@ class SupplierItemService:
|
|||||||
if err_type != ErrorType.SUCCESS:
|
if err_type != ErrorType.SUCCESS:
|
||||||
res.result.SetResult(err_type)
|
res.result.SetResult(err_type)
|
||||||
return res
|
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 = [
|
res.supplier_items = [
|
||||||
SupplierItemData(
|
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
|
for r in rows
|
||||||
]
|
]
|
||||||
@ -109,8 +110,10 @@ class SupplierItemService:
|
|||||||
if err_type != ErrorType.SUCCESS:
|
if err_type != ErrorType.SUCCESS:
|
||||||
res.result.SetResult(err_type)
|
res.result.SetResult(err_type)
|
||||||
return res
|
return res
|
||||||
# Row(supplier_id, supply_type)
|
# Row(supplier_id, supply_type, name, supplier_item_id)
|
||||||
res.suppliers = [ItemSupplyType(supplier_id=r[0], supply_type=r[1]) for r in rows]
|
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
|
return res
|
||||||
|
|
||||||
async def create(self, company_id: str, req: Req_CreateSupplierItem) -> Res_SupplierItem:
|
async def create(self, company_id: str, req: Req_CreateSupplierItem) -> Res_SupplierItem:
|
||||||
|
|||||||
@ -170,6 +170,7 @@ class SupplierService:
|
|||||||
manager_email=req.manager_email,
|
manager_email=req.manager_email,
|
||||||
manager_contact_number=req.manager_contact_number,
|
manager_contact_number=req.manager_contact_number,
|
||||||
total_revenue=req.total_revenue,
|
total_revenue=req.total_revenue,
|
||||||
|
custom=req.custom,
|
||||||
)
|
)
|
||||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
[suppliers.DBType()],
|
[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 './itemDataCode';
|
||||||
export * from './itemDataCreatedAt';
|
export * from './itemDataCreatedAt';
|
||||||
export * from './itemDataCreatorName';
|
export * from './itemDataCreatorName';
|
||||||
|
export * from './itemDataCustom';
|
||||||
|
export * from './itemDataCustomAnyOf';
|
||||||
export * from './itemDataDeliveryFeeYn';
|
export * from './itemDataDeliveryFeeYn';
|
||||||
export * from './itemDataDeliveryType';
|
export * from './itemDataDeliveryType';
|
||||||
export * from './itemDataImageUrl';
|
export * from './itemDataImageUrl';
|
||||||
@ -72,6 +74,8 @@ export * from './itemDataSpec';
|
|||||||
export * from './itemDataUpdatedAt';
|
export * from './itemDataUpdatedAt';
|
||||||
export * from './itemDataVatYn';
|
export * from './itemDataVatYn';
|
||||||
export * from './itemSupplyType';
|
export * from './itemSupplyType';
|
||||||
|
export * from './itemSupplyTypeSupplierItemId';
|
||||||
|
export * from './itemSupplyTypeSupplierName';
|
||||||
export * from './listCardsParams';
|
export * from './listCardsParams';
|
||||||
export * from './listItemsParams';
|
export * from './listItemsParams';
|
||||||
export * from './listNotificationsParams';
|
export * from './listNotificationsParams';
|
||||||
@ -139,6 +143,8 @@ export * from './reqCreateCompanyUser';
|
|||||||
export * from './reqCreateItem';
|
export * from './reqCreateItem';
|
||||||
export * from './reqCreateItemCategory';
|
export * from './reqCreateItemCategory';
|
||||||
export * from './reqCreateItemCode';
|
export * from './reqCreateItemCode';
|
||||||
|
export * from './reqCreateItemCustom';
|
||||||
|
export * from './reqCreateItemCustomAnyOf';
|
||||||
export * from './reqCreateItemDeliveryFeeYn';
|
export * from './reqCreateItemDeliveryFeeYn';
|
||||||
export * from './reqCreateItemDeliveryType';
|
export * from './reqCreateItemDeliveryType';
|
||||||
export * from './reqCreateItemImageUrl';
|
export * from './reqCreateItemImageUrl';
|
||||||
@ -168,6 +174,8 @@ export * from './reqCreateQuotationVersionId';
|
|||||||
export * from './reqCreateSupplier';
|
export * from './reqCreateSupplier';
|
||||||
export * from './reqCreateSupplierAccount';
|
export * from './reqCreateSupplierAccount';
|
||||||
export * from './reqCreateSupplierCode';
|
export * from './reqCreateSupplierCode';
|
||||||
|
export * from './reqCreateSupplierCustom';
|
||||||
|
export * from './reqCreateSupplierCustomAnyOf';
|
||||||
export * from './reqCreateSupplierItem';
|
export * from './reqCreateSupplierItem';
|
||||||
export * from './reqCreateSupplierManagerContactNumber';
|
export * from './reqCreateSupplierManagerContactNumber';
|
||||||
export * from './reqCreateSupplierManagerEmail';
|
export * from './reqCreateSupplierManagerEmail';
|
||||||
@ -186,6 +194,8 @@ export * from './reqUpdateCardNumber';
|
|||||||
export * from './reqUpdateCardScript';
|
export * from './reqUpdateCardScript';
|
||||||
export * from './reqUpdateCardStatus';
|
export * from './reqUpdateCardStatus';
|
||||||
export * from './reqUpdateCardUsageType';
|
export * from './reqUpdateCardUsageType';
|
||||||
|
export * from './reqUpdateCompanySettings';
|
||||||
|
export * from './reqUpdateCompanySettingsSettings';
|
||||||
export * from './reqUpdateCompanyUser';
|
export * from './reqUpdateCompanyUser';
|
||||||
export * from './reqUpdateCompanyUserContactNumber';
|
export * from './reqUpdateCompanyUserContactNumber';
|
||||||
export * from './reqUpdateCompanyUserEmail';
|
export * from './reqUpdateCompanyUserEmail';
|
||||||
@ -196,6 +206,8 @@ export * from './reqUpdateItem';
|
|||||||
export * from './reqUpdateItemCategory';
|
export * from './reqUpdateItemCategory';
|
||||||
export * from './reqUpdateItemCategoryType';
|
export * from './reqUpdateItemCategoryType';
|
||||||
export * from './reqUpdateItemCode';
|
export * from './reqUpdateItemCode';
|
||||||
|
export * from './reqUpdateItemCustom';
|
||||||
|
export * from './reqUpdateItemCustomAnyOf';
|
||||||
export * from './reqUpdateItemDeliveryFeeYn';
|
export * from './reqUpdateItemDeliveryFeeYn';
|
||||||
export * from './reqUpdateItemDeliveryType';
|
export * from './reqUpdateItemDeliveryType';
|
||||||
export * from './reqUpdateItemImageUrl';
|
export * from './reqUpdateItemImageUrl';
|
||||||
@ -224,6 +236,8 @@ export * from './reqUpdateQuotationSettingTargetMarginRate';
|
|||||||
export * from './reqUpdateSupplier';
|
export * from './reqUpdateSupplier';
|
||||||
export * from './reqUpdateSupplierAccountStatus';
|
export * from './reqUpdateSupplierAccountStatus';
|
||||||
export * from './reqUpdateSupplierCode';
|
export * from './reqUpdateSupplierCode';
|
||||||
|
export * from './reqUpdateSupplierCustom';
|
||||||
|
export * from './reqUpdateSupplierCustomAnyOf';
|
||||||
export * from './reqUpdateSupplierManagerContactNumber';
|
export * from './reqUpdateSupplierManagerContactNumber';
|
||||||
export * from './reqUpdateSupplierManagerEmail';
|
export * from './reqUpdateSupplierManagerEmail';
|
||||||
export * from './reqUpdateSupplierManagerName';
|
export * from './reqUpdateSupplierManagerName';
|
||||||
@ -239,6 +253,10 @@ export * from './resCardListMsg';
|
|||||||
export * from './resCardMsg';
|
export * from './resCardMsg';
|
||||||
export * from './resCheckCodes';
|
export * from './resCheckCodes';
|
||||||
export * from './resCheckCodesMsg';
|
export * from './resCheckCodesMsg';
|
||||||
|
export * from './resCompanySettings';
|
||||||
|
export * from './resCompanySettingsMsg';
|
||||||
|
export * from './resCompanySettingsSettings';
|
||||||
|
export * from './resCompanySettingsSettingsAnyOf';
|
||||||
export * from './resCompanyUser';
|
export * from './resCompanyUser';
|
||||||
export * from './resCompanyUserList';
|
export * from './resCompanyUserList';
|
||||||
export * from './resCompanyUserListMsg';
|
export * from './resCompanyUserListMsg';
|
||||||
@ -361,6 +379,8 @@ export * from './sessionData';
|
|||||||
export * from './sessionDataAnchoringPrice';
|
export * from './sessionDataAnchoringPrice';
|
||||||
export * from './sessionDataBidAt';
|
export * from './sessionDataBidAt';
|
||||||
export * from './sessionDataBidPrice';
|
export * from './sessionDataBidPrice';
|
||||||
|
export * from './sessionDataCustom';
|
||||||
|
export * from './sessionDataCustomAnyOf';
|
||||||
export * from './sessionDataEmailSentAt';
|
export * from './sessionDataEmailSentAt';
|
||||||
export * from './sessionDataRejectDeliveryType';
|
export * from './sessionDataRejectDeliveryType';
|
||||||
export * from './sessionDataRejectPrice';
|
export * from './sessionDataRejectPrice';
|
||||||
@ -369,6 +389,7 @@ export * from './sessionStatus';
|
|||||||
export * from './statCardUsage';
|
export * from './statCardUsage';
|
||||||
export * from './statCategory';
|
export * from './statCategory';
|
||||||
export * from './statKpi';
|
export * from './statKpi';
|
||||||
|
export * from './statMarkupPoint';
|
||||||
export * from './statMonthPoint';
|
export * from './statMonthPoint';
|
||||||
export * from './statOutcome';
|
export * from './statOutcome';
|
||||||
export * from './statParticipation';
|
export * from './statParticipation';
|
||||||
@ -382,6 +403,8 @@ export * from './supplierDataAccountStatus';
|
|||||||
export * from './supplierDataCode';
|
export * from './supplierDataCode';
|
||||||
export * from './supplierDataCreatedAt';
|
export * from './supplierDataCreatedAt';
|
||||||
export * from './supplierDataCreatorName';
|
export * from './supplierDataCreatorName';
|
||||||
|
export * from './supplierDataCustom';
|
||||||
|
export * from './supplierDataCustomAnyOf';
|
||||||
export * from './supplierDataManagerContactNumber';
|
export * from './supplierDataManagerContactNumber';
|
||||||
export * from './supplierDataManagerEmail';
|
export * from './supplierDataManagerEmail';
|
||||||
export * from './supplierDataManagerName';
|
export * from './supplierDataManagerName';
|
||||||
@ -389,7 +412,9 @@ export * from './supplierDataTotalRevenue';
|
|||||||
export * from './supplierDataUpdatedAt';
|
export * from './supplierDataUpdatedAt';
|
||||||
export * from './supplierItemData';
|
export * from './supplierItemData';
|
||||||
export * from './supplierItemDataCreatedAt';
|
export * from './supplierItemDataCreatedAt';
|
||||||
|
export * from './supplierItemDataItemCategory';
|
||||||
export * from './supplierItemDataItemCode';
|
export * from './supplierItemDataItemCode';
|
||||||
|
export * from './supplierItemDataItemManufacturer';
|
||||||
export * from './supplierItemDataUpdatedAt';
|
export * from './supplierItemDataUpdatedAt';
|
||||||
export * from './targetCandidate';
|
export * from './targetCandidate';
|
||||||
export * from './userRole';
|
export * from './userRole';
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import type { ItemDataQuantityUnit } from './itemDataQuantityUnit';
|
|||||||
import type { ItemDataDeliveryType } from './itemDataDeliveryType';
|
import type { ItemDataDeliveryType } from './itemDataDeliveryType';
|
||||||
import type { ItemDataVatYn } from './itemDataVatYn';
|
import type { ItemDataVatYn } from './itemDataVatYn';
|
||||||
import type { ItemDataDeliveryFeeYn } from './itemDataDeliveryFeeYn';
|
import type { ItemDataDeliveryFeeYn } from './itemDataDeliveryFeeYn';
|
||||||
|
import type { ItemDataCustom } from './itemDataCustom';
|
||||||
import type { ItemDataCreatedAt } from './itemDataCreatedAt';
|
import type { ItemDataCreatedAt } from './itemDataCreatedAt';
|
||||||
import type { ItemDataUpdatedAt } from './itemDataUpdatedAt';
|
import type { ItemDataUpdatedAt } from './itemDataUpdatedAt';
|
||||||
|
|
||||||
@ -50,6 +51,8 @@ export interface ItemData {
|
|||||||
delivery_type?: ItemDataDeliveryType;
|
delivery_type?: ItemDataDeliveryType;
|
||||||
vat_yn?: ItemDataVatYn;
|
vat_yn?: ItemDataVatYn;
|
||||||
delivery_fee_yn?: ItemDataDeliveryFeeYn;
|
delivery_fee_yn?: ItemDataDeliveryFeeYn;
|
||||||
|
custom?: ItemDataCustom;
|
||||||
|
supplier_names?: string[];
|
||||||
created_at?: ItemDataCreatedAt;
|
created_at?: ItemDataCreatedAt;
|
||||||
updated_at?: ItemDataUpdatedAt;
|
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
|
* Negodata Api Server
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
import type { ItemSupplyTypeSupplierName } from './itemSupplyTypeSupplierName';
|
||||||
|
import type { ItemSupplyTypeSupplierItemId } from './itemSupplyTypeSupplierItemId';
|
||||||
|
|
||||||
export interface ItemSupplyType {
|
export interface ItemSupplyType {
|
||||||
supplier_id: string;
|
supplier_id: string;
|
||||||
supply_type: number;
|
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 { ReqCreateItemDeliveryType } from './reqCreateItemDeliveryType';
|
||||||
import type { ReqCreateItemVatYn } from './reqCreateItemVatYn';
|
import type { ReqCreateItemVatYn } from './reqCreateItemVatYn';
|
||||||
import type { ReqCreateItemDeliveryFeeYn } from './reqCreateItemDeliveryFeeYn';
|
import type { ReqCreateItemDeliveryFeeYn } from './reqCreateItemDeliveryFeeYn';
|
||||||
|
import type { ReqCreateItemCustom } from './reqCreateItemCustom';
|
||||||
|
|
||||||
export interface ReqCreateItem {
|
export interface ReqCreateItem {
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -43,4 +44,5 @@ export interface ReqCreateItem {
|
|||||||
delivery_type?: ReqCreateItemDeliveryType;
|
delivery_type?: ReqCreateItemDeliveryType;
|
||||||
vat_yn?: ReqCreateItemVatYn;
|
vat_yn?: ReqCreateItemVatYn;
|
||||||
delivery_fee_yn?: ReqCreateItemDeliveryFeeYn;
|
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 { ReqCreateSupplierManagerEmail } from './reqCreateSupplierManagerEmail';
|
||||||
import type { ReqCreateSupplierManagerContactNumber } from './reqCreateSupplierManagerContactNumber';
|
import type { ReqCreateSupplierManagerContactNumber } from './reqCreateSupplierManagerContactNumber';
|
||||||
import type { ReqCreateSupplierTotalRevenue } from './reqCreateSupplierTotalRevenue';
|
import type { ReqCreateSupplierTotalRevenue } from './reqCreateSupplierTotalRevenue';
|
||||||
|
import type { ReqCreateSupplierCustom } from './reqCreateSupplierCustom';
|
||||||
|
|
||||||
export interface ReqCreateSupplier {
|
export interface ReqCreateSupplier {
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -17,4 +18,5 @@ export interface ReqCreateSupplier {
|
|||||||
manager_email?: ReqCreateSupplierManagerEmail;
|
manager_email?: ReqCreateSupplierManagerEmail;
|
||||||
manager_contact_number?: ReqCreateSupplierManagerContactNumber;
|
manager_contact_number?: ReqCreateSupplierManagerContactNumber;
|
||||||
total_revenue?: ReqCreateSupplierTotalRevenue;
|
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 { ReqUpdateItemDeliveryType } from './reqUpdateItemDeliveryType';
|
||||||
import type { ReqUpdateItemVatYn } from './reqUpdateItemVatYn';
|
import type { ReqUpdateItemVatYn } from './reqUpdateItemVatYn';
|
||||||
import type { ReqUpdateItemDeliveryFeeYn } from './reqUpdateItemDeliveryFeeYn';
|
import type { ReqUpdateItemDeliveryFeeYn } from './reqUpdateItemDeliveryFeeYn';
|
||||||
|
import type { ReqUpdateItemCustom } from './reqUpdateItemCustom';
|
||||||
|
|
||||||
export interface ReqUpdateItem {
|
export interface ReqUpdateItem {
|
||||||
name?: ReqUpdateItemName;
|
name?: ReqUpdateItemName;
|
||||||
@ -46,4 +47,5 @@ export interface ReqUpdateItem {
|
|||||||
delivery_type?: ReqUpdateItemDeliveryType;
|
delivery_type?: ReqUpdateItemDeliveryType;
|
||||||
vat_yn?: ReqUpdateItemVatYn;
|
vat_yn?: ReqUpdateItemVatYn;
|
||||||
delivery_fee_yn?: ReqUpdateItemDeliveryFeeYn;
|
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 { ReqUpdateSupplierManagerEmail } from './reqUpdateSupplierManagerEmail';
|
||||||
import type { ReqUpdateSupplierManagerContactNumber } from './reqUpdateSupplierManagerContactNumber';
|
import type { ReqUpdateSupplierManagerContactNumber } from './reqUpdateSupplierManagerContactNumber';
|
||||||
import type { ReqUpdateSupplierTotalRevenue } from './reqUpdateSupplierTotalRevenue';
|
import type { ReqUpdateSupplierTotalRevenue } from './reqUpdateSupplierTotalRevenue';
|
||||||
|
import type { ReqUpdateSupplierCustom } from './reqUpdateSupplierCustom';
|
||||||
|
|
||||||
export interface ReqUpdateSupplier {
|
export interface ReqUpdateSupplier {
|
||||||
name?: ReqUpdateSupplierName;
|
name?: ReqUpdateSupplierName;
|
||||||
@ -18,4 +19,5 @@ export interface ReqUpdateSupplier {
|
|||||||
manager_email?: ReqUpdateSupplierManagerEmail;
|
manager_email?: ReqUpdateSupplierManagerEmail;
|
||||||
manager_contact_number?: ReqUpdateSupplierManagerContactNumber;
|
manager_contact_number?: ReqUpdateSupplierManagerContactNumber;
|
||||||
total_revenue?: ReqUpdateSupplierTotalRevenue;
|
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 { SessionDataRejectPrice } from './sessionDataRejectPrice';
|
||||||
import type { SessionDataRejectDeliveryType } from './sessionDataRejectDeliveryType';
|
import type { SessionDataRejectDeliveryType } from './sessionDataRejectDeliveryType';
|
||||||
import type { SessionDataEmailSentAt } from './sessionDataEmailSentAt';
|
import type { SessionDataEmailSentAt } from './sessionDataEmailSentAt';
|
||||||
|
import type { SessionDataCustom } from './sessionDataCustom';
|
||||||
|
|
||||||
export interface SessionData {
|
export interface SessionData {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@ -32,5 +33,6 @@ export interface SessionData {
|
|||||||
reject_price?: SessionDataRejectPrice;
|
reject_price?: SessionDataRejectPrice;
|
||||||
reject_delivery_type?: SessionDataRejectDeliveryType;
|
reject_delivery_type?: SessionDataRejectDeliveryType;
|
||||||
email_sent_at?: SessionDataEmailSentAt;
|
email_sent_at?: SessionDataEmailSentAt;
|
||||||
|
custom?: SessionDataCustom;
|
||||||
url?: string;
|
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 { SupplierDataManagerEmail } from './supplierDataManagerEmail';
|
||||||
import type { SupplierDataManagerContactNumber } from './supplierDataManagerContactNumber';
|
import type { SupplierDataManagerContactNumber } from './supplierDataManagerContactNumber';
|
||||||
import type { SupplierDataTotalRevenue } from './supplierDataTotalRevenue';
|
import type { SupplierDataTotalRevenue } from './supplierDataTotalRevenue';
|
||||||
|
import type { SupplierDataCustom } from './supplierDataCustom';
|
||||||
import type { SupplierDataAccountLoginId } from './supplierDataAccountLoginId';
|
import type { SupplierDataAccountLoginId } from './supplierDataAccountLoginId';
|
||||||
import type { SupplierDataAccountStatus } from './supplierDataAccountStatus';
|
import type { SupplierDataAccountStatus } from './supplierDataAccountStatus';
|
||||||
import type { SupplierDataCreatedAt } from './supplierDataCreatedAt';
|
import type { SupplierDataCreatedAt } from './supplierDataCreatedAt';
|
||||||
@ -26,6 +27,7 @@ export interface SupplierData {
|
|||||||
manager_email?: SupplierDataManagerEmail;
|
manager_email?: SupplierDataManagerEmail;
|
||||||
manager_contact_number?: SupplierDataManagerContactNumber;
|
manager_contact_number?: SupplierDataManagerContactNumber;
|
||||||
total_revenue?: SupplierDataTotalRevenue;
|
total_revenue?: SupplierDataTotalRevenue;
|
||||||
|
custom?: SupplierDataCustom;
|
||||||
account_login_id?: SupplierDataAccountLoginId;
|
account_login_id?: SupplierDataAccountLoginId;
|
||||||
account_status?: SupplierDataAccountStatus;
|
account_status?: SupplierDataAccountStatus;
|
||||||
created_at?: SupplierDataCreatedAt;
|
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
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
import type { SupplierItemDataItemCode } from './supplierItemDataItemCode';
|
import type { SupplierItemDataItemCode } from './supplierItemDataItemCode';
|
||||||
|
import type { SupplierItemDataItemCategory } from './supplierItemDataItemCategory';
|
||||||
|
import type { SupplierItemDataItemManufacturer } from './supplierItemDataItemManufacturer';
|
||||||
import type { SupplierItemDataCreatedAt } from './supplierItemDataCreatedAt';
|
import type { SupplierItemDataCreatedAt } from './supplierItemDataCreatedAt';
|
||||||
import type { SupplierItemDataUpdatedAt } from './supplierItemDataUpdatedAt';
|
import type { SupplierItemDataUpdatedAt } from './supplierItemDataUpdatedAt';
|
||||||
|
|
||||||
@ -14,6 +16,8 @@ export interface SupplierItemData {
|
|||||||
item_name: string;
|
item_name: string;
|
||||||
item_code?: SupplierItemDataItemCode;
|
item_code?: SupplierItemDataItemCode;
|
||||||
supply_type: number;
|
supply_type: number;
|
||||||
|
item_category?: SupplierItemDataItemCategory;
|
||||||
|
item_manufacturer?: SupplierItemDataItemManufacturer;
|
||||||
created_at?: SupplierItemDataCreatedAt;
|
created_at?: SupplierItemDataCreatedAt;
|
||||||
updated_at?: SupplierItemDataUpdatedAt;
|
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 QuotationPage from '../pages/quotation';
|
||||||
import CardsPage from '../pages/cards';
|
import CardsPage from '../pages/cards';
|
||||||
import MembersPage from '../pages/members';
|
import MembersPage from '../pages/members';
|
||||||
|
import SettingsPage from '../pages/settings';
|
||||||
import NotificationsPage from '../pages/notifications';
|
import NotificationsPage from '../pages/notifications';
|
||||||
import OnboardingPage from '../pages/onboarding';
|
import OnboardingPage from '../pages/onboarding';
|
||||||
|
|
||||||
@ -88,6 +89,12 @@ export const router = createBrowserRouter([
|
|||||||
loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')),
|
loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')),
|
||||||
Component: MembersPage,
|
Component: MembersPage,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 최고관리자 전용. 회사 브랜딩/용어/커스텀필드 설정.
|
||||||
|
path: 'settings',
|
||||||
|
loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')),
|
||||||
|
Component: SettingsPage,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -12,6 +12,7 @@ const PAGE_TO_PATH: Record<PageType, string> = {
|
|||||||
QUOTATION: '/quotation',
|
QUOTATION: '/quotation',
|
||||||
CARDS: '/cards',
|
CARDS: '/cards',
|
||||||
MEMBERS: '/members',
|
MEMBERS: '/members',
|
||||||
|
SETTINGS: '/settings',
|
||||||
NOTIFICATIONS: '/notifications',
|
NOTIFICATIONS: '/notifications',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState, type ReactNode, type ElementType } from 'react';
|
import { useEffect, useState, type ReactNode, type ElementType } from 'react';
|
||||||
import { PageType } from '@/types';
|
import { PageType } from '@/types';
|
||||||
import { useAuth } from '@/features/auth/useAuth';
|
import { useAuth } from '@/features/auth/useAuth';
|
||||||
|
import { useBranding } from '@/features/settings/useCompanySettings';
|
||||||
import { ProfileSheet } from '@/features/auth/components/ProfileSheet';
|
import { ProfileSheet } from '@/features/auth/components/ProfileSheet';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@ -58,7 +59,10 @@ const menuGroups: { label?: string; items: MenuItem[] }[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '관리',
|
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: '견적관리',
|
QUOTATION: '견적관리',
|
||||||
CARDS: '협상카드관리',
|
CARDS: '협상카드관리',
|
||||||
MEMBERS: '회원관리',
|
MEMBERS: '회원관리',
|
||||||
|
SETTINGS: '회사 설정',
|
||||||
NOTIFICATIONS: '알림',
|
NOTIFICATIONS: '알림',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Layout({ children, currentPage, setPage, onLogout }: LayoutProps) {
|
export default function Layout({ children, currentPage, setPage, onLogout }: LayoutProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const branding = useBranding(); // 회사 설정 브랜딩(서비스명/로고). 미설정 시 기본 NegoData
|
||||||
// 기준일시(오늘) — 로컬 타임존 기준 YYYY-MM-DD
|
// 기준일시(오늘) — 로컬 타임존 기준 YYYY-MM-DD
|
||||||
const today = new Date().toLocaleDateString('sv-SE');
|
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">
|
<div className="h-14 shrink-0 flex items-center justify-between px-4 border-b border-sidebar-border">
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<Typography as="div" variant="body" className="flex items-center gap-2 font-extrabold tracking-tight text-foreground">
|
<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" />
|
{branding.logoUrl ? (
|
||||||
NegoData
|
<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>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,8 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { PhoneInput } from '@/components/ui/phone-input';
|
import { PhoneInput } from '@/components/ui/phone-input';
|
||||||
import { Sheet } from '@/components/ui/sheet';
|
import { Sheet } from '@/components/ui/sheet';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { useCompanySettings } from '@/features/settings/useCompanySettings';
|
||||||
|
import { CustomFieldInputs, useCustomFieldValues } from '@/features/settings/CustomFieldInputs';
|
||||||
import { SupplierItemsManager } from './SupplierItemsManager';
|
import { SupplierItemsManager } from './SupplierItemsManager';
|
||||||
import { SupplierAccountManager } from './SupplierAccountManager';
|
import { SupplierAccountManager } from './SupplierAccountManager';
|
||||||
import { type Partner } from '../types';
|
import { type Partner } from '../types';
|
||||||
@ -83,6 +85,11 @@ export function PartnerFormSheet({
|
|||||||
// 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(백엔드 RequireOwner 와 동일 규칙).
|
// 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(백엔드 RequireOwner 와 동일 규칙).
|
||||||
const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자');
|
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 onValid = async (v: FormValues) => {
|
||||||
const common = {
|
const common = {
|
||||||
name: v.name,
|
name: v.name,
|
||||||
@ -91,6 +98,7 @@ export function PartnerFormSheet({
|
|||||||
manager_email: v.managerEmail,
|
manager_email: v.managerEmail,
|
||||||
manager_contact_number: v.managerPhone,
|
manager_contact_number: v.managerPhone,
|
||||||
total_revenue: v.totalRevenue?.trim() ? Number(v.totalRevenue.replace(/[^0-9]/g, '')) : undefined,
|
total_revenue: v.totalRevenue?.trim() ? Number(v.totalRevenue.replace(/[^0-9]/g, '')) : undefined,
|
||||||
|
...(supplierFields.length > 0 ? { custom: customValues.values } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
@ -213,6 +221,9 @@ export function PartnerFormSheet({
|
|||||||
{errors.managerPhone && <p className="text-[10px] text-rose-500">{errors.managerPhone.message}</p>}
|
{errors.managerPhone && <p className="text-[10px] text-rose-500">{errors.managerPhone.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 회사 커스텀 필드 — companies.settings.supplier_fields 정의대로 렌더, suppliers.custom 에 저장 */}
|
||||||
|
<CustomFieldInputs fields={supplierFields} state={customValues} title="회사 추가 항목" />
|
||||||
|
|
||||||
{/* 취급상품 관리 — 수정 모드(협력사 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */}
|
{/* 취급상품 관리 — 수정 모드(협력사 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */}
|
||||||
{mode === 'edit' && partner && <SupplierItemsManager supplierId={partner.supplier_id} />}
|
{mode === 'edit' && partner && <SupplierItemsManager supplierId={partner.supplier_id} />}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import type { Partner } from '../types';
|
|||||||
|
|
||||||
type PartnerTableProps = {
|
type PartnerTableProps = {
|
||||||
data: Partner[];
|
data: Partner[];
|
||||||
|
selectedIds: string[];
|
||||||
|
onSelectionChange: (ids: string[]) => void;
|
||||||
onRowClick: (part: Partner) => void;
|
onRowClick: (part: Partner) => void;
|
||||||
page: number;
|
page: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
@ -15,6 +17,8 @@ type PartnerTableProps = {
|
|||||||
|
|
||||||
export function PartnerTable({
|
export function PartnerTable({
|
||||||
data,
|
data,
|
||||||
|
selectedIds,
|
||||||
|
onSelectionChange,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
page,
|
page,
|
||||||
totalPages,
|
totalPages,
|
||||||
@ -27,6 +31,7 @@ export function PartnerTable({
|
|||||||
data={data}
|
data={data}
|
||||||
rowKey={(part) => part.supplier_id}
|
rowKey={(part) => part.supplier_id}
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
|
selection={{ selectedKeys: selectedIds, onSelectionChange }}
|
||||||
empty="협약된 가용 B2B 파트너사가 존재하지 않습니다."
|
empty="협약된 가용 B2B 파트너사가 존재하지 않습니다."
|
||||||
footer={
|
footer={
|
||||||
<TablePagination
|
<TablePagination
|
||||||
|
|||||||
@ -62,6 +62,7 @@ export function SupplierItemsManager({ supplierId }: { supplierId: string }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 분류카테고리 파생(IMK #17, B안) — 취급상품에서 (카테고리-제조원, 공급유형) distinct 집계.
|
||||||
return (
|
return (
|
||||||
<div className="pt-4 border-t border-border space-y-2">
|
<div className="pt-4 border-t border-border space-y-2">
|
||||||
<Typography as="label" variant="label">취급상품 ({items.length})</Typography>
|
<Typography as="label" variant="label">취급상품 ({items.length})</Typography>
|
||||||
@ -110,7 +111,9 @@ export function SupplierItemsManager({ supplierId }: { supplierId: string }) {
|
|||||||
items.map((m) => (
|
items.map((m) => (
|
||||||
<div key={m.supplier_item_id} className="flex items-center justify-between gap-2 p-2">
|
<div key={m.supplier_item_id} className="flex items-center justify-between gap-2 p-2">
|
||||||
<div className="min-w-0">
|
<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 && (
|
{m.item_code && (
|
||||||
<Typography as="span" variant="small" className="text-muted-foreground text-[10px]">{m.item_code}</Typography>
|
<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 { Typography } from '@/components/ui/typography';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
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';
|
import type { Product } from '../types';
|
||||||
|
|
||||||
// 엑셀에서 읽어온 원본 행(입력값만). status/message는 저장하지 않고 검증에서 파생한다.
|
// 엑셀에서 읽어온 원본 행(입력값만). status/message는 저장하지 않고 검증에서 파생한다.
|
||||||
@ -29,20 +31,16 @@ type RawRow = {
|
|||||||
moq: string;
|
moq: string;
|
||||||
lead_time: number;
|
lead_time: number;
|
||||||
quantity_unit: string;
|
quantity_unit: string;
|
||||||
delivery_type: string; // 한글 라벨로 입력 → 전송 시 코드 변환
|
delivery_type: string; // 라벨로 입력 → 전송 시 코드 변환
|
||||||
vat_yn: string; // Y/N
|
vat_yn: string; // Y/N
|
||||||
delivery_fee_yn: string; // Y/N
|
delivery_fee_yn: string; // Y/N
|
||||||
|
custom: Record<string, string>; // 회사 커스텀필드(item_fields) 값 — 전송 시 타입 변환
|
||||||
};
|
};
|
||||||
|
|
||||||
// 검증 결과가 붙은 행. UI는 이걸 그린다.
|
// 검증 결과가 붙은 행. UI는 이걸 그린다.
|
||||||
type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string };
|
type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string };
|
||||||
|
|
||||||
// 배송형태 라벨 ↔ delivery_type 코드(공용 enum 과 동일 집합)
|
type LabelFn = (key: string) => string;
|
||||||
const DELIVERY_LABEL_TO_CODE: Record<string, number> = {
|
|
||||||
협력사배송: 1,
|
|
||||||
지정택배배송: 2,
|
|
||||||
픽업배송: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 자유로운 Y/N 표기 → boolean (Y·예·true·O·포함·1 = true)
|
// 자유로운 Y/N 표기 → boolean (Y·예·true·O·포함·1 = true)
|
||||||
const parseYn = (s: string): boolean => /^(y|yes|true|1|예|o|포함)$/i.test(s.trim());
|
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 =>
|
const isYnToken = (s: string): boolean =>
|
||||||
/^(y|yes|true|1|예|o|포함|n|no|false|0|아니오|x|미포함)$/i.test(s.trim());
|
/^(y|yes|true|1|예|o|포함|n|no|false|0|아니오|x|미포함)$/i.test(s.trim());
|
||||||
|
|
||||||
// 업로드 양식(.csv) 컬럼 정의 — 헤더 ↔ RawRow 필드. 양식/예시/파싱이 이 한 곳을 공유한다.
|
// 표준 컬럼(고정 18개 — 삭제 없음). labelKey 가 있으면 헤더를 회사 설정 용어로 치환하고,
|
||||||
const UPLOAD_COLUMNS: { header: string; key: keyof RawRow }[] = [
|
// base 헤더는 구양식 파일 호환용 별칭으로 계속 인식한다.
|
||||||
{ header: '상품명', key: 'name' },
|
const STANDARD_COLUMNS: { base: string; key: Exclude<keyof RawRow, 'id' | 'rowNum' | 'custom'>; labelKey?: string; suffix?: string }[] = [
|
||||||
{ header: '상품코드', key: 'code' },
|
{ base: '상품명', key: 'name' },
|
||||||
{ header: '모델번호', key: 'model_name' },
|
{ base: '상품코드', key: 'code' },
|
||||||
{ header: '카테고리', key: 'category' },
|
{ base: '모델번호', key: 'model_name' },
|
||||||
{ header: '규격', key: 'spec' },
|
{ base: '카테고리', key: 'category', labelKey: 'category' },
|
||||||
{ header: '제조사', key: 'manufacturer' },
|
{ base: '규격', key: 'spec' },
|
||||||
{ header: '원산지', key: 'made_in' },
|
{ base: '제조사', key: 'manufacturer' },
|
||||||
{ header: '상품 단가', key: 'price' },
|
{ base: '원산지', key: 'made_in' },
|
||||||
{ header: '최저한도', key: 'minPrice' },
|
{ base: '상품 단가', key: 'price', labelKey: 'item.price' },
|
||||||
{ header: '매입가', key: 'purchase_price' },
|
{ base: '최저한도', key: 'minPrice' },
|
||||||
{ header: '판매가', key: 'selling_price' },
|
{ base: '매입가', key: 'purchase_price' },
|
||||||
{ header: '이미지URL', key: 'image_url' },
|
{ base: '판매가', key: 'selling_price' },
|
||||||
{ header: '최소주문수량', key: 'moq' },
|
{ base: '이미지URL', key: 'image_url' },
|
||||||
{ header: '리드타임(일)', key: 'lead_time' },
|
{ base: '최소주문수량', key: 'moq' },
|
||||||
{ header: '단위', key: 'quantity_unit' },
|
{ base: '리드타임(일)', key: 'lead_time', labelKey: 'lead_time', suffix: '(일)' },
|
||||||
{ header: '배송형태', key: 'delivery_type' },
|
{ base: '단위', key: 'quantity_unit' },
|
||||||
{ header: '부가세포함(Y/N)', key: 'vat_yn' },
|
{ base: '배송형태', key: 'delivery_type' },
|
||||||
{ header: '배송비포함(Y/N)', key: 'delivery_fee_yn' },
|
{ base: '부가세포함(Y/N)', key: 'vat_yn' },
|
||||||
|
{ base: '배송비포함(Y/N)', key: 'delivery_fee_yn' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 숫자 입력 컬럼 / 필수 컬럼(헤더에 * 표기)
|
// 렌더/파싱/양식이 공유하는 컬럼(헤더는 회사 설정 반영). customKey 있으면 커스텀필드 컬럼.
|
||||||
const NUMERIC_KEYS = new Set<keyof RawRow>(['price', 'minPrice', 'purchase_price', 'selling_price', 'lead_time']);
|
type UploadColumn = {
|
||||||
const REQUIRED_KEYS = new Set<keyof RawRow>(['name', 'code', 'price']);
|
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>[] = [
|
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',
|
name: '리튬인산철 배터리 모듈', code: 'BAT-LFP-100', model_name: 'LFP-100A',
|
||||||
category: '에너지/배터리', spec: '3.2V 100Ah', manufacturer: '한성에너지', made_in: '대한민국',
|
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',
|
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',
|
vat_yn: 'Y', delivery_fee_yn: 'N',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '산업용 6축 로봇암', code: 'ROB-6AX-22', model_name: 'RX-6A',
|
name: '산업용 6축 로봇암', code: 'ROB-6AX-22', model_name: 'RX-6A',
|
||||||
category: '자동화설비', spec: '가반하중 12kg', manufacturer: '오토메카', made_in: '일본',
|
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',
|
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',
|
vat_yn: 'Y', delivery_fee_yn: 'N',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 업로드 양식(.csv) 다운로드 — 전체 컬럼 헤더 + 예시 행(시드 상품 셋). UPLOAD_COLUMNS 단일 정의 공유. 툴바·모달이 공유한다.
|
// 업로드 양식(.csv) 다운로드 — 회사 설정 헤더(라벨 치환) + 커스텀필드 컬럼 + 예시 행.
|
||||||
export function downloadProductTemplate() {
|
// 파싱(buildColumns)과 같은 정의를 공유하므로 받은 양식이 그대로 다시 업로드된다. 툴바·모달이 공유한다.
|
||||||
|
export function downloadProductTemplate(label: LabelFn, itemFields: CustomFieldDef[]) {
|
||||||
|
const columns = buildColumns(label, itemFields);
|
||||||
downloadExcel<Record<string, string | number>>(
|
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,
|
EXAMPLE_ROWS,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -117,6 +164,10 @@ function validateRows(
|
|||||||
rows: RawRow[],
|
rows: RawRow[],
|
||||||
products: Product[],
|
products: Product[],
|
||||||
serverErrors: Record<string, string>,
|
serverErrors: Record<string, string>,
|
||||||
|
deliveryMap: Record<string, number>,
|
||||||
|
deliveryLabels: string[],
|
||||||
|
priceLabel: string,
|
||||||
|
itemFields: CustomFieldDef[],
|
||||||
): ValidatedRow[] {
|
): ValidatedRow[] {
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
const fail = (message: string): ValidatedRow => ({ ...row, status: '오류', message });
|
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);
|
const dupInExcel = rows.some((other) => other.id !== row.id && other.code === row.code);
|
||||||
if (dupInProducts || dupInExcel) return fail('코드 중복 - 이미 존재하거나 목록 내 중복된 코드입니다.');
|
if (dupInProducts || dupInExcel) return fail('코드 중복 - 이미 존재하거나 목록 내 중복된 코드입니다.');
|
||||||
|
|
||||||
if (row.price <= 0) return fail('유효성 위반 - 상품 단가는 0보다 커야 합니다.');
|
if (row.price <= 0) return fail(`유효성 위반 - ${priceLabel}는 0보다 커야 합니다.`);
|
||||||
if (row.minPrice > row.price) return fail('유효성 위반 - 최저 한도가 상품 단가보다 큽니다.');
|
if (row.minPrice > row.price) return fail(`유효성 위반 - 최저 한도가 ${priceLabel}보다 큽니다.`);
|
||||||
|
|
||||||
// 선택 필드 형식 검증(값이 있을 때만). 배송형태/부가세/배송비/이미지URL.
|
// 선택 필드 형식 검증(값이 있을 때만). 배송형태/부가세/배송비/이미지URL.
|
||||||
if (row.delivery_type.trim() && !(row.delivery_type.trim() in DELIVERY_LABEL_TO_CODE)) {
|
if (row.delivery_type.trim() && !(row.delivery_type.trim() in deliveryMap)) {
|
||||||
return fail('배송형태 - 협력사배송 / 지정택배배송 / 픽업배송 중 하나여야 합니다.');
|
return fail(`배송형태 - ${deliveryLabels.join(' / ')} 중 하나여야 합니다.`);
|
||||||
}
|
}
|
||||||
if (row.vat_yn.trim() && !isYnToken(row.vat_yn)) {
|
if (row.vat_yn.trim() && !isYnToken(row.vat_yn)) {
|
||||||
return fail('부가세포함 - Y 또는 N(예/아니오)으로 입력해 주십시오.');
|
return fail('부가세포함 - Y 또는 N(예/아니오)으로 입력해 주십시오.');
|
||||||
@ -144,6 +195,13 @@ function validateRows(
|
|||||||
if (row.image_url.trim() && !/^https?:\/\//i.test(row.image_url.trim())) {
|
if (row.image_url.trim() && !/^https?:\/\//i.test(row.image_url.trim())) {
|
||||||
return fail('이미지URL - http:// 또는 https:// 로 시작하는 주소여야 합니다.');
|
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]);
|
if (serverErrors[row.code]) return fail(serverErrors[row.code]);
|
||||||
@ -153,7 +211,14 @@ function validateRows(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 검증된(정상) 행 → 서버 생성 payload. 엑셀 업로드 공통 기본값 적용.
|
// 검증된(정상) 행 → 서버 생성 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 {
|
return {
|
||||||
name: row.name,
|
name: row.name,
|
||||||
code: row.code,
|
code: row.code,
|
||||||
@ -169,19 +234,27 @@ function toItemCreate(row: RawRow): ItemCreate {
|
|||||||
moq: row.moq || undefined,
|
moq: row.moq || undefined,
|
||||||
lead_time: row.lead_time || undefined,
|
lead_time: row.lead_time || undefined,
|
||||||
quantity_unit: row.quantity_unit || 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,
|
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,
|
delivery_fee_yn: row.delivery_fee_yn.trim() ? parseYn(row.delivery_fee_yn) : undefined,
|
||||||
// minPrice(최저한도) = 인터넷 최저가(실값) → internet_lowest_price 로 저장(수기 폼과 동일).
|
// minPrice(최저한도) = 인터넷 최저가(실값) → internet_lowest_price 로 저장(수기 폼과 동일).
|
||||||
internet_lowest_price: row.minPrice,
|
internet_lowest_price: row.minPrice,
|
||||||
internet_lowest_price_yn: row.minPrice > 0,
|
internet_lowest_price_yn: row.minPrice > 0,
|
||||||
|
...(Object.keys(custom).length > 0 ? { custom } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 상품 엑셀 일괄 업로드 모달. 파일 파싱(목업)·원본 행 state는 이 컴포넌트가 소유하고,
|
// 상품 엑셀 일괄 업로드 모달. 파일 파싱·원본 행 state는 이 컴포넌트가 소유하고,
|
||||||
// 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임.
|
// 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임.
|
||||||
|
// 컬럼(헤더 라벨·커스텀필드)은 회사 설정을 따른다 — 양식 다운로드와 동일 정의.
|
||||||
export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUploadModalProps) {
|
export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUploadModalProps) {
|
||||||
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
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 [excelFile, setExcelFile] = useState<string | null>(null);
|
||||||
const [rows, setRows] = useState<RawRow[]>([]);
|
const [rows, setRows] = useState<RawRow[]>([]);
|
||||||
const [serverErrors, setServerErrors] = useState<Record<string, string>>({}); // 서버(DB) 거부 code→사유
|
const [serverErrors, setServerErrors] = useState<Record<string, string>>({}); // 서버(DB) 거부 code→사유
|
||||||
@ -190,8 +263,12 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
|||||||
|
|
||||||
// 파생: 검증 결과 + 카운트 (state로 저장하지 않음)
|
// 파생: 검증 결과 + 카운트 (state로 저장하지 않음)
|
||||||
const validated = useMemo(
|
const validated = useMemo(
|
||||||
() => validateRows(rows, products, serverErrors),
|
() => validateRows(
|
||||||
[rows, products, serverErrors],
|
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 validRows = validated.filter((r) => r.status === '정상');
|
||||||
const validCount = validRows.length;
|
const validCount = validRows.length;
|
||||||
@ -206,31 +283,31 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). 헤더는 양식과 동일해야 함.
|
// 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생).
|
||||||
|
// 헤더는 회사 라벨과 기본 헤더(구양식) 둘 다 인식한다(aliases).
|
||||||
const handleFile = async (file: File) => {
|
const handleFile = async (file: File) => {
|
||||||
const parsed = parseCsv(await file.text());
|
const parsed = parseCsv(await file.text());
|
||||||
const loaded: RawRow[] = parsed.map((r, i) => ({
|
const pick = (r: Record<string, string>, c: UploadColumn): string => {
|
||||||
id: `row-${i + 1}`,
|
for (const a of c.aliases) if (r[a] !== undefined) return r[a];
|
||||||
rowNum: i + 2,
|
return '';
|
||||||
name: r['상품명'] ?? '',
|
};
|
||||||
code: r['상품코드'] ?? '',
|
const loaded: RawRow[] = parsed.map((r, i) => {
|
||||||
model_name: r['모델번호'] ?? '',
|
const row: RawRow = {
|
||||||
category: r['카테고리'] ?? '',
|
id: `row-${i + 1}`,
|
||||||
spec: r['규격'] ?? '',
|
rowNum: i + 2,
|
||||||
manufacturer: r['제조사'] ?? '',
|
name: '', code: '', model_name: '', category: '', spec: '', manufacturer: '', made_in: '',
|
||||||
made_in: r['원산지'] ?? '',
|
price: 0, minPrice: 0, purchase_price: 0, selling_price: 0,
|
||||||
price: Number(r['상품 단가']) || 0,
|
image_url: '', moq: '', lead_time: 0, quantity_unit: '', delivery_type: '', vat_yn: '', delivery_fee_yn: '',
|
||||||
minPrice: Number(r['최저한도']) || 0,
|
custom: {},
|
||||||
purchase_price: Number(r['매입가']) || 0,
|
};
|
||||||
selling_price: Number(r['판매가']) || 0,
|
for (const c of columns) {
|
||||||
image_url: r['이미지URL'] ?? '',
|
const v = pick(r, c);
|
||||||
moq: r['최소주문수량'] ?? '',
|
if (c.customKey) row.custom[c.customKey] = v;
|
||||||
lead_time: Number(r['리드타임(일)']) || 0,
|
else if (c.numeric) (row[c.key!] as number) = Number(v) || 0;
|
||||||
quantity_unit: r['단위'] ?? '',
|
else (row[c.key!] as string) = v;
|
||||||
delivery_type: r['배송형태'] ?? '',
|
}
|
||||||
vat_yn: r['부가세포함(Y/N)'] ?? '',
|
return row;
|
||||||
delivery_fee_yn: r['배송비포함(Y/N)'] ?? '',
|
});
|
||||||
}));
|
|
||||||
setExcelFile(file.name);
|
setExcelFile(file.name);
|
||||||
setRows(loaded);
|
setRows(loaded);
|
||||||
setServerErrors({}); // 새 파일 → 직전 서버사유 초기화
|
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) =>
|
setRows((cur) =>
|
||||||
cur.map((row) => {
|
cur.map((row) => {
|
||||||
if (row.id !== id) return row;
|
if (row.id !== id) return row;
|
||||||
if (NUMERIC_KEYS.has(field)) {
|
if (c.customKey) return { ...row, custom: { ...row.custom, [c.customKey]: value } };
|
||||||
return { ...row, [field]: Math.max(0, parseInt(value, 10) || 0) };
|
if (c.numeric) return { ...row, [c.key!]: Math.max(0, parseInt(value, 10) || 0) };
|
||||||
}
|
return { ...row, [c.key!]: value };
|
||||||
return { ...row, [field]: value };
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -276,7 +352,7 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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;
|
const okCount = validRows.length - failures.length;
|
||||||
if (failures.length === 0) {
|
if (failures.length === 0) {
|
||||||
showToast(`총 ${okCount}개 상품이 서버에 일괄 등록되었습니다.`, 'success');
|
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 w-12">삭제</TableHead>
|
||||||
<TableHead className="p-2 font-semibold text-center whitespace-nowrap">자격</TableHead>
|
<TableHead className="p-2 font-semibold text-center whitespace-nowrap">자격</TableHead>
|
||||||
<TableHead className="p-2 font-semibold whitespace-nowrap">진단 내용</TableHead>
|
<TableHead className="p-2 font-semibold whitespace-nowrap">진단 내용</TableHead>
|
||||||
{UPLOAD_COLUMNS.map((c) => (
|
{columns.map((c) => (
|
||||||
<TableHead
|
<TableHead
|
||||||
key={c.key}
|
key={c.header}
|
||||||
className={`p-2 font-semibold whitespace-nowrap ${NUMERIC_KEYS.has(c.key) ? 'text-right' : ''}`}
|
className={`p-2 font-semibold whitespace-nowrap ${c.numeric ? 'text-right' : ''}`}
|
||||||
>
|
>
|
||||||
{c.header}{REQUIRED_KEYS.has(c.key) ? ' *' : ''}
|
{c.header}{c.required ? ' *' : ''}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</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'}`}>
|
<TableCell className={`p-2 font-mono text-[10px] whitespace-nowrap ${row.status === '오류' ? 'text-rose-500' : 'text-emerald-600'}`}>
|
||||||
{row.message}
|
{row.message}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{UPLOAD_COLUMNS.map((c) => {
|
{columns.map((c) => {
|
||||||
const isNum = NUMERIC_KEYS.has(c.key);
|
const value = c.customKey ? row.custom[c.customKey] ?? '' : row[c.key!];
|
||||||
return (
|
return (
|
||||||
<TableCell key={c.key} className={`p-2 ${isNum ? 'text-right' : ''}`}>
|
<TableCell key={c.header} className={`p-2 ${c.numeric ? 'text-right' : ''}`}>
|
||||||
<Input
|
<Input
|
||||||
type={isNum ? 'number' : 'text'}
|
type={c.numeric && !c.customKey ? 'number' : 'text'}
|
||||||
placeholder={c.header}
|
placeholder={c.header}
|
||||||
className={`bg-muted/20 hover:bg-muted/50 ${isNum ? 'w-24 text-right font-mono' : 'min-w-[110px] font-mono'}`}
|
className={`bg-muted/20 hover:bg-muted/50 ${c.numeric ? 'w-24 text-right font-mono' : 'min-w-[110px] font-mono'}`}
|
||||||
value={String(row[c.key] ?? '')}
|
value={String(value ?? '')}
|
||||||
onChange={(e) => handleUpdateField(row.id, c.key, e.target.value)}
|
onChange={(e) => handleUpdateField(row.id, c, e.target.value)}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</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 { Sheet } from '@/components/ui/sheet';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
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';
|
import { type Product } from '../types';
|
||||||
|
|
||||||
// 폼 검증 스키마. 필수: 상품명/상품코드/단가/최저가. 나머지는 선택.
|
// 폼 검증 스키마. 필수: 상품명/상품코드/단가/최저가. 나머지는 선택.
|
||||||
@ -128,7 +131,13 @@ export function ProductFormSheet({
|
|||||||
defaultValues: buildDefaults(mode, product),
|
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차 차단, 백엔드도 강제).
|
// 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정·삭제(프론트 1차 차단, 백엔드도 강제).
|
||||||
const myUserId = useAuthStore((s) => s.user?.userId);
|
const myUserId = useAuthStore((s) => s.user?.userId);
|
||||||
@ -162,6 +171,7 @@ export function ProductFormSheet({
|
|||||||
internet_lowest_price: v.minPrice,
|
internet_lowest_price: v.minPrice,
|
||||||
purchase_price: v.purchasePrice,
|
purchase_price: v.purchasePrice,
|
||||||
selling_price: v.sellingPrice,
|
selling_price: v.sellingPrice,
|
||||||
|
...(itemFields.length > 0 ? { custom: customValues.values } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
@ -235,7 +245,7 @@ export function ProductFormSheet({
|
|||||||
</div>
|
</div>
|
||||||
{/* Category — 기존(서버 items distinct) 선택 또는 새 카테고리 직접 입력(datalist 콤보) */}
|
{/* Category — 기존(서버 items distinct) 선택 또는 새 카테고리 직접 입력(datalist 콤보) */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Typography as="label" variant="label">분류 카테고리</Typography>
|
<Typography as="label" variant="label">{label('category')}</Typography>
|
||||||
<Input
|
<Input
|
||||||
id="form-product-category"
|
id="form-product-category"
|
||||||
list="form-product-category-options"
|
list="form-product-category-options"
|
||||||
@ -263,7 +273,7 @@ export function ProductFormSheet({
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{/* Price */}
|
{/* Price */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Typography as="label" variant="label">상품 단가 (₩)</Typography>
|
<Typography as="label" variant="label">{label('item.price')} (₩)</Typography>
|
||||||
<Input
|
<Input
|
||||||
id="form-product-price"
|
id="form-product-price"
|
||||||
type="number"
|
type="number"
|
||||||
@ -359,7 +369,7 @@ export function ProductFormSheet({
|
|||||||
</div>
|
</div>
|
||||||
{/* Lead Time */}
|
{/* Lead Time */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Typography as="label" variant="label">배송 리드타임 (일)</Typography>
|
<Typography as="label" variant="label">{label('lead_time')} (일)</Typography>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
{...register('leadTime', { valueAsNumber: true })}
|
{...register('leadTime', { valueAsNumber: true })}
|
||||||
@ -465,6 +475,12 @@ export function ProductFormSheet({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Drag-and-Drop Image Dropzone */}
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Image as ImageIcon } from 'lucide-react';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { DataTable } from '@/components/ui/data-table';
|
import { DataTable } from '@/components/ui/data-table';
|
||||||
import { TablePagination } from '@/components/ui/table-pagination';
|
import { TablePagination } from '@/components/ui/table-pagination';
|
||||||
|
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||||
import { type Product } from '../types';
|
import { type Product } from '../types';
|
||||||
|
|
||||||
type ProductTableProps = {
|
type ProductTableProps = {
|
||||||
@ -27,6 +28,7 @@ export function ProductTable({
|
|||||||
pageSize,
|
pageSize,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
}: ProductTableProps) {
|
}: ProductTableProps) {
|
||||||
|
const label = useLabels(); // 회사 설정 용어(카테고리/상품 단가 등)
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
data={data}
|
data={data}
|
||||||
@ -73,7 +75,7 @@ export function ProductTable({
|
|||||||
cell: (prod) => prod.code,
|
cell: (prod) => prod.code,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: '카테고리',
|
header: label('category'),
|
||||||
align: 'left',
|
align: 'left',
|
||||||
cell: (prod) => (
|
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">
|
<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',
|
align: 'right',
|
||||||
cellClassName: 'font-mono font-bold text-foreground',
|
cellClassName: 'font-mono font-bold text-foreground',
|
||||||
cell: (prod) => `₩${(prod.price || 0).toLocaleString()}`,
|
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 { 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 { useNavigate } from 'react-router';
|
||||||
import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item';
|
import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item';
|
||||||
import { useListItems, useGetItem } from '@/api/generated/item/item';
|
import { useListItems, useGetItem } from '@/api/generated/item/item';
|
||||||
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
||||||
import { useListCards } from '@/api/generated/card/card';
|
import { useListCards } from '@/api/generated/card/card';
|
||||||
import { mapCardData } from '@/features/cards/types';
|
import { mapCardData } from '@/features/cards/types';
|
||||||
|
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Typography, typographyVariants } from '@/components/ui/typography';
|
import { Typography, typographyVariants } from '@/components/ui/typography';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@ -125,6 +126,7 @@ export function QuotationCreateModal({
|
|||||||
|
|
||||||
// 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함).
|
// 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함).
|
||||||
const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } });
|
const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } });
|
||||||
|
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
|
||||||
const supplyTypeBySupplier = useMemo(() => {
|
const supplyTypeBySupplier = useMemo(() => {
|
||||||
const m = new Map<string, number>();
|
const m = new Map<string, number>();
|
||||||
(supplyTypeQuery.data?.suppliers ?? []).forEach((s) => m.set(s.supplier_id, s.supply_type));
|
(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 }));
|
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]));
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
if (submitting) return;
|
if (submitting) return;
|
||||||
@ -496,7 +509,7 @@ export function QuotationCreateModal({
|
|||||||
{(value) => {
|
{(value) => {
|
||||||
const qs = quotationSettings.find((s) => s.qt_setting_id === value);
|
const qs = quotationSettings.find((s) => s.qt_setting_id === value);
|
||||||
return qs
|
return qs
|
||||||
? `[목표 마진: ${qs.target_margin}] 카드 ${qs.card_use_count}`
|
? `[${label('target_margin')}: ${qs.target_margin}] 카드 ${qs.card_use_count}`
|
||||||
: '';
|
: '';
|
||||||
}}
|
}}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
@ -504,7 +517,7 @@ export function QuotationCreateModal({
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{quotationSettings.map((qs) => (
|
{quotationSettings.map((qs) => (
|
||||||
<SelectItem key={qs.qt_setting_id} value={qs.qt_setting_id}>
|
<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>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@ -559,7 +572,19 @@ export function QuotationCreateModal({
|
|||||||
{/* Step 4 — 협상카드(1:1 협상 전용, 별도 스텝으로 분리해 과밀 방지) */}
|
{/* Step 4 — 협상카드(1:1 협상 전용, 별도 스텝으로 분리해 과밀 방지) */}
|
||||||
{step === 4 && oneToOne && (
|
{step === 4 && oneToOne && (
|
||||||
<div className="space-y-2">
|
<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">
|
<Typography as="span" variant="small" className="block text-[10px] text-muted-foreground">
|
||||||
1:1 협상에서 AI 협상봇이 발동할 카드입니다.
|
1:1 협상에서 AI 협상봇이 발동할 카드입니다.
|
||||||
</Typography>
|
</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 { SessionData } from '@/api/generated/model/sessionData';
|
||||||
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
|
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
|
||||||
import type { ChatMessageData } from '@/api/generated/model/chatMessageData';
|
import type { ChatMessageData } from '@/api/generated/model/chatMessageData';
|
||||||
@ -8,8 +8,53 @@ import { Typography } from '@/components/ui/typography';
|
|||||||
import { StatusPill, sessionStatusTone } from './StatusPill';
|
import { StatusPill, sessionStatusTone } from './StatusPill';
|
||||||
import { type Product, type Partner, sessionStatusLabel } from '../../types';
|
import { type Product, type Partner, sessionStatusLabel } from '../../types';
|
||||||
import { maskPrices } from '@/lib/utils';
|
import { maskPrices } from '@/lib/utils';
|
||||||
|
import { useCompanySettings } from '@/features/settings/useCompanySettings';
|
||||||
import { renderEmphasis } from '@/lib/emphasis';
|
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({
|
export function ChatTab({
|
||||||
serverSessions,
|
serverSessions,
|
||||||
partners,
|
partners,
|
||||||
@ -32,6 +77,13 @@ export function ChatTab({
|
|||||||
// 목표가는 협상(세션) 단위 고정값(sessions.target_price)이라 메시지마다가 아니라 헤더에 한 번만 표시한다.
|
// 목표가는 협상(세션) 단위 고정값(sessions.target_price)이라 메시지마다가 아니라 헤더에 한 번만 표시한다.
|
||||||
const currentSession = serverSessions.find((s) => s.session_id === effectiveSessionId);
|
const currentSession = serverSessions.find((s) => s.session_id === effectiveSessionId);
|
||||||
const targetPrice = currentSession?.target_price;
|
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 (
|
return (
|
||||||
<div className="h-[500px] border border-border rounded-lg overflow-hidden bg-card flex">
|
<div className="h-[500px] border border-border rounded-lg overflow-hidden bg-card flex">
|
||||||
{/* Sessions list */}
|
{/* 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 text-muted-foreground">
|
||||||
기록: <Typography as="span" variant="small" className="text-xs font-mono font-semibold text-foreground">{chatMessages.length}</Typography> 메시지
|
기록: <Typography as="span" variant="small" className="text-xs font-mono font-semibold text-foreground">{chatMessages.length}</Typography> 메시지
|
||||||
</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>
|
||||||
</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">
|
<div className="flex-1 min-h-0 p-4 overflow-y-auto space-y-4">
|
||||||
{!effectiveSessionId ? (
|
{!effectiveSessionId ? (
|
||||||
<Typography as="div" variant="small" className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs">
|
<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 { QuotationStatusBadge } from './StatusPill';
|
||||||
import { ResultSummaryBand } from './ResultSummaryBand';
|
import { ResultSummaryBand } from './ResultSummaryBand';
|
||||||
import type { QuotationData } from '@/api/generated/model/quotationData';
|
import type { QuotationData } from '@/api/generated/model/quotationData';
|
||||||
|
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||||
import {
|
import {
|
||||||
type Product,
|
type Product,
|
||||||
type QuotationSetting,
|
type QuotationSetting,
|
||||||
@ -50,6 +51,7 @@ export function DrawerHeaderCards({
|
|||||||
onShowTarget,
|
onShowTarget,
|
||||||
collapsed = false,
|
collapsed = false,
|
||||||
}: DrawerHeaderCardsProps) {
|
}: DrawerHeaderCardsProps) {
|
||||||
|
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
|
||||||
// 접으면 결과 요약 밴드만 노출(목표가·낙찰·절감). 상세 그리드 계산은 건너뛴다.
|
// 접으면 결과 요약 밴드만 노출(목표가·낙찰·절감). 상세 그리드 계산은 건너뛴다.
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
@ -178,7 +180,7 @@ export function DrawerHeaderCards({
|
|||||||
{selectedSettingObj ? (
|
{selectedSettingObj ? (
|
||||||
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
|
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
|
||||||
<InfoField
|
<InfoField
|
||||||
label="목표 마진율"
|
label={label('target_margin')}
|
||||||
value={selectedSettingObj.target_margin}
|
value={selectedSettingObj.target_margin}
|
||||||
valueClassName="font-bold text-emerald-600 dark:text-emerald-400 font-sans"
|
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 { Typography } from '@/components/ui/typography';
|
||||||
import { useGetTargetBreakdown } from '@/api/generated/quotation/quotation';
|
import { useGetTargetBreakdown } from '@/api/generated/quotation/quotation';
|
||||||
import { useScrollLock } from '@/lib/useScrollLock';
|
import { useScrollLock } from '@/lib/useScrollLock';
|
||||||
|
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||||
|
|
||||||
// 세션 목표가 산정내역 모달. 후보·채택·앵커링가는 백엔드 /target-breakdown 이 산정한 값을 '표시만' 한다.
|
// 세션 목표가 산정내역 모달. 후보·채택·앵커링가는 백엔드 /target-breakdown 이 산정한 값을 '표시만' 한다.
|
||||||
// (프론트 재계산 없음 → 저장된 목표가와 항상 일치. 산정 로직은 백엔드 _candidates 단일 출처.)
|
// (프론트 재계산 없음 → 저장된 목표가와 항상 일치. 산정 로직은 백엔드 _candidates 단일 출처.)
|
||||||
@ -18,10 +19,11 @@ type TargetPriceModalProps = {
|
|||||||
const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-');
|
const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-');
|
||||||
|
|
||||||
// 후보 basis 별 부가 설명(수수료/마진 적용 표시). 백엔드 라벨에 없는 보조 문구만 프론트가 덧붙인다.
|
// 후보 basis 별 부가 설명(수수료/마진 적용 표시). 백엔드 라벨에 없는 보조 문구만 프론트가 덧붙인다.
|
||||||
const CANDIDATE_SUB: Record<string, string> = {
|
// 회사 설정 용어(목표 마진)를 반영해 컴포넌트 안에서 만든다.
|
||||||
|
const candidateSub = (marginLabel: string): Record<string, string> => ({
|
||||||
internet: '인터넷 평균 수수료 적용',
|
internet: '인터넷 평균 수수료 적용',
|
||||||
selling: '목표 마진율 적용',
|
selling: `${marginLabel} 적용`,
|
||||||
};
|
});
|
||||||
|
|
||||||
export function TargetPriceModal({
|
export function TargetPriceModal({
|
||||||
onClose,
|
onClose,
|
||||||
@ -33,6 +35,8 @@ export function TargetPriceModal({
|
|||||||
category,
|
category,
|
||||||
}: TargetPriceModalProps) {
|
}: TargetPriceModalProps) {
|
||||||
useScrollLock(); // 모달은 열릴 때만 마운트(부모 게이트) → 배경 스크롤 잠금
|
useScrollLock(); // 모달은 열릴 때만 마운트(부모 게이트) → 배경 스크롤 잠금
|
||||||
|
const label = useLabels(); // 회사 설정 용어
|
||||||
|
const CANDIDATE_SUB = candidateSub(`${label('target_margin')}`);
|
||||||
const { data: bd, isLoading } = useGetTargetBreakdown(sessionId, { query: { enabled: !!sessionId } });
|
const { data: bd, isLoading } = useGetTargetBreakdown(sessionId, { query: { enabled: !!sessionId } });
|
||||||
const candidates = bd?.candidates ?? [];
|
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">
|
<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="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">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">
|
<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>
|
</Typography>
|
||||||
{bd.is_inherited && (
|
{bd.is_inherited && (
|
||||||
<Typography as="p" variant="small" className="text-[10px] text-amber-600">
|
<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 { Typography } from '@/components/ui/typography';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||||
|
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||||
import { type QuotationSetting } from '../types';
|
import { type QuotationSetting } from '../types';
|
||||||
import type { SettingInput } from '../hooks/useQuotations';
|
import type { SettingInput } from '../hooks/useQuotations';
|
||||||
|
|
||||||
@ -24,6 +25,7 @@ export function QuotationSettingsModal({
|
|||||||
onClose,
|
onClose,
|
||||||
}: QuotationSettingsModalProps) {
|
}: QuotationSettingsModalProps) {
|
||||||
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
||||||
|
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
|
||||||
const [targetMargin, setTargetMargin] = useState('');
|
const [targetMargin, setTargetMargin] = useState('');
|
||||||
const [cardUseCount, setCardUseCount] = 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">
|
<Table className="w-full text-left font-mono text-[11px] divide-y divide-border">
|
||||||
<TableHeader className="bg-muted text-muted-foreground">
|
<TableHeader className="bg-muted text-muted-foreground">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="p-2">목표 마진율</TableHead>
|
<TableHead className="p-2">{label('target_margin')}</TableHead>
|
||||||
<TableHead className="p-2">카드 사용 횟수</TableHead>
|
<TableHead className="p-2">카드 사용 횟수</TableHead>
|
||||||
<TableHead className="p-2 text-center w-12">삭제</TableHead>
|
<TableHead className="p-2 text-center w-12">삭제</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -100,7 +102,7 @@ export function QuotationSettingsModal({
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<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" />
|
<Input type="number" step="0.1" value={targetMargin} onChange={(e) => setTargetMargin(e.target.value)} placeholder="예: 12" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import type { ReqCreateQuotation } from '@/api/generated/model/reqCreateQuotatio
|
|||||||
import { showToast } from '@/lib/notify';
|
import { showToast } from '@/lib/notify';
|
||||||
import { confirm } from '@/lib/confirm';
|
import { confirm } from '@/lib/confirm';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||||
import type { Estimate } from '../types';
|
import type { Estimate } from '../types';
|
||||||
import { mapItem, mapSupplier, mapSetting, mapQuotation } from '../types';
|
import { mapItem, mapSupplier, mapSetting, mapQuotation } from '../types';
|
||||||
import { QuotationStatus } from '@/api/generated/model';
|
import { QuotationStatus } from '@/api/generated/model';
|
||||||
@ -54,6 +55,7 @@ export type SettingInput = {
|
|||||||
// 상품/협력사/세팅/견적은 서버(orval)에서 읽고, 견적·세팅·채팅은 로컬 state로 낙관적 갱신한다.
|
// 상품/협력사/세팅/견적은 서버(orval)에서 읽고, 견적·세팅·채팅은 로컬 state로 낙관적 갱신한다.
|
||||||
// (협상카드 카탈로그/채팅은 백엔드 미연동 → 빈 상태)
|
// (협상카드 카탈로그/채팅은 백엔드 미연동 → 빈 상태)
|
||||||
export function useQuotations(params: ListQuotationsParams) {
|
export function useQuotations(params: ListQuotationsParams) {
|
||||||
|
const label = useLabels(); // 회사 설정 용어(목표 마진율 등)
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const itemsQuery = useListItems({ size: 100 });
|
const itemsQuery = useListItems({ size: 100 });
|
||||||
const suppliersQuery = useListSuppliers({ 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 marginPct = Number(String(input.targetMargin).replace('%', '').trim());
|
||||||
const cardCount = parseInt(String(input.cardUseCount).replace(/[^0-9-]/g, ''), 10);
|
const cardCount = parseInt(String(input.cardUseCount).replace(/[^0-9-]/g, ''), 10);
|
||||||
if (!Number.isFinite(marginPct) || !Number.isInteger(cardCount)) {
|
if (!Number.isFinite(marginPct) || !Number.isInteger(cardCount)) {
|
||||||
showToast('목표 마진율·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error');
|
showToast(`${label('target_margin')}·카드 사용 횟수를 숫자로 입력해야 합니다.`, 'error');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
createSettingMutation.mutate(
|
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 { useOverlayRouter } from '@/lib/useOverlayRouter';
|
||||||
import { showToast } from '@/lib/notify';
|
import { showToast } from '@/lib/notify';
|
||||||
import { confirm } from '@/lib/confirm';
|
import { confirm } from '@/lib/confirm';
|
||||||
import { PageContainer } from '@/components/layout/PageContainer';
|
import { PageContainer } from '@/components/layout/PageContainer';
|
||||||
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
|
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||||
import { useServerList } from '@/lib/useServerList';
|
import { useServerList } from '@/lib/useServerList';
|
||||||
import { usePartners } from '@/features/partners/hooks/usePartners';
|
import { usePartners } from '@/features/partners/hooks/usePartners';
|
||||||
@ -36,6 +39,27 @@ export default function PartnersPage() {
|
|||||||
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
|
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
|
||||||
const isFormOpen = overlay.has('new') || !!editing;
|
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 openCreate = () => overlay.open('new');
|
||||||
const openEdit = (part: Partner) => overlay.open('detail', part.supplier_id);
|
const openEdit = (part: Partner) => overlay.open('detail', part.supplier_id);
|
||||||
|
|
||||||
@ -55,6 +79,19 @@ export default function PartnersPage() {
|
|||||||
<PageToolbar
|
<PageToolbar
|
||||||
actions={
|
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>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger render={<Button variant="outline" />}>
|
<DropdownMenuTrigger render={<Button variant="outline" />}>
|
||||||
<FileSpreadsheet />
|
<FileSpreadsheet />
|
||||||
@ -92,6 +129,8 @@ export default function PartnersPage() {
|
|||||||
|
|
||||||
<PartnerTable
|
<PartnerTable
|
||||||
data={partners}
|
data={partners}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onSelectionChange={setSelectedIds}
|
||||||
onRowClick={openEdit}
|
onRowClick={openEdit}
|
||||||
page={list.page}
|
page={list.page}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { useState } from 'react';
|
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 { useOverlayRouter } from '@/lib/useOverlayRouter';
|
||||||
import { showToast } from '@/lib/notify';
|
import { showToast } from '@/lib/notify';
|
||||||
import { confirm } from '@/lib/confirm';
|
import { confirm } from '@/lib/confirm';
|
||||||
import { PageContainer } from '@/components/layout/PageContainer';
|
import { PageContainer } from '@/components/layout/PageContainer';
|
||||||
|
import { useCompanySettings, useLabels } from '@/features/settings/useCompanySettings';
|
||||||
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
|
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -19,6 +20,8 @@ import { ExcelUploadModal, downloadProductTemplate } from '@/features/products/c
|
|||||||
import { type Product } from '@/features/products/types';
|
import { type Product } from '@/features/products/types';
|
||||||
|
|
||||||
export default function ProductsPage() {
|
export default function ProductsPage() {
|
||||||
|
const label = useLabels(); // 회사 설정 용어
|
||||||
|
const { settings } = useCompanySettings(); // 엑셀 양식 커스텀필드(item_fields)
|
||||||
// 검색/카테고리/페이지 상태(재사용 훅) → 서버 쿼리 파라미터로 변환.
|
// 검색/카테고리/페이지 상태(재사용 훅) → 서버 쿼리 파라미터로 변환.
|
||||||
const list = useServerList({ pageSize: 10, initialFilters: { category: 'ALL' } });
|
const list = useServerList({ pageSize: 10, initialFilters: { category: 'ALL' } });
|
||||||
const categoryFilter = list.filters.category;
|
const categoryFilter = list.filters.category;
|
||||||
@ -57,6 +60,24 @@ export default function ProductsPage() {
|
|||||||
const openCreate = () => overlay.open('new');
|
const openCreate = () => overlay.open('new');
|
||||||
const openEdit = (prod: Product) => overlay.open('detail', prod.item_id);
|
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) => {
|
const handleDeleteProduct = async (id: string, prodName: string) => {
|
||||||
if (await confirm({ title: '상품 삭제', description: `[${prodName}] 상품 정보를 완전 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
|
if (await confirm({ title: '상품 삭제', description: `[${prodName}] 상품 정보를 완전 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
|
||||||
try {
|
try {
|
||||||
@ -73,6 +94,17 @@ export default function ProductsPage() {
|
|||||||
<PageToolbar
|
<PageToolbar
|
||||||
actions={
|
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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={selectedIds.length === 0}
|
disabled={selectedIds.length === 0}
|
||||||
@ -94,7 +126,7 @@ export default function ProductsPage() {
|
|||||||
<Upload />
|
<Upload />
|
||||||
일괄 업로드
|
일괄 업로드
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={downloadProductTemplate}>
|
<DropdownMenuItem onClick={() => downloadProductTemplate(label, settings.item_fields ?? [])}>
|
||||||
<Download />
|
<Download />
|
||||||
양식 다운로드
|
양식 다운로드
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@ -120,13 +152,13 @@ export default function ProductsPage() {
|
|||||||
<Select value={categoryFilter} onValueChange={(v) => list.setFilter('category', v as string)}>
|
<Select value={categoryFilter} onValueChange={(v) => list.setFilter('category', v as string)}>
|
||||||
<SelectTrigger id="product-category-filter" className="w-full sm:w-48">
|
<SelectTrigger id="product-category-filter" className="w-full sm:w-48">
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
{(value) => (value === 'ALL' ? '품목 카테고리 (전체)' : value)}
|
{(value) => (value === 'ALL' ? `품목 ${label('category')} (전체)` : value)}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{['ALL', ...categories].map((cat) => (
|
{['ALL', ...categories].map((cat) => (
|
||||||
<SelectItem key={cat} value={cat}>
|
<SelectItem key={cat} value={cat}>
|
||||||
{cat === 'ALL' ? '품목 카테고리 (전체)' : cat}
|
{cat === 'ALL' ? `품목 ${label('category')} (전체)` : cat}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</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) 카드는 없음
|
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