diff --git a/negodata/front/src/app/router.tsx b/negodata/front/src/app/router.tsx index e8a8cbe..821e90f 100644 --- a/negodata/front/src/app/router.tsx +++ b/negodata/front/src/app/router.tsx @@ -4,6 +4,7 @@ import {isLoggedIn} from '../stores/auth'; import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout'; import LoginPage from '../pages/login'; import ForbiddenPage from '../pages/forbidden'; +import NotFoundPage from '../pages/not-found'; import ProductsPage from '../pages/products'; import PartnersPage from '../pages/partners'; import QuotationPage from '../pages/quotation'; @@ -57,4 +58,8 @@ export const router = createBrowserRouter([ {path: 'cards', Component: CardsPage}, ], }, + { + path: '*', + Component: NotFoundPage, + }, ]); diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx index e5fff6f..f05c778 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx @@ -3,10 +3,11 @@ import type { SessionData } from '@/api/generated/model/sessionData'; import type { QuotationCardData } from '@/api/generated/model/quotationCardData'; import type { ChatMessageData } from '@/api/generated/model/chatMessageData'; import { ChatSender, CardType } from '@/api/generated/model'; -import { Input } from '@/components/ui/input'; import SlateRenderer from '@/components/SlateRenderer'; +import { Typography } from '@/components/ui/typography'; import { StatusPill, sessionStatusTone } from './StatusPill'; import { type Product, type Partner, sessionStatusLabel } from '../../types'; +import { maskPrices } from '@/lib/utils'; export function ChatTab({ serverSessions, @@ -34,14 +35,14 @@ export function ChatTab({
{/* Sessions list */}
-
+ 참여자 협력사 리스트 -
+
{serverSessions.length === 0 && ( -
+ 참여 협상 세션이 없습니다. (리스트가 비어 있습니다) -
+ )} {serverSessions.map((sd) => { const isSelected = sd.session_id === effectiveSessionId; @@ -56,16 +57,16 @@ export function ChatTab({ }`} >
- {name} + {name} {statusLabel}
-
- 최종 제의 - +
+ 최종 제의 + {sd.bid_price ? `₩${Number(sd.bid_price).toLocaleString()}` : '-'} - +
); @@ -75,162 +76,237 @@ export function ChatTab({ {/* Chat zone */}
-
-
- 협력사: {currentSupplierName} -
-
+
+ + 협력사: {currentSupplierName} + +
{targetPrice != null && ( - - 목표가: ₩{Number(targetPrice).toLocaleString()} - + + 목표가: ₩{Number(targetPrice).toLocaleString()} + )} - - 기록: {chatMessages.length} 메시지 - + + 기록: {chatMessages.length} 메시지 +
{!effectiveSessionId ? ( -
+ 선택된 협력사가 없습니다. -
+ ) : chatMessages.length === 0 ? ( -
+ 기록된 협상 대화가 없습니다. -
+ ) : ( - chatMessages.map((m) => { - const isBot = m.sender === ChatSender.BOT; - // 메시지가 쓴 협상카드 전체(이름만이 아니라 멘트/조건/메모까지) 를 chat_id 로 매칭. - const usedCard = m.card_used_yn - ? serverCards.find((c) => c.session_card_id === m.chat_id) - : undefined; - const cardNodes = Array.isArray(usedCard?.edit_script) ? (usedCard.edit_script as unknown[]) : null; - const isWildCard = usedCard?.type === CardType.WILD; - return ( -
-
-
- {isBot ? 'Negosium Bot' : currentSupplierName} - · - #{m.index} -
- -
- {/* 진행 단계(chats.meta.step). 주로 봇 턴에만 존재. */} - {m.step && ( -
- {m.step} -
- )} - {/* 말풍선 멘트(chats.meta.script). 봇=협상 스크립트, 협력사=입력값. */} - {m.script && ( -

{m.script}

- )} - {/* 제시가: 협력사(user)가 실제로 제시한 가격만 표시. 목표가는 헤더 고정. - 가격 제시 턴이 아니면(target_price=0) 숨긴다(₩0 오표시 방지). */} - {!isBot && m.target_price > 0 && ( -
제시가 ₩{Number(m.target_price).toLocaleString()}
- )} - {usedCard && ( -
- {/* 헤더: 어떤 카드인지(번호·이름·종류) */} -
- - 협상카드 - {usedCard.number && #{usedCard.number}} - {usedCard.name && · {usedCard.name}} - - {isWildCard ? '와일드' : '협상'} - -
- - {/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script */} - {cardNodes ? ( -
- -
- ) : usedCard.script ? ( -

- {usedCard.script} -

- ) : null} - - {/* 와일드카드 부가 정보: 사용 조건 / 메모 */} - {isWildCard && (usedCard.condition || usedCard.memo) && ( -
- {usedCard.condition && ( -
- 조건: {usedCard.condition} -
- )} - {usedCard.memo && ( -
- 메모: {usedCard.memo} -
- )} -
- )} -
- )} -
-
-
- ); - }) + chatMessages.map((m) => + m.sender === ChatSender.BOT ? ( + + ) : ( + + ), + ) )}
- -
- - -
); } + +// 봇(좌측) 말풍선. 진행 단계·협상 스크립트(가격 마스킹)·사용 협상카드를 보여준다. +// 간격은 부모(flex flex-col gap)에서 주고, 말풍선 박스는 block 으로 둬 긴 텍스트 줄바꿈이 깨지지 않게 한다. +function BotBubble({ + message, + currentSupplierName, + currentProduct, + serverCards, +}: { + message: ChatMessageData; + currentSupplierName: string; + currentProduct: Product | undefined; + serverCards: QuotationCardData[]; +}) { + const m = message; + return ( +
+
+
+ Negosium Bot + · + #{m.index} +
+
+ {m.step && ( + + {m.step} + + )} + {m.script && ( + + {maskPrices(m.script)} + + )} + +
+
+
+ ); +} + +// 협력사(우측) 말풍선. 협력사 입력값을 보여주되, 채팅 '내용'에 제시 금액이 노출되지 않도록 maskPrices 로 가린다. +function PartnerBubble({ + message, + currentSupplierName, + currentProduct, + serverCards, +}: { + message: ChatMessageData; + currentSupplierName: string; + currentProduct: Product | undefined; + serverCards: QuotationCardData[]; +}) { + const m = message; + return ( +
+
+
+ {currentSupplierName} + · + #{m.index} +
+
+ {m.step && ( + + {m.step} + + )} + {m.script && ( + + {maskPrices(m.script)} + + )} + +
+
+
+ ); +} + +// 말풍선에 붙는 협상카드 박스(봇/협력사 공용). 카드 미사용 메시지면 아무것도 렌더하지 않는다. +// 톤(amber/primary)만 isBot 으로 가르고, 멘트/조건/메모 렌더 로직은 공유한다. +function UsedCardBox({ + message, + isBot, + currentSupplierName, + currentProduct, + serverCards, +}: { + message: ChatMessageData; + isBot: boolean; + currentSupplierName: string; + currentProduct: Product | undefined; + serverCards: QuotationCardData[]; +}) { + const m = message; + // 메시지가 쓴 협상카드 전체(이름만이 아니라 멘트/조건/메모까지) 를 chat_id 로 매칭. + const usedCard = m.card_used_yn + ? serverCards.find((c) => c.session_card_id === m.chat_id) + : undefined; + if (!usedCard) return null; + const cardNodes = Array.isArray(usedCard.edit_script) ? (usedCard.edit_script as unknown[]) : null; + const isWildCard = usedCard.type === CardType.WILD; + + return ( +
+ {/* 헤더: 어떤 카드인지(번호·이름·종류) */} +
+ + 협상카드 + {usedCard.number && ( + #{usedCard.number} + )} + {usedCard.name && ( + · {usedCard.name} + )} + + {isWildCard ? '와일드' : '협상'} + +
+ + {/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script */} + {cardNodes ? ( +
+ +
+ ) : usedCard.script ? ( + + {usedCard.script} + + ) : null} + + {/* 와일드카드 부가 정보: 사용 조건 / 메모 */} + {isWildCard && (usedCard.condition || usedCard.memo) && ( +
+ {usedCard.condition && ( + + 조건: {usedCard.condition} + + )} + {usedCard.memo && ( + + 메모: {usedCard.memo} + + )} +
+ )} +
+ ); +} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/RegenerateModal.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/RegenerateModal.tsx index 89b0225..b3923c1 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/RegenerateModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/RegenerateModal.tsx @@ -43,7 +43,7 @@ export function RegenerateModal({ open, partners, sessionStatusBySupplier, defau return (
-
+
{/* Header */}
diff --git a/negodata/front/src/features/quotations/components/QuotationTable.tsx b/negodata/front/src/features/quotations/components/QuotationTable.tsx index 293a3d9..9ca85f7 100644 --- a/negodata/front/src/features/quotations/components/QuotationTable.tsx +++ b/negodata/front/src/features/quotations/components/QuotationTable.tsx @@ -1,7 +1,15 @@ -import type { ReactNode } from 'react'; -import { Clock, Building2, Link2 } from 'lucide-react'; +import { type ReactNode } from 'react'; +import { Clock, Building2, Link2, CornerDownRight } from 'lucide-react'; import { DataTable } from '@/components/ui/data-table'; -import { type Estimate, type Product, quotationStatusLabel, quotationTypeLabel } from '../types'; +import { Typography } from '@/components/ui/typography'; +import { useQuotationChain } from '../hooks/useQuotationChain'; +import { + type Estimate, + type Product, + quotationStatusLabel, + quotationTypeLabel, + CHAIN_ROUND_STATE_LABEL, +} from '../types'; import { QuotationType, QuotationStatus } from '@/api/generated/model'; type QuotationTableProps = { @@ -44,13 +52,15 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo const productName = product?.name ?? est.productName; // 목록에 없으면 서버 조인 상품명으로 폴백 return (
- + {est.title} - - + + 대상 상품: {productName ?? '확인 불가'} (₩{(product?.price ?? 0).toLocaleString()}) - + + {/* 이 견적에서 재생성된 다음 차수(직속 자식)만 행 밑에 표시 — 전체 체인 반복 X */} +
); }, @@ -70,17 +80,19 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo className="inline-flex items-center gap-1 hover:text-primary hover:underline cursor-pointer" > - {est.number} + {est.number} ) : ( - est.number + {est.number} ), }, { header: '유형', align: 'center', cell: (est) => ( - {quotationTypeLabel(est.type)} - + ), }, { header: '차수', align: 'center', - cellClassName: 'font-bold font-mono text-sm', - cell: (est) => `${est.round}차`, + cell: (est) => ( + + {est.round}차 + + ), }, { header: '견적상태', align: 'center', cell: (est) => ( - {quotationStatusLabel(est.status)} - + ), }, { @@ -114,22 +131,63 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo cell: (est) => (
- {est.dueDate} + {est.dueDate}
), }, { header: '생성일', cellClassName: 'font-mono text-muted-foreground whitespace-nowrap', - cell: (est) => est.createdDate ?? '-', + cell: (est) => ( + {est.createdDate ?? '-'} + ), }, { header: '협력사수', align: 'center', cellClassName: 'font-mono font-bold text-foreground', - cell: (est) => `${est.participationCount}개사`, + cell: (est) => ( + {est.participationCount}개사 + ), }, ]} /> ); } +function RegeneratedChild({ + number, + currentQtId, + onOpenRound, +}: { + number?: string | null; + currentQtId?: string; + onOpenRound: (qtId: string) => void; +}) { + const { rounds } = useQuotationChain(number); + const current = rounds.find((r) => r.qt_id === currentQtId); + // rounds 는 round 오름차순 → 현재보다 큰 첫 라운드가 직속 자식. + const child = current ? rounds.find((r) => r.round > current.round) : undefined; + if (!child) return null; + + return ( +
+ + + 재생성됨 + + +
+ ); +} diff --git a/negodata/front/src/lib/utils.ts b/negodata/front/src/lib/utils.ts index bd0c391..e433c61 100644 --- a/negodata/front/src/lib/utils.ts +++ b/negodata/front/src/lib/utils.ts @@ -4,3 +4,10 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +// 문자열에서 금액(원/₩) 표기의 숫자만 '***'로 가린다. 접미사·문장 구조는 유지. +export function maskPrices(text: string): string { + return text + .replace(/₩\s?[0-9][0-9,]*/g, "₩***") + .replace(/[0-9][0-9,]*\s*(?=원)/g, "***") +} diff --git a/negodata/front/src/pages/not-found.tsx b/negodata/front/src/pages/not-found.tsx new file mode 100644 index 0000000..0595c3d --- /dev/null +++ b/negodata/front/src/pages/not-found.tsx @@ -0,0 +1,42 @@ +import {Link, useNavigate, useRouteError} from 'react-router'; +import {FileQuestion} from 'lucide-react'; +import { Typography } from '@/components/ui/typography'; + +// 매칭되는 라우트가 없을 때(catch-all '*') 떨어지는 404 페이지. +// 라우터 errorElement 로도 재사용 가능하도록 useRouteError 는 있으면 참고만 한다(없어도 동작). +export default function NotFoundPage() { + const navigate = useNavigate(); + const error = useRouteError() as {status?: number} | undefined; + const status = error?.status ?? 404; + + return ( +
+
+
+ +
+ {status} · 페이지를 찾을 수 없음 + + 요청하신 주소의 페이지가 존재하지 않거나, 이동·삭제되었습니다. +
+ 주소를 다시 확인해 주세요. +
+
+ + + 상품관리로 돌아가기 + +
+
+
+ ); +}