feat(frontend): Stitch 디자인 전면 반영 + 로그인 인증 UI

- Booth Design Studio(SCR-06) Stitch 정합 + 4샷 그리드·Before/After
- 신규 8화면(참가업체홈·배치안비교·유틸신청·규정리포트·승인큐·검수상세 + 모바일 M1/M2) + App 라우트
- 로그인 인증 UI(회원가입·비번찾기/초기화·아이디기억·비번강도) + 번들 이미지 25장(워터마크 상시)
- 로컬 rollup win32 크래시로 vite는 서버빌드 신뢰(tsc 통과·에이전트 vite EXIT 0 확인)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-11 20:51:34 +09:00
parent 7c830ddea0
commit e41553ba59
56 changed files with 4252 additions and 40 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@ -1,11 +1,19 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { Navigate, Outlet, Route, Routes } from 'react-router-dom';
import { useAuthStore } from './store/authStore';
import { LoginPage } from './screens/login/LoginPage';
import { BoothLayoutEditorPage } from './screens/floorplan/BoothLayoutEditorPage';
import { LayoutComparisonPage } from './screens/floorplan/LayoutComparisonPage';
import { OrganizerDashboardPage } from './screens/dashboard/OrganizerDashboardPage';
import { ExhibitorHomePage } from './screens/exhibitor/ExhibitorHomePage';
import { BoothDesignStudioPage } from './screens/design/BoothDesignStudioPage';
import { UtilityWiringPage } from './screens/utility/UtilityWiringPage';
import { UtilityOrderSummaryPage } from './screens/utility/UtilityOrderSummaryPage';
import { ComplianceReportPage } from './screens/compliance/ComplianceReportPage';
import { ManagerApprovalQueuePage } from './screens/approval/ManagerApprovalQueuePage';
import { ReviewDetailsPage } from './screens/approval/ReviewDetailsPage';
import { VisualizationGalleryPage } from './screens/gallery/VisualizationGalleryPage';
import { ContractorOnsiteChecklistPage } from './screens/mobile/ContractorOnsiteChecklistPage';
import { ManagerFieldInspectionPage } from './screens/mobile/ManagerFieldInspectionPage';
import { AppShell } from './components/layout/AppShell';
/** 인증 가드 — 미인증 시 로그인으로. (역할별 라우팅은 화면 추가 시 확장) */
@ -20,7 +28,7 @@ export function App() {
<Routes>
<Route path="/login" element={<LoginPage />} />
{/* 인증 필요 — 사이드바 셸 안 */}
{/* 인증 필요 — 사이드바 셸 안 (데스크톱 1440) */}
<Route
element={
<RequireAuth>
@ -30,21 +38,61 @@ export function App() {
>
{/* SCR-02 주최자 대시보드 */}
<Route path="/events/:eventId/dashboard" element={<OrganizerDashboardPage />} />
{/* SCR-05 참가업체 홈 */}
<Route path="/events/:eventId/booths/:boothId/home" element={<ExhibitorHomePage />} />
{/* SCR-03 부스 배치 에디터 (M2) */}
<Route
path="/events/:eventId/halls/:hallId/layout"
element={<BoothLayoutEditorPage />}
/>
{/* SCR-04 AI 배치안 비교 */}
<Route
path="/events/:eventId/halls/:hallId/comparison"
element={<LayoutComparisonPage />}
/>
{/* SCR-06 부스 설계 스튜디오 (M3+M5) */}
<Route path="/events/:eventId/booths/:boothId/design" element={<BoothDesignStudioPage />} />
{/* SCR-07 유틸리티 배선 뷰 (M4a+M4b) */}
<Route path="/events/:eventId/booths/:boothId/utility" element={<UtilityWiringPage />} />
{/* SCR-08 유틸리티 신청 요약 */}
<Route
path="/events/:eventId/booths/:boothId/utility/order"
element={<UtilityOrderSummaryPage />}
/>
{/* SCR-09 규정 검증 결과 */}
<Route
path="/events/:eventId/booths/:boothId/compliance"
element={<ComplianceReportPage />}
/>
{/* SCR-10 검수 대기열 (홀매니저) */}
<Route
path="/events/:eventId/halls/:hallId/approvals"
element={<ManagerApprovalQueuePage />}
/>
{/* SCR-11 검수 상세 */}
<Route
path="/events/:eventId/halls/:hallId/booths/:booth/review"
element={<ReviewDetailsPage />}
/>
{/* SCR-12 시각화 갤러리 (M5) */}
<Route
path="/events/:eventId/booths/:boothId/gallery"
element={<VisualizationGalleryPage />}
/>
{/* 향후: SCR-05 부스홈, SCR-10 승인큐 등 */}
</Route>
{/* 모바일 전용 (390) — 셸 없이 전폭, 조회·현장 */}
<Route
element={
<RequireAuth>
<Outlet />
</RequireAuth>
}
>
{/* SCR-M1 장치업체 현장 체크리스트 */}
<Route path="/m/booths/:boothId/checklist" element={<ContractorOnsiteChecklistPage />} />
{/* SCR-M2 홀매니저 현장 검수 */}
<Route path="/m/booths/:boothId/inspection" element={<ManagerFieldInspectionPage />} />
</Route>
<Route path="*" element={<Navigate to="/login" replace />} />

View File

@ -11,13 +11,17 @@ import type {
DesignPlanDto,
DesignSaveRequest,
DesignSpec,
ForgotPasswordRequest,
KintexPrincipal,
LayoutDto,
LayoutSaveRequest,
LoginRequest,
LoginResponse,
RegisterRequest,
RegisterResponse,
RenderJobDto,
RenderJobRequest,
ResetPasswordRequest,
UtilityOrderDto,
UtilityQuote,
UtilityQuoteRequest,
@ -34,6 +38,15 @@ export const authApi = {
me: () => api.get<KintexPrincipal>('/api/auth/me'),
acceptInvite: (body: AcceptInviteRequest) =>
api.post<LoginResponse>('/api/auth/accept-invite', body),
// 회원가입 — 성공 시 상태(승인 대기/활성) 반환. 스켈레톤이면 501(호출부 degraded).
register: (body: RegisterRequest) =>
api.post<RegisterResponse>('/api/auth/register', body, { anonymous: true }),
// 비밀번호 찾기(요청) — 사용자 열거 방지 위해 백엔드는 항상 성공 처리.
forgotPassword: (body: ForgotPasswordRequest) =>
api.post<void>('/api/auth/password/forgot', body, { anonymous: true }),
// 비밀번호 재설정 — 이메일+인증코드+새 비밀번호.
resetPassword: (body: ResetPasswordRequest) =>
api.post<void>('/api/auth/password/reset', body, { anonymous: true }),
};
// ── M2 플로어플랜 (SCR-03/04) ──

View File

@ -68,6 +68,31 @@ export interface AcceptInviteRequest {
inviteCode: string;
companyRegistrationNo: string;
}
// 회원가입 (SCR-01 Signup)
export interface RegisterRequest {
email: string;
displayName: string;
password: string;
companyName?: string;
inviteCode?: string;
}
export interface RegisterResponse {
userId: string;
// 계정 상태 — 'ACTIVE'(즉시 로그인) | 'PENDING_APPROVAL'(승인 대기) 등 백엔드 권위
status: string;
message?: string;
}
// 비밀번호 찾기/초기화 (SCR-01 ForgotPassword 2단계)
export interface ForgotPasswordRequest {
email: string;
}
export interface ResetPasswordRequest {
email: string;
code: string;
newPassword: string;
}
export interface KintexPrincipal {
userId: string;
displayName: string;

View File

@ -15,6 +15,10 @@ interface AiImageProps {
status?: RenderJobDto['status'];
onRetry?: () => void;
alt?: string;
/** 번들 예시(사용자 부스 실 렌더 아님)임을 표시 — "예시" 칩 노출. */
sample?: boolean;
/** 예시 타일에서 "이 뷰 생성" 버튼 노출. */
onGenerate?: () => void;
}
export function AiImage({
@ -26,6 +30,8 @@ export function AiImage({
status = 'DONE',
onRetry,
alt = 'AI 생성 예상 이미지',
sample = false,
onGenerate,
}: AiImageProps) {
return (
<figure className="kx-aiimg">
@ -55,9 +61,16 @@ export function AiImage({
<div className="kx-aiimg__topbar">
<AiLabel>AI </AiLabel>
{sample && <span className="kx-aiimg__sample"></span>}
{shotLabel && <span className="kx-aiimg__shot">{shotLabel}</span>}
{versionTag && <span className="kx-aiimg__ver">{versionTag}</span>}
</div>
{sample && onGenerate && (
<button type="button" className="kx-aiimg__gen" onClick={onGenerate}>
<span aria-hidden="true"></span>
</button>
)}
</div>
{/* 고지문 — 항상 노출 */}
<figcaption className="kx-aiimg__notice">{notice}</figcaption>

View File

@ -95,6 +95,34 @@
font-size: 11px;
font-weight: 600;
}
.kx-aiimg__sample {
background: rgba(109, 74, 255, 0.9);
color: var(--color-white);
border-radius: var(--radius-pill);
padding: 2px 8px;
font-size: 11px;
font-weight: 700;
}
.kx-aiimg__gen {
position: absolute;
bottom: var(--space-2);
right: var(--space-2);
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--color-ai-accent);
color: var(--color-white);
border: none;
border-radius: var(--radius-sm);
padding: 6px 12px;
font-size: var(--fs-caption);
font-weight: 600;
cursor: pointer;
box-shadow: var(--shadow-level2);
}
.kx-aiimg__gen:hover {
background: #5b3aeb;
}
.kx-aiimg__notice {
font-size: 11px;
line-height: 15px;

View File

@ -0,0 +1,184 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Button } from '../../components/ui/Button';
import { DdayChip } from '../../components/ui/Badge';
import './approval-queue.css';
/*
* SCR-10 (). Stitch manager_approval_queue .
* - + (··AI ) + · (AI ) · : /AI
* . '검수' SCR-11 .
*/
interface DocRow {
id: string;
event: string;
booth: string;
docType: string;
submitter: string;
submittedAt: string;
dday: number;
ai: 'block' | 'pass';
aiCount: number;
status: string;
}
const ROWS: DocRow[] = [
{ id: 'd1', event: '스마트팩토리 코리아', booth: 'A-102', docType: '장치 도면', submitter: '(주)공간디자인', submittedAt: '07.10', dday: 6, ai: 'block', aiCount: 1, status: '검토 대기' },
{ id: 'd2', event: '스마트팩토리 코리아', booth: 'B-201', docType: '유틸리티 위치표시도', submitter: '미래테크(주)', submittedAt: '07.11', dday: 14, ai: 'pass', aiCount: 0, status: '검토 대기' },
{ id: 'd3', event: '스마트팩토리 코리아', booth: 'C-118', docType: '방염 확인서', submitter: '한빛로보틱스', submittedAt: '07.11', dday: 9, ai: 'pass', aiCount: 0, status: '검토 대기' },
{ id: 'd4', event: '스마트팩토리 코리아', booth: 'A-330', docType: '구조계산서', submitter: '(주)대한전시', submittedAt: '07.09', dday: 3, ai: 'block', aiCount: 2, status: '검토 대기' },
];
const HALLS = ['홀6', '홀7', '홀8'];
export function ManagerApprovalQueuePage() {
const { eventId = '', hallId = 'H7' } = useParams();
const navigate = useNavigate();
const [hall, setHall] = useState('홀7');
return (
<div className="kx-aq">
{/* 서브 헤더 */}
<header className="kx-aq__head">
<div className="kx-aq__head-top">
<div className="kx-aq__title-row">
<h1> </h1>
<div className="kx-aq__hall-tabs" role="tablist" aria-label="홀 선택">
{HALLS.map((h) => (
<button
key={h}
role="tab"
aria-selected={hall === h}
className={hall === h ? 'is-active' : ''}
onClick={() => setHall(h)}
>
{h}
</button>
))}
</div>
</div>
<div className="kx-aq__search">
<span aria-hidden="true"></span>
<input placeholder="검색어 입력…" aria-label="검색" />
</div>
</div>
<div className="kx-aq__stats">
<div className="kx-aq__stat">
<span> </span>
<strong className="tnum">23</strong>
</div>
<div className="kx-aq__stat">
<span> </span>
<strong className="tnum kx-aq__stat--primary">5</strong>
</div>
<div className="kx-aq__stat kx-aq__stat--alert">
<span> AI </span>
<strong className="tnum">4</strong>
</div>
</div>
</header>
<div className="kx-aq__body">
{/* 테이블 */}
<section className="kx-aq__main" aria-label={`제출 서류 목록 (${hall})`}>
<div className="kx-aq__main-head">
<h2> ({hall})</h2>
<Button leadingIcon="✓"> </Button>
</div>
<div className="kx-aq__table-wrap">
<table className="kx-aq__table">
<thead>
<tr>
<th className="kx-aq__col-check"><input type="checkbox" aria-label="전체 선택" /></th>
<th>/</th>
<th> </th>
<th></th>
<th></th>
<th></th>
<th className="kx-aq__col-ai">AI </th>
<th></th>
<th className="kx-aq__col-act"></th>
</tr>
</thead>
<tbody>
{ROWS.map((r) => (
<tr key={r.id}>
<td><input type="checkbox" aria-label={`${r.booth} 선택`} /></td>
<td>
<div className="kx-aq__event">{r.event}</div>
<div className="kx-aq__booth tnum">{r.booth}</div>
</td>
<td>{r.docType}</td>
<td className="kx-aq__muted">{r.submitter}</td>
<td className="kx-aq__muted tnum">{r.submittedAt}</td>
<td><DdayChip dday={r.dday} /></td>
<td>
{r.ai === 'block' ? (
<span className="kx-aq__ai kx-aq__ai--block"> {r.aiCount}</span>
) : (
<span className="kx-aq__ai kx-aq__ai--pass"> </span>
)}
</td>
<td className="kx-aq__muted">{r.status}</td>
<td className="kx-aq__col-act">
<button
className="kx-aq__review"
onClick={() =>
navigate(
`/events/${eventId}/halls/${hallId}/booths/${r.booth}/review`,
)
}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* 우 사이드 */}
<aside className="kx-aq__side" aria-label="홀 부하·인사이트">
<div className="kx-aq__card">
<h3>{hall} </h3>
<div className="kx-aq__load">
<div className="kx-aq__load-top">
<span> </span>
<strong className="tnum kx-aq__stat--primary">412kW</strong>
</div>
<div className="kx-aq__bar">
<div className="kx-aq__bar-fill" style={{ width: '75%' }} />
</div>
<div className="kx-aq__load-scale">
<span>0</span>
<span> 한도: 550kW</span>
</div>
</div>
<div className="kx-aq__load">
<div className="kx-aq__load-top">
<span> </span>
<strong className="tnum">128</strong>
</div>
<div className="kx-aq__bar">
<div className="kx-aq__bar-fill kx-aq__bar-fill--green" style={{ width: '40%' }} />
</div>
</div>
</div>
<div className="kx-aq__card kx-aq__card--ai">
<div className="kx-aq__ai-head">
<span aria-hidden="true"></span>
<h3>AI </h3>
</div>
<p>
A-102 85%.
.
</p>
</div>
</aside>
</div>
</div>
);
}

View File

@ -0,0 +1,199 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Button } from '../../components/ui/Button';
import { AiLabel } from '../../components/ui/Badge';
import './review-details.css';
/*
* SCR-11 . Stitch review_details .
* - 70% ( + AI , · ) / 30% AI +
* - 하단: 반려/ ( '확인함' )
* . / ( ).
*/
const MOCK = `${import.meta.env.BASE_URL}mock/review_details`;
const BLUEPRINT = `${MOCK}/img1.jpg`;
interface V {
pin: number;
title: string;
detail: string;
}
const VIOLATIONS: V[] = [
{ pin: 1, title: '높이 규정 위반 (목공 벽체)', detail: '제출된 도면의 후면 벽체 높이가 4.5m로, 해당 구역 제한 높이(4.0m)를 초과함.' },
{ pin: 2, title: '통로 여유 공간 부족', detail: '우측 전시물 배치가 지정된 통로 경계선으로부터 500mm 여유 공간을 확보하지 않음.' },
{ pin: 3, title: '소화기 배치 위치 불명확', detail: '안전 규정에 따른 필수 소화기 비치 위치가 도면 상에 명시되지 않음.' },
];
const TABS = ['도면', '예상 사진 (참고용)', '버전 비교'];
const PIN_POS = [
{ top: '30%', left: '45%' },
{ top: '60%', left: '20%' },
{ top: '50%', left: '68%' },
];
export function ReviewDetailsPage() {
const { booth = 'A-102' } = useParams();
const navigate = useNavigate();
const [tab, setTab] = useState(0);
const [overlay, setOverlay] = useState(true);
const [checked, setChecked] = useState<Record<number, boolean>>({});
const [decision, setDecision] = useState<'approved' | 'rejected' | null>(null);
const allChecked = VIOLATIONS.every((v) => checked[v.pin]);
return (
<div className="kx-rev">
{/* 헤더 */}
<header className="kx-rev__head">
<div>
<nav className="kx-rev__crumb" aria-label="경로">
<button onClick={() => navigate(-1)}> </button>
<span aria-hidden="true"></span>
<span> </span>
<span aria-hidden="true"></span>
<span className="kx-rev__crumb-cur"> {booth}</span>
</nav>
<div className="kx-rev__title-row">
<h1> </h1>
<span className="kx-rev__meta">버전: v2</span>
<span className="kx-rev__meta">: ()</span>
<span className="kx-rev__meta">일자: 07.10</span>
</div>
</div>
<Button variant="ghost" leadingIcon="⭳">
</Button>
</header>
<div className="kx-rev__body">
{/* 캔버스 */}
<section className="kx-rev__canvas" aria-label="도면 뷰어">
<div className="kx-rev__canvas-top">
<div className="kx-rev__tabs" role="tablist">
{TABS.map((t, i) => (
<button
key={t}
role="tab"
aria-selected={tab === i}
className={tab === i ? 'is-active' : ''}
onClick={() => setTab(i)}
>
{t}
</button>
))}
</div>
<button
className={`kx-rev__overlay-toggle ${overlay ? 'is-on' : ''}`}
onClick={() => setOverlay((o) => !o)}
aria-pressed={overlay}
>
<span aria-hidden="true"></span> AI
<span className="kx-rev__switch" aria-hidden="true">
<span className="kx-rev__switch-knob" />
</span>
</button>
</div>
<div className="kx-rev__viewer">
<div className="kx-rev__grid-bg" aria-hidden="true" />
<div className="kx-rev__drawing">
<img src={BLUEPRINT} alt="장치 도면" loading="lazy" />
{overlay &&
VIOLATIONS.map((v, i) => (
<span
key={v.pin}
className="kx-rev__pin"
style={PIN_POS[i]}
title={v.title}
>
{v.pin}
</span>
))}
</div>
<div className="kx-rev__zoom">
<button aria-label="확대">+</button>
<button aria-label="축소"></button>
<button aria-label="화면맞춤"></button>
</div>
</div>
</section>
{/* 우 패널 */}
<aside className="kx-rev__panel" aria-label="AI 사전심사·협의">
<div className="kx-rev__ai-card">
<div className="kx-rev__ai-card-head">
<AiLabel>AI </AiLabel>
<span className="kx-rev__ai-count">3 </span>
</div>
<ul className="kx-rev__vlist">
{VIOLATIONS.map((v) => (
<li key={v.pin}>
<span className="kx-rev__vpin" aria-hidden="true">{v.pin}</span>
<div>
<p className="kx-rev__vtitle">{v.title}</p>
<p className="kx-rev__vdetail">{v.detail}</p>
<label className="kx-rev__vcheck">
<input
type="checkbox"
checked={checked[v.pin] ?? false}
onChange={(e) =>
setChecked((c) => ({ ...c, [v.pin]: e.target.checked }))
}
/>
</label>
</div>
</li>
))}
</ul>
</div>
<div className="kx-rev__thread">
<h3> </h3>
<div className="kx-rev__comment">
<span className="kx-rev__avatar"></span>
<div>
<div className="kx-rev__comment-top">
<strong></strong>
<span>07.10 14:32</span>
</div>
<p> 4.0m .</p>
</div>
</div>
<div className="kx-rev__comment">
<span className="kx-rev__avatar kx-rev__avatar--con"></span>
<div>
<div className="kx-rev__comment-top">
<strong></strong>
<span>07.10 16:10</span>
</div>
<p>. v3 .</p>
</div>
</div>
</div>
</aside>
</div>
{/* 하단 액션 */}
<footer className="kx-rev__footer">
<p className="kx-rev__foot-note">
{decision === 'approved'
? '승인 처리되었습니다.'
: decision === 'rejected'
? '반려 처리되었습니다 — 제출자에게 통보됩니다.'
: allChecked
? '모든 위반 항목을 확인했습니다.'
: '위반 항목을 모두 확인해야 승인할 수 있습니다.'}
</p>
<div className="kx-rev__foot-actions">
<Button variant="danger" disabled={!!decision} onClick={() => setDecision('rejected')}>
</Button>
<Button disabled={!allChecked || !!decision} onClick={() => setDecision('approved')}>
</Button>
</div>
</footer>
</div>
);
}

View File

@ -0,0 +1,290 @@
.kx-aq {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.kx-aq__head {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
border-bottom: var(--border-card);
background: var(--color-white);
}
.kx-aq__head-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.kx-aq__title-row {
display: flex;
align-items: center;
gap: var(--space-4);
}
.kx-aq__title-row h1 {
font-size: var(--fs-h3);
font-weight: 700;
color: var(--color-neutral-900);
}
.kx-aq__hall-tabs {
display: flex;
gap: var(--space-2);
}
.kx-aq__hall-tabs button {
padding: 4px 14px;
border: 1px solid var(--color-neutral-200);
background: var(--color-white);
border-radius: var(--radius-pill);
font-size: var(--fs-caption);
color: var(--color-neutral-500);
cursor: pointer;
}
.kx-aq__hall-tabs button.is-active {
border-color: var(--color-primary-600);
background: var(--color-primary-050);
color: var(--color-primary-700);
font-weight: 700;
}
.kx-aq__search {
display: flex;
align-items: center;
gap: var(--space-2);
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-sm);
padding: 6px 10px;
color: var(--color-neutral-500);
}
.kx-aq__search input {
border: none;
outline: none;
font-size: var(--fs-body);
width: 220px;
}
.kx-aq__stats {
display: flex;
gap: var(--space-3);
}
.kx-aq__stat {
display: flex;
align-items: center;
gap: var(--space-2);
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-lg);
padding: var(--space-2) var(--space-4);
}
.kx-aq__stat span {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-aq__stat strong {
font-size: var(--fs-h3);
color: var(--color-neutral-900);
}
.kx-aq__stat--primary {
color: var(--color-primary-600) !important;
}
.kx-aq__stat--alert {
background: #fef3f2;
border-color: #fecdca;
}
.kx-aq__stat--alert span,
.kx-aq__stat--alert strong {
color: var(--color-error);
}
.kx-aq__body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.kx-aq__main {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
overflow: auto;
}
.kx-aq__main-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.kx-aq__main-head h2 {
font-size: var(--fs-h3);
color: var(--color-neutral-900);
}
.kx-aq__table-wrap {
border: var(--border-card);
border-radius: var(--radius-lg);
overflow: auto;
background: var(--color-white);
}
.kx-aq__table {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-body);
}
.kx-aq__table thead {
background: var(--color-primary-050);
position: sticky;
top: 0;
}
.kx-aq__table th {
text-align: left;
padding: var(--space-3);
font-size: var(--fs-caption);
color: var(--color-neutral-500);
font-weight: 600;
border-bottom: 1px solid var(--color-neutral-200);
}
.kx-aq__col-ai {
color: var(--color-ai-accent) !important;
}
.kx-aq__col-act {
text-align: right;
}
.kx-aq__table td {
padding: var(--space-3);
border-bottom: 1px solid var(--color-neutral-100);
vertical-align: middle;
}
.kx-aq__table tbody tr:hover {
background: var(--row-hover);
}
.kx-aq__event {
font-weight: 700;
color: var(--color-neutral-900);
}
.kx-aq__booth {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-aq__muted {
color: var(--color-neutral-500);
}
.kx-aq__ai {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 700;
padding: 3px 8px;
border-radius: var(--radius-sm);
}
.kx-aq__ai--block {
color: var(--color-error);
background: #fef3f2;
border: 1px solid #fecdca;
}
.kx-aq__ai--pass {
color: var(--color-success);
background: #e6f4ee;
border: 1px solid #a6e0c8;
}
.kx-aq__review {
border: 1px solid var(--color-neutral-200);
background: var(--color-white);
color: var(--color-primary-600);
border-radius: var(--radius-sm);
padding: 5px 14px;
font-size: var(--fs-caption);
font-weight: 600;
cursor: pointer;
}
.kx-aq__review:hover {
background: var(--color-primary-050);
}
/* side */
.kx-aq__side {
width: 320px;
border-left: var(--border-card);
background: var(--color-neutral-050);
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-4);
overflow-y: auto;
}
.kx-aq__card {
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-aq__card h3 {
font-size: var(--fs-h3);
color: var(--color-neutral-900);
border-bottom: 1px solid var(--color-neutral-100);
padding-bottom: var(--space-2);
}
.kx-aq__load {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.kx-aq__load-top {
display: flex;
align-items: flex-end;
justify-content: space-between;
}
.kx-aq__load-top span {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-aq__load-top strong {
font-size: var(--fs-h3);
color: var(--color-neutral-900);
}
.kx-aq__bar {
height: 8px;
background: var(--color-neutral-100);
border-radius: var(--radius-pill);
overflow: hidden;
}
.kx-aq__bar-fill {
height: 100%;
background: var(--color-primary-600);
}
.kx-aq__bar-fill--green {
background: var(--color-success);
}
.kx-aq__load-scale {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--color-neutral-500);
}
.kx-aq__card--ai {
border-color: var(--color-ai-accent);
background: var(--color-ai-surface);
}
.kx-aq__ai-head {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--color-ai-accent);
}
.kx-aq__ai-head h3 {
border: none;
padding: 0;
font-size: var(--fs-h3);
}
.kx-aq__card--ai p {
font-size: var(--fs-body);
line-height: var(--lh-body);
color: var(--color-neutral-700);
}
@media (max-width: 1100px) {
.kx-aq__side {
display: none;
}
}

View File

@ -0,0 +1,369 @@
.kx-rev {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.kx-rev__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-3) var(--space-5);
border-bottom: var(--border-card);
background: var(--color-white);
}
.kx-rev__crumb {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-caption);
color: var(--color-neutral-500);
margin-bottom: var(--space-1);
}
.kx-rev__crumb button {
border: none;
background: none;
color: var(--color-neutral-500);
cursor: pointer;
font-size: var(--fs-caption);
padding: 0;
}
.kx-rev__crumb button:hover {
color: var(--color-primary-600);
}
.kx-rev__crumb-cur {
color: var(--color-neutral-900);
}
.kx-rev__title-row {
display: flex;
align-items: center;
gap: var(--space-2);
}
.kx-rev__title-row h1 {
font-size: var(--fs-h3);
color: var(--color-neutral-900);
}
.kx-rev__meta {
font-size: 11px;
color: var(--color-neutral-500);
background: var(--color-neutral-100);
border: 1px solid var(--color-neutral-200);
padding: 2px 8px;
border-radius: var(--radius-sm);
}
.kx-rev__body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* 캔버스 */
.kx-rev__canvas {
flex: 7;
position: relative;
display: flex;
flex-direction: column;
background: var(--color-canvas-bg);
border-right: var(--border-card);
}
.kx-rev__canvas-top {
position: absolute;
top: var(--space-4);
left: var(--space-4);
right: var(--space-4);
z-index: 2;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
pointer-events: none;
}
.kx-rev__tabs {
display: flex;
gap: 2px;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: var(--radius-sm);
padding: 3px;
pointer-events: auto;
}
.kx-rev__tabs button {
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.7);
padding: 6px 14px;
border-radius: var(--radius-sm);
font-size: var(--fs-caption);
cursor: pointer;
}
.kx-rev__tabs button.is-active {
background: rgba(0, 102, 179, 0.35);
color: var(--color-white);
border: 1px solid rgba(0, 102, 179, 0.5);
}
.kx-rev__overlay-toggle {
display: flex;
align-items: center;
gap: var(--space-2);
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(8px);
border: 1px solid var(--color-ai-accent);
color: var(--color-white);
border-radius: var(--radius-sm);
padding: 7px 12px;
font-size: var(--fs-caption);
cursor: pointer;
pointer-events: auto;
box-shadow: 0 0 15px rgba(109, 74, 255, 0.2);
}
.kx-rev__switch {
width: 30px;
height: 16px;
border-radius: var(--radius-pill);
background: rgba(255, 255, 255, 0.25);
position: relative;
}
.kx-rev__overlay-toggle.is-on .kx-rev__switch {
background: var(--color-ai-accent);
}
.kx-rev__switch-knob {
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-white);
transition: left 0.15s;
}
.kx-rev__overlay-toggle.is-on .kx-rev__switch-knob {
left: 16px;
}
.kx-rev__viewer {
flex: 1;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.kx-rev__grid-bg {
position: absolute;
inset: 0;
opacity: 0.2;
background-image: radial-gradient(circle at 2px 2px, white 1px, transparent 0);
background-size: 40px 40px;
}
.kx-rev__drawing {
position: relative;
width: 78%;
height: 78%;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: var(--radius-sm);
overflow: hidden;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
}
.kx-rev__drawing img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.75;
mix-blend-mode: screen;
}
.kx-rev__pin {
position: absolute;
width: 30px;
height: 30px;
transform: translate(-50%, -50%);
border-radius: 50%;
background: var(--color-error);
color: var(--color-white);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: var(--fs-caption);
border: 2px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.kx-rev__zoom {
position: absolute;
bottom: var(--space-4);
right: var(--space-4);
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: var(--radius-sm);
overflow: hidden;
}
.kx-rev__zoom button {
border: none;
background: transparent;
color: var(--color-white);
padding: 8px 10px;
cursor: pointer;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
font-size: 15px;
}
.kx-rev__zoom button:last-child {
border-bottom: none;
}
.kx-rev__zoom button:hover {
background: rgba(255, 255, 255, 0.12);
}
/* 우 패널 */
.kx-rev__panel {
flex: 3;
min-width: 300px;
background: var(--color-white);
overflow-y: auto;
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.kx-rev__ai-card {
border: var(--border-card);
border-radius: var(--radius-lg);
overflow: hidden;
}
.kx-rev__ai-card-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3);
background: var(--color-ai-surface);
border-bottom: 1px solid var(--color-neutral-200);
}
.kx-rev__ai-count {
font-size: 11px;
font-weight: 700;
color: var(--color-error);
background: #fef3f2;
padding: 2px 8px;
border-radius: var(--radius-sm);
}
.kx-rev__vlist {
list-style: none;
padding: var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-rev__vlist li {
display: flex;
gap: var(--space-3);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-neutral-100);
}
.kx-rev__vlist li:last-child {
border-bottom: none;
padding-bottom: 0;
}
.kx-rev__vpin {
width: 24px;
height: 24px;
border-radius: 50%;
background: #fef3f2;
color: var(--color-error);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 12px;
flex-shrink: 0;
}
.kx-rev__vtitle {
font-size: var(--fs-body);
font-weight: 600;
color: var(--color-neutral-900);
margin-bottom: 2px;
}
.kx-rev__vdetail {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
margin-bottom: var(--space-2);
line-height: 16px;
}
.kx-rev__vcheck {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-caption);
color: var(--color-neutral-700);
cursor: pointer;
}
.kx-rev__thread h3 {
font-size: var(--fs-h3);
color: var(--color-neutral-900);
margin-bottom: var(--space-3);
}
.kx-rev__comment {
display: flex;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.kx-rev__avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--color-primary-100);
color: var(--color-primary-700);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.kx-rev__avatar--con {
background: var(--color-ai-surface);
color: var(--color-ai-accent);
}
.kx-rev__comment-top {
display: flex;
align-items: baseline;
gap: var(--space-2);
margin-bottom: 2px;
}
.kx-rev__comment-top strong {
font-size: var(--fs-caption);
color: var(--color-neutral-900);
}
.kx-rev__comment-top span {
font-size: 11px;
color: var(--color-neutral-500);
}
.kx-rev__comment p {
font-size: var(--fs-caption);
color: var(--color-neutral-700);
line-height: 16px;
}
/* 하단 */
.kx-rev__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-3) var(--space-5);
border-top: var(--border-card);
background: var(--color-white);
}
.kx-rev__foot-note {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-rev__foot-actions {
display: flex;
gap: var(--space-3);
}

View File

@ -0,0 +1,139 @@
import { useParams } from 'react-router-dom';
import { Button } from '../../components/ui/Button';
import { AiLabel } from '../../components/ui/Badge';
import type { ComplianceReport, Violation } from '../../api/types';
import './compliance-report.css';
/*
* SCR-09 . Stitch compliance_report .
* - (//)
* - : 자동 () / : 상세 (Elevation) +
* §4 precheck(ComplianceReport) shape ().
*/
const MOCK = `${import.meta.env.BASE_URL}mock/compliance_report`;
const ELEVATION = `${MOCK}/img1.jpg`;
const SAMPLE_REPORT: ComplianceReport = {
rulesetVersion: 'v1.0',
disclaimer: '본 검증은 사전 심사이며 최종 승인은 현장 검수·구조기술사 판단이 우선합니다.',
blockCount: 1,
warnCount: 2,
passCount: 14,
submittable: false,
violations: [
{ pin: 1, code: 'H-01', group: '높이 규정', label: '장치물 높이 5m 이하', severity: 'block', measured: '5.4m (제한 5m)' },
{ pin: 2, code: 'R-03', group: '리깅', label: '리깅 트러스 6.5m 초과 구조계산', severity: 'warn', measured: '7.0m', requiresDocument: '구조계산서 D-7' },
{ pin: 3, code: 'A-02', group: '통로', label: '통로 여유 공간 확보', severity: 'warn', measured: '통로 인접 80% 여유' },
],
};
const PASS_GROUPS = [
{ group: '퇴출구', label: '퇴출구 확보' },
{ group: '방염', label: '방염 자재 사용 확인' },
{ group: '전기', label: '전력 부하 적정' },
{ group: '표시', label: '통로 표시 도장 적정' },
];
const SEV_META: Record<Violation['severity'], { label: string; cls: string }> = {
block: { label: '차단', cls: 'block' },
warn: { label: '경고', cls: 'warn' },
};
export function ComplianceReportPage() {
const { boothId = 'A-102' } = useParams();
const report = SAMPLE_REPORT;
return (
<div className="kx-comp">
<header className="kx-comp__head">
<h1> {boothId} · v2 · </h1>
</header>
{/* 요약 배너 */}
<div
className={`kx-comp__banner ${report.submittable ? 'is-ok' : 'is-block'}`}
role="status"
>
<span className="kx-comp__banner-icon" aria-hidden="true">
{report.submittable ? '✓' : '⛔'}
</span>
<span className="tnum">
{report.blockCount} · {report.warnCount} · {report.passCount}
</span>
{!report.submittable && <span className="kx-comp__banner-note"> </span>}
</div>
{/* PDF 업로드 존 */}
<div className="kx-comp__drop">
<span className="kx-comp__drop-icon" aria-hidden="true"></span>
<p> (PDF/)</p>
<span className="kx-comp__drop-hint"> </span>
<AiLabel>AI </AiLabel>
</div>
<div className="kx-comp__body">
{/* 체크리스트 */}
<section className="kx-comp__list" aria-label="자동 검증 체크리스트">
<div className="kx-comp__list-head">
<h2> </h2>
<div className="kx-comp__legend">
<span className="kx-comp__lg kx-comp__lg--pass"></span>
<span className="kx-comp__lg kx-comp__lg--warn"></span>
<span className="kx-comp__lg kx-comp__lg--block"></span>
</div>
</div>
<ul className="kx-comp__items">
{report.violations.map((v) => (
<li key={v.code} className={`kx-comp__item is-${SEV_META[v.severity].cls}`}>
<span className="kx-comp__pin tnum" aria-hidden="true">{v.pin}</span>
<div className="kx-comp__item-body">
<div className="kx-comp__item-top">
<span className="kx-comp__item-label">{v.label}</span>
<span className={`kx-comp__sev kx-comp__sev--${SEV_META[v.severity].cls}`}>
{SEV_META[v.severity].label}
</span>
</div>
<span className="kx-comp__measured">
{v.group} · {v.measured}
</span>
{v.requiresDocument && (
<span className="kx-comp__doc">{v.requiresDocument} </span>
)}
</div>
</li>
))}
{PASS_GROUPS.map((p) => (
<li key={p.group} className="kx-comp__item is-pass">
<span className="kx-comp__pin kx-comp__pin--pass" aria-hidden="true"></span>
<div className="kx-comp__item-body">
<div className="kx-comp__item-top">
<span className="kx-comp__item-label">{p.label}</span>
<span className="kx-comp__sev kx-comp__sev--pass"></span>
</div>
</div>
</li>
))}
</ul>
</section>
{/* 상세 도면 검토 */}
<section className="kx-comp__drawing" aria-label="상세 도면 검토">
<div className="kx-comp__drawing-head">
<h2> (Elevation)</h2>
</div>
<figure className="kx-comp__drawing-frame">
<img src={ELEVATION} alt="상세 도면 (입면) 검토" loading="lazy" />
<span className="kx-comp__overlay-box" aria-hidden="true" />
<span className="kx-comp__overlay-badge"> 5.4m · COMPLIANCE </span>
</figure>
</section>
</div>
<footer className="kx-comp__footer">
<p className="kx-comp__disclaimer">{report.disclaimer} · {report.rulesetVersion}</p>
<Button disabled={!report.submittable}> </Button>
</footer>
</div>
);
}

View File

@ -0,0 +1,257 @@
.kx-comp {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-5);
max-width: var(--container-max);
}
.kx-comp__head h1 {
font-size: var(--fs-h1);
color: var(--color-neutral-900);
}
.kx-comp__banner {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
font-size: var(--fs-body);
font-weight: 600;
}
.kx-comp__banner.is-block {
background: #fef3f2;
color: var(--color-error);
border: 1px solid #fecdca;
}
.kx-comp__banner.is-ok {
background: #e6f4ee;
color: var(--color-success);
border: 1px solid #a6e0c8;
}
.kx-comp__banner-note {
font-weight: 400;
}
.kx-comp__drop {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
border: 1px dashed var(--color-neutral-200);
border-radius: var(--radius-lg);
background: var(--color-neutral-050);
padding: var(--space-6);
color: var(--color-neutral-500);
font-size: var(--fs-body);
}
.kx-comp__drop-icon {
font-size: 22px;
color: var(--color-ai-accent);
}
.kx-comp__drop-hint {
font-size: var(--fs-caption);
}
.kx-comp__body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
align-items: start;
}
.kx-comp__list {
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
.kx-comp__list-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3);
}
.kx-comp__list-head h2 {
font-size: var(--fs-h3);
color: var(--color-neutral-900);
}
.kx-comp__legend {
display: flex;
gap: var(--space-2);
}
.kx-comp__lg {
font-size: 11px;
font-weight: 600;
padding: 1px 8px;
border-radius: var(--radius-pill);
}
.kx-comp__lg--pass {
background: #e6f4ee;
color: var(--color-success);
}
.kx-comp__lg--warn {
background: var(--color-violation-warn-bg);
color: var(--color-violation-warn-text);
}
.kx-comp__lg--block {
background: #fef3f2;
color: var(--color-error);
}
.kx-comp__items {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.kx-comp__item {
display: flex;
gap: var(--space-3);
padding: var(--space-3);
border-radius: var(--radius-sm);
border: 1px solid var(--color-neutral-200);
}
.kx-comp__item.is-block {
border-color: #fecdca;
background: #fffbfa;
}
.kx-comp__item.is-warn {
border-color: #fde3ad;
background: #fffcf5;
}
.kx-comp__item.is-pass {
border-color: var(--color-neutral-100);
}
.kx-comp__pin {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
background: var(--color-error);
color: var(--color-white);
}
.kx-comp__item.is-warn .kx-comp__pin {
background: var(--color-violation-warn);
}
.kx-comp__pin--pass {
background: var(--color-success) !important;
}
.kx-comp__item-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.kx-comp__item-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.kx-comp__item-label {
font-size: var(--fs-body);
font-weight: 600;
color: var(--color-neutral-900);
}
.kx-comp__sev {
font-size: 11px;
font-weight: 700;
padding: 1px 8px;
border-radius: var(--radius-pill);
}
.kx-comp__sev--block {
background: #fef3f2;
color: var(--color-error);
}
.kx-comp__sev--warn {
background: var(--color-violation-warn-bg);
color: var(--color-violation-warn-text);
}
.kx-comp__sev--pass {
background: #e6f4ee;
color: var(--color-success);
}
.kx-comp__measured {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-comp__doc {
font-size: 11px;
font-weight: 600;
color: var(--color-ai-accent);
background: var(--color-ai-surface);
border: 1px solid var(--color-ai-accent);
border-radius: var(--radius-sm);
padding: 2px 8px;
align-self: flex-start;
}
/* 도면 */
.kx-comp__drawing {
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
.kx-comp__drawing-head h2 {
font-size: var(--fs-h3);
color: var(--color-neutral-900);
margin-bottom: var(--space-3);
}
.kx-comp__drawing-frame {
position: relative;
margin: 0;
border-radius: var(--radius-lg);
overflow: hidden;
border: var(--border-card);
background: #f6f8fb;
}
.kx-comp__drawing-frame img {
width: 100%;
display: block;
object-fit: cover;
}
.kx-comp__overlay-box {
position: absolute;
top: 12%;
left: 30%;
width: 40%;
height: 22%;
border: 2px solid var(--color-error);
background: rgba(217, 45, 32, 0.15);
border-radius: 2px;
}
.kx-comp__overlay-badge {
position: absolute;
top: 12%;
right: 6%;
font-size: 11px;
font-weight: 700;
color: var(--color-white);
background: var(--color-error);
padding: 3px 8px;
border-radius: var(--radius-sm);
}
.kx-comp__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.kx-comp__disclaimer {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
@media (max-width: 1000px) {
.kx-comp__body {
grid-template-columns: 1fr;
}
}

View File

@ -11,6 +11,11 @@ import {
} from './sampleDashboard';
import './dashboard.css';
// Stitch organizer_dashboard 번들 이미지(실 KINTEX 촬영본).
const MOCK = `${import.meta.env.BASE_URL}mock/organizer_dashboard`;
const S7_AERIAL = `${MOCK}/img3.jpg`; // 홀 전경 조감(아이소메트릭 항공뷰)
const BOOTH_THUMBS = [`${MOCK}/img1.jpg`, `${MOCK}/img2.jpg`, `${MOCK}/img3.jpg`];
/*
* SCR-02 . Stitch organizer_dashboard .
* - 히어로: D- + KPI 4 + + + S7 .
@ -100,7 +105,7 @@ export function OrganizerDashboardPage() {
</tr>
</thead>
<tbody>
{SAMPLE_EXHIBITORS.map((r) => (
{SAMPLE_EXHIBITORS.map((r, i) => (
<tr key={r.boothNo}>
<td className="tnum">{r.boothNo}</td>
<td>{r.company}</td>
@ -111,7 +116,12 @@ export function OrganizerDashboardPage() {
<StatusBadge status={r.utilityStatus} />
</td>
<td>
<span className="kx-table__thumb" aria-hidden="true" />
<img
className="kx-table__thumb"
src={BOOTH_THUMBS[i % BOOTH_THUMBS.length]}
alt={`${r.company} 예상 사진 썸네일`}
loading="lazy"
/>
</td>
</tr>
))}
@ -139,8 +149,9 @@ export function OrganizerDashboardPage() {
<AiLabel>S7</AiLabel>
</div>
<AiImage
imageUrl={null}
status="QUEUED"
imageUrl={S7_AERIAL}
status="DONE"
sample
watermarkText="AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있습니다"
notice="AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 — 시공 기준은 도면입니다"
shotLabel="S7 홀 전경"

View File

@ -227,6 +227,8 @@
width: 44px;
height: 30px;
border-radius: var(--radius-sm);
object-fit: cover;
border: 1px solid var(--color-neutral-200);
background: linear-gradient(120deg, var(--color-neutral-100), var(--color-neutral-200));
}

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
import { ApiRequestError } from '../../api/client';
import { designApi, renderApi } from '../../api/endpoints';
import { subscribeRenderJob } from '../../api/websocket';
@ -8,7 +8,7 @@ import { AiLabel } from '../../components/ui/Badge';
import { AiImage } from '../../components/ui/AiImage';
import { CompareSlider } from '../../components/ui/CompareSlider';
import type { ComplianceReport, DesignSpec, RenderJobDto, RenderJobRequest } from '../../api/types';
import { AFTER_PLACEHOLDER, BEFORE_EMPTY_HALL } from './placeholders';
import { AFTER_PLACEHOLDER, AFTER_SAMPLE, BEFORE_EMPTY_HALL, MOCK_SHOT_IMG } from './placeholders';
import './studio.css';
/*
@ -47,6 +47,7 @@ type Section = 'basic' | 'zones' | 'structure' | 'material' | 'lighting';
export function BoothDesignStudioPage() {
const { eventId = '', boothId = '' } = useParams();
const navigate = useNavigate();
const [spec, setSpec] = useState<DesignSpec>(DEFAULT_SPEC);
const [open, setOpen] = useState<Record<Section, boolean>>({
@ -160,7 +161,8 @@ export function BoothDesignStudioPage() {
);
const s1 = jobs.S1;
const afterSrc = s1?.status === 'DONE' && s1.imageUrl ? s1.imageUrl : null;
const afterReal = s1?.status === 'DONE' && s1.imageUrl ? s1.imageUrl : null;
const afterSrc = afterReal ?? AFTER_SAMPLE;
const passCount = report?.passCount ?? 0;
const warnCount = report?.warnCount ?? 0;
@ -172,8 +174,27 @@ export function BoothDesignStudioPage() {
{/* 상단 컨텍스트 바 */}
<header className="kx-studio__head">
<div className="kx-studio__ctx">
<h2> {boothId || 'A-102'} · 6×3m</h2>
<span className="kx-studio__ctx-sub"> 5m · v2</span>
<button
type="button"
className="kx-studio__back"
aria-label="뒤로"
onClick={() => navigate(-1)}
>
</button>
<div>
<h2> {boothId || 'A-102'} · 6×3m</h2>
<span className="kx-studio__ctx-sub"> 5m</span>
</div>
<span className="kx-studio__ctx-div" aria-hidden="true" />
<select
className="kx-studio__ver-select"
aria-label="설계 버전"
defaultValue="v2"
>
<option value="v2"> v2</option>
<option value="v1"> v1</option>
</select>
</div>
<Button variant="ai" leadingIcon="✦" onClick={generateShots} disabled={generating}>
{generating ? '생성 요청 중…' : '예상 사진 생성'}
@ -349,10 +370,23 @@ export function BoothDesignStudioPage() {
onRetry={() => generateSingle(shot.preset)}
alt={`${shot.label} 예상 이미지`}
/>
) : MOCK_SHOT_IMG[shot.preset] ? (
<AiImage
imageUrl={MOCK_SHOT_IMG[shot.preset]}
status="DONE"
watermarkText="AI 생성 예상 이미지"
notice="AI 예시 이미지 — 실제 부스 렌더는 ‘이 뷰 생성’으로 요청하세요. 시공 기준은 도면입니다."
shotLabel={shot.label}
versionTag="설계 v2"
sample
onGenerate={() => generateSingle(shot.preset)}
alt={`${shot.label} 예시 이미지`}
/>
) : (
<div className="kx-studio__tile-empty">
<div className="kx-studio__tile-icon" aria-hidden="true"></div>
<span className="kx-studio__tile-label">{shot.label}</span>
<button onClick={() => generateSingle(shot.preset)}> </button>
<button onClick={() => generateSingle(shot.preset)}> </button>
</div>
)}
</div>
@ -364,24 +398,22 @@ export function BoothDesignStudioPage() {
<div className="kx-studio__compare">
<div className="kx-studio__compare-head">
<AiLabel>S5 Before / After</AiLabel>
{!afterSrc && <span className="kx-studio__compare-note">S1 </span>}
{!afterReal && <span className="kx-studio__compare-note"> · S1 </span>}
</div>
<CompareSlider
beforeSrc={BEFORE_EMPTY_HALL}
afterSrc={afterSrc ?? AFTER_PLACEHOLDER}
afterSrc={afterSrc || AFTER_PLACEHOLDER}
beforeLabel="시공 전"
afterLabel="시공 후"
afterOverlay={
afterSrc ? (
<>
<div className="kx-studio__wm" aria-hidden="true">
{s1?.watermarkText ?? 'AI 생성 예상 이미지'}
</div>
<span className="kx-studio__after-ai">
<AiLabel>AI </AiLabel>
</span>
</>
) : undefined
<>
<div className="kx-studio__wm" aria-hidden="true">
{s1?.watermarkText ?? 'AI 생성 예상 이미지'}
</div>
<span className="kx-studio__after-ai">
<AiLabel>{afterReal ? 'AI 생성' : '예시'}</AiLabel>
</span>
</>
}
/>
<p className="kx-studio__compare-notice">

View File

@ -1,13 +1,27 @@
/*
* Before/After ( ).
* S1 RenderJob DONE after는 imageUrl로 .
* design.md (canvas-bg ) SVG data URI.
* Before/After + 4 .
* Stitch booth_design_studio ( KINTEX ) public/mock/
* · RenderJob DONE /after는 imageUrl로 .
* · "AI 생성 예상 이미지" · ().
* BEFORE_EMPTY_HALL / SVG .
*/
function svgDataUri(svg: string): string {
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
}
/** 시공 전 — 빈 홀 콘크리트 바닥(격자). */
const BASE = `${import.meta.env.BASE_URL}mock/booth_design_studio`;
/** Stitch 4샷 예시 이미지(번들). S1·S2·S3 채움, S4는 빈 상태(생성 유도). */
export const MOCK_SHOT_IMG: Record<string, string> = {
S1: `${BASE}/img1.jpg`, // 부스 정면 (전시홀 내부)
S2: `${BASE}/img2.jpg`, // 야간 점등
S3: `${BASE}/img3.jpg`, // 통로 뷰
};
/** Before/After — after 기본 프리뷰(번들). */
export const AFTER_SAMPLE = `${BASE}/img1.jpg`;
/** 시공 전 — 빈 홀 콘크리트 바닥(격자) SVG 폴백. */
export const BEFORE_EMPTY_HALL = svgDataUri(
`<svg xmlns='http://www.w3.org/2000/svg' width='640' height='360'>
<rect width='640' height='360' fill='#d9d5cf'/>
@ -19,7 +33,7 @@ export const BEFORE_EMPTY_HALL = svgDataUri(
</svg>`,
);
/** 시공 후 자리 표시(S1 미생성). */
/** 시공 후 자리 표시(S1 미생성·번들 부재). */
export const AFTER_PLACEHOLDER = svgDataUri(
`<svg xmlns='http://www.w3.org/2000/svg' width='640' height='360'>
<rect width='640' height='360' fill='#1c2536'/>

View File

@ -15,14 +15,57 @@
border-bottom: var(--border-card);
background: var(--color-white);
}
.kx-studio__ctx {
display: flex;
align-items: center;
gap: var(--space-3);
}
.kx-studio__ctx h2 {
font-size: var(--fs-h2);
font-size: var(--fs-h3);
font-weight: 700;
color: var(--color-neutral-900);
}
.kx-studio__ctx-sub {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-studio__back {
width: 32px;
height: 32px;
border: 1px solid var(--color-neutral-200);
background: var(--color-white);
border-radius: var(--radius-sm);
color: var(--color-neutral-700);
font-size: 16px;
cursor: pointer;
}
.kx-studio__back:hover {
background: var(--color-neutral-050);
}
.kx-studio__ctx-div {
width: 1px;
height: 24px;
background: var(--color-neutral-200);
}
.kx-studio__ver-select {
padding: 5px 10px;
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-sm);
font-size: var(--fs-caption);
color: var(--color-neutral-700);
background: var(--color-white);
}
.kx-studio__tile-icon {
width: 44px;
height: 44px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-ai-surface);
color: var(--color-ai-accent);
font-size: 20px;
}
.kx-studio__body {
display: grid;

View File

@ -0,0 +1,138 @@
import { useNavigate, useParams } from 'react-router-dom';
import { Button } from '../../components/ui/Button';
import { AiLabel, DdayChip, StatusBadge } from '../../components/ui/Badge';
import './exhibitor-home.css';
/*
* SCR-05 . Stitch exhibitor_home .
* - (·· + ) · ()
* - : 해야 ( · · ) / : (AI, ) + CTA
* ·· . CTA는 .
*/
const MOCK = `${import.meta.env.BASE_URL}mock/exhibitor_home`;
const BOOTH_PREVIEW = `${MOCK}/img1.jpg`; // 내 부스 예상 사진(전시홀 내부)
const FLOORPLAN_THUMB = `${MOCK}/img3.jpg`; // 미니 도면 스키매틱
const STEPS = [
{ key: 'apply', label: '신청', state: 'done' },
{ key: 'approve', label: '승인', state: 'current', note: '설계 승인 대기중' },
{ key: 'build', label: '시공', state: 'todo' },
{ key: 'inspect', label: '검수', state: 'todo' },
] as const;
const COST_ROWS = [
{ label: '전기 5kW', amount: '₩275,000', status: 'submitted' as const, statusLabel: '신청됨' },
{ label: '인터넷 1회선', amount: '₩150,000', status: 'draft' as const, statusLabel: '작성중' },
];
export function ExhibitorHomePage() {
const { eventId = '', boothId = 'A-102' } = useParams();
const navigate = useNavigate();
return (
<div className="kx-exh">
{/* 헤더 카드 */}
<header className="kx-exh__header">
<div>
<h1 className="kx-exh__company">()</h1>
<div className="kx-exh__ctx">
<span> {boothId}</span>
<span aria-hidden="true">·</span>
<span> 6×3m</span>
<span aria-hidden="true">·</span>
<span>7</span>
</div>
</div>
<div className="kx-exh__miniplan">
<img src={FLOORPLAN_THUMB} alt="내 부스 위치 미니 도면" loading="lazy" />
<span className="kx-exh__miniplan-pin" aria-hidden="true" />
</div>
</header>
{/* 진행 스테퍼 */}
<section className="kx-exh__stepper" aria-label="진행 단계">
<ol className="kx-stepper">
{STEPS.map((s) => (
<li key={s.key} className={`kx-stepper__item is-${s.state}`}>
<span className="kx-stepper__dot" aria-hidden="true">
{s.state === 'done' ? '✓' : ''}
</span>
<span className="kx-stepper__label">{s.label}</span>
{'note' in s && s.note && <span className="kx-stepper__note">{s.note}</span>}
</li>
))}
</ol>
</section>
<div className="kx-exh__split">
{/* 좌: 해야 할 일 */}
<section className="kx-exh__tasks" aria-label="해야 할 일">
<h2 className="kx-exh__h"> </h2>
<article className="kx-task kx-task--urgent">
<DdayChip dday={6} />
<h3> </h3>
<p className="kx-task__warn"> </p>
</article>
<article className="kx-task">
<DdayChip dday={15} />
<h3> </h3>
</article>
<article className="kx-task kx-task--primary">
<h3> 1</h3>
<button
className="kx-task__link"
onClick={() => navigate(`/events/${eventId}/booths/${boothId}/design`)}
>
</button>
</article>
<article className="kx-cost">
<h4> </h4>
{COST_ROWS.map((c) => (
<div key={c.label} className="kx-cost__row">
<span>{c.label}</span>
<div className="kx-cost__right">
<strong className="tnum">{c.amount}</strong>
<StatusBadge status={c.status} />
</div>
</div>
))}
</article>
</section>
{/* 우: 내 부스 예상 사진 */}
<section className="kx-exh__preview" aria-label="내 부스 예상 사진">
<h2 className="kx-exh__h">
<AiLabel>AI</AiLabel>
</h2>
<figure className="kx-exh__preview-frame">
<img src={BOOTH_PREVIEW} alt="내 부스 AI 예상 사진" loading="lazy" />
<div className="kx-exh__wm" aria-hidden="true">
AI
</div>
<figcaption>AI </figcaption>
</figure>
<div className="kx-exh__cta">
<Button
block
onClick={() => navigate(`/events/${eventId}/booths/${boothId}/design`)}
>
</Button>
<Button
variant="ghost"
block
onClick={() => navigate(`/events/${eventId}/booths/${boothId}/utility`)}
>
</Button>
</div>
</section>
</div>
</div>
);
}

View File

@ -0,0 +1,307 @@
.kx-exh {
display: flex;
flex-direction: column;
gap: var(--space-5);
padding: var(--space-5);
max-width: var(--container-max);
}
/* 헤더 카드 */
.kx-exh__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
padding: var(--space-5);
}
.kx-exh__company {
font-size: var(--fs-display);
line-height: var(--lh-display);
font-weight: 700;
color: var(--color-neutral-900);
margin-bottom: var(--space-2);
}
.kx-exh__ctx {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--color-neutral-500);
font-size: var(--fs-h3);
}
.kx-exh__miniplan {
position: relative;
width: 128px;
height: 96px;
border-radius: var(--radius-lg);
overflow: hidden;
border: var(--border-card);
flex-shrink: 0;
}
.kx-exh__miniplan img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.85;
}
.kx-exh__miniplan-pin {
position: absolute;
top: 50%;
left: 50%;
width: 22px;
height: 16px;
transform: translate(-50%, -50%);
background: rgba(0, 102, 179, 0.8);
border: 2px solid var(--color-primary-600);
border-radius: 2px;
box-shadow: var(--shadow-level2);
}
/* 스테퍼 */
.kx-exh__stepper {
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
padding: var(--space-6) var(--space-5) var(--space-8);
}
.kx-stepper {
display: flex;
align-items: flex-start;
justify-content: space-between;
max-width: 720px;
margin: 0 auto;
position: relative;
list-style: none;
}
.kx-stepper::before {
content: '';
position: absolute;
top: 16px;
left: 5%;
right: 5%;
height: 2px;
background: var(--color-neutral-200);
}
.kx-stepper__item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
background: var(--color-white);
padding: 0 var(--space-3);
z-index: 1;
}
.kx-stepper__dot {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
border: 2px solid var(--color-neutral-200);
color: var(--color-neutral-500);
background: var(--color-white);
}
.kx-stepper__item.is-done .kx-stepper__dot {
background: var(--color-primary-600);
border-color: var(--color-primary-600);
color: var(--color-white);
}
.kx-stepper__item.is-current .kx-stepper__dot {
border-color: var(--color-primary-600);
color: var(--color-primary-600);
box-shadow: 0 0 0 4px var(--color-primary-100);
}
.kx-stepper__item.is-current .kx-stepper__dot::after {
content: '';
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--color-primary-600);
}
.kx-stepper__label {
font-size: var(--fs-caption);
font-weight: 600;
color: var(--color-neutral-700);
}
.kx-stepper__item.is-current .kx-stepper__label {
color: var(--color-primary-700);
}
.kx-stepper__note {
position: absolute;
top: 62px;
white-space: nowrap;
font-size: 11px;
color: var(--color-primary-700);
background: var(--color-primary-050);
padding: 2px 8px;
border-radius: var(--radius-sm);
}
/* split */
.kx-exh__split {
display: grid;
grid-template-columns: 5fr 7fr;
gap: var(--space-4);
align-items: start;
}
.kx-exh__h {
font-size: var(--fs-h2);
color: var(--color-neutral-900);
margin-bottom: var(--space-3);
display: flex;
align-items: center;
gap: var(--space-2);
}
.kx-exh__tasks {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-task {
position: relative;
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
padding: var(--space-4);
padding-left: var(--space-5);
overflow: hidden;
}
.kx-task::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: var(--color-neutral-200);
}
.kx-task--urgent {
border-color: var(--color-violation-warn);
}
.kx-task--urgent::before {
background: var(--color-violation-warn);
}
.kx-task--primary::before {
background: var(--color-primary-600);
}
.kx-task h3 {
font-size: var(--fs-h3);
color: var(--color-neutral-900);
margin-top: var(--space-2);
}
.kx-task__warn {
font-size: var(--fs-caption);
color: var(--color-error);
margin-top: var(--space-1);
}
.kx-task__link {
margin-top: var(--space-2);
border: none;
background: none;
color: var(--color-primary-600);
font-size: var(--fs-body);
font-weight: 600;
cursor: pointer;
padding: 0;
}
.kx-task__link:hover {
text-decoration: underline;
}
.kx-cost {
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
.kx-cost h4 {
font-size: var(--fs-caption);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-neutral-500);
margin-bottom: var(--space-2);
}
.kx-cost__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-neutral-100);
font-size: var(--fs-body);
}
.kx-cost__row:last-child {
border-bottom: none;
}
.kx-cost__right {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* preview */
.kx-exh__preview {
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.kx-exh__preview-frame {
position: relative;
margin: 0;
border-radius: var(--radius-lg);
overflow: hidden;
border: var(--border-card);
border-left: var(--accent-ai);
min-height: 300px;
flex: 1;
}
.kx-exh__preview-frame img {
width: 100%;
height: 100%;
min-height: 300px;
object-fit: cover;
display: block;
}
.kx-exh__preview-frame figcaption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: var(--space-3);
font-size: 11px;
color: rgba(255, 255, 255, 0.85);
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
}
.kx-exh__wm {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
transform: rotate(-24deg);
color: rgba(255, 255, 255, 0.5);
font-size: 15px;
font-weight: 700;
letter-spacing: 0.04em;
pointer-events: none;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
}
.kx-exh__cta {
display: flex;
gap: var(--space-3);
}
@media (max-width: 1100px) {
.kx-exh__split {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,107 @@
import { useNavigate, useParams } from 'react-router-dom';
import { Button } from '../../components/ui/Button';
import { AiLabel } from '../../components/ui/Badge';
import './layout-comparison.css';
/*
* SCR-04 AI . Stitch layout_comparison .
* - 3 (S7 AI + + ), 1 .
* §3 auto-generate(AutoLayoutOption[]) 3 . .
*/
const MOCK = `${import.meta.env.BASE_URL}mock/layout_comparison`;
const AERIAL = `${MOCK}/img1.jpg`;
interface Option {
id: string;
label: string;
booths: number;
salesAreaM2: number;
minAisleM: number;
violationBlock: number;
violationWarn: number;
recommended?: boolean;
}
const OPTIONS: Option[] = [
{ id: 'A', label: '배치안 A', booths: 512, salesAreaM2: 4450, minAisleM: 3.0, violationBlock: 0, violationWarn: 0 },
{ id: 'B', label: '배치안 B', booths: 530, salesAreaM2: 4420, minAisleM: 3.5, violationBlock: 0, violationWarn: 0, recommended: true },
{ id: 'C', label: '배치안 C', booths: 525, salesAreaM2: 4550, minAisleM: 2.8, violationBlock: 0, violationWarn: 2 },
];
export function LayoutComparisonPage() {
const { eventId = '', hallId = 'H7' } = useParams();
const navigate = useNavigate();
return (
<div className="kx-cmp">
<header className="kx-cmp__head">
<div>
<h1>AI </h1>
<p className="kx-cmp__sub">조건: 목표 530 · 15% · 1</p>
</div>
<Button variant="ghost"> </Button>
</header>
<div className="kx-cmp__grid" role="radiogroup" aria-label="배치안 선택">
{OPTIONS.map((o) => (
<article
key={o.id}
className={`kx-cmp__card ${o.recommended ? 'is-selected' : ''}`}
role="radio"
aria-checked={o.recommended ?? false}
tabIndex={0}
>
<figure className="kx-cmp__thumb">
<img src={AERIAL} alt={`${o.label} 조감 예상 이미지`} loading="lazy" />
<span className="kx-cmp__ai">
<AiLabel>AI </AiLabel>
</span>
{o.recommended && <span className="kx-cmp__check" aria-hidden="true"></span>}
<div className="kx-cmp__wm" aria-hidden="true">AI </div>
</figure>
<div className="kx-cmp__body">
<div className="kx-cmp__title-row">
<h2>{o.label}</h2>
{o.recommended && <span className="kx-cmp__reco"></span>}
</div>
<dl className="kx-cmp__metrics tnum">
<div>
<dt> </dt>
<dd>{o.booths}</dd>
</div>
<div>
<dt></dt>
<dd>{o.salesAreaM2.toLocaleString()}</dd>
</div>
<div>
<dt> </dt>
<dd>{o.minAisleM.toFixed(1)}m</dd>
</div>
<div>
<dt></dt>
<dd className={o.violationBlock + o.violationWarn > 0 ? 'kx-cmp__warn' : 'kx-cmp__ok'}>
{o.violationBlock + o.violationWarn === 0
? '✓ 0건'
: `${o.violationBlock + o.violationWarn}`}
</dd>
</div>
</dl>
</div>
</article>
))}
</div>
<div className="kx-cmp__footer">
<p className="kx-cmp__disclaimer">
AI .
</p>
<Button
onClick={() => navigate(`/events/${eventId}/halls/${hallId}/layout`)}
>
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,158 @@
.kx-cmp {
display: flex;
flex-direction: column;
gap: var(--space-5);
padding: var(--space-5);
max-width: var(--container-max);
}
.kx-cmp__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
}
.kx-cmp__head h1 {
font-size: var(--fs-h1);
color: var(--color-neutral-900);
}
.kx-cmp__sub {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
margin-top: var(--space-1);
}
.kx-cmp__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-4);
}
.kx-cmp__card {
background: var(--color-white);
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-lg);
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.kx-cmp__card:hover {
box-shadow: var(--shadow-level2);
}
.kx-cmp__card.is-selected {
border-color: var(--color-primary-600);
box-shadow: 0 0 0 2px var(--color-primary-100);
}
.kx-cmp__card:focus-visible {
outline: 2px solid var(--color-primary-600);
outline-offset: 2px;
}
.kx-cmp__thumb {
position: relative;
margin: 0;
aspect-ratio: 16 / 10;
overflow: hidden;
}
.kx-cmp__thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.kx-cmp__ai {
position: absolute;
top: var(--space-2);
left: var(--space-2);
}
.kx-cmp__check {
position: absolute;
top: var(--space-2);
right: var(--space-2);
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--color-primary-600);
color: var(--color-white);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
}
.kx-cmp__wm {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
transform: rotate(-24deg);
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
pointer-events: none;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.kx-cmp__body {
padding: var(--space-4);
}
.kx-cmp__title-row {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.kx-cmp__title-row h2 {
font-size: var(--fs-h3);
color: var(--color-neutral-900);
}
.kx-cmp__reco {
font-size: 11px;
font-weight: 700;
color: var(--color-primary-700);
background: var(--color-primary-100);
padding: 2px 8px;
border-radius: var(--radius-pill);
}
.kx-cmp__metrics {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.kx-cmp__metrics div {
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--fs-body);
}
.kx-cmp__metrics dt {
color: var(--color-neutral-500);
}
.kx-cmp__metrics dd {
color: var(--color-neutral-900);
font-weight: 600;
}
.kx-cmp__ok {
color: var(--color-success);
}
.kx-cmp__warn {
color: var(--color-warning);
}
.kx-cmp__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding-top: var(--space-2);
}
.kx-cmp__disclaimer {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
@media (max-width: 1000px) {
.kx-cmp__grid {
grid-template-columns: 1fr;
}
}

View File

@ -2,18 +2,20 @@ import type { RenderJobDto } from '../../api/types';
/*
* Degraded render-jobs 501(NOT_IMPLEMENTED) .
* (imageUrl=null) ·· UX만 . API .
* Stitch visualization_gallery (public/mock) DONE .
* · . render-jobs API .
*/
const WATERMARK = 'AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있습니다';
const NOTICE = 'AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 — 시공 기준은 도면입니다';
const G = `${import.meta.env.BASE_URL}mock/visualization_gallery`;
const SHOTS: { preset: string; status: RenderJobDto['status'] }[] = [
{ preset: 'S1', status: 'DONE' },
{ preset: 'S2', status: 'DONE' },
const SHOTS: { preset: string; status: RenderJobDto['status']; img?: string }[] = [
{ preset: 'S1', status: 'DONE', img: `${G}/img1.jpg` },
{ preset: 'S2', status: 'DONE', img: `${G}/img2.jpg` },
{ preset: 'S3', status: 'RUNNING' },
{ preset: 'S4', status: 'DONE' },
{ preset: 'S5', status: 'DONE' },
{ preset: 'S6', status: 'DONE' },
{ preset: 'S4', status: 'DONE', img: `${G}/img3.jpg` },
{ preset: 'S5', status: 'DONE', img: `${G}/img4.jpg` },
{ preset: 'S6', status: 'DONE', img: `${G}/img5.jpg` },
{ preset: 'S7', status: 'FAILED' },
];
@ -23,7 +25,7 @@ export function sampleGallery(boothId: string): RenderJobDto[] {
boothId: boothId || 'b-102',
shotPreset: s.preset,
status: s.status,
imageUrl: null,
imageUrl: s.img ?? null,
schemaHash: s.status === 'DONE' ? `sha256:sample${i}` : null,
modelVersion: s.status === 'DONE' ? 'gemini-3.1-flash-image-preview' : null,
watermarkRequired: true,

View File

@ -0,0 +1,193 @@
import { useState } from 'react';
import { authApi } from '../../api/endpoints';
import { ApiRequestError } from '../../api/client';
import { Button } from '../../components/ui/Button';
import { evaluatePassword } from './passwordStrength';
import './login.css';
type Step = 'request' | 'reset' | 'done';
/**
* / (SCR-01) 2.
* 요청: 이메일 POST /api/auth/password/forgot ( 방지: 항상 )
* 재설정: 이메일++ POST /api/auth/password/reset
* (501/404) "준비 중입니다" degraded .
*/
export function ForgotPasswordDialog({
onClose,
onDone,
defaultEmail = '',
}: {
onClose: () => void;
onDone: (email: string) => void;
defaultEmail?: string;
}) {
const [step, setStep] = useState<Step>('request');
const [email, setEmail] = useState(defaultEmail);
const [code, setCode] = useState('');
const [newPassword, setNewPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const hint = evaluatePassword(newPassword);
function degradedMessage(err: ApiRequestError): string | null {
if (err.code === 'NOT_IMPLEMENTED' || err.httpStatus === 404) {
return '비밀번호 재설정 서비스가 준비 중입니다. 잠시 후 다시 시도해 주세요.';
}
return null;
}
async function requestCode(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await authApi.forgotPassword({ email: email.trim() });
setStep('reset');
} catch (err) {
if (err instanceof ApiRequestError) {
setError(degradedMessage(err) ?? err.message);
} else {
setError('요청 중 오류가 발생했습니다.');
}
} finally {
setSubmitting(false);
}
}
async function resetPassword(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await authApi.resetPassword({
email: email.trim(),
code: code.trim(),
newPassword,
});
setStep('done');
} catch (err) {
if (err instanceof ApiRequestError) {
if (err.code === 'OTP_INVALID' || err.code === 'VALIDATION') {
setError('인증코드가 올바르지 않거나 만료되었습니다.');
} else {
setError(degradedMessage(err) ?? err.message);
}
} else {
setError('재설정 중 오류가 발생했습니다.');
}
} finally {
setSubmitting(false);
}
}
return (
<div className="kx-modal" role="dialog" aria-modal="true" aria-labelledby="forgot-title">
<div className="kx-modal__backdrop" onClick={onClose} />
<div className="kx-modal__panel">
{step === 'done' ? (
<div className="kx-modal__blocked" role="status">
<span className="kx-modal__blocked-icon kx-modal__ok-icon" aria-hidden="true">
</span>
<h3 id="forgot-title"> </h3>
<p> .</p>
<div className="kx-modal__actions">
<Button onClick={() => onDone(email.trim())}> </Button>
</div>
</div>
) : step === 'request' ? (
<form onSubmit={requestCode}>
<h3 id="forgot-title" className="kx-modal__title">
</h3>
<p className="kx-modal__desc">
. .
</p>
<div className="kx-field">
<label htmlFor="fp-email"></label>
<input
id="fp-email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@company.com"
required
/>
</div>
{error && (
<p className="kx-login__error" role="alert">
{error}
</p>
)}
<div className="kx-modal__actions">
<Button variant="ghost" type="button" onClick={onClose}>
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? '전송 중…' : '인증코드 받기'}
</Button>
</div>
</form>
) : (
<form onSubmit={resetPassword}>
<h3 id="forgot-title" className="kx-modal__title">
</h3>
<p className="kx-modal__desc">
<strong>{email}</strong> .
</p>
<div className="kx-field">
<label htmlFor="fp-code"></label>
<input
id="fp-code"
inputMode="numeric"
autoComplete="one-time-code"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="메일로 받은 코드"
required
/>
</div>
<div className="kx-field">
<label htmlFor="fp-newpw"> </label>
<input
id="fp-newpw"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="8자 이상"
aria-describedby="fp-pw-hint"
required
/>
<div className="kx-pw" id="fp-pw-hint" aria-live="polite">
<div className={`kx-pw__bar kx-pw__bar--${hint.level}`}>
{[0, 1, 2].map((i) => (
<span key={i} className={i < hint.score ? 'is-on' : ''} />
))}
</div>
{hint.label && <span className="kx-pw__label">{hint.label}</span>}
</div>
</div>
{error && (
<p className="kx-login__error" role="alert">
{error}
</p>
)}
<div className="kx-modal__actions">
<Button variant="ghost" type="button" onClick={() => setStep('request')}>
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? '변경 중…' : '비밀번호 변경'}
</Button>
</div>
</form>
)}
</div>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { authApi } from '../../api/endpoints';
import { ApiRequestError } from '../../api/client';
@ -7,11 +7,16 @@ import { Button } from '../../components/ui/Button';
import { DdayChip, RoleBadge } from '../../components/ui/Badge';
import { EmptyState } from '../../components/ui/States';
import { InviteDialog } from './InviteDialog';
import { SignupDialog } from './SignupDialog';
import { ForgotPasswordDialog } from './ForgotPasswordDialog';
import type { WorkspaceDto } from '../../api/types';
import './login.css';
type Phase = 'login' | 'workspace';
// 아이디 기억 — 이메일만 저장(비번 저장 금지).
const REMEMBER_KEY = 'kintex_remember_email';
export function LoginPage() {
const navigate = useNavigate();
const applyLogin = useAuthStore((s) => s.applyLogin);
@ -21,9 +26,21 @@ export function LoginPage() {
const [phase, setPhase] = useState<Phase>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [remember, setRemember] = useState(false);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [inviteOpen, setInviteOpen] = useState(false);
const [signupOpen, setSignupOpen] = useState(false);
const [forgotOpen, setForgotOpen] = useState(false);
// 아이디 기억 프리필 — 저장된 이메일이 있으면 채우고 체크 유지.
useEffect(() => {
const saved = localStorage.getItem(REMEMBER_KEY);
if (saved) {
setEmail(saved);
setRemember(true);
}
}, []);
async function handleLogin(e: React.FormEvent) {
e.preventDefault();
@ -31,6 +48,8 @@ export function LoginPage() {
setSubmitting(true);
try {
const res = await authApi.login({ email, password });
if (remember) localStorage.setItem(REMEMBER_KEY, email.trim());
else localStorage.removeItem(REMEMBER_KEY);
applyLogin(res);
setPhase('workspace');
} catch (err) {
@ -164,6 +183,25 @@ export function LoginPage() {
/>
</div>
<div className="kx-login__row">
<label className="kx-check" htmlFor="remember">
<input
id="remember"
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
/>
<span> </span>
</label>
<button
type="button"
className="kx-login__text-link"
onClick={() => setForgotOpen(true)}
>
</button>
</div>
{error && (
<p className="kx-login__error" role="alert">
{error}
@ -175,6 +213,17 @@ export function LoginPage() {
</Button>
</form>
<p className="kx-login__signup-row">
?{' '}
<button
type="button"
className="kx-login__text-link"
onClick={() => setSignupOpen(true)}
>
</button>
</p>
<div className="kx-login__divider">
<span></span>
</div>
@ -195,6 +244,28 @@ export function LoginPage() {
</section>
{inviteOpen && <InviteDialog onClose={() => setInviteOpen(false)} />}
{signupOpen && (
<SignupDialog
defaultEmail={email}
onClose={() => setSignupOpen(false)}
onDone={(e) => {
setSignupOpen(false);
setEmail(e);
setPassword('');
}}
/>
)}
{forgotOpen && (
<ForgotPasswordDialog
defaultEmail={email}
onClose={() => setForgotOpen(false)}
onDone={(e) => {
setForgotOpen(false);
setEmail(e);
setPassword('');
}}
/>
)}
</div>
);
}

View File

@ -0,0 +1,201 @@
import { useState } from 'react';
import { authApi } from '../../api/endpoints';
import { ApiRequestError } from '../../api/client';
import { Button } from '../../components/ui/Button';
import { evaluatePassword } from './passwordStrength';
import './login.css';
/**
* (SCR-01) ··(+)·()·().
* POST /api/auth/register (/) .
* (501/404) "준비 중입니다" degraded .
*/
export function SignupDialog({
onClose,
onDone,
defaultEmail = '',
}: {
onClose: () => void;
/** 회원가입 성공 후 로그인 화면으로 되돌아가며 이메일 프리필 */
onDone: (email: string) => void;
defaultEmail?: string;
}) {
const [email, setEmail] = useState(defaultEmail);
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [companyName, setCompanyName] = useState('');
const [inviteCode, setInviteCode] = useState('');
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const hint = evaluatePassword(password);
const mismatch = confirm.length > 0 && password !== confirm;
async function submit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (password !== confirm) {
setError('비밀번호가 일치하지 않습니다.');
return;
}
setSubmitting(true);
try {
const res = await authApi.register({
email: email.trim(),
displayName: displayName.trim(),
password,
companyName: companyName.trim() || undefined,
inviteCode: inviteCode.trim() || undefined,
});
const pending = res?.status && res.status !== 'ACTIVE';
setNotice(
res?.message ??
(pending
? '계정이 생성되었습니다. 관리자 승인 후 로그인할 수 있습니다.'
: '계정이 생성되었습니다. 이제 로그인해 주세요.'),
);
} catch (err) {
if (err instanceof ApiRequestError) {
if (err.code === 'NOT_IMPLEMENTED' || err.httpStatus === 404) {
setError('회원가입 서비스가 준비 중입니다. 잠시 후 다시 시도해 주세요.');
} else if (err.code === 'CONFLICT') {
setError('이미 가입된 이메일입니다.');
} else {
setError(err.message);
}
} else {
setError('회원가입 중 오류가 발생했습니다.');
}
} finally {
setSubmitting(false);
}
}
return (
<div className="kx-modal" role="dialog" aria-modal="true" aria-labelledby="signup-title">
<div className="kx-modal__backdrop" onClick={onClose} />
<div className="kx-modal__panel">
{notice ? (
<div className="kx-modal__blocked" role="status">
<span className="kx-modal__blocked-icon kx-modal__ok-icon" aria-hidden="true">
</span>
<h3 id="signup-title"> </h3>
<p>{notice}</p>
<div className="kx-modal__actions">
<Button onClick={() => onDone(email.trim())}> </Button>
</div>
</div>
) : (
<form onSubmit={submit}>
<h3 id="signup-title" className="kx-modal__title">
</h3>
<p className="kx-modal__desc">
KINTEX AI . · .
</p>
<div className="kx-field">
<label htmlFor="su-email"></label>
<input
id="su-email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@company.com"
required
/>
</div>
<div className="kx-field">
<label htmlFor="su-name"></label>
<input
id="su-name"
autoComplete="name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="홍길동"
required
/>
</div>
<div className="kx-field">
<label htmlFor="su-pw"></label>
<input
id="su-pw"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="8자 이상"
aria-describedby="su-pw-hint"
required
/>
<div className="kx-pw" id="su-pw-hint" aria-live="polite">
<div className={`kx-pw__bar kx-pw__bar--${hint.level}`}>
{[0, 1, 2].map((i) => (
<span key={i} className={i < hint.score ? 'is-on' : ''} />
))}
</div>
{hint.label && <span className="kx-pw__label">{hint.label}</span>}
</div>
</div>
<div className="kx-field">
<label htmlFor="su-pw2"> </label>
<input
id="su-pw2"
type="password"
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="비밀번호 재입력"
aria-invalid={mismatch}
required
/>
{mismatch && (
<span className="kx-pw__label kx-pw__label--error">
.
</span>
)}
</div>
<div className="kx-field">
<label htmlFor="su-company"> ()</label>
<input
id="su-company"
autoComplete="organization"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
placeholder="(주)킨텍스"
/>
</div>
<div className="kx-field">
<label htmlFor="su-invite"> ()</label>
<input
id="su-invite"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
placeholder="INV-XXXX"
/>
</div>
{error && (
<p className="kx-login__error" role="alert">
{error}
</p>
)}
<div className="kx-modal__actions">
<Button variant="ghost" type="button" onClick={onClose}>
</Button>
<Button type="submit" disabled={submitting || mismatch}>
{submitting ? '가입 중…' : '가입하기'}
</Button>
</div>
</form>
)}
</div>
</div>
);
}

View File

@ -168,6 +168,107 @@
.kx-login__invite-link:hover {
color: var(--color-primary-700);
}
/* 아이디 기억 / 비밀번호 찾기 행 */
.kx-login__row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: -4px;
}
.kx-check {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-caption);
color: var(--color-neutral-700);
cursor: pointer;
user-select: none;
}
.kx-check input {
width: 16px;
height: 16px;
accent-color: var(--color-primary-600);
cursor: pointer;
}
.kx-login__text-link {
background: none;
border: none;
padding: 0;
color: var(--color-primary-600);
font-size: var(--fs-caption);
font-weight: 600;
cursor: pointer;
}
.kx-login__text-link:hover {
color: var(--color-primary-700);
text-decoration: underline;
}
.kx-login__text-link:focus-visible {
outline: 2px solid var(--color-primary-600);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.kx-login__signup-row {
text-align: center;
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-login__signup-row .kx-login__text-link {
font-size: var(--fs-caption);
}
/* 비밀번호 강도 힌트 */
.kx-pw {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: 2px;
}
.kx-pw__bar {
display: inline-flex;
gap: 4px;
}
.kx-pw__bar span {
width: 26px;
height: 4px;
border-radius: var(--radius-pill);
background: var(--color-neutral-200);
transition: background-color 0.15s ease;
}
.kx-pw__bar--weak span.is-on {
background: var(--color-error);
}
.kx-pw__bar--fair span.is-on {
background: var(--color-warning, #d97706);
}
.kx-pw__bar--strong span.is-on {
background: var(--color-success, #16a34a);
}
.kx-pw__label {
font-size: 11px;
color: var(--color-neutral-500);
}
.kx-pw__label--error {
color: var(--color-error);
}
/* 성공 아이콘(회원가입/재설정 완료) */
.kx-modal__ok-icon {
width: 56px;
height: 56px;
border-radius: var(--radius-pill);
background: var(--color-success, #16a34a);
color: var(--color-white);
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
line-height: 1;
}
.kx-modal__blocked .kx-modal__ok-icon + h3 {
color: var(--color-neutral-900);
}
.kx-login__copy {
position: absolute;
bottom: var(--space-5);

View File

@ -0,0 +1,33 @@
/*
* ( + ). UX .
* design.md 토큰: level별 login.css의 .kx-pw-* .
*/
export type PasswordStrength = 'empty' | 'weak' | 'fair' | 'strong';
export interface PasswordHint {
level: PasswordStrength;
/** 0..3 채워진 막대 수 */
score: number;
label: string;
}
export function evaluatePassword(pw: string): PasswordHint {
if (!pw) return { level: 'empty', score: 0, label: '' };
let classes = 0;
if (/[a-z]/.test(pw)) classes += 1;
if (/[A-Z]/.test(pw)) classes += 1;
if (/[0-9]/.test(pw)) classes += 1;
if (/[^A-Za-z0-9]/.test(pw)) classes += 1;
const longEnough = pw.length >= 8;
const veryLong = pw.length >= 12;
if (!longEnough || classes <= 1) {
return { level: 'weak', score: 1, label: '약함 · 8자 이상 + 영문·숫자·기호 조합 권장' };
}
if (classes >= 3 && veryLong) {
return { level: 'strong', score: 3, label: '강함 · 안전한 비밀번호입니다' };
}
return { level: 'fair', score: 2, label: '보통 · 12자 이상이면 더 안전합니다' };
}

View File

@ -0,0 +1,186 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import './mobile.css';
/*
* SCR-M1 ( 390px). Stitch contractor_onsite_checklist .
* - · // · ·
* - "검수 요청 보내기" . .
* .
*/
type Section = 'before' | 'building' | 'structure';
export function ContractorOnsiteChecklistPage() {
const navigate = useNavigate();
const [view, setView] = useState<'plan' | 'photo'>('plan');
const [open, setOpen] = useState<Record<Section, boolean>>({
before: true,
building: true,
structure: true,
});
const [checks, setChecks] = useState<Record<string, boolean>>({
floor: false,
elec: false,
});
const [height, setHeight] = useState('');
const requiredDone =
checks.floor && checks.elec && height !== '' && Number(height) <= 5 && Number(height) > 0;
const toggle = (s: Section) => setOpen((o) => ({ ...o, [s]: !o[s] }));
return (
<div className="kx-m kx-m--checklist">
{/* 헤더 */}
<header className="kx-m__head">
<div className="kx-m__head-row">
<div className="kx-m__head-left">
<button className="kx-m__back" aria-label="뒤로" onClick={() => navigate(-1)}>
</button>
<div>
<h1> A-102</h1>
<div className="kx-m__sub">
<span>7</span>
<span className="kx-m__dot" aria-hidden="true" />
<span className="kx-m__accent"> 1</span>
</div>
</div>
</div>
<span className="kx-m__offline"> </span>
</div>
<div className="kx-m__seg" role="tablist">
<button
role="tab"
aria-selected={view === 'plan'}
className={view === 'plan' ? 'is-active' : ''}
onClick={() => setView('plan')}
>
</button>
<button
role="tab"
aria-selected={view === 'photo'}
className={view === 'photo' ? 'is-active' : ''}
onClick={() => setView('photo')}
>
</button>
</div>
</header>
<main className="kx-m__body">
<div className="kx-m__info"> 3 </div>
{/* 반입 전 (완료) */}
<section className="kx-m__acc">
<button className="kx-m__acc-head" onClick={() => toggle('before')} aria-expanded={open.before}>
<span> </span>
<span className="kx-m__acc-count">2/2</span>
</button>
{open.before && (
<div className="kx-m__acc-body">
<label className="kx-m__check is-done">
<input type="checkbox" checked disabled />
<span> </span>
</label>
<label className="kx-m__check is-done">
<input type="checkbox" checked disabled />
<span> </span>
</label>
</div>
)}
</section>
{/* 경고 카드 */}
<div className="kx-m__warn">
<h3> </h3>
<ul>
<li>금지: 전기톱, , </li>
<li> 한도: 70~75dB </li>
</ul>
</div>
{/* 시공 중 */}
<section className="kx-m__acc">
<button className="kx-m__acc-head" onClick={() => toggle('building')} aria-expanded={open.building}>
<span> </span>
<span className="kx-m__acc-count">{Number(checks.floor) + Number(checks.elec)}/2</span>
</button>
{open.building && (
<div className="kx-m__acc-body">
<div className="kx-m__check-row">
<label className="kx-m__check">
<input
type="checkbox"
checked={checks.floor}
onChange={(e) => setChecks((c) => ({ ...c, floor: e.target.checked }))}
/>
<span className="kx-m__check-text">
<small> </small>
</span>
</label>
<button className="kx-m__camera" aria-label="사진 첨부">📷</button>
</div>
<div className="kx-m__check-row">
<label className="kx-m__check">
<input
type="checkbox"
checked={checks.elec}
onChange={(e) => setChecks((c) => ({ ...c, elec: e.target.checked }))}
/>
<span className="kx-m__check-text">
<small> </small>
</span>
</label>
<button className="kx-m__camera" aria-label="사진 첨부">📷</button>
</div>
</div>
)}
</section>
{/* 구조 확인 */}
<section className="kx-m__acc">
<button className="kx-m__acc-head" onClick={() => toggle('structure')} aria-expanded={open.structure}>
<span> </span>
<span className="kx-m__acc-count">{height && Number(height) <= 5 && Number(height) > 0 ? '1' : '0'}/1</span>
</button>
{open.structure && (
<div className="kx-m__acc-body">
<div className="kx-m__field">
<label htmlFor="m-height">
<span className="kx-m__rule">규정: 5m </span>
</label>
<div className="kx-m__input-wrap">
<input
id="m-height"
type="number"
inputMode="decimal"
placeholder="4.2"
value={height}
onChange={(e) => setHeight(e.target.value)}
/>
<span className="kx-m__unit">m</span>
</div>
{height !== '' && Number(height) > 5 && (
<p className="kx-m__field-err">5m </p>
)}
</div>
</div>
)}
</section>
</main>
{/* 하단 */}
<footer className="kx-m__foot">
<p className="kx-m__foot-note"> </p>
<button className="kx-m__cta" disabled={!requiredDone}>
</button>
</footer>
</div>
);
}

View File

@ -0,0 +1,148 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import './mobile.css';
/*
* SCR-M2 ( 390px). Stitch manager_field_inspection .
* - vs (AI ) · / · ·
* - , .
* .
*/
const MOCK = `${import.meta.env.BASE_URL}mock/manager_field_inspection`;
const DRAWING = `${MOCK}/img2.jpg`;
const FIELD = `${MOCK}/img3.jpg`;
const VIOLATION = `${MOCK}/img4.jpg`;
interface Item {
id: string;
label: string;
note?: string;
hasPhoto?: boolean;
}
const ITEMS: Item[] = [
{ id: 'height', label: '장치물 높이 (제한 4m)' },
{ id: 'flame', label: '방염 라벨 부착' },
{ id: 'aisle', label: '통로 침범 여부', note: '통로 0.3m 침범 확인', hasPhoto: true },
{ id: 'noise', label: '소음 규정 준수' },
];
export function ManagerFieldInspectionPage() {
const navigate = useNavigate();
// 기본값: 통로만 부적합(Stitch)
const [verdicts, setVerdicts] = useState<Record<string, 'pass' | 'fail'>>({
height: 'pass',
flame: 'pass',
aisle: 'fail',
noise: 'pass',
});
const [memo, setMemo] = useState('통로 0.3m 침범');
const [slide, setSlide] = useState(0);
const anyFail = Object.values(verdicts).some((v) => v === 'fail');
return (
<div className="kx-m kx-m--inspect">
<header className="kx-m__head">
<div className="kx-m__head-row">
<div className="kx-m__head-left">
<button className="kx-m__back" aria-label="뒤로" onClick={() => navigate(-1)}>
</button>
<div>
<h1> · A-102</h1>
<div className="kx-m__sub">
<span>7 · KINTEX</span>
</div>
</div>
</div>
<img className="kx-m__thumb" src={DRAWING} alt="도면 썸네일" loading="lazy" />
</div>
</header>
<main className="kx-m__body">
{/* 비교 카드 */}
<section className="kx-m__compare">
<div className="kx-m__compare-head">
<h2> vs </h2>
<span className="kx-m__flag"> AI 3</span>
</div>
<div className="kx-m__compare-imgs">
<figure>
<span className="kx-m__img-label"> </span>
<img src={DRAWING} alt="승인 도면" loading="lazy" />
</figure>
<div className="kx-m__compare-div" aria-hidden="true"></div>
<figure>
<span className="kx-m__img-label"> </span>
<img src={FIELD} alt="현장 사진" loading="lazy" />
</figure>
</div>
<div className="kx-m__dots" role="tablist" aria-label="비교 슬라이드">
{[0, 1, 2].map((i) => (
<button
key={i}
className={slide === i ? 'is-active' : ''}
aria-label={`슬라이드 ${i + 1}`}
onClick={() => setSlide(i)}
/>
))}
</div>
</section>
{/* 검수 항목 */}
<section className="kx-m__ilist">
<h3> (4)</h3>
{ITEMS.map((it) => {
const v = verdicts[it.id];
return (
<div key={it.id} className={`kx-m__icard ${v === 'fail' ? 'is-fail' : ''}`}>
<div className="kx-m__icard-top">
<div className="kx-m__icard-label">
<span>{it.label}</span>
{v === 'fail' && it.note && <small> {it.note}</small>}
</div>
<div className="kx-m__verdict" role="radiogroup" aria-label={`${it.label} 판정`}>
<button
role="radio"
aria-checked={v === 'pass'}
className={v === 'pass' ? 'is-pass' : ''}
onClick={() => setVerdicts((s) => ({ ...s, [it.id]: 'pass' }))}
>
</button>
<button
role="radio"
aria-checked={v === 'fail'}
className={v === 'fail' ? 'is-fail' : ''}
onClick={() => setVerdicts((s) => ({ ...s, [it.id]: 'fail' }))}
>
</button>
</div>
</div>
{v === 'fail' && it.hasPhoto && (
<div className="kx-m__icard-evidence">
<img src={VIOLATION} alt="위반 사진" loading="lazy" />
<textarea
value={memo}
onChange={(e) => setMemo(e.target.value)}
rows={2}
placeholder="메모 입력…"
/>
</div>
)}
</div>
);
})}
</section>
</main>
<footer className="kx-m__foot kx-m__foot--dual">
<button className="kx-m__cta kx-m__cta--danger"> </button>
<button className="kx-m__cta kx-m__cta--ok" disabled={anyFail}>
</button>
</footer>
</div>
);
}

View File

@ -0,0 +1,513 @@
/* 모바일 390px 셸 (design.md §2 — 조회·승인·현장 전용) */
.kx-m {
width: 390px;
max-width: 100vw;
margin: 0 auto;
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--color-neutral-050);
position: relative;
box-shadow: 0 0 0 1px var(--color-neutral-200);
}
/* 헤더 */
.kx-m__head {
position: sticky;
top: 0;
z-index: 20;
background: var(--color-white);
border-bottom: var(--border-card);
padding: var(--space-3) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-m__head-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.kx-m__head-left {
display: flex;
align-items: center;
gap: var(--space-2);
}
.kx-m__back {
width: 36px;
height: 36px;
border: none;
background: transparent;
border-radius: 50%;
font-size: 18px;
color: var(--color-neutral-700);
cursor: pointer;
}
.kx-m__back:hover {
background: var(--color-neutral-100);
}
.kx-m__head h1 {
font-size: var(--fs-h3);
color: var(--color-neutral-900);
}
.kx-m__sub {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-m__dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--color-neutral-200);
}
.kx-m__accent {
color: var(--color-primary-600);
font-weight: 700;
}
.kx-m__offline {
font-size: 11px;
color: var(--color-neutral-500);
background: var(--color-neutral-100);
border: 1px solid var(--color-neutral-200);
padding: 3px 8px;
border-radius: var(--radius-pill);
white-space: nowrap;
}
.kx-m__thumb {
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
object-fit: cover;
border: 1px solid var(--color-neutral-200);
}
.kx-m__seg {
display: flex;
background: var(--color-neutral-100);
border-radius: var(--radius-sm);
padding: 3px;
gap: 3px;
}
.kx-m__seg button {
flex: 1;
border: none;
background: transparent;
padding: 7px;
border-radius: var(--radius-sm);
font-size: var(--fs-caption);
font-weight: 600;
color: var(--color-neutral-500);
cursor: pointer;
}
.kx-m__seg button.is-active {
background: var(--color-white);
color: var(--color-primary-700);
box-shadow: var(--shadow-level2);
}
/* 본문 */
.kx-m__body {
flex: 1;
padding: var(--space-4);
padding-bottom: 120px;
display: flex;
flex-direction: column;
gap: var(--space-4);
overflow-y: auto;
}
.kx-m__info {
background: var(--color-primary-050);
border: 1px solid var(--color-primary-100);
border-radius: var(--radius-lg);
padding: var(--space-3);
font-size: var(--fs-body);
font-weight: 600;
color: var(--color-primary-700);
}
/* 아코디언 */
.kx-m__acc {
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
overflow: hidden;
}
.kx-m__acc-head {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
border: none;
background: var(--color-white);
border-bottom: 1px solid var(--color-neutral-100);
cursor: pointer;
}
.kx-m__acc-head span:first-child {
font-size: var(--fs-h3);
font-weight: 600;
color: var(--color-neutral-900);
}
.kx-m__acc-count {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
background: var(--color-neutral-100);
padding: 2px 10px;
border-radius: var(--radius-pill);
}
.kx-m__acc-body {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-m__check {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3);
background: var(--color-neutral-050);
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-lg);
flex: 1;
cursor: pointer;
}
.kx-m__check input {
margin-top: 2px;
width: 20px;
height: 20px;
flex-shrink: 0;
accent-color: var(--color-primary-600);
}
.kx-m__check.is-done span {
text-decoration: line-through;
color: var(--color-neutral-500);
}
.kx-m__check-text {
display: flex;
flex-direction: column;
font-size: var(--fs-body);
color: var(--color-neutral-900);
}
.kx-m__check-text small {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
margin-top: 2px;
}
.kx-m__check-row {
display: flex;
align-items: stretch;
gap: var(--space-3);
}
.kx-m__camera {
width: 48px;
border: 1px solid var(--color-neutral-200);
background: var(--color-neutral-100);
border-radius: var(--radius-lg);
font-size: 20px;
cursor: pointer;
flex-shrink: 0;
}
.kx-m__warn {
background: #fef3f2;
border: 1px solid var(--color-error);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
.kx-m__warn h3 {
font-size: var(--fs-body);
font-weight: 700;
color: var(--color-error);
margin-bottom: var(--space-2);
}
.kx-m__warn ul {
list-style: disc;
padding-left: var(--space-5);
font-size: var(--fs-caption);
color: var(--color-neutral-700);
display: flex;
flex-direction: column;
gap: 2px;
}
.kx-m__field {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
background: var(--color-neutral-050);
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-lg);
}
.kx-m__field label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--fs-body);
color: var(--color-neutral-900);
}
.kx-m__rule {
font-size: 11px;
color: var(--color-neutral-500);
background: var(--color-neutral-100);
padding: 2px 8px;
border-radius: var(--radius-sm);
}
.kx-m__input-wrap {
position: relative;
}
.kx-m__input-wrap input {
width: 100%;
height: 56px;
padding: 0 44px 0 16px;
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-lg);
font-size: var(--fs-h2);
font-weight: 600;
background: var(--color-white);
}
.kx-m__unit {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
font-size: var(--fs-h2);
color: var(--color-neutral-500);
}
.kx-m__field-err {
font-size: var(--fs-caption);
color: var(--color-error);
font-weight: 600;
}
/* 하단 */
.kx-m__foot {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 390px;
max-width: 100vw;
background: var(--color-white);
border-top: var(--border-card);
padding: var(--space-4);
padding-bottom: calc(var(--space-4) + env(safe-area-inset-bottom, 12px));
z-index: 20;
}
.kx-m__foot-note {
text-align: center;
font-size: 11px;
color: var(--color-neutral-500);
margin-bottom: var(--space-2);
}
.kx-m__cta {
width: 100%;
height: 52px;
border: none;
border-radius: var(--radius-lg);
background: var(--color-primary-600);
color: var(--color-white);
font-size: var(--fs-h3);
font-weight: 700;
cursor: pointer;
}
.kx-m__cta:disabled {
background: var(--color-primary-100);
color: var(--color-white);
cursor: not-allowed;
}
.kx-m__foot--dual {
display: flex;
gap: var(--space-3);
}
.kx-m__cta--danger {
background: var(--color-error);
}
.kx-m__cta--ok {
background: var(--color-success);
}
.kx-m__cta--ok:disabled {
background: #a6e0c8;
}
/* ── SCR-M2 비교/검수 ── */
.kx-m__compare {
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
overflow: hidden;
}
.kx-m__compare-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3);
border-bottom: 1px solid var(--color-neutral-100);
background: var(--color-neutral-050);
}
.kx-m__compare-head h2 {
font-size: var(--fs-body);
font-weight: 700;
color: var(--color-neutral-900);
}
.kx-m__flag {
font-size: 11px;
font-weight: 700;
color: var(--color-ai-accent);
background: var(--color-ai-surface);
border: 1px solid var(--color-ai-accent);
padding: 3px 8px;
border-radius: var(--radius-pill);
}
.kx-m__compare-imgs {
display: flex;
align-items: stretch;
gap: var(--space-2);
padding: var(--space-3);
}
.kx-m__compare-imgs figure {
flex: 1;
position: relative;
margin: 0;
}
.kx-m__compare-imgs img {
width: 100%;
aspect-ratio: 4 / 5;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--color-neutral-200);
}
.kx-m__img-label {
position: absolute;
top: 6px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
color: var(--color-neutral-700);
background: rgba(255, 255, 255, 0.85);
padding: 2px 6px;
border-radius: var(--radius-sm);
z-index: 1;
white-space: nowrap;
}
.kx-m__compare-div {
align-self: center;
color: var(--color-neutral-500);
font-size: 14px;
}
.kx-m__dots {
display: flex;
justify-content: center;
gap: var(--space-1);
padding-bottom: var(--space-3);
}
.kx-m__dots button {
width: 8px;
height: 8px;
border-radius: 50%;
border: none;
background: var(--color-neutral-200);
cursor: pointer;
}
.kx-m__dots button.is-active {
background: var(--color-primary-600);
}
.kx-m__ilist {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.kx-m__ilist h3 {
font-size: var(--fs-body);
font-weight: 700;
color: var(--color-neutral-900);
padding: 0 var(--space-1);
}
.kx-m__icard {
background: var(--color-white);
border: var(--border-card);
border-left: 4px solid var(--color-neutral-200);
border-radius: var(--radius-lg);
padding: var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-m__icard.is-fail {
border-color: var(--color-error);
border-left-color: var(--color-error);
background: #fffbfa;
}
.kx-m__icard-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.kx-m__icard-label {
display: flex;
flex-direction: column;
font-size: var(--fs-body);
color: var(--color-neutral-900);
}
.kx-m__icard-label small {
font-size: 11px;
color: var(--color-error);
font-weight: 700;
margin-top: 2px;
}
.kx-m__verdict {
display: flex;
background: var(--color-neutral-050);
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-sm);
padding: 2px;
flex-shrink: 0;
}
.kx-m__verdict button {
border: none;
background: transparent;
padding: 6px 12px;
border-radius: var(--radius-sm);
font-size: var(--fs-caption);
font-weight: 600;
color: var(--color-neutral-500);
cursor: pointer;
}
.kx-m__verdict button.is-pass {
background: var(--color-white);
color: var(--color-success);
box-shadow: var(--shadow-level2);
}
.kx-m__verdict button.is-fail {
background: var(--color-error);
color: var(--color-white);
}
.kx-m__icard-evidence {
display: flex;
gap: var(--space-2);
}
.kx-m__icard-evidence img {
width: 64px;
height: 64px;
border-radius: var(--radius-sm);
object-fit: cover;
border: 1px solid var(--color-error);
flex-shrink: 0;
}
.kx-m__icard-evidence textarea {
flex: 1;
border: 1px solid var(--color-error);
border-radius: var(--radius-sm);
padding: var(--space-2);
font-size: var(--fs-caption);
resize: none;
font-family: inherit;
}

View File

@ -0,0 +1,153 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { utilityApi } from '../../api/endpoints';
import { Button } from '../../components/ui/Button';
import { DdayChip } from '../../components/ui/Badge';
import type { UtilityOrderDto, UtilityQuote } from '../../api/types';
import './utility-order.css';
/*
* SCR-08 . Stitch utility_order_summary .
* - : 위치표시도(PDF, "AI verified" ) / : 신청 + +
* - 하단: 신청자 + 2 +
* utility GET(order) 501/ degraded .
*/
const MOCK = `${import.meta.env.BASE_URL}mock/utility_order_summary`;
const DIAGRAM = `${MOCK}/img1.jpg`;
const SAMPLE_QUOTE: UtilityQuote = {
totalPowerKw: 5,
requestedKw: 5,
distributionBox50A: 1,
lines: [
{ label: '전기 (24시간) 5kW', qty: 1, unitPrice: 275000, amount: 275000 },
{ label: '분전반 설치 (50A)', qty: 1, unitPrice: 100000, amount: 100000 },
{ label: '전용선 인터넷 (LAN)', qty: 1, unitPrice: 150000, amount: 150000 },
{ label: '급배수 (오·급수 연결점)', qty: 1, unitPrice: 150000, amount: 150000 },
],
total: 675000,
currency: 'KRW',
rulesetVersion: 'v1.0',
disclaimer: 'AI verified — 위치표시도는 참고용이며 시공 기준은 확정 도면입니다.',
};
const won = (n: number) => `${n.toLocaleString('ko-KR')}`;
export function UtilityOrderSummaryPage() {
const { eventId = '', boothId = 'A-102' } = useParams();
const [order, setOrder] = useState<UtilityOrderDto | null>(null);
const [quote, setQuote] = useState<UtilityQuote>(SAMPLE_QUOTE);
const [degraded, setDegraded] = useState(false);
const [agree1, setAgree1] = useState(false);
const [agree2, setAgree2] = useState(false);
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
let alive = true;
utilityApi
.get(eventId, boothId)
.then((o) => {
if (!alive) return;
setOrder(o);
setQuote(o.quote);
})
.catch(() => alive && setDegraded(true));
return () => {
alive = false;
};
}, [eventId, boothId]);
return (
<div className="kx-uord">
<header className="kx-uord__head">
<div className="kx-uord__title">
<button className="kx-uord__back" aria-label="뒤로" onClick={() => history.back()}>
</button>
<h1> </h1>
</div>
<ol className="kx-uord__wizard" aria-label="신청 단계">
<li className="is-done"> </li>
<li className="is-current"> </li>
<li> </li>
</ol>
</header>
<div className="kx-uord__body">
{/* 위치표시도 */}
<section className="kx-uord__diagram" aria-label="유틸리티 위치표시도">
<div className="kx-uord__diagram-bar">
<div>
<h2> </h2>
<p> .</p>
</div>
<Button variant="ghost" leadingIcon="⭳">
PDF
</Button>
</div>
<figure className="kx-uord__diagram-frame">
<img src={DIAGRAM} alt="유틸리티 위치표시도 (도면)" loading="lazy" />
<span className="kx-uord__verified" aria-hidden="true"> AI verified</span>
</figure>
</section>
{/* 신청 내역 */}
<aside className="kx-uord__panel" aria-label="신청 내역">
<div className="kx-uord__panel-head">
<h2> </h2>
<DdayChip dday={6} />
</div>
<ul className="kx-uord__lines">
{quote.lines.map((l) => (
<li key={l.label}>
<span>{l.label}</span>
<strong className="tnum">{won(l.amount)}</strong>
</li>
))}
</ul>
<div className="kx-uord__total">
<span> (VAT )</span>
<strong className="tnum">{won(quote.total)}</strong>
</div>
<div className="kx-uord__notice">
<span className="kx-uord__notice-icon" aria-hidden="true"></span>
<p>
2 · .
</p>
</div>
{degraded && (
<p className="kx-uord__degraded"> API .</p>
)}
<div className="kx-uord__applicant">
<h3> </h3>
<p>() · {boothId} ()</p>
<p> · 010-1234-5678 · dav@techsolutions.com</p>
</div>
<label className="kx-uord__agree">
<input type="checkbox" checked={agree1} onChange={(e) => setAgree1(e.target.checked)} />
(D-6) .
</label>
<label className="kx-uord__agree">
<input type="checkbox" checked={agree2} onChange={(e) => setAgree2(e.target.checked)} />
, .
</label>
<Button
block
disabled={!agree1 || !agree2 || submitted}
onClick={() => setSubmitted(true)}
>
{submitted ? '신청 완료됨' : '신청 제출 →'}
</Button>
{order && <p className="kx-uord__order-id tnum"> {order.orderId}</p>}
</aside>
</div>
</div>
);
}

View File

@ -0,0 +1,234 @@
.kx-uord {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.kx-uord__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-4) var(--space-5);
border-bottom: var(--border-card);
background: var(--color-white);
}
.kx-uord__title {
display: flex;
align-items: center;
gap: var(--space-3);
}
.kx-uord__title h1 {
font-size: var(--fs-h2);
color: var(--color-neutral-900);
}
.kx-uord__back {
width: 32px;
height: 32px;
border: 1px solid var(--color-neutral-200);
background: var(--color-white);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 16px;
color: var(--color-neutral-700);
}
.kx-uord__wizard {
display: flex;
align-items: center;
gap: var(--space-4);
list-style: none;
counter-reset: step;
}
.kx-uord__wizard li {
counter-increment: step;
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-uord__wizard li::before {
content: counter(step);
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
background: var(--color-neutral-200);
color: var(--color-neutral-500);
}
.kx-uord__wizard li.is-done::before {
content: '✓';
background: var(--color-primary-600);
color: var(--color-white);
}
.kx-uord__wizard li.is-current {
color: var(--color-primary-700);
font-weight: 600;
}
.kx-uord__wizard li.is-current::before {
background: var(--color-primary-600);
color: var(--color-white);
}
.kx-uord__body {
display: grid;
grid-template-columns: 1fr 380px;
gap: var(--space-5);
padding: var(--space-5);
flex: 1;
min-height: 0;
overflow-y: auto;
align-items: start;
}
.kx-uord__diagram-bar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-3);
}
.kx-uord__diagram-bar h2 {
font-size: var(--fs-h3);
color: var(--color-neutral-900);
}
.kx-uord__diagram-bar p {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
margin-top: 2px;
}
.kx-uord__diagram-frame {
position: relative;
margin: 0;
border: var(--border-card);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-primary-050);
}
.kx-uord__diagram-frame img {
width: 100%;
display: block;
object-fit: contain;
background: #f6f8fb;
}
.kx-uord__verified {
position: absolute;
bottom: var(--space-3);
right: var(--space-3);
font-size: 11px;
font-weight: 700;
color: var(--color-primary-700);
background: rgba(255, 255, 255, 0.9);
border: 1px solid var(--color-primary-100);
padding: 3px 8px;
border-radius: var(--radius-pill);
}
/* 신청 내역 패널 */
.kx-uord__panel {
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-uord__panel-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.kx-uord__panel-head h2 {
font-size: var(--fs-h3);
color: var(--color-neutral-900);
}
.kx-uord__lines {
list-style: none;
display: flex;
flex-direction: column;
}
.kx-uord__lines li {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-neutral-100);
font-size: var(--fs-body);
color: var(--color-neutral-700);
}
.kx-uord__lines strong {
color: var(--color-neutral-900);
}
.kx-uord__total {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) 0;
border-top: 2px solid var(--color-neutral-200);
}
.kx-uord__total span {
font-size: var(--fs-body);
color: var(--color-neutral-700);
}
.kx-uord__total strong {
font-size: var(--fs-h2);
color: var(--color-primary-700);
}
.kx-uord__notice {
display: flex;
gap: var(--space-2);
background: var(--color-primary-050);
border-radius: var(--radius-sm);
padding: var(--space-3);
font-size: var(--fs-caption);
color: var(--color-primary-700);
line-height: 16px;
}
.kx-uord__notice-icon {
flex-shrink: 0;
}
.kx-uord__degraded {
font-size: var(--fs-caption);
color: var(--color-warning);
}
.kx-uord__applicant {
border-top: 1px solid var(--color-neutral-100);
padding-top: var(--space-3);
}
.kx-uord__applicant h3 {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
margin-bottom: var(--space-1);
}
.kx-uord__applicant p {
font-size: var(--fs-caption);
color: var(--color-neutral-700);
}
.kx-uord__agree {
display: flex;
gap: var(--space-2);
font-size: var(--fs-caption);
color: var(--color-neutral-700);
line-height: 16px;
align-items: flex-start;
}
.kx-uord__agree input {
margin-top: 2px;
flex-shrink: 0;
}
.kx-uord__order-id {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
text-align: center;
}
@media (max-width: 1000px) {
.kx-uord__body {
grid-template-columns: 1fr;
}
}