[feat] negodata/front: 협상완료 부가정보 협상현황 모달·아이콘 툴팁(base-ui)·헤더 회사명 모바일 전용

This commit is contained in:
Mina Choi 2026-07-22 15:54:05 +09:00
parent 08c1e66582
commit bb576af346
5 changed files with 146 additions and 44 deletions

View File

@ -502,16 +502,16 @@ function HeaderMeta({ user, today }: { user: SidebarUser; today: string }) {
<Typography as="div" variant="caption" className="hidden lg:block">
기준일시: <span className="font-semibold text-foreground">{today}</span>
</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" />
<Typography as="span" variant="caption" className="shrink-0">회사:</Typography>
<Typography
as="span"
variant="caption"
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}
</Typography>

View 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 }

View File

@ -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<string, unknown>;
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<string, unknown> | undefined)?.[f.key] }))
.filter((r) => r.value !== undefined && r.value !== null && r.value !== '');
return (
<div className="h-[500px] border border-border rounded-lg overflow-hidden bg-card flex">
{/* 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 (
<button
<div
key={sd.session_id}
onClick={() => onSelectSession(sd.session_id)}
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()}` : '-'}
</Typography>
</div>
</button>
</div>
);
})}
</div>
@ -155,11 +155,11 @@ export function ChatTab({
</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">
<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">
{r.label}: <span className="font-semibold text-foreground">{String(r.value)}</span>
</Typography>
@ -199,6 +199,7 @@ export function ChatTab({
)}
</div>
</div>
</div>
);
}

View File

@ -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<typeof mapServerSessionView>;
@ -37,6 +39,13 @@ export function SessionsStatusTab({
/** 고른 협력사를 낙찰 처리. 성공 시 true. */
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 [sendingId, setSendingId] = useState<string | null>(null);
const [selectedWinnerId, setSelectedWinnerId] = useState<string | null>(null);
@ -220,27 +229,48 @@ export function SessionsStatusTab({
>
{sess.supplier_name}
</Link>
<button
onClick={() => onOpenChat(sess.session_id)}
title="협상 대화방으로 이동"
className="p-1 hover:bg-primary/10 rounded text-primary hover:text-primary/80 transition-colors cursor-pointer shrink-0"
>
<MessageSquare size={13} />
</button>
<Tooltip>
<TooltipTrigger render={
<button
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>
} />
<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>
</TableCell>
<TableCell className="p-2 font-mono text-center w-10">
{sess.url ? (
<button
onClick={() => {
void navigator.clipboard?.writeText(sess.url);
showToast('협상 URL을 복사했습니다.', 'success');
}}
title="협상 URL 복사"
className="p-1 hover:bg-muted rounded text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<Copy size={12} />
</button>
<Tooltip>
<TooltipTrigger render={
<button
onClick={() => {
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"
>
<Copy size={12} />
</button>
} />
<TooltipContent>협상 URL 복사</TooltipContent>
</Tooltip>
) : (
<span className="text-muted-foreground">-</span>
)}
@ -257,20 +287,20 @@ export function SessionsStatusTab({
<Mail size={11} /> 미발송
</span>
)}
<button
onClick={() => handleOne(sess.session_id, sess.supplier_name, !!sess.email_sent_at)}
disabled={!canNotify || sendingId === sess.session_id}
title={
!canNotify
? '본인이 생성한 견적만 초청 메일을 발송할 수 있습니다.'
: sess.email_sent_at
? '이 협력사에게 초청 메일 재발송'
: '이 협력사에게 초청 메일 발송'
}
className="p-1 rounded text-primary hover:bg-primary/10 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
<Send size={12} />
</button>
<Tooltip>
<TooltipTrigger render={
<button
onClick={() => 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"
>
<Send size={12} />
</button>
} />
<TooltipContent>
{!canNotify ? '본인 견적만 발송 가능' : sess.email_sent_at ? '초청 메일 재발송' : '초청 메일 발송'}
</TooltipContent>
</Tooltip>
</div>
{sess.email_sent_at && (
<span className="text-muted-foreground text-[10px] font-mono">{sess.email_sent_at}</span>
@ -311,6 +341,35 @@ export function SessionsStatusTab({
</TableBody>
</Table>
</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>
);
}

View File

@ -250,6 +250,7 @@ export type SessionView = {
end_time: string;
url: string; // 세션 chat 실행 URL(공급사 협상 프론트)
email_sent_at: string | null; // 초청 메일 발송 시각(KST). null=미발송 → 발송배지/재발송 판단
custom: Record<string, unknown>; // 협상완료 부가정보(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<string, unknown>,
};
}