[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:
Mina Choi 2026-07-07 16:18:33 +09:00
parent c32ee300e6
commit c62b66e35b
56 changed files with 1900 additions and 188 deletions

View 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

View File

@ -5,6 +5,7 @@ from sqlalchemy import select, func, and_, or_, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import users
from common.enums import ErrorType from common.enums import ErrorType
from common.logger import LOG from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
@ -32,13 +33,21 @@ class ICardCRUD(ABC):
async def soft_delete(self, cdb: AsyncSession, model, pk_col, card_id) -> ErrorType: async def soft_delete(self, cdb: AsyncSession, model, pk_col, card_id) -> ErrorType:
pass pass
@abstractmethod
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
pass
class CardCRUD(ICardCRUD): class CardCRUD(ICardCRUD):
async def search( async def search(
self, cdb: AsyncSession, model, user_id, search: Optional[str], skip: int, limit: int self, cdb: AsyncSession, model, user_id, search: Optional[str], skip: int, limit: int
) -> Tuple[ErrorType, list, int]: ) -> Tuple[ErrorType, list, int]:
try: 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: if search:
conditions.append( conditions.append(
or_( or_(
@ -102,3 +111,18 @@ class CardCRUD(ICardCRUD):
except Exception as ex: except Exception as ex:
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED return ErrorType.DB_RUN_FAILED
async def 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, {}

View File

@ -5,7 +5,7 @@ from sqlalchemy import select, func, and_, or_, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import items from common.database.model.models import items, users
from common.enums import ErrorType from common.enums import ErrorType
from common.logger import LOG from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
@ -45,6 +45,10 @@ class IItemCRUD(ABC):
async def soft_delete(self, cdb: AsyncSession, item_id) -> ErrorType: async def soft_delete(self, cdb: AsyncSession, item_id) -> ErrorType:
pass pass
@abstractmethod
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
pass
class ItemCRUD(IItemCRUD): class ItemCRUD(IItemCRUD):
async def search( async def search(
@ -166,3 +170,17 @@ class ItemCRUD(IItemCRUD):
except Exception as ex: except Exception as ex:
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED return ErrorType.DB_RUN_FAILED
async def 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, {}

View File

@ -10,7 +10,7 @@ from common.database.model.models import (
quotations, sessions, chats, nego_cards, wild_cards, items, suppliers, quotation_settings, quotations, sessions, chats, nego_cards, wild_cards, items, suppliers, quotation_settings,
version_nego_cards, version_wild_cards, users, 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.logger import LOG
from common.utils.gtime import GTime 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]: async def claim_for_close(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, int]:
pass pass
@abstractmethod
async def claim_for_award(self, cdb: AsyncSession, qt_id, supplier_id, supplier_name) -> Tuple[ErrorType, int]:
pass
class QuotationCRUD(IQuotationCRUD): class QuotationCRUD(IQuotationCRUD):
async def search( async def search(
@ -474,6 +478,36 @@ class QuotationCRUD(IQuotationCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0 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: async def update_sessions_status(self, cdb: AsyncSession, qt_id, from_statuses: list[int], to_status: int) -> ErrorType:
# 견적에 딸린 세션 중 from_statuses 에 속한 것만 to_status 로 일괄 전이(삭제 제외). 다른 상태는 건드리지 않는다. # 견적에 딸린 세션 중 from_statuses 에 속한 것만 to_status 로 일괄 전이(삭제 제외). 다른 상태는 건드리지 않는다.
try: try:

View File

@ -5,7 +5,7 @@ from sqlalchemy import select, func, and_, or_, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import suppliers from common.database.model.models import suppliers, users
from common.enums import ErrorType from common.enums import ErrorType
from common.logger import LOG from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
@ -41,6 +41,10 @@ class ISupplierCRUD(ABC):
async def soft_delete(self, cdb: AsyncSession, supplier_id) -> ErrorType: async def soft_delete(self, cdb: AsyncSession, supplier_id) -> ErrorType:
pass pass
@abstractmethod
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
pass
class SupplierCRUD(ISupplierCRUD): class SupplierCRUD(ISupplierCRUD):
async def search( async def search(
@ -144,3 +148,17 @@ class SupplierCRUD(ISupplierCRUD):
except Exception as ex: except Exception as ex:
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED return ErrorType.DB_RUN_FAILED
async def 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, {}

View File

@ -14,6 +14,7 @@ class CardProtocol(WebPacketProtocol):
class Req_CreateCard(CardProtocol): class Req_CreateCard(CardProtocol):
is_wildcard: bool = False is_wildcard: bool = False
is_shared: bool = False # True=전체(공용, user_id NULL 저장) / False=개인(등록 유저 소유)
name: Optional[str] = None name: Optional[str] = None
number: Optional[str] = None number: Optional[str] = None
script: 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) nego_card_id: uuid.UUID # 통합 식별자(일반=nego_card_id / 와일드=wild_card_id)
user_id: Optional[uuid.UUID] = None user_id: Optional[uuid.UUID] = None
is_wildcard: bool = False 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 name: Optional[str] = None
number: Optional[str] = None number: Optional[str] = None
script: Optional[str] = None script: Optional[str] = None

View File

@ -69,12 +69,12 @@ async def get_item(item_id: UUID, service: ItemService = Depends(), user_info: U
async def update_item( async def update_item(
item_id: UUID, req: Req_UpdateItem, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) 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="상품 삭제") @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)): 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="최저가 수집 요청(스텁)") @router.post(path="/{item_id}/lowest-price", response_model=Res_LowestPriceTrigger, summary="최저가 수집 요청(스텁)")

View File

@ -64,6 +64,7 @@ class ItemData(WebPacketProtocol):
item_id: uuid.UUID item_id: uuid.UUID
company_id: uuid.UUID company_id: uuid.UUID
user_id: uuid.UUID user_id: uuid.UUID
creator_name: Optional[str] = None # 등록자(작성자) 이름. user_id→company.users.name 조인
name: str name: str
code: Optional[str] = None code: Optional[str] = None
category: Optional[str] = None category: Optional[str] = None

View File

@ -40,6 +40,10 @@ class Req_RegenerateQuotation(QuotationProtocol):
supplier_ids: list[uuid.UUID] = [] # 다음 라운드에 부를 공급사(프론트 선택). 상품·기간·번호는 원 견적에서 이어받음 supplier_ids: list[uuid.UUID] = [] # 다음 라운드에 부를 공급사(프론트 선택). 상품·기간·번호는 원 견적에서 이어받음
class Req_AwardQuotation(QuotationProtocol):
winner_supplier_id: uuid.UUID # 담당자가 직접 낙찰시킬 협력사(투찰한 협상완료 세션 중 선택)
class QuotationData(WebPacketProtocol): class QuotationData(WebPacketProtocol):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@ -7,6 +7,7 @@ from common.models.gmodel import PageParams, UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
from services.quotation_service import QuotationService from services.quotation_service import QuotationService
from .protocol import ( from .protocol import (
Req_AwardQuotation,
Req_CreateQuotation, Req_CreateQuotation,
Req_RegenerateQuotation, Req_RegenerateQuotation,
Res_CreateQuotation, Res_CreateQuotation,
@ -55,14 +56,24 @@ async def create_quotation(
@router.post(path="/stop/{qt_id}", response_model=Res_Quotation, summary="견적 마감") @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)): 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="견적 재생성(다음 라운드)") @router.post(path="/regenerate/{qt_id}", response_model=Res_CreateQuotation, summary="견적 재생성(다음 라운드)")
async def regenerate_quotation( async def regenerate_quotation(
qt_id: UUID, req: Req_RegenerateQuotation, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) 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로 연결된 하위 데이터 / 일부는 모델 미존재로 스텁) ----- # ----- 견적 상세 (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="협상 초청 메일 발송") @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)): 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="채팅 상세") @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="세션 초청 메일 재발송") @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)): 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="낙찰 결과") @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="견적 삭제") @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)): 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="협력사 직전 견적 유형") @router.get(path="/supplier/{supplier_id}/last-type", response_model=Res_LastSupplierType, summary="협력사 직전 견적 유형")

View File

@ -35,6 +35,7 @@ class SupplierData(WebPacketProtocol):
supplier_id: uuid.UUID supplier_id: uuid.UUID
company_id: uuid.UUID company_id: uuid.UUID
user_id: uuid.UUID user_id: uuid.UUID
creator_name: Optional[str] = None # 등록자(작성자) 이름. user_id→company.users.name 조인
name: str name: str
code: Optional[str] = None code: Optional[str] = None
manager_name: Optional[str] = None manager_name: Optional[str] = None

View File

@ -3,7 +3,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from common.models.gmodel import PageParams, UserInfo 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 services.supplier_service import SupplierService
from .protocol import ( from .protocol import (
Req_CheckCodes, Req_CheckCodes,
@ -59,6 +59,7 @@ async def update_supplier(
) )
@router.delete(path="/delete/{supplier_id}", response_model=Res_DeleteSupplier, summary="협력사 삭제") @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)): 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))) return RemoveNoneResponse(await service.delete_supplier(user_info.company_id, str(supplier_id)))

View File

@ -25,6 +25,7 @@ class CardService:
nego_card_id=row.nego_card_id, nego_card_id=row.nego_card_id,
user_id=row.user_id, user_id=row.user_id,
is_wildcard=False, is_wildcard=False,
is_shared=row.user_id is None,
name=row.name, name=row.name,
number=row.number, number=row.number,
script=row.script, script=row.script,
@ -42,6 +43,7 @@ class CardService:
nego_card_id=row.wild_card_id, nego_card_id=row.wild_card_id,
user_id=row.user_id, user_id=row.user_id,
is_wildcard=True, is_wildcard=True,
is_shared=row.user_id is None,
name=row.name, name=row.name,
number=row.number, number=row.number,
script=row.script, script=row.script,
@ -56,7 +58,8 @@ class CardService:
# ---- 소유 카드 탐색(어느 테이블인지 모를 때) ------------------------------ # ---- 소유 카드 탐색(어느 테이블인지 모를 때) ------------------------------
async def _find_owned(self, user_uuid: uuid.UUID, card_id: uuid.UUID): 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) 반환.""" (ErrorType, model, pk_col, row, is_wildcard) 반환."""
err, row = await DB_SESSION_MNG.execute_lambda( err, row = await DB_SESSION_MNG.execute_lambda(
nego_cards.DBType(), 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), 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 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.CARD_NOT_FOUND, None, None, None, False
return ErrorType.SUCCESS, nego_cards, nego_cards.nego_card_id, row, 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), 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 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.CARD_NOT_FOUND, None, None, None, True
return ErrorType.SUCCESS, wild_cards, wild_cards.wild_card_id, row, 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 = [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) 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_nego = total_n
res.total_wild = total_w res.total_wild = total_w
# 선택된 탭 기준 페이지네이션 총건수(전체=합산). # 선택된 탭 기준 페이지네이션 총건수(전체=합산).
@ -134,6 +151,14 @@ class CardService:
res.result.SetResult(err) res.result.SetResult(err)
return res return res
res.card = self._wild_to_data(row) if is_wild else self._nego_to_data(row) 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 return res
# ---- 등록 ---------------------------------------------------------------- # ---- 등록 ----------------------------------------------------------------
@ -141,9 +166,11 @@ class CardService:
res = Res_Card() res = Res_Card()
user_uuid = uuid.UUID(user_id) user_uuid = uuid.UUID(user_id)
is_wildcard = req.is_wildcard is_wildcard = req.is_wildcard
# 전체(공용) 카드는 소유자 없이 저장(user_id NULL) → 모든 유저 목록에 노출.
owner_id = None if req.is_shared else user_uuid
common = dict( common = dict(
user_id=user_uuid, user_id=owner_id,
name=req.name, name=req.name,
number=req.number, number=req.number,
script=req.script, script=req.script,

View File

@ -2,6 +2,7 @@ import uuid
from fastapi import Depends, UploadFile 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.db_session_manager import DB_SESSION_MNG
from common.database.model.models import items from common.database.model.models import items
from common.enums import DBWRType, ErrorType from common.enums import DBWRType, ErrorType
@ -56,6 +57,16 @@ class ItemService:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res return res
res.items = [ItemData.model_validate(r) for r in rows] 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 res.total = total
return res return res
@ -81,6 +92,13 @@ class ItemService:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res return res
res.item = ItemData.model_validate(item) 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 return res
async def check_codes(self, company_id: str, codes: list) -> Res_CheckCodes: async def check_codes(self, company_id: str, codes: list) -> Res_CheckCodes:
@ -128,17 +146,22 @@ class ItemService:
# 서버 기본값(created_at/updated_at)은 insert 후 Python 객체에 실리지 않으므로 재조회한다. # 서버 기본값(created_at/updated_at)은 insert 후 Python 객체에 실리지 않으므로 재조회한다.
return await self.get_item(company_id, str(item.item_id)) 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() res = Res_Item()
company_uuid = uuid.UUID(company_id) company_uuid = uuid.UUID(company_id)
item_uuid = uuid.UUID(item_id) item_uuid = uuid.UUID(item_id)
data = req.model_dump(exclude_unset=True) data = req.model_dump(exclude_unset=True)
# 소유권 확인 # 회사 스코프 확인
err_type, _ = await self._fetch_owned(company_uuid, item_uuid) err_type, item = await self._fetch_owned(company_uuid, item_uuid)
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS or item is None:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res 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( err_type = await DB_SESSION_MNG.execute_lambda_run(
[items.DBType()], [items.DBType()],
@ -151,15 +174,20 @@ class ItemService:
# 갱신 후 재조회 # 갱신 후 재조회
return await self.get_item(company_id, item_id) 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() res = Res_DeleteItem()
company_uuid = uuid.UUID(company_id) company_uuid = uuid.UUID(company_id)
item_uuid = uuid.UUID(item_id) item_uuid = uuid.UUID(item_id)
err_type, _ = await self._fetch_owned(company_uuid, item_uuid) err_type, item = await self._fetch_owned(company_uuid, item_uuid)
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS or item is None:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res 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( err_type = await DB_SESSION_MNG.execute_lambda_run(
[items.DBType()], [items.DBType()],

View File

@ -12,6 +12,7 @@ from common.anchoring import (
fetch_current_values, fetch_current_values,
get_base_anchoring_value, 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.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.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 from common.enums import CloseOutcome, CloseReason, DBWRType, ErrorType, NotificationType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus
@ -678,7 +679,7 @@ class QuotationService:
# 4) 전원 미응찰 → 개찰(미응찰). # 4) 전원 미응찰 → 개찰(미응찰).
return await self._open(qt_uuid, original, CloseReason.OPEN_NOSHOW.value, "no_show") 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).""" 상품·기간·견적번호·카드버전은 원 견적에서 이어받는다(regenerate_next_round)."""
@ -689,6 +690,11 @@ class QuotationService:
if err_type != ErrorType.SUCCESS or original is None: if err_type != ErrorType.SUCCESS or original is None:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res 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: if original.status != QuotationStatus.CLOSED.value:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
@ -711,29 +717,106 @@ class QuotationService:
return await self.regenerate_next_round(qt_uuid, supplier_ids) 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)을 탄다 """[프론트] 수동 견적마감. 크론과 똑같은 마감 판정(close_and_decide)을 탄다
(단독낙찰 확정 / 동가·미참여면 다음 라운드 재생성 / 거부·한도면 그냥 마감).""" (단독낙찰 확정 / 동가·미참여면 다음 라운드 재생성 / 거부·한도면 그냥 마감)."""
res = Res_Quotation() res = Res_Quotation()
qt_uuid = uuid.UUID(qt_id) qt_uuid = uuid.UUID(qt_id)
# 존재 확인(+회사 가드) # 존재 확인(+회사 가드)
err_type, _ = await self._fetch(qt_uuid, company_id) err_type, original = await self._fetch(qt_uuid, company_id)
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS or original is None:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res 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) await self.close_and_decide(qt_uuid)
return await self.get_quotation(qt_id, company_id) 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() res = Res_DeleteQuotation()
qt_uuid = uuid.UUID(qt_id) qt_uuid = uuid.UUID(qt_id)
err_type, _ = await self._fetch(qt_uuid, company_id) err_type, original = await self._fetch(qt_uuid, company_id)
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS or original is None:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res 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( err_type = await DB_SESSION_MNG.execute_lambda_run(
[quotations.DBType()], [quotations.DBType()],
@ -815,15 +898,20 @@ class QuotationService:
return res 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 + 담당자 이메일 보유.""" 대상 = email_sent_at IS NULL + 담당자 이메일 보유."""
res = Res_NotifySessions() res = Res_NotifySessions()
qt_uuid = uuid.UUID(qt_id) qt_uuid = uuid.UUID(qt_id)
err_type, quotation = await self._fetch(qt_uuid, company_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) res.result.SetResult(err_type)
return res 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( err_type, rows = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(), sessions.DBType(),
@ -851,7 +939,7 @@ class QuotationService:
await self._mark_emailed(sent_ids) await self._mark_emailed(sent_ids)
return res 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() res = Res_NotifySessions()
sess_uuid = uuid.UUID(session_id) sess_uuid = uuid.UUID(session_id)
@ -866,9 +954,14 @@ class QuotationService:
sess, sp_name, email = got[0], got[1], got[2] sess, sp_name, email = got[0], got[1], got[2]
res.total = 1 res.total = 1
err_type, quotation = await self._fetch(sess.quotation_id, company_id) 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) res.result.SetResult(err_type)
return res 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: if not email:
res.skipped = 1 res.skipped = 1
return res return res

View File

@ -51,6 +51,16 @@ class SupplierService:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res return res
res.suppliers = [SupplierData.model_validate(r) for r in rows] 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 res.total = total
return res return res
@ -61,6 +71,13 @@ class SupplierService:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res return res
res.supplier = SupplierData.model_validate(supplier) 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 return res
async def check_codes(self, company_id: str, codes: list) -> Res_CheckCodes: async def check_codes(self, company_id: str, codes: list) -> Res_CheckCodes:

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

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

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

View File

@ -5,6 +5,7 @@
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
import type { CardDataUserId } from './cardDataUserId'; import type { CardDataUserId } from './cardDataUserId';
import type { CardDataCreatorName } from './cardDataCreatorName';
import type { CardDataName } from './cardDataName'; import type { CardDataName } from './cardDataName';
import type { CardDataNumber } from './cardDataNumber'; import type { CardDataNumber } from './cardDataNumber';
import type { CardDataScript } from './cardDataScript'; import type { CardDataScript } from './cardDataScript';
@ -20,6 +21,8 @@ export interface CardData {
nego_card_id: string; nego_card_id: string;
user_id?: CardDataUserId; user_id?: CardDataUserId;
is_wildcard?: boolean; is_wildcard?: boolean;
is_shared?: boolean;
creator_name?: CardDataCreatorName;
name?: CardDataName; name?: CardDataName;
number?: CardDataNumber; number?: CardDataNumber;
script?: CardDataScript; script?: CardDataScript;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type CardDataCreatorName = string | null;

View File

@ -4,6 +4,7 @@
* Negodata Api Server * Negodata Api Server
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
import type { ItemDataCreatorName } from './itemDataCreatorName';
import type { ItemDataCode } from './itemDataCode'; import type { ItemDataCode } from './itemDataCode';
import type { ItemDataCategory } from './itemDataCategory'; import type { ItemDataCategory } from './itemDataCategory';
import type { ItemDataImageUrl } from './itemDataImageUrl'; import type { ItemDataImageUrl } from './itemDataImageUrl';
@ -28,6 +29,7 @@ export interface ItemData {
item_id: string; item_id: string;
company_id: string; company_id: string;
user_id: string; user_id: string;
creator_name?: ItemDataCreatorName;
name: string; name: string;
code?: ItemDataCode; code?: ItemDataCode;
category?: ItemDataCategory; category?: ItemDataCategory;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ItemDataCreatorName = string | null;

View 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;
}

View File

@ -13,6 +13,7 @@ import type { ReqCreateCardMemo } from './reqCreateCardMemo';
export interface ReqCreateCard { export interface ReqCreateCard {
is_wildcard?: boolean; is_wildcard?: boolean;
is_shared?: boolean;
name?: ReqCreateCardName; name?: ReqCreateCardName;
number?: ReqCreateCardNumber; number?: ReqCreateCardNumber;
script?: ReqCreateCardScript; script?: ReqCreateCardScript;

View File

@ -4,6 +4,7 @@
* Negodata Api Server * Negodata Api Server
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
import type { SupplierDataCreatorName } from './supplierDataCreatorName';
import type { SupplierDataCode } from './supplierDataCode'; import type { SupplierDataCode } from './supplierDataCode';
import type { SupplierDataManagerName } from './supplierDataManagerName'; import type { SupplierDataManagerName } from './supplierDataManagerName';
import type { SupplierDataManagerEmail } from './supplierDataManagerEmail'; import type { SupplierDataManagerEmail } from './supplierDataManagerEmail';
@ -16,6 +17,7 @@ export interface SupplierData {
supplier_id: string; supplier_id: string;
company_id: string; company_id: string;
user_id: string; user_id: string;
creator_name?: SupplierDataCreatorName;
name: string; name: string;
code?: SupplierDataCode; code?: SupplierDataCode;
manager_name?: SupplierDataManagerName; manager_name?: SupplierDataManagerName;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type SupplierDataCreatorName = string | null;

View File

@ -26,6 +26,7 @@ import type {
import type { import type {
HTTPValidationError, HTTPValidationError,
ListQuotationsParams, ListQuotationsParams,
ReqAwardQuotation,
ReqCreateQuotation, ReqCreateQuotation,
ReqRegenerateQuotation, ReqRegenerateQuotation,
ResCreateQuotation, ResCreateQuotation,
@ -269,6 +270,71 @@ export const useStopQuotation = <TError = void | HTTPValidationError,
return useMutation(mutationOptions, queryClient); 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 견적 재생성(다음 라운드) * @summary 견적 재생성(다음 라운드)
*/ */
export const regenerateQuotation = ( export const regenerateQuotation = (

View 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}
/>
);
});

View File

@ -1,10 +1,12 @@
import { useForm } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { showToast } from '@/lib/notify'; import { showToast } from '@/lib/notify';
import { normalizePhone } from '@/lib/phone';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { PhoneInput } from '@/components/ui/phone-input';
import { Sheet } from '@/components/ui/sheet'; import { Sheet } from '@/components/ui/sheet';
import { useAuth } from '../useAuth'; import { useAuth } from '../useAuth';
import { updateMe } from '../service'; import { updateMe } from '../service';
@ -26,6 +28,7 @@ export function ProfileSheet({ open, onClose }: { open: boolean; onClose: () =>
const { user } = useAuth(); const { user } = useAuth();
const { const {
register, register,
control,
handleSubmit, handleSubmit,
setError, setError,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
@ -34,7 +37,7 @@ export function ProfileSheet({ open, onClose }: { open: boolean; onClose: () =>
defaultValues: { defaultValues: {
name: user?.name ?? '', name: user?.name ?? '',
email: user?.email ?? '', email: user?.email ?? '',
contactNumber: user?.contact ?? '', contactNumber: normalizePhone(user?.contact ?? ''),
password: '', password: '',
passwordConfirm: '', passwordConfirm: '',
}, },
@ -98,7 +101,20 @@ export function ProfileSheet({ open, onClose }: { open: boolean; onClose: () =>
{/* 연락처 */} {/* 연락처 */}
<div className="space-y-1"> <div className="space-y-1">
<Typography as="label" variant="label">연락처</Typography> <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> </div>
{/* 비밀번호 변경(옵션) */} {/* 비밀번호 변경(옵션) */}

View File

@ -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: '특정 원재료 포함 입찰에만 적용' },
],
);
}

View File

@ -16,6 +16,7 @@ import { CardScriptEditor, deserialize, serializeToText } from '../editor';
const schema = z.object({ const schema = z.object({
isWildcard: z.boolean(), isWildcard: z.boolean(),
isShared: z.boolean(),
usageType: z.number(), usageType: z.number(),
code: z.string().trim().min(1, '카드번호는 필수 기입 사항입니다.'), code: z.string().trim().min(1, '카드번호는 필수 기입 사항입니다.'),
title: z.string().trim().min(1, '카드이름은 필수 기입 사항입니다.'), title: z.string().trim().min(1, '카드이름은 필수 기입 사항입니다.'),
@ -48,6 +49,7 @@ function buildDefaults(
if (mode === 'edit' && card) { if (mode === 'edit' && card) {
return { return {
isWildcard: card.isWildcard, isWildcard: card.isWildcard,
isShared: card.isShared,
usageType: card.usageType, usageType: card.usageType,
code: card.code, code: card.code,
title: card.title, title: card.title,
@ -61,6 +63,7 @@ function buildDefaults(
const wild = activeTab === 'WILD'; const wild = activeTab === 'WILD';
return { return {
isWildcard: wild, isWildcard: wild,
isShared: false, // 기본: 개인(나만) — 전체 공용은 등록 시 명시 선택
usageType: CardUsageType.COMMON, // 기본: 공통(신규·재 모두) usageType: CardUsageType.COMMON, // 기본: 공통(신규·재 모두)
code: generateCardCode(wild), code: generateCardCode(wild),
title: '', title: '',
@ -105,6 +108,7 @@ export function CardFormSheet({
editorScript: v.editorScript, editorScript: v.editorScript,
status: v.status, status: v.status,
isWildcard: v.isWildcard, isWildcard: v.isWildcard,
isShared: v.isShared,
usageType: v.usageType, usageType: v.usageType,
triggerCondition: v.triggerCondition, triggerCondition: v.triggerCondition,
memo: v.memo, memo: v.memo,
@ -180,6 +184,16 @@ export function CardFormSheet({
{errors.code && <p className="text-[10px] text-rose-500">{errors.code.message}</p>} {errors.code && <p className="text-[10px] text-rose-500">{errors.code.message}</p>}
</div> </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 */} {/* Status */}
<div className="space-y-1"> <div className="space-y-1">
<Typography as="label" variant="small" className="font-semibold block mb-1">협상 적용 여부</Typography> <Typography as="label" variant="small" className="font-semibold block mb-1">협상 적용 여부</Typography>
@ -226,27 +240,57 @@ export function CardFormSheet({
</div> </div>
</div> </div>
{/* 카드 용도(usage_type): 공통 / 신규전용 / 재전용 */} <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1"> {/* 공개 범위(scope): 개인=나만 / 전체=모두 공용(user_id NULL). 등록 시에만 결정, 수정 시 고정 */}
<Typography as="label" variant="small" className="font-semibold">카드 용도</Typography> <div className="space-y-1">
<Controller <Typography as="label" variant="small" className="font-semibold">공개 범위</Typography>
control={control} <Controller
name="usageType" control={control}
render={({ field }) => ( name="isShared"
<Select value={String(field.value)} onValueChange={(v) => field.onChange(Number(v))}> render={({ field }) => (
<SelectTrigger id="form-card-usage-type" className="w-full"> <Select
<SelectValue> value={field.value ? 'ALL' : 'MINE'}
{(value) => CARD_USAGE_TYPE_LABEL[Number(value) as CardUsageType] ?? '공통'} onValueChange={(v) => {
</SelectValue> if (mode !== 'create') return; // 스코프는 등록 시에만 결정(수정 시 이동 불가)
</SelectTrigger> field.onChange(v === 'ALL');
<SelectContent> }}
{CARD_USAGE_TYPE_OPTIONS.map((o) => ( >
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem> <SelectTrigger id="form-card-scope" className="w-full" disabled={mode === 'edit'}>
))} <SelectValue>
</SelectContent> {(value) => (value === 'ALL' ? '전체 (모두 공용)' : '개인 (나만)')}
</Select> </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> </div>
{/* Title */} {/* Title */}

View File

@ -20,16 +20,24 @@ export function CardTable({ data, onEdit, footer }: CardTableProps) {
{ {
header: '구분', header: '구분',
headClassName: 'w-24', headClassName: 'w-24',
cell: (card) => cell: (card) => (
card.isWildcard ? ( <div className="flex flex-col items-start gap-1">
<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"> {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>
<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 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> 협상카드
), </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: '카드번호', header: '카드번호',
@ -88,6 +96,13 @@ export function CardTable({ data, onEdit, footer }: CardTableProps) {
<span className="text-muted-foreground font-mono font-medium opacity-65">-</span> <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 ?? '-'),
},
]} ]}
/> />
); );

View File

@ -10,6 +10,7 @@ import type { Descendant } from 'slate';
import type { ReqCreateCard } from '@/api/generated/model/reqCreateCard'; import type { ReqCreateCard } from '@/api/generated/model/reqCreateCard';
import type { ResCard } from '@/api/generated/model/resCard'; import type { ResCard } from '@/api/generated/model/resCard';
import type { NegotiationCard } from '@/types'; import type { NegotiationCard } from '@/types';
import type { BulkFailure } from '@/lib/excel';
import { mapCardData, toCardStatusCode } from '../types'; import { mapCardData, toCardStatusCode } from '../types';
import { serializeToText } from '../editor'; import { serializeToText } from '../editor';
@ -21,6 +22,7 @@ export type CardInput = {
editorScript: Descendant[]; editorScript: Descendant[];
status: 'ACTIVE' | 'INACTIVE'; status: 'ACTIVE' | 'INACTIVE';
isWildcard: boolean; isWildcard: boolean;
isShared: boolean; // 전체(공용, user_id NULL) 등록 여부. 등록 시에만 유효(수정 시 스코프 고정)
usageType: number; // usage_type(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용 usageType: number; // usage_type(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
triggerCondition?: string; triggerCondition?: string;
memo?: string; memo?: string;
@ -37,6 +39,7 @@ function cardError(res: ResCard): string | null {
function toReq(input: CardInput): ReqCreateCard { function toReq(input: CardInput): ReqCreateCard {
return { return {
is_wildcard: input.isWildcard, is_wildcard: input.isWildcard,
is_shared: input.isShared,
usage_type: input.usageType, usage_type: input.usageType,
name: input.title, name: input.title,
number: input.code, number: input.code,
@ -73,6 +76,22 @@ export function useCards(params: ListCardsParams) {
await refresh(); 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. // customFetch 가 본문을 그대로 주므로 cardsQuery.data 가 곧 ResCardList → .cards.
const cards: NegotiationCard[] = (cardsQuery.data?.cards ?? []).map(mapCardData); const cards: NegotiationCard[] = (cardsQuery.data?.cards ?? []).map(mapCardData);
const total = cardsQuery.data?.total ?? 0; // 선택 탭 기준 총건수(페이지네이션) const total = cardsQuery.data?.total ?? 0; // 선택 탭 기준 총건수(페이지네이션)
@ -87,6 +106,7 @@ export function useCards(params: ListCardsParams) {
createCard: createCardFn, createCard: createCardFn,
updateCard: updateCardFn, updateCard: updateCardFn,
deleteCard: deleteCardFn, deleteCard: deleteCardFn,
bulkCreate,
refresh, refresh,
cardsQuery, cardsQuery,
}; };

View File

@ -17,6 +17,7 @@ export function mapCardData(c: CardData): NegotiationCard {
return { return {
id: c.nego_card_id, id: c.nego_card_id,
isWildcard: c.is_wildcard ?? false, isWildcard: c.is_wildcard ?? false,
isShared: c.is_shared ?? false,
usageType: c.usage_type ?? CardUsageType.COMMON, usageType: c.usage_type ?? CardUsageType.COMMON,
code: c.number ?? '', code: c.number ?? '',
title: c.name ?? '', title: c.name ?? '',
@ -25,6 +26,7 @@ export function mapCardData(c: CardData): NegotiationCard {
status: toCardStatusLabel(c.status), status: toCardStatusLabel(c.status),
triggerCondition: c.condition ?? undefined, triggerCondition: c.condition ?? undefined,
memo: c.memo ?? undefined, memo: c.memo ?? undefined,
creatorName: c.creator_name ?? undefined,
}; };
} }

View File

@ -3,9 +3,11 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import { showToast } from '@/lib/notify'; import { showToast } from '@/lib/notify';
import { normalizePhone } from '@/lib/phone';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { PhoneInput } from '@/components/ui/phone-input';
import { Sheet } from '@/components/ui/sheet'; import { Sheet } from '@/components/ui/sheet';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { import {
@ -47,7 +49,7 @@ function buildDefaults(mode: 'create' | 'edit', member: Member | null): FormValu
passwordConfirm: '', passwordConfirm: '',
name: member.name ?? '', name: member.name ?? '',
email: member.email ?? '', email: member.email ?? '',
contactNumber: member.contact_number ?? '', contactNumber: normalizePhone(member.contact_number ?? ''),
status: member.status, status: member.status,
}; };
} }
@ -216,12 +218,19 @@ export function MemberFormSheet({
{/* 연락처 */} {/* 연락처 */}
<div className="space-y-1"> <div className="space-y-1">
<Typography as="label" variant="label">연락처</Typography> <Typography as="label" variant="label">연락처</Typography>
<Input <Controller
id="form-member-phone" control={control}
type="text" name="contactNumber"
{...register('contactNumber')} render={({ field }) => (
className={inputClass} <PhoneInput
placeholder="010-XXXX-XXXX" id="form-member-phone"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
className={inputClass}
placeholder="010-XXXX-XXXX"
/>
)}
/> />
</div> </div>

View File

@ -2,6 +2,7 @@ import { Badge } from '@/components/ui/badge';
import { DataTable } from '@/components/ui/data-table'; import { DataTable } from '@/components/ui/data-table';
import { TablePagination } from '@/components/ui/table-pagination'; import { TablePagination } from '@/components/ui/table-pagination';
import { USER_ROLE_LABEL } from '@/lib/enumLabels'; import { USER_ROLE_LABEL } from '@/lib/enumLabels';
import { formatPhoneKR } from '@/lib/phone';
import { UserStatus, USER_STATUS_LABEL, type Member } from '../types'; import { UserStatus, USER_STATUS_LABEL, type Member } from '../types';
type MemberTableProps = { type MemberTableProps = {
@ -66,7 +67,7 @@ export function MemberTable({
cell: (m) => ( cell: (m) => (
<div className="space-y-0.5 font-mono text-xs"> <div className="space-y-0.5 font-mono text-xs">
<div className="text-foreground">{m.email || '-'}</div> <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> </div>
), ),
}, },

View File

@ -1,5 +1,6 @@
import { useMemo, useRef, useState } from 'react'; import { useMemo, useRef, useState } from 'react';
import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-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 type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier';
import { showToast } from '@/lib/notify'; import { showToast } from '@/lib/notify';
import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel'; import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel';
@ -76,7 +77,7 @@ function toSupplierCreate(row: RawRow): SupplierCreate {
code: row.code, code: row.code,
manager_name: row.managerName, manager_name: row.managerName,
manager_email: row.managerEmail, 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, total_revenue: row.totalRevenue?.trim() ? Number(row.totalRevenue.replace(/[^0-9]/g, '')) : undefined,
}; };
} }
@ -100,6 +101,7 @@ export function downloadPartnerTemplate() {
// 협력사 엑셀 일괄 업로드 모달. 파일 파싱·원본 행 state는 이 컴포넌트가 소유하고, // 협력사 엑셀 일괄 업로드 모달. 파일 파싱·원본 행 state는 이 컴포넌트가 소유하고,
// 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임. // 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임.
export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUploadModalProps) { export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUploadModalProps) {
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
const [excelFile, setExcelFile] = useState<string | null>(null); const [excelFile, setExcelFile] = useState<string | null>(null);
const [rows, setRows] = useState<RawRow[]>([]); const [rows, setRows] = useState<RawRow[]>([]);
const [serverErrors, setServerErrors] = useState<Record<string, string>>({}); // 서버(DB) 거부 code→사유 const [serverErrors, setServerErrors] = useState<Record<string, string>>({}); // 서버(DB) 거부 code→사유
@ -199,8 +201,8 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
}; };
return ( 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">
<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="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 */} {/* Modal Title */}
<div className="flex items-center justify-between pb-4 border-b border-border"> <div className="flex items-center justify-between pb-4 border-b border-border">

View File

@ -1,14 +1,17 @@
import { useForm } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier'; import type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier';
import type { ReqUpdateSupplier as SupplierUpdate } from '@/api/generated/model/reqUpdateSupplier'; import type { ReqUpdateSupplier as SupplierUpdate } from '@/api/generated/model/reqUpdateSupplier';
import { showToast } from '@/lib/notify'; import { showToast } from '@/lib/notify';
import { normalizePhone } from '@/lib/phone';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { PhoneInput } from '@/components/ui/phone-input';
import { Sheet } from '@/components/ui/sheet'; import { Sheet } from '@/components/ui/sheet';
import { useAuthStore } from '@/stores/auth';
import { SupplierItemsManager } from './SupplierItemsManager'; import { SupplierItemsManager } from './SupplierItemsManager';
import { type Partner } from '../types'; import { type Partner } from '../types';
@ -41,7 +44,7 @@ function buildDefaults(mode: 'create' | 'edit', partner: Partner | null): FormVa
code: partner.code || '', code: partner.code || '',
managerName: partner.manager_name || '', managerName: partner.manager_name || '',
managerEmail: partner.manager_email || '', managerEmail: partner.manager_email || '',
managerPhone: partner.manager_contact_number || '', managerPhone: normalizePhone(partner.manager_contact_number || ''),
totalRevenue: partner.total_revenue != null ? String(partner.total_revenue) : '', 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)}`, code: `PART-CORP-${Math.floor(100 + Math.random() * 900)}`,
managerName: '', managerName: '',
managerEmail: '', managerEmail: '',
managerPhone: '010-', managerPhone: '010', // 신규 등록 시 010 프리필 → 뒷자리만 입력
totalRevenue: '', totalRevenue: '',
}; };
} }
@ -68,6 +71,7 @@ export function PartnerFormSheet({
}: PartnerFormSheetProps) { }: PartnerFormSheetProps) {
const { const {
register, register,
control,
handleSubmit, handleSubmit,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<FormValues>({ } = useForm<FormValues>({
@ -75,6 +79,9 @@ export function PartnerFormSheet({
defaultValues: buildDefaults(mode, partner), defaultValues: buildDefaults(mode, partner),
}); });
// 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(백엔드 RequireOwner 와 동일 규칙).
const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자');
const onValid = async (v: FormValues) => { const onValid = async (v: FormValues) => {
const common = { const common = {
name: v.name, name: v.name,
@ -151,6 +158,14 @@ export function PartnerFormSheet({
</div> </div>
</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 */} {/* Manager Name */}
<div className="space-y-1"> <div className="space-y-1">
<Typography as="label" variant="label">담당자명</Typography> <Typography as="label" variant="label">담당자명</Typography>
@ -180,12 +195,19 @@ export function PartnerFormSheet({
{/* Manager Phone */} {/* Manager Phone */}
<div className="space-y-1"> <div className="space-y-1">
<Typography as="label" variant="label">담당자 연락처</Typography> <Typography as="label" variant="label">담당자 연락처</Typography>
<Input <Controller
id="form-partner-phone" control={control}
type="text" name="managerPhone"
{...register('managerPhone')} render={({ field }) => (
className={inputClass} <PhoneInput
placeholder="010-XXXX-XXXX" 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>} {errors.managerPhone && <p className="text-[10px] text-rose-500">{errors.managerPhone.message}</p>}
</div> </div>
@ -200,6 +222,8 @@ export function PartnerFormSheet({
type="button" type="button"
variant="destructive" variant="destructive"
size="sm" size="sm"
disabled={!isSuperAdmin}
title={isSuperAdmin ? undefined : '협력사 삭제는 최고관리자만 할 수 있습니다.'}
onClick={() => { onClick={() => {
onDelete(partner.supplier_id, partner.name); onDelete(partner.supplier_id, partner.name);
onClose(); onClose();

View File

@ -1,5 +1,6 @@
import { DataTable } from '@/components/ui/data-table'; import { DataTable } from '@/components/ui/data-table';
import { TablePagination } from '@/components/ui/table-pagination'; import { TablePagination } from '@/components/ui/table-pagination';
import { formatPhoneKR } from '@/lib/phone';
import type { Partner } from '../types'; import type { Partner } from '../types';
type PartnerTableProps = { type PartnerTableProps = {
@ -59,7 +60,7 @@ export function PartnerTable({
<div className="space-y-0.5 font-mono text-xs"> <div className="space-y-0.5 font-mono text-xs">
<div className="text-foreground font-semibold">{part.manager_name}</div> <div className="text-foreground font-semibold">{part.manager_name}</div>
<div className="text-[10px] text-muted-foreground"> <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>
</div> </div>
), ),
@ -70,6 +71,12 @@ export function PartnerTable({
cellClassName: 'font-mono text-muted-foreground', cellClassName: 'font-mono text-muted-foreground',
cell: (part) => (part.total_revenue != null ? `₩${Number(part.total_revenue).toLocaleString()}` : '-'), 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 ?? '-',
},
]} ]}
/> />
); );

View File

@ -1,5 +1,6 @@
import { useMemo, useRef, useState } from 'react'; import { useMemo, useRef, useState } from 'react';
import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-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 type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem';
import { showToast } from '@/lib/notify'; import { showToast } from '@/lib/notify';
import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel'; import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel';
@ -180,6 +181,7 @@ function toItemCreate(row: RawRow): ItemCreate {
// 상품 엑셀 일괄 업로드 모달. 파일 파싱(목업)·원본 행 state는 이 컴포넌트가 소유하고, // 상품 엑셀 일괄 업로드 모달. 파일 파싱(목업)·원본 행 state는 이 컴포넌트가 소유하고,
// 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임. // 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임.
export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUploadModalProps) { export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUploadModalProps) {
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
const [excelFile, setExcelFile] = useState<string | null>(null); const [excelFile, setExcelFile] = useState<string | null>(null);
const [rows, setRows] = useState<RawRow[]>([]); const [rows, setRows] = useState<RawRow[]>([]);
const [serverErrors, setServerErrors] = useState<Record<string, string>>({}); // 서버(DB) 거부 code→사유 const [serverErrors, setServerErrors] = useState<Record<string, string>>({}); // 서버(DB) 거부 code→사유
@ -294,8 +296,8 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
}; };
return ( 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">
<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="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 */} {/* Modal Title */}
<div className="flex items-center justify-between pb-4 border-b border-border"> <div className="flex items-center justify-between pb-4 border-b border-border">

View File

@ -4,6 +4,7 @@ import { Globe, X, AlertCircle, Loader2, Cpu, RefreshCw } from 'lucide-react';
import { showToast } from '@/lib/notify'; import { showToast } from '@/lib/notify';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useScrollLock } from '@/lib/useScrollLock';
import type { Product } from '../types'; import type { Product } from '../types';
type PriceUpdateModalProps = { type PriceUpdateModalProps = {
@ -17,6 +18,7 @@ type PriceUpdateModalProps = {
// 인터넷 최저가 실시간 수집 데모 모달. 크롤링 진행 state는 이 컴포넌트가 소유한다. // 인터넷 최저가 실시간 수집 데모 모달. 크롤링 진행 state는 이 컴포넌트가 소유한다.
// NOTE: 서버 미연동 — 진행 애니메이션/로그만 데모. // NOTE: 서버 미연동 — 진행 애니메이션/로그만 데모.
export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose }: PriceUpdateModalProps) { export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose }: PriceUpdateModalProps) {
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
const [isCrawling, setIsCrawling] = useState(false); const [isCrawling, setIsCrawling] = useState(false);
const [crawlingProgress, setCrawlingProgress] = useState(0); const [crawlingProgress, setCrawlingProgress] = useState(0);
const [crawlerLogs, setCrawlerLogs] = useState<string[]>([]); const [crawlerLogs, setCrawlerLogs] = useState<string[]>([]);
@ -72,8 +74,8 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
}; };
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 backdrop-blur-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 overflow-hidden animate-scale-up font-mono text-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 */} {/* Modal Title */}
<div className="flex items-center justify-between pb-4 border-b border-border"> <div className="flex items-center justify-between pb-4 border-b border-border">

View File

@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Sheet } from '@/components/ui/sheet'; import { Sheet } from '@/components/ui/sheet';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useAuthStore } from '@/stores/auth';
import { type Product } from '../types'; import { type Product } from '../types';
// 폼 검증 스키마. 필수: 상품명/상품코드/단가/최저가. 나머지는 선택. // 폼 검증 스키마. 필수: 상품명/상품코드/단가/최저가. 나머지는 선택.
@ -129,6 +130,13 @@ export function ProductFormSheet({
const deliveryTypes = DELIVERY_TYPE_OPTIONS; 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 로 저장한다. // minPrice = 인터넷 최저가(실값) → internet_lowest_price 로 저장한다.
const onValid = async (v: FormValues) => { const onValid = async (v: FormValues) => {
// 기존 카테고리면 그 category_type(id) 재사용, 처음 쓰는 카테고리면 max+1 부여. // 기존 카테고리면 그 category_type(id) 재사용, 처음 쓰는 카테고리면 max+1 부여.
@ -244,6 +252,14 @@ export function ProductFormSheet({
</div> </div>
</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"> <div className="grid grid-cols-2 gap-4">
{/* Price */} {/* Price */}
<div className="space-y-1"> <div className="space-y-1">
@ -471,6 +487,8 @@ export function ProductFormSheet({
type="button" type="button"
variant="destructive" variant="destructive"
size="sm" size="sm"
disabled={!canManageOwn}
title={canManageOwn ? undefined : '본인이 등록한 상품만 삭제할 수 있습니다.'}
onClick={() => { onClick={() => {
onDelete(product.item_id, product.name); onDelete(product.item_id, product.name);
onClose(); onClose();
@ -483,7 +501,12 @@ export function ProductFormSheet({
<Button type="button" variant="outline" size="sm" onClick={onClose}> <Button type="button" variant="outline" size="sm" onClick={onClose}>
취소 취소
</Button> </Button>
<Button type="submit" size="sm" disabled={isSubmitting}> <Button
type="submit"
size="sm"
disabled={isSubmitting || !canSave}
title={canSave ? undefined : '본인이 등록한 상품만 수정할 수 있습니다.'}
>
{mode === 'create' ? '신규 상품 발행' : '변경사항 저장'} {mode === 'create' ? '신규 상품 발행' : '변경사항 저장'}
</Button> </Button>
</div> </div>

View File

@ -93,6 +93,12 @@ export function ProductTable({
cellClassName: 'font-mono font-semibold text-rose-600 dark:text-rose-400', 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()}` : '-'), 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 ?? '-',
},
]} ]}
/> />
); );

View File

@ -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 { X, PlusSquare, ArrowRight, Loader2, Gavel } from 'lucide-react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useGetSupplierLastType } from '@/api/generated/quotation/quotation';
import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item'; import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item';
import { useListItems, useGetItem } from '@/api/generated/item/item'; import { useListItems, useGetItem } from '@/api/generated/item/item';
import { useListSuppliers } from '@/api/generated/supplier/supplier'; import { useListSuppliers } from '@/api/generated/supplier/supplier';
@ -10,14 +9,13 @@ import { mapCardData } from '@/features/cards/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Typography, typographyVariants } from '@/components/ui/typography'; import { Typography, typographyVariants } from '@/components/ui/typography';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useScrollLock } from '@/lib/useScrollLock';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Combobox, type ComboOption } from '@/components/ui/combobox'; import { Combobox, type ComboOption } from '@/components/ui/combobox';
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types'; import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
import type { CreateQuotationInput } from '../hooks/useQuotations'; import type { CreateQuotationInput } from '../hooks/useQuotations';
import { QuotationType } from '@/api/generated/model';
import { import {
supplierTypeOptions,
is1v1, is1v1,
toQuotationType, toQuotationType,
awardStrategySummary, awardStrategySummary,
@ -55,6 +53,8 @@ export function QuotationCreateModal({
onCreate, onCreate,
onClose, onClose,
}: QuotationCreateModalProps) { }: QuotationCreateModalProps) {
// 모달 열린 동안 배경(부모) 스크롤 잠금 — 뒤 페이지가 같이 스크롤되는 것 방지.
useScrollLock();
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
// 유형은 '진행 방식(협상/경매) × 대상(신규/후속)' 2축으로 받아 제출 직전 4코드로 합성한다. // 유형은 '진행 방식(협상/경매) × 대상(신규/후속)' 2축으로 받아 제출 직전 4코드로 합성한다.
@ -65,9 +65,11 @@ export function QuotationCreateModal({
const [dueDate, setDueDate] = useState(nowKstLocalInput); const [dueDate, setDueDate] = useState(nowKstLocalInput);
const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? ''); const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? '');
const [selectedCardIds, setSelectedCardIds] = useState<string[]>([]); 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 [memo, setMemo] = useState('');
const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 상품값으로 목표가 산정 const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 상품값으로 목표가 산정
const [supplierType, setSupplierType] = useState(''); // 협력사 유형(SupplierType). 전 유형에서 입력 → 견적에 기록
const [midAction, setMidAction] = useState<number>(DEFAULT_MID_ACTION); // 앵커~목표가 구간: 낙찰/개찰 (1:1 전용) const [midAction, setMidAction] = useState<number>(DEFAULT_MID_ACTION); // 앵커~목표가 구간: 낙찰/개찰 (1:1 전용)
const [overAction, setOverAction] = useState<number>(DEFAULT_OVER_ACTION); // 목표가 초과 구간: 낙찰/개찰 (1:1 전용) const [overAction, setOverAction] = useState<number>(DEFAULT_OVER_ACTION); // 목표가 초과 구간: 낙찰/개찰 (1:1 전용)
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@ -81,18 +83,6 @@ export function QuotationCreateModal({
: ['기본 정보', '협력사 초청', '확인·완료']; : ['기본 정보', '협력사 초청', '확인·완료'];
const totalSteps = steps.length; 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(); const navigate = useNavigate();
// ── 픽리스트 서버검색(상품/협력사/카드) — size 캡 없이 검색으로 도달. 미검색이면 부모가 넘긴 목록으로 기본 노출. // ── 픽리스트 서버검색(상품/협력사/카드) — 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 cardRows = cardQ ? (cardSearch.data?.cards ?? []).map(mapCardData) : cards;
const cardOptions: ComboOption[] = cardRows const cardOptions: ComboOption[] = cardRows
.filter((c) => !c.isWildcard || c.status === 'ACTIVE') .filter((c) => !c.isWildcard || c.status === 'ACTIVE')
@ -159,6 +155,12 @@ export function QuotationCreateModal({
</div> </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가가 유일한 후보 → 필수가 된다. // 상품에 산정 후보가 있는지(인터넷=공통, 매입·판매=재 한정). 없으면 MD가가 유일한 후보 → 필수가 된다.
const mdNum = Number(mdPrice) || 0; const mdNum = Number(mdPrice) || 0;
const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null)); const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
@ -175,7 +177,10 @@ export function QuotationCreateModal({
// 경매는 3스텝뿐 — 협상카드 스텝(4)에 있던 상태면 마지막(3)으로 당긴다. // 경매는 3스텝뿐 — 협상카드 스텝(4)에 있던 상태면 마지막(3)으로 당긴다.
if (next === 'auction') setStep((s) => Math.min(s, 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) => setSelectedPartnerIds((prev) =>
oneToOne oneToOne
? prev.includes(id) ? prev.includes(id)
@ -185,8 +190,12 @@ export function QuotationCreateModal({
? prev.filter((p) => p !== id) ? prev.filter((p) => p !== id)
: [...prev, 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])); setSelectedCardIds((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]));
};
const handleSubmit = async () => { const handleSubmit = async () => {
if (submitting) return; if (submitting) return;
@ -208,7 +217,6 @@ export function QuotationCreateModal({
cardIds: oneToOne ? selectedCardIds : [], cardIds: oneToOne ? selectedCardIds : [],
memo, memo,
mdPrice: mdPrice ? Number(mdPrice) : null, mdPrice: mdPrice ? Number(mdPrice) : null,
supplierType: supplierType ? Number(supplierType) : null,
midAction: oneToOne ? midAction : undefined, midAction: oneToOne ? midAction : undefined,
overAction: oneToOne ? overAction : undefined, overAction: oneToOne ? overAction : undefined,
}); });
@ -219,7 +227,7 @@ export function QuotationCreateModal({
}; };
return ( 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 && ( {submitting && (
<div className="fixed inset-0 z-[60] flex items-center justify-center"> <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"> <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> </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 */} {/* 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"> <div className="flex items-center gap-2">
<PlusSquare className="text-foreground" size={18} /> <PlusSquare className="text-foreground" size={18} />
<Typography variant="small" className="font-bold">신규 견적 등록 (단계 {step}/{totalSteps})</Typography> <Typography variant="small" className="font-bold">신규 견적 등록 (단계 {step}/{totalSteps})</Typography>
@ -243,7 +251,7 @@ export function QuotationCreateModal({
</div> </div>
{/* Steps indicator — 스텝 수는 유형에 따라 3(경매)/4(협상) */} {/* 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) => { {steps.map((label, i) => {
const n = i + 1; const n = i + 1;
return ( return (
@ -257,8 +265,8 @@ export function QuotationCreateModal({
})} })}
</div> </div>
{/* Step content */} {/* Step content — 남은 높이를 채우고 내용이 길면 여기만 스크롤(작은 화면 대응) */}
<div className="my-6 min-h-60 text-xs text-foreground space-y-4"> <div className="flex-1 overflow-y-auto min-h-0 my-6 pr-1 text-xs text-foreground space-y-4">
{step === 1 && ( {step === 1 && (
<div className="space-y-4"> <div className="space-y-4">
@ -387,7 +395,7 @@ export function QuotationCreateModal({
{step === 2 && ( {step === 2 && (
<div className="space-y-3"> <div className="space-y-3">
<Typography as="span" variant="label" className="block">협력사 초청 ({oneToOne ? '단일선택' : '다중선택'})</Typography> <Typography as="span" variant="label" className="block">협력사 초청 ({oneToOne ? '단일선택' : '다중선택'})</Typography>
{/* 서버검색 다중선택 — 각 행에 선택 상품 취급유형 배지(미매핑=미취급). oneToOne이면 togglePartner가 단일로 강제. */} {/* 서버검색 다중선택 — 각 행에 선택 상품 상품조달유형 배지(미매핑=미정). oneToOne이면 togglePartner가 단일로 강제. */}
<Combobox <Combobox
variant="inline" variant="inline"
multiple multiple
@ -400,27 +408,17 @@ export function QuotationCreateModal({
emptyText="협력사가 없습니다" emptyText="협력사가 없습니다"
maxListHeight="max-h-56" maxListHeight="max-h-56"
/> />
{/* 선택 목록 — 검색어가 바뀌어도 담은 협력사가 유지되는 고정 테이블(취급유형은 상품×협력사 매핑). */}
{/* 협력사 유형 — 항상 노출(처음부터 입력 가능). 재협상(1:1)이면 선택 협력사의 직전 견적 값으로 자동 디폴트. */} <div className="space-y-1">
<div className="space-y-1 pt-3 border-t border-border/40"> <Typography as="span" variant="label" className="block text-[10px] text-muted-foreground">
<Typography as="label" variant="label">협력사 유형</Typography> 담긴 협력사 {selectedPartnerRows.length}곳
<Select value={supplierType} onValueChange={(v) => setSupplierType(v ?? '')}> </Typography>
<SelectTrigger id="wizard-supplier-type" className="w-full"> <SelectedPartnerTable
<SelectValue> rows={selectedPartnerRows}
{(value) => (value ? supplierTypeLabel(Number(value)) : '협력사 유형 선택...')} supplyTypeBySupplier={supplyTypeBySupplier}
</SelectValue> showSupplyType={!!productId}
</SelectTrigger> onRemove={togglePartner}
<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> </div>
</div> </div>
)} )}
@ -514,13 +512,20 @@ export function QuotationCreateModal({
emptyText="협상카드가 없습니다" emptyText="협상카드가 없습니다"
maxListHeight="max-h-72" 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>
)} )}
</div> </div>
{/* Footer nav */} {/* 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 <Button
type="button" type="button"
variant="outline" variant="outline"
@ -560,12 +565,12 @@ export function QuotationCreateModal({
// ── 헬퍼 컴포넌트 (메인 아래) ────────────────────────────────────────────── // ── 헬퍼 컴포넌트 (메인 아래) ──────────────────────────────────────────────
// 협력사 취급유형 배지 — type undefined = 이 상품 미취급, 그 외 SupplierType 라벨(제조/유통/총판/없음). // 협력사 상품조달유형 배지 — type undefined = 유형 미지정(매핑 없음). 초청 협력사는 그 상품을 공급하므로 '미취급'이 아니라 '미정'.
function SupplyTypeBadge({ type }: { type?: number }) { function SupplyTypeBadge({ type }: { type?: number }) {
if (type === undefined) { if (type === undefined) {
return ( return (
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0"> <Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
미취급 미정
</Typography> </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({ function Segmented({
options, options,

View File

@ -38,6 +38,8 @@ type DrawerHeaderCardsProps = {
currentProduct: Product | undefined; currentProduct: Product | undefined;
/** 목표가 클릭 → 산정내역 모달(대표 세션 기준). 목표가/앵커링가는 견적 단위(1견적=1상품)라 세션 공통값. */ /** 목표가 클릭 → 산정내역 모달(대표 세션 기준). 목표가/앵커링가는 견적 단위(1견적=1상품)라 세션 공통값. */
onShowTarget: (sessionId: string) => void; onShowTarget: (sessionId: string) => void;
/** 접힘 상태 — 상세 그리드는 감추고 상단 결과 요약(목표가·낙찰·절감) 밴드만 남긴다. */
collapsed?: boolean;
}; };
export function DrawerHeaderCards({ export function DrawerHeaderCards({
@ -46,7 +48,17 @@ export function DrawerHeaderCards({
sessionViews, sessionViews,
currentProduct, currentProduct,
onShowTarget, onShowTarget,
collapsed = false,
}: DrawerHeaderCardsProps) { }: DrawerHeaderCardsProps) {
// 접으면 결과 요약 밴드만 노출(목표가·낙찰·절감). 상세 그리드 계산은 건너뛴다.
if (collapsed) {
return (
<div className="text-xs font-mono">
<ResultSummaryBand quotation={quotation} sessionViews={sessionViews} />
</div>
);
}
// Quotations DDL 표시값 // Quotations DDL 표시값
const q_name = quotation.name || '-'; const q_name = quotation.name || '-';
const q_number = quotation.number || '-'; const q_number = quotation.number || '-';

View File

@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { X, RefreshCw, Loader2 } from 'lucide-react'; import { X, RefreshCw, Loader2 } from 'lucide-react';
import { useScrollLock } from '@/lib/useScrollLock';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
import { type Partner, sessionStatusLabel } from '../../types'; import { type Partner, sessionStatusLabel } from '../../types';
@ -20,6 +21,7 @@ type RegenerateModalProps = {
// 마감된 견적의 '다음 라운드'를 만들 때 부를 공급사를 고르는 모달. // 마감된 견적의 '다음 라운드'를 만들 때 부를 공급사를 고르는 모달.
// 상품·견적번호·협상기간·카드는 원 견적에서 이어받으므로 여기선 공급사만 선택한다. // 상품·견적번호·협상기간·카드는 원 견적에서 이어받으므로 여기선 공급사만 선택한다.
export function RegenerateModal({ open, partners, sessionStatusBySupplier, defaultSupplierIds, onConfirm, onClose }: RegenerateModalProps) { export function RegenerateModal({ open, partners, sessionStatusBySupplier, defaultSupplierIds, onConfirm, onClose }: RegenerateModalProps) {
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
const [selected, setSelected] = useState<string[]>(defaultSupplierIds); const [selected, setSelected] = useState<string[]>(defaultSupplierIds);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
if (!open) return null; if (!open) return null;
@ -42,8 +44,8 @@ export function RegenerateModal({ open, partners, sessionStatusBySupplier, defau
}; };
return ( return (
<div className="fixed inset-0 z-[55] flex items-center justify-center bg-black/40 backdrop-blur-xs"> <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 animate-scale-up font-mono"> <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 */} {/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border"> <div className="flex items-center justify-between pb-4 border-b border-border">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@ -1,8 +1,12 @@
import { useState } from 'react'; 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 { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm'; 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 { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { SessionStatus } from '@/api/generated/model';
import { StatusPill, sessionStatusTone } from './StatusPill'; import { StatusPill, sessionStatusTone } from './StatusPill';
import { mapServerSessionView, sessionStatusLabel } from '../../types'; import { mapServerSessionView, sessionStatusLabel } from '../../types';
@ -11,23 +15,63 @@ type SessionView = ReturnType<typeof mapServerSessionView>;
export function SessionsStatusTab({ export function SessionsStatusTab({
sessionViews, sessionViews,
canNotify, canNotify,
canAward,
winnerSupplierId,
onOpenChat, onOpenChat,
onNotifyAll, onNotifyAll,
onNotifyOne, onNotifyOne,
onAward,
}: { }: {
sessionViews: SessionView[]; sessionViews: SessionView[];
/** 초청 메일 발송 권한(견적 소유자만). false 면 발송 버튼 비활성. */ /** 초청 메일 발송 권한(견적 소유자만). false 면 발송 버튼 비활성. */
canNotify: boolean; canNotify: boolean;
/** 직접 낙찰 권한(개찰 상태 + 본인 견적). true 일 때만 낙찰 선택 UI 노출. */
canAward: boolean;
/** 낙찰 확정된 협력사 id(quotations.preferred_sp_id). 그 행만 초록 배경으로 강조. */
winnerSupplierId: string | null;
onOpenChat: (sessionId: string) => void; onOpenChat: (sessionId: string) => void;
/** 미발송 세션 전체에 초청 메일 발송. */ /** 미발송 세션 전체에 초청 메일 발송. */
onNotifyAll: () => Promise<void>; onNotifyAll: () => Promise<void>;
/** 한 세션(공급사)에 초청 메일 발송/재발송. */ /** 한 세션(공급사)에 초청 메일 발송/재발송. */
onNotifyOne: (sessionId: string) => Promise<void>; onNotifyOne: (sessionId: string) => Promise<void>;
/** 고른 협력사를 낙찰 처리. 성공 시 true. */
onAward: (supplierId: string, supplierName: string) => Promise<boolean>;
}) { }) {
const [sendingAll, setSendingAll] = useState(false); const [sendingAll, setSendingAll] = useState(false);
const [sendingId, setSendingId] = useState<string | null>(null); 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; 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 () => { const handleAll = async () => {
if ( if (
!(await confirm({ !(await confirm({
@ -88,10 +132,30 @@ export function SessionsStatusTab({
</button> </button>
</div> </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"> <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]"> <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"> <TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
<TableRow> <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-3 font-semibold font-sans">협력사</TableHead>
<TableHead className="p-2 font-semibold text-center w-10">URL</TableHead> <TableHead className="p-2 font-semibold text-center w-10">URL</TableHead>
<TableHead className="p-3 font-semibold text-center font-sans">초청메일</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"> <TableBody className="divide-y divide-border">
{sessionViews.length === 0 && ( {sessionViews.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={12} className="p-12 text-center text-muted-foreground"> <TableCell colSpan={colCount} className="p-12 text-center text-muted-foreground">
참여 중인 협상 세션이 없습니다. (리스트가 비어 있습니다) 참여 중인 협상 세션이 없습니다. (리스트가 비어 있습니다)
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
{sessionViews.map((sess) => ( {sessionViews.map((sess) => {
<TableRow key={sess.session_id} className="hover:bg-muted/30 transition-colors text-[11px]"> 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"> <TableCell className="p-3 font-bold text-foreground font-sans">
<div className="flex items-center gap-2"> <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 <button
onClick={() => onOpenChat(sess.session_id)} onClick={() => onOpenChat(sess.session_id)}
title="협상 대화방으로 이동" 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} /> <MessageSquare size={13} />
</button> </button>
@ -148,7 +249,7 @@ export function SessionsStatusTab({
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{sess.email_sent_at ? ( {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} /> 발송됨 <MailCheck size={11} /> 발송됨
</span> </span>
) : ( ) : (
@ -205,7 +306,8 @@ export function SessionsStatusTab({
</TableCell> </TableCell>
<TableCell className="p-3 text-muted-foreground font-sans">{sess.reject_delivery_type || '-'}</TableCell> <TableCell className="p-3 text-muted-foreground font-sans">{sess.reject_delivery_type || '-'}</TableCell>
</TableRow> </TableRow>
))} );
})}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>

View File

@ -1,6 +1,7 @@
import { X, Check } from 'lucide-react'; import { X, Check } from 'lucide-react';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
import { useGetTargetBreakdown } from '@/api/generated/quotation/quotation'; import { useGetTargetBreakdown } from '@/api/generated/quotation/quotation';
import { useScrollLock } from '@/lib/useScrollLock';
// 세션 목표가 산정내역 모달. 후보·채택·앵커링가는 백엔드 /target-breakdown 이 산정한 값을 '표시만' 한다. // 세션 목표가 산정내역 모달. 후보·채택·앵커링가는 백엔드 /target-breakdown 이 산정한 값을 '표시만' 한다.
// (프론트 재계산 없음 → 저장된 목표가와 항상 일치. 산정 로직은 백엔드 _candidates 단일 출처.) // (프론트 재계산 없음 → 저장된 목표가와 항상 일치. 산정 로직은 백엔드 _candidates 단일 출처.)
@ -33,16 +34,17 @@ export function TargetPriceModal({
category, category,
supplierTypeLabel, supplierTypeLabel,
}: TargetPriceModalProps) { }: TargetPriceModalProps) {
useScrollLock(); // 모달은 열릴 때만 마운트(부모 게이트) → 배경 스크롤 잠금
const { data: bd, isLoading } = useGetTargetBreakdown(sessionId, { query: { enabled: !!sessionId } }); const { data: bd, isLoading } = useGetTargetBreakdown(sessionId, { query: { enabled: !!sessionId } });
const candidates = bd?.candidates ?? []; const candidates = bd?.candidates ?? [];
return ( return (
<div <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} onClick={onClose}
> >
<div <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()} onClick={(e) => e.stopPropagation()}
> >
{/* 헤더 */} {/* 헤더 */}

View File

@ -19,6 +19,7 @@ import {
mapSetting, mapSetting,
mapServerSessionView, mapServerSessionView,
mapServerCardView, mapServerCardView,
chainRoundState,
} from '../../types'; } from '../../types';
import { supplierTypeLabel } from '@/lib/enumLabels'; import { supplierTypeLabel } from '@/lib/enumLabels';
import { QuotationStatus } from '@/api/generated/model'; import { QuotationStatus } from '@/api/generated/model';
@ -35,6 +36,8 @@ type DrawerTab = 'status' | 'cards' | 'chat';
type QuotationDetailSheetProps = { type QuotationDetailSheetProps = {
quotation: QuotationData; quotation: QuotationData;
onCloseQuotation: (id: string, name: string) => void; onCloseQuotation: (id: string, name: string) => void;
/** 개찰(낙찰자 미정 마감) 견적을 협상현황 표에서 직접 낙찰. 성공 시 true. */
onAward: (qtId: string, winnerSupplierId: string, winnerName: string) => Promise<boolean>;
/** 라운드 타임라인에서 다른 차수로 전환(같은 견적번호의 다른 견적 상세 열기). */ /** 라운드 타임라인에서 다른 차수로 전환(같은 견적번호의 다른 견적 상세 열기). */
onSwitchRound: (qtId: string) => void; onSwitchRound: (qtId: string) => void;
/** 마감된 견적의 다음 라운드를 수동 생성(공급사 선택). 성공 시 새 qt_id 반환. */ /** 마감된 견적의 다음 라운드를 수동 생성(공급사 선택). 성공 시 새 qt_id 반환. */
@ -49,6 +52,7 @@ type QuotationDetailSheetProps = {
export function QuotationDetailSheet({ export function QuotationDetailSheet({
quotation, quotation,
onCloseQuotation, onCloseQuotation,
onAward,
onSwitchRound, onSwitchRound,
onRegenerate, onRegenerate,
onNotify, onNotify,
@ -70,9 +74,14 @@ export function QuotationDetailSheet({
).map(mapSetting); ).map(mapSetting);
const qtId = quotation.qt_id ?? ''; const qtId = quotation.qt_id ?? '';
// 초청 메일 발송은 견적 소유자만. (백엔드 스코프 도입 전까지의 1차 차단 — 본인 견적 아니면 버튼 비활성) // 소유자 게이팅 — 견적을 바꾸는 액션(초청메일·마감·재생성·낙찰)은 '본인 견적' 또는 최고관리자만.
// 프론트 1차 차단이며, 실제 보안은 백엔드가 동일 스코프로 강제해야 함(버튼 숨김만으론 우회 가능).
const myUserId = useAuthStore((s) => s.user?.userId); 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, { const sessionsQuery = useGetQuotationSessions(qtId, {
@ -93,7 +102,7 @@ export function QuotationDetailSheet({
const { rounds: chainRounds, isLoading: chainLoading } = useQuotationChain(quotation.number); const { rounds: chainRounds, isLoading: chainLoading } = useQuotationChain(quotation.number);
const maxRound = chainRounds.length ? Math.max(...chainRounds.map((r) => r.round)) : (quotation.round ?? 1); const maxRound = chainRounds.length ? Math.max(...chainRounds.map((r) => r.round)) : (quotation.round ?? 1);
const isLatestRound = (quotation.round ?? 1) >= maxRound; 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 [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const effectiveSessionId = selectedSessionId ?? serverSessions[0]?.session_id ?? 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"> <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 (고정) */} {/* Header title bar (고정) — 아래에 상세 그리드(펼침) 또는 결과 요약 밴드(접힘)가 항상 붙는다. */}
<div className={`shrink-0 px-6 pt-6 bg-muted/30 ${showHeaderCards ? 'pb-3' : 'pb-6 border-b border-border'}`}> <div className="shrink-0 px-6 pt-6 pb-3 bg-muted/30">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<div className="flex items-center gap-2 text-muted-foreground text-[10px] font-mono tracking-widest uppercase"> <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> <span>다음 견적 재생성</span>
</button> </button>
)} )}
{/* 마감 버튼은 항상 노출하되, 마감 가능한 상태(생성·진행중·보류)가 아니면 비활성화만 한다. */} {/* 마감 버튼은 항상 노출하되, 본인/최고관리자가 아니거나 마감 가능한 상태가 아니면 비활성화한다. */}
{(() => { {(() => {
const canClose = quotation.status !== QuotationStatus.CLOSED; const alreadyClosed = quotation.status === QuotationStatus.CLOSED;
const canClose = canManage && !alreadyClosed;
return ( return (
<button <button
onClick={() => onCloseQuotation(quotation.qt_id ?? '', q_name)} onClick={() => onCloseQuotation(quotation.qt_id ?? '', q_name)}
disabled={!canClose} 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" 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} /> <CheckCircle2 size={14} />
@ -194,8 +210,8 @@ export function QuotationDetailSheet({
</div> </div>
</div> </div>
{/* 견적 상세 정보 — 탭과 flex 비율(헤더:탭 = 2:1)로 높이를 나눠 가지고 자체 스크롤 */} {/* 견적 상세 정보 — 펼치면 탭과 flex 비율(헤더:탭 = 2:1)로 높이를 나눠 가지고 자체 스크롤 */}
{showHeaderCards && ( {showHeaderCards ? (
<div <div
style={{ flex: '2 1 0%' }} style={{ flex: '2 1 0%' }}
className="min-h-0 overflow-y-auto px-6 pb-6 bg-muted/30 border-b border-border" 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} onShowTarget={setTargetSessionId}
/> />
</div> </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 */} {/* Tabs */}
@ -248,9 +276,12 @@ export function QuotationDetailSheet({
<SessionsStatusTab <SessionsStatusTab
sessionViews={sessionViews} sessionViews={sessionViews}
canNotify={canNotify} canNotify={canNotify}
canAward={canAward}
winnerSupplierId={quotation.preferred_sp_id ?? null}
onOpenChat={goToChat} onOpenChat={goToChat}
onNotifyAll={() => onNotify(qtId)} onNotifyAll={() => onNotify(qtId)}
onNotifyOne={(sessionId) => onNotifySession(sessionId, qtId)} onNotifyOne={(sessionId) => onNotifySession(sessionId, qtId)}
onAward={(supplierId, supplierName) => onAward(qtId, supplierId, supplierName)}
/> />
)} )}

View File

@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Settings, X } from 'lucide-react'; import { Settings, X } from 'lucide-react';
import { useScrollLock } from '@/lib/useScrollLock';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -22,6 +23,7 @@ export function QuotationSettingsModal({
onDelete, onDelete,
onClose, onClose,
}: QuotationSettingsModalProps) { }: QuotationSettingsModalProps) {
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
const [targetMargin, setTargetMargin] = useState(''); const [targetMargin, setTargetMargin] = useState('');
const [cardUseCount, setCardUseCount] = useState(''); const [cardUseCount, setCardUseCount] = useState('');
@ -38,8 +40,8 @@ export function QuotationSettingsModal({
}; };
return ( 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">
<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="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 justify-between pb-4 border-b border-border mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@ -14,6 +14,7 @@ import {
useListQuotations, useListQuotations,
useCreateQuotation, useCreateQuotation,
useStopQuotation, useStopQuotation,
useAwardQuotation,
useRegenerateQuotation, useRegenerateQuotation,
useNotifyQuotation, useNotifyQuotation,
useNotifySession, useNotifySession,
@ -39,7 +40,6 @@ export type CreateQuotationInput = {
cardIds: string[]; cardIds: string[];
memo: string; memo: string;
mdPrice?: number | null; // MD 제시가(원). 비우면 미전송 → 서버가 기존 마진식으로 목표가 산정 mdPrice?: number | null; // MD 제시가(원). 비우면 미전송 → 서버가 기존 마진식으로 목표가 산정
supplierType?: number | null; // 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록
// 낙찰 기준 — 1:1 협상만 전송(경매는 미전송 → 서버가 mid=over=AWARD 강제). 2전략을 mid/over 로 전개해 담는다(over 항상 OPEN). // 낙찰 기준 — 1:1 협상만 전송(경매는 미전송 → 서버가 mid=over=AWARD 강제). 2전략을 mid/over 로 전개해 담는다(over 항상 OPEN).
midAction?: number; // PriceGateAction (앵커~목표가 처리: 낙찰/개찰) midAction?: number; // PriceGateAction (앵커~목표가 처리: 낙찰/개찰)
overAction?: number; // PriceGateAction (목표가 초과 처리: 협상은 항상 개찰) overAction?: number; // PriceGateAction (목표가 초과 처리: 협상은 항상 개찰)
@ -65,6 +65,7 @@ export function useQuotations(params: ListQuotationsParams) {
const deleteSettingMutation = useDeleteSetting(); const deleteSettingMutation = useDeleteSetting();
const createQuotationMutation = useCreateQuotation(); const createQuotationMutation = useCreateQuotation();
const stopQuotationMutation = useStopQuotation(); const stopQuotationMutation = useStopQuotation();
const awardQuotationMutation = useAwardQuotation();
const regenerateQuotationMutation = useRegenerateQuotation(); const regenerateQuotationMutation = useRegenerateQuotation();
const notifyQuotationMutation = useNotifyQuotation(); const notifyQuotationMutation = useNotifyQuotation();
const notifySessionMutation = useNotifySession(); 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 = () => const invalidateSettings = () =>
queryClient.invalidateQueries({ queryKey: getListSettingsQueryKey() }); queryClient.invalidateQueries({ queryKey: getListSettingsQueryKey() });
@ -194,7 +218,6 @@ export function useQuotations(params: ListQuotationsParams) {
manager_contact_number: me?.contact || undefined, manager_contact_number: me?.contact || undefined,
memo: input.memo.trim() || undefined, memo: input.memo.trim() || undefined,
md_price: input.mdPrice && input.mdPrice > 0 ? input.mdPrice : undefined, md_price: input.mdPrice && input.mdPrice > 0 ? input.mdPrice : undefined,
supplier_type: input.supplierType ?? undefined,
// 낙찰 기준은 1:1 협상만 전송(모달이 미리 걸러 담음) — 경매면 미전송 → 서버가 AWARD 강제. // 낙찰 기준은 1:1 협상만 전송(모달이 미리 걸러 담음) — 경매면 미전송 → 서버가 AWARD 강제.
mid_action: input.midAction ?? undefined, mid_action: input.midAction ?? undefined,
over_action: input.overAction ?? undefined, over_action: input.overAction ?? undefined,
@ -295,6 +318,7 @@ export function useQuotations(params: ListQuotationsParams) {
total, total,
quotationSettings, quotationSettings,
closeQuotation, closeQuotation,
awardQuotation,
addSetting, addSetting,
deleteSetting, deleteSetting,
createQuotation, createQuotation,

View 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)}`;
}

View File

@ -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 { useOverlayRouter } from '@/lib/useOverlayRouter';
import { showToast } from '@/lib/notify'; import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm'; import { confirm } from '@/lib/confirm';
import { PageContainer } from '@/components/layout/PageContainer'; import { PageContainer } from '@/components/layout/PageContainer';
import { SearchInput } from '@/components/layout/PageToolbar'; 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 { TablePagination } from '@/components/ui/table-pagination';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
import { useServerList } from '@/lib/useServerList'; import { useServerList } from '@/lib/useServerList';
@ -11,6 +13,7 @@ import { useCards } from '@/features/cards/hooks/useCards';
import { useGetCard } from '@/api/generated/card/card'; import { useGetCard } from '@/api/generated/card/card';
import { CardTable } from '@/features/cards/components/CardTable'; import { CardTable } from '@/features/cards/components/CardTable';
import { CardFormSheet } from '@/features/cards/components/CardFormSheet'; 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 { mapCardData, type CardTab, type NegotiationCard } from '@/features/cards/types';
import type { ListCardsParams } from '@/api/generated/model/listCardsParams'; import type { ListCardsParams } from '@/api/generated/model/listCardsParams';
@ -24,13 +27,14 @@ export default function CardsPage() {
page: list.page, page: list.page,
size: list.pageSize, 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); const totalPages = list.totalPages(total);
// 오버레이(폼)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원. // 오버레이(폼/엑셀)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
// ?detail=<id> 직접 접근 시 단건 API 로 받아 수정 폼을 연다(현재 페이지에 없어도 동작). // ?detail=<id> 직접 접근 시 단건 API 로 받아 수정 폼을 연다(현재 페이지에 없어도 동작).
const overlay = useOverlayRouter(['new', 'detail']); const overlay = useOverlayRouter(['new', 'detail', 'modal']);
const editId = overlay.get('detail'); const editId = overlay.get('detail');
const modal = overlay.get('modal'); // 'excel' | null
const editQuery = useGetCard(editId ?? '', { query: { enabled: !!editId } }); const editQuery = useGetCard(editId ?? '', { query: { enabled: !!editId } });
const editing: NegotiationCard | null = editQuery.data?.card ? mapCardData(editQuery.data.card) : null; const editing: NegotiationCard | null = editQuery.data?.card ? mapCardData(editQuery.data.card) : null;
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create'; const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
@ -65,19 +69,39 @@ export default function CardsPage() {
<div> <div>
<Typography variant="h3" className="text-xs">협상카드 및 와일드카드 관리</Typography> <Typography variant="h3" className="text-xs">협상카드 및 와일드카드 관리</Typography>
<Typography variant="muted" className="text-[10px] mt-0.5"> <Typography variant="muted" className="text-[10px] mt-0.5">
본인이 등록한 카드만 조회·관리할 수 있습니다. 본인 카드와 전체(공용) 카드를 함께 조회·관리합니다. 전체 카드는 모든 사용자에게 공유됩니다.
</Typography> </Typography>
</div> </div>
</div> </div>
<button <div className="flex items-center gap-2">
id="card-register-btn" <DropdownMenu>
onClick={openCreate} <DropdownMenuTrigger render={<Button variant="outline" />}>
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" <FileSpreadsheet />
> 엑셀업로드
<Plus size={15} /> <ChevronDown />
<span>신규 카드 등록</span> </DropdownMenuTrigger>
</button> <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> </div>
{/* Primary Navigation Tab */} {/* Primary Navigation Tab */}
@ -137,6 +161,14 @@ export default function CardsPage() {
onClose={overlay.close} onClose={overlay.close}
/> />
)} )}
{modal === 'excel' && (
<CardExcelUploadModal
open
onConfirm={bulkCreate}
onClose={overlay.close}
/>
)}
</PageContainer> </PageContainer>
); );
} }

View File

@ -164,8 +164,9 @@ function render(n: NotificationData): { icon: ReactNode; tone: string; event: st
case NotificationType.CREATED: case NotificationType.CREATED:
return { icon: <FilePlus2 size={18} />, tone: 'text-sky-600', event: '견적 생성', line: name, number }; return { icon: <FilePlus2 size={18} />, tone: 'text-sky-600', event: '견적 생성', line: name, number };
case NotificationType.SUCCESS: case NotificationType.SUCCESS:
// 자동 낙찰과 담당자 직접 낙찰(data.manual)은 같은 SUCCESS — 문구로만 '직접'을 구분한다.
return { 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()}원`, line: `${name} — ${d.winner_name ?? '-'} ${Number(d.winner_price ?? 0).toLocaleString()}원`,
number, number,
}; };

View File

@ -40,6 +40,7 @@ export default function QuotationPage() {
total, total,
quotationSettings, quotationSettings,
closeQuotation, closeQuotation,
awardQuotation,
addSetting, addSetting,
deleteSetting, deleteSetting,
createQuotation, createQuotation,
@ -178,6 +179,7 @@ export default function QuotationPage() {
key={activeQuotation.qt_id} key={activeQuotation.qt_id}
quotation={activeQuotation} quotation={activeQuotation}
onCloseQuotation={closeQuotation} onCloseQuotation={closeQuotation}
onAward={awardQuotation}
onSwitchRound={(qtId) => overlay.open('detail', qtId, { replace: true })} onSwitchRound={(qtId) => overlay.open('detail', qtId, { replace: true })}
onRegenerate={regenerateQuotation} onRegenerate={regenerateQuotation}
onNotify={notifyQuotation} onNotify={notifyQuotation}