[feat] negodata/front: 협상 채팅·견적 리스트 개선 및 404 페이지 추가
- 협상 채팅 말풍선에서 협력사 제시가 마스킹 + 챗버블 컴포넌트 분리 - 견적 리스트에 재생성된 다음 차수 표시 - 매칭 없는 경로용 404 폴백 페이지 추가 - 견적 재생성 모달 너비 확대 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b4dc862912
commit
05fe0de5ce
@ -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,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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, "***")
|
||||||
|
}
|
||||||
|
|||||||
42
negodata/front/src/pages/not-found.tsx
Normal file
42
negodata/front/src/pages/not-found.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user