[feat] negodata: 소유권 게이팅 + 견적 낙찰 + 작성자명 표시 + 전화입력 + 카드 엑셀
- 소유권 게이팅(common/authz): 변경 액션 본인∪OWNER, 협력사 삭제 OWNER 전용 - 견적 수동 낙찰(award) + 작성자명(creatorName) 표시 + 전화번호 입력 컴포넌트 + 카드 엑셀 업로드 - supplier_type 은 이번 커밋 미변경(다음 커밋에서 코드부터 정리 예정) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c32ee300e6
commit
c62b66e35b
9
negodata/backend/common/authz.py
Normal file
9
negodata/backend/common/authz.py
Normal file
@ -0,0 +1,9 @@
|
||||
from common.enums import UserRole
|
||||
|
||||
|
||||
def is_owner_or_admin(resource_user_id, user_id, role) -> bool:
|
||||
"""변경 액션 공용 소유권 판정 — 리소스 소유자(user_id 일치) 또는 최고관리자(OWNER)면 True.
|
||||
|
||||
프론트의 버튼 게이팅과 같은 규칙을 백엔드에서 강제하는 단일 출처.
|
||||
소유자 없는 공용 리소스(예: user_id NULL 공용카드)는 이 판정 대상이 아니다(도메인별 별도 처리)."""
|
||||
return str(resource_user_id) == str(user_id) or role == UserRole.OWNER.value
|
||||
@ -5,6 +5,7 @@ from sqlalchemy import select, func, and_, or_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import users
|
||||
from common.enums import ErrorType
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
@ -32,13 +33,21 @@ class ICardCRUD(ABC):
|
||||
async def soft_delete(self, cdb: AsyncSession, model, pk_col, card_id) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
|
||||
pass
|
||||
|
||||
|
||||
class CardCRUD(ICardCRUD):
|
||||
async def search(
|
||||
self, cdb: AsyncSession, model, user_id, search: Optional[str], skip: int, limit: int
|
||||
) -> Tuple[ErrorType, list, int]:
|
||||
try:
|
||||
conditions = [model.deleted == False, model.user_id == user_id] # noqa: E712
|
||||
# 내 개인 카드 + 전체(공용, user_id NULL) 카드. 남의 개인 카드는 제외.
|
||||
conditions = [
|
||||
model.deleted == False, # noqa: E712
|
||||
or_(model.user_id == user_id, model.user_id.is_(None)),
|
||||
]
|
||||
if search:
|
||||
conditions.append(
|
||||
or_(
|
||||
@ -102,3 +111,18 @@ class CardCRUD(ICardCRUD):
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
|
||||
"""user_id 목록 → {user_id: name}. 카드 목록 '작성자(등록자)' 표기용(company.users 조인).
|
||||
공용(user_id NULL) 카드는 호출 전에 걸러 넘긴다 → 맵에 없으면 작성자 없음."""
|
||||
try:
|
||||
if not user_ids:
|
||||
return ErrorType.SUCCESS, {}
|
||||
query = select(users.user_id, users.name).where(users.user_id.in_(user_ids))
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, {}
|
||||
return ErrorType.SUCCESS, {uid: name for uid, name in rows}
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, {}
|
||||
|
||||
@ -5,7 +5,7 @@ from sqlalchemy import select, func, and_, or_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import items
|
||||
from common.database.model.models import items, users
|
||||
from common.enums import ErrorType
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
@ -45,6 +45,10 @@ class IItemCRUD(ABC):
|
||||
async def soft_delete(self, cdb: AsyncSession, item_id) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
|
||||
pass
|
||||
|
||||
|
||||
class ItemCRUD(IItemCRUD):
|
||||
async def search(
|
||||
@ -166,3 +170,17 @@ class ItemCRUD(IItemCRUD):
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
|
||||
"""user_id 목록 → {user_id: name}. 상품 목록/상세 '등록자(작성자)' 표기용(company.users 조인)."""
|
||||
try:
|
||||
if not user_ids:
|
||||
return ErrorType.SUCCESS, {}
|
||||
query = select(users.user_id, users.name).where(users.user_id.in_(user_ids))
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, {}
|
||||
return ErrorType.SUCCESS, {uid: name for uid, name in rows}
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, {}
|
||||
|
||||
@ -10,7 +10,7 @@ from common.database.model.models import (
|
||||
quotations, sessions, chats, nego_cards, wild_cards, items, suppliers, quotation_settings,
|
||||
version_nego_cards, version_wild_cards, users,
|
||||
)
|
||||
from common.enums import ErrorType, QuotationStatus, SessionStatus
|
||||
from common.enums import CloseReason, ErrorType, QuotationStatus, SessionStatus
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
|
||||
@ -148,6 +148,10 @@ class IQuotationCRUD(ABC):
|
||||
async def claim_for_close(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, int]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def claim_for_award(self, cdb: AsyncSession, qt_id, supplier_id, supplier_name) -> Tuple[ErrorType, int]:
|
||||
pass
|
||||
|
||||
|
||||
class QuotationCRUD(IQuotationCRUD):
|
||||
async def search(
|
||||
@ -474,6 +478,36 @@ class QuotationCRUD(IQuotationCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, 0
|
||||
|
||||
async def claim_for_award(self, cdb: AsyncSession, qt_id, supplier_id, supplier_name) -> Tuple[ErrorType, int]:
|
||||
"""[동시 직접낙찰 가드] 개찰(마감·낙찰자 미정, close_reason ∈ OPEN_*)인 견적만 낙찰(AWARDED)로 선점 전이.
|
||||
선택 협력사를 낙찰자(preferred_sp_*)로 박고 동가 플래그는 내린다. status 는 이미 CLOSED 라 유지.
|
||||
반환: (ErrorType, 적용행수). 이미 낙찰됐거나(재클릭) 개찰이 아니면 0 → 서비스가 한 번만 통과시킨다."""
|
||||
try:
|
||||
query = (
|
||||
update(quotations)
|
||||
.where(
|
||||
quotations.qt_id == qt_id,
|
||||
quotations.status == QuotationStatus.CLOSED.value,
|
||||
quotations.close_reason.in_(
|
||||
[CloseReason.OPEN_PRICE.value, CloseReason.OPEN_EQUAL.value,
|
||||
CloseReason.OPEN_NOSHOW.value, CloseReason.OPEN_REJECT.value]
|
||||
),
|
||||
quotations.deleted == False, # noqa: E712
|
||||
)
|
||||
.values(
|
||||
close_reason=CloseReason.AWARDED.value,
|
||||
preferred_sp_yn=True,
|
||||
preferred_sp_id=supplier_id,
|
||||
preferred_sp_name=supplier_name,
|
||||
equal_bid_yn=False,
|
||||
updated_at=GTime.UTC(),
|
||||
)
|
||||
)
|
||||
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, 0
|
||||
|
||||
async def update_sessions_status(self, cdb: AsyncSession, qt_id, from_statuses: list[int], to_status: int) -> ErrorType:
|
||||
# 견적에 딸린 세션 중 from_statuses 에 속한 것만 to_status 로 일괄 전이(삭제 제외). 다른 상태는 건드리지 않는다.
|
||||
try:
|
||||
|
||||
@ -5,7 +5,7 @@ from sqlalchemy import select, func, and_, or_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import suppliers
|
||||
from common.database.model.models import suppliers, users
|
||||
from common.enums import ErrorType
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
@ -41,6 +41,10 @@ class ISupplierCRUD(ABC):
|
||||
async def soft_delete(self, cdb: AsyncSession, supplier_id) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
|
||||
pass
|
||||
|
||||
|
||||
class SupplierCRUD(ISupplierCRUD):
|
||||
async def search(
|
||||
@ -144,3 +148,17 @@ class SupplierCRUD(ISupplierCRUD):
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
|
||||
"""user_id 목록 → {user_id: name}. 협력사 목록/상세 '등록자(작성자)' 표기용(company.users 조인)."""
|
||||
try:
|
||||
if not user_ids:
|
||||
return ErrorType.SUCCESS, {}
|
||||
query = select(users.user_id, users.name).where(users.user_id.in_(user_ids))
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, {}
|
||||
return ErrorType.SUCCESS, {uid: name for uid, name in rows}
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, {}
|
||||
|
||||
@ -14,6 +14,7 @@ class CardProtocol(WebPacketProtocol):
|
||||
|
||||
class Req_CreateCard(CardProtocol):
|
||||
is_wildcard: bool = False
|
||||
is_shared: bool = False # True=전체(공용, user_id NULL 저장) / False=개인(등록 유저 소유)
|
||||
name: Optional[str] = None
|
||||
number: Optional[str] = None
|
||||
script: Optional[str] = None
|
||||
@ -42,6 +43,8 @@ class CardData(WebPacketProtocol):
|
||||
nego_card_id: uuid.UUID # 통합 식별자(일반=nego_card_id / 와일드=wild_card_id)
|
||||
user_id: Optional[uuid.UUID] = None
|
||||
is_wildcard: bool = False
|
||||
is_shared: bool = False # 전체(공용) 카드 여부 = user_id NULL. 목록 배지/폼 스코프 표시용
|
||||
creator_name: Optional[str] = None # 작성자(등록자) 이름. user_id→company.users.name 조인. 공용 카드는 None
|
||||
name: Optional[str] = None
|
||||
number: Optional[str] = None
|
||||
script: Optional[str] = None
|
||||
|
||||
@ -69,12 +69,12 @@ async def get_item(item_id: UUID, service: ItemService = Depends(), user_info: U
|
||||
async def update_item(
|
||||
item_id: UUID, req: Req_UpdateItem, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
||||
):
|
||||
return RemoveNoneResponse(await service.update_item(user_info.company_id, str(item_id), req))
|
||||
return RemoveNoneResponse(await service.update_item(user_info.company_id, str(item_id), req, user_info.user_id, user_info.role))
|
||||
|
||||
|
||||
@router.delete(path="/delete/{item_id}", response_model=Res_DeleteItem, summary="상품 삭제")
|
||||
async def delete_item(item_id: UUID, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.delete_item(user_info.company_id, str(item_id)))
|
||||
return RemoveNoneResponse(await service.delete_item(user_info.company_id, str(item_id), user_info.user_id, user_info.role))
|
||||
|
||||
|
||||
@router.post(path="/{item_id}/lowest-price", response_model=Res_LowestPriceTrigger, summary="최저가 수집 요청(스텁)")
|
||||
|
||||
@ -64,6 +64,7 @@ class ItemData(WebPacketProtocol):
|
||||
item_id: uuid.UUID
|
||||
company_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
creator_name: Optional[str] = None # 등록자(작성자) 이름. user_id→company.users.name 조인
|
||||
name: str
|
||||
code: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
|
||||
@ -40,6 +40,10 @@ class Req_RegenerateQuotation(QuotationProtocol):
|
||||
supplier_ids: list[uuid.UUID] = [] # 다음 라운드에 부를 공급사(프론트 선택). 상품·기간·번호는 원 견적에서 이어받음
|
||||
|
||||
|
||||
class Req_AwardQuotation(QuotationProtocol):
|
||||
winner_supplier_id: uuid.UUID # 담당자가 직접 낙찰시킬 협력사(투찰한 협상완료 세션 중 선택)
|
||||
|
||||
|
||||
class QuotationData(WebPacketProtocol):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ from common.models.gmodel import PageParams, UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
||||
from services.quotation_service import QuotationService
|
||||
from .protocol import (
|
||||
Req_AwardQuotation,
|
||||
Req_CreateQuotation,
|
||||
Req_RegenerateQuotation,
|
||||
Res_CreateQuotation,
|
||||
@ -55,14 +56,24 @@ async def create_quotation(
|
||||
|
||||
@router.post(path="/stop/{qt_id}", response_model=Res_Quotation, summary="견적 마감")
|
||||
async def stop_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.stop_quotation(str(qt_id), user_info.company_id))
|
||||
return RemoveNoneResponse(await service.stop_quotation(str(qt_id), user_info.company_id, user_info.user_id, user_info.role))
|
||||
|
||||
|
||||
@router.post(path="/award/{qt_id}", response_model=Res_Quotation, summary="개찰 견적 직접 낙찰")
|
||||
async def award_quotation(
|
||||
qt_id: UUID, req: Req_AwardQuotation, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
||||
):
|
||||
# 권한(본인 견적만/OWNER 예외)은 서비스에서 판정하도록 호출자 user_id·role 을 넘긴다.
|
||||
return RemoveNoneResponse(
|
||||
await service.award_quotation(str(qt_id), user_info.company_id, user_info.user_id, user_info.role, req.winner_supplier_id)
|
||||
)
|
||||
|
||||
|
||||
@router.post(path="/regenerate/{qt_id}", response_model=Res_CreateQuotation, summary="견적 재생성(다음 라운드)")
|
||||
async def regenerate_quotation(
|
||||
qt_id: UUID, req: Req_RegenerateQuotation, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
||||
):
|
||||
return RemoveNoneResponse(await service.regenerate_quotation(str(qt_id), user_info.company_id, req.supplier_ids))
|
||||
return RemoveNoneResponse(await service.regenerate_quotation(str(qt_id), user_info.company_id, req.supplier_ids, user_info.user_id, user_info.role))
|
||||
|
||||
|
||||
# ----- 견적 상세 (FK로 연결된 하위 데이터 / 일부는 모델 미존재로 스텁) -----
|
||||
@ -78,7 +89,7 @@ async def get_quotation_sessions(qt_id: UUID, service: QuotationService = Depend
|
||||
|
||||
@router.post(path="/{qt_id}/notify", response_model=Res_NotifySessions, summary="협상 초청 메일 발송")
|
||||
async def notify_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.notify_sessions(str(qt_id), user_info.company_id))
|
||||
return RemoveNoneResponse(await service.notify_sessions(str(qt_id), user_info.company_id, user_info.user_id, user_info.role))
|
||||
|
||||
|
||||
@router.get(path="/session/{session_id}/chat", response_model=Res_SessionChat, summary="채팅 상세")
|
||||
@ -93,7 +104,7 @@ async def get_target_breakdown(session_id: UUID, service: QuotationService = Dep
|
||||
|
||||
@router.post(path="/session/{session_id}/notify", response_model=Res_NotifySessions, summary="세션 초청 메일 재발송")
|
||||
async def notify_session(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.notify_session(str(session_id), user_info.company_id))
|
||||
return RemoveNoneResponse(await service.notify_session(str(session_id), user_info.company_id, user_info.user_id, user_info.role))
|
||||
|
||||
|
||||
@router.get(path="/{qt_id}/result", response_model=Res_QuotationResult, summary="낙찰 결과")
|
||||
@ -108,7 +119,7 @@ async def get_quotation_cards(qt_id: UUID, service: QuotationService = Depends()
|
||||
|
||||
@router.delete(path="/delete/{qt_id}", response_model=Res_DeleteQuotation, summary="견적 삭제")
|
||||
async def delete_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.delete_quotation(str(qt_id), user_info.company_id))
|
||||
return RemoveNoneResponse(await service.delete_quotation(str(qt_id), user_info.company_id, user_info.user_id, user_info.role))
|
||||
|
||||
|
||||
@router.get(path="/supplier/{supplier_id}/last-type", response_model=Res_LastSupplierType, summary="협력사 직전 견적 유형")
|
||||
|
||||
@ -35,6 +35,7 @@ class SupplierData(WebPacketProtocol):
|
||||
supplier_id: uuid.UUID
|
||||
company_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
creator_name: Optional[str] = None # 등록자(작성자) 이름. user_id→company.users.name 조인
|
||||
name: str
|
||||
code: Optional[str] = None
|
||||
manager_name: Optional[str] = None
|
||||
|
||||
@ -3,7 +3,7 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from common.models.gmodel import PageParams, UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, RequireOwner
|
||||
from services.supplier_service import SupplierService
|
||||
from .protocol import (
|
||||
Req_CheckCodes,
|
||||
@ -59,6 +59,7 @@ async def update_supplier(
|
||||
)
|
||||
|
||||
|
||||
@router.delete(path="/delete/{supplier_id}", response_model=Res_DeleteSupplier, summary="협력사 삭제")
|
||||
async def delete_supplier(supplier_id: UUID, service: SupplierService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
@router.delete(path="/delete/{supplier_id}", response_model=Res_DeleteSupplier, summary="협력사 삭제 (최고관리자 전용)")
|
||||
async def delete_supplier(supplier_id: UUID, service: SupplierService = Depends(), user_info: UserInfo = Depends(RequireOwner)):
|
||||
# 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(RequireOwner 가 비-OWNER 를 403 차단).
|
||||
return RemoveNoneResponse(await service.delete_supplier(user_info.company_id, str(supplier_id)))
|
||||
|
||||
@ -25,6 +25,7 @@ class CardService:
|
||||
nego_card_id=row.nego_card_id,
|
||||
user_id=row.user_id,
|
||||
is_wildcard=False,
|
||||
is_shared=row.user_id is None,
|
||||
name=row.name,
|
||||
number=row.number,
|
||||
script=row.script,
|
||||
@ -42,6 +43,7 @@ class CardService:
|
||||
nego_card_id=row.wild_card_id,
|
||||
user_id=row.user_id,
|
||||
is_wildcard=True,
|
||||
is_shared=row.user_id is None,
|
||||
name=row.name,
|
||||
number=row.number,
|
||||
script=row.script,
|
||||
@ -56,7 +58,8 @@ class CardService:
|
||||
|
||||
# ---- 소유 카드 탐색(어느 테이블인지 모를 때) ------------------------------
|
||||
async def _find_owned(self, user_uuid: uuid.UUID, card_id: uuid.UUID):
|
||||
"""card_id 를 nego_cards → wild_cards 순으로 찾고 소유권 확인.
|
||||
"""card_id 를 nego_cards → wild_cards 순으로 찾고 접근권 확인.
|
||||
전체(공용, user_id NULL) 카드는 누구나 조회·수정·삭제 가능. 개인 카드는 소유자만.
|
||||
(ErrorType, model, pk_col, row, is_wildcard) 반환."""
|
||||
err, row = await DB_SESSION_MNG.execute_lambda(
|
||||
nego_cards.DBType(),
|
||||
@ -64,7 +67,7 @@ class CardService:
|
||||
lambda s: self.card_crud.get_by_id(s, nego_cards, nego_cards.nego_card_id, card_id),
|
||||
)
|
||||
if err == ErrorType.SUCCESS and row is not None:
|
||||
if row.user_id != user_uuid:
|
||||
if row.user_id is not None and row.user_id != user_uuid:
|
||||
return ErrorType.CARD_NOT_FOUND, None, None, None, False
|
||||
return ErrorType.SUCCESS, nego_cards, nego_cards.nego_card_id, row, False
|
||||
|
||||
@ -74,7 +77,7 @@ class CardService:
|
||||
lambda s: self.card_crud.get_by_id(s, wild_cards, wild_cards.wild_card_id, card_id),
|
||||
)
|
||||
if err == ErrorType.SUCCESS and row is not None:
|
||||
if row.user_id != user_uuid:
|
||||
if row.user_id is not None and row.user_id != user_uuid:
|
||||
return ErrorType.CARD_NOT_FOUND, None, None, None, True
|
||||
return ErrorType.SUCCESS, wild_cards, wild_cards.wild_card_id, row, True
|
||||
|
||||
@ -114,7 +117,21 @@ class CardService:
|
||||
|
||||
merged = [self._nego_to_data(r) for r in nego_rows] + [self._wild_to_data(r) for r in wild_rows]
|
||||
merged.sort(key=lambda c: c.created_at or "", reverse=True)
|
||||
res.cards = merged[pg.skip : pg.skip + pg.size]
|
||||
page = merged[pg.skip : pg.skip + pg.size]
|
||||
|
||||
# 작성자명 배치 조인 — 페이지 카드의 작성자 id를 모아 IN 쿼리 1회로 {id:name} 맵을 만들어 매핑.
|
||||
# (공용 카드는 user_id=NULL → 맵에 없어 creator_name=None). 행마다 조회하지 않으므로 부하 없음.
|
||||
author_ids = list({c.user_id for c in page if c.user_id is not None})
|
||||
if author_ids:
|
||||
nm_err, name_map = await DB_SESSION_MNG.execute_lambda(
|
||||
nego_cards.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.card_crud.user_name_map(s, author_ids),
|
||||
)
|
||||
if nm_err == ErrorType.SUCCESS:
|
||||
for c in page:
|
||||
c.creator_name = name_map.get(c.user_id)
|
||||
res.cards = page
|
||||
res.total_nego = total_n
|
||||
res.total_wild = total_w
|
||||
# 선택된 탭 기준 페이지네이션 총건수(전체=합산).
|
||||
@ -134,6 +151,14 @@ class CardService:
|
||||
res.result.SetResult(err)
|
||||
return res
|
||||
res.card = self._wild_to_data(row) if is_wild else self._nego_to_data(row)
|
||||
# 등록자명 — 공용(user_id NULL) 카드는 작성자 없음(None 유지).
|
||||
if row.user_id is not None:
|
||||
nm_err, name_map = await DB_SESSION_MNG.execute_lambda(
|
||||
nego_cards.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.card_crud.user_name_map(s, [row.user_id]),
|
||||
)
|
||||
if nm_err == ErrorType.SUCCESS:
|
||||
res.card.creator_name = name_map.get(row.user_id)
|
||||
return res
|
||||
|
||||
# ---- 등록 ----------------------------------------------------------------
|
||||
@ -141,9 +166,11 @@ class CardService:
|
||||
res = Res_Card()
|
||||
user_uuid = uuid.UUID(user_id)
|
||||
is_wildcard = req.is_wildcard
|
||||
# 전체(공용) 카드는 소유자 없이 저장(user_id NULL) → 모든 유저 목록에 노출.
|
||||
owner_id = None if req.is_shared else user_uuid
|
||||
|
||||
common = dict(
|
||||
user_id=user_uuid,
|
||||
user_id=owner_id,
|
||||
name=req.name,
|
||||
number=req.number,
|
||||
script=req.script,
|
||||
|
||||
@ -2,6 +2,7 @@ import uuid
|
||||
|
||||
from fastapi import Depends, UploadFile
|
||||
|
||||
from common.authz import is_owner_or_admin
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import items
|
||||
from common.enums import DBWRType, ErrorType
|
||||
@ -56,6 +57,16 @@ class ItemService:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
res.items = [ItemData.model_validate(r) for r in rows]
|
||||
# 등록자명 배치 조인 — 페이지 상품의 user_id를 모아 IN 쿼리 1회로 {id:name} 맵을 만들어 매핑(행별 조회 아님).
|
||||
author_ids = list({r.user_id for r in rows if r.user_id is not None})
|
||||
if author_ids:
|
||||
nm_err, name_map = await DB_SESSION_MNG.execute_lambda(
|
||||
items.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.item_crud.user_name_map(s, author_ids),
|
||||
)
|
||||
if nm_err == ErrorType.SUCCESS:
|
||||
for d in res.items:
|
||||
d.creator_name = name_map.get(d.user_id)
|
||||
res.total = total
|
||||
return res
|
||||
|
||||
@ -81,6 +92,13 @@ class ItemService:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
res.item = ItemData.model_validate(item)
|
||||
if item.user_id is not None:
|
||||
nm_err, name_map = await DB_SESSION_MNG.execute_lambda(
|
||||
items.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.item_crud.user_name_map(s, [item.user_id]),
|
||||
)
|
||||
if nm_err == ErrorType.SUCCESS:
|
||||
res.item.creator_name = name_map.get(item.user_id)
|
||||
return res
|
||||
|
||||
async def check_codes(self, company_id: str, codes: list) -> Res_CheckCodes:
|
||||
@ -128,17 +146,22 @@ class ItemService:
|
||||
# 서버 기본값(created_at/updated_at)은 insert 후 Python 객체에 실리지 않으므로 재조회한다.
|
||||
return await self.get_item(company_id, str(item.item_id))
|
||||
|
||||
async def update_item(self, company_id: str, item_id: str, req: Req_UpdateItem) -> Res_Item:
|
||||
async def update_item(self, company_id: str, item_id: str, req: Req_UpdateItem, user_id=None, role=None) -> Res_Item:
|
||||
res = Res_Item()
|
||||
company_uuid = uuid.UUID(company_id)
|
||||
item_uuid = uuid.UUID(item_id)
|
||||
data = req.model_dump(exclude_unset=True)
|
||||
|
||||
# 소유권 확인
|
||||
err_type, _ = await self._fetch_owned(company_uuid, item_uuid)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
# 회사 스코프 확인
|
||||
err_type, item = await self._fetch_owned(company_uuid, item_uuid)
|
||||
if err_type != ErrorType.SUCCESS or item is None:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정(user_id 미지정=내부 호출은 스킵).
|
||||
if user_id is not None and not is_owner_or_admin(item.user_id, user_id, role):
|
||||
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
||||
res.msg = "본인이 등록한 상품만 수정할 수 있습니다."
|
||||
return res
|
||||
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[items.DBType()],
|
||||
@ -151,15 +174,20 @@ class ItemService:
|
||||
# 갱신 후 재조회
|
||||
return await self.get_item(company_id, item_id)
|
||||
|
||||
async def delete_item(self, company_id: str, item_id: str) -> Res_DeleteItem:
|
||||
async def delete_item(self, company_id: str, item_id: str, user_id=None, role=None) -> Res_DeleteItem:
|
||||
res = Res_DeleteItem()
|
||||
company_uuid = uuid.UUID(company_id)
|
||||
item_uuid = uuid.UUID(item_id)
|
||||
|
||||
err_type, _ = await self._fetch_owned(company_uuid, item_uuid)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
err_type, item = await self._fetch_owned(company_uuid, item_uuid)
|
||||
if err_type != ErrorType.SUCCESS or item is None:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 삭제(user_id 미지정=내부 호출은 스킵).
|
||||
if user_id is not None and not is_owner_or_admin(item.user_id, user_id, role):
|
||||
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
||||
res.msg = "본인이 등록한 상품만 삭제할 수 있습니다."
|
||||
return res
|
||||
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[items.DBType()],
|
||||
|
||||
@ -12,6 +12,7 @@ from common.anchoring import (
|
||||
fetch_current_values,
|
||||
get_base_anchoring_value,
|
||||
)
|
||||
from common.authz import is_owner_or_admin
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import quotations, sessions, chats, versions, version_nego_cards, version_wild_cards
|
||||
from common.enums import CloseOutcome, CloseReason, DBWRType, ErrorType, NotificationType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus
|
||||
@ -678,7 +679,7 @@ class QuotationService:
|
||||
# 4) 전원 미응찰 → 개찰(미응찰).
|
||||
return await self._open(qt_uuid, original, CloseReason.OPEN_NOSHOW.value, "no_show")
|
||||
|
||||
async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list) -> Res_CreateQuotation:
|
||||
async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list, user_id=None, role=None) -> Res_CreateQuotation:
|
||||
"""[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다.
|
||||
크론/수동마감의 자동 재생성과 달리 사유·체인 한도 판정 없이, 프론트가 고른 공급사로 바로 만든다.
|
||||
상품·기간·견적번호·카드버전은 원 견적에서 이어받는다(regenerate_next_round)."""
|
||||
@ -689,6 +690,11 @@ class QuotationService:
|
||||
if err_type != ErrorType.SUCCESS or original is None:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# 소유자 게이팅 — 본인 견적 또는 최고관리자만 재생성(user_id 미지정=내부 호출은 스킵).
|
||||
if user_id is not None and not is_owner_or_admin(original.user_id, user_id, role):
|
||||
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
||||
res.msg = "본인이 생성한 견적만 재생성할 수 있습니다."
|
||||
return res
|
||||
# 마감된 견적만 재생성(진행 중인 라운드를 또 찍어 같은 번호가 동시에 살아있는 걸 막는다).
|
||||
if original.status != QuotationStatus.CLOSED.value:
|
||||
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
||||
@ -711,29 +717,106 @@ class QuotationService:
|
||||
|
||||
return await self.regenerate_next_round(qt_uuid, supplier_ids)
|
||||
|
||||
async def stop_quotation(self, qt_id: str, company_id=None) -> Res_Quotation:
|
||||
async def stop_quotation(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_Quotation:
|
||||
"""[프론트] 수동 견적마감. 크론과 똑같은 마감 판정(close_and_decide)을 탄다
|
||||
(단독낙찰 확정 / 동가·미참여면 다음 라운드 재생성 / 거부·한도면 그냥 마감)."""
|
||||
res = Res_Quotation()
|
||||
qt_uuid = uuid.UUID(qt_id)
|
||||
|
||||
# 존재 확인(+회사 가드)
|
||||
err_type, _ = await self._fetch(qt_uuid, company_id)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
err_type, original = await self._fetch(qt_uuid, company_id)
|
||||
if err_type != ErrorType.SUCCESS or original is None:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# 소유자 게이팅 — 본인 견적 또는 최고관리자만 마감(user_id 미지정=내부 호출은 스킵).
|
||||
if user_id is not None and not is_owner_or_admin(original.user_id, user_id, role):
|
||||
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
||||
res.msg = "본인이 생성한 견적만 마감할 수 있습니다."
|
||||
return res
|
||||
|
||||
await self.close_and_decide(qt_uuid)
|
||||
return await self.get_quotation(qt_id, company_id)
|
||||
|
||||
async def delete_quotation(self, qt_id: str, company_id=None) -> Res_DeleteQuotation:
|
||||
async def award_quotation(self, qt_id: str, company_id, user_id, role, winner_supplier_id) -> Res_Quotation:
|
||||
"""[프론트] 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리한다.
|
||||
투찰한 협상완료(DONE) 세션 중 고른 협력사를 낙찰자로 박고 close_reason 을 AWARDED 로 바꾼다(직접 낙찰).
|
||||
자동 낙찰(close_and_decide)과 결과 컬럼은 같되, 알림에 manual 플래그로 '직접 낙찰'임을 남긴다.
|
||||
권한: 본인이 생성한 견적만. 단 최고관리자(OWNER)는 회사 내 남의 견적도 낙찰할 수 있다."""
|
||||
res = Res_Quotation()
|
||||
qt_uuid = uuid.UUID(qt_id)
|
||||
sp_uuid = winner_supplier_id if isinstance(winner_supplier_id, uuid.UUID) else uuid.UUID(str(winner_supplier_id))
|
||||
|
||||
# 존재 확인(+회사 가드) — 남의 회사 견적은 NOT_FOUND.
|
||||
err_type, original = await self._fetch(qt_uuid, company_id)
|
||||
if err_type != ErrorType.SUCCESS or original is None:
|
||||
res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND)
|
||||
return res
|
||||
|
||||
# 자기 견적만 직접 낙찰 — 최고관리자(OWNER)만 회사 내 남의 견적도 허용. 되돌릴 수 없는 낙찰이라 백엔드에서 강제한다.
|
||||
if not is_owner_or_admin(original.user_id, user_id, role):
|
||||
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
||||
res.msg = "본인이 생성한 견적만 낙찰할 수 있습니다."
|
||||
return res
|
||||
|
||||
# 개찰(마감·낙찰자 미정, close_reason ∈ OPEN_*)만 직접 낙찰 대상. 진행중/이미 낙찰은 거부.
|
||||
if original.status != QuotationStatus.CLOSED.value or original.close_reason not in (
|
||||
CloseReason.OPEN_PRICE.value, CloseReason.OPEN_EQUAL.value,
|
||||
CloseReason.OPEN_NOSHOW.value, CloseReason.OPEN_REJECT.value,
|
||||
):
|
||||
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
||||
res.msg = "개찰(낙찰자 미정) 상태의 견적만 직접 낙찰할 수 있습니다."
|
||||
return res
|
||||
|
||||
# 낙찰 후보 = 투찰한 협상완료(DONE) 세션. close_and_decide 와 같은 조회(list_sessions_status,
|
||||
# 공급사 삭제돼도 포함되는 outerjoin)를 써서 자동낙찰과 후보 집합을 일치시킨다.
|
||||
err_type, rows = await DB_SESSION_MNG.execute_lambda(
|
||||
sessions.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.list_sessions_status(s, qt_uuid),
|
||||
)
|
||||
rows = rows if err_type == ErrorType.SUCCESS else []
|
||||
winner = next(
|
||||
(r for r in rows
|
||||
if r.status == SessionStatus.DONE.value and r.bid_price is not None and r.supplier_id == sp_uuid),
|
||||
None,
|
||||
)
|
||||
if winner is None:
|
||||
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
||||
res.msg = "선택한 협력사는 이 견적의 낙찰 후보(투찰한 협상완료 협력사)가 아닙니다."
|
||||
return res
|
||||
|
||||
# [동시 직접낙찰 가드] 개찰→낙찰 원자 선점. 실제로 전이한 호출자만 통과(재클릭·경합 방어).
|
||||
claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim(
|
||||
quotations.DBType(),
|
||||
lambda s: self.quotation_crud.claim_for_award(s, qt_uuid, sp_uuid, (winner.name or "")[:20]),
|
||||
)
|
||||
if claim_err != ErrorType.SUCCESS or claimed == 0:
|
||||
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
||||
res.msg = "이미 낙찰 처리된 견적입니다."
|
||||
return res
|
||||
|
||||
# 작성자 알림 — 자동낙찰과 같은 SUCCESS 코드, manual 플래그로 '직접 낙찰' 구분.
|
||||
await create_notification(
|
||||
original.user_id, NotificationType.SUCCESS,
|
||||
{"qt_name": original.name, "qt_number": original.number,
|
||||
"winner_name": winner.name, "winner_price": winner.bid_price, "manual": True},
|
||||
ref_qt_id=qt_uuid,
|
||||
)
|
||||
return await self.get_quotation(qt_id, company_id)
|
||||
|
||||
async def delete_quotation(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_DeleteQuotation:
|
||||
res = Res_DeleteQuotation()
|
||||
qt_uuid = uuid.UUID(qt_id)
|
||||
|
||||
err_type, _ = await self._fetch(qt_uuid, company_id)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
err_type, original = await self._fetch(qt_uuid, company_id)
|
||||
if err_type != ErrorType.SUCCESS or original is None:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# 소유자 게이팅 — 본인 견적 또는 최고관리자만 삭제(user_id 미지정=내부 호출은 스킵).
|
||||
if user_id is not None and not is_owner_or_admin(original.user_id, user_id, role):
|
||||
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
||||
res.msg = "본인이 생성한 견적만 삭제할 수 있습니다."
|
||||
return res
|
||||
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[quotations.DBType()],
|
||||
@ -815,15 +898,20 @@ class QuotationService:
|
||||
return res
|
||||
|
||||
# ----- 협상 초청 메일 (수동 발송)
|
||||
async def notify_sessions(self, qt_id: str, company_id=None) -> Res_NotifySessions:
|
||||
async def notify_sessions(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_NotifySessions:
|
||||
"""[수동 발송] 견적의 '미발송' 세션(공급사 담당자)에게 협상 초청 메일을 일괄 발송한다.
|
||||
대상 = email_sent_at IS NULL + 담당자 이메일 보유."""
|
||||
res = Res_NotifySessions()
|
||||
qt_uuid = uuid.UUID(qt_id)
|
||||
err_type, quotation = await self._fetch(qt_uuid, company_id)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
if err_type != ErrorType.SUCCESS or quotation is None:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# 소유자 게이팅 — 본인 견적 또는 최고관리자만 초청메일 발송(user_id 미지정=내부 호출은 스킵).
|
||||
if user_id is not None and not is_owner_or_admin(quotation.user_id, user_id, role):
|
||||
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
||||
res.msg = "본인이 생성한 견적만 초청 메일을 발송할 수 있습니다."
|
||||
return res
|
||||
|
||||
err_type, rows = await DB_SESSION_MNG.execute_lambda(
|
||||
sessions.DBType(),
|
||||
@ -851,7 +939,7 @@ class QuotationService:
|
||||
await self._mark_emailed(sent_ids)
|
||||
return res
|
||||
|
||||
async def notify_session(self, session_id: str, company_id=None) -> Res_NotifySessions:
|
||||
async def notify_session(self, session_id: str, company_id=None, user_id=None, role=None) -> Res_NotifySessions:
|
||||
"""[수동 재발송] 단일 세션(공급사)에 초청 메일 발송(이미 보냈어도 강제 재발송)."""
|
||||
res = Res_NotifySessions()
|
||||
sess_uuid = uuid.UUID(session_id)
|
||||
@ -866,9 +954,14 @@ class QuotationService:
|
||||
sess, sp_name, email = got[0], got[1], got[2]
|
||||
res.total = 1
|
||||
err_type, quotation = await self._fetch(sess.quotation_id, company_id)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
if err_type != ErrorType.SUCCESS or quotation is None:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# 소유자 게이팅 — 본인 견적 또는 최고관리자만 재발송(user_id 미지정=내부 호출은 스킵).
|
||||
if user_id is not None and not is_owner_or_admin(quotation.user_id, user_id, role):
|
||||
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
||||
res.msg = "본인이 생성한 견적만 초청 메일을 발송할 수 있습니다."
|
||||
return res
|
||||
if not email:
|
||||
res.skipped = 1
|
||||
return res
|
||||
|
||||
@ -51,6 +51,16 @@ class SupplierService:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
res.suppliers = [SupplierData.model_validate(r) for r in rows]
|
||||
# 등록자명 배치 조인 — 페이지 협력사의 user_id를 모아 IN 쿼리 1회로 {id:name} 맵을 만들어 매핑(행별 조회 아님).
|
||||
author_ids = list({r.user_id for r in rows if r.user_id is not None})
|
||||
if author_ids:
|
||||
nm_err, name_map = await DB_SESSION_MNG.execute_lambda(
|
||||
suppliers.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.supplier_crud.user_name_map(s, author_ids),
|
||||
)
|
||||
if nm_err == ErrorType.SUCCESS:
|
||||
for d in res.suppliers:
|
||||
d.creator_name = name_map.get(d.user_id)
|
||||
res.total = total
|
||||
return res
|
||||
|
||||
@ -61,6 +71,13 @@ class SupplierService:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
res.supplier = SupplierData.model_validate(supplier)
|
||||
if supplier.user_id is not None:
|
||||
nm_err, name_map = await DB_SESSION_MNG.execute_lambda(
|
||||
suppliers.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.supplier_crud.user_name_map(s, [supplier.user_id]),
|
||||
)
|
||||
if nm_err == ErrorType.SUCCESS:
|
||||
res.supplier.creator_name = name_map.get(supplier.user_id)
|
||||
return res
|
||||
|
||||
async def check_codes(self, company_id: str, codes: list) -> Res_CheckCodes:
|
||||
|
||||
65
negodata/backend/tests/test_card.py
Normal file
65
negodata/backend/tests/test_card.py
Normal file
@ -0,0 +1,65 @@
|
||||
"""card 도메인 스코프 e2e — 개인 카드는 소유자만, 전체(공용) 카드는 누구나.
|
||||
스코프 규칙: user_id 있으면 개인(본인만 조회·관리) / NULL 이면 전체(모든 유저 조회·수정·삭제). 로그인은 auth_headers."""
|
||||
|
||||
|
||||
async def _create_card(client, headers, *, number, name="카드", is_shared=False, is_wildcard=False):
|
||||
r = await client.post(
|
||||
"/v1/card/create",
|
||||
json={
|
||||
"is_wildcard": is_wildcard,
|
||||
"is_shared": is_shared,
|
||||
"name": name,
|
||||
"number": number,
|
||||
"script": "안녕하세요",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
body = r.json()
|
||||
assert body["result"]["success"] is True, body
|
||||
return body["card"]["nego_card_id"]
|
||||
|
||||
|
||||
async def _list_numbers(client, headers):
|
||||
r = await client.get("/v1/card/list", headers=headers)
|
||||
return {c["number"] for c in r.json().get("cards", [])}
|
||||
|
||||
|
||||
async def test_personal_card_is_owner_only(client, auth_headers):
|
||||
"""검증: user_id 가 박힌 개인 카드는 소유자 목록·단건조회에만 노출되고 타 유저에겐 숨는다.
|
||||
기대결과: A 목록엔 있고 B 목록엔 없음, B 의 단건 조회는 success=False(CARD_NOT_FOUND)."""
|
||||
ha = await auth_headers("cardA")
|
||||
hb = await auth_headers("cardB")
|
||||
cid = await _create_card(client, ha, number="P-1", is_shared=False)
|
||||
|
||||
assert "P-1" in await _list_numbers(client, ha)
|
||||
assert "P-1" not in await _list_numbers(client, hb)
|
||||
assert (await client.get(f"/v1/card/{cid}", headers=hb)).json()["result"]["success"] is False
|
||||
|
||||
|
||||
async def test_shared_card_visible_to_all(client, auth_headers):
|
||||
"""검증: is_shared=True 카드는 user_id NULL 로 저장돼 모든 유저 목록·단건조회에 노출된다.
|
||||
기대결과: A·B 목록 모두에 존재, 비생성자 B 의 단건 조회 success=True, is_shared 플래그 True."""
|
||||
ha = await auth_headers("cardSA")
|
||||
hb = await auth_headers("cardSB")
|
||||
cid = await _create_card(client, ha, number="S-1", is_shared=True)
|
||||
|
||||
assert "S-1" in await _list_numbers(client, ha)
|
||||
assert "S-1" in await _list_numbers(client, hb)
|
||||
got = await client.get(f"/v1/card/{cid}", headers=hb)
|
||||
assert got.json()["result"]["success"] is True
|
||||
assert got.json()["card"]["is_shared"] is True
|
||||
|
||||
|
||||
async def test_shared_card_editable_and_deletable_by_anyone(client, auth_headers):
|
||||
"""검증: 전체(공용) 카드는 소유자가 없어 아무 유저나 수정·삭제 가능(정책: 누구나).
|
||||
기대결과: 비생성자 B 의 수정·삭제 모두 success=True, 삭제 후 목록에서 사라짐."""
|
||||
ha = await auth_headers("cardEA")
|
||||
hb = await auth_headers("cardEB")
|
||||
cid = await _create_card(client, ha, number="S-EDIT", is_shared=True)
|
||||
|
||||
upd = await client.patch(f"/v1/card/update/{cid}", json={"name": "B가 수정"}, headers=hb)
|
||||
assert upd.json()["result"]["success"] is True
|
||||
|
||||
dele = await client.delete(f"/v1/card/delete/{cid}", headers=hb)
|
||||
assert dele.json()["result"]["success"] is True
|
||||
assert "S-EDIT" not in await _list_numbers(client, hb)
|
||||
122
negodata/backend/tests/test_ownership_gating.py
Normal file
122
negodata/backend/tests/test_ownership_gating.py
Normal file
@ -0,0 +1,122 @@
|
||||
"""소유자 게이팅 — 변경 액션은 '본인 소유' 또는 '최고관리자(OWNER)'만. 프론트 버튼 차단과 같은 규칙을 백엔드가 강제한다.
|
||||
|
||||
- item(상품): 같은 회사의 다른 일반유저는 남의 상품을 수정·삭제 못 한다(OWNER는 가능).
|
||||
- quotation(견적): 남의 견적 삭제는 비소유 일반유저 차단, OWNER 허용, user_id 미지정(내부 호출)은 스킵.
|
||||
공용 판정은 common.authz.is_owner_or_admin — 여기 통과하면 다른 변경 액션(마감·재생성·초청메일)도 같은 규칙을 탄다.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from common.enums import ErrorType, QuotationStatus, QuotationType, UserRole
|
||||
from crud.quotation_crud import QuotationCRUD
|
||||
from services.quotation_service import QuotationService
|
||||
|
||||
PAST = datetime(2020, 1, 1)
|
||||
|
||||
|
||||
# ===== item(상품) — HTTP e2e: 같은 회사, 다른 유저 =====
|
||||
async def test_item_update_delete_blocked_for_non_owner(client, auth_headers):
|
||||
"""검증: A가 등록한 상품을 같은 회사의 다른 일반유저 B가 수정·삭제 시도.
|
||||
기대결과: 둘 다 거부(ACCOUNT_FORBIDDEN), 상품은 원값 그대로 남는다."""
|
||||
ha = await auth_headers("item_owner") # 소유자
|
||||
hb = await auth_headers("item_other") # 같은 회사, USER
|
||||
item_id = (await client.post(
|
||||
"/v1/item/create", json={"name": "상품A", "price": 1000, "code": "OWN1"}, headers=ha
|
||||
)).json()["item"]["item_id"]
|
||||
|
||||
r_upd = await client.patch(f"/v1/item/update/{item_id}", json={"price": 9999}, headers=hb)
|
||||
assert r_upd.json()["result"]["success"] is False
|
||||
assert r_upd.json()["result"]["code"] == ErrorType.ACCOUNT_FORBIDDEN.value
|
||||
|
||||
r_del = await client.delete(f"/v1/item/delete/{item_id}", headers=hb)
|
||||
assert r_del.json()["result"]["success"] is False
|
||||
assert r_del.json()["result"]["code"] == ErrorType.ACCOUNT_FORBIDDEN.value
|
||||
|
||||
# 수정·삭제 모두 무산 — 원값으로 조회된다
|
||||
assert (await client.get(f"/v1/item/{item_id}", headers=ha)).json()["item"]["price"] == 1000
|
||||
|
||||
|
||||
async def test_item_delete_allowed_for_owner_role(client, auth_headers):
|
||||
"""검증: A가 등록한 상품을 같은 회사 최고관리자(OWNER)가 삭제.
|
||||
기대결과: 성공 — 소유자가 아니어도 OWNER는 허용."""
|
||||
ha = await auth_headers("item_owner2")
|
||||
hadmin = await auth_headers("item_admin", role=UserRole.OWNER.value)
|
||||
item_id = (await client.post(
|
||||
"/v1/item/create", json={"name": "상품B", "price": 500, "code": "OWN2"}, headers=ha
|
||||
)).json()["item"]["item_id"]
|
||||
|
||||
r = await client.delete(f"/v1/item/delete/{item_id}", headers=hadmin)
|
||||
assert r.json()["result"]["success"] is True
|
||||
|
||||
|
||||
# ===== quotation(견적) — 서비스 직접: 삭제 게이팅 =====
|
||||
async def test_quotation_delete_blocked_for_non_owner(db_engine):
|
||||
"""검증: 남의 견적을 비소유 일반유저(USER)가 삭제 시도.
|
||||
기대결과: 거부(ACCOUNT_FORBIDDEN) + soft-delete 안 됨(deleted=false)."""
|
||||
owner, other = uuid.uuid4(), uuid.uuid4()
|
||||
qt = await _seed_quotation(db_engine, user_id=owner, number="DEL-NONOWNER")
|
||||
|
||||
res = await _service().delete_quotation(str(qt), None, other, UserRole.USER.value)
|
||||
|
||||
assert res.result.success is False
|
||||
assert res.result.code == ErrorType.ACCOUNT_FORBIDDEN.value
|
||||
assert await _deleted(db_engine, qt) is False
|
||||
|
||||
|
||||
async def test_quotation_delete_allowed_for_owner_role(db_engine):
|
||||
"""검증: 남의 견적을 최고관리자(OWNER)가 삭제.
|
||||
기대결과: 성공 + deleted=true."""
|
||||
creator, admin = uuid.uuid4(), uuid.uuid4()
|
||||
qt = await _seed_quotation(db_engine, user_id=creator, number="DEL-OWNER")
|
||||
|
||||
res = await _service().delete_quotation(str(qt), None, admin, UserRole.OWNER.value)
|
||||
|
||||
assert res.result.success is True
|
||||
assert await _deleted(db_engine, qt) is True
|
||||
|
||||
|
||||
async def test_quotation_delete_skips_gate_for_internal_call(db_engine):
|
||||
"""검증: user_id 미지정(내부/스케줄러 호출)로 삭제.
|
||||
기대결과: 소유권 검사 스킵 → 성공(회사 스코프만 적용)."""
|
||||
creator = uuid.uuid4()
|
||||
qt = await _seed_quotation(db_engine, user_id=creator, number="DEL-INTERNAL")
|
||||
|
||||
res = await _service().delete_quotation(str(qt), None)
|
||||
|
||||
assert res.result.success is True
|
||||
assert await _deleted(db_engine, qt) is True
|
||||
|
||||
|
||||
# ===== 헬퍼 =====
|
||||
async def _seed_quotation(engine, *, user_id, number):
|
||||
"""견적 1건 시드(삭제 게이팅 확인용 — 상태는 무관하므로 CLOSED로 고정)."""
|
||||
qt_id = uuid.uuid4()
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO quotations "
|
||||
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, "
|
||||
" round, iteration, start_time, end_time, deleted) VALUES "
|
||||
"(:qt_id, :user_id, :qt_setting_id, :version_id, '견적', :number, :type, :status, "
|
||||
" 1, 0, :t, :t, false)"
|
||||
),
|
||||
{
|
||||
"qt_id": qt_id, "user_id": user_id, "qt_setting_id": uuid.uuid4(),
|
||||
"version_id": uuid.uuid4(), "number": number, "type": QuotationType.REQUOTE.value,
|
||||
"status": QuotationStatus.CLOSED.value, "t": PAST,
|
||||
},
|
||||
)
|
||||
return qt_id
|
||||
|
||||
|
||||
async def _deleted(engine, qt_id) -> bool:
|
||||
async with engine.begin() as conn:
|
||||
return (await conn.execute(
|
||||
text("SELECT deleted FROM quotations WHERE qt_id = :q"), {"q": qt_id}
|
||||
)).scalar_one()
|
||||
|
||||
|
||||
def _service():
|
||||
return QuotationService(QuotationCRUD())
|
||||
206
negodata/backend/tests/test_quotation_award.py
Normal file
206
negodata/backend/tests/test_quotation_award.py
Normal file
@ -0,0 +1,206 @@
|
||||
"""개찰 견적 직접 낙찰(award_quotation) 테스트 — 담당자가 개찰(낙찰자 미정 마감) 견적의 낙찰자를 직접 지정.
|
||||
|
||||
직접 낙찰은 자동 낙찰(close_and_decide)과 결과 컬럼은 같되(close_reason=AWARDED, preferred_sp_*),
|
||||
알림에 manual 플래그로 '직접' 낙찰임을 남긴다. 다음을 본다:
|
||||
· 개찰 + 투찰(DONE) 협력사 지정 → 낙찰 확정 + 알림 SUCCESS(manual=True)
|
||||
· 개찰 아님(이미 낙찰) → 거부(INVALID_REQUEST_DATA), 알림 없음
|
||||
· 후보 아닌 협력사 지정 → 거부, close_reason 유지
|
||||
· 낙찰 후 재지정(재클릭) → 거부(동시성 가드), 알림 1건 유지
|
||||
|
||||
세션의 협상 결과(협상완료/입찰가)와 개찰 상태(close_reason)는 협상/마감에서만 생기는 값이라 SQL 로 직접 넣는다.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import text
|
||||
|
||||
from common.enums import CloseReason, ErrorType, QuotationStatus, QuotationType, SessionStatus, UserRole
|
||||
from crud.quotation_crud import QuotationCRUD
|
||||
from services.quotation_service import QuotationService
|
||||
|
||||
PAST = datetime(2020, 1, 1)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def clean(db_engine):
|
||||
"""conftest 는 notifications 를 비우지 않는다 → 알림 단언이 다른 테스트에 안 흔들리게 여기서 함께 비운다."""
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(text("TRUNCATE TABLE sessions, quotations, notifications RESTART IDENTITY CASCADE"))
|
||||
return db_engine
|
||||
|
||||
|
||||
async def test_award_opened_sets_winner_and_notifies(clean):
|
||||
"""검증: 개찰(동가) 견적 + 투찰 협력사 2건(A=100, B=120) 중 A 를 직접 낙찰.
|
||||
기대결과: close_reason→낙찰, preferred_sp=A, 동가플래그 해제 + 알림 SUCCESS(manual=True, winner_price=100)."""
|
||||
engine = clean
|
||||
user_id, supplier_a = uuid.uuid4(), uuid.uuid4()
|
||||
qt = await _seed_opened(engine, user_id=user_id, number="A-WIN", close_reason=CloseReason.OPEN_EQUAL.value)
|
||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
|
||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=120)
|
||||
|
||||
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a)
|
||||
|
||||
assert res.result.success is True
|
||||
row = await _quotation(engine, qt)
|
||||
assert row.close_reason == CloseReason.AWARDED.value
|
||||
assert row.preferred_sp_yn is True
|
||||
assert str(row.preferred_sp_id) == str(supplier_a)
|
||||
assert row.equal_bid_yn is False
|
||||
|
||||
notis = await _notifications(engine, user_id)
|
||||
assert len(notis) == 1
|
||||
type_, data, ref = notis[0]
|
||||
assert type_ == 1 # NotificationType.SUCCESS
|
||||
assert data["manual"] is True
|
||||
assert data["winner_price"] == 100
|
||||
assert str(ref) == str(qt)
|
||||
|
||||
|
||||
async def test_award_rejects_when_not_opened(clean):
|
||||
"""검증: 이미 낙찰된 견적(close_reason=AWARDED)에 직접 낙찰을 다시 시도.
|
||||
기대결과: 거부(INVALID_REQUEST_DATA) + 새 알림 없음."""
|
||||
engine = clean
|
||||
user_id, supplier_a = uuid.uuid4(), uuid.uuid4()
|
||||
qt = await _seed_opened(engine, user_id=user_id, number="A-DONE", close_reason=CloseReason.AWARDED.value)
|
||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
|
||||
|
||||
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a)
|
||||
|
||||
assert res.result.success is False
|
||||
assert res.result.code == ErrorType.INVALID_REQUEST_DATA.value
|
||||
assert len(await _notifications(engine, user_id)) == 0
|
||||
|
||||
|
||||
async def test_award_rejects_unknown_supplier(clean):
|
||||
"""검증: 개찰 견적에, 투찰 후보가 아닌 협력사 id 를 지정.
|
||||
기대결과: 거부 + close_reason 은 개찰(OPEN_PRICE) 그대로 유지, 알림 없음."""
|
||||
engine = clean
|
||||
user_id, bidder, stranger = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
||||
qt = await _seed_opened(engine, user_id=user_id, number="A-STRANGER", close_reason=CloseReason.OPEN_PRICE.value)
|
||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=bidder)
|
||||
|
||||
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, stranger)
|
||||
|
||||
assert res.result.success is False
|
||||
row = await _quotation(engine, qt)
|
||||
assert row.close_reason == CloseReason.OPEN_PRICE.value
|
||||
assert row.preferred_sp_id is None
|
||||
assert len(await _notifications(engine, user_id)) == 0
|
||||
|
||||
|
||||
async def test_award_is_idempotent(clean):
|
||||
"""검증: 직접 낙찰 성공 후 같은 견적에 재지정(재클릭/경합).
|
||||
기대결과: 2번째는 거부(이미 낙찰) + 알림은 1건만 유지(동시성 가드가 한 번만 통과)."""
|
||||
engine = clean
|
||||
user_id, supplier_a = uuid.uuid4(), uuid.uuid4()
|
||||
qt = await _seed_opened(engine, user_id=user_id, number="A-IDEMP", close_reason=CloseReason.OPEN_REJECT.value)
|
||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
|
||||
|
||||
first = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a)
|
||||
second = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a)
|
||||
|
||||
assert first.result.success is True
|
||||
assert second.result.success is False
|
||||
assert len(await _notifications(engine, user_id)) == 1
|
||||
|
||||
|
||||
async def test_award_rejects_non_owner(clean):
|
||||
"""검증: 남의 개찰 견적을 일반 유저(비소유·USER)가 직접 낙찰 시도.
|
||||
기대결과: 거부(ACCOUNT_FORBIDDEN) + close_reason 개찰 유지 + 알림 없음."""
|
||||
engine = clean
|
||||
owner, other, supplier_a = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
||||
qt = await _seed_opened(engine, user_id=owner, number="A-NONOWNER", close_reason=CloseReason.OPEN_PRICE.value)
|
||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
|
||||
|
||||
res = await _service().award_quotation(str(qt), None, other, UserRole.USER.value, supplier_a)
|
||||
|
||||
assert res.result.success is False
|
||||
assert res.result.code == ErrorType.ACCOUNT_FORBIDDEN.value
|
||||
row = await _quotation(engine, qt)
|
||||
assert row.close_reason == CloseReason.OPEN_PRICE.value
|
||||
assert row.preferred_sp_id is None
|
||||
assert len(await _notifications(engine, owner)) == 0
|
||||
|
||||
|
||||
async def test_award_allows_owner_role(clean):
|
||||
"""검증: 남의 개찰 견적을 최고관리자(OWNER)가 직접 낙찰.
|
||||
기대결과: 낙찰 성공 + 알림은 견적 작성자(owner) 인박스에 남는다(호출자가 아니라)."""
|
||||
engine = clean
|
||||
creator, admin, supplier_a = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
||||
qt = await _seed_opened(engine, user_id=creator, number="A-OWNER", close_reason=CloseReason.OPEN_EQUAL.value)
|
||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
|
||||
|
||||
res = await _service().award_quotation(str(qt), None, admin, UserRole.OWNER.value, supplier_a)
|
||||
|
||||
assert res.result.success is True
|
||||
row = await _quotation(engine, qt)
|
||||
assert row.close_reason == CloseReason.AWARDED.value
|
||||
assert str(row.preferred_sp_id) == str(supplier_a)
|
||||
assert len(await _notifications(engine, creator)) == 1
|
||||
assert len(await _notifications(engine, admin)) == 0
|
||||
|
||||
|
||||
# ===== 헬퍼 =====
|
||||
async def _seed_opened(engine, *, user_id, number, close_reason, round_=1):
|
||||
"""개찰/낙찰 상태(status=CLOSED + close_reason)로 견적 1건 시드. 낙찰자 컬럼은 비운 채 시작."""
|
||||
qt_id = uuid.uuid4()
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO quotations "
|
||||
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, close_reason, "
|
||||
" round, iteration, start_time, end_time, deleted) VALUES "
|
||||
"(:qt_id, :user_id, :qt_setting_id, :version_id, '견적A', :number, :type, :status, :close_reason, "
|
||||
" :round, 0, :start_time, :end_time, false)"
|
||||
),
|
||||
{
|
||||
"qt_id": qt_id, "user_id": user_id, "qt_setting_id": uuid.uuid4(),
|
||||
"version_id": uuid.uuid4(), "number": number, "type": QuotationType.REQUOTE.value,
|
||||
"status": QuotationStatus.CLOSED.value, "close_reason": close_reason,
|
||||
"round": round_, "start_time": PAST, "end_time": PAST,
|
||||
},
|
||||
)
|
||||
return qt_id
|
||||
|
||||
|
||||
async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None):
|
||||
"""세션 1건 시드(공급사 협상 1건). status/bid_price 로 협상완료·입찰가를 만든다."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO sessions "
|
||||
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
|
||||
" target_price, status, bid_price, end_time) VALUES "
|
||||
"(:session_id, :quotation_id, :item_id, :supplier_id, 'Q', 1, :qt_type, "
|
||||
" 0, :status, :bid_price, :end_time)"
|
||||
),
|
||||
{
|
||||
"session_id": uuid.uuid4(), "quotation_id": qt_id, "item_id": uuid.uuid4(),
|
||||
"supplier_id": supplier_id or uuid.uuid4(), "qt_type": QuotationType.REQUOTE.value,
|
||||
"status": status, "bid_price": bid_price, "end_time": PAST,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _quotation(engine, qt_id):
|
||||
"""견적 1행(마감 결과 컬럼 확인용)."""
|
||||
async with engine.begin() as conn:
|
||||
return (await conn.execute(
|
||||
text("SELECT close_reason, preferred_sp_yn, preferred_sp_id, equal_bid_yn "
|
||||
"FROM quotations WHERE qt_id = :qt_id"),
|
||||
{"qt_id": qt_id},
|
||||
)).one()
|
||||
|
||||
|
||||
async def _notifications(engine, user_id):
|
||||
"""user_id(작성자) 인박스 알림 (type, data, ref_qt_id) — 생성순."""
|
||||
async with engine.begin() as conn:
|
||||
return (await conn.execute(
|
||||
text("SELECT type, data, ref_qt_id FROM notifications WHERE user_id = :uid ORDER BY created_at"),
|
||||
{"uid": user_id},
|
||||
)).all()
|
||||
|
||||
|
||||
def _service():
|
||||
return QuotationService(QuotationCRUD())
|
||||
@ -5,6 +5,7 @@
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { CardDataUserId } from './cardDataUserId';
|
||||
import type { CardDataCreatorName } from './cardDataCreatorName';
|
||||
import type { CardDataName } from './cardDataName';
|
||||
import type { CardDataNumber } from './cardDataNumber';
|
||||
import type { CardDataScript } from './cardDataScript';
|
||||
@ -20,6 +21,8 @@ export interface CardData {
|
||||
nego_card_id: string;
|
||||
user_id?: CardDataUserId;
|
||||
is_wildcard?: boolean;
|
||||
is_shared?: boolean;
|
||||
creator_name?: CardDataCreatorName;
|
||||
name?: CardDataName;
|
||||
number?: CardDataNumber;
|
||||
script?: CardDataScript;
|
||||
|
||||
@ -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 CardDataCreatorName = string | null;
|
||||
@ -4,6 +4,7 @@
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ItemDataCreatorName } from './itemDataCreatorName';
|
||||
import type { ItemDataCode } from './itemDataCode';
|
||||
import type { ItemDataCategory } from './itemDataCategory';
|
||||
import type { ItemDataImageUrl } from './itemDataImageUrl';
|
||||
@ -28,6 +29,7 @@ export interface ItemData {
|
||||
item_id: string;
|
||||
company_id: string;
|
||||
user_id: string;
|
||||
creator_name?: ItemDataCreatorName;
|
||||
name: string;
|
||||
code?: ItemDataCode;
|
||||
category?: ItemDataCategory;
|
||||
|
||||
@ -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 ItemDataCreatorName = string | null;
|
||||
10
negodata/front/src/api/generated/model/reqAwardQuotation.ts
Normal file
10
negodata/front/src/api/generated/model/reqAwardQuotation.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface ReqAwardQuotation {
|
||||
winner_supplier_id: string;
|
||||
}
|
||||
@ -13,6 +13,7 @@ import type { ReqCreateCardMemo } from './reqCreateCardMemo';
|
||||
|
||||
export interface ReqCreateCard {
|
||||
is_wildcard?: boolean;
|
||||
is_shared?: boolean;
|
||||
name?: ReqCreateCardName;
|
||||
number?: ReqCreateCardNumber;
|
||||
script?: ReqCreateCardScript;
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { SupplierDataCreatorName } from './supplierDataCreatorName';
|
||||
import type { SupplierDataCode } from './supplierDataCode';
|
||||
import type { SupplierDataManagerName } from './supplierDataManagerName';
|
||||
import type { SupplierDataManagerEmail } from './supplierDataManagerEmail';
|
||||
@ -16,6 +17,7 @@ export interface SupplierData {
|
||||
supplier_id: string;
|
||||
company_id: string;
|
||||
user_id: string;
|
||||
creator_name?: SupplierDataCreatorName;
|
||||
name: string;
|
||||
code?: SupplierDataCode;
|
||||
manager_name?: SupplierDataManagerName;
|
||||
|
||||
@ -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 SupplierDataCreatorName = string | null;
|
||||
@ -26,6 +26,7 @@ import type {
|
||||
import type {
|
||||
HTTPValidationError,
|
||||
ListQuotationsParams,
|
||||
ReqAwardQuotation,
|
||||
ReqCreateQuotation,
|
||||
ReqRegenerateQuotation,
|
||||
ResCreateQuotation,
|
||||
@ -269,6 +270,71 @@ export const useStopQuotation = <TError = void | HTTPValidationError,
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
/**
|
||||
* @summary 개찰 견적 직접 낙찰
|
||||
*/
|
||||
export const awardQuotation = (
|
||||
qtId: string,
|
||||
reqAwardQuotation: ReqAwardQuotation,
|
||||
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customFetch<ResQuotation>(
|
||||
{url: `/v1/quotation/award/${qtId}`, method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', },
|
||||
data: reqAwardQuotation, signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const getAwardQuotationMutationOptions = <TError = void | HTTPValidationError,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof awardQuotation>>, TError,{qtId: string;data: ReqAwardQuotation}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof awardQuotation>>, TError,{qtId: string;data: ReqAwardQuotation}, TContext> => {
|
||||
|
||||
const mutationKey = ['awardQuotation'];
|
||||
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 awardQuotation>>, {qtId: string;data: ReqAwardQuotation}> = (props) => {
|
||||
const {qtId,data} = props ?? {};
|
||||
|
||||
return awardQuotation(qtId,data,requestOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type AwardQuotationMutationResult = NonNullable<Awaited<ReturnType<typeof awardQuotation>>>
|
||||
export type AwardQuotationMutationBody = ReqAwardQuotation
|
||||
export type AwardQuotationMutationError = void | HTTPValidationError
|
||||
|
||||
/**
|
||||
* @summary 개찰 견적 직접 낙찰
|
||||
*/
|
||||
export const useAwardQuotation = <TError = void | HTTPValidationError,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof awardQuotation>>, TError,{qtId: string;data: ReqAwardQuotation}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient): UseMutationResult<
|
||||
Awaited<ReturnType<typeof awardQuotation>>,
|
||||
TError,
|
||||
{qtId: string;data: ReqAwardQuotation},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
const mutationOptions = getAwardQuotationMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
/**
|
||||
* @summary 견적 재생성(다음 라운드)
|
||||
*/
|
||||
export const regenerateQuotation = (
|
||||
|
||||
26
negodata/front/src/components/ui/phone-input.tsx
Normal file
26
negodata/front/src/components/ui/phone-input.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { Input } from './input';
|
||||
import { formatPhoneKR, normalizePhone } from '@/lib/phone';
|
||||
|
||||
// 연락처 전용 입력. 표시는 하이픈 자동(010/02/031/1588 등), 밖으로 넘기는 값은 숫자만.
|
||||
// react-hook-form Controller 의 field(value/onChange/onBlur)를 그대로 물리면 된다.
|
||||
type PhoneInputProps = Omit<React.ComponentProps<typeof Input>, 'value' | 'onChange' | 'type'> & {
|
||||
value?: string; // 숫자만(정규화된 폼 값)
|
||||
onChange?: (value: string) => void; // 숫자만으로 방출
|
||||
};
|
||||
|
||||
export const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(function PhoneInput(
|
||||
{ value = '', onChange, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
value={formatPhoneKR(value)}
|
||||
onChange={(e) => onChange?.(normalizePhone(e.target.value))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@ -1,10 +1,12 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { normalizePhone } from '@/lib/phone';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { PhoneInput } from '@/components/ui/phone-input';
|
||||
import { Sheet } from '@/components/ui/sheet';
|
||||
import { useAuth } from '../useAuth';
|
||||
import { updateMe } from '../service';
|
||||
@ -26,6 +28,7 @@ export function ProfileSheet({ open, onClose }: { open: boolean; onClose: () =>
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
@ -34,7 +37,7 @@ export function ProfileSheet({ open, onClose }: { open: boolean; onClose: () =>
|
||||
defaultValues: {
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
contactNumber: user?.contact ?? '',
|
||||
contactNumber: normalizePhone(user?.contact ?? ''),
|
||||
password: '',
|
||||
passwordConfirm: '',
|
||||
},
|
||||
@ -98,7 +101,20 @@ export function ProfileSheet({ open, onClose }: { open: boolean; onClose: () =>
|
||||
{/* 연락처 */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">연락처</Typography>
|
||||
<Input id="profile-phone" type="text" {...register('contactNumber')} className={inputClass} placeholder="010-XXXX-XXXX" />
|
||||
<Controller
|
||||
control={control}
|
||||
name="contactNumber"
|
||||
render={({ field }) => (
|
||||
<PhoneInput
|
||||
id="profile-phone"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
className={inputClass}
|
||||
placeholder="010-XXXX-XXXX"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 변경(옵션) */}
|
||||
|
||||
@ -0,0 +1,372 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { CardUsageType } from '@/api/generated/model';
|
||||
import { deserialize } from '../editor';
|
||||
import type { CardInput } from '../hooks/useCards';
|
||||
|
||||
// 엑셀에서 읽어온 원본 행(입력값만). status/message는 검증에서 파생한다.
|
||||
type RawRow = {
|
||||
id: string;
|
||||
rowNum: number;
|
||||
kind: string; // 카드종류 원문(협상/와일드)
|
||||
code: string; // 카드번호
|
||||
title: string; // 카드이름
|
||||
script: string; // 스크립트(평문 — 변수는 {변수} 텍스트로)
|
||||
usage: string; // 카드용도 원문(공통/신규견적전용/재견적전용)
|
||||
scope: string; // 공개범위 원문(개인/전체)
|
||||
condition: string; // 와일드 전용 사용조건
|
||||
memo: string; // 와일드 전용 메모
|
||||
};
|
||||
|
||||
type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string };
|
||||
|
||||
type TemplateRow = { kind: string; code: string; title: string; script: string; usage: string; scope: string; condition: string; memo: string };
|
||||
|
||||
// 카드종류 원문 → 와일드 여부. '와일드'/'wild' 포함이면 와일드, 그 외 협상.
|
||||
const isWild = (kind: string) => /와일드|wild/i.test(kind.trim());
|
||||
// 공개범위 원문 → 전체(공용) 여부. '전체'/'공용'/'all' 이면 공용, 그 외 개인.
|
||||
const isShared = (scope: string) => /전체|공용|all/i.test(scope.trim());
|
||||
// 카드용도 원문 → CardUsageType 코드. '신규'→NEW '재'→REUSE 그 외 COMMON.
|
||||
function usageCode(usage: string): number {
|
||||
const u = usage.trim();
|
||||
if (/신규/.test(u)) return CardUsageType.NEW;
|
||||
if (/재/.test(u)) return CardUsageType.REUSE;
|
||||
return CardUsageType.COMMON;
|
||||
}
|
||||
|
||||
type CardExcelUploadModalProps = {
|
||||
open: boolean;
|
||||
onConfirm: (rows: CardInput[]) => Promise<{ failures: BulkFailure[] }>;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// 행 검증 — 순수 함수. 우선순위 순으로 첫 위반 메시지를 매긴다.
|
||||
// serverErrors: 서버가 거부한 code(카드번호)→사유. 프론트 검증 통과 행에만 마지막에 덧씌운다.
|
||||
function validateRows(rows: RawRow[], serverErrors: Record<string, string>): ValidatedRow[] {
|
||||
return rows.map((row) => {
|
||||
const fail = (message: string): ValidatedRow => ({ ...row, status: '오류', message });
|
||||
|
||||
if (!row.code.trim()) return fail('유효성 위반 - 카드번호를 입력해 주십시오.');
|
||||
if (!row.title.trim()) return fail('유효성 위반 - 카드이름을 입력해 주십시오.');
|
||||
if (!row.script.trim()) return fail('유효성 위반 - 스크립트를 입력해 주십시오.');
|
||||
|
||||
if (serverErrors[row.code]) return fail(serverErrors[row.code]);
|
||||
|
||||
const kind = isWild(row.kind) ? '와일드' : '협상';
|
||||
const scope = isShared(row.scope) ? '전체' : '개인';
|
||||
return { ...row, status: '정상', message: `등록 적격 - ${kind}카드 · ${scope}` };
|
||||
});
|
||||
}
|
||||
|
||||
// 검증된(정상) 행 → 서버 등록용 CardInput. 스크립트 평문은 Slate 노드로 복원(변수칩 재현).
|
||||
function toCardInput(row: RawRow): CardInput {
|
||||
const wild = isWild(row.kind);
|
||||
return {
|
||||
title: row.title,
|
||||
code: row.code,
|
||||
editorScript: deserialize(undefined, row.script),
|
||||
status: 'ACTIVE',
|
||||
isWildcard: wild,
|
||||
isShared: isShared(row.scope),
|
||||
usageType: usageCode(row.usage),
|
||||
triggerCondition: wild ? row.condition : undefined,
|
||||
memo: wild ? row.memo : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// 카드 엑셀 일괄 업로드 모달. 파일 파싱·원본 행 state는 이 컴포넌트가 소유하고,
|
||||
// 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임.
|
||||
export function CardExcelUploadModal({ open, onConfirm, onClose }: CardExcelUploadModalProps) {
|
||||
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
||||
const [excelFile, setExcelFile] = useState<string | null>(null);
|
||||
const [rows, setRows] = useState<RawRow[]>([]);
|
||||
const [serverErrors, setServerErrors] = useState<Record<string, string>>({});
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const validated = useMemo(() => validateRows(rows, serverErrors), [rows, serverErrors]);
|
||||
const validRows = validated.filter((r) => r.status === '정상');
|
||||
const validCount = validRows.length;
|
||||
const errorCount = validated.length - validCount;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const close = () => {
|
||||
setExcelFile(null);
|
||||
setRows([]);
|
||||
setServerErrors({});
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). 헤더는 양식과 동일해야 함.
|
||||
const handleFile = async (file: File) => {
|
||||
const parsed = parseCsv(await file.text());
|
||||
const loaded: RawRow[] = parsed.map((r, i) => ({
|
||||
id: `row-${i + 1}`,
|
||||
rowNum: i + 2,
|
||||
kind: r['카드종류'] ?? '',
|
||||
code: r['카드번호'] ?? '',
|
||||
title: r['카드이름'] ?? '',
|
||||
script: r['스크립트'] ?? '',
|
||||
usage: r['카드용도'] ?? '',
|
||||
scope: r['공개범위'] ?? '',
|
||||
condition: r['사용조건'] ?? '',
|
||||
memo: r['메모'] ?? '',
|
||||
}));
|
||||
setExcelFile(file.name);
|
||||
setRows(loaded);
|
||||
setServerErrors({});
|
||||
};
|
||||
|
||||
const handleUpdateField = (id: string, field: 'code' | 'title' | 'script', value: string) => {
|
||||
setRows((cur) => cur.map((row) => (row.id === id ? { ...row, [field]: value } : row)));
|
||||
};
|
||||
|
||||
const handleRemoveRow = (id: string) => {
|
||||
setRows((cur) => cur.filter((row) => row.id !== id));
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (validRows.length === 0) {
|
||||
showToast('정합성이 무결한 카드 행이 존재하지 않습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { failures } = await onConfirm(validRows.map(toCardInput));
|
||||
const okCount = validRows.length - failures.length;
|
||||
if (failures.length === 0) {
|
||||
showToast(`총 ${okCount}개 카드가 서버에 일괄 등록되었습니다.`, 'success');
|
||||
close();
|
||||
return;
|
||||
}
|
||||
// 부분 성공: 등록 성공한 행만 제거하고, 서버가 거부한 행은 사유와 함께 남긴다.
|
||||
const failMap: Record<string, string> = {};
|
||||
failures.forEach((f) => { failMap[f.code] = f.message; });
|
||||
const okCodes = new Set(validRows.map((r) => r.code).filter((c) => failMap[c] === undefined));
|
||||
setServerErrors(failMap);
|
||||
setRows((cur) => cur.filter((r) => !okCodes.has(r.code)));
|
||||
showToast(`${okCount}건 등록 완료 · ${failures.length}건 서버 검증 실패`, 'error');
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : '엑셀 일괄 등록 실패', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
|
||||
<div className="w-full max-w-6xl bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono">
|
||||
|
||||
{/* Modal Title */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border">
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<FileSpreadsheet className="text-muted-foreground" size={20} />
|
||||
<Typography variant="h3">협상카드 엑셀 일괄 검증 및 등록</Typography>
|
||||
</div>
|
||||
<button onClick={close} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File select + drop */}
|
||||
<div className="my-6">
|
||||
{!excelFile ? (
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
}}
|
||||
className={`border-2 border-dashed rounded-lg p-8 flex flex-col items-center justify-center transition-colors ${
|
||||
isDragging ? 'border-primary bg-primary/10' : 'border-border bg-muted/30'
|
||||
}`}
|
||||
>
|
||||
<Upload size={32} className="text-muted-foreground mb-3" />
|
||||
<Typography variant="small" className="font-semibold">
|
||||
협상카드 리스트 엑셀 파일을 업로드해 주십시오
|
||||
</Typography>
|
||||
<Typography variant="muted" className="text-[10px] mt-1.5 mb-4">
|
||||
카드번호·카드이름·스크립트 필수 항목 검사를 수행합니다. 스크립트는 평문으로 등록됩니다.
|
||||
</Typography>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
id="excel-cards-file-input"
|
||||
accept=".csv,.xls,.xlsx"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="py-1.5 px-3 bg-foreground text-background text-xs font-semibold rounded cursor-pointer hover:opacity-95"
|
||||
>
|
||||
엑셀 파일 업로드하기
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* File meta */}
|
||||
<div className="flex items-center justify-between p-3 rounded bg-emerald-500/10 border border-emerald-500/30 text-xs">
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<CheckCircle2 className="text-emerald-500" size={16} />
|
||||
<span>{excelFile}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">{validated.length}개 데이터 행 검출</span>
|
||||
</div>
|
||||
|
||||
{/* Previews table and validation */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-bold text-foreground block">엑셀 정밀 유효값 진단</span>
|
||||
|
||||
<div className="border border-border rounded overflow-auto max-h-60">
|
||||
<Table className="w-full text-left font-mono text-[11px] border-collapse bg-background">
|
||||
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
|
||||
<TableRow>
|
||||
<TableHead className="p-2 font-semibold w-12 text-center">행</TableHead>
|
||||
<TableHead className="p-2 font-semibold text-center w-12">삭제</TableHead>
|
||||
<TableHead className="p-2 font-semibold text-center w-16">자격</TableHead>
|
||||
<TableHead className="p-2 font-semibold">진단 내용</TableHead>
|
||||
<TableHead className="p-2 font-semibold w-16">종류</TableHead>
|
||||
<TableHead className="p-2 font-semibold">카드번호 *</TableHead>
|
||||
<TableHead className="p-2 font-semibold">카드이름 *</TableHead>
|
||||
<TableHead className="p-2 font-semibold">스크립트 *</TableHead>
|
||||
<TableHead className="p-2 font-semibold w-16">용도</TableHead>
|
||||
<TableHead className="p-2 font-semibold w-16">공개</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="divide-y divide-border">
|
||||
{validated.map((row) => (
|
||||
<TableRow key={row.id} className={row.status === '오류' ? 'bg-red-500/5 hover:bg-red-500/10' : 'bg-emerald-500/5 hover:bg-emerald-500/10'}>
|
||||
<TableCell className="p-2 text-center text-muted-foreground">{row.rowNum}</TableCell>
|
||||
<TableCell className="p-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveRow(row.id)}
|
||||
title="이 행 삭제"
|
||||
className="p-1 rounded text-muted-foreground hover:text-rose-600 hover:bg-rose-500/10 cursor-pointer"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="p-2 text-center">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[9px] font-bold block ${
|
||||
row.status === '정상'
|
||||
? 'bg-emerald-100 text-emerald-800 border border-emerald-300 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-800/80'
|
||||
: 'bg-red-100 text-red-800 border border-red-300 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-950'
|
||||
}`}>
|
||||
{row.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className={`p-2 font-mono text-[10px] ${row.status === '오류' ? 'text-rose-500' : 'text-emerald-600'}`}>
|
||||
{row.message}
|
||||
</TableCell>
|
||||
<TableCell className="p-2 text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{isWild(row.kind) ? '와일드' : '협상'}
|
||||
</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Input
|
||||
type="text"
|
||||
className="bg-muted/20 hover:bg-muted/50 text-foreground font-mono"
|
||||
value={row.code}
|
||||
onChange={(e) => handleUpdateField(row.id, 'code', e.target.value)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Input
|
||||
type="text"
|
||||
className="bg-muted/20 hover:bg-muted/50 text-foreground font-semibold"
|
||||
value={row.title}
|
||||
onChange={(e) => handleUpdateField(row.id, 'title', e.target.value)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Input
|
||||
type="text"
|
||||
className="bg-muted/20 hover:bg-muted/50 text-foreground"
|
||||
value={row.script}
|
||||
onChange={(e) => handleUpdateField(row.id, 'script', e.target.value)}
|
||||
placeholder="협상 스크립트(평문)"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-2 text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{usageCode(row.usage) === CardUsageType.NEW ? '신규' : usageCode(row.usage) === CardUsageType.REUSE ? '재' : '공통'}
|
||||
</TableCell>
|
||||
<TableCell className="p-2 text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{isShared(row.scope) ? '전체' : '개인'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Typography variant="muted" className="text-[10px]">
|
||||
※ 와일드카드의 사용조건·메모는 표에 표시되지 않지만 업로드에 함께 반영됩니다. 스크립트의 굵게·색상 서식은 평문 등록 후 카드 편집에서 지정하세요.
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Excel footer */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-border mt-6 text-xs bg-muted/40 p-3 rounded">
|
||||
<span className="font-mono text-muted-foreground">
|
||||
적격 카드: {validCount}개 // 비적격 차단: {errorCount}개
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="py-1.5 px-3 border border-border rounded hover:bg-muted text-foreground cursor-pointer text-xs"
|
||||
>
|
||||
중단
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="excel-cards-confirm-button"
|
||||
disabled={validCount === 0}
|
||||
onClick={handleConfirm}
|
||||
className="py-1.5 px-4 bg-primary text-primary-foreground font-bold rounded hover:opacity-95 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer text-xs"
|
||||
>
|
||||
적격 카드 등록 (총 {validCount}개)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 업로드 양식(.csv) 다운로드 — 채워 넣을 컬럼 헤더 + 예시 2행(협상/와일드). 툴바·모달이 공유한다.
|
||||
export function downloadCardTemplate() {
|
||||
downloadExcel<TemplateRow>(
|
||||
'협상카드_업로드_양식',
|
||||
[
|
||||
{ header: '카드종류', value: (r) => r.kind },
|
||||
{ header: '카드번호', value: (r) => r.code },
|
||||
{ header: '카드이름', value: (r) => r.title },
|
||||
{ header: '스크립트', value: (r) => r.script },
|
||||
{ header: '카드용도', value: (r) => r.usage },
|
||||
{ header: '공개범위', value: (r) => r.scope },
|
||||
{ header: '사용조건', value: (r) => r.condition },
|
||||
{ header: '메모', value: (r) => r.memo },
|
||||
],
|
||||
[
|
||||
{ kind: '협상', code: 'CARD-EX-01', title: '예시) 최우수 등급 부여 카드', script: '귀사를 최우수 협력사로 지정하여 {목표가} 조건을 제안드립니다.', usage: '공통', scope: '개인', condition: '', memo: '' },
|
||||
{ kind: '와일드', code: 'WILD-EX-01', title: '예시) 원자재 급등 대응 카드', script: '원자재 시세 급등에 따른 단가 재조정을 요청드립니다.', usage: '공통', scope: '전체', condition: '원부자재 시세가 계약일 대비 3.5% 상회 시', memo: '특정 원재료 포함 입찰에만 적용' },
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -16,6 +16,7 @@ import { CardScriptEditor, deserialize, serializeToText } from '../editor';
|
||||
|
||||
const schema = z.object({
|
||||
isWildcard: z.boolean(),
|
||||
isShared: z.boolean(),
|
||||
usageType: z.number(),
|
||||
code: z.string().trim().min(1, '카드번호는 필수 기입 사항입니다.'),
|
||||
title: z.string().trim().min(1, '카드이름은 필수 기입 사항입니다.'),
|
||||
@ -48,6 +49,7 @@ function buildDefaults(
|
||||
if (mode === 'edit' && card) {
|
||||
return {
|
||||
isWildcard: card.isWildcard,
|
||||
isShared: card.isShared,
|
||||
usageType: card.usageType,
|
||||
code: card.code,
|
||||
title: card.title,
|
||||
@ -61,6 +63,7 @@ function buildDefaults(
|
||||
const wild = activeTab === 'WILD';
|
||||
return {
|
||||
isWildcard: wild,
|
||||
isShared: false, // 기본: 개인(나만) — 전체 공용은 등록 시 명시 선택
|
||||
usageType: CardUsageType.COMMON, // 기본: 공통(신규·재 모두)
|
||||
code: generateCardCode(wild),
|
||||
title: '',
|
||||
@ -105,6 +108,7 @@ export function CardFormSheet({
|
||||
editorScript: v.editorScript,
|
||||
status: v.status,
|
||||
isWildcard: v.isWildcard,
|
||||
isShared: v.isShared,
|
||||
usageType: v.usageType,
|
||||
triggerCondition: v.triggerCondition,
|
||||
memo: v.memo,
|
||||
@ -180,6 +184,16 @@ export function CardFormSheet({
|
||||
{errors.code && <p className="text-[10px] text-rose-500">{errors.code.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* 작성자(등록자) — 읽기전용, 편집 시에만. 공용 카드는 작성자 없음. */}
|
||||
{mode === 'edit' && (
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="small" className="font-semibold">작성자</Typography>
|
||||
<Typography as="p" variant="small" className="text-muted-foreground">
|
||||
{card?.isShared ? '공용' : card?.creatorName ?? '-'}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="small" className="font-semibold block mb-1">협상 적용 여부</Typography>
|
||||
@ -226,27 +240,57 @@ export function CardFormSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카드 용도(usage_type): 공통 / 신규전용 / 재전용 */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="small" className="font-semibold">카드 용도</Typography>
|
||||
<Controller
|
||||
control={control}
|
||||
name="usageType"
|
||||
render={({ field }) => (
|
||||
<Select value={String(field.value)} onValueChange={(v) => field.onChange(Number(v))}>
|
||||
<SelectTrigger id="form-card-usage-type" className="w-full">
|
||||
<SelectValue>
|
||||
{(value) => CARD_USAGE_TYPE_LABEL[Number(value) as CardUsageType] ?? '공통'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CARD_USAGE_TYPE_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{/* 공개 범위(scope): 개인=나만 / 전체=모두 공용(user_id NULL). 등록 시에만 결정, 수정 시 고정 */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="small" className="font-semibold">공개 범위</Typography>
|
||||
<Controller
|
||||
control={control}
|
||||
name="isShared"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value ? 'ALL' : 'MINE'}
|
||||
onValueChange={(v) => {
|
||||
if (mode !== 'create') return; // 스코프는 등록 시에만 결정(수정 시 이동 불가)
|
||||
field.onChange(v === 'ALL');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="form-card-scope" className="w-full" disabled={mode === 'edit'}>
|
||||
<SelectValue>
|
||||
{(value) => (value === 'ALL' ? '전체 (모두 공용)' : '개인 (나만)')}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MINE">개인 (나만)</SelectItem>
|
||||
<SelectItem value="ALL">전체 (모두 공용)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 카드 용도(usage_type): 공통 / 신규전용 / 재전용 */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="small" className="font-semibold">카드 용도</Typography>
|
||||
<Controller
|
||||
control={control}
|
||||
name="usageType"
|
||||
render={({ field }) => (
|
||||
<Select value={String(field.value)} onValueChange={(v) => field.onChange(Number(v))}>
|
||||
<SelectTrigger id="form-card-usage-type" className="w-full">
|
||||
<SelectValue>
|
||||
{(value) => CARD_USAGE_TYPE_LABEL[Number(value) as CardUsageType] ?? '공통'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CARD_USAGE_TYPE_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
|
||||
@ -20,16 +20,24 @@ export function CardTable({ data, onEdit, footer }: CardTableProps) {
|
||||
{
|
||||
header: '구분',
|
||||
headClassName: 'w-24',
|
||||
cell: (card) =>
|
||||
card.isWildcard ? (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-rose-50 text-rose-700 dark:bg-rose-950/20 dark:text-rose-400 border border-rose-200/40">
|
||||
와일드카드
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-blue-50 text-blue-700 dark:bg-blue-950/20 dark:text-blue-400 border border-blue-200/40">
|
||||
협상카드
|
||||
</span>
|
||||
),
|
||||
cell: (card) => (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
{card.isWildcard ? (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-rose-50 text-rose-700 dark:bg-rose-950/20 dark:text-rose-400 border border-rose-200/40">
|
||||
와일드카드
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-blue-50 text-blue-700 dark:bg-blue-950/20 dark:text-blue-400 border border-blue-200/40">
|
||||
협상카드
|
||||
</span>
|
||||
)}
|
||||
{card.isShared && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-violet-50 text-violet-700 dark:bg-violet-950/20 dark:text-violet-400 border border-violet-200/40">
|
||||
전체
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '카드번호',
|
||||
@ -88,6 +96,13 @@ export function CardTable({ data, onEdit, footer }: CardTableProps) {
|
||||
<span className="text-muted-foreground font-mono font-medium opacity-65">-</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '작성자',
|
||||
align: 'center',
|
||||
headClassName: 'w-24',
|
||||
cellClassName: 'text-muted-foreground whitespace-nowrap',
|
||||
cell: (card) => (card.isShared ? '공용' : card.creatorName ?? '-'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -10,6 +10,7 @@ import type { Descendant } from 'slate';
|
||||
import type { ReqCreateCard } from '@/api/generated/model/reqCreateCard';
|
||||
import type { ResCard } from '@/api/generated/model/resCard';
|
||||
import type { NegotiationCard } from '@/types';
|
||||
import type { BulkFailure } from '@/lib/excel';
|
||||
import { mapCardData, toCardStatusCode } from '../types';
|
||||
import { serializeToText } from '../editor';
|
||||
|
||||
@ -21,6 +22,7 @@ export type CardInput = {
|
||||
editorScript: Descendant[];
|
||||
status: 'ACTIVE' | 'INACTIVE';
|
||||
isWildcard: boolean;
|
||||
isShared: boolean; // 전체(공용, user_id NULL) 등록 여부. 등록 시에만 유효(수정 시 스코프 고정)
|
||||
usageType: number; // usage_type(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
|
||||
triggerCondition?: string;
|
||||
memo?: string;
|
||||
@ -37,6 +39,7 @@ function cardError(res: ResCard): string | null {
|
||||
function toReq(input: CardInput): ReqCreateCard {
|
||||
return {
|
||||
is_wildcard: input.isWildcard,
|
||||
is_shared: input.isShared,
|
||||
usage_type: input.usageType,
|
||||
name: input.title,
|
||||
number: input.code,
|
||||
@ -73,6 +76,22 @@ export function useCards(params: ListCardsParams) {
|
||||
await refresh();
|
||||
};
|
||||
|
||||
// 엑셀 일괄 등록 — 행마다 createCard(협력사와 동일 패턴). 실패 행은 code(카드번호)로 사유 수집.
|
||||
// N번 refresh 방지 위해 raw createCard 를 직접 돌리고 끝에 한 번만 무효화한다.
|
||||
const bulkCreate = async (rows: CardInput[]): Promise<{ failures: BulkFailure[] }> => {
|
||||
const failures: BulkFailure[] = [];
|
||||
for (const input of rows) {
|
||||
try {
|
||||
const msg = cardError(await createCard(toReq(input)));
|
||||
if (msg) failures.push({ code: input.code, message: msg });
|
||||
} catch (err) {
|
||||
failures.push({ code: input.code, message: err instanceof Error ? err.message : '등록 실패' });
|
||||
}
|
||||
}
|
||||
await refresh();
|
||||
return { failures };
|
||||
};
|
||||
|
||||
// customFetch 가 본문을 그대로 주므로 cardsQuery.data 가 곧 ResCardList → .cards.
|
||||
const cards: NegotiationCard[] = (cardsQuery.data?.cards ?? []).map(mapCardData);
|
||||
const total = cardsQuery.data?.total ?? 0; // 선택 탭 기준 총건수(페이지네이션)
|
||||
@ -87,6 +106,7 @@ export function useCards(params: ListCardsParams) {
|
||||
createCard: createCardFn,
|
||||
updateCard: updateCardFn,
|
||||
deleteCard: deleteCardFn,
|
||||
bulkCreate,
|
||||
refresh,
|
||||
cardsQuery,
|
||||
};
|
||||
|
||||
@ -17,6 +17,7 @@ export function mapCardData(c: CardData): NegotiationCard {
|
||||
return {
|
||||
id: c.nego_card_id,
|
||||
isWildcard: c.is_wildcard ?? false,
|
||||
isShared: c.is_shared ?? false,
|
||||
usageType: c.usage_type ?? CardUsageType.COMMON,
|
||||
code: c.number ?? '',
|
||||
title: c.name ?? '',
|
||||
@ -25,6 +26,7 @@ export function mapCardData(c: CardData): NegotiationCard {
|
||||
status: toCardStatusLabel(c.status),
|
||||
triggerCondition: c.condition ?? undefined,
|
||||
memo: c.memo ?? undefined,
|
||||
creatorName: c.creator_name ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -3,9 +3,11 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { normalizePhone } from '@/lib/phone';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { PhoneInput } from '@/components/ui/phone-input';
|
||||
import { Sheet } from '@/components/ui/sheet';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
@ -47,7 +49,7 @@ function buildDefaults(mode: 'create' | 'edit', member: Member | null): FormValu
|
||||
passwordConfirm: '',
|
||||
name: member.name ?? '',
|
||||
email: member.email ?? '',
|
||||
contactNumber: member.contact_number ?? '',
|
||||
contactNumber: normalizePhone(member.contact_number ?? ''),
|
||||
status: member.status,
|
||||
};
|
||||
}
|
||||
@ -216,12 +218,19 @@ export function MemberFormSheet({
|
||||
{/* 연락처 */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">연락처</Typography>
|
||||
<Input
|
||||
id="form-member-phone"
|
||||
type="text"
|
||||
{...register('contactNumber')}
|
||||
className={inputClass}
|
||||
placeholder="010-XXXX-XXXX"
|
||||
<Controller
|
||||
control={control}
|
||||
name="contactNumber"
|
||||
render={({ field }) => (
|
||||
<PhoneInput
|
||||
id="form-member-phone"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
className={inputClass}
|
||||
placeholder="010-XXXX-XXXX"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { DataTable } from '@/components/ui/data-table';
|
||||
import { TablePagination } from '@/components/ui/table-pagination';
|
||||
import { USER_ROLE_LABEL } from '@/lib/enumLabels';
|
||||
import { formatPhoneKR } from '@/lib/phone';
|
||||
import { UserStatus, USER_STATUS_LABEL, type Member } from '../types';
|
||||
|
||||
type MemberTableProps = {
|
||||
@ -66,7 +67,7 @@ export function MemberTable({
|
||||
cell: (m) => (
|
||||
<div className="space-y-0.5 font-mono text-xs">
|
||||
<div className="text-foreground">{m.email || '-'}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{m.contact_number || '-'}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{m.contact_number ? formatPhoneKR(m.contact_number) : '-'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel';
|
||||
@ -76,7 +77,7 @@ function toSupplierCreate(row: RawRow): SupplierCreate {
|
||||
code: row.code,
|
||||
manager_name: row.managerName,
|
||||
manager_email: row.managerEmail,
|
||||
manager_contact_number: '010-0000-0000',
|
||||
manager_contact_number: '01000000000', // 양식에 연락처 컬럼 없음 → placeholder(숫자만 저장 컨벤션)
|
||||
total_revenue: row.totalRevenue?.trim() ? Number(row.totalRevenue.replace(/[^0-9]/g, '')) : undefined,
|
||||
};
|
||||
}
|
||||
@ -100,6 +101,7 @@ export function downloadPartnerTemplate() {
|
||||
// 협력사 엑셀 일괄 업로드 모달. 파일 파싱·원본 행 state는 이 컴포넌트가 소유하고,
|
||||
// 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임.
|
||||
export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUploadModalProps) {
|
||||
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
||||
const [excelFile, setExcelFile] = useState<string | null>(null);
|
||||
const [rows, setRows] = useState<RawRow[]>([]);
|
||||
const [serverErrors, setServerErrors] = useState<Record<string, string>>({}); // 서버(DB) 거부 code→사유
|
||||
@ -199,8 +201,8 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs">
|
||||
<div className="w-full max-w-5xl bg-card border border-border rounded-lg shadow-2xl p-6 overflow-hidden animate-scale-up font-mono">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
|
||||
<div className="w-full max-w-5xl bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono">
|
||||
|
||||
{/* Modal Title */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border">
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier';
|
||||
import type { ReqUpdateSupplier as SupplierUpdate } from '@/api/generated/model/reqUpdateSupplier';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { normalizePhone } from '@/lib/phone';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { PhoneInput } from '@/components/ui/phone-input';
|
||||
import { Sheet } from '@/components/ui/sheet';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { SupplierItemsManager } from './SupplierItemsManager';
|
||||
import { type Partner } from '../types';
|
||||
|
||||
@ -41,7 +44,7 @@ function buildDefaults(mode: 'create' | 'edit', partner: Partner | null): FormVa
|
||||
code: partner.code || '',
|
||||
managerName: partner.manager_name || '',
|
||||
managerEmail: partner.manager_email || '',
|
||||
managerPhone: partner.manager_contact_number || '',
|
||||
managerPhone: normalizePhone(partner.manager_contact_number || ''),
|
||||
totalRevenue: partner.total_revenue != null ? String(partner.total_revenue) : '',
|
||||
};
|
||||
}
|
||||
@ -50,7 +53,7 @@ function buildDefaults(mode: 'create' | 'edit', partner: Partner | null): FormVa
|
||||
code: `PART-CORP-${Math.floor(100 + Math.random() * 900)}`,
|
||||
managerName: '',
|
||||
managerEmail: '',
|
||||
managerPhone: '010-',
|
||||
managerPhone: '010', // 신규 등록 시 010 프리필 → 뒷자리만 입력
|
||||
totalRevenue: '',
|
||||
};
|
||||
}
|
||||
@ -68,6 +71,7 @@ export function PartnerFormSheet({
|
||||
}: PartnerFormSheetProps) {
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
@ -75,6 +79,9 @@ export function PartnerFormSheet({
|
||||
defaultValues: buildDefaults(mode, partner),
|
||||
});
|
||||
|
||||
// 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(백엔드 RequireOwner 와 동일 규칙).
|
||||
const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자');
|
||||
|
||||
const onValid = async (v: FormValues) => {
|
||||
const common = {
|
||||
name: v.name,
|
||||
@ -151,6 +158,14 @@ export function PartnerFormSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 작성자(등록자) — 읽기전용, 편집 시에만 */}
|
||||
{mode === 'edit' && (
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">작성자</Typography>
|
||||
<Typography as="p" variant="small" className="text-muted-foreground">{partner?.creator_name ?? '-'}</Typography>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manager Name */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">담당자명</Typography>
|
||||
@ -180,12 +195,19 @@ export function PartnerFormSheet({
|
||||
{/* Manager Phone */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">담당자 연락처</Typography>
|
||||
<Input
|
||||
id="form-partner-phone"
|
||||
type="text"
|
||||
{...register('managerPhone')}
|
||||
className={inputClass}
|
||||
placeholder="010-XXXX-XXXX"
|
||||
<Controller
|
||||
control={control}
|
||||
name="managerPhone"
|
||||
render={({ field }) => (
|
||||
<PhoneInput
|
||||
id="form-partner-phone"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
className={inputClass}
|
||||
placeholder="010-XXXX-XXXX"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.managerPhone && <p className="text-[10px] text-rose-500">{errors.managerPhone.message}</p>}
|
||||
</div>
|
||||
@ -200,6 +222,8 @@ export function PartnerFormSheet({
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={!isSuperAdmin}
|
||||
title={isSuperAdmin ? undefined : '협력사 삭제는 최고관리자만 할 수 있습니다.'}
|
||||
onClick={() => {
|
||||
onDelete(partner.supplier_id, partner.name);
|
||||
onClose();
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { DataTable } from '@/components/ui/data-table';
|
||||
import { TablePagination } from '@/components/ui/table-pagination';
|
||||
import { formatPhoneKR } from '@/lib/phone';
|
||||
import type { Partner } from '../types';
|
||||
|
||||
type PartnerTableProps = {
|
||||
@ -59,7 +60,7 @@ export function PartnerTable({
|
||||
<div className="space-y-0.5 font-mono text-xs">
|
||||
<div className="text-foreground font-semibold">{part.manager_name}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{part.manager_email} / {part.manager_contact_number}
|
||||
{part.manager_email} / {part.manager_contact_number ? formatPhoneKR(part.manager_contact_number) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@ -70,6 +71,12 @@ export function PartnerTable({
|
||||
cellClassName: 'font-mono text-muted-foreground',
|
||||
cell: (part) => (part.total_revenue != null ? `₩${Number(part.total_revenue).toLocaleString()}` : '-'),
|
||||
},
|
||||
{
|
||||
header: '작성자',
|
||||
align: 'center',
|
||||
cellClassName: 'text-muted-foreground whitespace-nowrap',
|
||||
cell: (part) => part.creator_name ?? '-',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel';
|
||||
@ -180,6 +181,7 @@ function toItemCreate(row: RawRow): ItemCreate {
|
||||
// 상품 엑셀 일괄 업로드 모달. 파일 파싱(목업)·원본 행 state는 이 컴포넌트가 소유하고,
|
||||
// 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임.
|
||||
export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUploadModalProps) {
|
||||
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
||||
const [excelFile, setExcelFile] = useState<string | null>(null);
|
||||
const [rows, setRows] = useState<RawRow[]>([]);
|
||||
const [serverErrors, setServerErrors] = useState<Record<string, string>>({}); // 서버(DB) 거부 code→사유
|
||||
@ -294,8 +296,8 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs">
|
||||
<div className="w-full max-w-5xl bg-card border border-border rounded-lg shadow-2xl p-6 overflow-hidden animate-scale-up font-mono">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
|
||||
<div className="w-full max-w-5xl bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono">
|
||||
|
||||
{/* Modal Title */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border">
|
||||
|
||||
@ -4,6 +4,7 @@ import { Globe, X, AlertCircle, Loader2, Cpu, RefreshCw } from 'lucide-react';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import type { Product } from '../types';
|
||||
|
||||
type PriceUpdateModalProps = {
|
||||
@ -17,6 +18,7 @@ type PriceUpdateModalProps = {
|
||||
// 인터넷 최저가 실시간 수집 데모 모달. 크롤링 진행 state는 이 컴포넌트가 소유한다.
|
||||
// NOTE: 서버 미연동 — 진행 애니메이션/로그만 데모.
|
||||
export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose }: PriceUpdateModalProps) {
|
||||
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
||||
const [isCrawling, setIsCrawling] = useState(false);
|
||||
const [crawlingProgress, setCrawlingProgress] = useState(0);
|
||||
const [crawlerLogs, setCrawlerLogs] = useState<string[]>([]);
|
||||
@ -72,8 +74,8 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 backdrop-blur-xs">
|
||||
<div className="w-full max-w-lg bg-card border border-border rounded-lg shadow-2xl p-6 overflow-hidden animate-scale-up font-mono text-xs">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/55 backdrop-blur-xs">
|
||||
<div className="w-full max-w-lg bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono text-xs">
|
||||
|
||||
{/* Modal Title */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border">
|
||||
|
||||
@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Sheet } from '@/components/ui/sheet';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { type Product } from '../types';
|
||||
|
||||
// 폼 검증 스키마. 필수: 상품명/상품코드/단가/최저가. 나머지는 선택.
|
||||
@ -129,6 +130,13 @@ export function ProductFormSheet({
|
||||
|
||||
const deliveryTypes = DELIVERY_TYPE_OPTIONS;
|
||||
|
||||
// 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정·삭제(프론트 1차 차단, 백엔드도 강제).
|
||||
const myUserId = useAuthStore((s) => s.user?.userId);
|
||||
const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자');
|
||||
const canManageOwn = !!product && (product.user_id === myUserId || isSuperAdmin);
|
||||
// 저장 가능 여부 — 신규는 항상, 수정은 소유자/관리자만.
|
||||
const canSave = mode === 'create' || canManageOwn;
|
||||
|
||||
// minPrice = 인터넷 최저가(실값) → internet_lowest_price 로 저장한다.
|
||||
const onValid = async (v: FormValues) => {
|
||||
// 기존 카테고리면 그 category_type(id) 재사용, 처음 쓰는 카테고리면 max+1 부여.
|
||||
@ -244,6 +252,14 @@ export function ProductFormSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 작성자(등록자) — 읽기전용, 편집 시에만 */}
|
||||
{mode === 'edit' && (
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">작성자</Typography>
|
||||
<Typography as="p" variant="small" className="text-muted-foreground">{product?.creator_name ?? '-'}</Typography>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Price */}
|
||||
<div className="space-y-1">
|
||||
@ -471,6 +487,8 @@ export function ProductFormSheet({
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={!canManageOwn}
|
||||
title={canManageOwn ? undefined : '본인이 등록한 상품만 삭제할 수 있습니다.'}
|
||||
onClick={() => {
|
||||
onDelete(product.item_id, product.name);
|
||||
onClose();
|
||||
@ -483,7 +501,12 @@ export function ProductFormSheet({
|
||||
<Button type="button" variant="outline" size="sm" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={isSubmitting}>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={isSubmitting || !canSave}
|
||||
title={canSave ? undefined : '본인이 등록한 상품만 수정할 수 있습니다.'}
|
||||
>
|
||||
{mode === 'create' ? '신규 상품 발행' : '변경사항 저장'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -93,6 +93,12 @@ export function ProductTable({
|
||||
cellClassName: 'font-mono font-semibold text-rose-600 dark:text-rose-400',
|
||||
cell: (prod) => (prod.internet_lowest_price != null ? `₩${Number(prod.internet_lowest_price).toLocaleString()}` : '-'),
|
||||
},
|
||||
{
|
||||
header: '작성자',
|
||||
align: 'center',
|
||||
cellClassName: 'text-muted-foreground whitespace-nowrap',
|
||||
cell: (prod) => prod.creator_name ?? '-',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { X, PlusSquare, ArrowRight, Loader2, Gavel } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useGetSupplierLastType } from '@/api/generated/quotation/quotation';
|
||||
import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item';
|
||||
import { useListItems, useGetItem } from '@/api/generated/item/item';
|
||||
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
||||
@ -10,14 +9,13 @@ import { mapCardData } from '@/features/cards/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Typography, typographyVariants } from '@/components/ui/typography';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Combobox, type ComboOption } from '@/components/ui/combobox';
|
||||
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
|
||||
import type { CreateQuotationInput } from '../hooks/useQuotations';
|
||||
import { QuotationType } from '@/api/generated/model';
|
||||
import {
|
||||
supplierTypeOptions,
|
||||
is1v1,
|
||||
toQuotationType,
|
||||
awardStrategySummary,
|
||||
@ -55,6 +53,8 @@ export function QuotationCreateModal({
|
||||
onCreate,
|
||||
onClose,
|
||||
}: QuotationCreateModalProps) {
|
||||
// 모달 열린 동안 배경(부모) 스크롤 잠금 — 뒤 페이지가 같이 스크롤되는 것 방지.
|
||||
useScrollLock();
|
||||
const [step, setStep] = useState(1);
|
||||
const [title, setTitle] = useState('');
|
||||
// 유형은 '진행 방식(협상/경매) × 대상(신규/후속)' 2축으로 받아 제출 직전 4코드로 합성한다.
|
||||
@ -65,9 +65,11 @@ export function QuotationCreateModal({
|
||||
const [dueDate, setDueDate] = useState(nowKstLocalInput);
|
||||
const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? '');
|
||||
const [selectedCardIds, setSelectedCardIds] = useState<string[]>([]);
|
||||
// 선택 항목 표시데이터 캐시 — 담는 순간 이름/이메일·카드메타를 적재해, 검색어가 바뀌어 콤보 목록에서 빠져도 아래 선택 테이블이 유지되게 한다.
|
||||
const [partnerDetails, setPartnerDetails] = useState<Map<string, { name: string; email: string }>>(() => new Map());
|
||||
const [cardDetails, setCardDetails] = useState<Map<string, { code: string; title: string; isWildcard: boolean }>>(() => new Map());
|
||||
const [memo, setMemo] = useState('');
|
||||
const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 상품값으로 목표가 산정
|
||||
const [supplierType, setSupplierType] = useState(''); // 협력사 유형(SupplierType). 전 유형에서 입력 → 견적에 기록
|
||||
const [midAction, setMidAction] = useState<number>(DEFAULT_MID_ACTION); // 앵커~목표가 구간: 낙찰/개찰 (1:1 전용)
|
||||
const [overAction, setOverAction] = useState<number>(DEFAULT_OVER_ACTION); // 목표가 초과 구간: 낙찰/개찰 (1:1 전용)
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@ -81,18 +83,6 @@ export function QuotationCreateModal({
|
||||
: ['기본 정보', '협력사 초청', '확인·완료'];
|
||||
const totalSteps = steps.length;
|
||||
|
||||
// 협력사 유형은 전 유형에서 입력받되, 재협상(1:1)이면 선택 협력사의 직전 견적 supplier_type 을 조회해 디폴트로 채운다.
|
||||
const renegoSupplierId = type === QuotationType.RENEGO ? (selectedPartnerIds[0] ?? '') : '';
|
||||
const lastTypeQuery = useGetSupplierLastType(renegoSupplierId, {
|
||||
query: { enabled: !!renegoSupplierId },
|
||||
});
|
||||
const prevSupplierType = lastTypeQuery.data?.supplier_type ?? null; // 협력사 직전 견적 유형(없으면 null)
|
||||
const prevQtNumber = lastTypeQuery.data?.qt_number ?? '';
|
||||
// 협력사가 정해지면 직전 견적 유형으로 디폴트(이후 사용자가 바꾸면 그 값 유지).
|
||||
useEffect(() => {
|
||||
setSupplierType(prevSupplierType != null ? String(prevSupplierType) : '');
|
||||
}, [renegoSupplierId, prevSupplierType]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ── 픽리스트 서버검색(상품/협력사/카드) — size 캡 없이 검색으로 도달. 미검색이면 부모가 넘긴 목록으로 기본 노출.
|
||||
@ -141,6 +131,12 @@ export function QuotationCreateModal({
|
||||
),
|
||||
}));
|
||||
|
||||
// 선택된 협력사 표시행 — 이름/이메일은 캐시에서, 취급유형은 상품 매핑(supplyTypeBySupplier)에서 라이브로 읽는다.
|
||||
const selectedPartnerRows = selectedPartnerIds.map((id) => {
|
||||
const d = partnerDetails.get(id);
|
||||
return { id, name: d?.name ?? id, email: d?.email ?? '' };
|
||||
});
|
||||
|
||||
const cardRows = cardQ ? (cardSearch.data?.cards ?? []).map(mapCardData) : cards;
|
||||
const cardOptions: ComboOption[] = cardRows
|
||||
.filter((c) => !c.isWildcard || c.status === 'ACTIVE')
|
||||
@ -159,6 +155,12 @@ export function QuotationCreateModal({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// 선택된 카드 표시행 — 캐시에서 번호/유형/카드명을 읽어 검색어와 무관하게 유지한다.
|
||||
const selectedCardRows = selectedCardIds.map((id) => {
|
||||
const d = cardDetails.get(id);
|
||||
return { id, code: d?.code ?? '', title: d?.title ?? id, isWildcard: d?.isWildcard ?? false };
|
||||
});
|
||||
// 상품에 산정 후보가 있는지(인터넷=공통, 매입·판매=재 한정). 없으면 MD가가 유일한 후보 → 필수가 된다.
|
||||
const mdNum = Number(mdPrice) || 0;
|
||||
const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
|
||||
@ -175,7 +177,10 @@ export function QuotationCreateModal({
|
||||
// 경매는 3스텝뿐 — 협상카드 스텝(4)에 있던 상태면 마지막(3)으로 당긴다.
|
||||
if (next === 'auction') setStep((s) => Math.min(s, 3));
|
||||
};
|
||||
const togglePartner = (id: string) =>
|
||||
const togglePartner = (id: string) => {
|
||||
// 담는 순간 이름/이메일을 캐시에 적재 — 이후 검색어가 바뀌어 목록에서 빠져도 선택 테이블이 유지된다.
|
||||
const row = supplierRows.find((r) => r.id === id);
|
||||
if (row) setPartnerDetails((m) => new Map(m).set(id, { name: row.name, email: row.email }));
|
||||
setSelectedPartnerIds((prev) =>
|
||||
oneToOne
|
||||
? prev.includes(id)
|
||||
@ -185,8 +190,12 @@ export function QuotationCreateModal({
|
||||
? prev.filter((p) => p !== id)
|
||||
: [...prev, id],
|
||||
);
|
||||
const toggleCard = (id: string) =>
|
||||
};
|
||||
const toggleCard = (id: string) => {
|
||||
const row = cardRows.find((c) => c.id === id);
|
||||
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]));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (submitting) return;
|
||||
@ -208,7 +217,6 @@ export function QuotationCreateModal({
|
||||
cardIds: oneToOne ? selectedCardIds : [],
|
||||
memo,
|
||||
mdPrice: mdPrice ? Number(mdPrice) : null,
|
||||
supplierType: supplierType ? Number(supplierType) : null,
|
||||
midAction: oneToOne ? midAction : undefined,
|
||||
overAction: oneToOne ? overAction : undefined,
|
||||
});
|
||||
@ -219,7 +227,7 @@ export function QuotationCreateModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
|
||||
{submitting && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl bg-card px-8 py-6 shadow-2xl border border-border">
|
||||
@ -229,10 +237,10 @@ export function QuotationCreateModal({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full max-w-2xl bg-card border border-border rounded-lg shadow-2xl p-6 overflow-hidden animate-scale-up font-mono">
|
||||
<div className="w-full max-w-2xl bg-card border border-border rounded-lg shadow-2xl p-6 flex flex-col max-h-[90vh] overflow-hidden animate-scale-up font-mono">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border">
|
||||
<div className="shrink-0 flex items-center justify-between pb-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<PlusSquare className="text-foreground" size={18} />
|
||||
<Typography variant="small" className="font-bold">신규 견적 등록 (단계 {step}/{totalSteps})</Typography>
|
||||
@ -243,7 +251,7 @@ export function QuotationCreateModal({
|
||||
</div>
|
||||
|
||||
{/* Steps indicator — 스텝 수는 유형에 따라 3(경매)/4(협상) */}
|
||||
<div className="flex items-center justify-between gap-2 py-4 border-b border-border/40 text-muted-foreground">
|
||||
<div className="shrink-0 flex items-center justify-between gap-2 py-4 border-b border-border/40 text-muted-foreground">
|
||||
{steps.map((label, i) => {
|
||||
const n = i + 1;
|
||||
return (
|
||||
@ -257,8 +265,8 @@ export function QuotationCreateModal({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="my-6 min-h-60 text-xs text-foreground space-y-4">
|
||||
{/* Step content — 남은 높이를 채우고 내용이 길면 여기만 스크롤(작은 화면 대응) */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 my-6 pr-1 text-xs text-foreground space-y-4">
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
@ -387,7 +395,7 @@ export function QuotationCreateModal({
|
||||
{step === 2 && (
|
||||
<div className="space-y-3">
|
||||
<Typography as="span" variant="label" className="block">협력사 초청 ({oneToOne ? '단일선택' : '다중선택'})</Typography>
|
||||
{/* 서버검색 다중선택 — 각 행에 선택 상품 취급유형 배지(미매핑=미취급). oneToOne이면 togglePartner가 단일로 강제. */}
|
||||
{/* 서버검색 다중선택 — 각 행에 선택 상품 상품조달유형 배지(미매핑=미정). oneToOne이면 togglePartner가 단일로 강제. */}
|
||||
<Combobox
|
||||
variant="inline"
|
||||
multiple
|
||||
@ -400,27 +408,17 @@ export function QuotationCreateModal({
|
||||
emptyText="협력사가 없습니다"
|
||||
maxListHeight="max-h-56"
|
||||
/>
|
||||
|
||||
{/* 협력사 유형 — 항상 노출(처음부터 입력 가능). 재협상(1:1)이면 선택 협력사의 직전 견적 값으로 자동 디폴트. */}
|
||||
<div className="space-y-1 pt-3 border-t border-border/40">
|
||||
<Typography as="label" variant="label">협력사 유형</Typography>
|
||||
<Select value={supplierType} onValueChange={(v) => setSupplierType(v ?? '')}>
|
||||
<SelectTrigger id="wizard-supplier-type" className="w-full">
|
||||
<SelectValue>
|
||||
{(value) => (value ? supplierTypeLabel(Number(value)) : '협력사 유형 선택...')}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{supplierTypeOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{prevSupplierType != null && (
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
|
||||
이전 견적({prevQtNumber}) 값으로 자동 선택됨 · 수정 가능
|
||||
</Typography>
|
||||
)}
|
||||
{/* 선택 목록 — 검색어가 바뀌어도 담은 협력사가 유지되는 고정 테이블(취급유형은 상품×협력사 매핑). */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="span" variant="label" className="block text-[10px] text-muted-foreground">
|
||||
담긴 협력사 {selectedPartnerRows.length}곳
|
||||
</Typography>
|
||||
<SelectedPartnerTable
|
||||
rows={selectedPartnerRows}
|
||||
supplyTypeBySupplier={supplyTypeBySupplier}
|
||||
showSupplyType={!!productId}
|
||||
onRemove={togglePartner}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -514,13 +512,20 @@ export function QuotationCreateModal({
|
||||
emptyText="협상카드가 없습니다"
|
||||
maxListHeight="max-h-72"
|
||||
/>
|
||||
{/* 선택 목록 — 검색어가 바뀌어도 담은 카드가 유지되는 고정 테이블. */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="span" variant="label" className="block text-[10px] text-muted-foreground">
|
||||
담긴 카드 {selectedCardRows.length}장
|
||||
</Typography>
|
||||
<SelectedCardTable rows={selectedCardRows} onRemove={toggleCard} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer nav */}
|
||||
<div className="flex justify-between items-center pt-4 border-t border-border mt-6">
|
||||
<div className="shrink-0 flex justify-between items-center pt-4 border-t border-border mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@ -560,12 +565,12 @@ export function QuotationCreateModal({
|
||||
|
||||
// ── 헬퍼 컴포넌트 (메인 아래) ──────────────────────────────────────────────
|
||||
|
||||
// 협력사 취급유형 배지 — type undefined = 이 상품 미취급, 그 외 SupplierType 라벨(제조/유통/총판/없음).
|
||||
// 협력사 상품조달유형 배지 — type undefined = 유형 미지정(매핑 없음). 초청 협력사는 그 상품을 공급하므로 '미취급'이 아니라 '미정'.
|
||||
function SupplyTypeBadge({ type }: { type?: number }) {
|
||||
if (type === undefined) {
|
||||
return (
|
||||
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
|
||||
미취급
|
||||
미정
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
@ -576,6 +581,108 @@ function SupplyTypeBadge({ type }: { type?: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 선택된 협력사 테이블 — 콤보로 담은 협력사를 검색어와 무관하게 고정 노출한다. 취급유형 컬럼은 상품 선택 시에만.
|
||||
function SelectedPartnerTable({
|
||||
rows,
|
||||
supplyTypeBySupplier,
|
||||
showSupplyType,
|
||||
onRemove,
|
||||
}: {
|
||||
rows: { id: string; name: string; email: string }[];
|
||||
supplyTypeBySupplier: Map<string, number>;
|
||||
showSupplyType: boolean;
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="rounded border border-dashed border-border px-2 py-3 text-center">
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
|
||||
담긴 협력사가 없습니다 — 위에서 검색해 담아주세요.
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded border border-border overflow-hidden">
|
||||
<table className="w-full table-fixed text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted/40">
|
||||
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">협력사명</Typography></th>
|
||||
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">담당자 이메일</Typography></th>
|
||||
{showSupplyType && <th className="w-24 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">상품조달유형</Typography></th>}
|
||||
<th className="w-9 px-2 py-1.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-t border-border">
|
||||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate font-semibold">{r.name}</Typography></td>
|
||||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate text-muted-foreground">{r.email || '-'}</Typography></td>
|
||||
{showSupplyType && <td className="px-2 py-1.5"><SupplyTypeBadge type={supplyTypeBySupplier.get(r.id)} /></td>}
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
<button type="button" onClick={() => onRemove(r.id)} title="제외" className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-rose-600 cursor-pointer">
|
||||
<X size={13} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 선택된 카드 테이블 — 콤보로 담은 협상/와일드카드를 검색어와 무관하게 고정 노출한다.
|
||||
function SelectedCardTable({
|
||||
rows,
|
||||
onRemove,
|
||||
}: {
|
||||
rows: { id: string; code: string; title: string; isWildcard: boolean }[];
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="rounded border border-dashed border-border px-2 py-3 text-center">
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
|
||||
담긴 카드가 없습니다 — 위에서 검색해 담아주세요.
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded border border-border overflow-hidden">
|
||||
<table className="w-full table-fixed text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted/40">
|
||||
<th className="w-24 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">카드번호</Typography></th>
|
||||
<th className="w-16 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">유형</Typography></th>
|
||||
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">카드명</Typography></th>
|
||||
<th className="w-9 px-2 py-1.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-t border-border">
|
||||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate font-mono text-muted-foreground">{r.code}</Typography></td>
|
||||
<td className="px-2 py-1.5">
|
||||
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${r.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}>
|
||||
{r.isWildcard ? '와일드' : '협상'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate">{r.title}</Typography></td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
<button type="button" onClick={() => onRemove(r.id)} title="제외" className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-rose-600 cursor-pointer">
|
||||
<X size={13} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 세그먼트 컨트롤 — 소수의 명명된 이산 선택(진행 방식·대상)에 라디오보다 명확. 값은 문자열.
|
||||
function Segmented({
|
||||
options,
|
||||
|
||||
@ -38,6 +38,8 @@ type DrawerHeaderCardsProps = {
|
||||
currentProduct: Product | undefined;
|
||||
/** 목표가 클릭 → 산정내역 모달(대표 세션 기준). 목표가/앵커링가는 견적 단위(1견적=1상품)라 세션 공통값. */
|
||||
onShowTarget: (sessionId: string) => void;
|
||||
/** 접힘 상태 — 상세 그리드는 감추고 상단 결과 요약(목표가·낙찰·절감) 밴드만 남긴다. */
|
||||
collapsed?: boolean;
|
||||
};
|
||||
|
||||
export function DrawerHeaderCards({
|
||||
@ -46,7 +48,17 @@ export function DrawerHeaderCards({
|
||||
sessionViews,
|
||||
currentProduct,
|
||||
onShowTarget,
|
||||
collapsed = false,
|
||||
}: DrawerHeaderCardsProps) {
|
||||
// 접으면 결과 요약 밴드만 노출(목표가·낙찰·절감). 상세 그리드 계산은 건너뛴다.
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="text-xs font-mono">
|
||||
<ResultSummaryBand quotation={quotation} sessionViews={sessionViews} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Quotations DDL 표시값
|
||||
const q_name = quotation.name || '-';
|
||||
const q_number = quotation.number || '-';
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { X, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { type Partner, sessionStatusLabel } from '../../types';
|
||||
@ -20,6 +21,7 @@ type RegenerateModalProps = {
|
||||
// 마감된 견적의 '다음 라운드'를 만들 때 부를 공급사를 고르는 모달.
|
||||
// 상품·견적번호·협상기간·카드는 원 견적에서 이어받으므로 여기선 공급사만 선택한다.
|
||||
export function RegenerateModal({ open, partners, sessionStatusBySupplier, defaultSupplierIds, onConfirm, onClose }: RegenerateModalProps) {
|
||||
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
||||
const [selected, setSelected] = useState<string[]>(defaultSupplierIds);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
if (!open) return null;
|
||||
@ -42,8 +44,8 @@ export function RegenerateModal({ open, partners, sessionStatusBySupplier, defau
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[55] flex items-center justify-center bg-black/40 backdrop-blur-xs">
|
||||
<div className="w-full max-w-xl bg-card border border-border rounded-lg shadow-2xl p-6 animate-scale-up font-mono">
|
||||
<div className="fixed inset-0 z-[55] flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
|
||||
<div className="w-full max-w-xl bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { MessageSquare, Copy, Mail, MailCheck, Send } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { MessageSquare, Copy, Mail, MailCheck, Send, Trophy } from 'lucide-react';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { confirm } from '@/lib/confirm';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Typography, typographyVariants } from '@/components/ui/typography';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||
import { SessionStatus } from '@/api/generated/model';
|
||||
import { StatusPill, sessionStatusTone } from './StatusPill';
|
||||
import { mapServerSessionView, sessionStatusLabel } from '../../types';
|
||||
|
||||
@ -11,23 +15,63 @@ type SessionView = ReturnType<typeof mapServerSessionView>;
|
||||
export function SessionsStatusTab({
|
||||
sessionViews,
|
||||
canNotify,
|
||||
canAward,
|
||||
winnerSupplierId,
|
||||
onOpenChat,
|
||||
onNotifyAll,
|
||||
onNotifyOne,
|
||||
onAward,
|
||||
}: {
|
||||
sessionViews: SessionView[];
|
||||
/** 초청 메일 발송 권한(견적 소유자만). false 면 발송 버튼 비활성. */
|
||||
canNotify: boolean;
|
||||
/** 직접 낙찰 권한(개찰 상태 + 본인 견적). true 일 때만 낙찰 선택 UI 노출. */
|
||||
canAward: boolean;
|
||||
/** 낙찰 확정된 협력사 id(quotations.preferred_sp_id). 그 행만 초록 배경으로 강조. */
|
||||
winnerSupplierId: string | null;
|
||||
onOpenChat: (sessionId: string) => void;
|
||||
/** 미발송 세션 전체에 초청 메일 발송. */
|
||||
onNotifyAll: () => Promise<void>;
|
||||
/** 한 세션(공급사)에 초청 메일 발송/재발송. */
|
||||
onNotifyOne: (sessionId: string) => Promise<void>;
|
||||
/** 고른 협력사를 낙찰 처리. 성공 시 true. */
|
||||
onAward: (supplierId: string, supplierName: string) => Promise<boolean>;
|
||||
}) {
|
||||
const [sendingAll, setSendingAll] = useState(false);
|
||||
const [sendingId, setSendingId] = useState<string | null>(null);
|
||||
const [selectedWinnerId, setSelectedWinnerId] = useState<string | null>(null);
|
||||
const [awarding, setAwarding] = useState(false);
|
||||
const unsentCount = sessionViews.filter((s) => !s.email_sent_at).length;
|
||||
|
||||
// 낙찰 후보 = 투찰한 협상완료(DONE) 협력사. 개찰 견적에서 이 중 하나를 담당자가 직접 낙찰한다.
|
||||
const candidates = sessionViews.filter((s) => s.status === SessionStatus.DONE && s.bid_price != null);
|
||||
const showAward = canAward && candidates.length > 0;
|
||||
// 최저 투찰가 = 자동낙찰과 같은 기준 → 추천 표시(담당자가 동가/사정상 다른 곳을 골라도 됨).
|
||||
const lowestBid = candidates.length ? Math.min(...candidates.map((s) => s.bid_price as number)) : null;
|
||||
const selectedWinner = candidates.find((s) => s.supplier_id === selectedWinnerId) ?? null;
|
||||
const colCount = showAward ? 13 : 12;
|
||||
|
||||
const handleAward = async () => {
|
||||
if (!selectedWinner) return;
|
||||
const name = selectedWinner.supplier_name;
|
||||
const price = selectedWinner.bid_price != null ? `₩${selectedWinner.bid_price.toLocaleString()}` : '-';
|
||||
if (
|
||||
!(await confirm({
|
||||
title: '직접 낙찰',
|
||||
description: `[${name}] (투찰가 ${price})을(를) 낙찰 처리하시겠습니까? 낙찰은 되돌릴 수 없습니다.`,
|
||||
confirmText: '낙찰 확정',
|
||||
}))
|
||||
)
|
||||
return;
|
||||
setAwarding(true);
|
||||
try {
|
||||
const ok = await onAward(selectedWinner.supplier_id, name);
|
||||
if (ok) setSelectedWinnerId(null);
|
||||
} finally {
|
||||
setAwarding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAll = async () => {
|
||||
if (
|
||||
!(await confirm({
|
||||
@ -88,10 +132,30 @@ export function SessionsStatusTab({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 직접 낙찰 툴바 — 개찰(낙찰자 미정) 견적에서만. 표에서 협력사 하나 선택 → 낙찰 확정. */}
|
||||
{showAward && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-warning/40 bg-warning/10 px-4 py-3">
|
||||
<Typography as="p" variant="small" className="text-[12px] text-warning">
|
||||
개찰 · 낙찰자 미정 — 협력사를 선택해 직접 낙찰하세요.
|
||||
{selectedWinner && <span className="ml-1 font-bold">선택: {selectedWinner.supplier_name}</span>}
|
||||
</Typography>
|
||||
<button
|
||||
onClick={handleAward}
|
||||
disabled={!selectedWinner || awarding}
|
||||
title={selectedWinner ? '선택한 협력사를 낙찰 처리합니다.' : '먼저 낙찰할 협력사를 선택하세요.'}
|
||||
className="flex shrink-0 items-center gap-2 px-3 py-2 bg-success text-white text-xs font-bold rounded hover:bg-success/90 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trophy size={14} />
|
||||
<span>{awarding ? '처리 중…' : '낙찰 확정'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-border rounded-lg bg-card overflow-x-auto">
|
||||
<Table className="w-full text-left text-xs border-collapse font-mono min-w-[1250px]">
|
||||
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
|
||||
<TableRow>
|
||||
{showAward && <TableHead className="p-3 font-semibold text-center font-sans w-14">낙찰선택</TableHead>}
|
||||
<TableHead className="p-3 font-semibold font-sans">협력사</TableHead>
|
||||
<TableHead className="p-2 font-semibold text-center w-10">URL</TableHead>
|
||||
<TableHead className="p-3 font-semibold text-center font-sans">초청메일</TableHead>
|
||||
@ -109,20 +173,57 @@ export function SessionsStatusTab({
|
||||
<TableBody className="divide-y divide-border">
|
||||
{sessionViews.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={12} className="p-12 text-center text-muted-foreground">
|
||||
<TableCell colSpan={colCount} className="p-12 text-center text-muted-foreground">
|
||||
참여 중인 협상 세션이 없습니다. (리스트가 비어 있습니다)
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{sessionViews.map((sess) => (
|
||||
<TableRow key={sess.session_id} className="hover:bg-muted/30 transition-colors text-[11px]">
|
||||
{sessionViews.map((sess) => {
|
||||
const isCandidate = sess.status === SessionStatus.DONE && sess.bid_price != null;
|
||||
const isRecommended = isCandidate && sess.bid_price === lowestBid;
|
||||
const isWinner = !!winnerSupplierId && sess.supplier_id === winnerSupplierId;
|
||||
return (
|
||||
<TableRow
|
||||
key={sess.session_id}
|
||||
className={`transition-colors text-[11px] ${
|
||||
isWinner ? 'bg-success/10 hover:bg-success/15' : 'hover:bg-muted/30'
|
||||
}`}
|
||||
>
|
||||
{showAward && (
|
||||
<TableCell className="p-3 text-center w-14">
|
||||
{isCandidate ? (
|
||||
<label className="flex flex-col items-center gap-1 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="award-winner"
|
||||
checked={selectedWinnerId === sess.supplier_id}
|
||||
onChange={() => setSelectedWinnerId(sess.supplier_id)}
|
||||
className="h-4 w-4 accent-success cursor-pointer"
|
||||
/>
|
||||
{isRecommended && (
|
||||
<Typography as="span" variant="caption" className="text-[9px] font-bold text-success">
|
||||
추천
|
||||
</Typography>
|
||||
)}
|
||||
</label>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="p-3 font-bold text-foreground font-sans">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{sess.supplier_name}</span>
|
||||
<Link
|
||||
to={`/partners?detail=${sess.supplier_id}`}
|
||||
title={`${sess.supplier_name} — 협력사 상세로 이동`}
|
||||
className={cn(typographyVariants({ variant: 'link' }), 'font-bold font-sans truncate')}
|
||||
>
|
||||
{sess.supplier_name}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => onOpenChat(sess.session_id)}
|
||||
title="협상 대화방으로 이동"
|
||||
className="p-1 hover:bg-primary/10 rounded text-primary hover:text-primary/80 transition-colors cursor-pointer"
|
||||
className="p-1 hover:bg-primary/10 rounded text-primary hover:text-primary/80 transition-colors cursor-pointer shrink-0"
|
||||
>
|
||||
<MessageSquare size={13} />
|
||||
</button>
|
||||
@ -148,7 +249,7 @@ export function SessionsStatusTab({
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{sess.email_sent_at ? (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600 text-[10px] font-semibold">
|
||||
<span className="inline-flex items-center gap-1 text-success text-[10px] font-semibold">
|
||||
<MailCheck size={11} /> 발송됨
|
||||
</span>
|
||||
) : (
|
||||
@ -205,7 +306,8 @@ export function SessionsStatusTab({
|
||||
</TableCell>
|
||||
<TableCell className="p-3 text-muted-foreground font-sans">{sess.reject_delivery_type || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { X, Check } from 'lucide-react';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { useGetTargetBreakdown } from '@/api/generated/quotation/quotation';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
|
||||
// 세션 목표가 산정내역 모달. 후보·채택·앵커링가는 백엔드 /target-breakdown 이 산정한 값을 '표시만' 한다.
|
||||
// (프론트 재계산 없음 → 저장된 목표가와 항상 일치. 산정 로직은 백엔드 _candidates 단일 출처.)
|
||||
@ -33,16 +34,17 @@ export function TargetPriceModal({
|
||||
category,
|
||||
supplierTypeLabel,
|
||||
}: TargetPriceModalProps) {
|
||||
useScrollLock(); // 모달은 열릴 때만 마운트(부모 게이트) → 배경 스크롤 잠금
|
||||
const { data: bd, isLoading } = useGetTargetBreakdown(sessionId, { query: { enabled: !!sessionId } });
|
||||
const candidates = bd?.candidates ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-xs"
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md bg-card border border-border rounded-lg shadow-2xl p-6 font-mono text-xs animate-scale-up"
|
||||
className="w-full max-w-md bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto font-mono text-xs animate-scale-up"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
mapSetting,
|
||||
mapServerSessionView,
|
||||
mapServerCardView,
|
||||
chainRoundState,
|
||||
} from '../../types';
|
||||
import { supplierTypeLabel } from '@/lib/enumLabels';
|
||||
import { QuotationStatus } from '@/api/generated/model';
|
||||
@ -35,6 +36,8 @@ type DrawerTab = 'status' | 'cards' | 'chat';
|
||||
type QuotationDetailSheetProps = {
|
||||
quotation: QuotationData;
|
||||
onCloseQuotation: (id: string, name: string) => void;
|
||||
/** 개찰(낙찰자 미정 마감) 견적을 협상현황 표에서 직접 낙찰. 성공 시 true. */
|
||||
onAward: (qtId: string, winnerSupplierId: string, winnerName: string) => Promise<boolean>;
|
||||
/** 라운드 타임라인에서 다른 차수로 전환(같은 견적번호의 다른 견적 상세 열기). */
|
||||
onSwitchRound: (qtId: string) => void;
|
||||
/** 마감된 견적의 다음 라운드를 수동 생성(공급사 선택). 성공 시 새 qt_id 반환. */
|
||||
@ -49,6 +52,7 @@ type QuotationDetailSheetProps = {
|
||||
export function QuotationDetailSheet({
|
||||
quotation,
|
||||
onCloseQuotation,
|
||||
onAward,
|
||||
onSwitchRound,
|
||||
onRegenerate,
|
||||
onNotify,
|
||||
@ -70,9 +74,14 @@ export function QuotationDetailSheet({
|
||||
).map(mapSetting);
|
||||
|
||||
const qtId = quotation.qt_id ?? '';
|
||||
// 초청 메일 발송은 견적 소유자만. (백엔드 스코프 도입 전까지의 1차 차단 — 본인 견적 아니면 버튼 비활성)
|
||||
// 소유자 게이팅 — 견적을 바꾸는 액션(초청메일·마감·재생성·낙찰)은 '본인 견적' 또는 최고관리자만.
|
||||
// 프론트 1차 차단이며, 실제 보안은 백엔드가 동일 스코프로 강제해야 함(버튼 숨김만으론 우회 가능).
|
||||
const myUserId = useAuthStore((s) => s.user?.userId);
|
||||
const canNotify = !!myUserId && quotation.user_id === myUserId;
|
||||
const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자');
|
||||
const canManage = !!myUserId && (quotation.user_id === myUserId || isSuperAdmin);
|
||||
const canNotify = canManage; // 초청 메일 발송/재발송
|
||||
// 직접 낙찰 = 개찰(낙찰자 미정 마감) 견적에서만. 후보(투찰한 협상완료 협력사) 유무는 표에서 판정.
|
||||
const canAward = canManage && chainRoundState(quotation) === 'opened';
|
||||
// 협상 세션·사용 카드는 견적 단위, 채팅은 선택 세션 단위로 서버에서 읽는다.
|
||||
// 세션은 협상 진행으로 계속 바뀌므로 탭 복귀 시 재조회한다. 카드는 생성 후 불변이라 끄둔다.
|
||||
const sessionsQuery = useGetQuotationSessions(qtId, {
|
||||
@ -93,7 +102,7 @@ export function QuotationDetailSheet({
|
||||
const { rounds: chainRounds, isLoading: chainLoading } = useQuotationChain(quotation.number);
|
||||
const maxRound = chainRounds.length ? Math.max(...chainRounds.map((r) => r.round)) : (quotation.round ?? 1);
|
||||
const isLatestRound = (quotation.round ?? 1) >= maxRound;
|
||||
const canRegenerate = !chainLoading && quotation.status === QuotationStatus.CLOSED && isLatestRound;
|
||||
const canRegenerate = canManage && !chainLoading && quotation.status === QuotationStatus.CLOSED && isLatestRound;
|
||||
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
|
||||
const effectiveSessionId = selectedSessionId ?? serverSessions[0]?.session_id ?? null;
|
||||
@ -139,8 +148,8 @@ export function QuotationDetailSheet({
|
||||
|
||||
<div className="w-full max-w-5xl bg-card border-l border-border h-full flex flex-col shadow-2xl overflow-hidden animate-slide-left">
|
||||
|
||||
{/* Header title bar (고정) */}
|
||||
<div className={`shrink-0 px-6 pt-6 bg-muted/30 ${showHeaderCards ? 'pb-3' : 'pb-6 border-b border-border'}`}>
|
||||
{/* Header title bar (고정) — 아래에 상세 그리드(펼침) 또는 결과 요약 밴드(접힘)가 항상 붙는다. */}
|
||||
<div className="shrink-0 px-6 pt-6 pb-3 bg-muted/30">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-[10px] font-mono tracking-widest uppercase">
|
||||
@ -169,14 +178,21 @@ export function QuotationDetailSheet({
|
||||
<span>다음 견적 재생성</span>
|
||||
</button>
|
||||
)}
|
||||
{/* 마감 버튼은 항상 노출하되, 마감 가능한 상태(생성·진행중·보류)가 아니면 비활성화만 한다. */}
|
||||
{/* 마감 버튼은 항상 노출하되, 본인/최고관리자가 아니거나 마감 가능한 상태가 아니면 비활성화한다. */}
|
||||
{(() => {
|
||||
const canClose = quotation.status !== QuotationStatus.CLOSED;
|
||||
const alreadyClosed = quotation.status === QuotationStatus.CLOSED;
|
||||
const canClose = canManage && !alreadyClosed;
|
||||
return (
|
||||
<button
|
||||
onClick={() => onCloseQuotation(quotation.qt_id ?? '', q_name)}
|
||||
disabled={!canClose}
|
||||
title={canClose ? undefined : '이미 마감된 견적입니다.'}
|
||||
title={
|
||||
!canManage
|
||||
? '본인이 생성한 견적만 마감할 수 있습니다.'
|
||||
: alreadyClosed
|
||||
? '이미 마감된 견적입니다.'
|
||||
: undefined
|
||||
}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-red-600 hover:bg-rose-700 text-white rounded text-xs font-semibold cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-red-600"
|
||||
>
|
||||
<CheckCircle2 size={14} />
|
||||
@ -194,8 +210,8 @@ export function QuotationDetailSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 견적 상세 정보 — 탭과 flex 비율(헤더:탭 = 2:1)로 높이를 나눠 가지고 자체 스크롤 */}
|
||||
{showHeaderCards && (
|
||||
{/* 견적 상세 정보 — 펼치면 탭과 flex 비율(헤더:탭 = 2:1)로 높이를 나눠 가지고 자체 스크롤 */}
|
||||
{showHeaderCards ? (
|
||||
<div
|
||||
style={{ flex: '2 1 0%' }}
|
||||
className="min-h-0 overflow-y-auto px-6 pb-6 bg-muted/30 border-b border-border"
|
||||
@ -208,6 +224,18 @@ export function QuotationDetailSheet({
|
||||
onShowTarget={setTargetSessionId}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* 접어도 결과 요약(목표가·낙찰·절감) 밴드는 상단에 그대로 남긴다. */
|
||||
<div className="shrink-0 px-6 pt-4 pb-4 bg-muted/30 border-b border-border">
|
||||
<DrawerHeaderCards
|
||||
collapsed
|
||||
quotation={quotation}
|
||||
quotationSettings={quotationSettings}
|
||||
sessionViews={sessionViews}
|
||||
currentProduct={currentProduct}
|
||||
onShowTarget={setTargetSessionId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
@ -248,9 +276,12 @@ export function QuotationDetailSheet({
|
||||
<SessionsStatusTab
|
||||
sessionViews={sessionViews}
|
||||
canNotify={canNotify}
|
||||
canAward={canAward}
|
||||
winnerSupplierId={quotation.preferred_sp_id ?? null}
|
||||
onOpenChat={goToChat}
|
||||
onNotifyAll={() => onNotify(qtId)}
|
||||
onNotifyOne={(sessionId) => onNotifySession(sessionId, qtId)}
|
||||
onAward={(supplierId, supplierName) => onAward(qtId, supplierId, supplierName)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Settings, X } from 'lucide-react';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@ -22,6 +23,7 @@ export function QuotationSettingsModal({
|
||||
onDelete,
|
||||
onClose,
|
||||
}: QuotationSettingsModalProps) {
|
||||
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
||||
const [targetMargin, setTargetMargin] = useState('');
|
||||
const [cardUseCount, setCardUseCount] = useState('');
|
||||
|
||||
@ -38,8 +40,8 @@ export function QuotationSettingsModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs">
|
||||
<div className="w-full max-w-2xl bg-card border border-border rounded-lg shadow-2xl p-6 overflow-hidden animate-scale-up font-mono text-xs">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
|
||||
<div className="w-full max-w-2xl bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono text-xs">
|
||||
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
useListQuotations,
|
||||
useCreateQuotation,
|
||||
useStopQuotation,
|
||||
useAwardQuotation,
|
||||
useRegenerateQuotation,
|
||||
useNotifyQuotation,
|
||||
useNotifySession,
|
||||
@ -39,7 +40,6 @@ export type CreateQuotationInput = {
|
||||
cardIds: string[];
|
||||
memo: string;
|
||||
mdPrice?: number | null; // MD 제시가(원). 비우면 미전송 → 서버가 기존 마진식으로 목표가 산정
|
||||
supplierType?: number | null; // 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록
|
||||
// 낙찰 기준 — 1:1 협상만 전송(경매는 미전송 → 서버가 mid=over=AWARD 강제). 2전략을 mid/over 로 전개해 담는다(over 항상 OPEN).
|
||||
midAction?: number; // PriceGateAction (앵커~목표가 처리: 낙찰/개찰)
|
||||
overAction?: number; // PriceGateAction (목표가 초과 처리: 협상은 항상 개찰)
|
||||
@ -65,6 +65,7 @@ export function useQuotations(params: ListQuotationsParams) {
|
||||
const deleteSettingMutation = useDeleteSetting();
|
||||
const createQuotationMutation = useCreateQuotation();
|
||||
const stopQuotationMutation = useStopQuotation();
|
||||
const awardQuotationMutation = useAwardQuotation();
|
||||
const regenerateQuotationMutation = useRegenerateQuotation();
|
||||
const notifyQuotationMutation = useNotifyQuotation();
|
||||
const notifySessionMutation = useNotifySession();
|
||||
@ -116,6 +117,29 @@ export function useQuotations(params: ListQuotationsParams) {
|
||||
);
|
||||
};
|
||||
|
||||
// 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리 → 서버 award_quotation(close_reason→낙찰 + 낙찰자 박제 + 작성자 알림).
|
||||
// 성공 시 단건 견적·세션·알림 목록 재조회로 결과밴드('개찰'→'낙찰')와 알림함을 동기화.
|
||||
const awardQuotation = async (qtId: string, winnerSupplierId: string, winnerName: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await awardQuotationMutation.mutateAsync({ qtId, data: { winner_supplier_id: winnerSupplierId } });
|
||||
if (!res?.result?.success) {
|
||||
const reason = res?.msg ?? res?.result?.desc ?? '서버 오류';
|
||||
const code = res?.result?.code;
|
||||
showToast(`직접 낙찰 실패${code ? ` [${code}]` : ''}: ${reason}`, 'error');
|
||||
return false;
|
||||
}
|
||||
invalidateQuotations();
|
||||
queryClient.invalidateQueries({ queryKey: getGetQuotationQueryKey(qtId) });
|
||||
queryClient.invalidateQueries({ queryKey: getGetQuotationSessionsQueryKey(qtId) });
|
||||
queryClient.invalidateQueries({ queryKey: ['/v1/notification/list'] });
|
||||
showToast(`[${winnerName}] 협력사를 낙찰 처리했습니다.`, 'success');
|
||||
return true;
|
||||
} catch {
|
||||
showToast('직접 낙찰에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'error');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const invalidateSettings = () =>
|
||||
queryClient.invalidateQueries({ queryKey: getListSettingsQueryKey() });
|
||||
|
||||
@ -194,7 +218,6 @@ export function useQuotations(params: ListQuotationsParams) {
|
||||
manager_contact_number: me?.contact || undefined,
|
||||
memo: input.memo.trim() || undefined,
|
||||
md_price: input.mdPrice && input.mdPrice > 0 ? input.mdPrice : undefined,
|
||||
supplier_type: input.supplierType ?? undefined,
|
||||
// 낙찰 기준은 1:1 협상만 전송(모달이 미리 걸러 담음) — 경매면 미전송 → 서버가 AWARD 강제.
|
||||
mid_action: input.midAction ?? undefined,
|
||||
over_action: input.overAction ?? undefined,
|
||||
@ -295,6 +318,7 @@ export function useQuotations(params: ListQuotationsParams) {
|
||||
total,
|
||||
quotationSettings,
|
||||
closeQuotation,
|
||||
awardQuotation,
|
||||
addSetting,
|
||||
deleteSetting,
|
||||
createQuotation,
|
||||
|
||||
34
negodata/front/src/lib/phone.ts
Normal file
34
negodata/front/src/lib/phone.ts
Normal file
@ -0,0 +1,34 @@
|
||||
// 국내 전화번호 표시/저장 헬퍼.
|
||||
// - 저장(폼 상태·DB)은 숫자만(normalizePhone): 검색·중복체크·정합성 위해 정규화.
|
||||
// - 화면 표시는 앞자리 규칙대로 하이픈 삽입(formatPhoneKR).
|
||||
|
||||
// 숫자만 뽑아 최대 11자리로 자른다. 폼 값·DB 저장은 이 결과를 쓴다.
|
||||
export function normalizePhone(value: string): string {
|
||||
return value.replace(/\D/g, '').slice(0, 11);
|
||||
}
|
||||
|
||||
// 앞자리로 국번 자리수를 분기해 하이픈을 넣는다. 입력이 하이픈 포함이어도(레거시) 먼저 정규화.
|
||||
export function formatPhoneKR(value: string): string {
|
||||
const d = normalizePhone(value);
|
||||
if (!d) return '';
|
||||
|
||||
// 대표번호 15xx/16xx/18xx: 4-4 (8자리)
|
||||
if (d.startsWith('1')) {
|
||||
if (d.length <= 4) return d;
|
||||
return `${d.slice(0, 4)}-${d.slice(4, 8)}`;
|
||||
}
|
||||
|
||||
// 서울 02: 2-3-4(9자리) 또는 2-4-4(10자리)
|
||||
if (d.startsWith('02')) {
|
||||
if (d.length <= 2) return d;
|
||||
if (d.length <= 5) return `${d.slice(0, 2)}-${d.slice(2)}`;
|
||||
if (d.length <= 9) return `${d.slice(0, 2)}-${d.slice(2, 5)}-${d.slice(5)}`;
|
||||
return `${d.slice(0, 2)}-${d.slice(2, 6)}-${d.slice(6, 10)}`;
|
||||
}
|
||||
|
||||
// 휴대폰 010·지역번호 031 등 3자리 국번: 3-3-4(10자리) 또는 3-4-4(11자리)
|
||||
if (d.length <= 3) return d;
|
||||
if (d.length <= 7) return `${d.slice(0, 3)}-${d.slice(3)}`;
|
||||
if (d.length <= 10) return `${d.slice(0, 3)}-${d.slice(3, 6)}-${d.slice(6)}`;
|
||||
return `${d.slice(0, 3)}-${d.slice(3, 7)}-${d.slice(7, 11)}`;
|
||||
}
|
||||
@ -1,9 +1,11 @@
|
||||
import { Plus, BookOpen } from 'lucide-react';
|
||||
import { Plus, BookOpen, Upload, Download, FileSpreadsheet, ChevronDown } from 'lucide-react';
|
||||
import { useOverlayRouter } from '@/lib/useOverlayRouter';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { confirm } from '@/lib/confirm';
|
||||
import { PageContainer } from '@/components/layout/PageContainer';
|
||||
import { SearchInput } from '@/components/layout/PageToolbar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||
import { TablePagination } from '@/components/ui/table-pagination';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { useServerList } from '@/lib/useServerList';
|
||||
@ -11,6 +13,7 @@ import { useCards } from '@/features/cards/hooks/useCards';
|
||||
import { useGetCard } from '@/api/generated/card/card';
|
||||
import { CardTable } from '@/features/cards/components/CardTable';
|
||||
import { CardFormSheet } from '@/features/cards/components/CardFormSheet';
|
||||
import { CardExcelUploadModal, downloadCardTemplate } from '@/features/cards/components/CardExcelUploadModal';
|
||||
import { mapCardData, type CardTab, type NegotiationCard } from '@/features/cards/types';
|
||||
import type { ListCardsParams } from '@/api/generated/model/listCardsParams';
|
||||
|
||||
@ -24,13 +27,14 @@ export default function CardsPage() {
|
||||
page: list.page,
|
||||
size: list.pageSize,
|
||||
};
|
||||
const { cards, total, totalNego, totalWild, createCard, updateCard, deleteCard } = useCards(params);
|
||||
const { cards, total, totalNego, totalWild, createCard, updateCard, deleteCard, bulkCreate } = useCards(params);
|
||||
const totalPages = list.totalPages(total);
|
||||
|
||||
// 오버레이(폼)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
|
||||
// 오버레이(폼/엑셀)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
|
||||
// ?detail=<id> 직접 접근 시 단건 API 로 받아 수정 폼을 연다(현재 페이지에 없어도 동작).
|
||||
const overlay = useOverlayRouter(['new', 'detail']);
|
||||
const overlay = useOverlayRouter(['new', 'detail', 'modal']);
|
||||
const editId = overlay.get('detail');
|
||||
const modal = overlay.get('modal'); // 'excel' | null
|
||||
const editQuery = useGetCard(editId ?? '', { query: { enabled: !!editId } });
|
||||
const editing: NegotiationCard | null = editQuery.data?.card ? mapCardData(editQuery.data.card) : null;
|
||||
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
|
||||
@ -65,19 +69,39 @@ export default function CardsPage() {
|
||||
<div>
|
||||
<Typography variant="h3" className="text-xs">협상카드 및 와일드카드 관리</Typography>
|
||||
<Typography variant="muted" className="text-[10px] mt-0.5">
|
||||
본인이 등록한 카드만 조회·관리할 수 있습니다.
|
||||
본인 카드와 전체(공용) 카드를 함께 조회·관리합니다. 전체 카드는 모든 사용자에게 공유됩니다.
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="card-register-btn"
|
||||
onClick={openCreate}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground text-xs font-bold rounded hover:opacity-95 cursor-pointer transition-colors whitespace-nowrap"
|
||||
>
|
||||
<Plus size={15} />
|
||||
<span>신규 카드 등록</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="outline" />}>
|
||||
<FileSpreadsheet />
|
||||
엑셀업로드
|
||||
<ChevronDown />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => overlay.open('modal', 'excel')}>
|
||||
<Upload />
|
||||
일괄 업로드
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={downloadCardTemplate}>
|
||||
<Download />
|
||||
양식 다운로드
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<button
|
||||
id="card-register-btn"
|
||||
onClick={openCreate}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground text-xs font-bold rounded hover:opacity-95 cursor-pointer transition-colors whitespace-nowrap"
|
||||
>
|
||||
<Plus size={15} />
|
||||
<span>신규 카드 등록</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Primary Navigation Tab */}
|
||||
@ -137,6 +161,14 @@ export default function CardsPage() {
|
||||
onClose={overlay.close}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modal === 'excel' && (
|
||||
<CardExcelUploadModal
|
||||
open
|
||||
onConfirm={bulkCreate}
|
||||
onClose={overlay.close}
|
||||
/>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -164,8 +164,9 @@ function render(n: NotificationData): { icon: ReactNode; tone: string; event: st
|
||||
case NotificationType.CREATED:
|
||||
return { icon: <FilePlus2 size={18} />, tone: 'text-sky-600', event: '견적 생성', line: name, number };
|
||||
case NotificationType.SUCCESS:
|
||||
// 자동 낙찰과 담당자 직접 낙찰(data.manual)은 같은 SUCCESS — 문구로만 '직접'을 구분한다.
|
||||
return {
|
||||
icon: <Trophy size={18} />, tone: 'text-emerald-600', event: '견적 낙찰',
|
||||
icon: <Trophy size={18} />, tone: 'text-emerald-600', event: d.manual ? '견적 낙찰 · 직접' : '견적 낙찰',
|
||||
line: `${name} — ${d.winner_name ?? '-'} ${Number(d.winner_price ?? 0).toLocaleString()}원`,
|
||||
number,
|
||||
};
|
||||
|
||||
@ -40,6 +40,7 @@ export default function QuotationPage() {
|
||||
total,
|
||||
quotationSettings,
|
||||
closeQuotation,
|
||||
awardQuotation,
|
||||
addSetting,
|
||||
deleteSetting,
|
||||
createQuotation,
|
||||
@ -178,6 +179,7 @@ export default function QuotationPage() {
|
||||
key={activeQuotation.qt_id}
|
||||
quotation={activeQuotation}
|
||||
onCloseQuotation={closeQuotation}
|
||||
onAward={awardQuotation}
|
||||
onSwitchRound={(qtId) => overlay.open('detail', qtId, { replace: true })}
|
||||
onRegenerate={regenerateQuotation}
|
||||
onNotify={notifyQuotation}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user