[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 common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import users
from common.enums import ErrorType
from common.logger import LOG
from common.utils.gtime import GTime
@ -32,13 +33,21 @@ class ICardCRUD(ABC):
async def soft_delete(self, cdb: AsyncSession, model, pk_col, card_id) -> ErrorType:
pass
@abstractmethod
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
pass
class CardCRUD(ICardCRUD):
async def search(
self, cdb: AsyncSession, model, user_id, search: Optional[str], skip: int, limit: int
) -> Tuple[ErrorType, list, int]:
try:
conditions = [model.deleted == False, model.user_id == user_id] # noqa: E712
# 내 개인 카드 + 전체(공용, user_id NULL) 카드. 남의 개인 카드는 제외.
conditions = [
model.deleted == False, # noqa: E712
or_(model.user_id == user_id, model.user_id.is_(None)),
]
if search:
conditions.append(
or_(
@ -102,3 +111,18 @@ class CardCRUD(ICardCRUD):
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
"""user_id 목록 → {user_id: name}. 카드 목록 '작성자(등록자)' 표기용(company.users 조인).
공용(user_id NULL) 카드는 호출 전에 걸러 넘긴다 맵에 없으면 작성자 없음."""
try:
if not user_ids:
return ErrorType.SUCCESS, {}
query = select(users.user_id, users.name).where(users.user_id.in_(user_ids))
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, {}
return ErrorType.SUCCESS, {uid: name for uid, name in rows}
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, {}

View File

@ -5,7 +5,7 @@ from sqlalchemy import select, func, and_, or_, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import items
from common.database.model.models import items, users
from common.enums import ErrorType
from common.logger import LOG
from common.utils.gtime import GTime
@ -45,6 +45,10 @@ class IItemCRUD(ABC):
async def soft_delete(self, cdb: AsyncSession, item_id) -> ErrorType:
pass
@abstractmethod
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
pass
class ItemCRUD(IItemCRUD):
async def search(
@ -166,3 +170,17 @@ class ItemCRUD(IItemCRUD):
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
"""user_id 목록 → {user_id: name}. 상품 목록/상세 '등록자(작성자)' 표기용(company.users 조인)."""
try:
if not user_ids:
return ErrorType.SUCCESS, {}
query = select(users.user_id, users.name).where(users.user_id.in_(user_ids))
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, {}
return ErrorType.SUCCESS, {uid: name for uid, name in rows}
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, {}

View File

@ -10,7 +10,7 @@ from common.database.model.models import (
quotations, sessions, chats, nego_cards, wild_cards, items, suppliers, quotation_settings,
version_nego_cards, version_wild_cards, users,
)
from common.enums import ErrorType, QuotationStatus, SessionStatus
from common.enums import CloseReason, ErrorType, QuotationStatus, SessionStatus
from common.logger import LOG
from common.utils.gtime import GTime
@ -148,6 +148,10 @@ class IQuotationCRUD(ABC):
async def claim_for_close(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, int]:
pass
@abstractmethod
async def claim_for_award(self, cdb: AsyncSession, qt_id, supplier_id, supplier_name) -> Tuple[ErrorType, int]:
pass
class QuotationCRUD(IQuotationCRUD):
async def search(
@ -474,6 +478,36 @@ class QuotationCRUD(IQuotationCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def claim_for_award(self, cdb: AsyncSession, qt_id, supplier_id, supplier_name) -> Tuple[ErrorType, int]:
"""[동시 직접낙찰 가드] 개찰(마감·낙찰자 미정, close_reason ∈ OPEN_*)인 견적만 낙찰(AWARDED)로 선점 전이.
선택 협력사를 낙찰자(preferred_sp_*) 박고 동가 플래그는 내린다. status 이미 CLOSED 유지.
반환: (ErrorType, 적용행수). 이미 낙찰됐거나(재클릭) 개찰이 아니면 0 서비스가 번만 통과시킨다."""
try:
query = (
update(quotations)
.where(
quotations.qt_id == qt_id,
quotations.status == QuotationStatus.CLOSED.value,
quotations.close_reason.in_(
[CloseReason.OPEN_PRICE.value, CloseReason.OPEN_EQUAL.value,
CloseReason.OPEN_NOSHOW.value, CloseReason.OPEN_REJECT.value]
),
quotations.deleted == False, # noqa: E712
)
.values(
close_reason=CloseReason.AWARDED.value,
preferred_sp_yn=True,
preferred_sp_id=supplier_id,
preferred_sp_name=supplier_name,
equal_bid_yn=False,
updated_at=GTime.UTC(),
)
)
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def update_sessions_status(self, cdb: AsyncSession, qt_id, from_statuses: list[int], to_status: int) -> ErrorType:
# 견적에 딸린 세션 중 from_statuses 에 속한 것만 to_status 로 일괄 전이(삭제 제외). 다른 상태는 건드리지 않는다.
try:

View File

@ -5,7 +5,7 @@ from sqlalchemy import select, func, and_, or_, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import suppliers
from common.database.model.models import suppliers, users
from common.enums import ErrorType
from common.logger import LOG
from common.utils.gtime import GTime
@ -41,6 +41,10 @@ class ISupplierCRUD(ABC):
async def soft_delete(self, cdb: AsyncSession, supplier_id) -> ErrorType:
pass
@abstractmethod
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
pass
class SupplierCRUD(ISupplierCRUD):
async def search(
@ -144,3 +148,17 @@ class SupplierCRUD(ISupplierCRUD):
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
"""user_id 목록 → {user_id: name}. 협력사 목록/상세 '등록자(작성자)' 표기용(company.users 조인)."""
try:
if not user_ids:
return ErrorType.SUCCESS, {}
query = select(users.user_id, users.name).where(users.user_id.in_(user_ids))
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, {}
return ErrorType.SUCCESS, {uid: name for uid, name in rows}
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, {}

View File

@ -14,6 +14,7 @@ class CardProtocol(WebPacketProtocol):
class Req_CreateCard(CardProtocol):
is_wildcard: bool = False
is_shared: bool = False # True=전체(공용, user_id NULL 저장) / False=개인(등록 유저 소유)
name: Optional[str] = None
number: Optional[str] = None
script: Optional[str] = None
@ -42,6 +43,8 @@ class CardData(WebPacketProtocol):
nego_card_id: uuid.UUID # 통합 식별자(일반=nego_card_id / 와일드=wild_card_id)
user_id: Optional[uuid.UUID] = None
is_wildcard: bool = False
is_shared: bool = False # 전체(공용) 카드 여부 = user_id NULL. 목록 배지/폼 스코프 표시용
creator_name: Optional[str] = None # 작성자(등록자) 이름. user_id→company.users.name 조인. 공용 카드는 None
name: Optional[str] = None
number: Optional[str] = None
script: Optional[str] = None

View File

@ -69,12 +69,12 @@ async def get_item(item_id: UUID, service: ItemService = Depends(), user_info: U
async def update_item(
item_id: UUID, req: Req_UpdateItem, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.update_item(user_info.company_id, str(item_id), req))
return RemoveNoneResponse(await service.update_item(user_info.company_id, str(item_id), req, user_info.user_id, user_info.role))
@router.delete(path="/delete/{item_id}", response_model=Res_DeleteItem, summary="상품 삭제")
async def delete_item(item_id: UUID, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.delete_item(user_info.company_id, str(item_id)))
return RemoveNoneResponse(await service.delete_item(user_info.company_id, str(item_id), user_info.user_id, user_info.role))
@router.post(path="/{item_id}/lowest-price", response_model=Res_LowestPriceTrigger, summary="최저가 수집 요청(스텁)")

View File

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

View File

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

View File

@ -7,6 +7,7 @@ from common.models.gmodel import PageParams, UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
from services.quotation_service import QuotationService
from .protocol import (
Req_AwardQuotation,
Req_CreateQuotation,
Req_RegenerateQuotation,
Res_CreateQuotation,
@ -55,14 +56,24 @@ async def create_quotation(
@router.post(path="/stop/{qt_id}", response_model=Res_Quotation, summary="견적 마감")
async def stop_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.stop_quotation(str(qt_id), user_info.company_id))
return RemoveNoneResponse(await service.stop_quotation(str(qt_id), user_info.company_id, user_info.user_id, user_info.role))
@router.post(path="/award/{qt_id}", response_model=Res_Quotation, summary="개찰 견적 직접 낙찰")
async def award_quotation(
qt_id: UUID, req: Req_AwardQuotation, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
# 권한(본인 견적만/OWNER 예외)은 서비스에서 판정하도록 호출자 user_id·role 을 넘긴다.
return RemoveNoneResponse(
await service.award_quotation(str(qt_id), user_info.company_id, user_info.user_id, user_info.role, req.winner_supplier_id)
)
@router.post(path="/regenerate/{qt_id}", response_model=Res_CreateQuotation, summary="견적 재생성(다음 라운드)")
async def regenerate_quotation(
qt_id: UUID, req: Req_RegenerateQuotation, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.regenerate_quotation(str(qt_id), user_info.company_id, req.supplier_ids))
return RemoveNoneResponse(await service.regenerate_quotation(str(qt_id), user_info.company_id, req.supplier_ids, user_info.user_id, user_info.role))
# ----- 견적 상세 (FK로 연결된 하위 데이터 / 일부는 모델 미존재로 스텁) -----
@ -78,7 +89,7 @@ async def get_quotation_sessions(qt_id: UUID, service: QuotationService = Depend
@router.post(path="/{qt_id}/notify", response_model=Res_NotifySessions, summary="협상 초청 메일 발송")
async def notify_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.notify_sessions(str(qt_id), user_info.company_id))
return RemoveNoneResponse(await service.notify_sessions(str(qt_id), user_info.company_id, user_info.user_id, user_info.role))
@router.get(path="/session/{session_id}/chat", response_model=Res_SessionChat, summary="채팅 상세")
@ -93,7 +104,7 @@ async def get_target_breakdown(session_id: UUID, service: QuotationService = Dep
@router.post(path="/session/{session_id}/notify", response_model=Res_NotifySessions, summary="세션 초청 메일 재발송")
async def notify_session(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.notify_session(str(session_id), user_info.company_id))
return RemoveNoneResponse(await service.notify_session(str(session_id), user_info.company_id, user_info.user_id, user_info.role))
@router.get(path="/{qt_id}/result", response_model=Res_QuotationResult, summary="낙찰 결과")
@ -108,7 +119,7 @@ async def get_quotation_cards(qt_id: UUID, service: QuotationService = Depends()
@router.delete(path="/delete/{qt_id}", response_model=Res_DeleteQuotation, summary="견적 삭제")
async def delete_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.delete_quotation(str(qt_id), user_info.company_id))
return RemoveNoneResponse(await service.delete_quotation(str(qt_id), user_info.company_id, user_info.user_id, user_info.role))
@router.get(path="/supplier/{supplier_id}/last-type", response_model=Res_LastSupplierType, summary="협력사 직전 견적 유형")

View File

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

View File

@ -3,7 +3,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends, Query
from common.models.gmodel import PageParams, UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, RequireOwner
from services.supplier_service import SupplierService
from .protocol import (
Req_CheckCodes,
@ -59,6 +59,7 @@ async def update_supplier(
)
@router.delete(path="/delete/{supplier_id}", response_model=Res_DeleteSupplier, summary="협력사 삭제")
async def delete_supplier(supplier_id: UUID, service: SupplierService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
@router.delete(path="/delete/{supplier_id}", response_model=Res_DeleteSupplier, summary="협력사 삭제 (최고관리자 전용)")
async def delete_supplier(supplier_id: UUID, service: SupplierService = Depends(), user_info: UserInfo = Depends(RequireOwner)):
# 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(RequireOwner 가 비-OWNER 를 403 차단).
return RemoveNoneResponse(await service.delete_supplier(user_info.company_id, str(supplier_id)))

View File

@ -25,6 +25,7 @@ class CardService:
nego_card_id=row.nego_card_id,
user_id=row.user_id,
is_wildcard=False,
is_shared=row.user_id is None,
name=row.name,
number=row.number,
script=row.script,
@ -42,6 +43,7 @@ class CardService:
nego_card_id=row.wild_card_id,
user_id=row.user_id,
is_wildcard=True,
is_shared=row.user_id is None,
name=row.name,
number=row.number,
script=row.script,
@ -56,7 +58,8 @@ class CardService:
# ---- 소유 카드 탐색(어느 테이블인지 모를 때) ------------------------------
async def _find_owned(self, user_uuid: uuid.UUID, card_id: uuid.UUID):
"""card_id 를 nego_cards → wild_cards 순으로 찾고 소유권 확인.
"""card_id 를 nego_cards → wild_cards 순으로 찾고 접근권 확인.
전체(공용, user_id NULL) 카드는 누구나 조회·수정·삭제 가능. 개인 카드는 소유자만.
(ErrorType, model, pk_col, row, is_wildcard) 반환."""
err, row = await DB_SESSION_MNG.execute_lambda(
nego_cards.DBType(),
@ -64,7 +67,7 @@ class CardService:
lambda s: self.card_crud.get_by_id(s, nego_cards, nego_cards.nego_card_id, card_id),
)
if err == ErrorType.SUCCESS and row is not None:
if row.user_id != user_uuid:
if row.user_id is not None and row.user_id != user_uuid:
return ErrorType.CARD_NOT_FOUND, None, None, None, False
return ErrorType.SUCCESS, nego_cards, nego_cards.nego_card_id, row, False
@ -74,7 +77,7 @@ class CardService:
lambda s: self.card_crud.get_by_id(s, wild_cards, wild_cards.wild_card_id, card_id),
)
if err == ErrorType.SUCCESS and row is not None:
if row.user_id != user_uuid:
if row.user_id is not None and row.user_id != user_uuid:
return ErrorType.CARD_NOT_FOUND, None, None, None, True
return ErrorType.SUCCESS, wild_cards, wild_cards.wild_card_id, row, True
@ -114,7 +117,21 @@ class CardService:
merged = [self._nego_to_data(r) for r in nego_rows] + [self._wild_to_data(r) for r in wild_rows]
merged.sort(key=lambda c: c.created_at or "", reverse=True)
res.cards = merged[pg.skip : pg.skip + pg.size]
page = merged[pg.skip : pg.skip + pg.size]
# 작성자명 배치 조인 — 페이지 카드의 작성자 id를 모아 IN 쿼리 1회로 {id:name} 맵을 만들어 매핑.
# (공용 카드는 user_id=NULL → 맵에 없어 creator_name=None). 행마다 조회하지 않으므로 부하 없음.
author_ids = list({c.user_id for c in page if c.user_id is not None})
if author_ids:
nm_err, name_map = await DB_SESSION_MNG.execute_lambda(
nego_cards.DBType(),
DBWRType.DB_READ.value,
lambda s: self.card_crud.user_name_map(s, author_ids),
)
if nm_err == ErrorType.SUCCESS:
for c in page:
c.creator_name = name_map.get(c.user_id)
res.cards = page
res.total_nego = total_n
res.total_wild = total_w
# 선택된 탭 기준 페이지네이션 총건수(전체=합산).
@ -134,6 +151,14 @@ class CardService:
res.result.SetResult(err)
return res
res.card = self._wild_to_data(row) if is_wild else self._nego_to_data(row)
# 등록자명 — 공용(user_id NULL) 카드는 작성자 없음(None 유지).
if row.user_id is not None:
nm_err, name_map = await DB_SESSION_MNG.execute_lambda(
nego_cards.DBType(), DBWRType.DB_READ.value,
lambda s: self.card_crud.user_name_map(s, [row.user_id]),
)
if nm_err == ErrorType.SUCCESS:
res.card.creator_name = name_map.get(row.user_id)
return res
# ---- 등록 ----------------------------------------------------------------
@ -141,9 +166,11 @@ class CardService:
res = Res_Card()
user_uuid = uuid.UUID(user_id)
is_wildcard = req.is_wildcard
# 전체(공용) 카드는 소유자 없이 저장(user_id NULL) → 모든 유저 목록에 노출.
owner_id = None if req.is_shared else user_uuid
common = dict(
user_id=user_uuid,
user_id=owner_id,
name=req.name,
number=req.number,
script=req.script,

View File

@ -2,6 +2,7 @@ import uuid
from fastapi import Depends, UploadFile
from common.authz import is_owner_or_admin
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import items
from common.enums import DBWRType, ErrorType
@ -56,6 +57,16 @@ class ItemService:
res.result.SetResult(err_type)
return res
res.items = [ItemData.model_validate(r) for r in rows]
# 등록자명 배치 조인 — 페이지 상품의 user_id를 모아 IN 쿼리 1회로 {id:name} 맵을 만들어 매핑(행별 조회 아님).
author_ids = list({r.user_id for r in rows if r.user_id is not None})
if author_ids:
nm_err, name_map = await DB_SESSION_MNG.execute_lambda(
items.DBType(), DBWRType.DB_READ.value,
lambda s: self.item_crud.user_name_map(s, author_ids),
)
if nm_err == ErrorType.SUCCESS:
for d in res.items:
d.creator_name = name_map.get(d.user_id)
res.total = total
return res
@ -81,6 +92,13 @@ class ItemService:
res.result.SetResult(err_type)
return res
res.item = ItemData.model_validate(item)
if item.user_id is not None:
nm_err, name_map = await DB_SESSION_MNG.execute_lambda(
items.DBType(), DBWRType.DB_READ.value,
lambda s: self.item_crud.user_name_map(s, [item.user_id]),
)
if nm_err == ErrorType.SUCCESS:
res.item.creator_name = name_map.get(item.user_id)
return res
async def check_codes(self, company_id: str, codes: list) -> Res_CheckCodes:
@ -128,17 +146,22 @@ class ItemService:
# 서버 기본값(created_at/updated_at)은 insert 후 Python 객체에 실리지 않으므로 재조회한다.
return await self.get_item(company_id, str(item.item_id))
async def update_item(self, company_id: str, item_id: str, req: Req_UpdateItem) -> Res_Item:
async def update_item(self, company_id: str, item_id: str, req: Req_UpdateItem, user_id=None, role=None) -> Res_Item:
res = Res_Item()
company_uuid = uuid.UUID(company_id)
item_uuid = uuid.UUID(item_id)
data = req.model_dump(exclude_unset=True)
# 소유권 확인
err_type, _ = await self._fetch_owned(company_uuid, item_uuid)
if err_type != ErrorType.SUCCESS:
# 회사 스코프 확인
err_type, item = await self._fetch_owned(company_uuid, item_uuid)
if err_type != ErrorType.SUCCESS or item is None:
res.result.SetResult(err_type)
return res
# 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정(user_id 미지정=내부 호출은 스킵).
if user_id is not None and not is_owner_or_admin(item.user_id, user_id, role):
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
res.msg = "본인이 등록한 상품만 수정할 수 있습니다."
return res
err_type = await DB_SESSION_MNG.execute_lambda_run(
[items.DBType()],
@ -151,15 +174,20 @@ class ItemService:
# 갱신 후 재조회
return await self.get_item(company_id, item_id)
async def delete_item(self, company_id: str, item_id: str) -> Res_DeleteItem:
async def delete_item(self, company_id: str, item_id: str, user_id=None, role=None) -> Res_DeleteItem:
res = Res_DeleteItem()
company_uuid = uuid.UUID(company_id)
item_uuid = uuid.UUID(item_id)
err_type, _ = await self._fetch_owned(company_uuid, item_uuid)
if err_type != ErrorType.SUCCESS:
err_type, item = await self._fetch_owned(company_uuid, item_uuid)
if err_type != ErrorType.SUCCESS or item is None:
res.result.SetResult(err_type)
return res
# 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 삭제(user_id 미지정=내부 호출은 스킵).
if user_id is not None and not is_owner_or_admin(item.user_id, user_id, role):
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
res.msg = "본인이 등록한 상품만 삭제할 수 있습니다."
return res
err_type = await DB_SESSION_MNG.execute_lambda_run(
[items.DBType()],

View File

@ -12,6 +12,7 @@ from common.anchoring import (
fetch_current_values,
get_base_anchoring_value,
)
from common.authz import is_owner_or_admin
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations, sessions, chats, versions, version_nego_cards, version_wild_cards
from common.enums import CloseOutcome, CloseReason, DBWRType, ErrorType, NotificationType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus
@ -678,7 +679,7 @@ class QuotationService:
# 4) 전원 미응찰 → 개찰(미응찰).
return await self._open(qt_uuid, original, CloseReason.OPEN_NOSHOW.value, "no_show")
async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list) -> Res_CreateQuotation:
async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list, user_id=None, role=None) -> Res_CreateQuotation:
"""[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다.
크론/수동마감의 자동 재생성과 달리 사유·체인 한도 판정 없이, 프론트가 고른 공급사로 바로 만든다.
상품·기간·견적번호·카드버전은 견적에서 이어받는다(regenerate_next_round)."""
@ -689,6 +690,11 @@ class QuotationService:
if err_type != ErrorType.SUCCESS or original is None:
res.result.SetResult(err_type)
return res
# 소유자 게이팅 — 본인 견적 또는 최고관리자만 재생성(user_id 미지정=내부 호출은 스킵).
if user_id is not None and not is_owner_or_admin(original.user_id, user_id, role):
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
res.msg = "본인이 생성한 견적만 재생성할 수 있습니다."
return res
# 마감된 견적만 재생성(진행 중인 라운드를 또 찍어 같은 번호가 동시에 살아있는 걸 막는다).
if original.status != QuotationStatus.CLOSED.value:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
@ -711,29 +717,106 @@ class QuotationService:
return await self.regenerate_next_round(qt_uuid, supplier_ids)
async def stop_quotation(self, qt_id: str, company_id=None) -> Res_Quotation:
async def stop_quotation(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_Quotation:
"""[프론트] 수동 견적마감. 크론과 똑같은 마감 판정(close_and_decide)을 탄다
(단독낙찰 확정 / 동가·미참여면 다음 라운드 재생성 / 거부·한도면 그냥 마감)."""
res = Res_Quotation()
qt_uuid = uuid.UUID(qt_id)
# 존재 확인(+회사 가드)
err_type, _ = await self._fetch(qt_uuid, company_id)
if err_type != ErrorType.SUCCESS:
err_type, original = await self._fetch(qt_uuid, company_id)
if err_type != ErrorType.SUCCESS or original is None:
res.result.SetResult(err_type)
return res
# 소유자 게이팅 — 본인 견적 또는 최고관리자만 마감(user_id 미지정=내부 호출은 스킵).
if user_id is not None and not is_owner_or_admin(original.user_id, user_id, role):
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
res.msg = "본인이 생성한 견적만 마감할 수 있습니다."
return res
await self.close_and_decide(qt_uuid)
return await self.get_quotation(qt_id, company_id)
async def delete_quotation(self, qt_id: str, company_id=None) -> Res_DeleteQuotation:
async def award_quotation(self, qt_id: str, company_id, user_id, role, winner_supplier_id) -> Res_Quotation:
"""[프론트] 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리한다.
투찰한 협상완료(DONE) 세션 고른 협력사를 낙찰자로 박고 close_reason AWARDED 바꾼다(직접 낙찰).
자동 낙찰(close_and_decide) 결과 컬럼은 같되, 알림에 manual 플래그로 '직접 낙찰'임을 남긴다.
권한: 본인이 생성한 견적만. 최고관리자(OWNER) 회사 남의 견적도 낙찰할 있다."""
res = Res_Quotation()
qt_uuid = uuid.UUID(qt_id)
sp_uuid = winner_supplier_id if isinstance(winner_supplier_id, uuid.UUID) else uuid.UUID(str(winner_supplier_id))
# 존재 확인(+회사 가드) — 남의 회사 견적은 NOT_FOUND.
err_type, original = await self._fetch(qt_uuid, company_id)
if err_type != ErrorType.SUCCESS or original is None:
res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND)
return res
# 자기 견적만 직접 낙찰 — 최고관리자(OWNER)만 회사 내 남의 견적도 허용. 되돌릴 수 없는 낙찰이라 백엔드에서 강제한다.
if not is_owner_or_admin(original.user_id, user_id, role):
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
res.msg = "본인이 생성한 견적만 낙찰할 수 있습니다."
return res
# 개찰(마감·낙찰자 미정, close_reason ∈ OPEN_*)만 직접 낙찰 대상. 진행중/이미 낙찰은 거부.
if original.status != QuotationStatus.CLOSED.value or original.close_reason not in (
CloseReason.OPEN_PRICE.value, CloseReason.OPEN_EQUAL.value,
CloseReason.OPEN_NOSHOW.value, CloseReason.OPEN_REJECT.value,
):
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = "개찰(낙찰자 미정) 상태의 견적만 직접 낙찰할 수 있습니다."
return res
# 낙찰 후보 = 투찰한 협상완료(DONE) 세션. close_and_decide 와 같은 조회(list_sessions_status,
# 공급사 삭제돼도 포함되는 outerjoin)를 써서 자동낙찰과 후보 집합을 일치시킨다.
err_type, rows = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.list_sessions_status(s, qt_uuid),
)
rows = rows if err_type == ErrorType.SUCCESS else []
winner = next(
(r for r in rows
if r.status == SessionStatus.DONE.value and r.bid_price is not None and r.supplier_id == sp_uuid),
None,
)
if winner is None:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = "선택한 협력사는 이 견적의 낙찰 후보(투찰한 협상완료 협력사)가 아닙니다."
return res
# [동시 직접낙찰 가드] 개찰→낙찰 원자 선점. 실제로 전이한 호출자만 통과(재클릭·경합 방어).
claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim(
quotations.DBType(),
lambda s: self.quotation_crud.claim_for_award(s, qt_uuid, sp_uuid, (winner.name or "")[:20]),
)
if claim_err != ErrorType.SUCCESS or claimed == 0:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = "이미 낙찰 처리된 견적입니다."
return res
# 작성자 알림 — 자동낙찰과 같은 SUCCESS 코드, manual 플래그로 '직접 낙찰' 구분.
await create_notification(
original.user_id, NotificationType.SUCCESS,
{"qt_name": original.name, "qt_number": original.number,
"winner_name": winner.name, "winner_price": winner.bid_price, "manual": True},
ref_qt_id=qt_uuid,
)
return await self.get_quotation(qt_id, company_id)
async def delete_quotation(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_DeleteQuotation:
res = Res_DeleteQuotation()
qt_uuid = uuid.UUID(qt_id)
err_type, _ = await self._fetch(qt_uuid, company_id)
if err_type != ErrorType.SUCCESS:
err_type, original = await self._fetch(qt_uuid, company_id)
if err_type != ErrorType.SUCCESS or original is None:
res.result.SetResult(err_type)
return res
# 소유자 게이팅 — 본인 견적 또는 최고관리자만 삭제(user_id 미지정=내부 호출은 스킵).
if user_id is not None and not is_owner_or_admin(original.user_id, user_id, role):
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
res.msg = "본인이 생성한 견적만 삭제할 수 있습니다."
return res
err_type = await DB_SESSION_MNG.execute_lambda_run(
[quotations.DBType()],
@ -815,15 +898,20 @@ class QuotationService:
return res
# ----- 협상 초청 메일 (수동 발송)
async def notify_sessions(self, qt_id: str, company_id=None) -> Res_NotifySessions:
async def notify_sessions(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_NotifySessions:
"""[수동 발송] 견적의 '미발송' 세션(공급사 담당자)에게 협상 초청 메일을 일괄 발송한다.
대상 = email_sent_at IS NULL + 담당자 이메일 보유."""
res = Res_NotifySessions()
qt_uuid = uuid.UUID(qt_id)
err_type, quotation = await self._fetch(qt_uuid, company_id)
if err_type != ErrorType.SUCCESS:
if err_type != ErrorType.SUCCESS or quotation is None:
res.result.SetResult(err_type)
return res
# 소유자 게이팅 — 본인 견적 또는 최고관리자만 초청메일 발송(user_id 미지정=내부 호출은 스킵).
if user_id is not None and not is_owner_or_admin(quotation.user_id, user_id, role):
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
res.msg = "본인이 생성한 견적만 초청 메일을 발송할 수 있습니다."
return res
err_type, rows = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(),
@ -851,7 +939,7 @@ class QuotationService:
await self._mark_emailed(sent_ids)
return res
async def notify_session(self, session_id: str, company_id=None) -> Res_NotifySessions:
async def notify_session(self, session_id: str, company_id=None, user_id=None, role=None) -> Res_NotifySessions:
"""[수동 재발송] 단일 세션(공급사)에 초청 메일 발송(이미 보냈어도 강제 재발송)."""
res = Res_NotifySessions()
sess_uuid = uuid.UUID(session_id)
@ -866,9 +954,14 @@ class QuotationService:
sess, sp_name, email = got[0], got[1], got[2]
res.total = 1
err_type, quotation = await self._fetch(sess.quotation_id, company_id)
if err_type != ErrorType.SUCCESS:
if err_type != ErrorType.SUCCESS or quotation is None:
res.result.SetResult(err_type)
return res
# 소유자 게이팅 — 본인 견적 또는 최고관리자만 재발송(user_id 미지정=내부 호출은 스킵).
if user_id is not None and not is_owner_or_admin(quotation.user_id, user_id, role):
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
res.msg = "본인이 생성한 견적만 초청 메일을 발송할 수 있습니다."
return res
if not email:
res.skipped = 1
return res

View File

@ -51,6 +51,16 @@ class SupplierService:
res.result.SetResult(err_type)
return res
res.suppliers = [SupplierData.model_validate(r) for r in rows]
# 등록자명 배치 조인 — 페이지 협력사의 user_id를 모아 IN 쿼리 1회로 {id:name} 맵을 만들어 매핑(행별 조회 아님).
author_ids = list({r.user_id for r in rows if r.user_id is not None})
if author_ids:
nm_err, name_map = await DB_SESSION_MNG.execute_lambda(
suppliers.DBType(), DBWRType.DB_READ.value,
lambda s: self.supplier_crud.user_name_map(s, author_ids),
)
if nm_err == ErrorType.SUCCESS:
for d in res.suppliers:
d.creator_name = name_map.get(d.user_id)
res.total = total
return res
@ -61,6 +71,13 @@ class SupplierService:
res.result.SetResult(err_type)
return res
res.supplier = SupplierData.model_validate(supplier)
if supplier.user_id is not None:
nm_err, name_map = await DB_SESSION_MNG.execute_lambda(
suppliers.DBType(), DBWRType.DB_READ.value,
lambda s: self.supplier_crud.user_name_map(s, [supplier.user_id]),
)
if nm_err == ErrorType.SUCCESS:
res.supplier.creator_name = name_map.get(supplier.user_id)
return res
async def check_codes(self, company_id: str, codes: list) -> Res_CheckCodes:

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

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

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 {
is_wildcard?: boolean;
is_shared?: boolean;
name?: ReqCreateCardName;
number?: ReqCreateCardNumber;
script?: ReqCreateCardScript;

View File

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

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 {
HTTPValidationError,
ListQuotationsParams,
ReqAwardQuotation,
ReqCreateQuotation,
ReqRegenerateQuotation,
ResCreateQuotation,
@ -269,6 +270,71 @@ export const useStopQuotation = <TError = void | HTTPValidationError,
return useMutation(mutationOptions, queryClient);
}
/**
* @summary
*/
export const awardQuotation = (
qtId: string,
reqAwardQuotation: ReqAwardQuotation,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResQuotation>(
{url: `/v1/quotation/award/${qtId}`, method: 'POST',
headers: {'Content-Type': 'application/json', },
data: reqAwardQuotation, signal
},
options);
}
export const getAwardQuotationMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof awardQuotation>>, TError,{qtId: string;data: ReqAwardQuotation}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof awardQuotation>>, TError,{qtId: string;data: ReqAwardQuotation}, TContext> => {
const mutationKey = ['awardQuotation'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof awardQuotation>>, {qtId: string;data: ReqAwardQuotation}> = (props) => {
const {qtId,data} = props ?? {};
return awardQuotation(qtId,data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type AwardQuotationMutationResult = NonNullable<Awaited<ReturnType<typeof awardQuotation>>>
export type AwardQuotationMutationBody = ReqAwardQuotation
export type AwardQuotationMutationError = void | HTTPValidationError
/**
* @summary
*/
export const useAwardQuotation = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof awardQuotation>>, TError,{qtId: string;data: ReqAwardQuotation}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof awardQuotation>>,
TError,
{qtId: string;data: ReqAwardQuotation},
TContext
> => {
const mutationOptions = getAwardQuotationMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* @summary ( )
*/
export const regenerateQuotation = (

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

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

View File

@ -20,16 +20,24 @@ export function CardTable({ data, onEdit, footer }: CardTableProps) {
{
header: '구분',
headClassName: 'w-24',
cell: (card) =>
card.isWildcard ? (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-rose-50 text-rose-700 dark:bg-rose-950/20 dark:text-rose-400 border border-rose-200/40">
</span>
) : (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-blue-50 text-blue-700 dark:bg-blue-950/20 dark:text-blue-400 border border-blue-200/40">
</span>
),
cell: (card) => (
<div className="flex flex-col items-start gap-1">
{card.isWildcard ? (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-rose-50 text-rose-700 dark:bg-rose-950/20 dark:text-rose-400 border border-rose-200/40">
</span>
) : (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-blue-50 text-blue-700 dark:bg-blue-950/20 dark:text-blue-400 border border-blue-200/40">
</span>
)}
{card.isShared && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-violet-50 text-violet-700 dark:bg-violet-950/20 dark:text-violet-400 border border-violet-200/40">
</span>
)}
</div>
),
},
{
header: '카드번호',
@ -88,6 +96,13 @@ export function CardTable({ data, onEdit, footer }: CardTableProps) {
<span className="text-muted-foreground font-mono font-medium opacity-65">-</span>
),
},
{
header: '작성자',
align: 'center',
headClassName: 'w-24',
cellClassName: 'text-muted-foreground whitespace-nowrap',
cell: (card) => (card.isShared ? '공용' : card.creatorName ?? '-'),
},
]}
/>
);

View File

@ -10,6 +10,7 @@ import type { Descendant } from 'slate';
import type { ReqCreateCard } from '@/api/generated/model/reqCreateCard';
import type { ResCard } from '@/api/generated/model/resCard';
import type { NegotiationCard } from '@/types';
import type { BulkFailure } from '@/lib/excel';
import { mapCardData, toCardStatusCode } from '../types';
import { serializeToText } from '../editor';
@ -21,6 +22,7 @@ export type CardInput = {
editorScript: Descendant[];
status: 'ACTIVE' | 'INACTIVE';
isWildcard: boolean;
isShared: boolean; // 전체(공용, user_id NULL) 등록 여부. 등록 시에만 유효(수정 시 스코프 고정)
usageType: number; // usage_type(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
triggerCondition?: string;
memo?: string;
@ -37,6 +39,7 @@ function cardError(res: ResCard): string | null {
function toReq(input: CardInput): ReqCreateCard {
return {
is_wildcard: input.isWildcard,
is_shared: input.isShared,
usage_type: input.usageType,
name: input.title,
number: input.code,
@ -73,6 +76,22 @@ export function useCards(params: ListCardsParams) {
await refresh();
};
// 엑셀 일괄 등록 — 행마다 createCard(협력사와 동일 패턴). 실패 행은 code(카드번호)로 사유 수집.
// N번 refresh 방지 위해 raw createCard 를 직접 돌리고 끝에 한 번만 무효화한다.
const bulkCreate = async (rows: CardInput[]): Promise<{ failures: BulkFailure[] }> => {
const failures: BulkFailure[] = [];
for (const input of rows) {
try {
const msg = cardError(await createCard(toReq(input)));
if (msg) failures.push({ code: input.code, message: msg });
} catch (err) {
failures.push({ code: input.code, message: err instanceof Error ? err.message : '등록 실패' });
}
}
await refresh();
return { failures };
};
// customFetch 가 본문을 그대로 주므로 cardsQuery.data 가 곧 ResCardList → .cards.
const cards: NegotiationCard[] = (cardsQuery.data?.cards ?? []).map(mapCardData);
const total = cardsQuery.data?.total ?? 0; // 선택 탭 기준 총건수(페이지네이션)
@ -87,6 +106,7 @@ export function useCards(params: ListCardsParams) {
createCard: createCardFn,
updateCard: updateCardFn,
deleteCard: deleteCardFn,
bulkCreate,
refresh,
cardsQuery,
};

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import { useMemo, useRef, useState } from 'react';
import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react';
import { useScrollLock } from '@/lib/useScrollLock';
import type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier';
import { showToast } from '@/lib/notify';
import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel';
@ -76,7 +77,7 @@ function toSupplierCreate(row: RawRow): SupplierCreate {
code: row.code,
manager_name: row.managerName,
manager_email: row.managerEmail,
manager_contact_number: '010-0000-0000',
manager_contact_number: '01000000000', // 양식에 연락처 컬럼 없음 → placeholder(숫자만 저장 컨벤션)
total_revenue: row.totalRevenue?.trim() ? Number(row.totalRevenue.replace(/[^0-9]/g, '')) : undefined,
};
}
@ -100,6 +101,7 @@ export function downloadPartnerTemplate() {
// 협력사 엑셀 일괄 업로드 모달. 파일 파싱·원본 행 state는 이 컴포넌트가 소유하고,
// 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임.
export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUploadModalProps) {
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
const [excelFile, setExcelFile] = useState<string | null>(null);
const [rows, setRows] = useState<RawRow[]>([]);
const [serverErrors, setServerErrors] = useState<Record<string, string>>({}); // 서버(DB) 거부 code→사유
@ -199,8 +201,8 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs">
<div className="w-full max-w-5xl bg-card border border-border rounded-lg shadow-2xl p-6 overflow-hidden animate-scale-up font-mono">
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
<div className="w-full max-w-5xl bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono">
{/* Modal Title */}
<div className="flex items-center justify-between pb-4 border-b border-border">

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

View File

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

View File

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

View File

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

View File

@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Sheet } from '@/components/ui/sheet';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useAuthStore } from '@/stores/auth';
import { type Product } from '../types';
// 폼 검증 스키마. 필수: 상품명/상품코드/단가/최저가. 나머지는 선택.
@ -129,6 +130,13 @@ export function ProductFormSheet({
const deliveryTypes = DELIVERY_TYPE_OPTIONS;
// 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정·삭제(프론트 1차 차단, 백엔드도 강제).
const myUserId = useAuthStore((s) => s.user?.userId);
const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자');
const canManageOwn = !!product && (product.user_id === myUserId || isSuperAdmin);
// 저장 가능 여부 — 신규는 항상, 수정은 소유자/관리자만.
const canSave = mode === 'create' || canManageOwn;
// minPrice = 인터넷 최저가(실값) → internet_lowest_price 로 저장한다.
const onValid = async (v: FormValues) => {
// 기존 카테고리면 그 category_type(id) 재사용, 처음 쓰는 카테고리면 max+1 부여.
@ -244,6 +252,14 @@ export function ProductFormSheet({
</div>
</div>
{/* 작성자(등록자) — 읽기전용, 편집 시에만 */}
{mode === 'edit' && (
<div className="space-y-1">
<Typography as="label" variant="label"></Typography>
<Typography as="p" variant="small" className="text-muted-foreground">{product?.creator_name ?? '-'}</Typography>
</div>
)}
<div className="grid grid-cols-2 gap-4">
{/* Price */}
<div className="space-y-1">
@ -471,6 +487,8 @@ export function ProductFormSheet({
type="button"
variant="destructive"
size="sm"
disabled={!canManageOwn}
title={canManageOwn ? undefined : '본인이 등록한 상품만 삭제할 수 있습니다.'}
onClick={() => {
onDelete(product.item_id, product.name);
onClose();
@ -483,7 +501,12 @@ export function ProductFormSheet({
<Button type="button" variant="outline" size="sm" onClick={onClose}>
</Button>
<Button type="submit" size="sm" disabled={isSubmitting}>
<Button
type="submit"
size="sm"
disabled={isSubmitting || !canSave}
title={canSave ? undefined : '본인이 등록한 상품만 수정할 수 있습니다.'}
>
{mode === 'create' ? '신규 상품 발행' : '변경사항 저장'}
</Button>
</div>

View File

@ -93,6 +93,12 @@ export function ProductTable({
cellClassName: 'font-mono font-semibold text-rose-600 dark:text-rose-400',
cell: (prod) => (prod.internet_lowest_price != null ? `${Number(prod.internet_lowest_price).toLocaleString()}` : '-'),
},
{
header: '작성자',
align: 'center',
cellClassName: 'text-muted-foreground whitespace-nowrap',
cell: (prod) => prod.creator_name ?? '-',
},
]}
/>
);

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 { useNavigate } from 'react-router';
import { useGetSupplierLastType } from '@/api/generated/quotation/quotation';
import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item';
import { useListItems, useGetItem } from '@/api/generated/item/item';
import { useListSuppliers } from '@/api/generated/supplier/supplier';
@ -10,14 +9,13 @@ import { mapCardData } from '@/features/cards/types';
import { Button } from '@/components/ui/button';
import { Typography, typographyVariants } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
import { useScrollLock } from '@/lib/useScrollLock';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Combobox, type ComboOption } from '@/components/ui/combobox';
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
import type { CreateQuotationInput } from '../hooks/useQuotations';
import { QuotationType } from '@/api/generated/model';
import {
supplierTypeOptions,
is1v1,
toQuotationType,
awardStrategySummary,
@ -55,6 +53,8 @@ export function QuotationCreateModal({
onCreate,
onClose,
}: QuotationCreateModalProps) {
// 모달 열린 동안 배경(부모) 스크롤 잠금 — 뒤 페이지가 같이 스크롤되는 것 방지.
useScrollLock();
const [step, setStep] = useState(1);
const [title, setTitle] = useState('');
// 유형은 '진행 방식(협상/경매) × 대상(신규/후속)' 2축으로 받아 제출 직전 4코드로 합성한다.
@ -65,9 +65,11 @@ export function QuotationCreateModal({
const [dueDate, setDueDate] = useState(nowKstLocalInput);
const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? '');
const [selectedCardIds, setSelectedCardIds] = useState<string[]>([]);
// 선택 항목 표시데이터 캐시 — 담는 순간 이름/이메일·카드메타를 적재해, 검색어가 바뀌어 콤보 목록에서 빠져도 아래 선택 테이블이 유지되게 한다.
const [partnerDetails, setPartnerDetails] = useState<Map<string, { name: string; email: string }>>(() => new Map());
const [cardDetails, setCardDetails] = useState<Map<string, { code: string; title: string; isWildcard: boolean }>>(() => new Map());
const [memo, setMemo] = useState('');
const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 상품값으로 목표가 산정
const [supplierType, setSupplierType] = useState(''); // 협력사 유형(SupplierType). 전 유형에서 입력 → 견적에 기록
const [midAction, setMidAction] = useState<number>(DEFAULT_MID_ACTION); // 앵커~목표가 구간: 낙찰/개찰 (1:1 전용)
const [overAction, setOverAction] = useState<number>(DEFAULT_OVER_ACTION); // 목표가 초과 구간: 낙찰/개찰 (1:1 전용)
const [submitting, setSubmitting] = useState(false);
@ -81,18 +83,6 @@ export function QuotationCreateModal({
: ['기본 정보', '협력사 초청', '확인·완료'];
const totalSteps = steps.length;
// 협력사 유형은 전 유형에서 입력받되, 재협상(1:1)이면 선택 협력사의 직전 견적 supplier_type 을 조회해 디폴트로 채운다.
const renegoSupplierId = type === QuotationType.RENEGO ? (selectedPartnerIds[0] ?? '') : '';
const lastTypeQuery = useGetSupplierLastType(renegoSupplierId, {
query: { enabled: !!renegoSupplierId },
});
const prevSupplierType = lastTypeQuery.data?.supplier_type ?? null; // 협력사 직전 견적 유형(없으면 null)
const prevQtNumber = lastTypeQuery.data?.qt_number ?? '';
// 협력사가 정해지면 직전 견적 유형으로 디폴트(이후 사용자가 바꾸면 그 값 유지).
useEffect(() => {
setSupplierType(prevSupplierType != null ? String(prevSupplierType) : '');
}, [renegoSupplierId, prevSupplierType]);
const navigate = useNavigate();
// ── 픽리스트 서버검색(상품/협력사/카드) — size 캡 없이 검색으로 도달. 미검색이면 부모가 넘긴 목록으로 기본 노출.
@ -141,6 +131,12 @@ export function QuotationCreateModal({
),
}));
// 선택된 협력사 표시행 — 이름/이메일은 캐시에서, 취급유형은 상품 매핑(supplyTypeBySupplier)에서 라이브로 읽는다.
const selectedPartnerRows = selectedPartnerIds.map((id) => {
const d = partnerDetails.get(id);
return { id, name: d?.name ?? id, email: d?.email ?? '' };
});
const cardRows = cardQ ? (cardSearch.data?.cards ?? []).map(mapCardData) : cards;
const cardOptions: ComboOption[] = cardRows
.filter((c) => !c.isWildcard || c.status === 'ACTIVE')
@ -159,6 +155,12 @@ export function QuotationCreateModal({
</div>
),
}));
// 선택된 카드 표시행 — 캐시에서 번호/유형/카드명을 읽어 검색어와 무관하게 유지한다.
const selectedCardRows = selectedCardIds.map((id) => {
const d = cardDetails.get(id);
return { id, code: d?.code ?? '', title: d?.title ?? id, isWildcard: d?.isWildcard ?? false };
});
// 상품에 산정 후보가 있는지(인터넷=공통, 매입·판매=재 한정). 없으면 MD가가 유일한 후보 → 필수가 된다.
const mdNum = Number(mdPrice) || 0;
const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
@ -175,7 +177,10 @@ export function QuotationCreateModal({
// 경매는 3스텝뿐 — 협상카드 스텝(4)에 있던 상태면 마지막(3)으로 당긴다.
if (next === 'auction') setStep((s) => Math.min(s, 3));
};
const togglePartner = (id: string) =>
const togglePartner = (id: string) => {
// 담는 순간 이름/이메일을 캐시에 적재 — 이후 검색어가 바뀌어 목록에서 빠져도 선택 테이블이 유지된다.
const row = supplierRows.find((r) => r.id === id);
if (row) setPartnerDetails((m) => new Map(m).set(id, { name: row.name, email: row.email }));
setSelectedPartnerIds((prev) =>
oneToOne
? prev.includes(id)
@ -185,8 +190,12 @@ export function QuotationCreateModal({
? prev.filter((p) => p !== id)
: [...prev, id],
);
const toggleCard = (id: string) =>
};
const toggleCard = (id: string) => {
const row = cardRows.find((c) => c.id === id);
if (row) setCardDetails((m) => new Map(m).set(id, { code: row.code, title: row.title, isWildcard: row.isWildcard }));
setSelectedCardIds((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]));
};
const handleSubmit = async () => {
if (submitting) return;
@ -208,7 +217,6 @@ export function QuotationCreateModal({
cardIds: oneToOne ? selectedCardIds : [],
memo,
mdPrice: mdPrice ? Number(mdPrice) : null,
supplierType: supplierType ? Number(supplierType) : null,
midAction: oneToOne ? midAction : undefined,
overAction: oneToOne ? overAction : undefined,
});
@ -219,7 +227,7 @@ export function QuotationCreateModal({
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs">
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
{submitting && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div className="flex flex-col items-center gap-3 rounded-xl bg-card px-8 py-6 shadow-2xl border border-border">
@ -229,10 +237,10 @@ export function QuotationCreateModal({
</div>
</div>
)}
<div className="w-full max-w-2xl bg-card border border-border rounded-lg shadow-2xl p-6 overflow-hidden animate-scale-up font-mono">
<div className="w-full max-w-2xl bg-card border border-border rounded-lg shadow-2xl p-6 flex flex-col max-h-[90vh] overflow-hidden animate-scale-up font-mono">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border">
<div className="shrink-0 flex items-center justify-between pb-4 border-b border-border">
<div className="flex items-center gap-2">
<PlusSquare className="text-foreground" size={18} />
<Typography variant="small" className="font-bold"> ( {step}/{totalSteps})</Typography>
@ -243,7 +251,7 @@ export function QuotationCreateModal({
</div>
{/* Steps indicator — 스텝 수는 유형에 따라 3(경매)/4(협상) */}
<div className="flex items-center justify-between gap-2 py-4 border-b border-border/40 text-muted-foreground">
<div className="shrink-0 flex items-center justify-between gap-2 py-4 border-b border-border/40 text-muted-foreground">
{steps.map((label, i) => {
const n = i + 1;
return (
@ -257,8 +265,8 @@ export function QuotationCreateModal({
})}
</div>
{/* Step content */}
<div className="my-6 min-h-60 text-xs text-foreground space-y-4">
{/* Step content — 남은 높이를 채우고 내용이 길면 여기만 스크롤(작은 화면 대응) */}
<div className="flex-1 overflow-y-auto min-h-0 my-6 pr-1 text-xs text-foreground space-y-4">
{step === 1 && (
<div className="space-y-4">
@ -387,7 +395,7 @@ export function QuotationCreateModal({
{step === 2 && (
<div className="space-y-3">
<Typography as="span" variant="label" className="block"> ({oneToOne ? '단일선택' : '다중선택'})</Typography>
{/* 서버검색 다중선택 — 각 행에 선택 상품 취급유형 배지(미매핑=미취급). oneToOne이면 togglePartner가 단일로 강제. */}
{/* 서버검색 다중선택 — 각 행에 선택 상품 상품조달유형 배지(미매핑=미정). oneToOne이면 togglePartner가 단일로 강제. */}
<Combobox
variant="inline"
multiple
@ -400,27 +408,17 @@ export function QuotationCreateModal({
emptyText="협력사가 없습니다"
maxListHeight="max-h-56"
/>
{/* 협력사 유형 — 항상 노출(처음부터 입력 가능). 재협상(1:1)이면 선택 협력사의 직전 견적 값으로 자동 디폴트. */}
<div className="space-y-1 pt-3 border-t border-border/40">
<Typography as="label" variant="label"> </Typography>
<Select value={supplierType} onValueChange={(v) => setSupplierType(v ?? '')}>
<SelectTrigger id="wizard-supplier-type" className="w-full">
<SelectValue>
{(value) => (value ? supplierTypeLabel(Number(value)) : '협력사 유형 선택...')}
</SelectValue>
</SelectTrigger>
<SelectContent>
{supplierTypeOptions.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
{prevSupplierType != null && (
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
({prevQtNumber}) ·
</Typography>
)}
{/* 선택 목록 — 검색어가 바뀌어도 담은 협력사가 유지되는 고정 테이블(취급유형은 상품×협력사 매핑). */}
<div className="space-y-1">
<Typography as="span" variant="label" className="block text-[10px] text-muted-foreground">
{selectedPartnerRows.length}
</Typography>
<SelectedPartnerTable
rows={selectedPartnerRows}
supplyTypeBySupplier={supplyTypeBySupplier}
showSupplyType={!!productId}
onRemove={togglePartner}
/>
</div>
</div>
)}
@ -514,13 +512,20 @@ export function QuotationCreateModal({
emptyText="협상카드가 없습니다"
maxListHeight="max-h-72"
/>
{/* 선택 목록 — 검색어가 바뀌어도 담은 카드가 유지되는 고정 테이블. */}
<div className="space-y-1">
<Typography as="span" variant="label" className="block text-[10px] text-muted-foreground">
{selectedCardRows.length}
</Typography>
<SelectedCardTable rows={selectedCardRows} onRemove={toggleCard} />
</div>
</div>
)}
</div>
{/* Footer nav */}
<div className="flex justify-between items-center pt-4 border-t border-border mt-6">
<div className="shrink-0 flex justify-between items-center pt-4 border-t border-border mt-6">
<Button
type="button"
variant="outline"
@ -560,12 +565,12 @@ export function QuotationCreateModal({
// ── 헬퍼 컴포넌트 (메인 아래) ──────────────────────────────────────────────
// 협력사 취급유형 배지 — type undefined = 이 상품 미취급, 그 외 SupplierType 라벨(제조/유통/총판/없음).
// 협력사 상품조달유형 배지 — type undefined = 유형 미지정(매핑 없음). 초청 협력사는 그 상품을 공급하므로 '미취급'이 아니라 '미정'.
function SupplyTypeBadge({ type }: { type?: number }) {
if (type === undefined) {
return (
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
</Typography>
);
}
@ -576,6 +581,108 @@ function SupplyTypeBadge({ type }: { type?: number }) {
);
}
// 선택된 협력사 테이블 — 콤보로 담은 협력사를 검색어와 무관하게 고정 노출한다. 취급유형 컬럼은 상품 선택 시에만.
function SelectedPartnerTable({
rows,
supplyTypeBySupplier,
showSupplyType,
onRemove,
}: {
rows: { id: string; name: string; email: string }[];
supplyTypeBySupplier: Map<string, number>;
showSupplyType: boolean;
onRemove: (id: string) => void;
}) {
if (rows.length === 0) {
return (
<div className="rounded border border-dashed border-border px-2 py-3 text-center">
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
.
</Typography>
</div>
);
}
return (
<div className="rounded border border-border overflow-hidden">
<table className="w-full table-fixed text-xs">
<thead>
<tr className="bg-muted/40">
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground"></Typography></th>
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground"> </Typography></th>
{showSupplyType && <th className="w-24 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground"></Typography></th>}
<th className="w-9 px-2 py-1.5" />
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-t border-border">
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate font-semibold">{r.name}</Typography></td>
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate text-muted-foreground">{r.email || '-'}</Typography></td>
{showSupplyType && <td className="px-2 py-1.5"><SupplyTypeBadge type={supplyTypeBySupplier.get(r.id)} /></td>}
<td className="px-2 py-1.5 text-right">
<button type="button" onClick={() => onRemove(r.id)} title="제외" className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-rose-600 cursor-pointer">
<X size={13} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// 선택된 카드 테이블 — 콤보로 담은 협상/와일드카드를 검색어와 무관하게 고정 노출한다.
function SelectedCardTable({
rows,
onRemove,
}: {
rows: { id: string; code: string; title: string; isWildcard: boolean }[];
onRemove: (id: string) => void;
}) {
if (rows.length === 0) {
return (
<div className="rounded border border-dashed border-border px-2 py-3 text-center">
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
.
</Typography>
</div>
);
}
return (
<div className="rounded border border-border overflow-hidden">
<table className="w-full table-fixed text-xs">
<thead>
<tr className="bg-muted/40">
<th className="w-24 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground"></Typography></th>
<th className="w-16 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground"></Typography></th>
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground"></Typography></th>
<th className="w-9 px-2 py-1.5" />
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-t border-border">
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate font-mono text-muted-foreground">{r.code}</Typography></td>
<td className="px-2 py-1.5">
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${r.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}>
{r.isWildcard ? '와일드' : '협상'}
</span>
</td>
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate">{r.title}</Typography></td>
<td className="px-2 py-1.5 text-right">
<button type="button" onClick={() => onRemove(r.id)} title="제외" className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-rose-600 cursor-pointer">
<X size={13} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// 세그먼트 컨트롤 — 소수의 명명된 이산 선택(진행 방식·대상)에 라디오보다 명확. 값은 문자열.
function Segmented({
options,

View File

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

View File

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

View File

@ -1,8 +1,12 @@
import { useState } from 'react';
import { MessageSquare, Copy, Mail, MailCheck, Send } from 'lucide-react';
import { Link } from 'react-router';
import { MessageSquare, Copy, Mail, MailCheck, Send, Trophy } from 'lucide-react';
import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
import { cn } from '@/lib/utils';
import { Typography, typographyVariants } from '@/components/ui/typography';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { SessionStatus } from '@/api/generated/model';
import { StatusPill, sessionStatusTone } from './StatusPill';
import { mapServerSessionView, sessionStatusLabel } from '../../types';
@ -11,23 +15,63 @@ type SessionView = ReturnType<typeof mapServerSessionView>;
export function SessionsStatusTab({
sessionViews,
canNotify,
canAward,
winnerSupplierId,
onOpenChat,
onNotifyAll,
onNotifyOne,
onAward,
}: {
sessionViews: SessionView[];
/** 초청 메일 발송 권한(견적 소유자만). false 면 발송 버튼 비활성. */
canNotify: boolean;
/** 직접 낙찰 권한(개찰 상태 + 본인 견적). true 일 때만 낙찰 선택 UI 노출. */
canAward: boolean;
/** 낙찰 확정된 협력사 id(quotations.preferred_sp_id). 그 행만 초록 배경으로 강조. */
winnerSupplierId: string | null;
onOpenChat: (sessionId: string) => void;
/** 미발송 세션 전체에 초청 메일 발송. */
onNotifyAll: () => Promise<void>;
/** 한 세션(공급사)에 초청 메일 발송/재발송. */
onNotifyOne: (sessionId: string) => Promise<void>;
/** 고른 협력사를 낙찰 처리. 성공 시 true. */
onAward: (supplierId: string, supplierName: string) => Promise<boolean>;
}) {
const [sendingAll, setSendingAll] = useState(false);
const [sendingId, setSendingId] = useState<string | null>(null);
const [selectedWinnerId, setSelectedWinnerId] = useState<string | null>(null);
const [awarding, setAwarding] = useState(false);
const unsentCount = sessionViews.filter((s) => !s.email_sent_at).length;
// 낙찰 후보 = 투찰한 협상완료(DONE) 협력사. 개찰 견적에서 이 중 하나를 담당자가 직접 낙찰한다.
const candidates = sessionViews.filter((s) => s.status === SessionStatus.DONE && s.bid_price != null);
const showAward = canAward && candidates.length > 0;
// 최저 투찰가 = 자동낙찰과 같은 기준 → 추천 표시(담당자가 동가/사정상 다른 곳을 골라도 됨).
const lowestBid = candidates.length ? Math.min(...candidates.map((s) => s.bid_price as number)) : null;
const selectedWinner = candidates.find((s) => s.supplier_id === selectedWinnerId) ?? null;
const colCount = showAward ? 13 : 12;
const handleAward = async () => {
if (!selectedWinner) return;
const name = selectedWinner.supplier_name;
const price = selectedWinner.bid_price != null ? `${selectedWinner.bid_price.toLocaleString()}` : '-';
if (
!(await confirm({
title: '직접 낙찰',
description: `[${name}] (투찰가 ${price})을(를) 낙찰 처리하시겠습니까? 낙찰은 되돌릴 수 없습니다.`,
confirmText: '낙찰 확정',
}))
)
return;
setAwarding(true);
try {
const ok = await onAward(selectedWinner.supplier_id, name);
if (ok) setSelectedWinnerId(null);
} finally {
setAwarding(false);
}
};
const handleAll = async () => {
if (
!(await confirm({
@ -88,10 +132,30 @@ export function SessionsStatusTab({
</button>
</div>
{/* 직접 낙찰 툴바 — 개찰(낙찰자 미정) 견적에서만. 표에서 협력사 하나 선택 → 낙찰 확정. */}
{showAward && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-warning/40 bg-warning/10 px-4 py-3">
<Typography as="p" variant="small" className="text-[12px] text-warning">
· .
{selectedWinner && <span className="ml-1 font-bold">: {selectedWinner.supplier_name}</span>}
</Typography>
<button
onClick={handleAward}
disabled={!selectedWinner || awarding}
title={selectedWinner ? '선택한 협력사를 낙찰 처리합니다.' : '먼저 낙찰할 협력사를 선택하세요.'}
className="flex shrink-0 items-center gap-2 px-3 py-2 bg-success text-white text-xs font-bold rounded hover:bg-success/90 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<Trophy size={14} />
<span>{awarding ? '처리 중…' : '낙찰 확정'}</span>
</button>
</div>
)}
<div className="border border-border rounded-lg bg-card overflow-x-auto">
<Table className="w-full text-left text-xs border-collapse font-mono min-w-[1250px]">
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
<TableRow>
{showAward && <TableHead className="p-3 font-semibold text-center font-sans w-14"></TableHead>}
<TableHead className="p-3 font-semibold font-sans"></TableHead>
<TableHead className="p-2 font-semibold text-center w-10">URL</TableHead>
<TableHead className="p-3 font-semibold text-center font-sans"></TableHead>
@ -109,20 +173,57 @@ export function SessionsStatusTab({
<TableBody className="divide-y divide-border">
{sessionViews.length === 0 && (
<TableRow>
<TableCell colSpan={12} className="p-12 text-center text-muted-foreground">
<TableCell colSpan={colCount} className="p-12 text-center text-muted-foreground">
. ( )
</TableCell>
</TableRow>
)}
{sessionViews.map((sess) => (
<TableRow key={sess.session_id} className="hover:bg-muted/30 transition-colors text-[11px]">
{sessionViews.map((sess) => {
const isCandidate = sess.status === SessionStatus.DONE && sess.bid_price != null;
const isRecommended = isCandidate && sess.bid_price === lowestBid;
const isWinner = !!winnerSupplierId && sess.supplier_id === winnerSupplierId;
return (
<TableRow
key={sess.session_id}
className={`transition-colors text-[11px] ${
isWinner ? 'bg-success/10 hover:bg-success/15' : 'hover:bg-muted/30'
}`}
>
{showAward && (
<TableCell className="p-3 text-center w-14">
{isCandidate ? (
<label className="flex flex-col items-center gap-1 cursor-pointer">
<input
type="radio"
name="award-winner"
checked={selectedWinnerId === sess.supplier_id}
onChange={() => setSelectedWinnerId(sess.supplier_id)}
className="h-4 w-4 accent-success cursor-pointer"
/>
{isRecommended && (
<Typography as="span" variant="caption" className="text-[9px] font-bold text-success">
</Typography>
)}
</label>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
)}
<TableCell className="p-3 font-bold text-foreground font-sans">
<div className="flex items-center gap-2">
<span>{sess.supplier_name}</span>
<Link
to={`/partners?detail=${sess.supplier_id}`}
title={`${sess.supplier_name} — 협력사 상세로 이동`}
className={cn(typographyVariants({ variant: 'link' }), 'font-bold font-sans truncate')}
>
{sess.supplier_name}
</Link>
<button
onClick={() => onOpenChat(sess.session_id)}
title="협상 대화방으로 이동"
className="p-1 hover:bg-primary/10 rounded text-primary hover:text-primary/80 transition-colors cursor-pointer"
className="p-1 hover:bg-primary/10 rounded text-primary hover:text-primary/80 transition-colors cursor-pointer shrink-0"
>
<MessageSquare size={13} />
</button>
@ -148,7 +249,7 @@ export function SessionsStatusTab({
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-1.5">
{sess.email_sent_at ? (
<span className="inline-flex items-center gap-1 text-emerald-600 text-[10px] font-semibold">
<span className="inline-flex items-center gap-1 text-success text-[10px] font-semibold">
<MailCheck size={11} />
</span>
) : (
@ -205,7 +306,8 @@ export function SessionsStatusTab({
</TableCell>
<TableCell className="p-3 text-muted-foreground font-sans">{sess.reject_delivery_type || '-'}</TableCell>
</TableRow>
))}
);
})}
</TableBody>
</Table>
</div>

View File

@ -1,6 +1,7 @@
import { X, Check } from 'lucide-react';
import { Typography } from '@/components/ui/typography';
import { useGetTargetBreakdown } from '@/api/generated/quotation/quotation';
import { useScrollLock } from '@/lib/useScrollLock';
// 세션 목표가 산정내역 모달. 후보·채택·앵커링가는 백엔드 /target-breakdown 이 산정한 값을 '표시만' 한다.
// (프론트 재계산 없음 → 저장된 목표가와 항상 일치. 산정 로직은 백엔드 _candidates 단일 출처.)
@ -33,16 +34,17 @@ export function TargetPriceModal({
category,
supplierTypeLabel,
}: TargetPriceModalProps) {
useScrollLock(); // 모달은 열릴 때만 마운트(부모 게이트) → 배경 스크롤 잠금
const { data: bd, isLoading } = useGetTargetBreakdown(sessionId, { query: { enabled: !!sessionId } });
const candidates = bd?.candidates ?? [];
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-xs"
className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs"
onClick={onClose}
>
<div
className="w-full max-w-md bg-card border border-border rounded-lg shadow-2xl p-6 font-mono text-xs animate-scale-up"
className="w-full max-w-md bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto font-mono text-xs animate-scale-up"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}

View File

@ -19,6 +19,7 @@ import {
mapSetting,
mapServerSessionView,
mapServerCardView,
chainRoundState,
} from '../../types';
import { supplierTypeLabel } from '@/lib/enumLabels';
import { QuotationStatus } from '@/api/generated/model';
@ -35,6 +36,8 @@ type DrawerTab = 'status' | 'cards' | 'chat';
type QuotationDetailSheetProps = {
quotation: QuotationData;
onCloseQuotation: (id: string, name: string) => void;
/** 개찰(낙찰자 미정 마감) 견적을 협상현황 표에서 직접 낙찰. 성공 시 true. */
onAward: (qtId: string, winnerSupplierId: string, winnerName: string) => Promise<boolean>;
/** 라운드 타임라인에서 다른 차수로 전환(같은 견적번호의 다른 견적 상세 열기). */
onSwitchRound: (qtId: string) => void;
/** 마감된 견적의 다음 라운드를 수동 생성(공급사 선택). 성공 시 새 qt_id 반환. */
@ -49,6 +52,7 @@ type QuotationDetailSheetProps = {
export function QuotationDetailSheet({
quotation,
onCloseQuotation,
onAward,
onSwitchRound,
onRegenerate,
onNotify,
@ -70,9 +74,14 @@ export function QuotationDetailSheet({
).map(mapSetting);
const qtId = quotation.qt_id ?? '';
// 초청 메일 발송은 견적 소유자만. (백엔드 스코프 도입 전까지의 1차 차단 — 본인 견적 아니면 버튼 비활성)
// 소유자 게이팅 — 견적을 바꾸는 액션(초청메일·마감·재생성·낙찰)은 '본인 견적' 또는 최고관리자만.
// 프론트 1차 차단이며, 실제 보안은 백엔드가 동일 스코프로 강제해야 함(버튼 숨김만으론 우회 가능).
const myUserId = useAuthStore((s) => s.user?.userId);
const canNotify = !!myUserId && quotation.user_id === myUserId;
const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자');
const canManage = !!myUserId && (quotation.user_id === myUserId || isSuperAdmin);
const canNotify = canManage; // 초청 메일 발송/재발송
// 직접 낙찰 = 개찰(낙찰자 미정 마감) 견적에서만. 후보(투찰한 협상완료 협력사) 유무는 표에서 판정.
const canAward = canManage && chainRoundState(quotation) === 'opened';
// 협상 세션·사용 카드는 견적 단위, 채팅은 선택 세션 단위로 서버에서 읽는다.
// 세션은 협상 진행으로 계속 바뀌므로 탭 복귀 시 재조회한다. 카드는 생성 후 불변이라 끄둔다.
const sessionsQuery = useGetQuotationSessions(qtId, {
@ -93,7 +102,7 @@ export function QuotationDetailSheet({
const { rounds: chainRounds, isLoading: chainLoading } = useQuotationChain(quotation.number);
const maxRound = chainRounds.length ? Math.max(...chainRounds.map((r) => r.round)) : (quotation.round ?? 1);
const isLatestRound = (quotation.round ?? 1) >= maxRound;
const canRegenerate = !chainLoading && quotation.status === QuotationStatus.CLOSED && isLatestRound;
const canRegenerate = canManage && !chainLoading && quotation.status === QuotationStatus.CLOSED && isLatestRound;
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const effectiveSessionId = selectedSessionId ?? serverSessions[0]?.session_id ?? null;
@ -139,8 +148,8 @@ export function QuotationDetailSheet({
<div className="w-full max-w-5xl bg-card border-l border-border h-full flex flex-col shadow-2xl overflow-hidden animate-slide-left">
{/* Header title bar (고정) */}
<div className={`shrink-0 px-6 pt-6 bg-muted/30 ${showHeaderCards ? 'pb-3' : 'pb-6 border-b border-border'}`}>
{/* Header title bar (고정) — 아래에 상세 그리드(펼침) 또는 결과 요약 밴드(접힘)가 항상 붙는다. */}
<div className="shrink-0 px-6 pt-6 pb-3 bg-muted/30">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 text-muted-foreground text-[10px] font-mono tracking-widest uppercase">
@ -169,14 +178,21 @@ export function QuotationDetailSheet({
<span> </span>
</button>
)}
{/* 마감 버튼은 항상 노출하되, 마감 가능한 상태(생성·진행중·보류)가 아니면 비활성화한다. */}
{/* 마감 버튼은 항상 노출하되, 본인/최고관리자가 아니거나 마감 가능한 상태가 아니면 비활성화한다. */}
{(() => {
const canClose = quotation.status !== QuotationStatus.CLOSED;
const alreadyClosed = quotation.status === QuotationStatus.CLOSED;
const canClose = canManage && !alreadyClosed;
return (
<button
onClick={() => onCloseQuotation(quotation.qt_id ?? '', q_name)}
disabled={!canClose}
title={canClose ? undefined : '이미 마감된 견적입니다.'}
title={
!canManage
? '본인이 생성한 견적만 마감할 수 있습니다.'
: alreadyClosed
? '이미 마감된 견적입니다.'
: undefined
}
className="flex items-center gap-1 px-3 py-1.5 bg-red-600 hover:bg-rose-700 text-white rounded text-xs font-semibold cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-red-600"
>
<CheckCircle2 size={14} />
@ -194,8 +210,8 @@ export function QuotationDetailSheet({
</div>
</div>
{/* 견적 상세 정보 — 탭과 flex 비율(헤더:탭 = 2:1)로 높이를 나눠 가지고 자체 스크롤 */}
{showHeaderCards && (
{/* 견적 상세 정보 — 펼치면 탭과 flex 비율(헤더:탭 = 2:1)로 높이를 나눠 가지고 자체 스크롤 */}
{showHeaderCards ? (
<div
style={{ flex: '2 1 0%' }}
className="min-h-0 overflow-y-auto px-6 pb-6 bg-muted/30 border-b border-border"
@ -208,6 +224,18 @@ export function QuotationDetailSheet({
onShowTarget={setTargetSessionId}
/>
</div>
) : (
/* 접어도 결과 요약(목표가·낙찰·절감) 밴드는 상단에 그대로 남긴다. */
<div className="shrink-0 px-6 pt-4 pb-4 bg-muted/30 border-b border-border">
<DrawerHeaderCards
collapsed
quotation={quotation}
quotationSettings={quotationSettings}
sessionViews={sessionViews}
currentProduct={currentProduct}
onShowTarget={setTargetSessionId}
/>
</div>
)}
{/* Tabs */}
@ -248,9 +276,12 @@ export function QuotationDetailSheet({
<SessionsStatusTab
sessionViews={sessionViews}
canNotify={canNotify}
canAward={canAward}
winnerSupplierId={quotation.preferred_sp_id ?? null}
onOpenChat={goToChat}
onNotifyAll={() => onNotify(qtId)}
onNotifyOne={(sessionId) => onNotifySession(sessionId, qtId)}
onAward={(supplierId, supplierName) => onAward(qtId, supplierId, supplierName)}
/>
)}

View File

@ -1,5 +1,6 @@
import { useState } from 'react';
import { Settings, X } from 'lucide-react';
import { useScrollLock } from '@/lib/useScrollLock';
import { Button } from '@/components/ui/button';
import { Typography } from '@/components/ui/typography';
import { Input } from '@/components/ui/input';
@ -22,6 +23,7 @@ export function QuotationSettingsModal({
onDelete,
onClose,
}: QuotationSettingsModalProps) {
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
const [targetMargin, setTargetMargin] = useState('');
const [cardUseCount, setCardUseCount] = useState('');
@ -38,8 +40,8 @@ export function QuotationSettingsModal({
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs">
<div className="w-full max-w-2xl bg-card border border-border rounded-lg shadow-2xl p-6 overflow-hidden animate-scale-up font-mono text-xs">
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
<div className="w-full max-w-2xl bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono text-xs">
<div className="flex items-center justify-between pb-4 border-b border-border mb-4">
<div className="flex items-center gap-2">

View File

@ -14,6 +14,7 @@ import {
useListQuotations,
useCreateQuotation,
useStopQuotation,
useAwardQuotation,
useRegenerateQuotation,
useNotifyQuotation,
useNotifySession,
@ -39,7 +40,6 @@ export type CreateQuotationInput = {
cardIds: string[];
memo: string;
mdPrice?: number | null; // MD 제시가(원). 비우면 미전송 → 서버가 기존 마진식으로 목표가 산정
supplierType?: number | null; // 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록
// 낙찰 기준 — 1:1 협상만 전송(경매는 미전송 → 서버가 mid=over=AWARD 강제). 2전략을 mid/over 로 전개해 담는다(over 항상 OPEN).
midAction?: number; // PriceGateAction (앵커~목표가 처리: 낙찰/개찰)
overAction?: number; // PriceGateAction (목표가 초과 처리: 협상은 항상 개찰)
@ -65,6 +65,7 @@ export function useQuotations(params: ListQuotationsParams) {
const deleteSettingMutation = useDeleteSetting();
const createQuotationMutation = useCreateQuotation();
const stopQuotationMutation = useStopQuotation();
const awardQuotationMutation = useAwardQuotation();
const regenerateQuotationMutation = useRegenerateQuotation();
const notifyQuotationMutation = useNotifyQuotation();
const notifySessionMutation = useNotifySession();
@ -116,6 +117,29 @@ export function useQuotations(params: ListQuotationsParams) {
);
};
// 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리 → 서버 award_quotation(close_reason→낙찰 + 낙찰자 박제 + 작성자 알림).
// 성공 시 단건 견적·세션·알림 목록 재조회로 결과밴드('개찰'→'낙찰')와 알림함을 동기화.
const awardQuotation = async (qtId: string, winnerSupplierId: string, winnerName: string): Promise<boolean> => {
try {
const res = await awardQuotationMutation.mutateAsync({ qtId, data: { winner_supplier_id: winnerSupplierId } });
if (!res?.result?.success) {
const reason = res?.msg ?? res?.result?.desc ?? '서버 오류';
const code = res?.result?.code;
showToast(`직접 낙찰 실패${code ? ` [${code}]` : ''}: ${reason}`, 'error');
return false;
}
invalidateQuotations();
queryClient.invalidateQueries({ queryKey: getGetQuotationQueryKey(qtId) });
queryClient.invalidateQueries({ queryKey: getGetQuotationSessionsQueryKey(qtId) });
queryClient.invalidateQueries({ queryKey: ['/v1/notification/list'] });
showToast(`[${winnerName}] 협력사를 낙찰 처리했습니다.`, 'success');
return true;
} catch {
showToast('직접 낙찰에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'error');
return false;
}
};
const invalidateSettings = () =>
queryClient.invalidateQueries({ queryKey: getListSettingsQueryKey() });
@ -194,7 +218,6 @@ export function useQuotations(params: ListQuotationsParams) {
manager_contact_number: me?.contact || undefined,
memo: input.memo.trim() || undefined,
md_price: input.mdPrice && input.mdPrice > 0 ? input.mdPrice : undefined,
supplier_type: input.supplierType ?? undefined,
// 낙찰 기준은 1:1 협상만 전송(모달이 미리 걸러 담음) — 경매면 미전송 → 서버가 AWARD 강제.
mid_action: input.midAction ?? undefined,
over_action: input.overAction ?? undefined,
@ -295,6 +318,7 @@ export function useQuotations(params: ListQuotationsParams) {
total,
quotationSettings,
closeQuotation,
awardQuotation,
addSetting,
deleteSetting,
createQuotation,

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 { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
import { PageContainer } from '@/components/layout/PageContainer';
import { SearchInput } from '@/components/layout/PageToolbar';
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { TablePagination } from '@/components/ui/table-pagination';
import { Typography } from '@/components/ui/typography';
import { useServerList } from '@/lib/useServerList';
@ -11,6 +13,7 @@ import { useCards } from '@/features/cards/hooks/useCards';
import { useGetCard } from '@/api/generated/card/card';
import { CardTable } from '@/features/cards/components/CardTable';
import { CardFormSheet } from '@/features/cards/components/CardFormSheet';
import { CardExcelUploadModal, downloadCardTemplate } from '@/features/cards/components/CardExcelUploadModal';
import { mapCardData, type CardTab, type NegotiationCard } from '@/features/cards/types';
import type { ListCardsParams } from '@/api/generated/model/listCardsParams';
@ -24,13 +27,14 @@ export default function CardsPage() {
page: list.page,
size: list.pageSize,
};
const { cards, total, totalNego, totalWild, createCard, updateCard, deleteCard } = useCards(params);
const { cards, total, totalNego, totalWild, createCard, updateCard, deleteCard, bulkCreate } = useCards(params);
const totalPages = list.totalPages(total);
// 오버레이(폼)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
// 오버레이(폼/엑셀)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
// ?detail=<id> 직접 접근 시 단건 API 로 받아 수정 폼을 연다(현재 페이지에 없어도 동작).
const overlay = useOverlayRouter(['new', 'detail']);
const overlay = useOverlayRouter(['new', 'detail', 'modal']);
const editId = overlay.get('detail');
const modal = overlay.get('modal'); // 'excel' | null
const editQuery = useGetCard(editId ?? '', { query: { enabled: !!editId } });
const editing: NegotiationCard | null = editQuery.data?.card ? mapCardData(editQuery.data.card) : null;
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
@ -65,19 +69,39 @@ export default function CardsPage() {
<div>
<Typography variant="h3" className="text-xs"> </Typography>
<Typography variant="muted" className="text-[10px] mt-0.5">
· .
() ·. .
</Typography>
</div>
</div>
<button
id="card-register-btn"
onClick={openCreate}
className="flex items-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground text-xs font-bold rounded hover:opacity-95 cursor-pointer transition-colors whitespace-nowrap"
>
<Plus size={15} />
<span> </span>
</button>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" />}>
<FileSpreadsheet />
<ChevronDown />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => overlay.open('modal', 'excel')}>
<Upload />
</DropdownMenuItem>
<DropdownMenuItem onClick={downloadCardTemplate}>
<Download />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
id="card-register-btn"
onClick={openCreate}
className="flex items-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground text-xs font-bold rounded hover:opacity-95 cursor-pointer transition-colors whitespace-nowrap"
>
<Plus size={15} />
<span> </span>
</button>
</div>
</div>
{/* Primary Navigation Tab */}
@ -137,6 +161,14 @@ export default function CardsPage() {
onClose={overlay.close}
/>
)}
{modal === 'excel' && (
<CardExcelUploadModal
open
onConfirm={bulkCreate}
onClose={overlay.close}
/>
)}
</PageContainer>
);
}

View File

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

View File

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