diff --git a/backend/common/database/model/models.py b/backend/common/database/model/models.py index 6418d31..ccf03f9 100644 --- a/backend/common/database/model/models.py +++ b/backend/common/database/model/models.py @@ -213,6 +213,20 @@ class nego_cards(MAIN_BASE): 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): # negotiation.chats (협상 채팅 메시지 로그). session 1 : N chats. (session_id, seq) 유니크. @staticmethod diff --git a/backend/crud/chat_crud.py b/backend/crud/chat_crud.py index 6e36228..32deb8c 100644 --- a/backend/crud/chat_crud.py +++ b/backend/crud/chat_crud.py @@ -6,7 +6,7 @@ from sqlalchemy import asc, desc, select, update from sqlalchemy.ext.asyncio import AsyncSession 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.logger import LOG @@ -51,6 +51,10 @@ class IChatCRUD(ABC): async def get_nego_card_id_by_number(self, cdb: AsyncSession, number: str): pass + @abstractmethod + async def get_wild_card_id_by_number(self, cdb: AsyncSession, number: str): + pass + class ChatCRUD(IChatCRUD): 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) 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( self, cdb: AsyncSession, session_id, status: int, bid_price: Optional[int] = None, reject_reason: Optional[str] = None, reject_price: Optional[int] = None, diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index d434f0d..0dabae4 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -103,7 +103,7 @@ class ChatService: ) @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 는 전용 컬럼에도 적재. # nego_card_uuid: turn.card_id(번호)를 UUID 로 변환한 값(nego 카드). 있으면 chats.card_id/card_type/card_used_yn 컬럼에 적재 # → negodata 가 이 컬럼으로 카드 사용/효과를 조인한다. (wild 카드는 agent 가 card_id 미제공 — 별도 작업) @@ -112,9 +112,9 @@ class ChatService: sender=ChatSender.BOT.value, target_price=int(sess.target_price or 0), indicator_value=turn.indicator_value, - card_id=nego_card_uuid, - card_type=1 if nego_card_uuid else None, # CardType: 1=nego (wild=2 는 별도) - card_used_yn=True if nego_card_uuid else None, + card_id=card_uuid, + card_type=card_type if card_uuid else None, # CardType: 1=nego, 2=wild + card_used_yn=True if card_uuid else None, meta={ "script": turn.script, "step": turn.step, "client_step": turn.client_step, "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) - # nego 카드 번호(turn.card_id) → UUID 변환. 카드 사용 로그(chats.card_id/type/used)를 negodata 조인용으로 남긴다. - nego_card_uuid = None + # 카드 번호(turn.card_id) → UUID 변환. step 으로 nego/wild 갈라 각 테이블 조회(번호가 겹칠 수 있어 종류로 구분). + # 카드 사용 로그(chats.card_id/type/used)를 negodata 조인용으로 남긴다. (1% 인하 시스템 카드는 agent 가 card_id 미제공) + card_uuid = None + card_type = None if turn.card_id: - nego_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)), - ) + is_wild = bool(turn.step and turn.step.startswith("wild")) + if is_wild: + 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+거부사유·제시가). 한 트랜잭션. - 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)] # 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신. # 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능). diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index 4a9a011..aa1f6ec 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import ( 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.logger import LOG @@ -384,6 +384,25 @@ class QuotationCRUD(IQuotationCRUD): LOG.e_no_callstack(ex) 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]: """item_id -> company_id(소유 회사) 매핑. 앵커링 칸(회사×유형×가격구간) 해석 입력.""" try: diff --git a/negodata/backend/services/email.py b/negodata/backend/services/email.py index eaea9ed..9f2db41 100644 --- a/negodata/backend/services/email.py +++ b/negodata/backend/services/email.py @@ -96,6 +96,7 @@ def build_invite_email( qt_number: str, end_time: datetime | None, chat_url: str, + email_header: str | None = None, ) -> tuple[str, str, str]: """협상 초청 메일 (제목/HTML/텍스트) 생성. @@ -113,6 +114,7 @@ def build_invite_email( qt_number=escape(qt_number), deadline=escape(deadline), chat_url=escape(chat_url), + email_header=escape(email_header or "NEGODATA"), ) text = ( diff --git a/negodata/backend/services/email_templates/invite_email.html b/negodata/backend/services/email_templates/invite_email.html index aa79386..7aa45dd 100644 --- a/negodata/backend/services/email_templates/invite_email.html +++ b/negodata/backend/services/email_templates/invite_email.html @@ -6,7 +6,7 @@ - NEGODATA + $email_header diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index 7ea2b7c..a921b3f 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -962,6 +962,11 @@ class QuotationService: async def _send_invites(self, quotation, targets: list, res: Res_NotifySessions) -> list: """targets [(session, supplier_name, email)] 에 초청 메일 발송. res.sent/failed 를 채우고 성공한 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 = [] for sess, sp_name, email in targets: subject, html, text = build_invite_email( @@ -970,6 +975,7 @@ class QuotationService: qt_number=quotation.number, end_time=quotation.end_time, chat_url=self._session_chat_url(sess.session_id), + email_header=email_header, ) try: await send_email(email, subject, html, text) diff --git a/negodata/front/src/components/layout/Layout.tsx b/negodata/front/src/components/layout/Layout.tsx index 8d12d3c..858bcbe 100644 --- a/negodata/front/src/components/layout/Layout.tsx +++ b/negodata/front/src/components/layout/Layout.tsx @@ -502,16 +502,16 @@ function HeaderMeta({ user, today }: { user: SidebarUser; today: string }) { 기준일시: {today} -
-
+ {/* 회사명은 모바일에서만 — 데스크톱은 사이드바 브랜드에 이미 노출 */} +
회사: {user?.company} diff --git a/negodata/front/src/components/ui/tooltip.tsx b/negodata/front/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..69eaa2c --- /dev/null +++ b/negodata/front/src/components/ui/tooltip.tsx @@ -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 ( + + + + ) +} + +function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) { + return +} + +function TooltipContent({ + className, + sideOffset = 6, + children, + ...props +}: TooltipPrimitive.Popup.Props & { sideOffset?: number }) { + return ( + + + + {children} + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent } diff --git a/negodata/front/src/features/cards/components/CardTable.tsx b/negodata/front/src/features/cards/components/CardTable.tsx index 44772cd..df5bd9f 100644 --- a/negodata/front/src/features/cards/components/CardTable.tsx +++ b/negodata/front/src/features/cards/components/CardTable.tsx @@ -7,11 +7,14 @@ type CardTableProps = { data: NegotiationCard[]; onEdit: (card: NegotiationCard) => void; footer?: ReactNode; + /** 툴바와 한 카드로 붙일 때 테이블 자체 테두리/라운드를 죽이는 용도 */ + className?: string; }; -export function CardTable({ data, onEdit, footer }: CardTableProps) { +export function CardTable({ data, onEdit, footer, className }: CardTableProps) { return ( card.id} onRowClick={onEdit} diff --git a/negodata/front/src/features/products/components/ProductTable.tsx b/negodata/front/src/features/products/components/ProductTable.tsx index d378589..8344971 100644 --- a/negodata/front/src/features/products/components/ProductTable.tsx +++ b/negodata/front/src/features/products/components/ProductTable.tsx @@ -15,6 +15,8 @@ type ProductTableProps = { totalCount: number; pageSize: number; onPageChange: (page: number) => void; + /** 툴바와 한 카드로 붙일 때 테이블 자체 테두리/라운드를 죽이는 용도 */ + className?: string; }; export function ProductTable({ @@ -27,10 +29,12 @@ export function ProductTable({ totalCount, pageSize, onPageChange, + className, }: ProductTableProps) { const label = useLabels(); // 회사 설정 용어(카테고리/상품 단가 등) return ( prod.item_id} onRowClick={onRowClick} @@ -69,7 +73,7 @@ export function ProductTable({ ), }, { - header: '상품코드', + header: label('item.code'), align: 'left', cellClassName: 'font-mono font-medium text-muted-foreground', cell: (prod) => prod.code, diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx index 22c21e3..9ae0e92 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx @@ -80,10 +80,10 @@ export function ChatTab({ // 협상완료 부가정보(sessions.custom) — 공급사가 타결 후 입력. 라벨은 회사 설정(session_fields)에서. const { settings } = useCompanySettings(); const sessionFields = settings.session_fields ?? []; - const custom = (currentSession?.custom ?? {}) as Record; - const extraRows = sessionFields - .map((f) => ({ label: f.label, value: custom[f.key] })) - .filter((r) => r.value !== undefined && r.value !== null && r.value !== ''); + const extraRows = (sd: SessionData | null) => + sessionFields + .map((f) => ({ label: f.label, value: (sd?.custom as Record | undefined)?.[f.key] })) + .filter((r) => r.value !== undefined && r.value !== null && r.value !== ''); return (
{/* Sessions list */} @@ -102,7 +102,7 @@ export function ChatTab({ const name = partners.find((p) => p.id === sd.supplier_id)?.name || sd.supplier_id; const statusLabel = sessionStatusLabel(sd.status); return ( -
- +
); })}
@@ -155,11 +155,11 @@ export function ChatTab({
- {/* 협상완료 부가정보(공급사 입력) — 값이 있을 때만 노출 */} - {extraRows.length > 0 && ( + {/* 채팅에선 선택 세션의 부가정보를 밴드로 전부 노출. 리스트 버튼(모달)과 병행. */} + {extraRows(currentSession ?? null).length > 0 && (
협상완료 부가정보 - {extraRows.map((r) => ( + {extraRows(currentSession ?? null).map((r) => ( {r.label}: {String(r.value)} @@ -199,6 +199,7 @@ export function ChatTab({ )}
+ ); } diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx index fcb0e42..34caeae 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx @@ -1,14 +1,16 @@ import { useState } from 'react'; 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 { 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 { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { StatusPill, sessionStatusTone } from './StatusPill'; import { mapServerSessionView, sessionStatusLabel } from '../../types'; +import { useCompanySettings } from '@/features/settings/useCompanySettings'; type SessionView = ReturnType; @@ -37,6 +39,13 @@ export function SessionsStatusTab({ /** 고른 협력사를 낙찰 처리. 성공 시 true. */ onAward: (supplierId: string, supplierName: string) => Promise; }) { + const { settings } = useCompanySettings(); + const sessionFields = settings.session_fields ?? []; + const [extraSession, setExtraSession] = useState(null); + const extraRows = (sv: SessionView | null) => + sessionFields + .map((f) => ({ label: f.label, value: (sv?.custom as Record | undefined)?.[f.key] })) + .filter((r) => r.value !== undefined && r.value !== null && r.value !== ''); const [sendingAll, setSendingAll] = useState(false); const [sendingId, setSendingId] = useState(null); const [selectedWinnerId, setSelectedWinnerId] = useState(null); @@ -220,27 +229,48 @@ export function SessionsStatusTab({ > {sess.supplier_name} - + + onOpenChat(sess.session_id)} + className="p-1 hover:bg-primary/10 rounded text-primary hover:text-primary/80 transition-colors cursor-pointer shrink-0" + > + + + } /> + 협상 대화방으로 이동 + + {sess.status === SessionStatus.DONE && ( + + setExtraSession(sess)} + className="p-1 hover:bg-emerald-500/10 rounded text-emerald-600 hover:text-emerald-700 transition-colors cursor-pointer shrink-0" + > + + + } /> + 협상완료 부가정보 + + )} {sess.url ? ( - + + { + void navigator.clipboard?.writeText(sess.url); + showToast('협상 URL을 복사했습니다.', 'success'); + }} + className="p-1 hover:bg-muted rounded text-muted-foreground hover:text-foreground transition-colors cursor-pointer" + > + + + } /> + 협상 URL 복사 + ) : ( - )} @@ -257,20 +287,20 @@ export function SessionsStatusTab({ 미발송 )} - + + handleOne(sess.session_id, sess.supplier_name, !!sess.email_sent_at)} + 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" + > + + + } /> + + {!canNotify ? '본인 견적만 발송 가능' : sess.email_sent_at ? '초청 메일 재발송' : '초청 메일 발송'} + + {sess.email_sent_at && ( {sess.email_sent_at} @@ -311,6 +341,35 @@ export function SessionsStatusTab({ + + {extraSession && ( +
setExtraSession(null)}> +
e.stopPropagation()}> +
+
+ + 협상완료 부가정보 +
+ +
+ {extraSession.supplier_name} + {extraRows(extraSession).length > 0 ? ( +
+ {extraRows(extraSession).map((r) => ( +
+ {r.label} + {String(r.value)} +
+ ))} +
+ ) : ( + 입력된 부가정보가 없습니다. + )} +
+
+ )} ); } diff --git a/negodata/front/src/features/quotations/types.ts b/negodata/front/src/features/quotations/types.ts index 31bbe0d..0ccdc01 100644 --- a/negodata/front/src/features/quotations/types.ts +++ b/negodata/front/src/features/quotations/types.ts @@ -250,6 +250,7 @@ export type SessionView = { end_time: string; url: string; // 세션 chat 실행 URL(공급사 협상 프론트) email_sent_at: string | null; // 초청 메일 발송 시각(KST). null=미발송 → 발송배지/재발송 판단 + custom: Record; // 협상완료 부가정보(sessions.custom) }; export type QuotationCardView = { @@ -389,6 +390,7 @@ export function mapServerSessionView(sd: SessionData, partners: Partner[], produ end_time: fmtDateTime(sd.end_time), url: sd.url || '', email_sent_at: sd.email_sent_at ? fmtDateTime(sd.email_sent_at) : null, + custom: (sd.custom ?? {}) as Record, }; } diff --git a/negodata/front/src/pages/cards.tsx b/negodata/front/src/pages/cards.tsx index b31cfd2..699a76c 100644 --- a/negodata/front/src/pages/cards.tsx +++ b/negodata/front/src/pages/cards.tsx @@ -7,7 +7,7 @@ import { PageToolbar, 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 { cn } from '@/lib/utils'; import { useServerList } from '@/lib/useServerList'; import { useCards } from '@/features/cards/hooks/useCards'; import { useGetCard } from '@/api/generated/card/card'; @@ -62,8 +62,10 @@ export default function CardsPage() { return ( - {/* 플랫 툴바 — 다른 목록 페이지와 동일 문법(검색 좌측, 주버튼+⋯ 우측) */} + {/* 검색/액션 + 탭 + 테이블을 한 카드로 붙인다(포털형, 상품관리와 동일 문법). */} +
@@ -99,32 +101,30 @@ export default function CardsPage() { onClear={list.clearSearch} placeholder="전체 카드이름, 카드번호, 코드 검색..." /> + - {/* Primary Navigation Tab */} -
+ {/* 구분 탭 — 검색/액션 바 아래, 테이블 바로 위 */} +
{tabs.map((tab) => ( ))}
- {/* 배너에 있던 공용 카드 안내는 캡션 한 줄로 유지 */} - - 우리 회사 카드와 기본 제공(공용) 카드를 함께 조회합니다. 공용 카드는 수정·삭제할 수 없습니다. - - } /> +
{isFormOpen && ( + {/* 검색/액션 바 + 테이블을 한 카드로 붙인다(포털형). 툴바는 카드 상단 헤더, 테이블은 본문. */} +
{isFormOpen && (