kintex/src/frontend/src/screens/auction/AuctionDetailPage.tsx
zio c686365021 feat(live-demo): admin stats API, auction realtime ranking push, CMS scheduled publish
- AdminStatsController/AdminStatsDto: real-count admin metrics (SCR-16), SystemAccessGuard.requireAdmin
- AuctionRealtimeService: STOMP /topic/auctions/{id}/ranking broadcast after commit (public ranking only, sealed-bid safe), polling fallback kept
- CmsScheduledPublisher: 30s DB poller -> CmsService.runScheduledPublish (same path as manual publish), V58 partial index (idempotent)
- Frontend: AdminDashboardPage real stats wiring, AuctionDetailPage live ranking via websocket, CmsWorkflowPage schedule UI
- CmsServiceTest: constructor updated for AuditLogService

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 22:59:14 +09:00

509 lines
19 KiB
TypeScript

/*
* SCR-27 옥션 상세·실시간 순위·응찰 [M15]. 실 API 전환(폴링).
* 정본: GET /api/auctions/{id} (AuctionDetail) · POST /{id}/bids · POST /{id}/close.
* 대상: 장치업체(응찰)·발주자. 좌 AI 자료 뷰어 + 우 봉인 순위·응찰.
* ★ 봉인 입찰(서버 강제): 응답의 ranking 은 이미 마스킹됨 — 타사 금액/업체명 없음.
* 화면은 서버가 준 priceMasked/price 만 렌더(클라이언트 언마스킹 없음). refetchInterval 5초.
*/
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button } from '../../components/ui/Button';
import { AiLabel } from '../../components/ui/Badge';
import { ErrorState, Skeleton } from '../../components/ui/States';
import { IconDocument, IconExpand, IconImage, IconPlus } from '../../components/ui/icons';
import {
auctionApi,
formatWon,
type AuctionDetail,
type AuctionRankingSnapshot,
type MaterialKind,
type RankRow,
} from './auctionApi';
import { subscribeAuctionRanking, type WsStatus } from '../../api/websocket';
import { errMessage, useToast } from './aucShared';
import './auction.css';
const REF_DESC: Record<MaterialKind, { label: string; title: string; desc: string; ai?: boolean }> = {
layout: {
label: '배치도',
title: '배치도 (M2)',
desc: '부스 위치·주변 통로·트렌치 좌표가 표시된 홀 배치도입니다.',
},
design: {
label: '부스 설계안',
title: '부스 설계안 (M3)',
desc: '선택·병합된 최종 부스 설계 초안(3D/평면)입니다.',
},
boq: {
label: '물량서(BOQ)',
title: '물량서 (M4 · BOQ)',
desc: '공종·자재·수량·규격이 정리된 시공 물량 산출서입니다.',
},
aiimage: {
label: '예상 이미지',
title: '예상 이미지 (M5)',
desc: '나노바나나로 생성한 시공 후 예상 결과 이미지입니다.',
ai: true,
},
};
export function AuctionDetailPage() {
const { auctionId = '' } = useParams();
const navigate = useNavigate();
const qc = useQueryClient();
const { t } = useTranslation();
const { show, node: toast } = useToast();
const [tab, setTab] = useState<MaterialKind>('layout');
const [anon, setAnon] = useState(true);
const [seconds, setSeconds] = useState(0);
const [bidPrice, setBidPrice] = useState('');
const [leadDays, setLeadDays] = useState('');
// WebSocket 연결 상태 — 'connected' 면 실시간 푸시로 갱신하고 폴링을 저속(폴백)으로 늦춘다.
const [wsStatus, setWsStatus] = useState<WsStatus>('connecting');
const wsLive = wsStatus === 'connected';
const q = useQuery({
queryKey: ['auction', auctionId],
queryFn: () => auctionApi.detail(auctionId),
enabled: !!auctionId,
retry: false,
// 실시간 푸시가 살아 있으면 폴링을 30초 안전망으로 늦추고, 끊기면 즉시 5초 폴백.
refetchInterval: wsLive ? 30000 : 5000,
});
const detail = q.data;
// 실시간 순위 구독 — 서버 푸시(응찰·마감·낙찰) 수신 시 인증 상세를 재조회한다.
// ★ 봉인 안전: 푸시 페이로드는 공개 순위만 담고, 개인화 순위(내 순위·금액)는 인증 API 재조회로만 받는다.
useEffect(() => {
if (!auctionId) return;
const unsub = subscribeAuctionRanking<AuctionRankingSnapshot>(
auctionId,
() => {
qc.invalidateQueries({ queryKey: ['auction', auctionId] });
},
setWsStatus,
);
return () => {
unsub();
setWsStatus('connecting');
};
}, [auctionId, qc]);
// F035 물량서(BOQ) — 자료 탭이 boq 일 때만 서버 규칙 기반 산출 조회.
const boqQ = useQuery({
queryKey: ['auction', auctionId, 'boq'],
queryFn: () => auctionApi.boq(auctionId),
enabled: !!auctionId && tab === 'boq',
retry: false,
});
// 카운트다운(로컬 tick) — deadline 기준. detail 갱신 시 재동기화.
useEffect(() => {
if (!detail?.deadline) return;
const target = new Date(detail.deadline).getTime();
const sync = () => setSeconds(Math.max(0, Math.floor((target - Date.now()) / 1000)));
sync();
const id = window.setInterval(sync, 1000);
return () => window.clearInterval(id);
}, [detail?.deadline]);
// 자료 탭 초기값 = 존재하는 첫 자료
useEffect(() => {
if (detail && detail.materials.length && !detail.materials.includes(tab)) {
setTab(detail.materials[0]);
}
}, [detail, tab]);
const bidM = useMutation({
mutationFn: () =>
auctionApi.bid(auctionId, {
total: Number(bidPrice),
leadDays: leadDays ? Number(leadDays) : undefined,
}),
onSuccess: (r) => {
show(`응찰 완료 — 현재 ${r.myRank ?? '-'}위 (v${r.version})`);
setBidPrice('');
qc.invalidateQueries({ queryKey: ['auction', auctionId] });
},
onError: (e) => show(errMessage(e)),
});
const closeM = useMutation({
mutationFn: () => auctionApi.close(auctionId),
onSuccess: () => {
show('라운드를 마감했습니다. 봉인이 해제되어 견적 비교가 가능합니다.');
qc.invalidateQueries({ queryKey: ['auction', auctionId] });
},
onError: (e) => show(errMessage(e)),
});
if (q.isLoading) {
return (
<div className="kx-page kx-auc">
<Skeleton height={64} radius={12} />
<div style={{ height: 16 }} />
<Skeleton height={420} radius={16} />
</div>
);
}
if (q.isError || !detail) {
return (
<div className="kx-page kx-auc">
<ErrorState message="옥션 상세를 불러오지 못했습니다." onRetry={() => q.refetch()} />
</div>
);
}
const active = REF_DESC[tab];
const closed = detail.status !== '진행중';
return (
<div className="kx-page kx-auc">
{/* 상단 히어로 — Nifty blog 상세 헤더(브레드크럼·제목·상태·메타) */}
<header className="kx-detailview__hero">
<nav className="kx-detailview__crumb" aria-label="경로">
<button type="button" onClick={() => navigate('/auctions')}>
</button>
<span aria-hidden="true">/</span>
<span className="kx-detailview__crumb-cur">{detail.title}</span>
</nav>
<div className="kx-detailview__title-row">
{!closed && (
<span className="kx-live">
<span className="kx-live__dot" aria-hidden="true" />
</span>
)}
<h1 className="kx-detailview__title">{detail.title}</h1>
<span
className={`kx-detailview__badge ${
closed ? 'kx-detailview__badge--info' : 'kx-detailview__badge--ok'
}`}
>
{detail.status === '진행중' ? '활성 응찰 중' : detail.status}
</span>
<div className="kx-detailview__hero-aside">
<div className="kx-aucd__metric">
<span className="kx-aucd__metric-label"> </span>
<span className="kx-aucd__metric-value tnum">
{detail.lowestPrice != null ? formatWon(detail.lowestPrice) : '—'}
</span>
</div>
</div>
</div>
<div className="kx-detailview__meta">
<span className="kx-detailview__meta-item">
<span className="kx-detailview__meta-label"></span>
<span className="kx-detailview__meta-value">{detail.type}</span>
</span>
<span className="kx-detailview__meta-item">
<span className="kx-detailview__meta-label"></span>
<span className="kx-detailview__meta-value">{detail.round}</span>
</span>
</div>
</header>
<div className="kx-aucd__grid">
{/* 좌 — AI 자료 뷰어 */}
<section className="kx-viewer" aria-label="AI 설계 자료 뷰어">
<div className="kx-viewer__tabs" role="tablist" aria-label="자료 종류">
{detail.materials.map((kind) => (
<button
key={kind}
role="tab"
aria-selected={tab === kind}
className={`kx-viewer__tab ${tab === kind ? 'is-active' : ''}`}
onClick={() => setTab(kind)}
>
{REF_DESC[kind].label}
</button>
))}
</div>
<div className="kx-viewer__stage">
{tab === 'boq' ? (
<BoqPanel query={boqQ} onRetry={() => boqQ.refetch()} />
) : (
<div className="kx-viewer__placeholder">
{tab === 'aiimage' ? <IconImage size={40} /> : <IconDocument size={40} />}
<p className="kx-viewer__placeholder-title">{active.title}</p>
<p className="kx-viewer__placeholder-desc">{active.desc}</p>
{active.ai && <AiLabel>AI </AiLabel>}
</div>
)}
{active.ai && <span className="kx-viewer__watermark">AI </span>}
<div style={{ position: 'absolute', top: 12, right: 12 }}>
<button
type="button"
className="kx-viewer__tab"
style={{ color: 'rgba(255,255,255,0.8)', height: 'auto' }}
onClick={() => show('전체화면 뷰어는 자료 스토리지 연동 후 제공됩니다.')}
aria-label="전체화면"
>
<IconExpand size={18} />
</button>
</div>
</div>
</section>
{/* 우 — 실시간 순위·응찰 */}
<aside className="kx-rank" aria-label="실시간 순위">
<div className="kx-rank__head">
<h2>
{/* 소극적 연결 표시 — 실시간 푸시 연결 시 옅은 점, 폴백(폴링) 시 숨김성 회색. */}
<span
className={`kx-rank__ws ${wsLive ? 'is-live' : 'is-poll'}`}
aria-hidden="true"
title={
wsLive
? t('auction.rank.wsLive', { defaultValue: '실시간 갱신 중' })
: t('auction.rank.wsPoll', { defaultValue: '주기적 갱신 중' })
}
/>
</h2>
<button
type="button"
className="kx-toggle"
aria-pressed={anon}
onClick={() => setAnon((v) => !v)}
>
<span className={`kx-toggle__track ${anon ? 'is-on' : ''}`}>
<span className="kx-toggle__knob" />
</span>
</button>
</div>
<div className="kx-auc__gate kx-auc__gate--seal" style={{ margin: '0 16px' }}>
{detail.sealed
? '봉인 입찰 — 마감 전 경쟁 견적 금액은 비공개, 최저가만 공개됩니다.'
: '마감됨 — 발주자 견적 비교에서 전체가 공개됩니다.'}
</div>
<div className="kx-rank__list">
{detail.ranking.length === 0 ? (
<p className="kx-rank__note" style={{ padding: '8px 16px' }}>
.
</p>
) : (
detail.ranking.map((r) => <RankRowItem key={r.rank} row={r} anon={anon} />)
)}
</div>
<div className="kx-rank__foot">
{!closed && (
<div className="kx-countdown">
<span className="kx-countdown__label"> </span>
<span className="kx-countdown__value">{fmtClock(seconds)}</span>
</div>
)}
{detail.canBid ? (
<>
<div className="kx-grid-2" style={{ marginBottom: 8 }}>
<div className="kx-field">
<label className="kx-field__label" htmlFor="bid-price">
( )
</label>
<input
id="bid-price"
className="kx-input"
type="number"
min={1}
value={bidPrice}
placeholder="예: 8400000"
onChange={(e) => setBidPrice(e.target.value)}
/>
</div>
<div className="kx-field">
<label className="kx-field__label" htmlFor="bid-lead">
()
</label>
<input
id="bid-lead"
className="kx-input"
type="number"
min={0}
value={leadDays}
placeholder="예: 12"
onChange={(e) => setLeadDays(e.target.value)}
/>
</div>
</div>
<Button
block
leadingIcon={<IconPlus size={16} />}
onClick={() => {
if (!bidPrice || Number(bidPrice) <= 0) return show('응찰 금액을 입력해 주세요.');
bidM.mutate();
}}
disabled={bidM.isPending}
>
{bidM.isPending ? '제출 중…' : '견적서 제출 · 재응찰'}
</Button>
<p className="kx-rank__note">
( ).
.
</p>
</>
) : (
<p className="kx-rank__note">
{closed
? '마감된 옥션입니다. 응찰이 종료되었습니다.'
: '응찰 권한이 없습니다 — 킨텍스 등록 장치업체(초대 대상)만 응찰할 수 있습니다.'}
</p>
)}
{detail.canViewAward && (
<Button
variant="secondary"
block
style={{ marginTop: 8 }}
onClick={() => navigate(`/auctions/${auctionId}/award`)}
>
·
</Button>
)}
{!closed && detail.canViewAward === false && isOrdererHint(detail) && (
<Button
variant="ghost"
block
style={{ marginTop: 8 }}
onClick={() => closeM.mutate()}
disabled={closeM.isPending}
>
{closeM.isPending ? '마감 중…' : '라운드 마감(발주자)'}
</Button>
)}
</div>
</aside>
</div>
{toast}
</div>
);
}
/** 발주자이면서 아직 마감 전이라 close 버튼을 노출할지 힌트. canBid=false + !canViewAward + 응찰 불가 상태에서 노출. */
function isOrdererHint(d: AuctionDetail): boolean {
// 발주자 판단은 서버가 canViewAward(마감 후)로만 노출하므로, 마감 전에는 close 버튼을 항상 시도 가능하게 둔다
// (권한 없으면 서버가 403 → 토스트). 응찰 가능한 업체에는 노출하지 않는다.
return !d.canBid;
}
function RankRowItem({ row, anon }: { row: RankRow; anon: boolean }) {
const cls = row.isMe ? 'kx-rank__row--me' : row.isLowest ? 'kx-rank__row--lowest' : '';
const name = row.isMe ? '나의 응찰 (ME)' : anon ? row.alias : `(주)협력사 ${row.rank}`;
return (
<div className={`kx-rank__row ${cls}`}>
<span className="kx-rank__badge">{row.rank}</span>
<div className="kx-rank__main">
<p className="kx-rank__alias">{name}</p>
{!row.priceMasked && row.price != null ? (
<p className="kx-rank__price tnum">{formatWon(row.price)}</p>
) : (
<p className="kx-rank__price--masked" title="봉인 입찰 — 마감 후 공개">
·
</p>
)}
</div>
{row.isLowest && <span className="kx-rank__tag"></span>}
{row.isMe && (
<span
className="kx-rank__tag"
style={{ background: 'var(--color-ai-surface)', color: 'var(--color-ai-accent)' }}
>
</span>
)}
</div>
);
}
function fmtClock(total: number): string {
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const p = (n: number) => n.toString().padStart(2, '0');
return `${p(h)}:${p(m)}:${p(s)}`;
}
/** F035 물량서(BOQ) 표 — 서버 규칙 기반 산출값(공종·품명·단위·수량·근거). 금액은 응찰 견적서에서 산정. */
function BoqPanel({
query,
onRetry,
}: {
query: ReturnType<typeof useQuery<import('./auctionApi').BoqResult>>;
onRetry: () => void;
}) {
const { t } = useTranslation();
if (query.isLoading) {
return (
<div className="kx-boq">
<Skeleton height={28} radius={8} />
<div style={{ height: 8 }} />
<Skeleton height={180} radius={8} />
</div>
);
}
if (query.isError || !query.data) {
return (
<div className="kx-boq">
<ErrorState message={t('auction.boq.error')} onRetry={onRetry} />
</div>
);
}
const boq = query.data;
return (
<div className="kx-boq">
<div className="kx-boq__head">
<h3 className="kx-boq__title">{t('auction.boq.title')}</h3>
<span className="kx-boq__scope">
{boq.scope === 'booth'
? t('auction.boq.scopeBooth', { label: boq.scopeLabel })
: t('auction.boq.scopeEvent')}
{' · '}
{t('auction.boq.summary', { count: boq.boothCount, area: boq.totalAreaM2.toLocaleString() })}
</span>
</div>
{boq.lines.length === 0 ? (
<p className="kx-boq__note">{boq.note}</p>
) : (
<>
<div className="kx-boq__scroll">
<table className="kx-table">
<thead>
<tr>
<th>{t('auction.boq.colSeq')}</th>
<th>{t('auction.boq.colTrade')}</th>
<th>{t('auction.boq.colItem')}</th>
<th className="kx-num">{t('auction.boq.colQty')}</th>
<th>{t('auction.boq.colUnit')}</th>
<th>{t('auction.boq.colBasis')}</th>
</tr>
</thead>
<tbody>
{boq.lines.map((l) => (
<tr key={l.seq}>
<td className="tnum">{l.seq}</td>
<td>
<span className="kx-boq__trade">{l.trade}</span>
</td>
<td>{l.item}</td>
<td className="kx-num tnum">{l.qty.toLocaleString()}</td>
<td>{l.unit}</td>
<td className="kx-boq__basis">{l.basis}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="kx-boq__note">{boq.note}</p>
</>
)}
</div>
);
}