- 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>
509 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|