[feat] negodata/front: 협상 채팅·견적 리스트 개선 및 404 페이지 추가

- 협상 채팅 말풍선에서 협력사 제시가 마스킹 + 챗버블 컴포넌트 분리
- 견적 리스트에 재생성된 다음 차수 표시
- 매칭 없는 경로용 404 폴백 페이지 추가
- 견적 재생성 모달 너비 확대

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-06-25 10:50:36 +09:00
parent b4dc862912
commit 05fe0de5ce
6 changed files with 359 additions and 171 deletions

View File

@ -4,6 +4,7 @@ import {isLoggedIn} from '../stores/auth';
import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout'; import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout';
import LoginPage from '../pages/login'; import LoginPage from '../pages/login';
import ForbiddenPage from '../pages/forbidden'; import ForbiddenPage from '../pages/forbidden';
import NotFoundPage from '../pages/not-found';
import ProductsPage from '../pages/products'; import ProductsPage from '../pages/products';
import PartnersPage from '../pages/partners'; import PartnersPage from '../pages/partners';
import QuotationPage from '../pages/quotation'; import QuotationPage from '../pages/quotation';
@ -57,4 +58,8 @@ export const router = createBrowserRouter([
{path: 'cards', Component: CardsPage}, {path: 'cards', Component: CardsPage},
], ],
}, },
{
path: '*',
Component: NotFoundPage,
},
]); ]);

View File

@ -3,10 +3,11 @@ import type { SessionData } from '@/api/generated/model/sessionData';
import type { QuotationCardData } from '@/api/generated/model/quotationCardData'; import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
import type { ChatMessageData } from '@/api/generated/model/chatMessageData'; import type { ChatMessageData } from '@/api/generated/model/chatMessageData';
import { ChatSender, CardType } from '@/api/generated/model'; import { ChatSender, CardType } from '@/api/generated/model';
import { Input } from '@/components/ui/input';
import SlateRenderer from '@/components/SlateRenderer'; import SlateRenderer from '@/components/SlateRenderer';
import { Typography } from '@/components/ui/typography';
import { StatusPill, sessionStatusTone } from './StatusPill'; import { StatusPill, sessionStatusTone } from './StatusPill';
import { type Product, type Partner, sessionStatusLabel } from '../../types'; import { type Product, type Partner, sessionStatusLabel } from '../../types';
import { maskPrices } from '@/lib/utils';
export function ChatTab({ export function ChatTab({
serverSessions, serverSessions,
@ -34,14 +35,14 @@ export function ChatTab({
<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 */}
<div className="w-1/3 border-r border-border bg-muted/20 flex flex-col"> <div className="w-1/3 border-r border-border bg-muted/20 flex flex-col">
<div className="p-3 border-b border-border bg-muted/40 font-mono text-[10px] text-muted-foreground uppercase"> <Typography as="div" variant="mono" className="p-3 border-b border-border bg-muted/40 text-[10px]">
참여자 협력사 리스트 참여자 협력사 리스트
</div> </Typography>
<div className="flex-1 min-h-0 overflow-y-auto divide-y divide-border font-sans"> <div className="flex-1 min-h-0 overflow-y-auto divide-y divide-border font-sans">
{serverSessions.length === 0 && ( {serverSessions.length === 0 && (
<div className="p-4 text-center text-muted-foreground text-xs font-mono"> <Typography as="div" variant="small" className="p-4 text-center text-muted-foreground text-xs font-mono">
참여 협상 세션이 없습니다. (리스트가 비어 있습니다) 참여 협상 세션이 없습니다. (리스트가 비어 있습니다)
</div> </Typography>
)} )}
{serverSessions.map((sd) => { {serverSessions.map((sd) => {
const isSelected = sd.session_id === effectiveSessionId; const isSelected = sd.session_id === effectiveSessionId;
@ -56,16 +57,16 @@ export function ChatTab({
}`} }`}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-bold text-foreground text-xs">{name}</span> <Typography as="span" variant="small" className="text-xs font-bold text-foreground">{name}</Typography>
<StatusPill tone={sessionStatusTone(sd.status)} className="text-[9px] px-1.5 rounded"> <StatusPill tone={sessionStatusTone(sd.status)} className="text-[9px] px-1.5 rounded">
{statusLabel} {statusLabel}
</StatusPill> </StatusPill>
</div> </div>
<div className="flex items-center justify-between text-[10px] font-mono text-muted-foreground mt-2"> <div className="flex items-center justify-between mt-2">
<span>최종 제의</span> <Typography as="span" variant="small" className="text-[10px] font-mono text-muted-foreground">최종 제의</Typography>
<span className="font-bold text-foreground"> <Typography as="span" variant="small" className="text-[10px] font-mono font-bold text-foreground">
{sd.bid_price ? `₩${Number(sd.bid_price).toLocaleString()}` : '-'} {sd.bid_price ? `₩${Number(sd.bid_price).toLocaleString()}` : '-'}
</span> </Typography>
</div> </div>
</button> </button>
); );
@ -75,162 +76,237 @@ export function ChatTab({
{/* Chat zone */} {/* Chat zone */}
<div className="flex-1 flex flex-col bg-background justify-between"> <div className="flex-1 flex flex-col bg-background justify-between">
<div className="p-3 bg-muted/30 border-b border-border text-xs flex items-center justify-between font-mono"> <div className="p-3 bg-muted/30 border-b border-border flex items-center justify-between">
<div className="text-muted-foreground"> <Typography as="div" variant="small" className="text-xs font-mono text-muted-foreground">
협력사: <strong className="text-foreground">{currentSupplierName}</strong> 협력사: <Typography as="span" variant="small" className="text-xs font-mono font-bold text-foreground">{currentSupplierName}</Typography>
</div> </Typography>
<div className="flex items-center gap-4 text-xs text-muted-foreground"> <div className="flex items-center gap-4">
{targetPrice != null && ( {targetPrice != null && (
<span> <Typography as="span" variant="small" className="text-xs font-mono text-muted-foreground">
목표가: <strong className="text-foreground">₩{Number(targetPrice).toLocaleString()}</strong> 목표가: <Typography as="span" variant="small" className="text-xs font-mono font-bold text-foreground">₩{Number(targetPrice).toLocaleString()}</Typography>
</span> </Typography>
)} )}
<span> <Typography as="span" variant="small" className="text-xs font-mono text-muted-foreground">
기록: <span className="font-semibold text-foreground">{chatMessages.length}</span> 메시지 기록: <Typography as="span" variant="small" className="text-xs font-mono font-semibold text-foreground">{chatMessages.length}</Typography> 메시지
</span> </Typography>
</div> </div>
</div> </div>
<div className="flex-1 min-h-0 p-4 overflow-y-auto space-y-4"> <div className="flex-1 min-h-0 p-4 overflow-y-auto space-y-4">
{!effectiveSessionId ? ( {!effectiveSessionId ? (
<div className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs"> <Typography as="div" variant="small" className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs">
선택된 협력사가 없습니다. 선택된 협력사가 없습니다.
</div> </Typography>
) : chatMessages.length === 0 ? ( ) : chatMessages.length === 0 ? (
<div className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs"> <Typography as="div" variant="small" className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs">
기록된 협상 대화가 없습니다. 기록된 협상 대화가 없습니다.
</div> </Typography>
) : ( ) : (
chatMessages.map((m) => { chatMessages.map((m) =>
const isBot = m.sender === ChatSender.BOT; m.sender === ChatSender.BOT ? (
// 메시지가 쓴 협상카드 전체(이름만이 아니라 멘트/조건/메모까지) 를 chat_id 로 매칭. <BotBubble
const usedCard = m.card_used_yn key={m.chat_id}
? serverCards.find((c) => c.session_card_id === m.chat_id) message={m}
: undefined; currentSupplierName={currentSupplierName}
const cardNodes = Array.isArray(usedCard?.edit_script) ? (usedCard.edit_script as unknown[]) : null; currentProduct={currentProduct}
const isWildCard = usedCard?.type === CardType.WILD; serverCards={serverCards}
return ( />
<div key={m.chat_id} className={`flex ${isBot ? 'justify-start' : 'justify-end'}`}> ) : (
<div className="space-y-1 max-w-[85%]"> <PartnerBubble
<div key={m.chat_id}
className={`text-[10px] text-muted-foreground font-mono flex items-center gap-1.5 ${ message={m}
isBot ? '' : 'justify-end' currentSupplierName={currentSupplierName}
}`} currentProduct={currentProduct}
> serverCards={serverCards}
<span>{isBot ? 'Negosium Bot' : currentSupplierName}</span> />
<span>·</span> ),
<span>#{m.index}</span> )
</div>
<div
className={`p-3 rounded-md border text-xs shadow-xs ${
isBot
? 'bg-secondary border-border text-foreground'
: 'bg-primary border-transparent text-primary-foreground'
}`}
>
{/* 진행 단계(chats.meta.step). 주로 봇 턴에만 존재. */}
{m.step && (
<div className="text-[10px] font-mono uppercase tracking-wide opacity-60 mb-1">
{m.step}
</div>
)}
{/* 말풍선 멘트(chats.meta.script). 봇=협상 스크립트, 협력사=입력값. */}
{m.script && (
<p className="whitespace-pre-line leading-relaxed mb-1.5">{m.script}</p>
)}
{/* 제시가: 협력사(user)가 실제로 제시한 가격만 표시. 목표가는 헤더 고정.
가격 제시 턴이 아니면(target_price=0) 숨긴다(₩0 오표시 방지). */}
{!isBot && m.target_price > 0 && (
<div className="font-bold">제시가 ₩{Number(m.target_price).toLocaleString()}</div>
)}
{usedCard && (
<div
className={`mt-2 rounded border p-2 ${
isBot
? 'bg-amber-50/70 border-amber-200 dark:bg-amber-950/20 dark:border-amber-900/40'
: 'bg-white/10 border-white/20'
}`}
>
{/* 헤더: 어떤 카드인지(번호·이름·종류) */}
<div
className={`flex items-center gap-1 text-[10px] font-semibold ${
isBot ? 'text-amber-800 dark:text-amber-300' : 'text-primary-foreground'
}`}
>
<Sparkles size={10} />
<span>협상카드</span>
{usedCard.number && <span className="font-mono opacity-70">#{usedCard.number}</span>}
{usedCard.name && <span>· {usedCard.name}</span>}
<span
className={`ml-auto px-1.5 py-0.5 rounded font-bold ${
isWildCard
? 'bg-amber-200 text-amber-900 dark:bg-amber-400/25 dark:text-amber-100'
: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-600/40 dark:text-zinc-100'
}`}
>
{isWildCard ? '와일드' : '협상'}
</span>
</div>
{/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script */}
{cardNodes ? (
<div className="mt-1.5">
<SlateRenderer
nodes={cardNodes}
variables={{
target_price: m.target_price,
partner_name: currentSupplierName,
product_name: currentProduct?.name ?? '',
}}
/>
</div>
) : usedCard.script ? (
<p className="mt-1.5 text-xs leading-relaxed whitespace-pre-line text-foreground/85">
{usedCard.script}
</p>
) : null}
{/* 와일드카드 부가 정보: 사용 조건 / 메모 */}
{isWildCard && (usedCard.condition || usedCard.memo) && (
<div className="mt-1.5 pt-1.5 border-t border-amber-200/60 dark:border-amber-900/40 space-y-0.5 text-[10px] text-muted-foreground">
{usedCard.condition && (
<div>
<span className="font-semibold">조건:</span> {usedCard.condition}
</div>
)}
{usedCard.memo && (
<div>
<span className="font-semibold">메모:</span> {usedCard.memo}
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
})
)} )}
</div> </div>
<div className="p-3 border-t border-border bg-muted/20 flex gap-2">
<Input
type="text"
disabled
placeholder="이 대화방은 입찰 참여 세션 기록이므로 정독 전용입니다."
className="flex-1 text-xs text-muted-foreground"
/>
<button
disabled
className="py-1.5 px-3 bg-muted text-muted-foreground text-xs rounded border border-border cursor-not-allowed"
>
전송
</button>
</div>
</div> </div>
</div> </div>
); );
} }
// 봇(좌측) 말풍선. 진행 단계·협상 스크립트(가격 마스킹)·사용 협상카드를 보여준다.
// 간격은 부모(flex flex-col gap)에서 주고, 말풍선 박스는 block 으로 둬 긴 텍스트 줄바꿈이 깨지지 않게 한다.
function BotBubble({
message,
currentSupplierName,
currentProduct,
serverCards,
}: {
message: ChatMessageData;
currentSupplierName: string;
currentProduct: Product | undefined;
serverCards: QuotationCardData[];
}) {
const m = message;
return (
<div className="flex justify-start">
<div className="flex flex-col gap-1.5 max-w-[85%]">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Typography as="span" variant="small" className="text-[10px] font-mono text-inherit">Negosium Bot</Typography>
<Typography as="span" variant="small" className="text-[10px] font-mono text-inherit">·</Typography>
<Typography as="span" variant="small" className="text-[10px] font-mono text-inherit">#{m.index}</Typography>
</div>
<div className="p-3 rounded-md border shadow-xs bg-secondary border-border text-foreground space-y-1.5">
{m.step && (
<Typography as="div" variant="mono" className="text-[10px] tracking-wide opacity-60 text-inherit">
{m.step}
</Typography>
)}
{m.script && (
<Typography as="p" variant="small" className="text-xs whitespace-pre-line leading-relaxed text-inherit">
{maskPrices(m.script)}
</Typography>
)}
<UsedCardBox
message={m}
isBot
currentSupplierName={currentSupplierName}
currentProduct={currentProduct}
serverCards={serverCards}
/>
</div>
</div>
</div>
);
}
// 협력사(우측) 말풍선. 협력사 입력값을 보여주되, 채팅 '내용'에 제시 금액이 노출되지 않도록 maskPrices 로 가린다.
function PartnerBubble({
message,
currentSupplierName,
currentProduct,
serverCards,
}: {
message: ChatMessageData;
currentSupplierName: string;
currentProduct: Product | undefined;
serverCards: QuotationCardData[];
}) {
const m = message;
return (
<div className="flex justify-end">
<div className="flex flex-col gap-1.5 max-w-[85%]">
<div className="flex items-center justify-end gap-1.5 text-muted-foreground">
<Typography as="span" variant="small" className="text-[10px] font-mono text-inherit">{currentSupplierName}</Typography>
<Typography as="span" variant="small" className="text-[10px] font-mono text-inherit">·</Typography>
<Typography as="span" variant="small" className="text-[10px] font-mono text-inherit">#{m.index}</Typography>
</div>
<div className="p-3 rounded-md border shadow-xs bg-primary border-transparent text-primary-foreground space-y-1.5">
{m.step && (
<Typography as="div" variant="mono" className="text-[10px] tracking-wide opacity-60 text-inherit">
{m.step}
</Typography>
)}
{m.script && (
<Typography as="p" variant="small" className="text-xs whitespace-pre-line leading-relaxed text-inherit">
{maskPrices(m.script)}
</Typography>
)}
<UsedCardBox
message={m}
isBot={false}
currentSupplierName={currentSupplierName}
currentProduct={currentProduct}
serverCards={serverCards}
/>
</div>
</div>
</div>
);
}
// 말풍선에 붙는 협상카드 박스(봇/협력사 공용). 카드 미사용 메시지면 아무것도 렌더하지 않는다.
// 톤(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 (
<div
className={`rounded border p-2 ${
isBot
? 'bg-amber-50/70 border-amber-200 dark:bg-amber-950/20 dark:border-amber-900/40'
: 'bg-white/10 border-white/20'
}`}
>
{/* 헤더: 어떤 카드인지(번호·이름·종류) */}
<div className={`flex items-center gap-1 ${isBot ? 'text-amber-800 dark:text-amber-300' : 'text-primary-foreground'}`}>
<Sparkles size={10} />
<Typography as="span" variant="small" className="text-[10px] font-semibold text-inherit">협상카드</Typography>
{usedCard.number && (
<Typography as="span" variant="small" className="text-[10px] font-mono font-semibold opacity-70 text-inherit">#{usedCard.number}</Typography>
)}
{usedCard.name && (
<Typography as="span" variant="small" className="text-[10px] font-semibold text-inherit">· {usedCard.name}</Typography>
)}
<Typography
as="span"
variant="small"
className={`ml-auto px-1.5 py-0.5 rounded text-[10px] font-bold ${
isWildCard
? 'bg-amber-200 text-amber-900 dark:bg-amber-400/25 dark:text-amber-100'
: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-600/40 dark:text-zinc-100'
}`}
>
{isWildCard ? '와일드' : '협상'}
</Typography>
</div>
{/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script */}
{cardNodes ? (
<div className="mt-1.5">
<SlateRenderer
nodes={cardNodes}
variables={{
target_price: m.target_price,
partner_name: currentSupplierName,
product_name: currentProduct?.name ?? '',
}}
/>
</div>
) : usedCard.script ? (
<Typography as="p" variant="small" className="mt-1.5 text-xs leading-relaxed whitespace-pre-line text-foreground/85">
{usedCard.script}
</Typography>
) : null}
{/* 와일드카드 부가 정보: 사용 조건 / 메모 */}
{isWildCard && (usedCard.condition || usedCard.memo) && (
<div className="mt-1.5 pt-1.5 border-t border-amber-200/60 dark:border-amber-900/40 space-y-0.5 text-muted-foreground">
{usedCard.condition && (
<Typography as="div" variant="caption" className="text-[10px] text-inherit">
<Typography as="span" variant="caption" className="text-[10px] font-semibold text-inherit">조건:</Typography> {usedCard.condition}
</Typography>
)}
{usedCard.memo && (
<Typography as="div" variant="caption" className="text-[10px] text-inherit">
<Typography as="span" variant="caption" className="text-[10px] font-semibold text-inherit">메모:</Typography> {usedCard.memo}
</Typography>
)}
</div>
)}
</div>
);
}

View File

@ -43,7 +43,7 @@ export function RegenerateModal({ open, partners, sessionStatusBySupplier, defau
return ( return (
<div className="fixed inset-0 z-[55] flex items-center justify-center bg-black/40 backdrop-blur-xs"> <div className="fixed inset-0 z-[55] flex items-center justify-center bg-black/40 backdrop-blur-xs">
<div className="w-full max-w-md bg-card border border-border rounded-lg shadow-2xl p-6 animate-scale-up font-mono"> <div className="w-full max-w-xl bg-card border border-border rounded-lg shadow-2xl p-6 animate-scale-up font-mono">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border"> <div className="flex items-center justify-between pb-4 border-b border-border">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@ -1,7 +1,15 @@
import type { ReactNode } from 'react'; import { type ReactNode } from 'react';
import { Clock, Building2, Link2 } from 'lucide-react'; import { Clock, Building2, Link2, CornerDownRight } from 'lucide-react';
import { DataTable } from '@/components/ui/data-table'; 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'; import { QuotationType, QuotationStatus } from '@/api/generated/model';
type QuotationTableProps = { type QuotationTableProps = {
@ -44,13 +52,15 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo
const productName = product?.name ?? est.productName; // 목록에 없으면 서버 조인 상품명으로 폴백 const productName = product?.name ?? est.productName; // 목록에 없으면 서버 조인 상품명으로 폴백
return ( return (
<div> <div>
<span className="font-bold text-sm text-foreground hover:underline cursor-pointer block"> <Typography as="span" variant="small" className="block text-sm font-bold text-foreground hover:underline cursor-pointer">
{est.title} {est.title}
</span> </Typography>
<span className="text-[10px] text-muted-foreground font-mono flex items-center gap-1.5 mt-0.5"> <Typography as="span" variant="small" className="mt-0.5 flex items-center gap-1.5 text-[10px] font-mono text-muted-foreground">
<Building2 size={10} /> <Building2 size={10} />
대상 상품: {productName ?? '확인 불가'} (₩{(product?.price ?? 0).toLocaleString()}) 대상 상품: {productName ?? '확인 불가'} (₩{(product?.price ?? 0).toLocaleString()})
</span> </Typography>
{/* 이 견적에서 재생성된 다음 차수(직속 자식)만 행 밑에 표시 — 전체 체인 반복 X */}
<RegeneratedChild number={est.number} currentQtId={est.id} onOpenRound={onOpenDetail} />
</div> </div>
); );
}, },
@ -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" className="inline-flex items-center gap-1 hover:text-primary hover:underline cursor-pointer"
> >
<Link2 size={11} className="opacity-60" /> <Link2 size={11} className="opacity-60" />
{est.number} <Typography as="span" variant="small" className="text-xs text-inherit">{est.number}</Typography>
</button> </button>
) : ( ) : (
est.number <Typography as="span" variant="small" className="text-xs text-inherit">{est.number}</Typography>
), ),
}, },
{ {
header: '유형', header: '유형',
align: 'center', align: 'center',
cell: (est) => ( cell: (est) => (
<span <Typography
as="span"
variant="small"
className={`px-2 py-0.5 rounded-full text-[9px] font-bold ${ className={`px-2 py-0.5 rounded-full text-[9px] font-bold ${
est.type === QuotationType.RENEGO est.type === QuotationType.RENEGO
? 'bg-neutral-900 text-white dark:bg-zinc-100 dark:text-black' ? 'bg-neutral-900 text-white dark:bg-zinc-100 dark:text-black'
@ -88,24 +100,29 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo
}`} }`}
> >
{quotationTypeLabel(est.type)} {quotationTypeLabel(est.type)}
</span> </Typography>
), ),
}, },
{ {
header: '차수', header: '차수',
align: 'center', align: 'center',
cellClassName: 'font-bold font-mono text-sm', cell: (est) => (
cell: (est) => `${est.round}차`, <Typography as="span" variant="small" className="text-sm font-mono font-bold text-foreground">
{est.round}차
</Typography>
),
}, },
{ {
header: '견적상태', header: '견적상태',
align: 'center', align: 'center',
cell: (est) => ( cell: (est) => (
<span <Typography
as="span"
variant="small"
className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-[10px] font-semibold rounded-full border ${statusBadgeClass(est.status)}`} className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-[10px] font-semibold rounded-full border ${statusBadgeClass(est.status)}`}
> >
{quotationStatusLabel(est.status)} {quotationStatusLabel(est.status)}
</span> </Typography>
), ),
}, },
{ {
@ -114,22 +131,63 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo
cell: (est) => ( cell: (est) => (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Clock size={12} /> <Clock size={12} />
<span>{est.dueDate}</span> <Typography as="span" variant="small" className="text-xs text-inherit">{est.dueDate}</Typography>
</div> </div>
), ),
}, },
{ {
header: '생성일', header: '생성일',
cellClassName: 'font-mono text-muted-foreground whitespace-nowrap', cellClassName: 'font-mono text-muted-foreground whitespace-nowrap',
cell: (est) => est.createdDate ?? '-', cell: (est) => (
<Typography as="span" variant="small" className="text-xs text-inherit">{est.createdDate ?? '-'}</Typography>
),
}, },
{ {
header: '협력사수', header: '협력사수',
align: 'center', align: 'center',
cellClassName: 'font-mono font-bold text-foreground', cellClassName: 'font-mono font-bold text-foreground',
cell: (est) => `${est.participationCount}개사`, cell: (est) => (
<Typography as="span" variant="small" className="text-xs text-inherit">{est.participationCount}개사</Typography>
),
}, },
]} ]}
/> />
); );
} }
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 (
<div className="mt-1.5 flex items-center gap-1.5">
<CornerDownRight size={11} className="text-muted-foreground/50 shrink-0" />
<Typography as="span" variant="small" className="text-[10px] font-mono text-muted-foreground">
재생성됨
</Typography>
<button
type="button"
title={`${child.round}차 견적 · ${CHAIN_ROUND_STATE_LABEL[child.state]}`}
onClick={(e) => {
e.stopPropagation(); // 행 클릭(현재 견적 상세)과 분리
onOpenRound(child.qt_id);
}}
className="inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5 text-foreground hover:bg-muted-foreground/15 cursor-pointer transition-colors"
>
<Typography as="span" variant="small" className="text-[10px] font-mono font-bold text-inherit">
{child.round}차 · {CHAIN_ROUND_STATE_LABEL[child.state]}
</Typography>
</button>
</div>
);
}

View File

@ -4,3 +4,10 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) 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, "***")
}

View File

@ -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 (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-sm bg-card border border-border rounded-lg shadow-sm p-8 flex flex-col items-center text-center gap-3">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center">
<FileQuestion className="h-6 w-6 text-muted-foreground" />
</div>
<Typography variant="h3" as="h1">{status} · 페이지를 찾을 수 없음</Typography>
<Typography variant="muted">
요청하신 주소의 페이지가 존재하지 않거나, 이동·삭제되었습니다.
<br />
주소를 다시 확인해 주세요.
</Typography>
<div className="mt-2 flex items-center gap-2">
<button
type="button"
onClick={() => navigate(-1)}
className="py-2 px-4 border border-border text-foreground font-semibold rounded text-sm hover:bg-muted transition-colors cursor-pointer"
>
이전으로
</button>
<Link
to="/products"
className="py-2 px-4 bg-primary text-primary-foreground font-semibold rounded text-sm hover:bg-primary/95 transition-colors"
>
상품관리로 돌아가기
</Link>
</div>
</div>
</div>
);
}