Merge commit 'f8f8f70f54980eb5d1b60a4ff4aaa67215bd1497'
This commit is contained in:
commit
fa9113da55
@ -213,6 +213,20 @@ class nego_cards(MAIN_BASE):
|
|||||||
deleted = Column(Boolean, nullable=False, server_default=text("false"))
|
deleted = Column(Boolean, nullable=False, server_default=text("false"))
|
||||||
|
|
||||||
|
|
||||||
|
class wild_cards(MAIN_BASE):
|
||||||
|
# card.wild_cards (와일드카드). backend 는 번호→UUID 변환(chats.card_id 저장)만 위해 최소 컬럼 미러.
|
||||||
|
@staticmethod
|
||||||
|
def DBType():
|
||||||
|
return DBType.NEGOTIATION.value # 같은 negosium_db
|
||||||
|
|
||||||
|
__tablename__ = "wild_cards"
|
||||||
|
__table_args__ = {"schema": "card"}
|
||||||
|
|
||||||
|
wild_card_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
|
||||||
|
number = Column(String(10), nullable=True)
|
||||||
|
deleted = Column(Boolean, nullable=False, server_default=text("false"))
|
||||||
|
|
||||||
|
|
||||||
class chats(MAIN_BASE):
|
class chats(MAIN_BASE):
|
||||||
# negotiation.chats (협상 채팅 메시지 로그). session 1 : N chats. (session_id, seq) 유니크.
|
# negotiation.chats (협상 채팅 메시지 로그). session 1 : N chats. (session_id, seq) 유니크.
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -6,7 +6,7 @@ from sqlalchemy import asc, desc, select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import chats, items, sessions, nego_cards
|
from common.database.model.models import chats, items, sessions, nego_cards, wild_cards
|
||||||
from common.enums import ErrorType, SessionStatus
|
from common.enums import ErrorType, SessionStatus
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
|
|
||||||
@ -51,6 +51,10 @@ class IChatCRUD(ABC):
|
|||||||
async def get_nego_card_id_by_number(self, cdb: AsyncSession, number: str):
|
async def get_nego_card_id_by_number(self, cdb: AsyncSession, number: str):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_wild_card_id_by_number(self, cdb: AsyncSession, number: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ChatCRUD(IChatCRUD):
|
class ChatCRUD(IChatCRUD):
|
||||||
async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]:
|
async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]:
|
||||||
@ -128,6 +132,18 @@ class ChatCRUD(IChatCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def get_wild_card_id_by_number(self, cdb: AsyncSession, number: str):
|
||||||
|
# 와일드카드 번호(agent turn.card_id, wild_card_dynamic) → wild_card_id(UUID). 없으면 None.
|
||||||
|
try:
|
||||||
|
query = select(wild_cards.wild_card_id).where(wild_cards.number == number, wild_cards.deleted == False).limit(1) # noqa: E712
|
||||||
|
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_wild_card_id_by_number({number}) failed.")
|
||||||
|
if err_type != ErrorType.SUCCESS or not row_list:
|
||||||
|
return None
|
||||||
|
return row_list[0]
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return None
|
||||||
|
|
||||||
async def finalize_session(
|
async def finalize_session(
|
||||||
self, cdb: AsyncSession, session_id, status: int,
|
self, cdb: AsyncSession, session_id, status: int,
|
||||||
bid_price: Optional[int] = None, reject_reason: Optional[str] = None, reject_price: Optional[int] = None,
|
bid_price: Optional[int] = None, reject_reason: Optional[str] = None, reject_price: Optional[int] = None,
|
||||||
|
|||||||
@ -103,7 +103,7 @@ class ChatService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_bot_chat(sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = None, nego_card_uuid=None) -> chats:
|
def _build_bot_chat(sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = None, card_uuid=None, card_type=None) -> chats:
|
||||||
# bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원 시 폼 재현. indicator_value 는 전용 컬럼에도 적재.
|
# bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원 시 폼 재현. indicator_value 는 전용 컬럼에도 적재.
|
||||||
# nego_card_uuid: turn.card_id(번호)를 UUID 로 변환한 값(nego 카드). 있으면 chats.card_id/card_type/card_used_yn 컬럼에 적재
|
# nego_card_uuid: turn.card_id(번호)를 UUID 로 변환한 값(nego 카드). 있으면 chats.card_id/card_type/card_used_yn 컬럼에 적재
|
||||||
# → negodata 가 이 컬럼으로 카드 사용/효과를 조인한다. (wild 카드는 agent 가 card_id 미제공 — 별도 작업)
|
# → negodata 가 이 컬럼으로 카드 사용/효과를 조인한다. (wild 카드는 agent 가 card_id 미제공 — 별도 작업)
|
||||||
@ -112,9 +112,9 @@ class ChatService:
|
|||||||
sender=ChatSender.BOT.value,
|
sender=ChatSender.BOT.value,
|
||||||
target_price=int(sess.target_price or 0),
|
target_price=int(sess.target_price or 0),
|
||||||
indicator_value=turn.indicator_value,
|
indicator_value=turn.indicator_value,
|
||||||
card_id=nego_card_uuid,
|
card_id=card_uuid,
|
||||||
card_type=1 if nego_card_uuid else None, # CardType: 1=nego (wild=2 는 별도)
|
card_type=card_type if card_uuid else None, # CardType: 1=nego, 2=wild
|
||||||
card_used_yn=True if nego_card_uuid else None,
|
card_used_yn=True if card_uuid else None,
|
||||||
meta={
|
meta={
|
||||||
"script": turn.script, "step": turn.step, "client_step": turn.client_step,
|
"script": turn.script, "step": turn.step, "client_step": turn.client_step,
|
||||||
"input_mode": turn.input_mode, "input_options": turn.input_options,
|
"input_mode": turn.input_mode, "input_options": turn.input_options,
|
||||||
@ -377,16 +377,27 @@ class ChatService:
|
|||||||
# 유저 미입력 가격 타결 케이스 — 마지막 유저 제시가와 다를 수 있다).
|
# 유저 미입력 가격 타결 케이스 — 마지막 유저 제시가와 다를 수 있다).
|
||||||
summary = await self._build_summary(sess, quote, item, final_price, turn.settled_price or last_price)
|
summary = await self._build_summary(sess, quote, item, final_price, turn.settled_price or last_price)
|
||||||
|
|
||||||
# nego 카드 번호(turn.card_id) → UUID 변환. 카드 사용 로그(chats.card_id/type/used)를 negodata 조인용으로 남긴다.
|
# 카드 번호(turn.card_id) → UUID 변환. step 으로 nego/wild 갈라 각 테이블 조회(번호가 겹칠 수 있어 종류로 구분).
|
||||||
nego_card_uuid = None
|
# 카드 사용 로그(chats.card_id/type/used)를 negodata 조인용으로 남긴다. (1% 인하 시스템 카드는 agent 가 card_id 미제공)
|
||||||
|
card_uuid = None
|
||||||
|
card_type = None
|
||||||
if turn.card_id:
|
if turn.card_id:
|
||||||
nego_card_uuid = await DB_SESSION_MNG.execute_lambda(
|
is_wild = bool(turn.step and turn.step.startswith("wild"))
|
||||||
chats.DBType(), DBWRType.DB_READ.value,
|
if is_wild:
|
||||||
lambda s: self.chat_crud.get_nego_card_id_by_number(s, str(turn.card_id)),
|
card_uuid = await DB_SESSION_MNG.execute_lambda(
|
||||||
)
|
chats.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.chat_crud.get_wild_card_id_by_number(s, str(turn.card_id)),
|
||||||
|
)
|
||||||
|
card_type = 2
|
||||||
|
else:
|
||||||
|
card_uuid = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
chats.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.chat_crud.get_nego_card_id_by_number(s, str(turn.card_id)),
|
||||||
|
)
|
||||||
|
card_type = 1
|
||||||
|
|
||||||
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
|
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
|
||||||
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary, nego_card_uuid=nego_card_uuid)
|
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary, card_uuid=card_uuid, card_type=card_type)
|
||||||
funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)]
|
funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)]
|
||||||
# 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신.
|
# 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신.
|
||||||
# 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능).
|
# 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능).
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import (
|
from common.database.model.models import (
|
||||||
quotations, sessions, chats, nego_cards, wild_cards, items, suppliers, quotation_settings,
|
quotations, sessions, chats, nego_cards, wild_cards, items, suppliers, quotation_settings,
|
||||||
version_nego_cards, version_wild_cards, users, supplier_items,
|
version_nego_cards, version_wild_cards, users, supplier_items, companies,
|
||||||
)
|
)
|
||||||
from common.enums import CloseReason, ErrorType, QuotationStatus, SessionStatus
|
from common.enums import CloseReason, ErrorType, QuotationStatus, SessionStatus
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
@ -384,6 +384,25 @@ class QuotationCRUD(IQuotationCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, {}
|
return ErrorType.DB_RUN_FAILED, {}
|
||||||
|
|
||||||
|
async def get_email_header(self, cdb: AsyncSession, user_id):
|
||||||
|
# 견적 소유 유저 → 회사 → companies.settings.branding.email_header. 초청 메일 헤더 브랜딩용. 없으면 None.
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
select(companies.settings)
|
||||||
|
.select_from(users)
|
||||||
|
.join(companies, companies.company_id == users.company_id)
|
||||||
|
.where(users.user_id == user_id, companies.deleted == False) # noqa: E712
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
err, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||||
|
if err != ErrorType.SUCCESS or not rows:
|
||||||
|
return None
|
||||||
|
settings = rows[0] or {}
|
||||||
|
return (settings.get("branding") or {}).get("email_header")
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return None
|
||||||
|
|
||||||
async def get_item_companies(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]:
|
async def get_item_companies(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]:
|
||||||
"""item_id -> company_id(소유 회사) 매핑. 앵커링 칸(회사×유형×가격구간) 해석 입력."""
|
"""item_id -> company_id(소유 회사) 매핑. 앵커링 칸(회사×유형×가격구간) 해석 입력."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -96,6 +96,7 @@ def build_invite_email(
|
|||||||
qt_number: str,
|
qt_number: str,
|
||||||
end_time: datetime | None,
|
end_time: datetime | None,
|
||||||
chat_url: str,
|
chat_url: str,
|
||||||
|
email_header: str | None = None,
|
||||||
) -> tuple[str, str, str]:
|
) -> tuple[str, str, str]:
|
||||||
"""협상 초청 메일 (제목/HTML/텍스트) 생성.
|
"""협상 초청 메일 (제목/HTML/텍스트) 생성.
|
||||||
|
|
||||||
@ -113,6 +114,7 @@ def build_invite_email(
|
|||||||
qt_number=escape(qt_number),
|
qt_number=escape(qt_number),
|
||||||
deadline=escape(deadline),
|
deadline=escape(deadline),
|
||||||
chat_url=escape(chat_url),
|
chat_url=escape(chat_url),
|
||||||
|
email_header=escape(email_header or "NEGODATA"),
|
||||||
)
|
)
|
||||||
|
|
||||||
text = (
|
text = (
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
<!-- 헤더 바 -->
|
<!-- 헤더 바 -->
|
||||||
<tr>
|
<tr>
|
||||||
<td style="background-color:#2563eb;padding:18px 28px;">
|
<td style="background-color:#2563eb;padding:18px 28px;">
|
||||||
<span style="color:#ffffff;font-size:16px;font-weight:700;letter-spacing:1px;">NEGODATA</span>
|
<span style="color:#ffffff;font-size:16px;font-weight:700;letter-spacing:1px;">$email_header</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
|||||||
@ -962,6 +962,11 @@ class QuotationService:
|
|||||||
async def _send_invites(self, quotation, targets: list, res: Res_NotifySessions) -> list:
|
async def _send_invites(self, quotation, targets: list, res: Res_NotifySessions) -> list:
|
||||||
"""targets [(session, supplier_name, email)] 에 초청 메일 발송. res.sent/failed 를 채우고
|
"""targets [(session, supplier_name, email)] 에 초청 메일 발송. res.sent/failed 를 채우고
|
||||||
성공한 session_id 목록을 반환. ACS/SMTP 미설정이면 첫 발송에서 중단(EMAIL_NOT_CONFIGURED)."""
|
성공한 session_id 목록을 반환. ACS/SMTP 미설정이면 첫 발송에서 중단(EMAIL_NOT_CONFIGURED)."""
|
||||||
|
# 회사 브랜딩(초청 메일 헤더) 한 번 조회 — 견적당 동일.
|
||||||
|
email_header = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
quotations.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.quotation_crud.get_email_header(s, quotation.user_id),
|
||||||
|
)
|
||||||
sent_ids = []
|
sent_ids = []
|
||||||
for sess, sp_name, email in targets:
|
for sess, sp_name, email in targets:
|
||||||
subject, html, text = build_invite_email(
|
subject, html, text = build_invite_email(
|
||||||
@ -970,6 +975,7 @@ class QuotationService:
|
|||||||
qt_number=quotation.number,
|
qt_number=quotation.number,
|
||||||
end_time=quotation.end_time,
|
end_time=quotation.end_time,
|
||||||
chat_url=self._session_chat_url(sess.session_id),
|
chat_url=self._session_chat_url(sess.session_id),
|
||||||
|
email_header=email_header,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await send_email(email, subject, html, text)
|
await send_email(email, subject, html, text)
|
||||||
|
|||||||
@ -502,16 +502,16 @@ function HeaderMeta({ user, today }: { user: SidebarUser; today: string }) {
|
|||||||
<Typography as="div" variant="caption" className="hidden lg:block">
|
<Typography as="div" variant="caption" className="hidden lg:block">
|
||||||
기준일시: <span className="font-semibold text-foreground">{today}</span>
|
기준일시: <span className="font-semibold text-foreground">{today}</span>
|
||||||
</Typography>
|
</Typography>
|
||||||
<div className="h-4 w-px bg-border hidden lg:block" />
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
{/* 회사명은 모바일에서만 — 데스크톱은 사이드바 브랜드에 이미 노출 */}
|
||||||
|
<div className="flex items-center gap-2 min-w-0 md:hidden">
|
||||||
<span className="inline-block h-2 w-2 rounded-full bg-success animate-pulse shrink-0" />
|
<span className="inline-block h-2 w-2 rounded-full bg-success animate-pulse shrink-0" />
|
||||||
<Typography as="span" variant="caption" className="shrink-0">회사:</Typography>
|
<Typography as="span" variant="caption" className="shrink-0">회사:</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
as="span"
|
as="span"
|
||||||
variant="caption"
|
variant="caption"
|
||||||
id="header-company-name"
|
id="header-company-name"
|
||||||
className="font-bold text-foreground truncate max-w-[140px] md:max-w-none"
|
className="font-bold text-foreground truncate max-w-[140px]"
|
||||||
>
|
>
|
||||||
{user?.company}
|
{user?.company}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
40
negodata/front/src/components/ui/tooltip.tsx
Normal file
40
negodata/front/src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
// base-ui 기반 툴팁(프로젝트가 radix 아닌 @base-ui/react — tabs/select 와 동일 계열).
|
||||||
|
function Tooltip(props: TooltipPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider delay={100}>
|
||||||
|
<TooltipPrimitive.Root {...props} />
|
||||||
|
</TooltipPrimitive.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) {
|
||||||
|
return <TooltipPrimitive.Trigger {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 6,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Popup.Props & { sideOffset?: number }) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Positioner sideOffset={sideOffset} className="z-50">
|
||||||
|
<TooltipPrimitive.Popup
|
||||||
|
className={cn(
|
||||||
|
'rounded-md bg-foreground px-2 py-1 text-[11px] font-medium text-background shadow-md whitespace-nowrap',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TooltipPrimitive.Popup>
|
||||||
|
</TooltipPrimitive.Positioner>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent }
|
||||||
@ -7,11 +7,14 @@ type CardTableProps = {
|
|||||||
data: NegotiationCard[];
|
data: NegotiationCard[];
|
||||||
onEdit: (card: NegotiationCard) => void;
|
onEdit: (card: NegotiationCard) => void;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
|
/** 툴바와 한 카드로 붙일 때 테이블 자체 테두리/라운드를 죽이는 용도 */
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CardTable({ data, onEdit, footer }: CardTableProps) {
|
export function CardTable({ data, onEdit, footer, className }: CardTableProps) {
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
|
className={className}
|
||||||
data={data}
|
data={data}
|
||||||
rowKey={(card) => card.id}
|
rowKey={(card) => card.id}
|
||||||
onRowClick={onEdit}
|
onRowClick={onEdit}
|
||||||
|
|||||||
@ -15,6 +15,8 @@ type ProductTableProps = {
|
|||||||
totalCount: number;
|
totalCount: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
|
/** 툴바와 한 카드로 붙일 때 테이블 자체 테두리/라운드를 죽이는 용도 */
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProductTable({
|
export function ProductTable({
|
||||||
@ -27,10 +29,12 @@ export function ProductTable({
|
|||||||
totalCount,
|
totalCount,
|
||||||
pageSize,
|
pageSize,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
className,
|
||||||
}: ProductTableProps) {
|
}: ProductTableProps) {
|
||||||
const label = useLabels(); // 회사 설정 용어(카테고리/상품 단가 등)
|
const label = useLabels(); // 회사 설정 용어(카테고리/상품 단가 등)
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
|
className={className}
|
||||||
data={data}
|
data={data}
|
||||||
rowKey={(prod) => prod.item_id}
|
rowKey={(prod) => prod.item_id}
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
@ -69,7 +73,7 @@ export function ProductTable({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: '상품코드',
|
header: label('item.code'),
|
||||||
align: 'left',
|
align: 'left',
|
||||||
cellClassName: 'font-mono font-medium text-muted-foreground',
|
cellClassName: 'font-mono font-medium text-muted-foreground',
|
||||||
cell: (prod) => prod.code,
|
cell: (prod) => prod.code,
|
||||||
|
|||||||
@ -80,10 +80,10 @@ export function ChatTab({
|
|||||||
// 협상완료 부가정보(sessions.custom) — 공급사가 타결 후 입력. 라벨은 회사 설정(session_fields)에서.
|
// 협상완료 부가정보(sessions.custom) — 공급사가 타결 후 입력. 라벨은 회사 설정(session_fields)에서.
|
||||||
const { settings } = useCompanySettings();
|
const { settings } = useCompanySettings();
|
||||||
const sessionFields = settings.session_fields ?? [];
|
const sessionFields = settings.session_fields ?? [];
|
||||||
const custom = (currentSession?.custom ?? {}) as Record<string, unknown>;
|
const extraRows = (sd: SessionData | null) =>
|
||||||
const extraRows = sessionFields
|
sessionFields
|
||||||
.map((f) => ({ label: f.label, value: custom[f.key] }))
|
.map((f) => ({ label: f.label, value: (sd?.custom as Record<string, unknown> | undefined)?.[f.key] }))
|
||||||
.filter((r) => r.value !== undefined && r.value !== null && r.value !== '');
|
.filter((r) => r.value !== undefined && r.value !== null && r.value !== '');
|
||||||
return (
|
return (
|
||||||
<div className="h-[500px] border border-border rounded-lg overflow-hidden bg-card flex">
|
<div className="h-[500px] border border-border rounded-lg overflow-hidden bg-card flex">
|
||||||
{/* Sessions list */}
|
{/* Sessions list */}
|
||||||
@ -102,7 +102,7 @@ export function ChatTab({
|
|||||||
const name = partners.find((p) => p.id === sd.supplier_id)?.name || sd.supplier_id;
|
const name = partners.find((p) => p.id === sd.supplier_id)?.name || sd.supplier_id;
|
||||||
const statusLabel = sessionStatusLabel(sd.status);
|
const statusLabel = sessionStatusLabel(sd.status);
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={sd.session_id}
|
key={sd.session_id}
|
||||||
onClick={() => onSelectSession(sd.session_id)}
|
onClick={() => onSelectSession(sd.session_id)}
|
||||||
className={`w-full text-left p-3 flex flex-col justify-between transition-colors cursor-pointer ${
|
className={`w-full text-left p-3 flex flex-col justify-between transition-colors cursor-pointer ${
|
||||||
@ -121,7 +121,7 @@ export function ChatTab({
|
|||||||
{sd.bid_price ? `₩${Number(sd.bid_price).toLocaleString()}` : '-'}
|
{sd.bid_price ? `₩${Number(sd.bid_price).toLocaleString()}` : '-'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@ -155,11 +155,11 @@ export function ChatTab({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 협상완료 부가정보(공급사 입력) — 값이 있을 때만 노출 */}
|
{/* 채팅에선 선택 세션의 부가정보를 밴드로 전부 노출. 리스트 버튼(모달)과 병행. */}
|
||||||
{extraRows.length > 0 && (
|
{extraRows(currentSession ?? null).length > 0 && (
|
||||||
<div className="px-3 py-2 bg-emerald-500/5 border-b border-border flex flex-wrap items-center gap-x-3 gap-y-1">
|
<div className="px-3 py-2 bg-emerald-500/5 border-b border-border flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
<Typography as="span" variant="small" className="text-[10px] font-mono font-bold text-emerald-700 dark:text-emerald-400">협상완료 부가정보</Typography>
|
<Typography as="span" variant="small" className="text-[10px] font-mono font-bold text-emerald-700 dark:text-emerald-400">협상완료 부가정보</Typography>
|
||||||
{extraRows.map((r) => (
|
{extraRows(currentSession ?? null).map((r) => (
|
||||||
<Typography key={r.label} as="span" variant="small" className="text-[11px] font-mono text-muted-foreground">
|
<Typography key={r.label} as="span" variant="small" className="text-[11px] font-mono text-muted-foreground">
|
||||||
{r.label}: <span className="font-semibold text-foreground">{String(r.value)}</span>
|
{r.label}: <span className="font-semibold text-foreground">{String(r.value)}</span>
|
||||||
</Typography>
|
</Typography>
|
||||||
@ -199,6 +199,7 @@ export function ChatTab({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { MessageSquare, Copy, Mail, MailCheck, Send, Trophy } from 'lucide-react';
|
import { MessageSquare, Copy, Mail, MailCheck, Send, Trophy, ClipboardList, X } from 'lucide-react';
|
||||||
import { showToast } from '@/lib/notify';
|
import { showToast } from '@/lib/notify';
|
||||||
import { confirm } from '@/lib/confirm';
|
import { confirm } from '@/lib/confirm';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Typography, typographyVariants } from '@/components/ui/typography';
|
import { Typography, typographyVariants } from '@/components/ui/typography';
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||||
import { SessionStatus } from '@/api/generated/model';
|
import { SessionStatus } from '@/api/generated/model';
|
||||||
|
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||||
import { StatusPill, sessionStatusTone } from './StatusPill';
|
import { StatusPill, sessionStatusTone } from './StatusPill';
|
||||||
import { mapServerSessionView, sessionStatusLabel } from '../../types';
|
import { mapServerSessionView, sessionStatusLabel } from '../../types';
|
||||||
|
import { useCompanySettings } from '@/features/settings/useCompanySettings';
|
||||||
|
|
||||||
type SessionView = ReturnType<typeof mapServerSessionView>;
|
type SessionView = ReturnType<typeof mapServerSessionView>;
|
||||||
|
|
||||||
@ -37,6 +39,13 @@ export function SessionsStatusTab({
|
|||||||
/** 고른 협력사를 낙찰 처리. 성공 시 true. */
|
/** 고른 협력사를 낙찰 처리. 성공 시 true. */
|
||||||
onAward: (supplierId: string, supplierName: string) => Promise<boolean>;
|
onAward: (supplierId: string, supplierName: string) => Promise<boolean>;
|
||||||
}) {
|
}) {
|
||||||
|
const { settings } = useCompanySettings();
|
||||||
|
const sessionFields = settings.session_fields ?? [];
|
||||||
|
const [extraSession, setExtraSession] = useState<SessionView | null>(null);
|
||||||
|
const extraRows = (sv: SessionView | null) =>
|
||||||
|
sessionFields
|
||||||
|
.map((f) => ({ label: f.label, value: (sv?.custom as Record<string, unknown> | undefined)?.[f.key] }))
|
||||||
|
.filter((r) => r.value !== undefined && r.value !== null && r.value !== '');
|
||||||
const [sendingAll, setSendingAll] = useState(false);
|
const [sendingAll, setSendingAll] = useState(false);
|
||||||
const [sendingId, setSendingId] = useState<string | null>(null);
|
const [sendingId, setSendingId] = useState<string | null>(null);
|
||||||
const [selectedWinnerId, setSelectedWinnerId] = useState<string | null>(null);
|
const [selectedWinnerId, setSelectedWinnerId] = useState<string | null>(null);
|
||||||
@ -220,27 +229,48 @@ export function SessionsStatusTab({
|
|||||||
>
|
>
|
||||||
{sess.supplier_name}
|
{sess.supplier_name}
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<Tooltip>
|
||||||
onClick={() => onOpenChat(sess.session_id)}
|
<TooltipTrigger render={
|
||||||
title="협상 대화방으로 이동"
|
<button
|
||||||
className="p-1 hover:bg-primary/10 rounded text-primary hover:text-primary/80 transition-colors cursor-pointer shrink-0"
|
onClick={() => onOpenChat(sess.session_id)}
|
||||||
>
|
className="p-1 hover:bg-primary/10 rounded text-primary hover:text-primary/80 transition-colors cursor-pointer shrink-0"
|
||||||
<MessageSquare size={13} />
|
>
|
||||||
</button>
|
<MessageSquare size={13} />
|
||||||
|
</button>
|
||||||
|
} />
|
||||||
|
<TooltipContent>협상 대화방으로 이동</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
{sess.status === SessionStatus.DONE && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger render={
|
||||||
|
<button
|
||||||
|
onClick={() => setExtraSession(sess)}
|
||||||
|
className="p-1 hover:bg-emerald-500/10 rounded text-emerald-600 hover:text-emerald-700 transition-colors cursor-pointer shrink-0"
|
||||||
|
>
|
||||||
|
<ClipboardList size={13} />
|
||||||
|
</button>
|
||||||
|
} />
|
||||||
|
<TooltipContent>협상완료 부가정보</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="p-2 font-mono text-center w-10">
|
<TableCell className="p-2 font-mono text-center w-10">
|
||||||
{sess.url ? (
|
{sess.url ? (
|
||||||
<button
|
<Tooltip>
|
||||||
onClick={() => {
|
<TooltipTrigger render={
|
||||||
void navigator.clipboard?.writeText(sess.url);
|
<button
|
||||||
showToast('협상 URL을 복사했습니다.', 'success');
|
onClick={() => {
|
||||||
}}
|
void navigator.clipboard?.writeText(sess.url);
|
||||||
title="협상 URL 복사"
|
showToast('협상 URL을 복사했습니다.', 'success');
|
||||||
className="p-1 hover:bg-muted rounded text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
}}
|
||||||
>
|
className="p-1 hover:bg-muted rounded text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||||
<Copy size={12} />
|
>
|
||||||
</button>
|
<Copy size={12} />
|
||||||
|
</button>
|
||||||
|
} />
|
||||||
|
<TooltipContent>협상 URL 복사</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">-</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
@ -257,20 +287,20 @@ export function SessionsStatusTab({
|
|||||||
<Mail size={11} /> 미발송
|
<Mail size={11} /> 미발송
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<Tooltip>
|
||||||
onClick={() => handleOne(sess.session_id, sess.supplier_name, !!sess.email_sent_at)}
|
<TooltipTrigger render={
|
||||||
disabled={!canNotify || sendingId === sess.session_id}
|
<button
|
||||||
title={
|
onClick={() => handleOne(sess.session_id, sess.supplier_name, !!sess.email_sent_at)}
|
||||||
!canNotify
|
disabled={!canNotify || sendingId === sess.session_id}
|
||||||
? '본인이 생성한 견적만 초청 메일을 발송할 수 있습니다.'
|
className="p-1 rounded text-primary hover:bg-primary/10 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
: sess.email_sent_at
|
>
|
||||||
? '이 협력사에게 초청 메일 재발송'
|
<Send size={12} />
|
||||||
: '이 협력사에게 초청 메일 발송'
|
</button>
|
||||||
}
|
} />
|
||||||
className="p-1 rounded text-primary hover:bg-primary/10 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
<TooltipContent>
|
||||||
>
|
{!canNotify ? '본인 견적만 발송 가능' : sess.email_sent_at ? '초청 메일 재발송' : '초청 메일 발송'}
|
||||||
<Send size={12} />
|
</TooltipContent>
|
||||||
</button>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
{sess.email_sent_at && (
|
{sess.email_sent_at && (
|
||||||
<span className="text-muted-foreground text-[10px] font-mono">{sess.email_sent_at}</span>
|
<span className="text-muted-foreground text-[10px] font-mono">{sess.email_sent_at}</span>
|
||||||
@ -311,6 +341,35 @@ export function SessionsStatusTab({
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{extraSession && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs" onClick={() => setExtraSession(null)}>
|
||||||
|
<div className="w-full max-w-sm bg-card border border-border rounded-lg shadow-2xl p-5 animate-scale-up" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between pb-3 mb-3 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardList size={16} className="text-emerald-600" />
|
||||||
|
<Typography variant="h3">협상완료 부가정보</Typography>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setExtraSession(null)} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Typography as="p" variant="muted" className="text-[11px] mb-3">{extraSession.supplier_name}</Typography>
|
||||||
|
{extraRows(extraSession).length > 0 ? (
|
||||||
|
<div className="divide-y divide-border/60">
|
||||||
|
{extraRows(extraSession).map((r) => (
|
||||||
|
<div key={r.label} className="flex items-center justify-between py-2 text-xs">
|
||||||
|
<span className="text-muted-foreground">{r.label}</span>
|
||||||
|
<span className="font-semibold text-foreground">{String(r.value)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Typography as="p" variant="small" className="py-4 text-center text-muted-foreground text-xs">입력된 부가정보가 없습니다.</Typography>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -250,6 +250,7 @@ export type SessionView = {
|
|||||||
end_time: string;
|
end_time: string;
|
||||||
url: string; // 세션 chat 실행 URL(공급사 협상 프론트)
|
url: string; // 세션 chat 실행 URL(공급사 협상 프론트)
|
||||||
email_sent_at: string | null; // 초청 메일 발송 시각(KST). null=미발송 → 발송배지/재발송 판단
|
email_sent_at: string | null; // 초청 메일 발송 시각(KST). null=미발송 → 발송배지/재발송 판단
|
||||||
|
custom: Record<string, unknown>; // 협상완료 부가정보(sessions.custom)
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QuotationCardView = {
|
export type QuotationCardView = {
|
||||||
@ -389,6 +390,7 @@ export function mapServerSessionView(sd: SessionData, partners: Partner[], produ
|
|||||||
end_time: fmtDateTime(sd.end_time),
|
end_time: fmtDateTime(sd.end_time),
|
||||||
url: sd.url || '',
|
url: sd.url || '',
|
||||||
email_sent_at: sd.email_sent_at ? fmtDateTime(sd.email_sent_at) : null,
|
email_sent_at: sd.email_sent_at ? fmtDateTime(sd.email_sent_at) : null,
|
||||||
|
custom: (sd.custom ?? {}) as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||||
import { TablePagination } from '@/components/ui/table-pagination';
|
import { TablePagination } from '@/components/ui/table-pagination';
|
||||||
import { Typography } from '@/components/ui/typography';
|
import { cn } from '@/lib/utils';
|
||||||
import { useServerList } from '@/lib/useServerList';
|
import { useServerList } from '@/lib/useServerList';
|
||||||
import { useCards } from '@/features/cards/hooks/useCards';
|
import { useCards } from '@/features/cards/hooks/useCards';
|
||||||
import { useGetCard } from '@/api/generated/card/card';
|
import { useGetCard } from '@/api/generated/card/card';
|
||||||
@ -62,8 +62,10 @@ export default function CardsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
{/* 플랫 툴바 — 다른 목록 페이지와 동일 문법(검색 좌측, 주버튼+⋯ 우측) */}
|
{/* 검색/액션 + 탭 + 테이블을 한 카드로 붙인다(포털형, 상품관리와 동일 문법). */}
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||||
<PageToolbar
|
<PageToolbar
|
||||||
|
className="rounded-none border-0 border-b border-border"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@ -99,32 +101,30 @@ export default function CardsPage() {
|
|||||||
onClear={list.clearSearch}
|
onClear={list.clearSearch}
|
||||||
placeholder="전체 카드이름, 카드번호, 코드 검색..."
|
placeholder="전체 카드이름, 카드번호, 코드 검색..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
|
|
||||||
{/* Primary Navigation Tab */}
|
{/* 구분 탭 — 검색/액션 바 아래, 테이블 바로 위 */}
|
||||||
<div className="flex border-b border-border">
|
<div className="flex border-b border-border px-3">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
id={`card-tab-${tab.id.toLowerCase()}`}
|
id={`card-tab-${tab.id.toLowerCase()}`}
|
||||||
onClick={() => list.setFilter('tab', tab.id)}
|
onClick={() => list.setFilter('tab', tab.id)}
|
||||||
className={`py-2 px-6 font-bold text-xs tracking-tight border-b-2 transition-all cursor-pointer ${
|
className={cn(
|
||||||
|
'cursor-pointer border-b-2 px-6 py-2 text-xs font-bold tracking-tight transition-all',
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-primary text-primary'
|
? 'border-primary text-primary'
|
||||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||||
}`}
|
)}
|
||||||
>
|
>
|
||||||
{tab.label} ({tab.count})
|
{tab.label} ({tab.count})
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 배너에 있던 공용 카드 안내는 캡션 한 줄로 유지 */}
|
|
||||||
<Typography variant="caption" className="-mt-3 block">
|
|
||||||
우리 회사 카드와 기본 제공(공용) 카드를 함께 조회합니다. 공용 카드는 수정·삭제할 수 없습니다.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<CardTable
|
<CardTable
|
||||||
|
className="rounded-none border-0"
|
||||||
data={cards}
|
data={cards}
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
footer={
|
footer={
|
||||||
@ -139,6 +139,7 @@ export default function CardsPage() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isFormOpen && (
|
{isFormOpen && (
|
||||||
<CardFormSheet
|
<CardFormSheet
|
||||||
|
|||||||
@ -91,7 +91,10 @@ export default function ProductsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
|
{/* 검색/액션 바 + 테이블을 한 카드로 붙인다(포털형). 툴바는 카드 상단 헤더, 테이블은 본문. */}
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||||
<PageToolbar
|
<PageToolbar
|
||||||
|
className="rounded-none border-0 border-b border-border"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@ -166,6 +169,7 @@ export default function ProductsPage() {
|
|||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
|
|
||||||
<ProductTable
|
<ProductTable
|
||||||
|
className="rounded-none border-0"
|
||||||
data={products}
|
data={products}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
onSelectionChange={setSelectedIds}
|
onSelectionChange={setSelectedIds}
|
||||||
@ -176,6 +180,7 @@ export default function ProductsPage() {
|
|||||||
pageSize={list.pageSize}
|
pageSize={list.pageSize}
|
||||||
onPageChange={list.setPage}
|
onPageChange={list.setPage}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isFormOpen && (
|
{isFormOpen && (
|
||||||
<ProductFormSheet
|
<ProductFormSheet
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user