Compare commits

..

No commits in common. "becbc55152777311c102e434a0ec8e6d058c547f" and "345082c251c7e9b1b3380cb6ae97d639a45a9f0e" have entirely different histories.

51 changed files with 2 additions and 17322 deletions

View File

@ -31,8 +31,6 @@ export default function RootLayout() {
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="checklist" options={{ title: '현장 체크리스트' }} />
<Stack.Screen name="inspection" options={{ title: '현장 검수' }} />
<Stack.Screen name="tickets/index" options={{ title: '내 티켓' }} />
<Stack.Screen name="tickets/select" options={{ title: '티켓 예매' }} />
</Stack>
</AuthProvider>
</SafeAreaProvider>

View File

@ -1,188 +0,0 @@
/*
* SCR-M15 [] (B2C) .
* (··) + + QR .
* + . / .
* M10()·M9() ("샘플" ). API .
*/
import { Ionicons } from '@expo/vector-icons';
import { router, Stack } from 'expo-router';
import React, { useMemo, useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { Banner } from '../../components/Banner';
import { QrViewerModal } from '../../components/tickets/QrViewerModal';
import { TicketCard } from '../../components/tickets/TicketCard';
import {
FILTER_TABS,
SAMPLE_TICKETS,
type SampleTicket,
type TicketFilter,
} from '../../components/tickets/sampleTickets';
import { colors, radius, spacing, type } from '../../theme';
export default function TicketWalletScreen() {
const [filter, setFilter] = useState<TicketFilter>('active');
const [qrOpen, setQrOpen] = useState(false);
const [qrIndex, setQrIndex] = useState(0);
const filtered = useMemo(
() => SAMPLE_TICKETS.filter((t) => t.filter === filter),
[filter],
);
// QR 뷰어 대상 = 현재 필터 내 사용가능 티켓만(스와이프 전환 범위)
const usableInView = useMemo(
() => filtered.filter((t) => t.status === 'usable'),
[filtered],
);
function openQr(t: SampleTicket) {
const i = usableInView.findIndex((x) => x.id === t.id);
setQrIndex(i < 0 ? 0 : i);
setQrOpen(true);
}
function openDetail(t: SampleTicket) {
// SCR-P8(예매 확인·취소) 미구현 → 안내만(샘플)
Alert.alert(
'예매 상세·취소',
`${t.eventName}\n예매번호 ${t.bookingNoMasked}\n\n예매 확인·취소 화면(SCR-P8)은 티켓 백엔드(M10) 연동 후 제공됩니다.`,
);
}
return (
<View style={styles.flex}>
<Stack.Screen options={{ title: '내 티켓', headerTitleAlign: 'center' }} />
<ScrollView contentContainerStyle={styles.scroll}>
{/* 오프라인 표시 배지 */}
<View style={styles.offlineWrap}>
<View style={styles.offlineBadge}>
<View style={styles.offlineDot} />
<Text style={styles.offlineText}> </Text>
</View>
</View>
{/* 필터 탭(세그먼트) */}
<View style={styles.segment}>
{FILTER_TABS.map((tab) => {
const on = filter === tab.key;
return (
<Pressable
key={tab.key}
accessibilityRole="tab"
accessibilityState={{ selected: on }}
style={[styles.segBtn, on && styles.segBtnOn]}
onPress={() => setFilter(tab.key)}
>
<Text style={[styles.segText, on && styles.segTextOn]}>{tab.label}</Text>
</Pressable>
);
})}
</View>
{/* 배지 전환 안내 배너 */}
<Banner tone="info">
QR로
</Banner>
{/* 티켓 리스트 / 빈 상태 */}
{filtered.length === 0 ? (
<EmptyState />
) : (
filtered.map((t) => (
<TicketCard key={t.id} ticket={t} onOpenQr={openQr} onDetail={openDetail} />
))
)}
<Text style={styles.footNote}>
(M10) .
</Text>
</ScrollView>
<QrViewerModal
visible={qrOpen}
tickets={usableInView}
index={qrIndex}
onChangeIndex={setQrIndex}
onClose={() => setQrOpen(false)}
/>
</View>
);
}
function EmptyState() {
return (
<View style={styles.empty}>
<Ionicons name="ticket-outline" size={44} color={colors.neutral200} />
<Text style={styles.emptyTitle}> </Text>
<Text style={styles.emptyBody}> .</Text>
<Pressable
accessibilityRole="button"
style={styles.emptyBtn}
onPress={() => router.push('/tickets/select')}
>
<Text style={styles.emptyBtnText}> </Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
flex: { flex: 1, backgroundColor: colors.neutral050 },
scroll: { padding: spacing.md, gap: spacing.md, paddingBottom: 40 },
offlineWrap: { alignItems: 'center' },
offlineBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.neutral200,
borderRadius: radius.pill,
paddingHorizontal: 12,
paddingVertical: 5,
},
offlineDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.primary600 },
offlineText: { fontSize: 11, color: colors.neutral500, fontWeight: '500' },
segment: {
flexDirection: 'row',
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.neutral200,
borderRadius: radius.md,
padding: 4,
gap: 4,
},
segBtn: {
flex: 1,
minHeight: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
segBtnOn: { backgroundColor: colors.primary050 },
segText: { fontSize: type.body.fontSize, fontWeight: '600', color: colors.neutral500 },
segTextOn: { color: colors.primary700 },
footNote: { textAlign: 'center', fontSize: 11, color: colors.neutral500, marginTop: 4 },
empty: {
alignItems: 'center',
gap: 8,
paddingVertical: spacing.xl,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.neutral200,
borderRadius: radius.md,
},
emptyTitle: { fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
emptyBody: { fontSize: type.caption.fontSize, color: colors.neutral500, textAlign: 'center' },
emptyBtn: {
marginTop: 8,
minHeight: 48,
paddingHorizontal: 24,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.primary600,
borderRadius: radius.sm,
},
emptyBtnText: { color: colors.white, fontSize: type.h3.fontSize, fontWeight: '700' },
});

View File

@ -1,362 +0,0 @@
/*
* SCR-M14 [] SCR-M15 ( ).
* 4 (···) 1. + + .
* PG UI (design.md ). M10 .
* "다음" (/) ( API ).
*/
import { Ionicons } from '@expo/vector-icons';
import { router, Stack } from 'expo-router';
import React, { useMemo, useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { colors, radius, spacing, touch, type } from '../../theme';
interface TicketType {
id: string;
name: string;
price: number; // 원(0=무료)
priceNote?: string;
desc?: string;
badge?: { text: string; tone: 'discount' | 'ai' };
soldOut?: boolean;
accent: string;
}
const TICKET_TYPES: TicketType[] = [
{ id: 'std', name: '일반권', price: 0, priceNote: '사전등록 할인가', accent: colors.primary600 },
{
id: 'buyer',
name: '바이어권',
price: 0,
desc: '무료 (자격심사 필요)',
badge: { text: 'AI 추천', tone: 'ai' },
accent: colors.aiAccent,
},
{
id: 'group',
name: '단체권',
price: 9000,
priceNote: '10인 이상 단체 구매시 적용',
badge: { text: '10% OFF', tone: 'discount' },
accent: colors.success,
},
{ id: 'vip', name: 'VIP권', price: 50000, soldOut: true, accent: colors.neutral500 },
];
const STEPS = ['권종', '예매자', '결제', '완료'];
function won(n: number): string {
return n === 0 ? '₩0' : `${n.toLocaleString('ko-KR')}`;
}
export default function TicketSelectScreen() {
const [qty, setQty] = useState<Record<string, number>>({ group: 1 });
const totalCount = useMemo(
() => Object.values(qty).reduce((a, b) => a + b, 0),
[qty],
);
const totalPrice = useMemo(
() => TICKET_TYPES.reduce((sum, t) => sum + (qty[t.id] ?? 0) * t.price, 0),
[qty],
);
function change(id: string, delta: number) {
setQty((q) => ({ ...q, [id]: Math.max(0, (q[id] ?? 0) + delta) }));
}
function next() {
if (totalCount === 0) return;
Alert.alert(
'다음 단계',
'예매자 정보·결제 단계는 티켓 백엔드(M10) 및 PG 어댑터 연동 후 제공됩니다.\n결제는 PG사 보안 페이지에서 진행되며 카드정보는 저장되지 않습니다.',
);
}
return (
<View style={styles.flex}>
<Stack.Screen options={{ title: '티켓 예매', headerTitleAlign: 'center' }} />
<ScrollView contentContainerStyle={styles.scroll}>
{/* 행사 요약 칩 */}
<View style={styles.eventChip}>
<Ionicons name="calendar-outline" size={16} color={colors.primary600} />
<Text style={styles.eventChipText} numberOfLines={1}>
2026 · 2 7
</Text>
</View>
{/* 스텝 인디케이터 */}
<View style={styles.steps}>
{STEPS.map((s, i) => (
<React.Fragment key={s}>
<View style={styles.step}>
<View style={[styles.stepDot, i === 0 && styles.stepDotOn]}>
<Text style={[styles.stepNum, i === 0 && styles.stepNumOn]}>{i + 1}</Text>
</View>
<Text style={[styles.stepLabel, i === 0 && styles.stepLabelOn]}>{s}</Text>
</View>
{i < STEPS.length - 1 ? <View style={styles.stepLine} /> : null}
</React.Fragment>
))}
</View>
{/* 권종 카드 */}
<View style={styles.cardList}>
{TICKET_TYPES.map((t) => {
const count = qty[t.id] ?? 0;
return (
<View
key={t.id}
style={[styles.card, t.soldOut && styles.cardSoldOut]}
>
<View style={[styles.cardAccent, { backgroundColor: t.accent }]} />
<View style={styles.cardBody}>
<View style={styles.cardTop}>
<View style={styles.cardInfo}>
<View style={styles.nameRow}>
<Text style={styles.typeName}>{t.name}</Text>
{t.badge ? (
<View
style={[
styles.typeBadge,
t.badge.tone === 'ai' ? styles.badgeAi : styles.badgeDiscount,
]}
>
{t.badge.tone === 'ai' ? (
<Ionicons name="sparkles" size={11} color={colors.aiAccent} />
) : null}
<Text
style={[
styles.typeBadgeText,
{ color: t.badge.tone === 'ai' ? colors.aiAccent : colors.error },
]}
>
{t.badge.text}
</Text>
</View>
) : null}
{t.soldOut ? (
<View style={styles.soldOutBadge}>
<Text style={styles.soldOutText}></Text>
</View>
) : null}
</View>
{t.desc ? (
<Text style={styles.typeDesc}>{t.desc}</Text>
) : (
<Text style={styles.typePrice}>{won(t.price)}</Text>
)}
{t.priceNote ? <Text style={styles.priceNote}>{t.priceNote}</Text> : null}
</View>
{/* 수량 스테퍼 */}
<View style={styles.stepper}>
<Pressable
accessibilityLabel="감소"
disabled={t.soldOut}
onPress={() => change(t.id, -1)}
style={styles.stepperBtn}
>
<Ionicons
name="remove"
size={20}
color={t.soldOut ? colors.neutral200 : colors.neutral700}
/>
</Pressable>
<Text style={styles.stepperVal}>{count}</Text>
<Pressable
accessibilityLabel="증가"
disabled={t.soldOut}
onPress={() => change(t.id, 1)}
style={styles.stepperBtn}
>
<Ionicons
name="add"
size={20}
color={t.soldOut ? colors.neutral200 : colors.neutral700}
/>
</Pressable>
</View>
</View>
{t.badge?.tone === 'ai' ? (
<Text style={styles.aiNote}>
AI .
</Text>
) : null}
</View>
</View>
);
})}
</View>
{/* 결제 수단 미리보기 */}
<View style={styles.paySection}>
<Text style={styles.payTitle}> </Text>
<View style={styles.payChips}>
{['신용카드', '간편결제', '계좌이체'].map((p) => (
<View key={p} style={styles.payChip}>
<Text style={styles.payChipText}>{p}</Text>
</View>
))}
</View>
<View style={styles.secureNote}>
<Ionicons name="shield-checkmark-outline" size={20} color={colors.neutral500} />
<Text style={styles.secureText}>
PG사 .
</Text>
</View>
</View>
</ScrollView>
{/* 하단 스티키 합계 바 */}
<View style={styles.footer}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}> {totalCount}</Text>
<Text style={styles.totalPrice}> {won(totalPrice)}</Text>
</View>
<Pressable
accessibilityRole="button"
disabled={totalCount === 0}
onPress={next}
style={[styles.nextBtn, totalCount === 0 && styles.nextBtnOff]}
>
<Text style={styles.nextText}></Text>
<Ionicons name="chevron-forward" size={20} color={colors.white} />
</Pressable>
<Pressable style={styles.backLink} onPress={() => router.back()}>
<Text style={styles.backLinkText}> </Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
flex: { flex: 1, backgroundColor: colors.neutral050 },
scroll: { padding: spacing.md, gap: spacing.md, paddingBottom: 200 },
eventChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
alignSelf: 'flex-start',
backgroundColor: colors.primary050,
borderRadius: radius.pill,
paddingHorizontal: 14,
paddingVertical: 8,
maxWidth: '100%',
},
eventChipText: { flex: 1, fontSize: type.caption.fontSize, color: colors.neutral900, fontWeight: '600' },
steps: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 4 },
step: { alignItems: 'center', gap: 4 },
stepDot: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.neutral200,
alignItems: 'center',
justifyContent: 'center',
},
stepDotOn: { backgroundColor: colors.primary600 },
stepNum: { fontSize: type.body.fontSize, fontWeight: '700', color: colors.neutral500 },
stepNumOn: { color: colors.white },
stepLabel: { fontSize: 11, color: colors.neutral500 },
stepLabelOn: { color: colors.primary700, fontWeight: '700' },
stepLine: { flex: 1, height: 1, backgroundColor: colors.neutral200, marginHorizontal: 6, marginBottom: 18 },
cardList: { gap: spacing.md },
card: {
flexDirection: 'row',
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.neutral200,
borderRadius: radius.md,
overflow: 'hidden',
},
cardSoldOut: { opacity: 0.6 },
cardAccent: { width: 4 },
cardBody: { flex: 1, padding: spacing.md, gap: 8 },
cardTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 },
cardInfo: { flex: 1, gap: 4 },
nameRow: { flexDirection: 'row', alignItems: 'center', gap: 8, flexWrap: 'wrap' },
typeName: { fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
typeBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 3,
borderRadius: radius.sm,
paddingHorizontal: 6,
paddingVertical: 2,
borderWidth: 1,
},
badgeAi: { borderColor: colors.aiAccent, backgroundColor: colors.aiSurface },
badgeDiscount: { borderColor: colors.error, backgroundColor: '#FEF3F2' },
typeBadgeText: { fontSize: 10, fontWeight: '700' },
soldOutBadge: { backgroundColor: colors.neutral500, borderRadius: radius.sm, paddingHorizontal: 6, paddingVertical: 2 },
soldOutText: { color: colors.white, fontSize: 10, fontWeight: '700' },
typePrice: { fontSize: type.h2.fontSize, fontWeight: '700', color: colors.neutral900 },
typeDesc: { fontSize: type.body.fontSize, color: colors.neutral700 },
priceNote: { fontSize: type.caption.fontSize, color: colors.neutral500 },
aiNote: { fontSize: 11, color: colors.aiAccent, fontWeight: '500' },
stepper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.neutral050,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.neutral200,
},
stepperBtn: { width: touch.min, height: touch.min, alignItems: 'center', justifyContent: 'center' },
stepperVal: { width: 28, textAlign: 'center', fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
paySection: { gap: 8, marginTop: 4 },
payTitle: { fontSize: type.caption.fontSize, color: colors.neutral500, fontWeight: '600' },
payChips: { flexDirection: 'row', gap: 8 },
payChip: {
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.neutral200,
borderRadius: radius.pill,
paddingHorizontal: 14,
paddingVertical: 8,
},
payChipText: { fontSize: type.caption.fontSize, color: colors.neutral700 },
secureNote: {
flexDirection: 'row',
gap: 10,
alignItems: 'flex-start',
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.neutral200,
borderStyle: 'dashed',
borderRadius: radius.md,
padding: spacing.md,
marginTop: 8,
},
secureText: { flex: 1, fontSize: type.caption.fontSize, color: colors.neutral700, lineHeight: 18 },
footer: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
backgroundColor: colors.white,
borderTopWidth: 1,
borderTopColor: colors.neutral200,
padding: spacing.md,
paddingBottom: spacing.lg,
gap: 10,
},
totalRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
totalLabel: { fontSize: type.body.fontSize, color: colors.neutral500 },
totalPrice: { fontSize: type.h2.fontSize, fontWeight: '700', color: colors.primary700 },
nextBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
minHeight: 56,
backgroundColor: colors.primary600,
borderRadius: radius.md,
},
nextBtnOff: { opacity: 0.5 },
nextText: { color: colors.white, fontSize: type.h2.fontSize, fontWeight: '700' },
backLink: { alignItems: 'center', minHeight: 32, justifyContent: 'center' },
backLinkText: { fontSize: type.caption.fontSize, color: colors.neutral500, textDecorationLine: 'underline' },
});

View File

@ -1,122 +0,0 @@
/*
* QR (placeholder) QR (GAP).
* react-native-svg로 +
* "QR처럼 보이는" . "샘플" .
* QR은 M10 + QR (: _workspace/port_mobile_m15.md).
*/
import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Svg, { Rect } from 'react-native-svg';
import { colors, radius } from '../../theme';
interface Props {
seed: string;
size?: number;
quiet?: boolean; // 여백(quiet zone) 포함 여부
}
// 결정론적 해시(문자열 → 32bit) — 시드로 모듈 on/off 결정
function hashAt(seed: string, i: number): number {
let h = 2166136261 ^ i;
for (let k = 0; k < seed.length; k++) {
h ^= seed.charCodeAt(k);
h = Math.imul(h, 16777619);
}
return (h >>> 0) % 100;
}
const MODULES = 25; // 25x25 격자
export function QrPlaceholder({ seed, size = 192, quiet = true }: Props) {
const cells = useMemo(() => {
const out: { x: number; y: number }[] = [];
for (let y = 0; y < MODULES; y++) {
for (let x = 0; x < MODULES; x++) {
if (isFinderZone(x, y)) continue; // 파인더 영역은 별도 렌더
if (hashAt(seed, y * MODULES + x) < 48) out.push({ x, y });
}
}
return out;
}, [seed]);
const pad = quiet ? 2 : 0;
const total = MODULES + pad * 2;
const cell = size / total;
return (
<View
accessibilityRole="image"
accessibilityLabel="입장 QR 코드 (샘플)"
style={[styles.wrap, { width: size, height: size }]}
>
<Svg width={size} height={size}>
<Rect x={0} y={0} width={size} height={size} fill={colors.white} />
{cells.map((c, idx) => (
<Rect
key={idx}
x={(c.x + pad) * cell}
y={(c.y + pad) * cell}
width={cell}
height={cell}
fill={colors.neutral900}
/>
))}
{/* 3개 파인더 패턴(좌상·우상·좌하) */}
<Finder x={pad} y={pad} cell={cell} />
<Finder x={pad + MODULES - 7} y={pad} cell={cell} />
<Finder x={pad} y={pad + MODULES - 7} cell={cell} />
</Svg>
<View style={styles.sampleTag}>
<Text style={styles.sampleText}></Text>
</View>
</View>
);
}
function isFinderZone(x: number, y: number): boolean {
const inTL = x < 8 && y < 8;
const inTR = x >= MODULES - 8 && y < 8;
const inBL = x < 8 && y >= MODULES - 8;
return inTL || inTR || inBL;
}
function Finder({ x, y, cell }: { x: number; y: number; cell: number }) {
return (
<>
<Rect x={x * cell} y={y * cell} width={cell * 7} height={cell * 7} fill={colors.neutral900} />
<Rect
x={(x + 1) * cell}
y={(y + 1) * cell}
width={cell * 5}
height={cell * 5}
fill={colors.white}
/>
<Rect
x={(x + 2) * cell}
y={(y + 2) * cell}
width={cell * 3}
height={cell * 3}
fill={colors.neutral900}
/>
</>
);
}
const styles = StyleSheet.create({
wrap: {
borderRadius: radius.sm,
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
},
sampleTag: {
position: 'absolute',
right: 4,
bottom: 4,
backgroundColor: colors.aiAccent,
borderRadius: radius.sm,
paddingHorizontal: 6,
paddingVertical: 2,
},
sampleText: { color: colors.white, fontSize: 10, fontWeight: '700' },
});

View File

@ -1,177 +0,0 @@
/*
* QR (SCR-M15 3) QR + · + .
* ( ). .
* expo-brightness + (placeholder).
*/
import { Ionicons } from '@expo/vector-icons';
import React from 'react';
import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { colors, radius, spacing, touch, type } from '../../theme';
import { QrPlaceholder } from './QrPlaceholder';
import type { SampleTicket } from './sampleTickets';
export function QrViewerModal({
visible,
tickets,
index,
onChangeIndex,
onClose,
}: {
visible: boolean;
tickets: SampleTicket[];
index: number;
onChangeIndex: (i: number) => void;
onClose: () => void;
}) {
const ticket = tickets[index];
if (!ticket) return null;
const canPrev = index > 0;
const canNext = index < tickets.length - 1;
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={styles.backdrop}>
<View style={styles.sheet}>
{/* 헤더 (그라디언트 대체: 브랜드 딥블루 단색) */}
<View style={styles.header}>
<Pressable
accessibilityRole="button"
accessibilityLabel="닫기"
style={styles.closeBtn}
onPress={onClose}
>
<Ionicons name="close" size={24} color={colors.white} />
</Pressable>
<Text style={styles.headerTitle}> QR </Text>
<Text style={styles.headerSub} numberOfLines={1}>
{ticket.eventName}
</Text>
</View>
<ScrollView contentContainerStyle={styles.content}>
<QrPlaceholder seed={ticket.qrSeed} size={200} />
<View style={styles.nameRow}>
<Text style={styles.holderName}>{ticket.holderName}</Text>
<Text style={styles.holderType}> {ticket.ticketType}</Text>
</View>
<View style={styles.bookingChip}>
<Text style={styles.bookingText}>{ticket.bookingNoMasked}</Text>
</View>
{/* 밝기 부스트 안내 */}
<View style={styles.hint}>
<Ionicons name="bulb-outline" size={20} color={colors.primary600} />
<Text style={styles.hintText}>
<Text style={styles.hintBold}> </Text>
</Text>
</View>
{/* 스와이프(버튼) 전환 */}
{tickets.length > 1 ? (
<View style={styles.swipeRow}>
<Pressable
accessibilityLabel="이전 티켓"
disabled={!canPrev}
onPress={() => onChangeIndex(index - 1)}
style={[styles.navBtn, !canPrev && styles.navBtnOff]}
>
<Ionicons name="chevron-back" size={22} color={canPrev ? colors.primary600 : colors.neutral200} />
</Pressable>
<View style={styles.dots}>
{tickets.map((t, i) => (
<View
key={t.id}
style={[styles.dot, i === index ? styles.dotOn : null]}
/>
))}
</View>
<Pressable
accessibilityLabel="다음 티켓"
disabled={!canNext}
onPress={() => onChangeIndex(index + 1)}
style={[styles.navBtn, !canNext && styles.navBtnOff]}
>
<Ionicons name="chevron-forward" size={22} color={canNext ? colors.primary600 : colors.neutral200} />
</Pressable>
</View>
) : null}
<Text style={styles.swipeHint}> </Text>
</ScrollView>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(16,24,40,0.6)',
alignItems: 'center',
justifyContent: 'center',
padding: spacing.lg,
},
sheet: {
width: '100%',
maxWidth: 360,
backgroundColor: colors.white,
borderRadius: radius.md,
overflow: 'hidden',
},
header: {
backgroundColor: colors.primary700,
paddingVertical: spacing.lg,
alignItems: 'center',
gap: 4,
},
closeBtn: {
position: 'absolute',
top: 8,
right: 8,
width: touch.min,
height: touch.min,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: { color: colors.white, fontSize: type.h2.fontSize, fontWeight: '700' },
headerSub: { color: 'rgba(255,255,255,0.85)', fontSize: type.caption.fontSize, maxWidth: 260 },
content: { alignItems: 'center', padding: spacing.lg, gap: spacing.md },
nameRow: { flexDirection: 'row', alignItems: 'baseline' },
holderName: { fontSize: type.h1.fontSize, fontWeight: '700', color: colors.neutral900 },
holderType: { fontSize: type.body.fontSize, fontWeight: '500', color: colors.neutral500 },
bookingChip: {
backgroundColor: colors.primary050,
borderRadius: radius.pill,
paddingHorizontal: 12,
paddingVertical: 4,
},
bookingText: { color: colors.primary700, fontSize: type.caption.fontSize, fontVariant: ['tabular-nums'] },
hint: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: colors.neutral050,
borderRadius: radius.md,
padding: spacing.md,
width: '100%',
},
hintText: { flex: 1, fontSize: type.caption.fontSize, color: colors.neutral700 },
hintBold: { fontWeight: '700', color: colors.neutral900 },
swipeRow: { flexDirection: 'row', alignItems: 'center', gap: 12, marginTop: 4 },
navBtn: {
width: touch.min,
height: touch.min,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.neutral200,
alignItems: 'center',
justifyContent: 'center',
},
navBtnOff: { opacity: 0.5 },
dots: { flexDirection: 'row', alignItems: 'center', gap: 6 },
dot: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.neutral200 },
dotOn: { width: 20, backgroundColor: colors.primary600 },
swipeHint: { fontSize: 11, color: colors.neutral500, letterSpacing: 1 },
});

View File

@ -1,165 +0,0 @@
/*
* SCR-M15 .
* ··· + QR(placeholder) + "입장 QR 보기".
* / (dim). .
*/
import { Ionicons } from '@expo/vector-icons';
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors, radius, spacing, touch, type } from '../../theme';
import { QrPlaceholder } from './QrPlaceholder';
import { STATUS_META, type SampleTicket } from './sampleTickets';
const badgeStyle: Record<'success' | 'muted' | 'error', { bg: string; fg: string }> = {
success: { bg: '#E7F6EF', fg: colors.success },
muted: { bg: colors.neutral050, fg: colors.neutral500 },
error: { bg: '#FEF3F2', fg: colors.error },
};
export function TicketCard({
ticket,
onOpenQr,
onDetail,
}: {
ticket: SampleTicket;
onOpenQr: (t: SampleTicket) => void;
onDetail: (t: SampleTicket) => void;
}) {
const dim = ticket.status !== 'usable';
const sm = STATUS_META[ticket.status];
const bs = badgeStyle[sm.tone];
return (
<View style={[styles.card, dim && styles.cardDim]}>
<View style={[styles.accent, { backgroundColor: dim ? colors.neutral200 : colors.primary600 }]} />
<View style={styles.body}>
<View style={styles.topRow}>
<Text style={styles.eventName} numberOfLines={2}>
{ticket.eventName}
</Text>
<View style={[styles.badge, { backgroundColor: bs.bg }]}>
<Text style={[styles.badgeText, { color: bs.fg }]}>{sm.label}</Text>
</View>
</View>
<View style={styles.metaRow}>
<Ionicons name="calendar-outline" size={15} color={colors.neutral500} />
<Text style={styles.metaText}>{ticket.period}</Text>
</View>
<View style={styles.metaRow}>
<Ionicons name="location-outline" size={15} color={colors.neutral500} />
<Text style={styles.metaText}>{ticket.hallLabel}</Text>
</View>
<View style={styles.divider} />
<View style={styles.infoRow}>
<View style={styles.miniQrWrap}>
<QrPlaceholder seed={ticket.qrSeed} size={64} quiet={false} />
</View>
<View style={styles.infoCols}>
<View style={styles.infoCol}>
<Text style={styles.infoLabel}></Text>
<Text style={styles.infoValue}>{ticket.ticketType}</Text>
</View>
<View style={styles.infoCol}>
<Text style={styles.infoLabel}></Text>
<Text style={styles.infoValue}>{ticket.quantity}</Text>
</View>
<View style={styles.infoCol}>
<Text style={styles.infoLabel}></Text>
<Text style={styles.infoMono}>{ticket.bookingNoMasked}</Text>
</View>
</View>
</View>
{ticket.status === 'usable' ? (
<Pressable
accessibilityRole="button"
style={({ pressed }: { pressed: boolean }) => [styles.qrBtn, pressed && { opacity: 0.85 }]}
onPress={() => onOpenQr(ticket)}
>
<Ionicons name="qr-code-outline" size={20} color={colors.white} />
<Text style={styles.qrBtnText}> QR </Text>
</Pressable>
) : (
<View style={[styles.qrBtn, styles.qrBtnDisabled]}>
<Text style={styles.qrBtnDisabledText}>
{ticket.status === 'used' ? '입장 완료된 티켓입니다' : '취소된 티켓입니다'}
</Text>
</View>
)}
<Pressable
accessibilityRole="link"
style={styles.detailBtn}
onPress={() => onDetail(ticket)}
>
<Text style={styles.detailText}> ·</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
flexDirection: 'row',
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.neutral200,
borderRadius: radius.md,
overflow: 'hidden',
},
cardDim: { opacity: 0.6 },
accent: { width: 4 },
body: { flex: 1, padding: spacing.md, gap: 8 },
topRow: { flexDirection: 'row', justifyContent: 'space-between', gap: 8 },
eventName: { flex: 1, fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
badge: {
alignSelf: 'flex-start',
borderRadius: radius.sm,
paddingHorizontal: 8,
paddingVertical: 3,
},
badgeText: { fontSize: 11, fontWeight: '700' },
metaRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
metaText: { fontSize: type.caption.fontSize, color: colors.neutral700 },
divider: {
borderTopWidth: 1,
borderTopColor: colors.neutral200,
borderStyle: 'dashed',
marginVertical: 4,
},
infoRow: { flexDirection: 'row', gap: 12, alignItems: 'center' },
miniQrWrap: {
borderWidth: 1,
borderColor: colors.neutral200,
borderRadius: radius.sm,
padding: 3,
},
infoCols: { flex: 1, flexDirection: 'row', flexWrap: 'wrap', gap: 12 },
infoCol: { minWidth: 60 },
infoLabel: { fontSize: 11, color: colors.neutral500, marginBottom: 2 },
infoValue: { fontSize: type.body.fontSize, fontWeight: '700', color: colors.neutral900 },
infoMono: { fontSize: type.caption.fontSize, color: colors.neutral700, fontVariant: ['tabular-nums'] },
qrBtn: {
minHeight: touch.min,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
backgroundColor: colors.primary600,
borderRadius: radius.sm,
marginTop: 4,
},
qrBtnText: { color: colors.white, fontSize: type.h3.fontSize, fontWeight: '700' },
qrBtnDisabled: { backgroundColor: colors.neutral050, borderWidth: 1, borderColor: colors.neutral200 },
qrBtnDisabledText: { color: colors.neutral500, fontSize: type.body.fontSize, fontWeight: '600' },
detailBtn: { minHeight: 40, alignItems: 'center', justifyContent: 'center' },
detailText: {
fontSize: type.caption.fontSize,
color: colors.neutral500,
textDecorationLine: 'underline',
},
});

View File

@ -1,78 +0,0 @@
/*
* M10()·M9() .
* API (design.md SCR-M15 "신규·샘플").
* lib/api.ts unwrap .
*/
export type TicketStatus = 'usable' | 'used' | 'canceled';
export type TicketFilter = 'active' | 'upcoming' | 'past';
export interface SampleTicket {
id: string;
eventName: string;
hallLabel: string;
period: string; // 표시용 기간 문자열
ticketType: string; // 권종
holderName: string; // 예매자(표시명)
quantity: number; // 매수
status: TicketStatus;
bookingNoMasked: string; // 마스킹된 예매번호
qrSeed: string; // QR placeholder 시드(실제 QR 아님)
filter: TicketFilter;
}
export const STATUS_META: Record<
TicketStatus,
{ label: string; tone: 'success' | 'muted' | 'error' }
> = {
usable: { label: '사용가능', tone: 'success' },
used: { label: '사용됨', tone: 'muted' },
canceled: { label: '취소', tone: 'error' },
};
export const FILTER_TABS: { key: TicketFilter; label: string }[] = [
{ key: 'active', label: '진행중' },
{ key: 'upcoming', label: '예정' },
{ key: 'past', label: '지난' },
];
export const SAMPLE_TICKETS: SampleTicket[] = [
{
id: 'TKT-2026-0314-88',
eventName: '스마트팩토리 코리아 2026',
hallLabel: 'KINTEX 제1전시장 3-4홀',
period: '2026. 03.14(금) - 03.16(일)',
ticketType: '바이어권',
holderName: '정관람',
quantity: 1,
status: 'usable',
bookingNoMasked: 'KTX-****-8245',
qrSeed: 'KTX-SFK2026-BUYER-8245',
filter: 'active',
},
{
id: 'TKT-2026-0210-12',
eventName: '2026 서울 국제 인공지능 컨퍼런스',
hallLabel: 'KINTEX 제2전시장',
period: '2026. 02.10 - 02.12',
ticketType: '일반권',
holderName: '정관람',
quantity: 2,
status: 'used',
bookingNoMasked: 'KTX-****-1120',
qrSeed: 'KTX-AI2026-STD-1120',
filter: 'past',
},
{
id: 'TKT-2026-0505-31',
eventName: '그린에너지 엑스포 2026',
hallLabel: 'KINTEX 제1전시장 5홀',
period: '2026. 05.05 - 05.07',
ticketType: '단체권',
holderName: '정관람',
quantity: 1,
status: 'usable',
bookingNoMasked: 'KTX-****-0505',
qrSeed: 'KTX-GREEN2026-GRP-0505',
filter: 'upcoming',
},
];

View File

@ -1,4 +1,4 @@
import { Navigate, Outlet, Route, Routes, useNavigate } 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 { OtpSetupPage } from './screens/login/OtpSetupPage';
@ -31,41 +31,8 @@ import { MeetingPage } from './screens/work/MeetingPage';
import { ReportPage } from './screens/work/ReportPage';
import { NotificationCenterPage } from './screens/work/NotificationCenterPage';
import { MyPage } from './screens/work/MyPage';
import { DocsMilestonePage } from './screens/docs/DocsMilestonePage';
import { ReportAuthoringPage } from './screens/docs/ReportAuthoringPage';
import { DockReservationPage } from './screens/movein/DockReservationPage';
import { AuctionListPage } from './screens/auction/AuctionListPage';
import { AuctionDetailPage } from './screens/auction/AuctionDetailPage';
import { AwardComparePage } from './screens/auction/AwardComparePage';
import { ContractorBoothDashboardPage } from './screens/contractor/ContractorBoothDashboardPage';
import { VisitorRegistrationDashboardPage } from './screens/visitor/VisitorRegistrationDashboardPage';
import { LeadScoringPage } from './screens/visitor/LeadScoringPage';
import { EdmCampaignPage } from './screens/marketing/EdmCampaignPage';
import { SponsorshipPage } from './screens/marketing/SponsorshipPage';
import { CmsWorkflowPage } from './screens/cms/CmsWorkflowPage';
import { MicrositeBuilderPage } from './screens/cms/MicrositeBuilderPage';
import { MultilingualCmsPage } from './screens/cms/MultilingualCmsPage';
import { AuditLogPage } from './screens/admin/AuditLogPage';
import { SystemSettingsPage } from './screens/admin/SystemSettingsPage';
import { RulesetVersionsPage } from './screens/admin/RulesetVersionsPage';
import { TenantAdminPage } from './screens/admin/TenantAdminPage';
import {
PublicHomePage,
PublicEventDetailPage,
PublicFloorplanPage,
PublicRegistrationPage,
PublicMicrositePage,
PublicInquiryPage,
PublicTicketPage,
} from './screens/public';
import { AppShell } from './components/layout/AppShell';
/** SCR-22 → SCR-23 작성 화면 진입 배선. */
function DocsMilestoneRoute() {
const navigate = useNavigate();
return <DocsMilestonePage onOpenAuthoring={() => navigate('/docs/authoring')} />;
}
/** 인증 가드 — 미인증 시 로그인으로. (역할별 라우팅은 화면 추가 시 확장) */
function RequireAuth({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
@ -163,43 +130,8 @@ export function App() {
{/* SCR-47 알림센터 · SCR-48 마이페이지 */}
<Route path="/notifications" element={<NotificationCenterPage />} />
<Route path="/me" element={<MyPage />} />
{/* SCR-22·23 서류·마일스톤 / 신고서류 작성 (M6 — 샘플) */}
<Route path="/docs" element={<DocsMilestoneRoute />} />
<Route path="/docs/authoring" element={<ReportAuthoringPage />} />
{/* SCR-25 반입/반출 도크 슬롯 예약 (M8 — 샘플) */}
<Route path="/logistics" element={<DockReservationPage />} />
{/* SCR-26·27·29 공사/장치 옥션 (M15 — 샘플) · SCR-38 수주 부스 대시보드 */}
<Route path="/auctions" element={<AuctionListPage />} />
<Route path="/auctions/:auctionId" element={<AuctionDetailPage />} />
<Route path="/auctions/:auctionId/award" element={<AwardComparePage />} />
<Route path="/contractor/dashboard" element={<ContractorBoothDashboardPage />} />
{/* SCR-30·32 관람객·리드 (M10 — 샘플) · SCR-33·34 마케팅 (M12 — 샘플) */}
<Route path="/visitors" element={<VisitorRegistrationDashboardPage />} />
<Route path="/leads" element={<LeadScoringPage />} />
<Route path="/campaigns" element={<EdmCampaignPage />} />
<Route path="/sponsorship" element={<SponsorshipPage />} />
{/* SCR-35~37 CMS (M17 — 샘플) */}
<Route path="/cms" element={<CmsWorkflowPage />} />
<Route path="/cms/microsite" element={<MicrositeBuilderPage />} />
<Route path="/cms/i18n" element={<MultilingualCmsPage />} />
{/* SCR-A5~A9 관리자 모듈 (감사로그·설정=실배선, 룰셋·테넌트=샘플) */}
<Route path="/admin/audit" element={<AuditLogPage />} />
<Route path="/admin/settings" element={<SystemSettingsPage />} />
<Route path="/admin/masterdata/rulesets" element={<RulesetVersionsPage />} />
<Route path="/admin/tenants" element={<TenantAdminPage />} />
</Route>
{/* SCR-P1~P7 공개 홍보 사이트 — 비로그인, 자체 PublicShell */}
<Route path="/public" element={<PublicHomePage />} />
<Route path="/public/events/:eventId" element={<PublicEventDetailPage />} />
<Route path="/public/events/:eventId/floorplan" element={<PublicFloorplanPage />} />
<Route path="/public/events/:eventId/register" element={<PublicRegistrationPage />} />
<Route path="/public/exhibitors/:exhibitorId" element={<PublicMicrositePage />} />
<Route path="/public/exhibit-inquiry" element={<PublicInquiryPage />} />
<Route path="/tickets/:eventId/purchase" element={<PublicTicketPage />} />
<Route path="/tickets/lookup" element={<PublicTicketPage />} />
{/* 모바일 전용 (390) — 셸 없이 전폭, 조회·현장 */}
<Route
element={

View File

@ -32,22 +32,11 @@ const NAV_ITEMS: { key: string; label: string; Icon: ComponentType<IconProps>; t
{ key: 'schedule', label: '전시 일정', Icon: IconCalendar, to: '/schedule' },
{ key: 'operations', label: '현장운영', Icon: IconOperations, to: '/ops/operations' },
{ key: 'analytics', label: '경영분석', Icon: IconAnalytics, to: '/analytics' },
{ key: 'documents', label: '서류·마일스톤', Icon: IconDocument, to: '/docs' },
{ key: 'documents', label: '서류·마일스톤', Icon: IconDocument },
{ key: 'settlement', label: '정산', Icon: IconSettlement },
{ key: 'admin', label: '관리자', Icon: IconSettings, to: '/admin' },
];
/** 도메인 모듈(SCR-25~38 이식분) — 백엔드 미구현 모듈은 화면에 "샘플 데이터" 배지 표기. */
const DOMAIN_NAV: { key: string; label: string; Icon: ComponentType<IconProps>; to: string }[] = [
{ key: 'logistics', label: '반입·반출', Icon: IconOperations, to: '/logistics' },
{ key: 'auctions', label: '공사 옥션', Icon: IconSettlement, to: '/auctions' },
{ key: 'visitors', label: '관람객', Icon: IconUsers, to: '/visitors' },
{ key: 'leads', label: '리드', Icon: IconExhibitors, to: '/leads' },
{ key: 'campaigns', label: '마케팅', Icon: IconBell, to: '/campaigns' },
{ key: 'sponsorship', label: '스폰서십', Icon: IconSettlement, to: '/sponsorship' },
{ key: 'cms', label: '콘텐츠 CMS', Icon: IconDocument, to: '/cms' },
];
/** §5B 공통 업무 기능(SCR-39~48) — 전 역할 공통 영역. */
const WORK_NAV: { key: string; label: string; Icon: ComponentType<IconProps>; to: string }[] = [
{ key: 'worklog', label: '업무일지', Icon: IconDocument, to: '/work/worklog' },
@ -111,21 +100,6 @@ export function AppShell() {
))}
</ul>
<div className="kx-shell__nav-label"> </div>
<ul className="kx-shell__nav">
{DOMAIN_NAV.map((item) => (
<li key={item.key}>
<NavLink
to={item.to}
className={({ isActive }) => `kx-shell__nav-link ${isActive ? 'is-active' : ''}`}
>
<span className="kx-shell__nav-icon"><item.Icon size={20} /></span>
{item.label}
</NavLink>
</li>
))}
</ul>
<div className="kx-shell__nav-label"> </div>
<ul className="kx-shell__nav">
{WORK_NAV.map((item) => (

View File

@ -1,308 +0,0 @@
/*
* SCR-A5 (M18 · §5B-1). Stitch scr_a5 + .
* 정본: GET /api/admin/audit?action&actorId&eventId&page&size PageResponse<AuditLogRow> (AuditLogController).
* 보안: 백엔드 DTO에 IP/ IP (·PII ). summary ( ).
* 3종: 로딩 / / (). .
* CSV 내보내기: 현재 ( export ).
*/
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button';
import { IconDownload, IconSearch } from '../../components/ui/icons';
import { auditApi, KNOWN_AUDIT_ACTIONS, type AuditLogRow } from './adminModulesApi';
import './admin-modules.css';
const PAGE_SIZES = [20, 50, 100];
export function AuditLogPage() {
const [action, setAction] = useState('');
const [actorId, setActorId] = useState('');
const [eventId, setEventId] = useState('');
const [size, setSize] = useState(50);
const [page, setPage] = useState(0);
// 실제 조회에 반영된 확정 필터(입력과 분리 — "필터 적용" 클릭 시 반영)
const [applied, setApplied] = useState<{ action: string; actorId: string; eventId: string }>({
action: '',
actorId: '',
eventId: '',
});
const q = useQuery({
queryKey: ['admin-audit', applied, page, size],
queryFn: () =>
auditApi.search({
action: applied.action || undefined,
actorId: applied.actorId || undefined,
eventId: applied.eventId || undefined,
page,
size,
}),
retry: false,
});
const rows = q.data?.items ?? [];
const total = q.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / size));
const from = total === 0 ? 0 : page * size + 1;
const to = Math.min(total, (page + 1) * size);
function applyFilters() {
setPage(0);
setApplied({ action, actorId, eventId });
}
function resetFilters() {
setAction('');
setActorId('');
setEventId('');
setPage(0);
setApplied({ action: '', actorId: '', eventId: '' });
}
return (
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title"> </h1>
<p className="kx-adm__subtitle">
·· . ( )
</p>
</div>
<Button
variant="secondary"
leadingIcon={<IconDownload size={16} />}
onClick={() => exportCsv(rows)}
disabled={rows.length === 0}
>
CSV
</Button>
</header>
{/* 필터 바 */}
<section className="kx-card kx-adm-filter" aria-label="감사 로그 필터">
<div className="kx-adm-filter__grid">
<label className="kx-field">
<span className="kx-field__label"> </span>
<input
className="kx-field__input"
list="audit-actions"
placeholder="전체 액션"
value={action}
onChange={(e) => setAction(e.target.value)}
/>
<datalist id="audit-actions">
{KNOWN_AUDIT_ACTIONS.map((a) => (
<option key={a} value={a} />
))}
</datalist>
</label>
<label className="kx-field">
<span className="kx-field__label"> ID</span>
<input
className="kx-field__input"
placeholder="예: user-102"
value={actorId}
onChange={(e) => setActorId(e.target.value)}
/>
</label>
<label className="kx-field">
<span className="kx-field__label"> ID</span>
<input
className="kx-field__input"
placeholder="예: EVT-2026-01"
value={eventId}
onChange={(e) => setEventId(e.target.value)}
/>
</label>
<div className="kx-adm-filter__actions">
<Button variant="ghost" onClick={resetFilters}>
</Button>
<Button leadingIcon={<IconSearch size={16} />} onClick={applyFilters}>
</Button>
</div>
</div>
</section>
{/* 본문 */}
{q.isLoading && <TableSkeleton />}
{q.isError && !q.isLoading && (
<ErrorState message="감사 로그를 불러오지 못했습니다." onRetry={() => q.refetch()} />
)}
{!q.isLoading && !q.isError && (
<section className="kx-card kx-adm-table-card" aria-label="감사 로그 테이블">
{rows.length === 0 ? (
<EmptyState
title="조건에 맞는 감사 기록이 없습니다"
description="필터를 조정하거나 초기화해 다시 조회하세요."
/>
) : (
<>
<div className="kx-table-scroll">
<table className="kx-table kx-table--zebra kx-adm-audit">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td className="kx-adm-mono">{fmtTs(r.createdAt)}</td>
<td>
<div className="kx-adm-actor">
<span className="kx-adm-actor__avatar" aria-hidden="true">
{initial(r.actorName ?? r.actorId)}
</span>
<span className="kx-adm-actor__body">
<span className="kx-adm-actor__name">{r.actorName ?? '—'}</span>
{r.actorId && (
<span className="kx-adm-actor__id">{r.actorId}</span>
)}
</span>
</div>
</td>
<td>
<span className="kx-adm-action">{r.action ?? '—'}</span>
{r.summary && <span className="kx-adm-summary">{r.summary}</span>}
</td>
<td>
{r.targetType ? (
<span className="kx-adm-target">
<span className="kx-adm-target__type">{r.targetType}</span>
{r.targetId && (
<span className="kx-adm-target__id">#{r.targetId}</span>
)}
</span>
) : (
<span className="kx-adm-muted"></span>
)}
</td>
<td className="kx-adm-muted">{r.eventId ?? '—'}</td>
<td>
<ResultPill value={r.result} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="kx-adm-pager">
<p className="kx-adm-pager__info">
<strong className="tnum">{from}-{to}</strong> / {' '}
<strong className="tnum">{total.toLocaleString()}</strong>
</p>
<div className="kx-adm-pager__nav">
<Button
variant="ghost"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
>
</Button>
<span className="kx-adm-pager__page tnum">
{page + 1} / {totalPages}
</span>
<Button
variant="ghost"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
>
</Button>
</div>
<label className="kx-adm-pager__size">
<span> </span>
<select
className="kx-select"
value={size}
onChange={(e) => {
setSize(Number(e.target.value));
setPage(0);
}}
aria-label="페이지당 행 수"
>
{PAGE_SIZES.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</label>
</div>
</>
)}
</section>
)}
</div>
);
}
function ResultPill({ value }: { value: string | null }) {
const v = (value ?? '').toUpperCase();
const tone =
v === 'SUCCESS' || v === 'OK'
? 'success'
: v === 'FAILURE' || v === 'FAIL' || v === 'ERROR' || v === 'DENIED'
? 'error'
: 'neutral';
const label = tone === 'success' ? '성공' : tone === 'error' ? '실패' : value ?? '—';
return <span className={`kx-adm-pill kx-adm-pill--${tone}`}>{label}</span>;
}
function TableSkeleton() {
return (
<div className="kx-card" aria-hidden="true" style={{ display: 'grid', gap: 12 }}>
{[0, 1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} height={44} radius={6} />
))}
</div>
);
}
function initial(name: string | null | undefined): string {
const s = (name ?? '').trim();
return s ? s.charAt(0) : '?';
}
function fmtTs(s: string | null | undefined): string {
if (!s) return '—';
const d = new Date(s);
if (Number.isNaN(d.getTime())) return s;
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
/** 현재 로드된 페이지 rows 를 CSV 로 내보낸다. IP/자격증명 컬럼은 애초에 존재하지 않는다. */
function exportCsv(rows: AuditLogRow[]): void {
if (rows.length === 0) return;
const headers = ['시각', '담당자ID', '담당자명', '액션', '대상유형', '대상ID', '행사ID', '결과', '요약'];
const esc = (v: unknown) => {
const s = v == null ? '' : String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const lines = [
headers.join(','),
...rows.map((r) =>
[r.createdAt, r.actorId, r.actorName, r.action, r.targetType, r.targetId, r.eventId, r.result, r.summary]
.map(esc)
.join(','),
),
];
const blob = new Blob(['' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}

View File

@ -1,164 +0,0 @@
/*
* SCR-A8 (M18). Stitch scr_a8 .
* 원천: 샘플( ). classpath (resources/rulesets/compliance-v1.json) ,
* / REST (RuleSetLoader ). , .
* v1.1 compliance-v1.json .
*/
import { useState } from 'react';
import './admin-modules.css';
interface RuleItem {
code: string;
label: string;
value: string;
logic: string;
severity: 'block' | 'warn';
}
interface RulesetVersion {
version: string;
effectiveDate: string;
status: 'current' | 'expired' | 'draft';
note?: string;
rules: RuleItem[];
}
// 실제 compliance-v1.json(rulesetVersion=compliance-v1.1, effectiveDate=2026-07-12) 반영 — 읽기 전용 샘플.
const V11_RULES: RuleItem[] = [
{ code: 'HEIGHT_MAX', label: '장치물 높이', value: '≤ 5m', logic: '최대 높이 제한', severity: 'block' },
{ code: 'RIGGING_RANGE', label: '리깅 높이', value: '6.58.5m', logic: '구조계산서 제출(D-7) 필요', severity: 'warn' },
{ code: 'MEZZANINE_RATIO', label: '복층 부스', value: '≤ 1/2 면적', logic: '전체 점유 면적 대비', severity: 'block' },
{ code: 'FIRE_RETARDANT', label: '방염 성능', value: 'A급 필수', logic: '전 자재 방염', severity: 'block' },
{ code: 'FLOOR_LOAD', label: '바닥 하중', value: '홀6 2 · 홀7~10 5 t/㎡', logic: '홀별 하중 초과 금지', severity: 'block' },
{ code: 'AISLE_WIDTH_MIN', label: '피난 통로 폭', value: '≥ 3m', logic: 'PostGIS 산출값 대조', severity: 'block' },
{ code: 'EXIT_ACCESS', label: '비상구 접근성', value: '차단 0', logic: '부스가 비상구 차단 금지', severity: 'block' },
{ code: 'BOOTH_OVERLAP', label: '부스 겹침', value: '겹침 0', logic: 'ST_Intersects 무결성', severity: 'block' },
{ code: 'CLEARANCE_WALL', label: '벽 이격', value: '≥ 0.3m', logic: '인접 벽 이격', severity: 'warn' },
{ code: 'CLEARANCE_CEILING', label: '천장 이격', value: '≥ 0.6m', logic: '천장 이격', severity: 'warn' },
{ code: 'NOISE_LIMIT', label: '소음 규정', value: '≤ 75dB', logic: '장내 상시 기준', severity: 'warn' },
{ code: 'PROHIBITED_WORK', label: '금지 작업', value: '전기톱·용접·페인트', logic: '장내 금지작업 미포함', severity: 'warn' },
{ code: 'LIGHTING_BRING_IN', label: '조명 반입', value: '지정 조명만', logic: '조명 반입 금지', severity: 'warn' },
];
const VERSIONS: RulesetVersion[] = [
{
version: 'compliance-v1.1',
effectiveDate: '2026-07-12',
status: 'current',
note: '최신 전시 규정 통합본 (PostGIS 배치 무결성 규칙 추가)',
rules: V11_RULES,
},
{
version: 'compliance-v1.0',
effectiveDate: '2026-01-01',
status: 'expired',
note: '초기 규정 룰셋',
rules: V11_RULES.slice(0, 4),
},
];
export function RulesetVersionsPage() {
const [selected, setSelected] = useState(VERSIONS[0].version);
const current = VERSIONS.find((v) => v.version === selected) ?? VERSIONS[0];
return (
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title"> </h1>
<p className="kx-adm__subtitle"> .</p>
</div>
</header>
<div className="kx-adm-sample-banner" role="status">
<span className="kx-bi__degraded"> · </span>
/ REST API가 (compliance-v1.json)
. · .
</div>
<div className="kx-adm-ruleset">
{/* 좌: 버전 히스토리 */}
<aside className="kx-adm-ruleset__versions" aria-label="버전 히스토리">
<div className="kx-adm-ruleset__vhead">
<h2> </h2>
<button className="kx-btn kx-btn--secondary" disabled title="API 미제공">
</button>
</div>
<ul className="kx-adm-vlist">
{VERSIONS.map((v) => (
<li key={v.version}>
<button
type="button"
className={`kx-adm-vitem ${v.version === selected ? 'is-active' : ''}`}
onClick={() => setSelected(v.version)}
>
<span className="kx-adm-vitem__top">
<strong>{v.version}</strong>
<VersionBadge status={v.status} />
</span>
<span className="kx-adm-vitem__date"> {v.effectiveDate}</span>
{v.note && <span className="kx-adm-vitem__note">{v.note}</span>}
</button>
</li>
))}
</ul>
</aside>
{/* 우: 규칙 목록 */}
<section className="kx-card kx-adm-ruleset__editor" aria-label="규칙 목록">
<div className="kx-adm-ruleset__ehead">
<div>
<h2>{current.version} ({current.rules.length})</h2>
<p className="kx-adm-muted"> {current.effectiveDate}</p>
</div>
<button className="kx-btn kx-btn--secondary" disabled title="API 미제공">
(diff)
</button>
</div>
<div className="kx-table-scroll">
<table className="kx-table kx-table--zebra kx-adm-rules">
<thead>
<tr>
<th></th>
<th>/</th>
<th> / </th>
<th> </th>
</tr>
</thead>
<tbody>
{current.rules.map((r) => (
<tr key={r.code}>
<td>
<span className="kx-adm-rulename">{r.label}</span>
<span className="kx-adm-rulecode">{r.code}</span>
</td>
<td className="kx-adm-mono">{r.value}</td>
<td className="kx-adm-muted">{r.logic}</td>
<td>
<span className={`kx-adm-pill kx-adm-pill--${r.severity === 'block' ? 'error' : 'warn'}`}>
{r.severity === 'block' ? '차단' : '경고'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="kx-adm-disclaimer">
.
</p>
</section>
</div>
</div>
);
}
function VersionBadge({ status }: { status: RulesetVersion['status'] }) {
const map = {
current: { cls: 'success', label: '현재 발효' },
expired: { cls: 'neutral', label: '만료' },
draft: { cls: 'warn', label: '초안' },
} as const;
const m = map[status];
return <span className={`kx-adm-pill kx-adm-pill--${m.cls}`}>{m.label}</span>;
}

View File

@ -1,335 +0,0 @@
/*
* SCR-A6 (M18 · §5B-1). Stitch scr_a6 + .
* 정본: GET/POST /api/admin/settings, DELETE /{key} (SettingController). key/value .
* 보안: secretYn='Y' ******** , ( ·).
* Stitch (·AI게이트··) key (prefix) .
* 상태: 로딩 / / ().
*/
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button';
import { IconPlus } from '../../components/ui/icons';
import { errMessage, useToast } from '../work/workShared';
import { settingApi, type SettingRow, type SettingSaveBody } from './adminModulesApi';
import './admin-modules.css';
const VALUE_TYPES = ['STRING', 'NUMBER', 'BOOLEAN', 'JSON'];
export function SystemSettingsPage() {
const qc = useQueryClient();
const { show, node: toast } = useToast();
const q = useQuery({ queryKey: ['admin-settings'], queryFn: settingApi.list, retry: false });
const saveM = useMutation({
mutationFn: (body: SettingSaveBody) => settingApi.save(body),
onSuccess: () => {
show('설정을 저장했습니다.');
qc.invalidateQueries({ queryKey: ['admin-settings'] });
},
onError: (e) => show(errMessage(e)),
});
const delM = useMutation({
mutationFn: (key: string) => settingApi.remove(key),
onSuccess: () => {
show('설정을 삭제했습니다.');
qc.invalidateQueries({ queryKey: ['admin-settings'] });
},
onError: (e) => show(errMessage(e)),
});
const groups = useMemo(() => groupByPrefix(q.data ?? []), [q.data]);
return (
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title"> </h1>
<p className="kx-adm__subtitle">
· ·AI . 릿 .
</p>
</div>
</header>
{q.isLoading && <SettingsSkeleton />}
{q.isError && !q.isLoading && (
<ErrorState message="시스템 설정을 불러오지 못했습니다." onRetry={() => q.refetch()} />
)}
{!q.isLoading && !q.isError && (
<>
{(q.data ?? []).length === 0 ? (
<EmptyState
title="등록된 설정이 없습니다"
description="아래 폼에서 첫 설정 항목을 추가하세요."
/>
) : (
<div className="kx-adm-settings">
{groups.map((g) => (
<SettingGroupCard
key={g.name}
name={g.name}
rows={g.rows}
saving={saveM.isPending}
deleting={delM.isPending}
onSave={(b) => saveM.mutate(b)}
onDelete={(k) => {
if (window.confirm(`설정 '${k}' 을(를) 삭제하시겠습니까?`)) delM.mutate(k);
}}
/>
))}
</div>
)}
<AddSettingCard saving={saveM.isPending} onSave={(b) => saveM.mutate(b)} />
</>
)}
{toast}
</div>
);
}
function SettingGroupCard({
name,
rows,
saving,
deleting,
onSave,
onDelete,
}: {
name: string;
rows: SettingRow[];
saving: boolean;
deleting: boolean;
onSave: (b: SettingSaveBody) => void;
onDelete: (key: string) => void;
}) {
return (
<section className="kx-card kx-adm-group" aria-label={`설정 그룹 ${name}`}>
<div className="kx-adm-group__accent" aria-hidden="true" />
<div className="kx-adm-group__body">
<h2 className="kx-adm-group__title">{name}</h2>
<ul className="kx-adm-setrows">
{rows.map((r) => (
<SettingRowItem
key={r.settingKey}
row={r}
saving={saving}
deleting={deleting}
onSave={onSave}
onDelete={onDelete}
/>
))}
</ul>
</div>
</section>
);
}
function SettingRowItem({
row,
saving,
deleting,
onSave,
onDelete,
}: {
row: SettingRow;
saving: boolean;
deleting: boolean;
onSave: (b: SettingSaveBody) => void;
onDelete: (key: string) => void;
}) {
const isSecret = row.secretYn === 'Y';
const isBool = (row.valueType ?? '').toUpperCase() === 'BOOLEAN';
// 시크릿: 입력 비움 상태에서 시작(마스킹 값을 재저장하지 않기 위함). 비-시크릿: 현재값 편집.
const [val, setVal] = useState<string>(isSecret ? '' : row.settingValue ?? '');
const [dirty, setDirty] = useState(false);
const boolChecked = String(row.settingValue ?? '').toLowerCase() === 'true';
function submit(nextValue: string) {
onSave({
settingKey: row.settingKey,
settingValue: nextValue,
valueType: row.valueType,
description: row.description,
secretYn: row.secretYn,
});
setDirty(false);
}
return (
<li className="kx-adm-setrow">
<div className="kx-adm-setrow__meta">
<span className="kx-adm-setrow__key">
{row.settingKey}
{isSecret && <span className="kx-adm-secret" title="시크릿 — 값 마스킹">릿</span>}
{row.valueType && <span className="kx-adm-typechip">{row.valueType}</span>}
</span>
{row.description && <span className="kx-adm-setrow__desc">{row.description}</span>}
</div>
<div className="kx-adm-setrow__control">
{isBool ? (
<label className="kx-adm-switch">
<input
type="checkbox"
checked={boolChecked}
disabled={saving}
onChange={(e) => submit(e.target.checked ? 'true' : 'false')}
/>
<span className="kx-adm-switch__track" aria-hidden="true" />
<span className="kx-adm-switch__label">{boolChecked ? '켜짐' : '꺼짐'}</span>
</label>
) : (
<>
<input
className="kx-field__input kx-adm-setrow__input"
type={isSecret ? 'password' : 'text'}
value={val}
placeholder={isSecret ? '설정됨(마스킹) — 변경 시 새 값 입력' : ''}
autoComplete={isSecret ? 'new-password' : 'off'}
onChange={(e) => {
setVal(e.target.value);
setDirty(true);
}}
/>
<Button
variant="secondary"
disabled={saving || !dirty || (isSecret && val.trim() === '')}
onClick={() => submit(val)}
>
</Button>
</>
)}
<Button
variant="ghost"
className="kx-adm-del"
disabled={deleting}
onClick={() => onDelete(row.settingKey)}
aria-label={`${row.settingKey} 삭제`}
>
</Button>
</div>
{isSecret && (
<p className="kx-adm-setrow__hint">
릿 . .
</p>
)}
</li>
);
}
function AddSettingCard({
saving,
onSave,
}: {
saving: boolean;
onSave: (b: SettingSaveBody) => void;
}) {
const [key, setKey] = useState('');
const [value, setValue] = useState('');
const [type, setType] = useState('STRING');
const [desc, setDesc] = useState('');
const [secret, setSecret] = useState(false);
const canSave = key.trim() !== '';
function submit() {
if (!canSave) return;
onSave({
settingKey: key.trim(),
settingValue: value,
valueType: type,
description: desc || null,
secretYn: secret ? 'Y' : 'N',
});
setKey('');
setValue('');
setDesc('');
setSecret(false);
setType('STRING');
}
return (
<section className="kx-card kx-adm-add" aria-label="설정 추가">
<h2 className="kx-adm-add__title"> / </h2>
<div className="kx-adm-add__grid">
<label className="kx-field">
<span className="kx-field__label"> </span>
<input
className="kx-field__input"
placeholder="예: deadline.utility.days"
value={key}
onChange={(e) => setKey(e.target.value)}
/>
</label>
<label className="kx-field">
<span className="kx-field__label"></span>
<input
className="kx-field__input"
type={secret ? 'password' : 'text'}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</label>
<label className="kx-field">
<span className="kx-field__label"></span>
<select className="kx-select" value={type} onChange={(e) => setType(e.target.value)}>
{VALUE_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</label>
<label className="kx-field kx-adm-add__desc">
<span className="kx-field__label"></span>
<input
className="kx-field__input"
value={desc}
onChange={(e) => setDesc(e.target.value)}
/>
</label>
<label className="kx-adm-check">
<input type="checkbox" checked={secret} onChange={(e) => setSecret(e.target.checked)} />
릿( )
</label>
<div className="kx-adm-add__actions">
<Button leadingIcon={<IconPlus size={16} />} disabled={saving || !canSave} onClick={submit}>
</Button>
</div>
</div>
</section>
);
}
function SettingsSkeleton() {
return (
<div aria-hidden="true" style={{ display: 'grid', gap: 16 }}>
{[0, 1].map((i) => (
<Skeleton key={i} height={160} radius={8} />
))}
</div>
);
}
/** settingKey 의 '.' 앞 접두어로 그룹화. 접두어 없으면 '일반'. */
function groupByPrefix(rows: SettingRow[]): { name: string; rows: SettingRow[] }[] {
const map = new Map<string, SettingRow[]>();
for (const r of rows) {
const dot = r.settingKey.indexOf('.');
const name = dot > 0 ? r.settingKey.slice(0, dot) : '일반';
const arr = map.get(name) ?? [];
arr.push(r);
map.set(name, arr);
}
return [...map.entries()]
.map(([name, rs]) => ({ name, rows: rs.sort((a, b) => a.settingKey.localeCompare(b.settingKey)) }))
.sort((a, b) => a.name.localeCompare(b.name));
}

View File

@ -1,129 +0,0 @@
/*
* SCR-A9 () · (M18 · §1A · ). Stitch scr_a9 .
* 원천: 샘플. ( CRUD··) . .
* IA .
*/
import { useState } from 'react';
import './admin-modules.css';
interface TenantRow {
code: string;
name: string;
domain: string;
status: 'active' | 'onboarding' | 'suspended';
events: number;
users: number;
}
const TENANTS: TenantRow[] = [
{ code: 'KINTEX', name: '킨텍스', domain: 'kintex.wise.ai.kr', status: 'active', events: 24, users: 1280 },
{ code: 'COEX', name: '코엑스', domain: 'coex.wise.ai.kr', status: 'onboarding', events: 0, users: 3 },
{ code: 'BEXCO', name: '벡스코', domain: 'bexco.wise.ai.kr', status: 'suspended', events: 5, users: 210 },
];
const WIZARD_STEPS = [
{ key: 'create', label: '테넌트 생성', desc: '코드·표시명 등록' },
{ key: 'domain', label: '서브도메인', desc: 'DNS·브랜딩 도메인 배정' },
{ key: 'brand', label: '브랜딩', desc: '로고·색상·공개사이트 테마' },
{ key: 'master', label: '마스터데이터 입력', desc: '홀·요율·룰셋 시드' },
{ key: 'admin', label: '관리자 계정', desc: '테넌트 관리자 초대' },
{ key: 'smoke', label: '격리 스모크', desc: '크로스-테넌트 격리 검증' },
{ key: 'activate', label: '활성화', desc: '운영 전환' },
];
export function TenantAdminPage() {
const [selected, setSelected] = useState<string>(TENANTS[1].code);
const tenant = TENANTS.find((t) => t.code === selected) ?? TENANTS[0];
// 온보딩 진행 단계(샘플): active=완료 / onboarding=4단계 진행 / suspended=완료 후 중지
const currentStep = tenant.status === 'onboarding' ? 3 : WIZARD_STEPS.length;
return (
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title"> ·</h1>
<p className="kx-adm__subtitle"> · .</p>
</div>
</header>
<div className="kx-adm-sample-banner" role="status">
<span className="kx-bi__degraded"> · </span>
( CRUD··) IA .
.
</div>
<div className="kx-adm-tenant">
<section className="kx-card kx-adm-tenant__list" aria-label="테넌트 목록">
<div className="kx-adm-tenant__lhead">
<h2></h2>
<button className="kx-btn kx-btn--secondary" disabled title="미구현">
</button>
</div>
<div className="kx-table-scroll">
<table className="kx-table kx-table--zebra kx-adm-tenants">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th className="kx-num"></th>
<th className="kx-num"></th>
</tr>
</thead>
<tbody>
{TENANTS.map((t) => (
<tr
key={t.code}
className={t.code === selected ? 'is-selected' : ''}
onClick={() => setSelected(t.code)}
style={{ cursor: 'pointer' }}
>
<td className="kx-adm-tenants__name">{t.name}</td>
<td className="kx-adm-mono">{t.code}</td>
<td className="kx-adm-muted">{t.domain}</td>
<td><TenantStatus status={t.status} /></td>
<td className="kx-num tnum">{t.events}</td>
<td className="kx-num tnum">{t.users.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<aside className="kx-card kx-adm-tenant__wizard" aria-label="온보딩 위저드">
<h2 className="kx-adm-wiz__title">{tenant.name} </h2>
<p className="kx-adm-muted"> </p>
<ol className="kx-adm-wiz">
{WIZARD_STEPS.map((s, i) => {
const state = i < currentStep ? 'done' : i === currentStep ? 'current' : 'todo';
return (
<li key={s.key} className={`kx-adm-wiz__step is-${state}`}>
<span className="kx-adm-wiz__dot" aria-hidden="true">
{state === 'done' ? '✓' : i + 1}
</span>
<span className="kx-adm-wiz__body">
<span className="kx-adm-wiz__label">{s.label}</span>
<span className="kx-adm-wiz__desc">{s.desc}</span>
</span>
</li>
);
})}
</ol>
</aside>
</div>
</div>
);
}
function TenantStatus({ status }: { status: TenantRow['status'] }) {
const map = {
active: { cls: 'success', label: '활성' },
onboarding: { cls: 'warn', label: '온보딩중' },
suspended: { cls: 'error', label: '중지' },
} as const;
const m = map[status];
return <span className={`kx-adm-pill kx-adm-pill--${m.cls}`}>{m.label}</span>;
}

View File

@ -1,406 +0,0 @@
/*
* M18 관리자 모듈(SCR-A5·A6·A8·A9) 전용 스타일. design.md §1 토큰만 사용.
* shared.css 프리미티브(kx-page·kx-card·kx-table·kx-select ) 선반영.
*/
@import '../shared.css';
/* ── 공통 헤더 ── */
.kx-adm__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
}
.kx-adm__title {
font-size: var(--fs-h1);
line-height: var(--lh-h1);
font-weight: 700;
color: var(--color-neutral-900);
}
.kx-adm__subtitle {
margin-top: 4px;
font-size: var(--fs-body);
color: var(--color-neutral-500);
max-width: 720px;
}
/* ── 폼 필드 프리미티브 ── */
.kx-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.kx-field__label {
font-size: var(--fs-caption);
font-weight: 600;
color: var(--color-neutral-500);
}
.kx-field__input {
height: 36px;
padding: 0 var(--space-3);
border: var(--border-card);
border-radius: var(--radius-sm);
background: var(--color-white);
font-size: var(--fs-body);
color: var(--color-neutral-900);
width: 100%;
}
.kx-field__input:focus {
outline: 2px solid var(--color-primary-100);
border-color: var(--color-primary-600);
}
/* ── 상태 pill ── */
.kx-adm-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 10px;
border-radius: var(--radius-pill);
font-size: var(--fs-caption);
font-weight: 700;
white-space: nowrap;
}
.kx-adm-pill--success { color: var(--color-success); background: #e6f4ee; }
.kx-adm-pill--error { color: var(--color-error); background: #fef3f2; }
.kx-adm-pill--warn { color: var(--color-violation-warn-text); background: var(--color-violation-warn-bg); }
.kx-adm-pill--neutral { color: var(--color-neutral-500); background: var(--color-neutral-100); }
.kx-adm-muted { color: var(--color-neutral-500); }
.kx-adm-mono {
font-family: ui-monospace, 'JetBrains Mono', 'Menlo', monospace;
font-size: var(--fs-mono);
color: var(--color-neutral-700);
white-space: nowrap;
}
/* ── 샘플/degraded 배너 ── */
.kx-adm-sample-banner {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border: 1px dashed var(--color-neutral-200);
border-radius: var(--radius-lg);
background: var(--color-neutral-050);
font-size: var(--fs-body);
color: var(--color-neutral-700);
}
/* ═══════════ SCR-A5 감사로그 ═══════════ */
.kx-adm-filter__grid {
display: grid;
grid-template-columns: repeat(3, 1fr) auto;
gap: var(--space-4);
align-items: end;
}
.kx-adm-filter__actions {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
}
.kx-adm-table-card { padding: 0; overflow: hidden; }
.kx-adm-audit { width: 100%; border-collapse: collapse; }
.kx-adm-audit th,
.kx-adm-audit td {
padding: var(--space-3) var(--space-4);
text-align: left;
font-size: var(--fs-body);
vertical-align: top;
}
.kx-adm-audit thead th {
background: var(--color-neutral-100);
font-size: var(--fs-caption);
font-weight: 700;
color: var(--color-neutral-500);
text-transform: uppercase;
letter-spacing: 0.03em;
border-bottom: var(--border-card);
}
.kx-adm-audit tbody tr { border-bottom: 1px solid var(--color-neutral-100); }
.kx-adm-audit tbody tr:hover { background: var(--color-primary-050); }
.kx-adm-actor { display: flex; align-items: center; gap: var(--space-2); }
.kx-adm-actor__avatar {
width: 26px;
height: 26px;
flex: 0 0 26px;
border-radius: var(--radius-pill);
background: var(--color-primary-100);
color: var(--color-primary-700);
font-size: 11px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
}
.kx-adm-actor__body { display: flex; flex-direction: column; }
.kx-adm-actor__name { font-weight: 600; color: var(--color-neutral-900); }
.kx-adm-actor__id { font-size: var(--fs-caption); color: var(--color-neutral-500); }
.kx-adm-action {
display: inline-block;
font-weight: 600;
color: var(--color-neutral-900);
}
.kx-adm-summary {
display: block;
margin-top: 2px;
font-size: var(--fs-caption);
color: var(--color-neutral-500);
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kx-adm-target { display: flex; flex-direction: column; }
.kx-adm-target__type { font-weight: 600; color: var(--color-neutral-700); }
.kx-adm-target__id { font-size: var(--fs-caption); color: var(--color-neutral-500); }
.kx-adm-pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-top: var(--border-card);
background: var(--color-neutral-050);
flex-wrap: wrap;
}
.kx-adm-pager__info { font-size: var(--fs-caption); color: var(--color-neutral-500); }
.kx-adm-pager__nav { display: flex; align-items: center; gap: var(--space-3); }
.kx-adm-pager__page { font-size: var(--fs-body); font-weight: 600; color: var(--color-neutral-700); }
.kx-adm-pager__size {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
/* ═══════════ SCR-A6 시스템설정 ═══════════ */
.kx-adm-settings {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
gap: var(--space-4);
}
.kx-adm-group { padding: 0; overflow: hidden; display: flex; }
.kx-adm-group__accent { width: 4px; flex: 0 0 4px; background: var(--color-primary-600); }
.kx-adm-group__body { flex: 1; min-width: 0; padding: var(--space-4); }
.kx-adm-group__title {
font-size: var(--fs-h3);
font-weight: 700;
color: var(--color-neutral-900);
margin-bottom: var(--space-3);
text-transform: capitalize;
}
.kx-adm-setrows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-3); }
.kx-adm-setrow {
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-neutral-100);
}
.kx-adm-setrow:last-child { border-bottom: none; padding-bottom: 0; }
.kx-adm-setrow__meta { display: flex; flex-direction: column; gap: 2px; margin-bottom: 8px; }
.kx-adm-setrow__key {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
font-size: var(--fs-body);
font-weight: 600;
color: var(--color-neutral-900);
font-family: ui-monospace, 'JetBrains Mono', monospace;
}
.kx-adm-setrow__desc { font-size: var(--fs-caption); color: var(--color-neutral-500); }
.kx-adm-setrow__control { display: flex; align-items: center; gap: var(--space-2); }
.kx-adm-setrow__input { flex: 1; min-width: 0; }
.kx-adm-setrow__hint { margin-top: 6px; font-size: 11px; color: var(--color-neutral-500); }
.kx-adm-secret {
font-size: 10px;
font-weight: 700;
color: var(--color-warning);
background: #fff4e5;
padding: 1px 6px;
border-radius: var(--radius-pill);
}
.kx-adm-typechip {
font-size: 10px;
font-weight: 700;
color: var(--color-neutral-500);
background: var(--color-neutral-100);
padding: 1px 6px;
border-radius: var(--radius-pill);
}
.kx-adm-del { color: var(--color-error); }
/* boolean switch */
.kx-adm-switch { display: inline-flex; align-items: center; gap: var(--space-2); cursor: pointer; }
.kx-adm-switch input { position: absolute; opacity: 0; width: 0; height: 0; }
.kx-adm-switch__track {
width: 40px;
height: 22px;
border-radius: var(--radius-pill);
background: var(--color-neutral-200);
position: relative;
transition: background 0.15s;
flex: 0 0 40px;
}
.kx-adm-switch__track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: var(--radius-pill);
background: var(--color-white);
transition: transform 0.15s;
}
.kx-adm-switch input:checked + .kx-adm-switch__track { background: var(--color-primary-600); }
.kx-adm-switch input:checked + .kx-adm-switch__track::after { transform: translateX(18px); }
.kx-adm-switch input:focus-visible + .kx-adm-switch__track { outline: 2px solid var(--color-primary-100); }
.kx-adm-switch__label { font-size: var(--fs-caption); color: var(--color-neutral-700); }
.kx-adm-add__title { font-size: var(--fs-h3); font-weight: 700; color: var(--color-neutral-900); margin-bottom: var(--space-3); }
.kx-adm-add__grid {
display: grid;
grid-template-columns: 1.4fr 1.4fr 0.8fr;
gap: var(--space-4);
align-items: end;
}
.kx-adm-add__desc { grid-column: 1 / -1; }
.kx-adm-add__actions { grid-column: 1 / -1; display: flex; justify-content: flex-end; }
.kx-adm-check {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--fs-body);
color: var(--color-neutral-700);
}
/* ═══════════ SCR-A8 룰셋 버전 ═══════════ */
.kx-adm-ruleset { display: grid; grid-template-columns: 300px 1fr; gap: var(--space-4); align-items: start; }
.kx-adm-ruleset__versions { display: flex; flex-direction: column; gap: var(--space-3); }
.kx-adm-ruleset__vhead { display: flex; align-items: center; justify-content: space-between; }
.kx-adm-ruleset__vhead h2 { font-size: var(--fs-h3); font-weight: 700; color: var(--color-neutral-900); }
.kx-adm-vlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2); }
.kx-adm-vitem {
width: 100%;
text-align: left;
display: flex;
flex-direction: column;
gap: 4px;
padding: var(--space-3);
border: var(--border-card);
border-left: 4px solid transparent;
border-radius: var(--radius-lg);
background: var(--color-white);
cursor: pointer;
}
.kx-adm-vitem:hover { background: var(--color-neutral-050); }
.kx-adm-vitem.is-active { border-left-color: var(--color-primary-600); background: var(--color-primary-050); }
.kx-adm-vitem__top { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); }
.kx-adm-vitem__top strong { font-size: var(--fs-body); color: var(--color-neutral-900); }
.kx-adm-vitem__date { font-size: var(--fs-caption); color: var(--color-neutral-500); }
.kx-adm-vitem__note { font-size: var(--fs-caption); color: var(--color-neutral-500); font-style: italic; }
.kx-adm-ruleset__editor { padding: 0; overflow: hidden; }
.kx-adm-ruleset__ehead {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-4);
border-bottom: var(--border-card);
}
.kx-adm-ruleset__ehead h2 { font-size: var(--fs-h3); font-weight: 700; color: var(--color-neutral-900); }
.kx-adm-rules { width: 100%; border-collapse: collapse; }
.kx-adm-rules th, .kx-adm-rules td { padding: var(--space-3) var(--space-4); text-align: left; font-size: var(--fs-body); }
.kx-adm-rules thead th {
background: var(--color-neutral-100);
font-size: var(--fs-caption);
font-weight: 700;
color: var(--color-neutral-500);
text-transform: uppercase;
}
.kx-adm-rules tbody tr { border-bottom: 1px solid var(--color-neutral-100); }
.kx-adm-rulename { display: block; font-weight: 600; color: var(--color-neutral-900); }
.kx-adm-rulecode { display: block; font-size: 11px; color: var(--color-neutral-500); font-family: ui-monospace, monospace; }
.kx-adm-disclaimer { padding: var(--space-3) var(--space-4); font-size: var(--fs-caption); color: var(--color-neutral-500); border-top: var(--border-card); }
/* ═══════════ SCR-A9 테넌트 ═══════════ */
.kx-adm-tenant { display: grid; grid-template-columns: 1fr 340px; gap: var(--space-4); align-items: start; }
.kx-adm-tenant__list { padding: 0; overflow: hidden; }
.kx-adm-tenant__lhead {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
border-bottom: var(--border-card);
}
.kx-adm-tenant__lhead h2 { font-size: var(--fs-h3); font-weight: 700; color: var(--color-neutral-900); }
.kx-adm-tenants { width: 100%; border-collapse: collapse; }
.kx-adm-tenants th, .kx-adm-tenants td { padding: var(--space-3) var(--space-4); text-align: left; font-size: var(--fs-body); }
.kx-adm-tenants thead th {
background: var(--color-neutral-100);
font-size: var(--fs-caption);
font-weight: 700;
color: var(--color-neutral-500);
text-transform: uppercase;
}
.kx-adm-tenants tbody tr { border-bottom: 1px solid var(--color-neutral-100); }
.kx-adm-tenants tbody tr:hover { background: var(--color-primary-050); }
.kx-adm-tenants tbody tr.is-selected { background: var(--color-primary-050); }
.kx-adm-tenants__name { font-weight: 600; color: var(--color-neutral-900); }
.kx-adm-tenant__wizard { display: flex; flex-direction: column; gap: 4px; }
.kx-adm-wiz__title { font-size: var(--fs-h3); font-weight: 700; color: var(--color-neutral-900); }
.kx-adm-wiz { list-style: none; margin: var(--space-3) 0 0; padding: 0; display: flex; flex-direction: column; }
.kx-adm-wiz__step { display: flex; gap: var(--space-3); padding-bottom: var(--space-4); position: relative; }
.kx-adm-wiz__step:not(:last-child)::before {
content: '';
position: absolute;
left: 13px;
top: 28px;
bottom: 0;
width: 2px;
background: var(--color-neutral-200);
}
.kx-adm-wiz__dot {
width: 28px;
height: 28px;
flex: 0 0 28px;
border-radius: var(--radius-pill);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: var(--fs-caption);
font-weight: 700;
background: var(--color-neutral-100);
color: var(--color-neutral-500);
z-index: 1;
}
.kx-adm-wiz__step.is-done .kx-adm-wiz__dot { background: var(--color-success); color: var(--color-white); }
.kx-adm-wiz__step.is-current .kx-adm-wiz__dot { background: var(--color-primary-600); color: var(--color-white); }
.kx-adm-wiz__body { display: flex; flex-direction: column; gap: 2px; padding-top: 3px; }
.kx-adm-wiz__label { font-size: var(--fs-body); font-weight: 600; color: var(--color-neutral-900); }
.kx-adm-wiz__step.is-todo .kx-adm-wiz__label { color: var(--color-neutral-500); }
.kx-adm-wiz__desc { font-size: var(--fs-caption); color: var(--color-neutral-500); }
/* ── 반응형 ── */
@media (max-width: 1024px) {
.kx-adm-filter__grid { grid-template-columns: 1fr 1fr; }
.kx-adm-filter__actions { grid-column: 1 / -1; }
.kx-adm-ruleset { grid-template-columns: 1fr; }
.kx-adm-tenant { grid-template-columns: 1fr; }
.kx-adm-add__grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 640px) {
.kx-adm-filter__grid { grid-template-columns: 1fr; }
.kx-adm-settings { grid-template-columns: 1fr; }
.kx-adm-add__grid { grid-template-columns: 1fr; }
.kx-adm-pager { flex-direction: column; align-items: stretch; }
}

View File

@ -1,100 +0,0 @@
/*
* M18 (SCR-A5·A6) API .
* (../../api/client) client.ts·endpoints.ts .
* 정본: 백엔드
* - GET /api/admin/audit ApiResponse<PageResponse<AuditLogDto>> (AuditLogController)
* - GET /api/admin/settings, GET /{key}, POST, DELETE /{key} (SettingController)
* DTO에는 ·IP ( ipHint=null· ).
* DTO는 secretYn='Y' (********) .
*/
import { api } from '../../api/client';
import type { PageResponse } from '../../api/types';
// ── SCR-A5 감사로그 ──
/** 감사 로그 행(조회). 민감정보 미포함(계약 §0-3/§7-3). */
export interface AuditLogRow {
id: number;
actorId: string | null;
actorName: string | null;
action: string | null;
targetType: string | null;
targetId: string | null;
eventId: string | null;
summary: string | null;
result: string | null;
createdAt: string | null;
}
export interface AuditSearchParams {
action?: string;
actorId?: string;
eventId?: string;
page?: number;
size?: number;
}
export const auditApi = {
search: (p: AuditSearchParams = {}) => {
const qs = new URLSearchParams();
if (p.action) qs.set('action', p.action);
if (p.actorId) qs.set('actorId', p.actorId);
if (p.eventId) qs.set('eventId', p.eventId);
qs.set('page', String(p.page ?? 0));
qs.set('size', String(p.size ?? 20));
return api.get<PageResponse<AuditLogRow>>(`/api/admin/audit?${qs.toString()}`);
},
};
/** 백엔드 @Audited 액션 상수(datalist 힌트) — 필터 자동완성용. */
export const KNOWN_AUDIT_ACTIONS = [
'LOGIN_SLIDE_CREATE',
'LOGIN_SLIDE_UPDATE',
'LOGIN_SLIDE_DELETE',
'SETTING_SAVE',
'SETTING_DELETE',
'ROLE_SAVE',
'ROLE_DELETE',
'MENU_SAVE',
'MENU_DELETE',
'CODE_SAVE',
'CODE_DELETE',
'CODE_GROUP_SAVE',
'CODE_GROUP_DELETE',
'NOTICE_CREATE',
'NOTICE_UPDATE',
'NOTICE_DELETE',
'OPINION_COMMENT',
'OPINION_STATUS',
'SYS_USER_CREATE',
'SYS_USER_UPDATE',
'SYS_USER_STATUS',
'SYS_USER_PW_RESET',
'SYS_USER_OTP_RESET',
] as const;
// ── SCR-A6 시스템설정 ──
/** 시스템 설정 항목. secretYn='Y' 이면 settingValue 는 마스킹(********)되어 전달된다(원문 미노출). */
export interface SettingRow {
settingKey: string;
settingValue: string | null;
valueType: string | null;
description: string | null;
secretYn: string | null;
}
export interface SettingSaveBody {
settingKey: string;
settingValue?: string | null;
valueType?: string | null;
description?: string | null;
secretYn?: string | null;
}
/** 백엔드 마스킹 값(SettingService.MASK) — 마스킹된 시크릿은 미변경 시 저장 전송 제외. */
export const SETTING_MASK = '********';
export const settingApi = {
list: () => api.get<SettingRow[]>('/api/admin/settings'),
save: (body: SettingSaveBody) => api.post<void>('/api/admin/settings', body),
remove: (key: string) => api.del<void>(`/api/admin/settings/${encodeURIComponent(key)}`),
};

View File

@ -1,206 +0,0 @@
/*
* SCR-27 · · [M15]. Stitch scr_27_auction_detail .
* 대상: 장치업체(). AI (··BOQ·) + ·.
* M15 SAMPLE_RANKS . API/WebSocket ( ).
* ( ): ( ) ,
* "비공개" . "익명 순위" .
* () + ("백엔드 연동 예정").
*/
import { useEffect, useState } from 'react';
import { Button } from '../../components/ui/Button';
import { AiLabel } from '../../components/ui/Badge';
import { IconDocument, IconExpand, IconImage, IconPlus } from '../../components/ui/icons';
import {
REFERENCE_TABS,
SAMPLE_RANKS,
formatWon,
type MaterialKind,
type RankRow,
} from './sampleAuction';
import { SampleBadge, useToast } from './aucShared';
import './auction.css';
const REF_DESC: Record<MaterialKind, { title: string; desc: string; ai?: boolean }> = {
layout: {
title: '배치도 (M2)',
desc: 'A-102 부스 위치·주변 통로·트렌치 좌표가 표시된 홀 배치도입니다.',
},
design: {
title: '부스 설계안 (M3)',
desc: '선택·병합된 최종 부스 설계 초안(3D/평면)입니다.',
},
boq: {
title: '물량서 (M4 · BOQ)',
desc: '공종·자재·수량·규격이 정리된 시공 물량 산출서입니다.',
},
aiimage: {
title: '예상 이미지 (M5)',
desc: '나노바나나로 생성한 시공 후 예상 결과 이미지입니다.',
ai: true,
},
};
export function AuctionDetailPage() {
const { show, node: toast } = useToast();
const [tab, setTab] = useState<MaterialKind>('aiimage');
const [anon, setAnon] = useState(true);
const [seconds, setSeconds] = useState(5076); // 01:24:36
useEffect(() => {
const id = window.setInterval(() => setSeconds((s) => (s > 0 ? s - 1 : 0)), 1000);
return () => window.clearInterval(id);
}, []);
const active = REF_DESC[tab];
return (
<div className="kx-page">
{/* 상단 컨텍스트 바 */}
<div className="kx-aucd__bar">
<div className="kx-aucd__bar-left">
<span className="kx-live">
<span className="kx-live__dot" aria-hidden="true" />
</span>
<h1 className="kx-auc__title" style={{ fontSize: 'var(--fs-h2)' }}>
A-102
</h1>
<span className="kx-tag kx-tag--type"></span>
<span className="kx-tag kx-tag--muted"> 2</span>
<SampleBadge />
</div>
<div className="kx-aucd__bar-metrics">
<div className="kx-aucd__metric">
<span className="kx-aucd__metric-label"></span>
<span className="kx-aucd__metric-value kx-aucd__metric-value--ok"> </span>
</div>
<div className="kx-aucd__metric">
<span className="kx-aucd__metric-label"> </span>
<span className="kx-aucd__metric-value tnum">{formatWon(8_400_000)}</span>
</div>
</div>
</div>
<div className="kx-aucd__grid">
{/* 좌 — AI 자료 뷰어 */}
<section className="kx-viewer" aria-label="AI 설계 자료 뷰어">
<div className="kx-viewer__tabs" role="tablist" aria-label="자료 종류">
{REFERENCE_TABS.map((t) => (
<button
key={t.kind}
role="tab"
aria-selected={tab === t.kind}
className={`kx-viewer__tab ${tab === t.kind ? 'is-active' : ''}`}
onClick={() => setTab(t.kind)}
>
{t.label}
</button>
))}
</div>
<div className="kx-viewer__stage">
<div className="kx-viewer__placeholder">
{tab === 'aiimage' ? <IconImage size={40} /> : <IconDocument size={40} />}
<p className="kx-viewer__placeholder-title">{active.title}</p>
<p className="kx-viewer__placeholder-desc">{active.desc}</p>
{active.ai && <AiLabel>AI </AiLabel>}
</div>
{active.ai && <span className="kx-viewer__watermark">AI </span>}
<div style={{ position: 'absolute', top: 12, right: 12 }}>
<button
type="button"
className="kx-viewer__tab"
style={{ color: 'rgba(255,255,255,0.8)', height: 'auto' }}
onClick={() => show('전체화면 뷰어 — 백엔드 연동 예정')}
aria-label="전체화면"
>
<IconExpand size={18} />
</button>
</div>
</div>
</section>
{/* 우 — 실시간 순위·응찰 */}
<aside className="kx-rank" aria-label="실시간 순위">
<div className="kx-rank__head">
<h2> </h2>
<button
type="button"
className="kx-toggle"
aria-pressed={anon}
onClick={() => setAnon((v) => !v)}
>
<span className={`kx-toggle__track ${anon ? 'is-on' : ''}`}>
<span className="kx-toggle__knob" />
</span>
</button>
</div>
<div className="kx-auc__gate kx-auc__gate--seal" style={{ margin: '0 16px' }}>
, .
</div>
<div className="kx-rank__list">
{SAMPLE_RANKS.map((r) => (
<RankRowItem key={r.rank} row={r} anon={anon} />
))}
</div>
<div className="kx-rank__foot">
<div className="kx-countdown">
<span className="kx-countdown__label"> </span>
<span className="kx-countdown__value">{fmtClock(seconds)}</span>
</div>
<Button
block
leadingIcon={<IconPlus size={16} />}
onClick={() => show('견적서 작성(SCR-28) — 백엔드 연동 예정')}
>
·
</Button>
<p className="kx-rank__note">
. .
</p>
</div>
</aside>
</div>
{toast}
</div>
);
}
function RankRowItem({ row, anon }: { row: RankRow; anon: boolean }) {
// 봉인 규칙: 내 견적 + 현재 최저가(공개 벤치마크)만 금액 노출, 그 외 타사는 마스킹.
const revealPrice = row.isMe || row.isLowest;
const cls = row.isMe ? 'kx-rank__row--me' : row.isLowest ? 'kx-rank__row--lowest' : '';
const name = anon ? (row.isMe ? '나의 응찰 (ME)' : row.alias) : row.isMe ? '나의 응찰 (ME)' : `(주)협력사 ${row.rank}`;
return (
<div className={`kx-rank__row ${cls}`}>
<span className="kx-rank__badge">{row.rank}</span>
<div className="kx-rank__main">
<p className="kx-rank__alias">{name}</p>
{revealPrice ? (
<p className="kx-rank__price tnum">{formatWon(row.price)}</p>
) : (
<p className="kx-rank__price--masked" title="봉인 입찰 — 마감 후 공개">
·
</p>
)}
</div>
{row.isLowest && <span className="kx-rank__tag"></span>}
{row.isMe && (
<span className="kx-rank__tag" style={{ background: 'var(--color-ai-surface)', color: 'var(--color-ai-accent)' }}>
</span>
)}
</div>
);
}
function fmtClock(total: number): string {
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const p = (n: number) => n.toString().padStart(2, '0');
return `${p(h)}:${p(m)}:${p(s)}`;
}

View File

@ -1,339 +0,0 @@
/*
* SCR-26 / · [M15]. Stitch scr_26_auction_list .
* 대상: 참가업체·(). + .
* M15 SAMPLE_AUCTIONS ("샘플 데이터" ). API fetch .
* · + "백엔드 연동 예정" .
* 보안: 등록업체만 (verified ) .
*/
import { useMemo, useState, type ReactNode } from 'react';
import { Button } from '../../components/ui/Button';
import { DdayChip } from '../../components/ui/Badge';
import { EmptyState } from '../../components/ui/States';
import {
IconPlus,
IconDocument,
IconFloorplan,
IconImage,
IconSpark,
IconSettings,
} from '../../components/ui/icons';
import {
INVITABLE_COMPANIES,
MATERIAL_LABEL,
SAMPLE_AUCTIONS,
formatWon,
type AuctionStatus,
type AuctionSummary,
type AwardCriteria,
type MaterialKind,
} from './sampleAuction';
import { SampleBadge, useToast } from './aucShared';
import './auction.css';
type TabKey = 'live' | 'closed' | 'mine';
const TABS: { key: TabKey; label: string }[] = [
{ key: 'live', label: '진행중' },
{ key: 'closed', label: '마감' },
{ key: 'mine', label: '내가 개설' },
];
const MAT_ICON: Record<MaterialKind, ReactNode> = {
layout: <IconFloorplan size={16} />,
design: <IconSettings size={16} />,
boq: <IconDocument size={16} />,
aiimage: <IconImage size={16} />,
};
export function AuctionListPage() {
const { show, node: toast } = useToast();
const [tab, setTab] = useState<TabKey>('live');
const [criteria, setCriteria] = useState<AwardCriteria>('comprehensive');
const [weights, setWeights] = useState({ price: 60, reputation: 25, delivery: 15 });
const [invited, setInvited] = useState<Record<string, boolean>>({
'(주)에이치디자인': true,
});
const visible = useMemo(() => {
if (tab === 'closed') return SAMPLE_AUCTIONS.filter((a) => a.status !== '진행중');
if (tab === 'mine') return SAMPLE_AUCTIONS.filter((a) => a.type === '역경매');
return SAMPLE_AUCTIONS.filter((a) => a.status === '진행중');
}, [tab]);
return (
<div className="kx-page">
<header className="kx-auc__head">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h1 className="kx-auc__title">· </h1>
<SampleBadge />
</div>
<p className="kx-auc__subtitle">AI (M2~M5) · </p>
</div>
<div className="kx-auc__head-actions">
<div className="kx-seg" role="tablist" aria-label="옥션 필터">
{TABS.map((t) => (
<button
key={t.key}
role="tab"
aria-selected={tab === t.key}
className={`kx-seg__btn ${tab === t.key ? 'is-active' : ''}`}
onClick={() => setTab(t.key)}
>
{t.label}
</button>
))}
</div>
</div>
</header>
<div className="kx-auc__split">
{/* 좌 — 옥션 카드 목록 */}
<section className="kx-auc__list" aria-label="옥션 목록">
{visible.length === 0 ? (
<EmptyState
title="진행 중인 옥션이 없습니다"
description="새로운 시공 옥션을 개설해 협력사를 모집하세요."
/>
) : (
visible.map((a) => <AuctionCard key={a.id} auction={a} onOpen={() => show(`${a.title} 상세 — 백엔드 연동 예정`)} />)
)}
</section>
{/* 우 — 옥션 개설 패널 */}
<aside className="kx-auc-open" aria-label="옥션 개설">
<div className="kx-auc-open__head">
<h2>
<IconPlus size={18} />
</h2>
<p> .</p>
</div>
<div className="kx-auc-open__body">
<div className="kx-field">
<label className="kx-field__label" htmlFor="auc-cat">
</label>
<select id="auc-cat" className="kx-input">
<option></option>
<option> </option>
<option>/</option>
<option>/</option>
<option></option>
</select>
</div>
<div className="kx-field">
<span className="kx-field__label"> (Award Criteria)</span>
<div className="kx-seg" role="tablist" aria-label="낙찰 기준">
<button
role="tab"
aria-selected={criteria === 'lowest'}
className={`kx-seg__btn ${criteria === 'lowest' ? 'is-active' : ''}`}
onClick={() => setCriteria('lowest')}
>
</button>
<button
role="tab"
aria-selected={criteria === 'comprehensive'}
className={`kx-seg__btn ${criteria === 'comprehensive' ? 'is-active' : ''}`}
onClick={() => setCriteria('comprehensive')}
>
</button>
</div>
</div>
{criteria === 'comprehensive' && (
<div className="kx-weights" aria-label="종합평가 가중치">
<WeightSlider
label="가격 평가"
value={weights.price}
onChange={(v) => setWeights((w) => ({ ...w, price: v }))}
/>
<WeightSlider
label="평판/경력"
value={weights.reputation}
onChange={(v) => setWeights((w) => ({ ...w, reputation: v }))}
/>
<WeightSlider
label="납기/준수"
value={weights.delivery}
onChange={(v) => setWeights((w) => ({ ...w, delivery: v }))}
/>
</div>
)}
<div className="kx-grid-2">
<div className="kx-field">
<label className="kx-field__label" htmlFor="auc-round">
(Round)
</label>
<input id="auc-round" className="kx-input" type="text" defaultValue="1차" />
</div>
<div className="kx-field">
<label className="kx-field__label" htmlFor="auc-deadline">
</label>
<input id="auc-deadline" className="kx-input" type="date" />
</div>
</div>
<div className="kx-field">
<span className="kx-field__label"> </span>
<button
type="button"
className="kx-auc-attach"
onClick={() => show('M2~M5 자료 선택기 — 백엔드 연동 예정')}
>
<IconSpark size={26} />
M2~M5
<small> · · (BOQ) · AI </small>
</button>
</div>
<div className="kx-field">
<label className="kx-field__label">
<span className="kx-field__label-note"> </span>
</label>
<div className="kx-invite">
{INVITABLE_COMPANIES.map((c) => (
<label
key={c.name}
className={`kx-invite__row ${c.verified ? '' : 'kx-invite__row--blocked'}`}
>
<span>{c.name}</span>
{c.verified ? (
<input
type="checkbox"
checked={!!invited[c.name]}
onChange={(e) =>
setInvited((prev) => ({ ...prev, [c.name]: e.target.checked }))
}
aria-label={`${c.name} 초대`}
/>
) : (
<span className="kx-invite__blocked-tag" title="킨텍스 미등록 업체 — 응찰 불가">
·
</span>
)}
</label>
))}
</div>
</div>
<Button
block
leadingIcon={<IconPlus size={16} />}
onClick={() => show('옥션 공고 게시 — 백엔드 연동 예정')}
>
</Button>
</div>
</aside>
</div>
{toast}
</div>
);
}
function WeightSlider({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (v: number) => void;
}) {
return (
<div className="kx-weight">
<div className="kx-weight__top">
<span>{label}</span>
<span className="tnum">{value}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
aria-label={`${label} 가중치`}
/>
</div>
);
}
function StatusPill({ status }: { status: AuctionStatus }) {
if (status === '진행중') return <span className="kx-auc-pill kx-auc-pill--live"></span>;
if (status === '정산중') return <span className="kx-auc-pill kx-auc-pill--settle"> </span>;
return <span className="kx-auc-pill kx-auc-pill--closed"> </span>;
}
function AuctionCard({ auction, onOpen }: { auction: AuctionSummary; onOpen: () => void }) {
const closed = auction.status !== '진행중';
return (
<article
className={`kx-auc-card kx-auc-card--${auction.accent} ${closed ? 'kx-auc-card--closed' : ''}`}
>
<div className="kx-auc-card__top">
<div>
<div className="kx-auc-card__tags">
<span className={`kx-tag ${closed ? 'kx-tag--muted' : ''}`}>{auction.category}</span>
<span className={`kx-tag kx-tag--type ${closed ? 'kx-tag--muted' : ''}`}>
{auction.type}
</span>
</div>
<h3 className="kx-auc-card__name">{auction.title}</h3>
</div>
<div className="kx-auc-card__status">
{closed ? (
<span className="kx-auc-pill kx-auc-pill--closed"> </span>
) : (
<DdayChip dday={auction.dday} />
)}
<StatusPill status={auction.status} />
</div>
</div>
<div className="kx-auc-card__metrics">
<div className="kx-auc-card__metric">
<span className="kx-auc-card__metric-label">
{auction.awardedCompany ? '낙찰 업체' : '응찰 현황'}
</span>
<span className="kx-auc-card__metric-value">
{auction.awardedCompany ?? `${auction.bidderCount}개 업체 응찰 중`}
</span>
</div>
<div className="kx-auc-card__metric kx-auc-card__metric--end">
<span className="kx-auc-card__metric-label">
{auction.finalPrice ? '최종 낙찰가' : '현재 최저가'}
</span>
<span className="kx-auc-card__metric-value kx-auc-card__metric-value--price tnum">
{auction.finalPrice != null
? formatWon(auction.finalPrice)
: auction.lowestPrice != null
? formatWon(auction.lowestPrice)
: '—'}
</span>
</div>
</div>
{!closed && (
<div className="kx-auc-card__foot">
<div className="kx-auc-card__mats">
{auction.materials.map((m) => (
<span key={m} className="kx-auc-card__mat">
{MAT_ICON[m]}
{MATERIAL_LABEL[m]}
</span>
))}
</div>
<Button variant="secondary" onClick={onOpen}>
</Button>
</div>
)}
</article>
);
}

View File

@ -1,283 +0,0 @@
/*
* SCR-29 ·(Award) [M15]. Stitch scr_29_award_compare .
* 대상: 발주자(·). · .
* M15 SAMPLE_QUOTES . ("백엔드 연동 예정").
* 보안: 마감 ( ). .
*/
import { useState } from 'react';
import { Button } from '../../components/ui/Button';
import { AiLabel } from '../../components/ui/Badge';
import { EmptyState } from '../../components/ui/States';
import { IconAiBot, IconCheckCircle, IconDownload, IconWarning } from '../../components/ui/icons';
import { RISK_BARS, SAMPLE_QUOTES, formatWon, type QuoteColumn } from './sampleAuction';
import { SampleBadge, useToast } from './aucShared';
import './auction.css';
export function AwardComparePage() {
const { show, node: toast } = useToast();
const quotes = SAMPLE_QUOTES;
const recommended = quotes.find((q) => q.recommended) ?? quotes[0];
const [reason, setReason] = useState('');
const [awarded, setAwarded] = useState(false);
const onAward = () => {
if (!reason.trim()) {
show('선정 사유를 입력해 주세요.');
return;
}
setAwarded(true);
show(`${recommended.alias} 낙찰 승인 — 계약·발주 전환은 백엔드 연동 예정`);
};
return (
<div className="kx-page">
<header className="kx-auc__head">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h1 className="kx-auc__title"> </h1>
<SampleBadge />
</div>
<p className="kx-auc__subtitle">A-102 · ( )</p>
</div>
<Button variant="secondary" leadingIcon={<IconDownload size={16} />} onClick={() => show('응찰서 원본 다운로드 — 백엔드 연동 예정')}>
</Button>
</header>
<div className="kx-award__summary">
<div className="kx-award__chip">
<span className="kx-award__chip-label"></span>
<span className="kx-award__chip-value">A-102 </span>
</div>
<div className="kx-award__chip">
<span className="kx-award__chip-label"> </span>
<span className="kx-award__chip-value"> {quotes.length} </span>
</div>
<div className="kx-award__chip">
<span className="kx-award__chip-label"> </span>
<span className="kx-award__chip-value"> ( 40% + 60%)</span>
</div>
</div>
{quotes.length === 0 ? (
<EmptyState title="응찰이 없습니다" description="마감된 옥션에 제출된 견적서가 없습니다." />
) : (
<div className="kx-award__grid">
<div className="kx-award__main">
{/* 비교 매트릭스 */}
<section className="kx-card" aria-label="견적 비교표">
<div className="kx-card__head">
<h2> </h2>
<AiLabel>AI </AiLabel>
</div>
<div className="kx-table-scroll">
<table className="kx-compare">
<thead>
<tr>
<th className="kx-compare__metric-col"> </th>
{quotes.map((q) => (
<th
key={q.alias}
className={`kx-compare__co ${q.recommended ? 'kx-compare__co--rec' : ''}`}
>
{q.recommended && <span className="kx-compare__rec-tag"> </span>}
<span className="kx-compare__co-name">{q.alias}</span>
<span className="kx-compare__co-sub">{q.subtitle}</span>
</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td className="kx-compare__metric-col"> (VAT )</td>
{quotes.map((q) => (
<td key={q.alias} className={q.isLowestPrice ? 'kx-compare__best' : ''}>
<span className="kx-compare__val-strong tnum">{formatWon(q.totalPrice)}</span>
<span
className={`kx-compare__note ${q.isLowestPrice ? 'kx-compare__note--best' : ''}`}
>
{q.isLowestPrice ? '▼ 최저가' : `+${q.priceDeltaPct}% 차이`}
</span>
</td>
))}
</tr>
<tr>
<td className="kx-compare__metric-col"> ( )</td>
{quotes.map((q) => (
<td key={q.alias} className={q.isShortestLead ? 'kx-compare__best' : ''}>
<span className="tnum">{q.leadDays}</span>
{q.isShortestLead && (
<span className="kx-compare__note kx-compare__note--best"> </span>
)}
</td>
))}
</tr>
<tr>
<td className="kx-compare__metric-col"> ( 1)</td>
{quotes.map((q) => {
const best = q.reputation === Math.max(...quotes.map((x) => x.reputation));
return (
<td key={q.alias} className={best ? 'kx-compare__best' : ''}>
<span className="kx-compare__star" aria-hidden="true">
</span>{' '}
<span className="tnum">{q.reputation.toFixed(1)}</span>
</td>
);
})}
</tr>
<tr>
<td className="kx-compare__metric-col"> </td>
{quotes.map((q) => (
<td key={q.alias} className={q.recommended ? 'kx-compare__score' : ''}>
{q.recommended ? (
<span className="kx-compare__score-val tnum">{q.score.toFixed(1)}</span>
) : (
<span className="kx-compare__val-strong tnum" style={{ color: 'var(--color-neutral-500)' }}>
{q.score.toFixed(1)}
</span>
)}
</td>
))}
</tr>
</tbody>
</table>
</div>
</section>
{/* AI 리스크 분석 */}
<section className="kx-card" aria-label="AI 리스크 분석">
<div className="kx-card__head">
<h2>AI · </h2>
<AiLabel>AI </AiLabel>
</div>
<div className="kx-risk">
<ul className="kx-barlist">
{RISK_BARS.map((b) => (
<li key={b.label}>
<div className="kx-barlist__top">
<span>{b.label}</span>
<span
className="tnum"
style={{
color:
b.tone === 'success'
? 'var(--color-success)'
: 'var(--color-neutral-700)',
fontWeight: 700,
}}
>
{b.pct}%
</span>
</div>
<div className="kx-barlist__track">
<span
className={`kx-barlist__fill ${b.tone === 'success' ? 'kx-barlist__fill--success' : 'kx-barlist__fill--ai'}`}
style={{ width: `${b.pct}%` }}
/>
</div>
</li>
))}
</ul>
<div className="kx-risk__grade">
<span className="kx-award__rec-cell-label">AI </span>
<span className="kx-risk__grade-value">A+</span>
<span style={{ fontSize: 11, color: 'var(--color-success)' }}> </span>
</div>
</div>
<p className="kx-card__hint" style={{ marginTop: 12 }}>
A는 3 KINTEX (100~150) 12 , 0%
.
</p>
</section>
</div>
{/* 우 — 낙찰 선정 패널 */}
<aside className="kx-award__panel" aria-label="낙찰 선정">
<h2> </h2>
<RecommendCard quote={recommended} />
<div className="kx-field">
<label className="kx-field__label" htmlFor="award-reason">
()
</label>
<textarea
id="award-reason"
className="kx-award__textarea"
placeholder="종합 평가 결과 및 업체 강점을 입력하세요. 예: 최저가 응찰 + 과거 동일 규모 행사 수행 실적 우수"
value={reason}
onChange={(e) => setReason(e.target.value)}
disabled={awarded}
/>
</div>
<Button
variant={awarded ? 'secondary' : 'primary'}
block
leadingIcon={<IconCheckCircle size={16} />}
onClick={onAward}
disabled={awarded}
>
{awarded ? '낙찰 승인 완료' : '낙찰(Award) 최종 승인'}
</Button>
<Button variant="ghost" block onClick={() => show('재입찰 요청 — 백엔드 연동 예정')}>
</Button>
<div className="kx-award__warn">
<IconWarning size={16} />
<p>
, ·
.
</p>
</div>
<div className="kx-award__steps">
<p className="kx-award__steps-title"> </p>
<ol>
<li>
<span>1</span>
</li>
<li>
<span>2</span>
</li>
<li>
<span>3</span>
</li>
</ol>
</div>
</aside>
</div>
)}
{toast}
</div>
);
}
function RecommendCard({ quote }: { quote: QuoteColumn }) {
return (
<div className="kx-award__rec">
<div className="kx-award__rec-top">
<span className="kx-award__rec-avatar">{quote.alias.replace(/[^A-Za-z]/g, '') || 'A'}</span>
<div>
<p className="kx-award__rec-name">
{quote.alias} ({quote.subtitle})
</p>
<span className="kx-award__rec-ai">
<IconAiBot size={16} /> 98%
</span>
</div>
</div>
<div className="kx-award__rec-grid">
<div className="kx-award__rec-cell">
<span className="kx-award__rec-cell-label"> </span>
<span className="kx-award__rec-cell-value tnum">{formatWon(quote.totalPrice)}</span>
</div>
<div className="kx-award__rec-cell">
<span className="kx-award__rec-cell-label"> </span>
<span className="kx-award__rec-cell-value tnum">{quote.leadDays}</span>
</div>
</div>
</div>
);
}

View File

@ -1,29 +0,0 @@
/* 옥션 화면 공용 소도구 — 데모 토스트(뮤테이션 로컬 처리 안내). */
import { useCallback, useRef, useState } from 'react';
import { IconCheckCircle } from '../../components/ui/icons';
export function useToast() {
const [msg, setMsg] = useState<string | null>(null);
const timer = useRef<number | null>(null);
const show = useCallback((text: string) => {
setMsg(text);
if (timer.current) window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setMsg(null), 2600);
}, []);
const node = msg ? (
<div className="kx-auc__toast" role="status">
<IconCheckCircle size={18} />
{msg}
</div>
) : null;
return { show, node };
}
/** 화면 상단 공용 "샘플 데이터" 배지. */
export function SampleBadge() {
return (
<span className="kx-auc__sample" title="M15 역경매 엔진 미구현 — 시연용 정적 데이터">
</span>
);
}

View File

@ -1,998 +0,0 @@
/*
* M15 공사/장치 옥션 화면(SCR-26/27/29) 공통 스타일.
* 토큰은 styles/tokens.css(§1) 참조 하드코딩 금지. shared.css 프리미티브 선반영.
*/
@import '../shared.css';
/* ── 페이지 헤드(제목 + 샘플 배지) ── */
.kx-auc__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
}
.kx-auc__title {
font-size: var(--fs-h1);
line-height: var(--lh-h1);
color: var(--color-neutral-900);
font-weight: 700;
}
.kx-auc__subtitle {
font-size: var(--fs-body);
color: var(--color-neutral-500);
margin-top: 2px;
}
.kx-auc__head-actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
/* 샘플 데이터 배지 */
.kx-auc__sample {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
color: var(--color-warning);
background: #fff4e5;
padding: 3px 10px;
border-radius: var(--radius-pill);
white-space: nowrap;
}
/* 등록업체 게이트 고지 배너 */
.kx-auc__gate {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-caption);
color: var(--color-primary-700);
background: var(--color-primary-050);
border: 1px solid var(--color-primary-100);
border-radius: var(--radius-sm);
padding: 8px 12px;
}
.kx-auc__gate--seal {
color: var(--color-neutral-700);
background: var(--color-neutral-050);
border-color: var(--color-neutral-200);
}
/* ── SCR-26 목록 레이아웃(좌 목록 + 우 개설 패널) ── */
.kx-auc__split {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr);
gap: var(--space-5);
align-items: start;
}
@media (max-width: 1080px) {
.kx-auc__split {
grid-template-columns: 1fr;
}
}
.kx-auc__list {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
/* 옥션 카드 */
.kx-auc-card {
border: var(--border-card);
border-left: 4px solid var(--color-primary-600);
border-radius: var(--radius-lg);
background: var(--color-white);
padding: var(--space-4);
transition: box-shadow 0.15s ease, transform 0.15s ease;
}
.kx-auc-card:hover {
box-shadow: var(--shadow-level2);
transform: translateY(-1px);
}
.kx-auc-card--secondary {
border-left-color: var(--color-ai-accent);
}
.kx-auc-card--tertiary {
border-left-color: var(--color-success);
}
.kx-auc-card--closed {
opacity: 0.85;
}
.kx-auc-card__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.kx-auc-card__tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.kx-auc-card__name {
font-size: var(--fs-h3);
line-height: var(--lh-h3);
font-weight: 700;
color: var(--color-neutral-900);
}
.kx-auc-card__status {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.kx-auc-card__metrics {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
padding: var(--space-3) 0;
border-top: 1px dashed var(--color-neutral-200);
border-bottom: 1px dashed var(--color-neutral-200);
margin-bottom: var(--space-3);
}
.kx-auc-card__metric {
display: flex;
flex-direction: column;
gap: 2px;
}
.kx-auc-card__metric--end {
align-items: flex-end;
text-align: right;
}
.kx-auc-card__metric-label {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-auc-card__metric-value {
font-size: var(--fs-h3);
font-weight: 700;
color: var(--color-neutral-900);
}
.kx-auc-card__metric-value--price {
color: var(--color-primary-700);
}
.kx-auc-card__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
}
.kx-auc-card__mats {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
}
.kx-auc-card__mat {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--fs-caption);
color: var(--color-ai-accent);
font-weight: 600;
}
/* 카테고리·유형 태그 */
.kx-tag {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: var(--radius-pill);
font-size: var(--fs-caption);
font-weight: 600;
background: var(--color-primary-100);
color: var(--color-primary-700);
}
.kx-tag--type {
background: var(--color-ai-surface);
color: var(--color-ai-accent);
}
.kx-tag--muted {
background: var(--color-neutral-100);
color: var(--color-neutral-700);
}
/* 옥션 상태 pill */
.kx-auc-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: var(--radius-sm);
font-size: var(--fs-caption);
font-weight: 700;
}
.kx-auc-pill--live {
background: #e6f4ee;
color: var(--color-success);
}
.kx-auc-pill--closed {
background: var(--color-neutral-100);
color: var(--color-neutral-700);
}
.kx-auc-pill--settle {
background: var(--color-primary-050);
color: var(--color-primary-700);
}
/* ── 개설 패널 ── */
.kx-auc-open {
border: var(--border-card);
border-radius: var(--radius-lg);
background: var(--color-white);
overflow: hidden;
position: sticky;
top: var(--space-4);
}
.kx-auc-open__head {
background: var(--color-primary-700);
color: var(--color-white);
padding: var(--space-4);
}
.kx-auc-open__head h2 {
font-size: var(--fs-h3);
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.kx-auc-open__head p {
font-size: var(--fs-caption);
opacity: 0.9;
margin-top: 4px;
}
.kx-auc-open__body {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-4);
max-height: 720px;
overflow-y: auto;
}
.kx-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.kx-field__label {
font-size: var(--fs-caption);
color: var(--color-neutral-700);
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
}
.kx-field__label-note {
font-size: 11px;
color: var(--color-error);
font-weight: 500;
}
.kx-input {
height: 36px;
padding: 0 var(--space-3);
border: var(--border-card);
border-radius: var(--radius-sm);
background: var(--color-white);
font-size: var(--fs-body);
color: var(--color-neutral-900);
width: 100%;
}
.kx-grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
/* 가중치 슬라이더 카드 */
.kx-weights {
padding: var(--space-3);
background: var(--color-ai-surface);
border: 1px solid var(--color-ai-accent);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-weight__top {
display: flex;
justify-content: space-between;
font-size: var(--fs-caption);
color: var(--color-ai-accent);
font-weight: 600;
margin-bottom: 4px;
}
.kx-weight input[type='range'] {
width: 100%;
accent-color: var(--color-ai-accent);
cursor: pointer;
}
/* AI 자료 첨부 dropzone */
.kx-auc-attach {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: var(--space-5);
border: 2px dashed var(--color-ai-accent);
background: var(--color-ai-surface);
border-radius: var(--radius-lg);
color: var(--color-ai-accent);
cursor: pointer;
font-weight: 600;
}
.kx-auc-attach small {
color: var(--color-neutral-500);
font-weight: 400;
font-size: 11px;
}
/* 초대 업체 리스트 */
.kx-invite {
max-height: 160px;
overflow-y: auto;
border: var(--border-card);
border-radius: var(--radius-sm);
}
.kx-invite__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: 8px 12px;
border-bottom: 1px solid var(--color-neutral-100);
font-size: var(--fs-body);
}
.kx-invite__row:last-child {
border-bottom: none;
}
.kx-invite__row--blocked {
color: var(--color-neutral-500);
}
.kx-invite__blocked-tag {
font-size: 11px;
color: var(--color-error);
font-weight: 600;
}
/* ── SCR-27 상세 레이아웃 ── */
.kx-aucd__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
border: var(--border-card);
border-radius: var(--radius-lg);
background: var(--color-white);
padding: var(--space-3) var(--space-4);
flex-wrap: wrap;
}
.kx-aucd__bar-left {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.kx-live {
display: inline-flex;
align-items: center;
gap: 6px;
background: #fef3f2;
color: var(--color-error);
font-size: var(--fs-caption);
font-weight: 700;
padding: 3px 10px;
border-radius: var(--radius-sm);
}
.kx-live__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--color-error);
animation: kx-pulse 1.4s ease-in-out infinite;
}
@keyframes kx-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.kx-aucd__bar-metrics {
display: flex;
align-items: center;
gap: var(--space-4);
}
.kx-aucd__metric {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.kx-aucd__metric-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-neutral-500);
}
.kx-aucd__metric-value {
font-size: var(--fs-h3);
font-weight: 700;
color: var(--color-primary-700);
}
.kx-aucd__metric-value--ok {
color: var(--color-success);
}
.kx-aucd__grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr);
gap: var(--space-5);
align-items: start;
}
@media (max-width: 1080px) {
.kx-aucd__grid {
grid-template-columns: 1fr;
}
}
/* 자료 뷰어 */
.kx-viewer {
border: var(--border-card);
border-radius: var(--radius-lg);
background: var(--color-white);
overflow: hidden;
}
.kx-viewer__tabs {
display: flex;
gap: var(--space-4);
padding: 0 var(--space-4);
border-bottom: var(--border-card);
background: var(--color-neutral-050);
}
.kx-viewer__tab {
position: relative;
height: 44px;
border: none;
background: transparent;
font-size: var(--fs-body);
font-weight: 600;
color: var(--color-neutral-500);
cursor: pointer;
}
.kx-viewer__tab.is-active {
color: var(--color-primary-700);
}
.kx-viewer__tab.is-active::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 2px;
background: var(--color-primary-700);
}
.kx-viewer__stage {
position: relative;
aspect-ratio: 16 / 10;
background: var(--color-canvas-bg);
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.5);
overflow: hidden;
}
.kx-viewer__placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
text-align: center;
padding: var(--space-5);
}
.kx-viewer__placeholder-title {
font-size: var(--fs-h3);
color: rgba(255, 255, 255, 0.85);
font-weight: 600;
}
.kx-viewer__placeholder-desc {
font-size: var(--fs-caption);
color: rgba(255, 255, 255, 0.55);
max-width: 380px;
}
.kx-viewer__watermark {
position: absolute;
right: 16px;
bottom: 12px;
font-size: 11px;
font-style: italic;
color: rgba(255, 255, 255, 0.4);
pointer-events: none;
}
/* 실시간 순위 패널 */
.kx-rank {
border: var(--border-card);
border-radius: var(--radius-lg);
background: var(--color-white);
display: flex;
flex-direction: column;
overflow: hidden;
}
.kx-rank__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
border-bottom: var(--border-card);
}
.kx-rank__head h2 {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--fs-h3);
font-weight: 700;
color: var(--color-neutral-900);
}
.kx-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: var(--fs-caption);
color: var(--color-neutral-500);
background: none;
border: none;
cursor: pointer;
}
.kx-toggle__track {
width: 38px;
height: 20px;
border-radius: var(--radius-pill);
background: var(--color-neutral-200);
position: relative;
transition: background 0.15s ease;
}
.kx-toggle__track.is-on {
background: var(--color-primary-600);
}
.kx-toggle__knob {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--color-white);
transition: transform 0.15s ease;
}
.kx-toggle__track.is-on .kx-toggle__knob {
transform: translateX(18px);
}
.kx-rank__list {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
overflow-y: auto;
}
.kx-rank__row {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
border: var(--border-card);
border-radius: var(--radius-lg);
background: var(--color-white);
}
.kx-rank__row--lowest {
border-left: 4px solid var(--color-primary-600);
}
.kx-rank__row--me {
background: var(--color-ai-surface);
border: 2px solid var(--color-ai-accent);
}
.kx-rank__badge {
width: 34px;
height: 34px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
background: var(--color-primary-100);
color: var(--color-primary-700);
flex-shrink: 0;
}
.kx-rank__row--me .kx-rank__badge {
background: var(--color-ai-accent);
color: var(--color-white);
}
.kx-rank__main {
flex: 1;
min-width: 0;
}
.kx-rank__alias {
font-size: var(--fs-body);
font-weight: 700;
color: var(--color-neutral-900);
}
.kx-rank__row--me .kx-rank__alias {
color: var(--color-ai-accent);
}
.kx-rank__price {
font-size: var(--fs-h3);
font-weight: 700;
color: var(--color-primary-700);
}
.kx-rank__price--masked {
font-size: var(--fs-body);
color: var(--color-neutral-500);
font-weight: 600;
}
.kx-rank__tag {
font-size: 11px;
padding: 1px 8px;
border-radius: var(--radius-pill);
background: #e6f4ee;
color: var(--color-success);
font-weight: 600;
white-space: nowrap;
}
.kx-rank__foot {
border-top: var(--border-card);
padding: var(--space-4);
background: var(--color-neutral-050);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-countdown {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-3);
border: var(--border-card);
border-radius: var(--radius-lg);
background: var(--color-white);
}
.kx-countdown__label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--color-neutral-500);
font-weight: 700;
}
.kx-countdown__value {
font-size: var(--fs-display);
line-height: var(--lh-display);
font-weight: 700;
color: var(--color-primary-700);
font-variant-numeric: tabular-nums;
}
.kx-rank__note {
font-size: 11px;
color: var(--color-neutral-500);
text-align: center;
line-height: 1.5;
}
/* ── SCR-29 낙찰 비교 ── */
.kx-award__summary {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
}
.kx-award__chip {
padding: 8px 14px;
background: var(--color-white);
border: var(--border-card);
border-left: 4px solid var(--color-primary-600);
border-radius: var(--radius-sm);
}
.kx-award__chip:nth-child(2) {
border-left-color: var(--color-ai-accent);
}
.kx-award__chip:nth-child(3) {
border-left-color: var(--color-success);
}
.kx-award__chip-label {
font-size: 11px;
color: var(--color-neutral-500);
}
.kx-award__chip-value {
font-size: var(--fs-body);
font-weight: 700;
color: var(--color-neutral-900);
}
.kx-award__grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(340px, 380px);
gap: var(--space-5);
align-items: start;
}
@media (max-width: 1200px) {
.kx-award__grid {
grid-template-columns: 1fr;
}
}
.kx-award__main {
display: flex;
flex-direction: column;
gap: var(--space-5);
min-width: 0;
}
/* 비교 매트릭스 */
.kx-compare {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-body);
}
.kx-compare th,
.kx-compare td {
padding: var(--space-4);
border-bottom: var(--border-card);
text-align: center;
}
.kx-compare thead th {
background: var(--color-neutral-050);
font-weight: 700;
color: var(--color-neutral-900);
}
.kx-compare thead th.kx-compare__metric-col,
.kx-compare td.kx-compare__metric-col {
text-align: left;
width: 22%;
font-weight: 600;
color: var(--color-neutral-700);
}
.kx-compare__co {
position: relative;
}
.kx-compare__co-name {
font-size: var(--fs-h3);
font-weight: 700;
color: var(--color-neutral-900);
}
.kx-compare__co--rec .kx-compare__co-name {
color: var(--color-primary-700);
}
.kx-compare__co-sub {
display: block;
font-size: 11px;
font-weight: 400;
color: var(--color-neutral-500);
margin-top: 2px;
}
.kx-compare__rec-tag {
display: inline-block;
margin-bottom: 6px;
font-size: 10px;
font-weight: 700;
color: var(--color-white);
background: var(--color-primary-700);
padding: 2px 8px;
border-radius: var(--radius-pill);
}
.kx-compare__best {
background: #f0fbf6;
color: var(--color-success);
font-weight: 700;
}
.kx-compare__val-strong {
font-size: var(--fs-h3);
font-weight: 700;
}
.kx-compare__note {
display: block;
font-size: 11px;
color: var(--color-neutral-500);
margin-top: 2px;
}
.kx-compare__note--best {
color: var(--color-success);
}
.kx-compare__score {
background: var(--color-ai-surface);
}
.kx-compare__score-val {
font-size: var(--fs-display);
font-weight: 700;
color: var(--color-ai-accent);
}
.kx-compare__star {
color: var(--color-warning);
}
/* 리스크 카드 */
.kx-risk {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: var(--space-4);
align-items: center;
}
@media (max-width: 720px) {
.kx-risk {
grid-template-columns: 1fr;
}
}
.kx-risk__grade {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: var(--space-4);
background: var(--color-ai-surface);
border: 1px solid var(--color-ai-accent);
border-radius: var(--radius-lg);
}
.kx-risk__grade-value {
font-size: 40px;
font-weight: 700;
color: var(--color-ai-accent);
line-height: 1.1;
}
.kx-barlist__fill--success {
background: var(--color-success);
}
.kx-barlist__fill--ai {
background: var(--color-ai-accent);
}
/* 낙찰 선정 패널 */
.kx-award__panel {
border: var(--border-card);
border-radius: var(--radius-lg);
background: var(--color-white);
padding: var(--space-4);
position: sticky;
top: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.kx-award__panel h2 {
font-size: var(--fs-h2);
font-weight: 700;
color: var(--color-primary-700);
}
.kx-award__rec {
border: 1px solid var(--color-primary-600);
border-radius: var(--radius-lg);
padding: var(--space-4);
position: relative;
}
.kx-award__rec-top {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.kx-award__rec-avatar {
width: 44px;
height: 44px;
border-radius: var(--radius-lg);
background: var(--color-primary-700);
color: var(--color-white);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--fs-h2);
font-weight: 700;
}
.kx-award__rec-name {
font-size: var(--fs-h3);
font-weight: 700;
}
.kx-award__rec-ai {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--fs-caption);
color: var(--color-ai-accent);
}
.kx-award__rec-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
padding-top: var(--space-3);
border-top: var(--border-card);
}
.kx-award__rec-cell span {
display: block;
}
.kx-award__rec-cell-label {
font-size: 11px;
color: var(--color-neutral-500);
}
.kx-award__rec-cell-value {
font-size: var(--fs-body);
font-weight: 700;
color: var(--color-neutral-900);
}
.kx-award__textarea {
width: 100%;
min-height: 110px;
padding: var(--space-3);
border: var(--border-card);
border-radius: var(--radius-lg);
font-size: var(--fs-body);
font-family: var(--font-sans);
resize: vertical;
}
.kx-award__warn {
display: flex;
gap: var(--space-2);
padding: var(--space-3);
background: #fef3f2;
border-radius: var(--radius-sm);
font-size: 11px;
line-height: 1.5;
color: var(--color-error);
}
.kx-award__steps {
border-top: var(--border-card);
padding-top: var(--space-4);
}
.kx-award__steps-title {
font-size: var(--fs-caption);
font-weight: 700;
color: var(--color-neutral-700);
margin-bottom: var(--space-3);
}
.kx-award__steps ol {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-award__steps li {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--fs-caption);
color: var(--color-neutral-700);
}
.kx-award__steps li span {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--color-neutral-100);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
/* 토스트 */
.kx-auc__toast {
position: fixed;
bottom: var(--space-5);
right: var(--space-5);
z-index: 100;
display: flex;
align-items: center;
gap: var(--space-2);
padding: 12px 16px;
background: var(--color-neutral-900);
color: var(--color-white);
border-radius: var(--radius-lg);
font-size: var(--fs-body);
box-shadow: var(--shadow-level2);
}

View File

@ -1,210 +0,0 @@
/*
* M15 / .
* M15 API .
* fetch , "샘플 데이터" .
* / .
* (UI ):
* - (· ) verified .
* - 입찰: 마감 , ( ) .
* - (SCR-29) .
*/
export type AuctionCategory =
| '전시디자인설치'
| '구조물 임대'
| '전기/조명'
| '영상/음향'
| '네트워크';
export type AuctionType = '역경매' | 'RFQ';
export type AuctionStatus = '진행중' | '마감' | '정산중';
export type AwardCriteria = 'lowest' | 'comprehensive';
/** M2~M5 AI 자료 첨부 종류. */
export type MaterialKind = 'layout' | 'design' | 'boq' | 'aiimage';
export interface AuctionSummary {
id: string;
title: string;
category: AuctionCategory;
type: AuctionType;
status: AuctionStatus;
round: number;
dday: number; // 음수면 마감 경과
bidderCount: number;
lowestPrice: number | null; // 공개 최저가(역경매 벤치마크)
awardedCompany: string | null; // 정산중/마감 낙찰 업체
finalPrice: number | null;
materials: MaterialKind[];
accent: 'primary' | 'secondary' | 'tertiary';
}
export interface RankRow {
rank: number;
/** 익명 표기(업체 A/B/C). 실명은 마감 후 발주자 뷰에서만. */
alias: string;
price: number;
isMe: boolean;
isLowest: boolean;
}
export interface QuoteColumn {
alias: string;
subtitle: string; // 종합 1위 등
recommended: boolean;
totalPrice: number;
priceDeltaPct: number; // 최저가 대비 %
isLowestPrice: boolean;
leadDays: number;
isShortestLead: boolean;
reputation: number; // 5점 만점
score: number; // 종합 점수
}
export const MATERIAL_LABEL: Record<MaterialKind, string> = {
layout: '배치',
design: '설계',
boq: '물량서',
aiimage: '예상이미지',
};
/** SCR-26 옥션 목록. */
export const SAMPLE_AUCTIONS: AuctionSummary[] = [
{
id: 'AUC-2026-0102',
title: 'A-102 독립부스 시공',
category: '전시디자인설치',
type: '역경매',
status: '진행중',
round: 2,
dday: 2,
bidderCount: 5,
lowestPrice: 8_400_000,
awardedCompany: null,
finalPrice: null,
materials: ['layout', 'design', 'boq', 'aiimage'],
accent: 'primary',
},
{
id: 'AUC-2026-0405',
title: 'B-405 트러스 구조물 설치',
category: '구조물 임대',
type: '역경매',
status: '진행중',
round: 1,
dday: 1,
bidderCount: 12,
lowestPrice: 4_250_000,
awardedCompany: null,
finalPrice: null,
materials: ['layout', 'boq'],
accent: 'secondary',
},
{
id: 'AUC-2026-0210',
title: 'C-10 공용 통로 조명 공사',
category: '전기/조명',
type: '역경매',
status: '정산중',
round: 1,
dday: -3,
bidderCount: 7,
lowestPrice: 12_000_000,
awardedCompany: '(주)테크라이팅',
finalPrice: 12_000_000,
materials: ['layout', 'boq'],
accent: 'tertiary',
},
{
id: 'AUC-2026-0311',
title: '제2전시장 외벽 래핑',
category: '영상/음향',
type: 'RFQ',
status: '진행중',
round: 1,
dday: 6,
bidderCount: 3,
lowestPrice: 6_800_000,
awardedCompany: null,
finalPrice: null,
materials: ['design', 'aiimage'],
accent: 'secondary',
},
];
/** SCR-26 개설 패널 — 초대 후보 업체(등록 여부 게이트). */
export const INVITABLE_COMPANIES: { name: string; verified: boolean }[] = [
{ name: '(주)에이치디자인', verified: true },
{ name: '(주)공간창작', verified: true },
{ name: 'K-전시산업', verified: true },
{ name: '한빛부스시공', verified: false }, // 미등록 — 초대 불가
];
/** SCR-27 실시간 순위(봉인 — 최저가·내 견적만 금액 공개). */
export const SAMPLE_RANKS: RankRow[] = [
{ rank: 1, alias: '업체 A', price: 8_400_000, isMe: false, isLowest: true },
{ rank: 2, alias: '나의 응찰 (ME)', price: 8_700_000, isMe: true, isLowest: false },
{ rank: 3, alias: '업체 C', price: 8_900_000, isMe: false, isLowest: false },
{ rank: 4, alias: '업체 D', price: 9_050_000, isMe: false, isLowest: false },
{ rank: 5, alias: '업체 E', price: 9_400_000, isMe: false, isLowest: false },
];
/** SCR-27 자료 뷰어 탭. */
export const REFERENCE_TABS: { kind: MaterialKind; label: string }[] = [
{ kind: 'layout', label: '배치도' },
{ kind: 'design', label: '부스 설계안' },
{ kind: 'boq', label: '물량서(BOQ)' },
{ kind: 'aiimage', label: '예상 이미지' },
];
/** SCR-29 낙찰 비교표(마감 후 발주자 뷰 — 전 견적 공개). */
export const SAMPLE_QUOTES: QuoteColumn[] = [
{
alias: '업체 A',
subtitle: '종합 1위 (추천)',
recommended: true,
totalPrice: 8_400_000,
priceDeltaPct: 0,
isLowestPrice: true,
leadDays: 12,
isShortestLead: false,
reputation: 4.8,
score: 92.4,
},
{
alias: '업체 B',
subtitle: '종합 2위',
recommended: false,
totalPrice: 8_700_000,
priceDeltaPct: 3.5,
isLowestPrice: false,
leadDays: 10,
isShortestLead: true,
reputation: 4.6,
score: 89.1,
},
{
alias: '업체 C',
subtitle: '종합 3위',
recommended: false,
totalPrice: 8_900_000,
priceDeltaPct: 5.9,
isLowestPrice: false,
leadDays: 14,
isShortestLead: false,
reputation: 4.2,
score: 84.7,
},
];
/** SCR-29 리스크 분석 바(AI 시뮬레이션). */
export const RISK_BARS: { label: string; pct: number; tone: 'success' | 'ai' | 'warn' }[] = [
{ label: '시공 지연 확률', pct: 2.4, tone: 'success' },
{ label: '예산 초과 위험', pct: 12.8, tone: 'ai' },
{ label: '자재 품질 신뢰도', pct: 96.5, tone: 'success' },
];
/** 원 금액 → ₩ 구분자 포맷. */
export function formatWon(v: number): string {
return `${v.toLocaleString('ko-KR')}`;
}

View File

@ -1,306 +0,0 @@
/*
* SCR-35 CMS · [M17]. 참조: design.md §3 SCR-35.
* : 콘텐츠 /( · ) · : 블록 (·· ) · : 게시 (·· ).
* M17 ("샘플 데이터" ). API .
*/
import { useMemo, useState } from 'react';
import { Button } from '../../components/ui/Button';
import { AiLabel } from '../../components/ui/Badge';
import {
IconDocument,
IconSpark,
IconCalendar,
IconCheckCircle,
IconImage,
IconPlus,
IconWarning,
} from '../../components/ui/icons';
import './cms.css';
type Lang = 'ko' | 'en' | 'zh' | 'ja';
const LANGS: { key: Lang; label: string }[] = [
{ key: 'ko', label: '한국어' },
{ key: 'en', label: 'English' },
{ key: 'zh', label: '中文' },
{ key: 'ja', label: '日本語' },
];
type FlowStatus = 'draft' | 'review' | 'approved' | 'published';
const STATUS_META: Record<FlowStatus, { label: string; cls: string }> = {
draft: { label: '초안', cls: 'kx-cms-pill--draft' },
review: { label: '검수 중', cls: 'kx-cms-pill--review' },
approved: { label: '승인', cls: 'kx-cms-pill--approved' },
published: { label: '게시 완료', cls: 'kx-cms-pill--published' },
};
const FLOW_ORDER: FlowStatus[] = ['draft', 'review', 'approved', 'published'];
interface ContentItem {
id: string;
name: string;
status: FlowStatus;
}
const SAMPLE_CONTENT: ContentItem[] = [
{ id: 'page-home', name: '페이지', status: 'published' },
{ id: 'notice', name: '공지사항', status: 'draft' },
{ id: 'about', name: '행사 소개', status: 'review' },
{ id: 'speakers', name: '연사 정보', status: 'published' },
{ id: 'faq', name: 'FAQ', status: 'published' },
];
// 툴바용 소형 인라인 아이콘(stroke, currentColor) — 라이브러리 미보유분.
function ToolIcon({ d }: { d: string }) {
return (
<svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d={d} />
</svg>
);
}
const TOOLS = [
{ title: '굵게', d: 'M6 4h8a4 4 0 0 1 0 8H6zM6 12h9a4 4 0 0 1 0 8H6z' },
{ title: '기울임', d: 'M19 4h-9M14 20H5M15 4L9 20' },
{ title: '링크', d: 'M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1' },
{ title: '목록', d: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01' },
];
export function CmsWorkflowPage() {
const [lang, setLang] = useState<Lang>('ko');
const [selectedId, setSelectedId] = useState('about');
const [content, setContent] = useState<ContentItem[]>(SAMPLE_CONTENT);
const [signage, setSignage] = useState(true);
const [mailing, setMailing] = useState(false);
const selected = useMemo(
() => content.find((c) => c.id === selectedId) ?? content[0],
[content, selectedId],
);
function advance(next: FlowStatus) {
setContent((prev) => prev.map((c) => (c.id === selected.id ? { ...c, status: next } : c)));
}
const curIdx = FLOW_ORDER.indexOf(selected.status);
return (
<div className="kx-page">
<header className="kx-cms__head">
<div>
<h1 className="kx-cms__title">
(CMS)
<span className="kx-sample-badge">
<IconWarning size={12} />
</span>
</h1>
<p className="kx-cms__subtitle">
· ( ) · ·
</p>
</div>
<div className="kx-cms__head-actions">
<Button variant="secondary"> </Button>
</div>
</header>
<div className="kx-cms-grid">
{/* 좌: 콘텐츠 목록 */}
<section className="kx-card kx-cms-col" aria-label="콘텐츠 목록">
<div className="kx-seg" role="tablist" aria-label="언어">
{LANGS.map((l) => (
<button
key={l.key}
role="tab"
aria-selected={lang === l.key}
className={`kx-seg__btn ${lang === l.key ? 'is-active' : ''}`}
onClick={() => setLang(l.key)}
>
{l.label}
</button>
))}
</div>
<ul className="kx-cms-list">
{content.map((c) => {
const m = STATUS_META[c.status];
return (
<li key={c.id}>
<button
className={`kx-cms-list__item ${c.id === selected.id ? 'is-active' : ''}`}
onClick={() => setSelectedId(c.id)}
aria-current={c.id === selected.id}
>
<span className="kx-cms-list__label">
<IconDocument size={18} />
<span className="kx-cms-list__name">{c.name}</span>
</span>
<span className={`kx-cms-pill ${m.cls}`}>{m.label}</span>
</button>
</li>
);
})}
</ul>
</section>
{/* 중: 에디터 */}
<section className="kx-card kx-cms-col kx-cms-editor" aria-label="콘텐츠 에디터">
<input
className="kx-cms-title-input"
defaultValue={selected.name}
aria-label="제목"
key={selected.id}
/>
<div className="kx-cms-toolbar" role="toolbar" aria-label="서식">
{TOOLS.map((t) => (
<button key={t.title} className="kx-icon-btn" title={t.title} aria-label={t.title}>
<ToolIcon d={t.d} />
</button>
))}
<span className="kx-cms-toolbar__divider" aria-hidden="true" />
<button className="kx-icon-btn" title="이미지 삽입" aria-label="이미지 삽입">
<IconImage size={18} />
</button>
<button className="kx-icon-btn" title="버튼 블록" aria-label="버튼 블록">
<IconPlus size={18} />
</button>
</div>
<div>
<div className="kx-cms-sublabel" style={{ marginBottom: 8 }}>
</div>
<div className="kx-cms-media">
<div className="kx-cms-media__thumb">
<IconImage size={22} />
</div>
<div className="kx-cms-media__thumb">
<IconImage size={22} />
</div>
<button className="kx-cms-media__add" title="이미지 추가" aria-label="이미지 추가">
<IconPlus size={20} />
</button>
</div>
</div>
<div className="kx-cms-blocks">
<div className="kx-cms-block">
<div className="kx-cms-block__img"> </div>
</div>
<div className="kx-cms-block">
<div className="kx-cms-block__head">
<span className="kx-cms-block__kind"> </span>
</div>
<p className="kx-cms-block__body">
KINTEX .
AI , ,
.
</p>
</div>
<div className="kx-cms-block kx-cms-block--ai">
<div className="kx-cms-block__head">
<AiLabel>AI </AiLabel>
</div>
<p className="kx-cms-block__body">
AI와
.
</p>
</div>
</div>
<div>
<div className="kx-cms-sublabel" style={{ marginBottom: 8 }}>
</div>
<div className="kx-cms-sched">
<IconCalendar size={18} />
<span>2026.08.01</span>
<span aria-hidden="true">·</span>
<span>09:00</span>
<Button variant="ghost" style={{ marginLeft: 'auto' }}>
</Button>
</div>
</div>
</section>
{/* 우: 워크플로 */}
<aside className="kx-card kx-cms-flow" aria-label="게시 워크플로">
<div className="kx-cms-flow__section">
<span className="kx-cms-flow__label"> </span>
<div className="kx-cms-stepper" aria-label="게시 단계">
{FLOW_ORDER.map((s, i) => (
<div
key={s}
className={`kx-cms-step ${i < curIdx ? 'is-done' : ''} ${i === curIdx ? 'is-current' : ''}`}
>
<span className="kx-cms-step__dot">
{i < curIdx && <IconCheckCircle size={12} />}
</span>
{STATUS_META[s].label}
</div>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
{selected.status === 'draft' && (
<Button block variant="secondary" onClick={() => advance('review')}>
</Button>
)}
{selected.status === 'review' && (
<Button block onClick={() => advance('approved')} leadingIcon={<IconCheckCircle size={16} />}>
</Button>
)}
{selected.status === 'approved' && (
<Button block onClick={() => advance('published')} leadingIcon={<IconSpark size={16} />}>
</Button>
)}
{selected.status === 'published' && (
<Button block variant="secondary" onClick={() => advance('draft')}>
</Button>
)}
</div>
</div>
<div className="kx-cms-flow__section">
<span className="kx-cms-flow__label"> </span>
<div className="kx-cms-ver">
<div className="kx-cms-ver__item kx-cms-ver__item--current">
<div className="kx-cms-ver__v">v3 ( )</div>
<div className="kx-cms-ver__meta">2026.07.25 · </div>
</div>
<div className="kx-cms-ver__item kx-cms-ver__item--published">
<div className="kx-cms-ver__v">v2 ( )</div>
<div className="kx-cms-ver__meta">2026.06.10 · </div>
</div>
<div className="kx-cms-ver__item">
<div className="kx-cms-ver__v">v1</div>
<div className="kx-cms-ver__meta">2026.05.15 · </div>
</div>
</div>
</div>
<div className="kx-cms-schedcard">
<div className="kx-cms-schedcard__t">
<IconCalendar size={14} />
</div>
<div style={{ fontSize: 13, color: 'var(--color-neutral-700)' }}>
2026.08.01 09:00
</div>
</div>
<div className="kx-cms-flow__toggles">
<label className="kx-toggle">
<span> </span>
<input type="checkbox" checked={signage} onChange={(e) => setSignage(e.target.checked)} />
<span className="kx-toggle__track" aria-hidden="true" />
</label>
<label className="kx-toggle">
<span> </span>
<input type="checkbox" checked={mailing} onChange={(e) => setMailing(e.target.checked)} />
<span className="kx-toggle__track" aria-hidden="true" />
</label>
</div>
</aside>
</div>
</div>
);
}

View File

@ -1,359 +0,0 @@
/*
* SCR-36 [M17]. 참조: design.md §3 SCR-36.
* : 섹션 ( ·· ·)· · : 라이브 (/ , SCR-P5 ) · : 게시 ·URL·SEO··.
* M17 ("샘플 데이터" ). AiLabel . API .
*/
import { useState } from 'react';
import { Button } from '../../components/ui/Button';
import { AiLabel } from '../../components/ui/Badge';
import {
IconImage,
IconPlus,
IconWarning,
IconGrid,
IconExpand,
IconDownload,
} from '../../components/ui/icons';
import './cms.css';
type Lang = 'ko' | 'en' | 'zh' | 'ja';
const LANGS: { key: Lang; label: string }[] = [
{ key: 'ko', label: '한' },
{ key: 'en', label: '영' },
{ key: 'zh', label: '중' },
{ key: 'ja', label: '일' },
];
interface Section {
id: string;
name: string;
visible: boolean;
}
const SAMPLE_SECTIONS: Section[] = [
{ id: 'intro', name: '부스 소개', visible: true },
{ id: 'products', name: '제품', visible: true },
{ id: 'gallery', name: '예상샷 갤러리', visible: true },
{ id: 'contact', name: '연락처', visible: true },
];
const THEMES = [
{ key: 'blue', color: 'var(--color-primary-600)' },
{ key: 'violet', color: 'var(--color-ai-accent)' },
{ key: 'green', color: 'var(--color-success)' },
{ key: 'red', color: 'var(--color-error)' },
];
// 우측/좌측 소형 인라인 아이콘.
function MiniIcon({ d, size = 16 }: { d: string; size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d={d} />
</svg>
);
}
const D_DRAG = 'M9 5h.01M9 12h.01M9 19h.01M15 5h.01M15 12h.01M15 19h.01';
const D_EYE = 'M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z M12 12h.01';
const D_EDIT = 'M11 4H4v16h16v-7M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z';
const D_COPY = 'M9 9h10v10H9z M5 15H4V4h11v1';
export function MicrositeBuilderPage() {
const [sections, setSections] = useState<Section[]>(SAMPLE_SECTIONS);
const [activeSection, setActiveSection] = useState('intro');
const [device, setDevice] = useState<'desktop' | 'mobile'>('desktop');
const [theme, setTheme] = useState('blue');
const [lang, setLang] = useState<Lang>('ko');
const [title, setTitle] = useState('한빛로보틱스 | KINTEX AI 전시');
const [meta, setMeta] = useState(
'KINTEX AI EXPO (주)한빛로보틱스 공식 마이크로사이트 — 최첨단 협동로봇·서비스 로봇 솔루션을 만나보세요.',
);
return (
<div className="kx-page">
<header className="kx-cms__head">
<div>
<h1 className="kx-cms__title">
<span className="kx-sample-badge">
<IconWarning size={12} />
</span>
</h1>
<p className="kx-cms__subtitle">() · (··) </p>
</div>
<div className="kx-cms__head-actions">
<Button variant="secondary" leadingIcon={<IconExpand size={16} />}>
</Button>
<Button></Button>
</div>
</header>
<div className="kx-mb-grid">
{/* 좌: 섹션 편집 + 테마 */}
<section className="kx-card kx-cms-col kx-mb-sections" aria-label="섹션 편집">
<div className="kx-cms-sublabel"> </div>
<ul className="kx-mb-sec">
{sections.map((s) => (
<li key={s.id}>
<button
className={`kx-mb-sec__item ${s.id === activeSection ? 'is-active' : ''}`}
onClick={() => setActiveSection(s.id)}
aria-current={s.id === activeSection}
>
<span className="kx-mb-sec__label">
<MiniIcon d={D_DRAG} />
{s.name}
</span>
<span className="kx-mb-sec__ctrls">
<span
className="kx-icon-btn"
title={s.visible ? '표시 중' : '숨김'}
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
setSections((prev) =>
prev.map((x) => (x.id === s.id ? { ...x, visible: !x.visible } : x)),
);
}}
onKeyDown={() => {}}
>
<MiniIcon d={D_EYE} />
</span>
<span className="kx-icon-btn" title="편집" aria-hidden="true">
<MiniIcon d={D_EDIT} />
</span>
</span>
</button>
</li>
))}
</ul>
<button className="kx-mb-addbtn">
<IconPlus size={16} />
</button>
<div className="kx-cms-sublabel" style={{ marginTop: 8 }}>
</div>
<div className="kx-cms-field">
<span className="kx-cms-label"> </span>
<div className="kx-mb-swatches">
{THEMES.map((t) => (
<button
key={t.key}
className={`kx-mb-swatch ${theme === t.key ? 'is-active' : ''}`}
style={{ background: t.color }}
onClick={() => setTheme(t.key)}
title={`테마 ${t.key}`}
aria-label={`테마 ${t.key}`}
aria-pressed={theme === t.key}
/>
))}
</div>
</div>
<label className="kx-cms-field">
<span className="kx-cms-label"> </span>
<select className="kx-select" defaultValue="Pretendard">
<option>Pretendard (Corporate)</option>
<option>Inter (Modern)</option>
<option>Noto Sans KR</option>
</select>
</label>
</section>
{/* 중: 캔버스 프리뷰 */}
<section aria-label="라이브 미리보기">
<div className="kx-mb-canvas">
<div className="kx-mb-devicebar kx-seg" role="tablist" aria-label="디바이스">
<button
role="tab"
aria-selected={device === 'desktop'}
className={`kx-seg__btn ${device === 'desktop' ? 'is-active' : ''}`}
onClick={() => setDevice('desktop')}
>
</button>
<button
role="tab"
aria-selected={device === 'mobile'}
className={`kx-seg__btn ${device === 'mobile' ? 'is-active' : ''}`}
onClick={() => setDevice('mobile')}
>
</button>
</div>
<div className={`kx-mb-frame ${device === 'mobile' ? 'kx-mb-frame--mobile' : ''}`}>
{/* 마이크로사이트 nav */}
<div className="kx-mb-site__nav">
<div className="kx-mb-site__brand">
<span className="kx-mb-site__logo">H</span>
()
</div>
<nav className="kx-mb-site__menu">
<span></span>
<span></span>
<span></span>
<span style={{ color: 'var(--color-primary-700)' }}></span>
</nav>
</div>
{/* Hero */}
<section className="kx-mb-hero">
<div>
<span className="kx-mb-hero__eyebrow">KINTEX AI EXPO</span>
<h2 className="kx-mb-hero__title"> </h2>
<p className="kx-mb-hero__desc">
,
.
</p>
<div className="kx-mb-hero__cta">
<span className="kx-mb-btn kx-mb-btn--solid"> </span>
<span className="kx-mb-btn kx-mb-btn--ghost"> </span>
</div>
</div>
</section>
{/* 제품 그리드 */}
<section className="kx-mb-section kx-mb-section--alt">
<div className="kx-mb-section__head">
<span className="kx-mb-section__bar" />
<h3 className="kx-mb-section__title"> </h3>
</div>
<div className="kx-mb-cards">
<article className="kx-mb-card">
<div className="kx-mb-card__img">
<IconImage size={24} />
<span className="kx-mb-card__tag">NEW</span>
</div>
<div className="kx-mb-card__body">
<div className="kx-mb-card__title">HR-A1 </div>
<p className="kx-mb-card__desc">
·AI .
</p>
</div>
</article>
<article className="kx-mb-card">
<div className="kx-mb-card__img">
<IconImage size={24} />
<span className="kx-mb-card__tag">POPULAR</span>
</div>
<div className="kx-mb-card__body">
<div className="kx-mb-card__title">ServiBot Elite</div>
<p className="kx-mb-card__desc">
· · .
</p>
</div>
</article>
</div>
</section>
{/* AI 예상샷 갤러리 */}
<section className="kx-mb-section">
<div className="kx-mb-section__head">
<span className="kx-mb-section__bar" style={{ background: 'var(--color-ai-accent)' }} />
<h3 className="kx-mb-section__title">AI </h3>
</div>
<div className="kx-mb-gallery">
<div className="kx-mb-gallery__cell">
<IconGrid size={28} />
<span className="kx-mb-gallery__wm">
<AiLabel>AI </AiLabel>
</span>
</div>
<div className="kx-mb-gallery__cell">
<IconGrid size={28} />
<span className="kx-mb-gallery__wm">
<AiLabel>AI </AiLabel>
</span>
</div>
</div>
</section>
</div>
</div>
</section>
{/* 우: 게시·SEO */}
<aside className="kx-card kx-mb-side" aria-label="게시 및 SEO">
<div className="kx-mb-side__section">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span className="kx-cms-sublabel"> </span>
<span className="kx-mb-status">
<span className="kx-mb-status__dot" />
</span>
</div>
<span className="kx-cms-label"> URL</span>
<div className="kx-mb-url">
<span>hanbit.expo.kintex.kr</span>
<button className="kx-icon-btn" title="URL 복사" aria-label="URL 복사">
<MiniIcon d={D_COPY} />
</button>
</div>
</div>
<div className="kx-mb-side__section">
<span className="kx-cms-sublabel">SEO · </span>
<label className="kx-cms-field">
<span className="kx-cms-label"> </span>
<input
className="kx-cms-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
<label className="kx-cms-field">
<span className="kx-cms-label"> </span>
<textarea
className="kx-cms-textarea"
value={meta}
onChange={(e) => setMeta(e.target.value)}
/>
</label>
</div>
<div className="kx-mb-side__section">
<span className="kx-cms-sublabel"> </span>
<div className="kx-seg" role="tablist" aria-label="언어">
{LANGS.map((l) => (
<button
key={l.key}
role="tab"
aria-selected={lang === l.key}
className={`kx-seg__btn ${lang === l.key ? 'is-active' : ''}`}
onClick={() => setLang(l.key)}
>
{l.label}
</button>
))}
</div>
</div>
<div className="kx-mb-side__section">
<span className="kx-cms-sublabel"> </span>
<div className="kx-mb-activity">
<div className="kx-mb-activity__row">
<span className="kx-mb-activity__dot" />
<div>
<div className="kx-mb-activity__t"> </div>
<div className="kx-mb-activity__m">10 · </div>
</div>
</div>
<div className="kx-mb-activity__row">
<span className="kx-mb-activity__dot kx-mb-activity__dot--muted" />
<div>
<div className="kx-mb-activity__t" style={{ fontWeight: 400 }}>
SEO
</div>
<div className="kx-mb-activity__m">2 · </div>
</div>
</div>
</div>
</div>
<Button block leadingIcon={<IconDownload size={16} />} style={{ marginTop: 'auto' }}>
</Button>
</aside>
</div>
</div>
);
}

View File

@ -1,287 +0,0 @@
/*
* SCR-37 [M17 / F069]. 참조: design.md §3 SCR-37.
* 상단: 언어 ( 100· 82· 60· 45) · : 번역 ( × 상태: 미번역/AI /, AI ) · : 병렬 ()·· .
* M17·AI (Claude) ("샘플 데이터" ). API .
*/
import { useMemo, useState } from 'react';
import { Button } from '../../components/ui/Button';
import { IconSpark, IconWarning, IconClose } from '../../components/ui/icons';
import './cms.css';
type Target = 'en' | 'zh' | 'ja';
const TARGETS: { key: Target; label: string }[] = [
{ key: 'en', label: 'English (EN)' },
{ key: 'zh', label: 'Chinese (ZH)' },
{ key: 'ja', label: 'Japanese (JA)' },
];
const COVERAGE = [
{ label: '한국어 (KO)', pct: 100 },
{ label: 'English (EN)', pct: 82 },
{ label: 'Chinese (ZH)', pct: 60 },
{ label: 'Japanese (JA)', pct: 45 },
];
type TransStatus = 'none' | 'ai' | 'reviewed';
const STATUS_META: Record<TransStatus, { label: string; cls: string }> = {
none: { label: '미번역', cls: 'kx-cms-pill--none' },
ai: { label: 'AI 초벌', cls: 'kx-cms-pill--ai' },
reviewed: { label: '검수완료', cls: 'kx-cms-pill--published' },
};
interface Row {
id: string;
key: string;
source: string;
en: { text: string; status: TransStatus; updated: string };
}
const SAMPLE_ROWS: Row[] = [
{
id: 'GLOBAL_EVENT_NAME',
key: '행사명',
source: 'KINTEX 글로벌 스마트테크 엑스포 2026',
en: { text: 'KINTEX Global Smart Tech Expo 2026', status: 'reviewed', updated: '2시간 전' },
},
{
id: 'HALL_GUIDE',
key: '홀 안내',
source: '전시 홀 층별 안내',
en: { text: 'Exhibition Hall Floor Information', status: 'ai', updated: '1일 전' },
},
{
id: 'REGISTRATION',
key: '참가 신청',
source: '참가 신청 및 등록 절차',
en: { text: '', status: 'none', updated: '-' },
},
{
id: 'EXHIBITION_OVERVIEW',
key: '전시 개요',
source: '전시 개요 및 참가 안내',
en: {
text: 'Overview of the upcoming tech showcase and participation guidelines.',
status: 'ai',
updated: '3시간 전',
},
},
{
id: 'FLOOR_MAP_ALT',
key: '전시장 안내도',
source: '제1·2전시장 부스 배치도',
en: {
text: 'Detailed layout and booth mapping for KINTEX 1 and 2.',
status: 'reviewed',
updated: '5일 전',
},
},
];
const GLOSSARY = [
{ ko: '홀', en: 'Hall' },
{ ko: '안내', en: 'Guide' },
{ ko: '부스', en: 'Booth' },
];
export function MultilingualCmsPage() {
const [target, setTarget] = useState<Target>('en');
const [rows, setRows] = useState<Row[]>(SAMPLE_ROWS);
const [selectedId, setSelectedId] = useState('GLOBAL_EVENT_NAME');
const [translating, setTranslating] = useState(false);
const selected = useMemo(
() => rows.find((r) => r.id === selectedId) ?? rows[0],
[rows, selectedId],
);
function aiTranslateAll() {
setTranslating(true);
window.setTimeout(() => {
setRows((prev) =>
prev.map((r) =>
r.en.status === 'none'
? { ...r, en: { ...r.en, text: `[AI] ${r.source}`, status: 'ai', updated: '방금' } }
: r,
),
);
setTranslating(false);
}, 900);
}
function updateSelectedText(text: string) {
setRows((prev) =>
prev.map((r) => (r.id === selected.id ? { ...r, en: { ...r.en, text } } : r)),
);
}
function applyReviewed() {
setRows((prev) =>
prev.map((r) =>
r.id === selected.id ? { ...r, en: { ...r.en, status: 'reviewed', updated: '방금' } } : r,
),
);
}
return (
<div className="kx-page">
<header className="kx-cms__head">
<div>
<h1 className="kx-cms__title">
<span className="kx-sample-badge">
<IconWarning size={12} />
</span>
</h1>
<p className="kx-cms__subtitle"> · AI (Claude) · · </p>
</div>
</header>
{/* 커버리지 요약 */}
<div className="kx-i18n-cov" aria-label="언어 커버리지">
{COVERAGE.map((c) => (
<div className="kx-i18n-covcard" key={c.label}>
<div className="kx-i18n-covcard__top">
<span>{c.label}</span>
<span className="kx-i18n-covcard__pct">{c.pct}%</span>
</div>
<div className="kx-i18n-bar">
<span className="kx-i18n-bar__fill" style={{ width: `${c.pct}%` }} />
</div>
</div>
))}
</div>
<div className="kx-i18n-main">
{/* 좌: 번역 매트릭스 */}
<section className="kx-card" aria-label="번역 매트릭스">
<div className="kx-i18n-toolbar">
<div className="kx-seg" role="tablist" aria-label="대상 언어">
{TARGETS.map((t) => (
<button
key={t.key}
role="tab"
aria-selected={target === t.key}
className={`kx-seg__btn ${target === t.key ? 'is-active' : ''}`}
onClick={() => setTarget(t.key)}
>
{t.label}
</button>
))}
</div>
<Button variant="ai" leadingIcon={<IconSpark size={16} />} onClick={aiTranslateAll} disabled={translating}>
{translating ? '번역 중…' : 'AI 자동 번역'}
</Button>
</div>
<div className="kx-table-scroll">
<table className="kx-i18n-table">
<thead>
<tr>
<th style={{ width: '26%' }}> / (KO)</th>
<th> (Target)</th>
<th className="kx-i18n-cell--c" style={{ width: 96 }}>
</th>
<th className="kx-i18n-cell--r" style={{ width: 96 }}>
</th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const m = STATUS_META[r.en.status];
return (
<tr
key={r.id}
className={r.id === selected.id ? 'is-active' : ''}
onClick={() => setSelectedId(r.id)}
>
<td>
<div className="kx-i18n-row__key">{r.key}</div>
<div className="kx-i18n-row__id">{r.id}</div>
</td>
<td className={r.en.text ? '' : 'kx-i18n-row__trans--empty'}>
{r.en.text || '번역 대기 중…'}
</td>
<td className="kx-i18n-cell--c">
<span className={`kx-cms-pill ${m.cls}`}>{m.label}</span>
</td>
<td className="kx-i18n-cell--r">{r.en.updated}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
{/* 우: 병렬 편집기 */}
<aside className="kx-card kx-i18n-editor" aria-label="번역 편집">
<div className="kx-i18n-editor__head">
<span className="kx-cms-sublabel"> 편집: 원문() ()</span>
<button className="kx-icon-btn" title="닫기" aria-label="편집 닫기">
<IconClose size={16} />
</button>
</div>
<div className="kx-cms-field">
<span className="kx-cms-label">Selected ID</span>
<div className="kx-i18n-row__id" style={{ color: 'var(--color-primary-700)', fontWeight: 700 }}>
{selected.id}
</div>
</div>
<div className="kx-cms-field">
<span className="kx-cms-label"> (KO)</span>
<div className="kx-i18n-src">{selected.source}</div>
</div>
<div className="kx-cms-field">
<span className="kx-cms-label"> (EN)</span>
<textarea
className="kx-cms-textarea kx-i18n-editor__ta"
value={selected.en.text}
onChange={(e) => updateSelectedText(e.target.value)}
placeholder="번역을 입력하거나 AI 재생성하세요"
/>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="ghost"
leadingIcon={<IconSpark size={14} />}
onClick={() => updateSelectedText(`[AI] ${selected.source}`)}
>
AI
</Button>
</div>
</div>
<div className="kx-cms-field">
<span className="kx-cms-sublabel">Glossary</span>
<div className="kx-i18n-glossary">
{GLOSSARY.map((g) => (
<div className="kx-i18n-glossary__row" key={g.ko}>
<b>{g.ko}</b>
<span> {g.en}</span>
</div>
))}
</div>
</div>
<div className="kx-cms-field">
<span className="kx-cms-sublabel">Tone Guide</span>
<div className="kx-i18n-tone">
<div className="kx-i18n-tone__t"> (Formal Business)</div>
<p className="kx-i18n-tone__p">
AI 추천: 공식
.
</p>
</div>
</div>
<div className="kx-i18n-editor__actions">
<Button variant="secondary"></Button>
<Button onClick={applyReviewed}> · </Button>
</div>
</aside>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,286 +0,0 @@
/*
* SCR-38 ( ). Stitch scr_38_contractor_booth_dashboard .
* 대상: 장치·. KPI + ( ) + · .
* M3·M15 ("샘플 데이터" ). API fetch .
* ("백엔드 연동 예정").
* 보안: 옥션 ( ).
*/
import { Button } from '../../components/ui/Button';
import { DdayChip } from '../../components/ui/Badge';
import {
IconCheckCircle,
IconDocument,
IconGrid,
IconSettings,
IconUsers,
IconWarning,
} from '../../components/ui/icons';
import { SampleBadge, useToast } from '../auction/aucShared';
import { formatWon } from '../auction/sampleAuction';
import './contractor.css';
interface Kpi {
label: string;
value: number;
tone?: 'normal' | 'success' | 'error';
}
const KPIS: Kpi[] = [
{ label: '수주 부스', value: 8 },
{ label: '진행 옥션', value: 3 },
{ label: '응찰 대기', value: 2 },
{ label: '시공 진행', value: 5, tone: 'success' },
{ label: '마감 임박', value: 1, tone: 'error' },
];
const STEPS = ['설계', '규정검증', '반입', '시공', '검수'];
interface AwardedBooth {
event: string;
eventTag: string;
name: string;
booth: string;
client: string;
dday: number | null;
stepIndex: number; // 현재 단계(0-based)
variant: 'primary' | 'sub';
progressPct?: number;
statusText?: string;
}
const BOOTHS: AwardedBooth[] = [
{
event: 'Smart Factory 2026',
eventTag: 'Smart Factory 2026',
name: '스마트팩토리 코리아 2026',
booth: 'A-102',
client: '(주)한빛로보틱스',
dday: 14,
stepIndex: 1,
variant: 'primary',
},
{
event: 'K-Medical Expo 2026',
eventTag: 'K-Medical Expo 2026',
name: 'K-메디컬 엑스포',
booth: 'D-405',
client: '세종메디텍',
dday: null,
stepIndex: 0,
variant: 'sub',
progressPct: 15,
statusText: '시공 예정',
},
];
interface BidRow {
name: string;
price: number;
rank: number;
dday: number;
dim?: boolean;
}
const BIDS: BidRow[] = [
{ name: '전기공사 · Hall 1', price: 24_500_000, rank: 2, dday: 2 },
{ name: '네트워크 가설 · Hall 3', price: 12_000_000, rank: 1, dday: 5 },
{ name: '조명 렌탈 · 전시장 전체', price: 45_000_000, rank: 5, dday: 1, dim: true },
];
interface FeedItem {
text: string;
meta: string;
tone: 'ok' | 'warn' | 'info';
}
const FEED: FeedItem[] = [
{ text: 'Booth A-102 규정 검증 완료', meta: '방금 전 · KINTEX 운영국', tone: 'ok' },
{ text: '안전 교육 갱신 필요 알림', meta: '2시간 전 · 시스템', tone: 'warn' },
{ text: '신규 옥션 공고: 제2전시장 외벽 래핑', meta: '어제 · 입찰관리부', tone: 'info' },
];
export function ContractorBoothDashboardPage() {
const { show, node: toast } = useToast();
return (
<div className="kx-page">
<header className="kx-cbd__head">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h1 className="kx-cbd__title"> </h1>
<SampleBadge />
</div>
<p className="kx-cbd__subtitle">() · </p>
</div>
<Button variant="secondary" onClick={() => show('옥션 참여 목록 — 백엔드 연동 예정')}>
</Button>
</header>
{/* KPI 밴드 */}
<section className="kx-cbd__kpis" aria-label="핵심 지표">
{KPIS.map((k) => (
<div
key={k.label}
className={`kx-kpi ${k.tone === 'error' ? 'kx-kpi--error' : ''}`}
>
<span className="kx-kpi__label">
{k.tone === 'error' && <IconWarning size={14} />}
{k.label}
</span>
<strong
className="kx-kpi__value tnum"
style={k.tone === 'success' ? { color: 'var(--color-success)' } : undefined}
>
{k.value}
</strong>
</div>
))}
</section>
<div className="kx-cbd__grid">
{/* 좌 — 수주 부스 리스트 */}
<section className="kx-cbd__col" aria-label="수주 부스 리스트">
<div className="kx-card__head" style={{ marginBottom: 0 }}>
<h2 style={{ fontSize: 'var(--fs-h2)' }}> </h2>
<Button variant="ghost" onClick={() => show('전체 수주 부스 — 백엔드 연동 예정')}>
</Button>
</div>
{BOOTHS.map((b) => (
<BoothCard key={b.booth} booth={b} onAction={(label) => show(`${label} — 백엔드 연동 예정`)} />
))}
</section>
{/* 우 — 위젯 */}
<aside className="kx-cbd__col" aria-label="옥션·알림 위젯">
<div className="kx-cbd-widget">
<div className="kx-card__head">
<h2 style={{ fontSize: 'var(--fs-h3)' }}> </h2>
</div>
<div className="kx-cbd-widget__rows">
{BIDS.map((bid) => (
<BidItem key={bid.name} bid={bid} />
))}
</div>
<Button
variant="ghost"
block
onClick={() => show('옥션 대시보드 — 백엔드 연동 예정')}
style={{ marginTop: 16 }}
>
</Button>
</div>
<div className="kx-cbd-widget">
<div className="kx-card__head">
<h2 style={{ fontSize: 'var(--fs-h3)' }}> </h2>
</div>
<div className="kx-feed">
{FEED.map((f) => (
<div key={f.text} className="kx-feed__item">
<span className={`kx-feed__dot kx-feed__dot--${f.tone}`}>
{f.tone === 'ok' ? (
<IconCheckCircle size={14} />
) : f.tone === 'warn' ? (
<IconWarning size={14} />
) : (
<IconDocument size={14} />
)}
</span>
<p className="kx-feed__text">{f.text}</p>
<p className="kx-feed__meta">{f.meta}</p>
</div>
))}
</div>
</div>
</aside>
</div>
{toast}
</div>
);
}
function BoothCard({ booth, onAction }: { booth: AwardedBooth; onAction: (label: string) => void }) {
return (
<article className={`kx-booth ${booth.variant === 'sub' ? 'kx-booth--sub' : ''}`}>
<div className="kx-booth__top">
<div>
<span className="kx-booth__event-tag">{booth.eventTag}</span>
<h4 className="kx-booth__name">{booth.name}</h4>
<div className="kx-booth__meta">
<span>
<IconGrid size={14} /> Booth {booth.booth}
</span>
<span>
<IconUsers size={14} /> {booth.client}
</span>
</div>
</div>
<div className="kx-booth__right">
{booth.dday != null ? (
<>
<p className="kx-booth__right-label"> </p>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 4 }}>
<DdayChip dday={booth.dday} />
</div>
</>
) : (
<>
<p className="kx-booth__right-label"></p>
<p className="kx-booth__right-value" style={{ color: 'var(--color-success)', fontSize: 'var(--fs-body)' }}>
{booth.statusText}
</p>
</>
)}
</div>
</div>
{booth.variant === 'primary' ? (
<>
<div className="kx-stepper" role="list" aria-label="시공 단계">
{STEPS.map((label, i) => {
const state =
i < booth.stepIndex ? 'done' : i === booth.stepIndex ? 'current' : 'todo';
return (
<div key={label} role="listitem" className={`kx-step kx-step--${state}`}>
<span className="kx-step__dot">{i + 1}</span>
<span className="kx-step__label">{label}</span>
</div>
);
})}
</div>
<div className="kx-booth__actions">
<Button variant="primary" leadingIcon={<IconSettings size={16} />} onClick={() => onAction('설계 스튜디오')}>
</Button>
<Button variant="secondary" leadingIcon={<IconDocument size={16} />} onClick={() => onAction('규정 리포트')}>
</Button>
<Button variant="secondary" onClick={() => onAction('반입 예약')}>
</Button>
</div>
</>
) : (
<div className="kx-booth__progress" aria-label={`진행률 ${booth.progressPct}%`}>
<span style={{ width: `${booth.progressPct ?? 0}%` }} />
</div>
)}
</article>
);
}
function BidItem({ bid }: { bid: BidRow }) {
const rankCls = bid.rank === 1 ? 'kx-rankpill--1' : bid.rank <= 3 ? 'kx-rankpill--mid' : 'kx-rankpill--low';
return (
<div className={`kx-cbd-bid ${bid.dim ? 'kx-cbd-bid--dim' : ''}`}>
<div>
<p className="kx-cbd-bid__name">{bid.name}</p>
<p className="kx-cbd-bid__price tnum"> : {formatWon(bid.price)}</p>
</div>
<div className="kx-cbd-bid__right">
<span className={`kx-rankpill ${rankCls}`}>{bid.rank}</span>
<DdayChip dday={bid.dday} />
</div>
</div>
);
}

View File

@ -1,308 +0,0 @@
/*
* SCR-38 업체 수주 부스 대시보드(업체 포털). 토큰은 styles/tokens.css(§1) 참조.
*/
@import '../shared.css';
.kx-cbd__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
}
.kx-cbd__title {
font-size: var(--fs-h1);
line-height: var(--lh-h1);
color: var(--color-neutral-900);
font-weight: 700;
}
.kx-cbd__subtitle {
font-size: var(--fs-body);
color: var(--color-neutral-500);
margin-top: 2px;
}
/* KPI 밴드 */
.kx-cbd__kpis {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--space-3);
}
@media (max-width: 1080px) {
.kx-cbd__kpis {
grid-template-columns: repeat(2, 1fr);
}
}
/* 본문 2열 */
.kx-cbd__grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr);
gap: var(--space-5);
align-items: start;
}
@media (max-width: 1080px) {
.kx-cbd__grid {
grid-template-columns: 1fr;
}
}
.kx-cbd__col {
display: flex;
flex-direction: column;
gap: var(--space-4);
min-width: 0;
}
/* 수주 부스 카드 */
.kx-booth {
border: var(--border-card);
border-left: 4px solid var(--color-primary-600);
border-radius: var(--radius-lg);
background: var(--color-white);
padding: var(--space-4);
}
.kx-booth--sub {
border-left-color: var(--color-success);
opacity: 0.92;
}
.kx-booth__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.kx-booth__event-tag {
display: inline-block;
margin-bottom: 6px;
padding: 2px 10px;
border-radius: var(--radius-sm);
font-size: var(--fs-caption);
font-weight: 600;
background: var(--color-primary-100);
color: var(--color-primary-700);
}
.kx-booth--sub .kx-booth__event-tag {
background: #e6f4ee;
color: var(--color-success);
}
.kx-booth__name {
font-size: var(--fs-h3);
font-weight: 700;
color: var(--color-neutral-900);
}
.kx-booth__meta {
display: flex;
gap: var(--space-4);
margin-top: 4px;
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-booth__meta span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.kx-booth__right {
text-align: right;
}
.kx-booth__right-label {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-booth__right-value {
font-size: var(--fs-h3);
font-weight: 700;
}
/* 시공 스테퍼 */
.kx-stepper {
position: relative;
display: flex;
justify-content: space-between;
margin: var(--space-5) var(--space-2) var(--space-5);
}
.kx-stepper::before {
content: '';
position: absolute;
top: 16px;
left: 16px;
right: 16px;
height: 2px;
background: var(--color-neutral-200);
z-index: 0;
}
.kx-step {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.kx-step__dot {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--fs-caption);
font-weight: 700;
background: var(--color-neutral-100);
color: var(--color-neutral-500);
}
.kx-step--done .kx-step__dot {
background: var(--color-primary-700);
color: var(--color-white);
}
.kx-step--current .kx-step__dot {
background: var(--color-primary-600);
color: var(--color-white);
box-shadow: 0 0 0 4px var(--color-primary-100);
}
.kx-step__label {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-step--done .kx-step__label,
.kx-step--current .kx-step__label {
color: var(--color-primary-700);
font-weight: 600;
}
.kx-booth__actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.kx-booth__actions .kx-btn {
flex: 1;
min-width: 130px;
}
.kx-booth__progress {
height: 6px;
border-radius: var(--radius-pill);
background: var(--color-neutral-100);
overflow: hidden;
}
.kx-booth__progress span {
display: block;
height: 100%;
background: var(--color-success);
border-radius: var(--radius-pill);
}
/* 옥션 참여 위젯 */
.kx-cbd-widget {
border: var(--border-card);
border-radius: var(--radius-lg);
background: var(--color-white);
padding: var(--space-4);
}
.kx-cbd-widget__rows {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.kx-cbd-bid {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
border: var(--border-card);
border-radius: var(--radius-sm);
background: var(--color-neutral-050);
}
.kx-cbd-bid--dim {
opacity: 0.6;
}
.kx-cbd-bid__name {
font-size: var(--fs-body);
font-weight: 600;
color: var(--color-neutral-900);
}
.kx-cbd-bid__price {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
margin-top: 2px;
}
.kx-cbd-bid__right {
text-align: right;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.kx-rankpill {
font-size: 11px;
font-weight: 700;
padding: 1px 8px;
border-radius: var(--radius-pill);
}
.kx-rankpill--1 {
background: var(--color-primary-100);
color: var(--color-primary-700);
}
.kx-rankpill--mid {
background: var(--color-ai-surface);
color: var(--color-ai-accent);
}
.kx-rankpill--low {
background: var(--color-neutral-100);
color: var(--color-neutral-700);
}
/* 알림 피드 타임라인 */
.kx-feed {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.kx-feed::before {
content: '';
position: absolute;
left: 11px;
top: 4px;
bottom: 4px;
width: 2px;
background: var(--color-neutral-200);
}
.kx-feed__item {
position: relative;
padding-left: 34px;
}
.kx-feed__dot {
position: absolute;
left: 0;
top: 0;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.kx-feed__dot--ok {
background: var(--color-primary-100);
color: var(--color-primary-700);
}
.kx-feed__dot--warn {
background: #fef3f2;
color: var(--color-error);
}
.kx-feed__dot--info {
background: var(--color-neutral-100);
color: var(--color-neutral-700);
}
.kx-feed__text {
font-size: var(--fs-body);
color: var(--color-neutral-900);
}
.kx-feed__meta {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
margin-top: 2px;
}

View File

@ -1,326 +0,0 @@
/*
* SCR-22 · [M6]. Stitch scr_22_docs_milestones .
* 진입: 주최자 / "서류·마일스톤" (design.md §3 SCR-22).
* : (D-150D-0) (8··D-·)
* AI (··kxwp ) + .
* M6 . "샘플 데이터" + // 3 .
* fetch (port_ops_docs.md § ).
*/
import { useCallback, useEffect, useState } from 'react';
import { AiLabel, DdayChip, StatusBadge, type FlowStatus } from '../../components/ui/Badge';
import { Button } from '../../components/ui/Button';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import {
IconCheck,
IconDocument,
IconSpark,
IconTrendUp,
IconWarning,
} from '../../components/ui/icons';
import './docs.css';
type StepState = 'done' | 'active' | 'todo';
interface Milestone {
label: string;
sub: string;
state: StepState;
}
type DocAction = 'view' | 'hwp' | 'write' | 'fix';
interface RequiredDoc {
id: string;
name: string;
/** FlowStatus 또는 'pending'(준비중 — 배지 enum 밖이라 별도 표기) */
status: FlowStatus | 'pending';
dday?: number;
action?: DocAction;
}
interface AiIssue {
tone: 'warn' | 'info';
title: string;
desc: string;
}
const MILESTONES: Milestone[] = [
{ label: 'D-150 배정', sub: '완료', state: 'done' },
{ label: 'D-30 사전협의', sub: '완료', state: 'done' },
{ label: 'D-25 유틸리티', sub: '완료', state: 'done' },
{ label: 'D-7 신고서류', sub: '진행중', state: 'active' },
{ label: 'D-0 개장', sub: '대기', state: 'todo' },
];
const DOCS: RequiredDoc[] = [
{ id: 'd1', name: '행사운영계획서', status: 'approved', action: 'view' },
{ id: 'd2', name: '부스배치도', status: 'submitted', action: 'hwp' },
{ id: 'd3', name: '재해대처계획서', status: 'draft', dday: 7, action: 'write' },
{ id: 'd4', name: '리깅 구조계산서', status: 'rejected', action: 'fix' },
{ id: 'd5', name: '방화관리 책임서약서', status: 'pending' },
{ id: 'd6', name: '주차관리 신청서', status: 'pending' },
{ id: 'd7', name: '보안요원 배치계획', status: 'pending' },
{ id: 'd8', name: '위험물 반입신고서', status: 'pending' },
];
const AI_ISSUES: AiIssue[] = [
{
tone: 'warn',
title: '데이터 불일치 감지',
desc: '부스배치도 부스 수 486 ≠ 운영계획서 510 (불일치)',
},
{ tone: 'info', title: '필수요소 누락', desc: '재해대처계획서 필수요소 누락 2건' },
];
const ACTION_LABEL: Record<DocAction, string> = {
view: '보기',
hwp: 'HWP 생성',
write: '작성',
fix: '수정',
};
/** 로컬 샘플 데이터의 로딩/성공/에러 3상태를 시뮬레이션(실 API 호출 없음). */
function useSampleLoad<T>(value: T) {
const [status, setStatus] = useState<'loading' | 'error' | 'success'>('loading');
const load = useCallback(() => {
setStatus('loading');
const t = setTimeout(() => setStatus('success'), 320);
return () => clearTimeout(t);
}, []);
useEffect(load, [load]);
return { status, data: status === 'success' ? value : null, retry: load };
}
interface DocsData {
docs: RequiredDoc[];
issues: AiIssue[];
progressPct: number;
}
const DOCS_DATA: DocsData = { docs: DOCS, issues: AI_ISSUES, progressPct: 62.5 };
/**
* SCR-22 · .
* onOpenAuthoring: [] SCR-23 ( AppShell ).
*/
export function DocsMilestonePage({ onOpenAuthoring }: { onOpenAuthoring?: (docId: string) => void }) {
const { status, data, retry } = useSampleLoad(DOCS_DATA);
const [toast, setToast] = useState<string | null>(null);
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(null), 2200);
return () => clearTimeout(t);
}, [toast]);
function handleAction(doc: RequiredDoc) {
if (doc.action === 'write') {
if (onOpenAuthoring) onOpenAuthoring(doc.id);
else setToast(`${doc.name} 작성 화면(SCR-23)으로 이동`);
return;
}
setToast(`${doc.name} · ${ACTION_LABEL[doc.action ?? 'view']} (샘플)`);
}
const activeIdx = MILESTONES.findIndex((m) => m.state === 'active');
const fillPct = activeIdx <= 0 ? 0 : (activeIdx / (MILESTONES.length - 1)) * 100;
return (
<div className="kx-page">
<header className="kx-doc__head">
<div>
<h1 className="kx-doc__title">·</h1>
<p className="kx-doc__subtitle"> · AI 2026 </p>
</div>
<div className="kx-doc__head-badges">
<span className="kx-bi__degraded"> </span>
</div>
</header>
{/* 마일스톤 진행 바 */}
<section className="kx-card" aria-label="전시 마일스톤 현황">
<div className="kx-doc__miles-head">
<span className="kx-doc__miles-title">
<IconDocument size={18} />
</span>
<DdayChip dday={7} />
</div>
<div className="kx-doc__stepper">
<span className="kx-doc__track" aria-hidden="true" />
<span
className="kx-doc__track-fill"
aria-hidden="true"
style={{ width: `calc((100% - var(--space-5) * 2) * ${fillPct / 100})` }}
/>
{MILESTONES.map((m) => (
<div key={m.label} className={`kx-doc__step is-${m.state}`}>
<span className="kx-doc__node">
{m.state === 'done' ? (
<IconCheck size={20} />
) : (
<span className="tnum" aria-hidden="true">
{m.state === 'active' ? '⏳' : '·'}
</span>
)}
</span>
<span className="kx-doc__step-label">{m.label}</span>
<span className="kx-doc__step-sub">{m.sub}</span>
</div>
))}
</div>
</section>
{/* 로딩 3상태 */}
{status === 'loading' && (
<div className="kx-doc__grid" aria-busy="true">
<section className="kx-card">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} style={{ padding: '12px 0' }}>
<Skeleton height={22} />
</div>
))}
</section>
<section className="kx-card">
<Skeleton height={120} />
</section>
</div>
)}
{status === 'error' && (
<ErrorState message="서류 현황을 불러오지 못했습니다." onRetry={retry} />
)}
{status === 'success' && data && (
<div className="kx-doc__grid">
{/* 신고서류 체크리스트 */}
<section className="kx-card kx-doc__list" aria-label="신고서류 체크리스트">
<div className="kx-doc__list-head">
<div>
<h2> </h2>
<p> </p>
</div>
<ChecklistProgress docs={data.docs} />
</div>
{data.docs.length === 0 ? (
<EmptyState
title="구성된 서류가 없습니다"
description="행사를 생성하면 마일스톤·서류가 자동 구성됩니다."
/>
) : (
data.docs.map((doc) => <DocRow key={doc.id} doc={doc} onAction={handleAction} />)
)}
</section>
{/* 우측 — AI 서류 검수 + 공정률 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<section className="kx-doc__ai" aria-label="AI 서류 검수">
<div className="kx-doc__ai-head">
<IconSpark size={18} />
<h2>AI </h2>
<AiLabel>AI </AiLabel>
</div>
{data.issues.map((iss) => (
<div key={iss.title} className={`kx-doc__issue kx-doc__issue--${iss.tone}`}>
<span className="kx-doc__issue-icon" aria-hidden="true">
<IconWarning size={18} />
</span>
<div>
<p className="kx-doc__issue-title">{iss.title}</p>
<p className="kx-doc__issue-desc">{iss.desc}</p>
</div>
</div>
))}
<div className="kx-doc__note">
<p className="kx-doc__note-title">
<IconDocument size={14} /> kxwp
</p>
<p className="kx-doc__note-body">
(HWP/PDF) , kxwp
.
</p>
</div>
<Button
variant="ai"
block
leadingIcon={<IconSpark size={16} />}
onClick={() => setToast('AI 자동 문서 생성 요청 (샘플)')}
>
</Button>
</section>
<section className="kx-doc__stat" aria-label="전체 공정률">
<p className="kx-doc__stat-label"> </p>
<p className="kx-doc__stat-value tnum">{data.progressPct}%</p>
<p className="kx-doc__stat-delta">
<IconTrendUp size={16} /> 12%
</p>
</section>
</div>
</div>
)}
{toast && (
<div className="kx-doc__toast" role="status">
{toast}
</div>
)}
</div>
);
}
function ChecklistProgress({ docs }: { docs: RequiredDoc[] }) {
const total = docs.length;
// '준비중(pending)'을 제외한 진행/완료 건을 진척으로 집계.
const done = docs.filter((d) => d.status !== 'pending').length;
const pct = total ? Math.round((done / total) * 100) : 0;
return (
<div className="kx-doc__progress">
<span className="kx-doc__progress-num tnum">
{done}/{total}
</span>
<span className="kx-doc__progress-track" aria-hidden="true">
<span className="kx-doc__progress-fill" style={{ width: `${pct}%` }} />
</span>
</div>
);
}
function DocRow({ doc, onAction }: { doc: RequiredDoc; onAction: (d: RequiredDoc) => void }) {
const isPending = doc.status === 'pending';
const rowCls = [
'kx-doc__row',
doc.action === 'write' ? 'kx-doc__row--active' : '',
isPending ? 'kx-doc__row--muted' : '',
]
.filter(Boolean)
.join(' ');
const btnVariant =
doc.action === 'write'
? 'primary'
: doc.action === 'fix'
? 'danger'
: 'secondary';
return (
<div className={rowCls}>
<div className="kx-doc__row-main">
<span className="kx-doc__doc-icon" aria-hidden="true">
<IconDocument size={18} />
</span>
<span className="kx-doc__doc-name">{doc.name}</span>
{doc.dday != null && <DdayChip dday={doc.dday} />}
</div>
<div className="kx-doc__row-actions">
{doc.status === 'pending' ? (
<span className="kx-doc__pill-pending"></span>
) : (
<StatusBadge status={doc.status} />
)}
{doc.action && (
<Button variant={btnVariant} onClick={() => onAction(doc)}>
{ACTION_LABEL[doc.action]}
</Button>
)}
</div>
</div>
);
}

View File

@ -1,443 +0,0 @@
/*
* SCR-23 ( HWP/PDF) [M6 / F022]. Stitch scr_23_report_authoring .
* 진입: SCR-22 · "[작성]" (design.md §3 SCR-23).
* : ( + AI + ) A4
* ( ·HWP ·PDF · / kxwp ).
* M6· 릿 . // 3 , fetch .
*/
import { useCallback, useEffect, useState } from 'react';
import { AiLabel } from '../../components/ui/Badge';
import { Button } from '../../components/ui/Button';
import { ErrorState, Skeleton } from '../../components/ui/States';
import { IconChevronDown, IconSpark, IconWarning } from '../../components/ui/icons';
import './docs.css';
interface ReportForm {
eventName: string;
eventDate: string;
venue: string;
visitors: string;
safetyManager: string;
safetyPhone: string;
guardCount: string;
fireStation: string;
policeStation: string;
medical: string;
hazardous: string;
}
const EMPTY_FORM: ReportForm = {
eventName: '',
eventDate: '',
venue: '제1전시장 1~3홀',
visitors: '',
safetyManager: '',
safetyPhone: '',
guardCount: '',
fireStation: '',
policeStation: '',
medical: '',
hazardous: '',
};
const AUTOFILL: ReportForm = {
eventName: '2026 스마트팩토리 코리아',
eventDate: '2026-08-11',
venue: '제1전시장 1~3홀',
visitors: '15000',
safetyManager: '김안전',
safetyPhone: '031-810-8000',
guardCount: '45',
fireStation: '고양소방서 (031-909-0119)',
policeStation: '일산동부경찰서 (031-8073-0112)',
medical: '현장 의무실 1개소 · 상주 간호사 2명',
hazardous: 'LPG 소형용기 2조, 배터리 시연 셀 (안전관리자 상시 배치)',
};
const VENUES = [
'제1전시장 1~3홀',
'제1전시장 4~5홀',
'제2전시장 7~8홀',
'제2전시장 9~10홀',
];
type SectionId = 'overview' | 'safety' | 'contact' | 'hazard';
const SECTIONS: { id: SectionId; label: string }[] = [
{ id: 'overview', label: '1. 행사 개요' },
{ id: 'safety', label: '2. 안전 관리 담당' },
{ id: 'contact', label: '3. 비상 연락 체계' },
{ id: 'hazard', label: '4. 위험물 목록' },
];
/** 로컬 샘플 데이터의 로딩/성공/에러 3상태 시뮬레이션(실 API 호출 없음). */
function useSampleReady() {
const [status, setStatus] = useState<'loading' | 'error' | 'success'>('loading');
const load = useCallback(() => {
setStatus('loading');
const t = setTimeout(() => setStatus('success'), 300);
return () => clearTimeout(t);
}, []);
useEffect(load, [load]);
return { status, retry: load };
}
/** 미리보기용 날짜 포맷: "2026-08-11" → "2026. 08. 11." */
function fmtDate(v: string): string {
if (!v) return '—';
const [y, m, d] = v.split('-');
return `${y}. ${m}. ${d}.`;
}
export function ReportAuthoringPage({ docType = '재해대처계획서' }: { docType?: string }) {
const { status, retry } = useSampleReady();
const [form, setForm] = useState<ReportForm>(EMPTY_FORM);
const [open, setOpen] = useState<Record<SectionId, boolean>>({
overview: true,
safety: false,
contact: false,
hazard: false,
});
const [showErrors, setShowErrors] = useState(false);
const [toast, setToast] = useState<string | null>(null);
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(null), 2200);
return () => clearTimeout(t);
}, [toast]);
const set = <K extends keyof ReportForm>(k: K, v: ReportForm[K]) =>
setForm((f) => ({ ...f, [k]: v }));
const toggle = (id: SectionId) => setOpen((o) => ({ ...o, [id]: !o[id] }));
const invalid = {
eventName: !form.eventName.trim(),
eventDate: !form.eventDate,
safetyManager: !form.safetyManager.trim(),
};
const hasError = Object.values(invalid).some(Boolean);
function submit() {
if (hasError) {
setShowErrors(true);
setOpen((o) => ({ ...o, overview: true, safety: true }));
setToast('필수 항목을 입력하세요.');
return;
}
setToast('제출용 파일 생성 완료 — kxwp에 업로드하세요. (샘플)');
}
if (status === 'loading') {
return (
<div className="kx-page" aria-busy="true">
<div className="kx-rep">
<div className="kx-rep__form">
<Skeleton height={56} />
<Skeleton height={220} />
</div>
<div className="kx-card">
<Skeleton height={420} />
</div>
</div>
</div>
);
}
if (status === 'error') {
return (
<div className="kx-page">
<ErrorState message="작성 서식을 불러오지 못했습니다." onRetry={retry} />
</div>
);
}
return (
<div className="kx-page">
<header className="kx-doc__head">
<div>
<h1 className="kx-doc__title">{docType} </h1>
<p className="kx-doc__subtitle"> · HWP/PDF</p>
</div>
<div className="kx-doc__head-badges">
<span className="kx-bi__degraded"> </span>
</div>
</header>
<div className="kx-rep">
{/* 좌: 폼 */}
<form className="kx-rep__form" onSubmit={(e) => e.preventDefault()}>
<Button
type="button"
variant="ai"
className="kx-rep__autofill"
leadingIcon={<IconSpark size={16} />}
onClick={() => {
setForm(AUTOFILL);
setShowErrors(false);
setToast('행사 데이터로 자동 채움 완료 (AI)');
}}
>
</Button>
<AiLabel>AI </AiLabel>
{SECTIONS.map((sec) => (
<div key={sec.id} className={`kx-rep__acc ${open[sec.id] ? 'is-open' : ''}`}>
<button
type="button"
className="kx-rep__acc-head"
aria-expanded={open[sec.id]}
onClick={() => toggle(sec.id)}
>
<h3>{sec.label}</h3>
<span className="kx-rep__chev" aria-hidden="true">
<IconChevronDown size={20} />
</span>
</button>
{open[sec.id] && (
<div className="kx-rep__acc-body">
{sec.id === 'overview' && (
<>
<label className="kx-doc__field kx-doc__field--full">
<span className="kx-doc__label"> *</span>
<input
className={`kx-doc__input ${showErrors && invalid.eventName ? 'is-invalid' : ''}`}
value={form.eventName}
placeholder="예: 2026 스마트팩토리 코리아"
onChange={(e) => set('eventName', e.target.value)}
/>
{showErrors && invalid.eventName && (
<span className="kx-doc__err"> .</span>
)}
</label>
<label className="kx-doc__field">
<span className="kx-doc__label"> *</span>
<input
type="date"
className={`kx-doc__input ${showErrors && invalid.eventDate ? 'is-invalid' : ''}`}
value={form.eventDate}
onChange={(e) => set('eventDate', e.target.value)}
/>
{showErrors && invalid.eventDate && (
<span className="kx-doc__err"> .</span>
)}
</label>
<label className="kx-doc__field">
<span className="kx-doc__label"></span>
<select
className="kx-doc__select"
value={form.venue}
onChange={(e) => set('venue', e.target.value)}
>
{VENUES.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</label>
<label className="kx-doc__field kx-doc__field--full">
<span className="kx-doc__label"> ()</span>
<input
type="number"
min={0}
className="kx-doc__input"
value={form.visitors}
placeholder="15000"
onChange={(e) => set('visitors', e.target.value)}
/>
</label>
</>
)}
{sec.id === 'safety' && (
<>
<label className="kx-doc__field">
<span className="kx-doc__label"> *</span>
<input
className={`kx-doc__input ${showErrors && invalid.safetyManager ? 'is-invalid' : ''}`}
value={form.safetyManager}
placeholder="성명"
onChange={(e) => set('safetyManager', e.target.value)}
/>
{showErrors && invalid.safetyManager && (
<span className="kx-doc__err"> .</span>
)}
</label>
<label className="kx-doc__field">
<span className="kx-doc__label"> </span>
<input
className="kx-doc__input"
value={form.safetyPhone}
placeholder="031-000-0000"
onChange={(e) => set('safetyPhone', e.target.value)}
/>
</label>
<label className="kx-doc__field kx-doc__field--full">
<span className="kx-doc__label"> ()</span>
<input
type="number"
min={0}
className="kx-doc__input"
value={form.guardCount}
placeholder="45"
onChange={(e) => set('guardCount', e.target.value)}
/>
</label>
</>
)}
{sec.id === 'contact' && (
<>
<label className="kx-doc__field kx-doc__field--full">
<span className="kx-doc__label"> </span>
<input
className="kx-doc__input"
value={form.fireStation}
placeholder="소방서명 · 연락처"
onChange={(e) => set('fireStation', e.target.value)}
/>
</label>
<label className="kx-doc__field kx-doc__field--full">
<span className="kx-doc__label"> </span>
<input
className="kx-doc__input"
value={form.policeStation}
placeholder="경찰서명 · 연락처"
onChange={(e) => set('policeStation', e.target.value)}
/>
</label>
<label className="kx-doc__field kx-doc__field--full">
<span className="kx-doc__label">· </span>
<input
className="kx-doc__input"
value={form.medical}
placeholder="현장 의무실 · 상주 인력"
onChange={(e) => set('medical', e.target.value)}
/>
</label>
</>
)}
{sec.id === 'hazard' && (
<label className="kx-doc__field kx-doc__field--full">
<span className="kx-doc__label"> · </span>
<textarea
className="kx-doc__textarea"
value={form.hazardous}
placeholder="반입 위험물 종류·수량·안전관리 방안을 기재하세요."
onChange={(e) => set('hazardous', e.target.value)}
/>
</label>
)}
</div>
)}
</div>
))}
</form>
{/* 우: A4 실시간 미리보기 */}
<div className="kx-rep__preview" aria-label="문서 미리보기">
<article className="kx-rep__a4">
<div className="kx-rep__a4-title">
<h2>{docType}</h2>
</div>
<table className="kx-rep__table">
<tbody>
<tr>
<th> </th>
<td colSpan={3}>{form.eventName || '—'}</td>
</tr>
<tr>
<th> </th>
<td>{fmtDate(form.eventDate)}</td>
<th> </th>
<td>{form.venue}</td>
</tr>
<tr>
<th>/</th>
<td>KINTEX </td>
<th></th>
<td>
{form.visitors
? `${Number(form.visitors).toLocaleString()}`
: '—'}
</td>
</tr>
</tbody>
</table>
<section>
<h4>1. </h4>
<div className="kx-rep__box">
{form.safetyManager || '(미지정)'}
{form.safetyPhone ? ` (${form.safetyPhone})` : ''} {' '}
{form.guardCount || '0'} .
, .
</div>
</section>
<section>
<h4>2. </h4>
<ul>
<li> : {form.fireStation || '—'}</li>
<li> : {form.policeStation || '—'}</li>
<li>·: {form.medical || '—'}</li>
</ul>
</section>
<section>
<h4>3. </h4>
<ul>
<li> : 소방시설 </li>
<li> : 현장 119 </li>
<li> : 비상 </li>
</ul>
</section>
<section>
<h4>4. </h4>
<div className="kx-rep__box">{form.hazardous || '해당 없음'}</div>
</section>
<div className="kx-rep__sign">
<div className="kx-rep__sign-date">{fmtDate(form.eventDate)}</div>
<div className="kx-rep__sign-row">
<span className="kx-rep__sign-name"> </span>
<span className="kx-rep__stamp" aria-hidden="true">
KINTEX
<br />
</span>
</div>
</div>
</article>
</div>
</div>
{/* 하단 고정 액션 바 */}
<div className="kx-rep__bar">
<p className="kx-rep__bar-note">
<IconWarning size={16} /> kxwp
</p>
<div className="kx-rep__bar-actions">
<Button variant="ghost" onClick={() => setToast('임시 저장 완료 (샘플)')}>
</Button>
<Button variant="secondary" onClick={() => setToast('HWP 파일 생성 (샘플)')}>
HWP
</Button>
<Button variant="secondary" onClick={() => setToast('PDF 파일 생성 (샘플)')}>
PDF
</Button>
<Button onClick={submit}></Button>
</div>
</div>
{toast && (
<div className="kx-doc__toast" role="status">
{toast}
</div>
)}
</div>
);
}

View File

@ -1,600 +0,0 @@
/*
* SCR-22 서류·마일스톤 워크플로 · SCR-23 신고서류 작성 [M6] 전용 스타일.
* design.md §1 토큰만 참조(하드코딩 금지). shared.css 프리미티브(kx-page·kx-card·kx-bi__degraded·kx-barlist) 위에 얹는다.
* 클래스는 kx-doc__* / kx-rep__* 네임스페이스(다른 화면 CSS와 충돌 방지).
*/
@import '../shared.css';
/* ── 공용 페이지 헤더 ── */
.kx-doc__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
}
.kx-doc__title {
font-size: var(--fs-h1);
line-height: var(--lh-h1);
color: var(--color-neutral-900);
}
.kx-doc__subtitle {
margin-top: 2px;
font-size: var(--fs-body);
color: var(--color-neutral-500);
}
.kx-doc__head-badges {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
/* ── 공용 폼 필드(자립) ── */
.kx-doc__field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.kx-doc__field--full {
grid-column: 1 / -1;
}
.kx-doc__label {
font-size: var(--fs-caption);
font-weight: 600;
color: var(--color-neutral-500);
}
.kx-doc__input,
.kx-doc__select,
.kx-doc__textarea {
height: 40px;
padding: 0 var(--space-3);
border: var(--border-card);
border-radius: var(--radius-sm);
background: var(--color-white);
font-size: var(--fs-body);
color: var(--color-neutral-900);
font-family: inherit;
width: 100%;
}
.kx-doc__textarea {
height: auto;
min-height: 88px;
padding: var(--space-2) var(--space-3);
line-height: var(--lh-body);
resize: vertical;
}
.kx-doc__input:focus,
.kx-doc__select:focus,
.kx-doc__textarea:focus {
outline: 2px solid var(--color-primary-100);
border-color: var(--color-primary-600);
}
.kx-doc__input.is-invalid,
.kx-doc__select.is-invalid {
border-color: var(--color-error);
}
.kx-doc__err {
font-size: var(--fs-caption);
color: var(--color-error);
}
/* ============================================================
* SCR-22 서류·마일스톤
* ============================================================ */
/* 마일스톤 진행 바 */
.kx-doc__miles-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
.kx-doc__miles-title {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-h3);
color: var(--color-neutral-900);
}
.kx-doc__stepper {
position: relative;
display: flex;
justify-content: space-between;
padding: 0 var(--space-3);
}
.kx-doc__track,
.kx-doc__track-fill {
position: absolute;
top: 19px;
left: var(--space-5);
right: var(--space-5);
height: 2px;
background: var(--color-neutral-200);
z-index: 0;
}
.kx-doc__track-fill {
right: auto;
background: var(--color-primary-600);
}
.kx-doc__step {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 6px;
flex: 1;
}
.kx-doc__node {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-neutral-100);
border: 2px solid var(--color-neutral-200);
color: var(--color-neutral-500);
}
.kx-doc__step.is-done .kx-doc__node {
background: var(--color-primary-600);
border-color: var(--color-primary-600);
color: #fff;
}
.kx-doc__step.is-active .kx-doc__node {
background: var(--color-white);
border: 3px solid var(--color-warning);
color: var(--color-warning);
box-shadow: 0 0 0 4px #fff4e5;
}
.kx-doc__step-label {
font-size: var(--fs-caption);
font-weight: 700;
color: var(--color-neutral-700);
}
.kx-doc__step.is-active .kx-doc__step-label {
color: var(--color-warning);
}
.kx-doc__step-sub {
font-size: 11px;
color: var(--color-neutral-500);
}
/* 2열 그리드 */
.kx-doc__grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr);
gap: var(--space-4);
align-items: start;
}
@media (max-width: 1100px) {
.kx-doc__grid {
grid-template-columns: 1fr;
}
}
/* 서류 체크리스트 */
.kx-doc__list {
padding: 0;
overflow: hidden;
}
.kx-doc__list-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-4);
border-bottom: var(--border-card);
}
.kx-doc__list-head h2 {
font-size: var(--fs-h3);
color: var(--color-neutral-900);
}
.kx-doc__list-head p {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
margin-top: 2px;
}
.kx-doc__progress {
display: flex;
align-items: center;
gap: var(--space-2);
white-space: nowrap;
}
.kx-doc__progress-num {
font-size: var(--fs-caption);
font-weight: 700;
color: var(--color-primary-700);
}
.kx-doc__progress-track {
width: 120px;
height: 8px;
border-radius: var(--radius-pill);
background: var(--color-neutral-100);
overflow: hidden;
}
.kx-doc__progress-fill {
display: block;
height: 100%;
background: var(--color-primary-600);
}
.kx-doc__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-neutral-100);
}
.kx-doc__row:last-child {
border-bottom: none;
}
.kx-doc__row--active {
background: var(--color-ai-surface);
}
.kx-doc__row--muted {
opacity: 0.6;
}
.kx-doc__row-main {
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 0;
}
.kx-doc__doc-icon {
display: flex;
color: var(--color-neutral-500);
flex-shrink: 0;
}
.kx-doc__row--active .kx-doc__doc-icon {
color: var(--color-ai-accent);
}
.kx-doc__doc-name {
font-weight: 600;
color: var(--color-neutral-900);
font-size: var(--fs-body);
}
.kx-doc__row-actions {
display: flex;
align-items: center;
gap: var(--space-3);
flex-shrink: 0;
}
.kx-doc__pill-pending {
display: inline-flex;
padding: 1px 10px;
border-radius: var(--radius-pill);
font-size: var(--fs-caption);
font-weight: 600;
background: var(--color-neutral-100);
color: var(--color-neutral-500);
white-space: nowrap;
}
/* AI 서류 검수 카드 */
.kx-doc__ai {
border: 1px solid var(--color-neutral-200);
border-left: var(--accent-ai);
border-radius: var(--radius-lg);
background: var(--color-white);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-doc__ai-head {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--color-ai-accent);
}
.kx-doc__ai-head h2 {
font-size: var(--fs-h3);
}
.kx-doc__issue {
display: flex;
gap: var(--space-2);
padding: var(--space-3);
border-radius: var(--radius-sm);
border: var(--border-card);
}
.kx-doc__issue--warn {
background: #fef3f2;
border-color: #fecdca;
}
.kx-doc__issue--warn .kx-doc__issue-icon {
color: var(--color-error);
}
.kx-doc__issue--info {
background: var(--color-primary-050);
}
.kx-doc__issue--info .kx-doc__issue-icon {
color: var(--color-primary-600);
}
.kx-doc__issue-icon {
flex-shrink: 0;
margin-top: 1px;
}
.kx-doc__issue-title {
font-size: var(--fs-caption);
font-weight: 700;
color: var(--color-neutral-900);
}
.kx-doc__issue-desc {
font-size: var(--fs-caption);
color: var(--color-neutral-700);
line-height: 1.4;
margin-top: 2px;
}
.kx-doc__note {
background: var(--color-ai-surface);
border-radius: var(--radius-sm);
padding: var(--space-3);
}
.kx-doc__note-title {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--fs-caption);
font-weight: 700;
color: var(--color-ai-accent);
margin-bottom: 4px;
}
.kx-doc__note-body {
font-size: var(--fs-caption);
color: var(--color-neutral-700);
line-height: var(--lh-body);
}
/* 전체 공정률 카드 */
.kx-doc__stat {
border-radius: var(--radius-lg);
background: var(--color-primary-700);
color: #fff;
padding: var(--space-5);
}
.kx-doc__stat-label {
font-size: var(--fs-caption);
letter-spacing: 0.04em;
text-transform: uppercase;
opacity: 0.85;
}
.kx-doc__stat-value {
font-size: var(--fs-display);
line-height: 1;
font-weight: 700;
margin: var(--space-2) 0;
font-variant-numeric: tabular-nums;
}
.kx-doc__stat-delta {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--fs-caption);
color: var(--color-primary-100);
}
/* ============================================================
* SCR-23 신고서류 작성
* ============================================================ */
.kx-rep {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--space-4);
align-items: start;
}
@media (max-width: 1100px) {
.kx-rep {
grid-template-columns: 1fr;
}
}
.kx-rep__form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.kx-rep__autofill {
align-self: stretch;
}
/* 아코디언 */
.kx-rep__acc {
border: var(--border-card);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-white);
}
.kx-rep__acc.is-open {
border-color: var(--color-primary-600);
}
.kx-rep__acc-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
cursor: pointer;
background: var(--color-white);
border: none;
width: 100%;
text-align: left;
font-family: inherit;
}
.kx-rep__acc.is-open .kx-rep__acc-head {
background: var(--color-primary-600);
color: #fff;
}
.kx-rep__acc-head h3 {
font-size: var(--fs-h3);
color: inherit;
}
.kx-rep__acc-body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
padding: var(--space-4);
border-top: var(--border-card);
}
.kx-rep__chev {
transition: transform 0.15s ease;
}
.kx-rep__acc.is-open .kx-rep__chev {
transform: rotate(180deg);
}
/* A4 미리보기 */
.kx-rep__preview {
border: var(--border-card);
border-radius: var(--radius-lg);
background: var(--color-neutral-100);
padding: var(--space-5);
position: sticky;
top: 0;
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.kx-rep__a4 {
background: #fff;
box-shadow: var(--shadow-level2);
padding: 36px 32px;
max-width: 520px;
margin: 0 auto;
color: #141b2c;
font-size: 13px;
line-height: 1.6;
}
.kx-rep__a4-title {
text-align: center;
margin-bottom: 28px;
}
.kx-rep__a4-title h2 {
display: inline-block;
font-size: 22px;
font-weight: 700;
letter-spacing: 0.3em;
border-bottom: 3px solid #141b2c;
padding: 0 16px 6px;
}
.kx-rep__table {
border-collapse: collapse;
width: 100%;
border: 1.5px solid #141b2c;
margin-bottom: 24px;
}
.kx-rep__table th,
.kx-rep__table td {
border: 1px solid #141b2c;
padding: 7px 9px;
font-size: 12px;
text-align: left;
vertical-align: top;
}
.kx-rep__table th {
background: #f2f4f7;
font-weight: 700;
width: 22%;
white-space: nowrap;
}
.kx-rep__a4 h4 {
font-size: 14px;
font-weight: 700;
margin: 0 0 10px;
}
.kx-rep__a4 section {
margin-bottom: 22px;
}
.kx-rep__box {
border: 1px solid #141b2c;
padding: 12px;
min-height: 90px;
font-size: 12px;
}
.kx-rep__a4 ul {
margin: 0;
padding-left: 18px;
}
.kx-rep__a4 ul li {
margin-bottom: 5px;
}
.kx-rep__sign {
text-align: right;
margin-top: 36px;
}
.kx-rep__sign-date {
margin-bottom: 12px;
font-size: 13px;
}
.kx-rep__sign-row {
display: inline-flex;
align-items: center;
gap: 14px;
}
.kx-rep__sign-name {
font-size: 14px;
font-weight: 700;
}
.kx-rep__stamp {
width: 54px;
height: 54px;
border: 2px solid var(--color-error);
border-radius: 50%;
color: var(--color-error);
font-size: 10px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
transform: rotate(-8deg);
line-height: 1.2;
}
/* 하단 고정 액션 바 */
.kx-rep__bar {
position: sticky;
bottom: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
padding: var(--space-3) var(--space-4);
margin-top: var(--space-2);
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-level2);
}
.kx-rep__bar-note {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-rep__bar-actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
/* ── 토스트(자립) ── */
.kx-doc__toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 60;
background: var(--color-neutral-900);
color: #fff;
padding: 10px 18px;
border-radius: var(--radius-lg);
font-size: var(--fs-body);
box-shadow: 0 6px 20px rgba(16, 24, 40, 0.24);
}

View File

@ -1,235 +0,0 @@
import { useState } from 'react';
import { AiLabel } from '../../components/ui/Badge';
import { Button } from '../../components/ui/Button';
import { IconImage, IconPlus, IconSpark } from '../../components/ui/icons';
import {
CAMPAIGN_KPIS,
CAMPAIGN_STATUS_LABEL,
CAMPAIGNS,
SEGMENTS,
type Campaign,
type CampaignStatus,
} from './sampleMarketing';
import './marketing.css';
/*
* SCR-33 EDM· (M12). Stitch scr_33_edm_campaign .
* M12 API , sampleMarketing .
* · .
*/
export function EdmCampaignPage() {
const [filter, setFilter] = useState<'all' | 'active' | 'done'>('all');
const [segments, setSegments] = useState<Record<string, boolean>>(
Object.fromEntries(SEGMENTS.map((s) => [s.id, s.defaultOn])),
);
const [schedule, setSchedule] = useState<'now' | 'later'>('now');
const rows = CAMPAIGNS.filter((c) =>
filter === 'all'
? true
: filter === 'done'
? c.status === 'done'
: c.status === 'scheduled' || c.status === 'sending',
);
return (
<div className="kx-mkt">
<header className="kx-mkt__head">
<div>
<h1 className="kx-mkt__title">EDM · </h1>
<p className="kx-mkt__subtitle"> · · AI </p>
</div>
<div className="kx-mkt__head-actions">
<span className="kx-sample" title="M12 마케팅 모듈 미연동 — 시연 데이터"> </span>
<Button leadingIcon={<IconPlus size={16} />}> </Button>
</div>
</header>
<section className="kx-mkt__kpis" aria-label="캠페인 핵심 지표">
{CAMPAIGN_KPIS.map((k) => (
<div key={k.label} className={`kx-kpi kx-kpi--accent-${k.tone ?? 'primary'}`}>
<span className="kx-kpi__label">{k.label}</span>
<strong className="kx-kpi__value tnum">
{k.value}
{k.unit && <span className="kx-kpi__unit">{k.unit}</span>}
</strong>
{k.hint && <span className="kx-kpi__sub">{k.hint}</span>}
</div>
))}
</section>
<div className="kx-mkt__split">
{/* 캠페인 리스트 */}
<section className="kx-mkt__list-col" aria-label="캠페인 목록">
<div className="kx-mkt__list-head">
<h2> </h2>
<div className="kx-mkt__tabs" role="tablist" aria-label="상태 필터">
{(['all', 'active', 'done'] as const).map((t) => (
<button
key={t}
role="tab"
aria-selected={filter === t}
className={`kx-chip ${filter === t ? 'is-active' : ''}`}
onClick={() => setFilter(t)}
>
{t === 'all' ? '전체' : t === 'active' ? '진행중' : '완료'}
</button>
))}
</div>
</div>
<ul className="kx-mkt__campaigns">
{rows.map((c) => (
<CampaignRow key={c.id} campaign={c} />
))}
</ul>
</section>
{/* 캠페인 빌더 */}
<aside className="kx-mkt__builder" aria-label="캠페인 빌더">
<div className="kx-card">
<div className="kx-card__head">
<h2> </h2>
<AiLabel>AI </AiLabel>
</div>
<p className="kx-mkt__field-label"> </p>
<div className="kx-mkt__segments">
{SEGMENTS.map((s) => (
<label key={s.id} className="kx-mkt__segment">
<input
type="checkbox"
checked={!!segments[s.id]}
onChange={(e) => setSegments((prev) => ({ ...prev, [s.id]: e.target.checked }))}
/>
<span>{s.label}</span>
<span className="kx-mkt__segment-count tnum">{s.count.toLocaleString()}</span>
</label>
))}
</div>
<div className="kx-mkt__ai-box">
<div className="kx-mkt__ai-box-head">
<span>AI </span>
<IconSpark size={16} />
</div>
<p> .</p>
<Button variant="ai" block leadingIcon={<IconSpark size={16} />}>
AI
</Button>
</div>
<p className="kx-mkt__field-label"> </p>
<button type="button" className="kx-mkt__image-slot">
<IconImage size={28} />
<span> </span>
</button>
<p className="kx-mkt__field-label"> </p>
<div className="kx-mkt__schedule">
<div className="kx-seg" role="tablist" aria-label="발송 시점">
<button
role="tab"
aria-selected={schedule === 'now'}
className={`kx-seg__btn ${schedule === 'now' ? 'is-active' : ''}`}
onClick={() => setSchedule('now')}
>
</button>
<button
role="tab"
aria-selected={schedule === 'later'}
className={`kx-seg__btn ${schedule === 'later' ? 'is-active' : ''}`}
onClick={() => setSchedule('later')}
>
</button>
</div>
{schedule === 'later' && (
<input type="datetime-local" className="kx-mkt__datetime" aria-label="예약 발송 일시" />
)}
</div>
<div className="kx-mkt__preview">
<div className="kx-mkt__preview-head"></div>
<div className="kx-mkt__preview-body">
<div className="kx-mkt__preview-banner"> </div>
<p className="kx-mkt__preview-title">2026 KINTEX </p>
<p className="kx-mkt__preview-text">
, . KINTEX가 .
</p>
<footer className="kx-mkt__preview-foot">
<p> .</p>
<span className="kx-mkt__preview-links">
<a href="#unsubscribe" onClick={(e) => e.preventDefault()}></a>
<span> </span>
</span>
</footer>
</div>
</div>
<Button block> </Button>
</div>
</aside>
</div>
</div>
);
}
function CampaignRow({ campaign: c }: { campaign: Campaign }) {
return (
<li className="kx-mkt__campaign">
<div className="kx-mkt__campaign-main">
<span className={`kx-mkt__campaign-icon is-${c.status}`} aria-hidden="true">
<StatusGlyph status={c.status} />
</span>
<div className="kx-mkt__campaign-info">
<div className="kx-mkt__campaign-top">
<CampaignPill status={c.status} />
<h3>{c.name}</h3>
</div>
<div className="kx-mkt__campaign-meta">
<span>: {c.audience.toLocaleString()}</span>
<span className="kx-mkt__dot-sep" aria-hidden="true" />
<span>{c.meta}</span>
</div>
</div>
</div>
<div className="kx-mkt__campaign-metrics">
<div>
<span className="kx-mkt__metric-k"></span>
<strong>{c.openRate}</strong>
</div>
<div>
<span className="kx-mkt__metric-k"></span>
<strong>{c.clickRate}</strong>
</div>
</div>
</li>
);
}
function CampaignPill({ status }: { status: CampaignStatus }) {
return <span className={`kx-mkt__cpill is-${status}`}>{CAMPAIGN_STATUS_LABEL[status]}</span>;
}
function StatusGlyph({ status }: { status: CampaignStatus }) {
// 상태별 단순 선 글리프.
const common = { fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const };
return (
<svg width={20} height={20} viewBox="0 0 24 24" aria-hidden="true">
{status === 'scheduled' ? (
<>
<circle cx="12" cy="12" r="9" {...common} />
<path d="M12 7v5l3 2" {...common} />
</>
) : status === 'done' ? (
<path d="M4 12l5 5L20 6" {...common} />
) : (
<>
<path d="M4 6h16v12H4z" {...common} />
<path d="M4 7l8 6 8-6" {...common} />
</>
)}
</svg>
);
}

View File

@ -1,173 +0,0 @@
import { useState } from 'react';
import { Button } from '../../components/ui/Button';
import { DdayChip } from '../../components/ui/Badge';
import { IconArrowRight, IconCheck, IconPlus } from '../../components/ui/icons';
import {
FULFILLMENT,
SPONSOR_KPIS,
SPONSOR_STATUS_LABEL,
SPONSOR_TIERS,
SPONSORS,
type Sponsor,
type SponsorTier,
} from './sampleMarketing';
import './marketing.css';
/*
* SCR-34 · (M12 / F070). Stitch scr_34_sponsorship .
* M12 API , sampleMarketing .
*/
export function SponsorshipPage() {
const [selectedSponsor, setSelectedSponsor] = useState<string>(SPONSORS[0]?.id ?? '');
const active = SPONSORS.find((s) => s.id === selectedSponsor) ?? SPONSORS[0];
return (
<div className="kx-mkt">
<header className="kx-mkt__head">
<div>
<h1 className="kx-mkt__title"> · </h1>
<p className="kx-mkt__subtitle"> · · </p>
</div>
<div className="kx-mkt__head-actions">
<span className="kx-sample" title="M12 스폰서십 모듈 미연동 — 시연 데이터"> </span>
<Button leadingIcon={<IconPlus size={16} />}> </Button>
</div>
</header>
<section className="kx-mkt__kpis" aria-label="스폰서십 핵심 지표">
{SPONSOR_KPIS.map((k) => (
<div key={k.label} className={`kx-kpi kx-kpi--accent-${k.tone ?? 'primary'}`}>
<span className="kx-kpi__label">{k.label}</span>
<strong className="kx-kpi__value tnum">
{k.value}
{k.unit && <span className="kx-kpi__unit">{k.unit}</span>}
</strong>
</div>
))}
</section>
<div className="kx-mkt__split">
{/* 티어 카드 */}
<section className="kx-mkt__tiers-col" aria-label="스폰서십 패키지">
<div className="kx-mkt__list-head">
<h2> </h2>
<button type="button" className="kx-mkt__link"> </button>
</div>
<div className="kx-mkt__tiers">
{SPONSOR_TIERS.map((t) => (
<TierCard key={t.id} tier={t} />
))}
</div>
<div className="kx-mkt__deadline">
<div className="kx-mkt__deadline-info">
<span className="kx-mkt__deadline-icon" aria-hidden="true">
<IconArrowRight size={20} />
</span>
<div>
<h3> </h3>
<p> 14 . .</p>
</div>
</div>
<Button variant="secondary"> </Button>
</div>
</section>
{/* 스폰서·이행물 관리 */}
<aside className="kx-mkt__sponsor-col" aria-label="스폰서 관리">
<div className="kx-card">
<div className="kx-card__head">
<h2> </h2>
</div>
<ul className="kx-mkt__sponsors">
{SPONSORS.map((s) => (
<li key={s.id}>
<button
type="button"
className={`kx-mkt__sponsor ${s.id === active?.id ? 'is-selected' : ''}`}
aria-pressed={s.id === active?.id}
onClick={() => setSelectedSponsor(s.id)}
>
<span className="kx-mkt__sponsor-avatar" aria-hidden="true">{s.initial}</span>
<span className="kx-mkt__sponsor-info">
<strong>{s.name}</strong>
<span>{s.tier}</span>
</span>
<SponsorPill sponsor={s} />
</button>
</li>
))}
</ul>
</div>
<div className="kx-card">
<div className="kx-card__head">
<h2>{active?.name} </h2>
</div>
<ul className="kx-mkt__fulfillment">
{FULFILLMENT.map((f) => (
<li key={f.id} className={f.done ? 'is-done' : ''}>
<span className={`kx-mkt__check ${f.done ? 'is-done' : ''}`} aria-hidden="true">
{f.done && <IconCheck size={14} />}
</span>
{f.label}
</li>
))}
</ul>
<button type="button" className="kx-mkt__roi-link">
<span> · · ROI </span>
<IconArrowRight size={18} />
</button>
</div>
</aside>
</div>
</div>
);
}
function TierCard({ tier: t }: { tier: SponsorTier }) {
const sold = t.status === 'soldout';
return (
<div className={`kx-mkt__tier ${sold ? 'is-soldout' : ''}`} style={{ ['--tier-accent' as string]: t.accent }}>
<div className="kx-mkt__tier-body">
<div className="kx-mkt__tier-top">
<span className="kx-mkt__tier-badge">{t.name}</span>
{t.dday != null && <DdayChip dday={t.dday} />}
</div>
<h3 className="kx-mkt__tier-name">{t.nameKo}</h3>
<p className="kx-mkt__tier-price tnum">{t.price}</p>
<ul className="kx-mkt__tier-benefits">
{t.benefits.map((b, i) => (
<li key={i}>
<span className="kx-mkt__tier-check" aria-hidden="true">
<IconCheck size={16} />
</span>
{b}
</li>
))}
</ul>
</div>
<div className="kx-mkt__tier-foot">
{sold ? (
<>
<span className="kx-mkt__tier-remain is-sold"> </span>
<Button variant="secondary" disabled></Button>
</>
) : (
<>
<span className="kx-mkt__tier-remain">
<strong className="tnum">{t.remaining}</strong>
</span>
<Button></Button>
</>
)}
</div>
</div>
);
}
function SponsorPill({ sponsor }: { sponsor: Sponsor }) {
return (
<span className={`kx-mkt__spill is-${sponsor.status}`}>{SPONSOR_STATUS_LABEL[sponsor.status]}</span>
);
}

View File

@ -1,690 +0,0 @@
@import '../shared.css';
/* SCR-33 EDM·캠페인 · SCR-34 스폰서십 (M12) 공통 스타일. */
.kx-mkt {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.kx-mkt__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
}
.kx-mkt__title {
font-size: var(--fs-h1);
line-height: var(--lh-h1);
color: var(--color-neutral-900);
}
.kx-mkt__subtitle {
font-size: var(--fs-body);
color: var(--color-neutral-500);
margin-top: 2px;
}
.kx-mkt__head-actions {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
/* 샘플 데이터 배지 */
.kx-sample {
font-size: 11px;
font-weight: 700;
color: var(--color-warning);
background: #fff4e5;
border: 1px solid #fcd9a8;
padding: 3px 10px;
border-radius: var(--radius-pill);
white-space: nowrap;
}
/* 칩 */
.kx-chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--fs-caption);
font-weight: 600;
color: var(--color-neutral-700);
background: var(--color-neutral-100);
border: 1px solid transparent;
border-radius: var(--radius-pill);
padding: 4px 12px;
cursor: pointer;
}
.kx-chip.is-active {
color: var(--color-primary-700);
background: var(--color-primary-100);
}
/* KPI */
.kx-mkt__kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-4);
}
.kx-kpi__unit {
font-size: var(--fs-h3);
font-weight: 600;
margin-left: 3px;
color: var(--color-neutral-500);
}
.kx-kpi__sub {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-kpi--accent-primary {
border-left: 4px solid var(--color-primary-600);
}
.kx-kpi--accent-ai {
border-left: var(--accent-ai);
}
.kx-kpi--accent-success {
border-left: 4px solid var(--color-success);
}
.kx-kpi--accent-warning {
border-left: 4px solid var(--color-violation-warn);
}
/* 2열 분할 */
.kx-mkt__split {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--space-4);
align-items: start;
}
.kx-mkt__list-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.kx-mkt__list-head h2 {
font-size: var(--fs-h3);
color: var(--color-neutral-900);
}
.kx-mkt__tabs {
display: flex;
gap: var(--space-2);
}
.kx-mkt__link {
border: none;
background: none;
font-size: var(--fs-caption);
font-weight: 600;
color: var(--color-primary-600);
cursor: pointer;
}
/* ── 캠페인 리스트 ── */
.kx-mkt__campaigns {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-mkt__campaign {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-4);
border: var(--border-card);
border-radius: var(--radius-lg);
background: var(--color-white);
}
.kx-mkt__campaign:hover {
box-shadow: var(--shadow-level2);
}
.kx-mkt__campaign-main {
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 0;
}
.kx-mkt__campaign-icon {
width: 44px;
height: 44px;
flex-shrink: 0;
border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--color-primary-100);
color: var(--color-primary-700);
}
.kx-mkt__campaign-icon.is-scheduled {
background: #dcf3e9;
color: var(--color-success);
}
.kx-mkt__campaign-icon.is-sending {
background: #fdecea;
color: var(--color-error);
}
.kx-mkt__campaign-icon.is-done {
background: var(--color-neutral-100);
color: var(--color-neutral-500);
}
.kx-mkt__campaign-info {
min-width: 0;
}
.kx-mkt__campaign-top {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: 3px;
}
.kx-mkt__campaign-top h3 {
font-size: var(--fs-body);
font-weight: 700;
color: var(--color-neutral-900);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kx-mkt__campaign-meta {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-mkt__dot-sep {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--color-neutral-200);
}
.kx-mkt__campaign-metrics {
display: flex;
gap: var(--space-5);
flex-shrink: 0;
}
.kx-mkt__campaign-metrics > div {
text-align: center;
}
.kx-mkt__metric-k {
display: block;
font-size: var(--fs-caption);
color: var(--color-neutral-500);
margin-bottom: 2px;
}
.kx-mkt__campaign-metrics strong {
font-size: var(--fs-body);
color: var(--color-neutral-900);
}
/* 캠페인 상태 pill */
.kx-mkt__cpill {
flex-shrink: 0;
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 4px;
}
.kx-mkt__cpill.is-draft {
color: var(--color-neutral-500);
background: var(--color-neutral-100);
}
.kx-mkt__cpill.is-scheduled {
color: var(--color-primary-700);
background: var(--color-primary-100);
}
.kx-mkt__cpill.is-sending {
color: var(--color-error);
background: #fdecea;
}
.kx-mkt__cpill.is-done {
color: var(--color-success);
background: #dcf3e9;
}
/* ── 캠페인 빌더 ── */
.kx-mkt__builder .kx-card {
position: sticky;
top: 0;
}
.kx-mkt__field-label {
font-size: var(--fs-caption);
font-weight: 600;
color: var(--color-neutral-900);
margin: var(--space-4) 0 var(--space-2);
}
.kx-mkt__segments {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.kx-mkt__segment {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border: var(--border-card);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--fs-body);
color: var(--color-neutral-700);
}
.kx-mkt__segment:hover {
background: var(--color-neutral-050);
}
.kx-mkt__segment input {
width: 16px;
height: 16px;
accent-color: var(--color-ai-accent);
}
.kx-mkt__segment-count {
margin-left: auto;
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-mkt__ai-box {
margin-top: var(--space-4);
padding: var(--space-4);
background: var(--color-ai-surface);
border: 1px solid #d9cfff;
border-radius: var(--radius-lg);
}
.kx-mkt__ai-box-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--fs-caption);
font-weight: 700;
color: var(--color-ai-accent);
margin-bottom: var(--space-2);
}
.kx-mkt__ai-box p {
font-size: var(--fs-caption);
color: var(--color-neutral-700);
line-height: var(--lh-caption);
margin-bottom: var(--space-3);
}
.kx-mkt__image-slot {
width: 100%;
aspect-ratio: 16 / 9;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2);
border: 2px dashed var(--color-neutral-200);
border-radius: var(--radius-lg);
background: var(--color-neutral-050);
color: var(--color-neutral-500);
font-size: var(--fs-caption);
cursor: pointer;
}
.kx-mkt__image-slot:hover {
border-color: var(--color-primary-600);
color: var(--color-primary-600);
}
.kx-mkt__schedule {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.kx-mkt__schedule .kx-seg {
display: flex;
}
.kx-mkt__schedule .kx-seg__btn {
flex: 1;
}
.kx-mkt__datetime {
width: 100%;
height: 36px;
padding: 0 var(--space-3);
border: var(--border-card);
border-radius: var(--radius-sm);
font-size: var(--fs-body);
color: var(--color-neutral-700);
font-family: inherit;
}
.kx-mkt__preview {
margin: var(--space-4) 0;
border: var(--border-card);
border-radius: var(--radius-lg);
overflow: hidden;
}
.kx-mkt__preview-head {
padding: var(--space-2);
text-align: center;
font-size: 11px;
font-weight: 700;
color: var(--color-neutral-500);
background: var(--color-neutral-050);
border-bottom: 1px solid var(--color-neutral-200);
}
.kx-mkt__preview-body {
padding: var(--space-4);
background: var(--color-white);
}
.kx-mkt__preview-banner {
height: 96px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
background: var(--color-neutral-050);
color: var(--color-neutral-500);
font-size: var(--fs-caption);
font-style: italic;
margin-bottom: var(--space-3);
}
.kx-mkt__preview-title {
font-size: var(--fs-body);
font-weight: 700;
color: var(--color-neutral-900);
margin-bottom: var(--space-2);
}
.kx-mkt__preview-text {
font-size: var(--fs-caption);
color: var(--color-neutral-700);
line-height: var(--lh-caption);
}
.kx-mkt__preview-foot {
margin-top: var(--space-4);
padding-top: var(--space-3);
border-top: 1px solid var(--color-neutral-100);
font-size: 10px;
color: var(--color-neutral-500);
}
.kx-mkt__preview-links {
display: flex;
gap: var(--space-2);
margin-top: 4px;
}
.kx-mkt__preview-links a {
text-decoration: underline;
}
/* ── 스폰서십 티어 ── */
.kx-mkt__tiers {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-4);
}
.kx-mkt__tier {
display: flex;
flex-direction: column;
border: var(--border-card);
border-left: 4px solid var(--tier-accent, var(--color-primary-600));
border-radius: var(--radius-lg);
background: var(--color-white);
overflow: hidden;
}
.kx-mkt__tier.is-soldout {
opacity: 0.72;
}
.kx-mkt__tier-body {
padding: var(--space-4);
flex: 1;
}
.kx-mkt__tier-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3);
}
.kx-mkt__tier-badge {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--tier-accent, var(--color-primary-700));
background: color-mix(in srgb, var(--tier-accent, #0066b3) 12%, white);
padding: 3px 10px;
border-radius: var(--radius-pill);
}
.kx-mkt__tier-name {
font-size: var(--fs-h3);
color: var(--color-neutral-900);
margin-bottom: 2px;
}
.kx-mkt__tier-price {
font-size: var(--fs-h2);
font-weight: 800;
color: var(--color-primary-700);
margin-bottom: var(--space-4);
}
.kx-mkt__tier-benefits {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-mkt__tier-benefits li {
display: flex;
align-items: flex-start;
gap: var(--space-2);
font-size: var(--fs-caption);
color: var(--color-neutral-700);
}
.kx-mkt__tier-check {
color: var(--color-primary-600);
flex-shrink: 0;
}
.kx-mkt__tier-foot {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--color-neutral-100);
background: var(--color-neutral-050);
}
.kx-mkt__tier-remain {
font-size: var(--fs-caption);
font-weight: 600;
color: var(--color-neutral-700);
}
.kx-mkt__tier-remain strong {
color: var(--color-primary-700);
}
.kx-mkt__tier-remain.is-sold {
color: var(--color-error);
}
.kx-mkt__deadline {
margin-top: var(--space-4);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-4);
border: var(--border-card);
border-radius: var(--radius-lg);
background: var(--color-primary-050);
}
.kx-mkt__deadline-info {
display: flex;
align-items: center;
gap: var(--space-3);
}
.kx-mkt__deadline-icon {
width: 44px;
height: 44px;
flex-shrink: 0;
border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--color-white);
color: var(--color-primary-600);
}
.kx-mkt__deadline h3 {
font-size: var(--fs-h3);
color: var(--color-neutral-900);
}
.kx-mkt__deadline p {
font-size: var(--fs-caption);
color: var(--color-neutral-700);
margin-top: 2px;
}
/* ── 스폰서 관리 ── */
.kx-mkt__sponsor-col {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.kx-mkt__sponsors {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.kx-mkt__sponsor {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
border: none;
background: none;
border-radius: var(--radius-sm);
cursor: pointer;
text-align: left;
}
.kx-mkt__sponsor:hover {
background: var(--color-neutral-050);
}
.kx-mkt__sponsor.is-selected {
background: var(--color-primary-050);
box-shadow: inset 4px 0 0 var(--color-primary-600);
}
.kx-mkt__sponsor-avatar {
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: var(--radius-sm);
background: var(--color-primary-100);
color: var(--color-primary-700);
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
}
.kx-mkt__sponsor-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.kx-mkt__sponsor-info strong {
font-size: var(--fs-body);
color: var(--color-neutral-900);
}
.kx-mkt__sponsor-info span {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-mkt__spill {
flex-shrink: 0;
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 4px;
}
.kx-mkt__spill.is-signed {
color: var(--color-success);
background: #dcf3e9;
}
.kx-mkt__spill.is-pending {
color: var(--color-neutral-700);
background: var(--color-neutral-100);
}
/* 이행물 체크리스트 */
.kx-mkt__fulfillment {
list-style: none;
margin: 0 0 var(--space-4);
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-mkt__fulfillment li {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--fs-body);
color: var(--color-neutral-700);
}
.kx-mkt__fulfillment li.is-done {
color: var(--color-neutral-900);
}
.kx-mkt__check {
width: 20px;
height: 20px;
flex-shrink: 0;
border-radius: var(--radius-sm);
border: 1.5px solid var(--color-neutral-200);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-white);
}
.kx-mkt__check.is-done {
background: var(--color-primary-600);
border-color: var(--color-primary-600);
}
.kx-mkt__roi-link {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border: none;
border-radius: var(--radius-lg);
background: var(--color-primary-050);
color: var(--color-primary-700);
font-size: var(--fs-body);
font-weight: 700;
cursor: pointer;
}
.kx-mkt__roi-link:hover {
background: var(--color-primary-100);
}
/* 반응형 */
@media (max-width: 1100px) {
.kx-mkt__split {
grid-template-columns: 1fr;
}
.kx-mkt__builder .kx-card {
position: static;
}
}
@media (max-width: 720px) {
.kx-mkt__kpis {
grid-template-columns: repeat(2, 1fr);
}
.kx-mkt__tiers {
grid-template-columns: 1fr;
}
.kx-mkt__campaign {
flex-direction: column;
align-items: flex-start;
}
}

View File

@ -1,185 +0,0 @@
/*
* SCR-33 EDM· · SCR-34 (M12).
* M12 API , ("샘플 데이터" ).
* M10 (N2).
*/
// ── 공통 KPI ──
export interface MktKpi {
label: string;
value: string;
unit?: string;
hint?: string;
tone?: 'primary' | 'ai' | 'success' | 'warning';
}
// ── SCR-33 EDM·캠페인 ──
export const CAMPAIGN_KPIS: MktKpi[] = [
{ label: '전체 발송', value: '24,000', unit: '건', hint: '지난달 대비 +12%', tone: 'primary' },
{ label: '오픈율', value: '38', unit: '%', hint: '산업군 평균 상회', tone: 'ai' },
{ label: '클릭율', value: '9', unit: '%', hint: '유지 중', tone: 'success' },
{ label: '등록 전환', value: '1,240', unit: '명', hint: '목표 달성 82%', tone: 'warning' },
];
export type CampaignStatus = 'draft' | 'scheduled' | 'sending' | 'done';
export interface Campaign {
id: string;
name: string;
status: CampaignStatus;
audience: number;
meta: string;
openRate: string;
clickRate: string;
}
export const CAMPAIGNS: Campaign[] = [
{
id: 'c1',
name: '2026 스마트팩토리 사전등록 안내',
status: 'draft',
audience: 5200,
meta: '최종 수정: 2시간 전',
openRate: '-',
clickRate: '-',
},
{
id: 'c2',
name: 'VIP 바이어 네트워킹 데이 초대권',
status: 'scheduled',
audience: 850,
meta: '발송 예정: 2026.11.20 10:00',
openRate: '24% (예상)',
clickRate: '5% (예상)',
},
{
id: 'c3',
name: '글로벌 테크 트렌드 리포트 (Vol. 12)',
status: 'sending',
audience: 12000,
meta: '진행률: 68% (8,160건 완료)',
openRate: '42.5%',
clickRate: '11.2%',
},
{
id: 'c4',
name: 'KINTEX 추석 연휴 휴관 및 일정 안내',
status: 'done',
audience: 45000,
meta: '완료일: 2026.09.12',
openRate: '31.8%',
clickRate: '2.4%',
},
];
export const CAMPAIGN_STATUS_LABEL: Record<CampaignStatus, string> = {
draft: '초안',
scheduled: '예약',
sending: '발송중',
done: '완료',
};
export interface Segment {
id: string;
label: string;
count: number;
defaultOn: boolean;
}
export const SEGMENTS: Segment[] = [
{ id: 'prereg', label: '사전등록자', count: 3240, defaultOn: true },
{ id: 'past', label: '과거 관람객', count: 12500, defaultOn: false },
{ id: 'buyer', label: '바이어 리스트', count: 1820, defaultOn: false },
];
// ── SCR-34 스폰서십 ──
export const SPONSOR_KPIS: MktKpi[] = [
{ label: '스폰서', value: '18', unit: '사', tone: 'primary' },
{ label: '판매액', value: '₩420.0M', tone: 'primary' },
{ label: '잔여 패키지', value: '6', unit: '개', tone: 'ai' },
{ label: '이행물 진행', value: '72', unit: '%', tone: 'success' },
];
export type TierStatus = 'available' | 'soldout';
export interface SponsorTier {
id: string;
name: string;
nameKo: string;
price: string;
accent: string;
benefits: string[];
remaining: number;
status: TierStatus;
dday?: number;
}
export const SPONSOR_TIERS: SponsorTier[] = [
{
id: 'diamond',
name: 'DIAMOND',
nameKo: '다이아몬드',
price: '₩50,000,000',
accent: '#B8860B',
benefits: ['메인 로고 노출 (온·오프라인)', '전시 부스 100㎡ (최우선 배정)', '연사 세션 2회 부여'],
remaining: 1,
status: 'available',
dday: 12,
},
{
id: 'gold',
name: 'GOLD',
nameKo: '골드',
price: '₩30,000,000',
accent: '#667085',
benefits: ['서브 로고 노출', '전시 부스 50㎡', '연사 세션 1회'],
remaining: 0,
status: 'soldout',
},
{
id: 'silver',
name: 'SILVER',
nameKo: '실버',
price: '₩15,000,000',
accent: '#8B4513',
benefits: ['일반 로고 노출', '전시 부스 20㎡'],
remaining: 3,
status: 'available',
},
];
export type SponsorContractStatus = 'signed' | 'pending';
export interface Sponsor {
id: string;
name: string;
initial: string;
tier: string;
status: SponsorContractStatus;
}
export const SPONSORS: Sponsor[] = [
{ id: 's1', name: '(주)테크솔루션', initial: 'T', tier: '다이아몬드 패키지', status: 'signed' },
{ id: 's2', name: '글로벌바이오', initial: 'G', tier: '골드 패키지', status: 'pending' },
{ id: 's3', name: '넥스트인더스트리', initial: 'N', tier: '실버 패키지', status: 'signed' },
];
export const SPONSOR_STATUS_LABEL: Record<SponsorContractStatus, string> = {
signed: '계약완료',
pending: '결제대기',
};
export interface FulfillmentItem {
id: string;
label: string;
done: boolean;
}
export const FULFILLMENT: FulfillmentItem[] = [
{ id: 'f1', label: '공식 홈페이지 로고 노출', done: true },
{ id: 'f2', label: '전시 홀 부스 배정 (A-101)', done: true },
{ id: 'f3', label: '연사 세션 주제 선정', done: false },
{ id: 'f4', label: '브로슈어 광고 인쇄', done: false },
];

View File

@ -1,395 +0,0 @@
/*
* SCR-25 / [M8]. Stitch scr_25_dock_reservation .
* 진입: 통합 / "반입·반출" (design.md §3 SCR-25).
* : (··/ ) 1~6 ×
* ( · · ) (···· QR) + AI .
* . "샘플 데이터" + // 3 , fetch .
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AiLabel } from '../../components/ui/Badge';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { IconSpark } from '../../components/ui/icons';
import './movein.css';
/** 08:00 ~ 19:00 (12개 시간대) */
const HOURS = Array.from({ length: 12 }, (_, i) => `${String(8 + i).padStart(2, '0')}:00`);
const DOCKS = ['도크 1', '도크 2', '도크 3', '도크 4', '도크 5', '도크 6'];
const WEIGHTS = ['1t', '2.5t', '5t', '11t 이상'];
interface Reservation {
dock: number; // 0-based dock index
start: number; // 0-based hour column
span: number; // number of columns
label: string;
tone: 'booked' | 'priority';
}
/** 반입 기준 샘플 예약(Stitch 정합). 반출로 토글하면 별도 샘플 세트로 교체. */
const RESERVATIONS_IN: Reservation[] = [
{ dock: 0, start: 0, span: 2, label: '(주)공간디자인 · 5t 트럭', tone: 'booked' },
{ dock: 0, start: 3, span: 2, label: '중량물 우선 (5t 이상)', tone: 'priority' },
{ dock: 1, start: 4, span: 3, label: '글로벌부스테크 · 2.5t 트럭', tone: 'booked' },
{ dock: 2, start: 1, span: 2, label: '비욘드디자인 · 1t 탑차', tone: 'booked' },
{ dock: 4, start: 8, span: 2, label: '네오로지스 · 5t 윙바디', tone: 'booked' },
];
const RESERVATIONS_OUT: Reservation[] = [
{ dock: 0, start: 6, span: 3, label: '(주)공간디자인 · 철거 5t', tone: 'booked' },
{ dock: 2, start: 7, span: 2, label: '중량물 우선 (5t 이상)', tone: 'priority' },
{ dock: 3, start: 9, span: 3, label: '네오로지스 · 철거 11t', tone: 'booked' },
];
/** AI 철거일 대기열 예측(시간대별 밀집도 %) — 오후 4시경 피크. */
const QUEUE_FORECAST = [20, 30, 40, 55, 70, 95, 100, 80, 50, 30, 22, 18];
interface DockForm {
vehicleNo: string;
weight: string;
item: string;
forklift: boolean;
}
const EMPTY_FORM: DockForm = { vehicleNo: '', weight: '5t', item: '전시 부스 자재', forklift: false };
function useSampleReady() {
const [status, setStatus] = useState<'loading' | 'error' | 'success'>('loading');
const load = useCallback(() => {
setStatus('loading');
const t = setTimeout(() => setStatus('success'), 320);
return () => clearTimeout(t);
}, []);
useEffect(load, [load]);
return { status, retry: load };
}
export function DockReservationPage() {
const { status, retry } = useSampleReady();
const [direction, setDirection] = useState<'in' | 'out'>('in');
const [form, setForm] = useState<DockForm>(EMPTY_FORM);
const [selected, setSelected] = useState<{ dock: number; hour: number } | null>(null);
const [toast, setToast] = useState<string | null>(null);
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(null), 2200);
return () => clearTimeout(t);
}, [toast]);
const reservations = direction === 'in' ? RESERVATIONS_IN : RESERVATIONS_OUT;
function pickCell(dock: number, hour: number) {
setSelected({ dock, hour });
setToast(`${DOCKS[dock]} · ${HOURS[hour]} 슬롯 선택`);
}
const set = <K extends keyof DockForm>(k: K, v: DockForm[K]) =>
setForm((f) => ({ ...f, [k]: v }));
return (
<div className="kx-page">
<header className="kx-dock__head">
<div>
<h1 className="kx-dock__title">/ </h1>
<p className="kx-dock__subtitle">· · 2026 </p>
</div>
<div className="kx-dock__head-badges">
<span className="kx-bi__degraded"> </span>
</div>
</header>
{/* 필터 바 */}
<div className="kx-dock__filters">
<label className="kx-dock__field">
<span className="kx-dock__label"></span>
<select className="kx-select" aria-label="행사 선택" defaultValue="motor">
<option value="motor">2026 </option>
<option value="logistics">2026 </option>
</select>
</label>
<label className="kx-dock__field">
<span className="kx-dock__label"></span>
<select className="kx-select" aria-label="홀 선택" defaultValue="7">
<option value="7"> 7</option>
<option value="8"> 8</option>
<option value="9"> 9</option>
</select>
</label>
<div className="kx-dock__filters-spacer" />
<div className="kx-seg" role="tablist" aria-label="반입/반출 전환">
<button
role="tab"
aria-selected={direction === 'in'}
className={`kx-seg__btn ${direction === 'in' ? 'is-active' : ''}`}
onClick={() => {
setDirection('in');
setSelected(null);
}}
>
</button>
<button
role="tab"
aria-selected={direction === 'out'}
className={`kx-seg__btn ${direction === 'out' ? 'is-active' : ''}`}
onClick={() => {
setDirection('out');
setSelected(null);
}}
>
</button>
</div>
</div>
{status === 'loading' && (
<div className="kx-dock__split" aria-busy="true">
<section className="kx-card">
<Skeleton height={360} />
</section>
<section className="kx-card">
<Skeleton height={240} />
</section>
</div>
)}
{status === 'error' && (
<ErrorState message="도크 예약 현황을 불러오지 못했습니다." onRetry={retry} />
)}
{status === 'success' && (
<div className="kx-dock__split">
{/* 좌: 스케줄 그리드 */}
<section className="kx-card kx-dock__grid" aria-label={`${direction === 'in' ? '반입' : '반출'} 도크 슬롯 그리드`}>
{DOCKS.length === 0 ? (
<EmptyState title="예약 가능한 슬롯이 없습니다" description="행사·홀을 선택하세요." />
) : (
<>
<div className="kx-dock__grid-scroll">
<div className="kx-dock__ghead">
<div className="kx-dock__ghead-dock">DOCK</div>
<div className="kx-dock__times">
{HOURS.map((h) => (
<div key={h} className="kx-dock__time tnum">
{h}
</div>
))}
</div>
</div>
{DOCKS.map((dock, di) => (
<div key={dock} className="kx-dock__row">
<div className="kx-dock__row-label">{dock}</div>
<DockRowCells
dockIndex={di}
reservations={reservations}
selected={selected}
onPick={pickCell}
/>
</div>
))}
</div>
<div className="kx-dock__legend">
<span>
<span className="kx-dock__swatch kx-dock__swatch--booked" />
</span>
<span>
<span className="kx-dock__swatch kx-dock__swatch--priority" /> (5t)
</span>
<span>
<span className="kx-dock__swatch kx-dock__swatch--open" />
</span>
</div>
</>
)}
</section>
{/* 우: 예약 폼 + AI */}
<aside className="kx-dock__aside">
<section className="kx-card">
<span className="kx-dock__panel-title"> </span>
<div className="kx-dock__form">
{selected && (
<p className="kx-dock__slot-note">
: {DOCKS[selected.dock]} · {HOURS[selected.hour]} ·{' '}
{direction === 'in' ? '반입' : '반출'}
</p>
)}
<label className="kx-dock__field">
<span className="kx-dock__label"> </span>
<input
className="kx-dock__input"
placeholder="예: 12가 3456"
value={form.vehicleNo}
onChange={(e) => set('vehicleNo', e.target.value)}
/>
</label>
<label className="kx-dock__field">
<span className="kx-dock__label"> </span>
<select
className="kx-dock__select"
value={form.weight}
onChange={(e) => set('weight', e.target.value)}
>
{WEIGHTS.map((w) => (
<option key={w} value={w}>
{w}
</option>
))}
</select>
</label>
<label className="kx-dock__field">
<span className="kx-dock__label">{direction === 'in' ? '반입' : '반출'} </span>
<input
className="kx-dock__input"
value={form.item}
onChange={(e) => set('item', e.target.value)}
/>
</label>
<label className="kx-dock__check">
<input
type="checkbox"
checked={form.forklift}
onChange={(e) => set('forklift', e.target.checked)}
/>
()
</label>
<button
type="button"
className="kx-dock__qr"
onClick={() => {
if (!selected) {
setToast('먼저 슬롯을 선택하세요.');
return;
}
setToast('통행증(QR) 발급 완료 — 모바일 반입 화면 연동 (샘플)');
}}
>
<span className="kx-dock__qr-main">
<span className="kx-dock__qr-title"> (QR)</span>
<span className="kx-dock__qr-sub">Generate Logistics Pass</span>
</span>
<span className="kx-dock__qr-glyph" aria-hidden="true">
<QrGlyph />
</span>
</button>
</div>
</section>
<section className="kx-dock__ai" aria-label="철거일 대기열 예측">
<div className="kx-dock__ai-head">
<span className="kx-dock__ai-title">
<IconSpark size={18} />
</span>
<AiLabel>AI </AiLabel>
</div>
<div className="kx-dock__chart" role="img" aria-label="시간대별 차량 밀집도 예측 — 오후 4시경 피크">
{QUEUE_FORECAST.map((h, i) => (
<span
key={i}
className="kx-dock__bar"
style={{ height: `${h}%`, opacity: 0.35 + (h / 100) * 0.65 }}
/>
))}
</div>
<div className="kx-dock__chart-axis tnum">
<span>08:00</span>
<span>12:00</span>
<span>16:00</span>
<span>20:00</span>
</div>
<div className="kx-dock__ai-callout">
<strong> 4</strong> .
.
</div>
</section>
</aside>
</div>
)}
{toast && (
<div className="kx-dock__toast" role="status">
{toast}
</div>
)}
</div>
);
}
/** 단일 도크 행의 12개 시간대 셀 — 예약 블록은 span, 빈 칸은 선택 가능한 셀. */
function DockRowCells({
dockIndex,
reservations,
selected,
onPick,
}: {
dockIndex: number;
reservations: Reservation[];
selected: { dock: number; hour: number } | null;
onPick: (dock: number, hour: number) => void;
}) {
const cells = useMemo(() => {
const rowRes = reservations
.filter((r) => r.dock === dockIndex)
.sort((a, b) => a.start - b.start);
const out: JSX.Element[] = [];
let col = 0;
while (col < 12) {
const res = rowRes.find((r) => r.start === col);
if (res) {
out.push(
<div
key={`r${col}`}
className={`kx-dock__block kx-dock__block--${res.tone}`}
style={{ gridColumn: `span ${res.span}` }}
title={res.label}
>
{res.tone === 'priority' && <IconSpark size={12} />}
<span className="kx-dock__block-text">{res.label}</span>
</div>,
);
col += res.span;
} else {
const isSel = selected?.dock === dockIndex && selected?.hour === col;
const hour = col;
out.push(
<div
key={`c${col}`}
className={`kx-dock__cell ${isSel ? 'is-selected' : ''}`}
role="button"
tabIndex={0}
aria-label={`${DOCKS[dockIndex]} ${HOURS[hour]} 예약 가능`}
onClick={() => onPick(dockIndex, hour)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onPick(dockIndex, hour);
}
}}
/>,
);
col += 1;
}
}
return out;
}, [dockIndex, reservations, selected, onPick]);
return <div className="kx-dock__cells">{cells}</div>;
}
/** 통행증 미리보기용 QR 형태 SVG(인라인·이모지 금지). */
function QrGlyph() {
return (
<svg width={40} height={40} viewBox="0 0 40 40" fill="none" aria-hidden="true">
<rect x="2" y="2" width="12" height="12" rx="1" stroke="#101828" strokeWidth="2.5" />
<rect x="26" y="2" width="12" height="12" rx="1" stroke="#101828" strokeWidth="2.5" />
<rect x="2" y="26" width="12" height="12" rx="1" stroke="#101828" strokeWidth="2.5" />
<rect x="6" y="6" width="4" height="4" fill="#101828" />
<rect x="30" y="6" width="4" height="4" fill="#101828" />
<rect x="6" y="30" width="4" height="4" fill="#101828" />
<rect x="20" y="20" width="4" height="4" fill="#101828" />
<rect x="28" y="20" width="4" height="4" fill="#101828" />
<rect x="34" y="26" width="4" height="4" fill="#101828" />
<rect x="20" y="30" width="4" height="4" fill="#101828" />
<rect x="28" y="34" width="4" height="4" fill="#101828" />
<rect x="20" y="6" width="4" height="10" fill="#101828" />
</svg>
);
}

View File

@ -1,373 +0,0 @@
/*
* SCR-25 반입/반출 도크 슬롯 예약 [M8] 전용 스타일.
* design.md §1 토큰만 참조. shared.css 프리미티브(kx-page·kx-card·kx-seg·kx-select·kx-bi__degraded) 위에 얹는다.
* 클래스는 kx-dock__* 네임스페이스.
*/
@import '../shared.css';
.kx-dock__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
}
.kx-dock__title {
font-size: var(--fs-h1);
line-height: var(--lh-h1);
color: var(--color-neutral-900);
}
.kx-dock__subtitle {
margin-top: 2px;
font-size: var(--fs-body);
color: var(--color-neutral-500);
}
.kx-dock__head-badges {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
/* ── 필터 바 ── */
.kx-dock__filters {
display: flex;
align-items: flex-end;
gap: var(--space-3);
flex-wrap: wrap;
}
.kx-dock__field {
display: flex;
flex-direction: column;
gap: 4px;
}
.kx-dock__label {
font-size: var(--fs-caption);
font-weight: 600;
color: var(--color-neutral-500);
}
.kx-dock__filters-spacer {
flex: 1;
}
/* ── 2열 split ── */
.kx-dock__split {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr);
gap: var(--space-4);
align-items: start;
}
@media (max-width: 1200px) {
.kx-dock__split {
grid-template-columns: 1fr;
}
}
/* ── 스케줄 그리드 ── */
.kx-dock__grid {
padding: 0;
overflow: hidden;
}
.kx-dock__grid-scroll {
overflow-x: auto;
}
.kx-dock__ghead,
.kx-dock__row {
display: flex;
min-width: 720px;
}
.kx-dock__ghead {
background: var(--color-neutral-050);
border-bottom: var(--border-card);
}
.kx-dock__ghead-dock {
width: 72px;
flex-shrink: 0;
padding: 10px;
text-align: center;
font-size: var(--fs-caption);
font-weight: 700;
color: var(--color-neutral-500);
border-right: 1px solid var(--color-neutral-100);
display: flex;
align-items: center;
justify-content: center;
}
.kx-dock__times {
flex: 1;
display: grid;
grid-template-columns: repeat(12, 1fr);
}
.kx-dock__time {
padding: 10px 2px;
text-align: center;
font-size: 11px;
font-weight: 600;
color: var(--color-neutral-500);
border-right: 1px solid var(--color-neutral-100);
}
.kx-dock__time:last-child {
border-right: none;
}
.kx-dock__row {
border-bottom: 1px solid var(--color-neutral-100);
}
.kx-dock__row:last-child {
border-bottom: none;
}
.kx-dock__row-label {
width: 72px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--fs-caption);
font-weight: 700;
color: var(--color-primary-700);
border-right: 1px solid var(--color-neutral-100);
background: var(--color-neutral-050);
}
.kx-dock__cells {
flex: 1;
display: grid;
grid-template-columns: repeat(12, 1fr);
height: 60px;
}
.kx-dock__cell {
border-right: 1px solid var(--color-neutral-100);
cursor: pointer;
transition: background 0.12s ease;
}
.kx-dock__cell:hover {
background: var(--color-primary-050);
}
.kx-dock__cell.is-selected {
background: var(--color-primary-100);
outline: 2px solid var(--color-primary-600);
outline-offset: -2px;
}
.kx-dock__block {
margin: 4px 2px;
border-radius: var(--radius-sm);
padding: 6px 8px;
font-size: 11px;
line-height: 1.25;
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
}
.kx-dock__block--booked {
background: var(--color-primary-600);
color: #fff;
}
.kx-dock__block--priority {
background: var(--color-violation-warn-bg);
color: var(--color-violation-warn-text);
font-weight: 700;
border: 1px solid var(--color-violation-warn);
}
.kx-dock__block-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kx-dock__legend {
display: flex;
gap: var(--space-4);
flex-wrap: wrap;
padding: var(--space-3) var(--space-4);
border-top: var(--border-card);
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-dock__legend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.kx-dock__swatch {
width: 12px;
height: 12px;
border-radius: 3px;
display: inline-block;
}
.kx-dock__swatch--booked {
background: var(--color-primary-600);
}
.kx-dock__swatch--priority {
background: var(--color-violation-warn);
}
.kx-dock__swatch--open {
border: 1px solid var(--color-neutral-200);
background: var(--color-white);
}
/* ── 우측 예약 폼 ── */
.kx-dock__aside {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.kx-dock__panel-title {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-h3);
color: var(--color-primary-700);
padding-bottom: 6px;
border-bottom: 2px solid var(--color-primary-600);
margin-bottom: var(--space-4);
}
.kx-dock__form {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-dock__input,
.kx-dock__select {
height: 40px;
padding: 0 var(--space-3);
border: var(--border-card);
border-radius: var(--radius-sm);
background: var(--color-white);
font-size: var(--fs-body);
color: var(--color-neutral-900);
width: 100%;
font-family: inherit;
}
.kx-dock__input:focus,
.kx-dock__select:focus {
outline: 2px solid var(--color-primary-100);
border-color: var(--color-primary-600);
}
.kx-dock__check {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-body);
color: var(--color-neutral-700);
cursor: pointer;
}
.kx-dock__slot-note {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
background: var(--color-primary-050);
color: var(--color-primary-700);
}
/* 통행증 QR */
.kx-dock__qr {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4);
border-radius: var(--radius-lg);
background: var(--color-primary-700);
color: #fff;
cursor: pointer;
border: none;
text-align: left;
width: 100%;
}
.kx-dock__qr:hover {
background: var(--color-primary-600);
}
.kx-dock__qr-main {
flex: 1;
}
.kx-dock__qr-title {
font-size: var(--fs-h3);
font-weight: 700;
display: block;
}
.kx-dock__qr-sub {
font-size: 11px;
opacity: 0.85;
text-transform: uppercase;
}
.kx-dock__qr-glyph {
background: #fff;
padding: 4px;
border-radius: var(--radius-sm);
display: flex;
flex-shrink: 0;
}
/* AI 대기열 예측 카드 */
.kx-dock__ai {
border: 1px solid var(--color-neutral-200);
border-left: var(--accent-ai);
border-radius: var(--radius-lg);
background: var(--color-white);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-dock__ai-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.kx-dock__ai-title {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-h3);
color: var(--color-ai-accent);
}
.kx-dock__chart {
height: 96px;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 4px;
padding: var(--space-2) var(--space-2) 0;
border-bottom: 1px solid var(--color-neutral-100);
}
.kx-dock__bar {
flex: 1;
background: var(--color-ai-accent);
border-radius: 3px 3px 0 0;
min-height: 4px;
}
.kx-dock__chart-axis {
display: flex;
justify-content: space-between;
font-size: 10px;
color: var(--color-neutral-500);
padding: 0 2px;
}
.kx-dock__ai-callout {
display: flex;
gap: var(--space-2);
padding: var(--space-3);
border-radius: var(--radius-sm);
border-left: 3px solid var(--color-ai-accent);
background: var(--color-ai-surface);
font-size: var(--fs-caption);
line-height: var(--lh-body);
color: var(--color-neutral-700);
}
.kx-dock__ai-callout strong {
color: var(--color-ai-accent);
}
/* 토스트 */
.kx-dock__toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 60;
background: var(--color-neutral-900);
color: #fff;
padding: 10px 18px;
border-radius: var(--radius-lg);
font-size: var(--fs-body);
box-shadow: 0 6px 20px rgba(16, 24, 40, 0.24);
}

View File

@ -1,297 +0,0 @@
/*
* SCR-P2 (····FAQ) M12, .
* design.md §3B. + () + + + + CTA.
* 데이터: 공개 API .
*/
import { useState } from 'react';
import { PublicShell } from './PublicShell';
import {
IconCalendar,
IconPin,
IconFactory,
IconCloud,
IconRobot,
IconLeaf,
IconChat,
IconShare,
IconLink,
IconChevronRight,
} from './publicIcons';
const TABS = [
{ key: 'intro', label: '소개' },
{ key: 'program', label: '프로그램' },
{ key: 'speakers', label: '연사' },
{ key: 'exhibitors', label: '참가업체' },
{ key: 'transport', label: '교통' },
{ key: 'faq', label: 'FAQ' },
];
const THEMES = [
{ ic: <IconRobot width={40} height={40} />, title: 'AI & 자율 로봇', desc: '스스로 학습하고 판단하는 스마트 로봇 솔루션' },
{ ic: <IconCloud width={40} height={40} />, title: '디지털 트윈 & IoT', desc: '가상 세계와 현실 제조 현장의 실시간 동기화' },
{ ic: <IconLeaf width={40} height={40} />, title: '탄소중립 스마트 제조', desc: '에너지 효율 극대화를 위한 친환경 공정 기술' },
];
const AGENDA = [
{
time: '10:00 11:30',
room: '메인 컨퍼런스 홀',
tag: 'KEYNOTE',
title: '제조업의 미래: AI가 이끄는 생산성 혁명',
desc: 'AI 글로벌 석학들이 제안하는 2030 제조 전략과 실제 구현 사례 분석',
},
{
time: '13:00 14:30',
room: '세미나실 A',
tag: 'TECH SESSION',
title: '산업용 5G와 엣지 컴퓨팅의 결합',
desc: '지연 없는 데이터 전송을 통한 실시간 공정 제어 기술 시연',
},
{
time: '15:00 17:00',
room: '비즈니스 라운지',
tag: 'NETWORKING',
title: 'Global Buyers Matching Day',
desc: '해외 유망 기업과 국내 공급사 간의 1:1 비즈니스 미팅',
},
];
const SPEAKERS = [
{ name: '김철수', role: 'AI 연구소장 | TechCorp' },
{ name: '이영희', role: 'CTO | Automatech' },
{ name: '박지성', role: '로봇공학 석좌교수 | Korea Univ.' },
{ name: 'Sarah Jenkins', role: 'Global VP | Digitron' },
];
export function PublicEventDetailPage() {
const [activeTab, setActiveTab] = useState('intro');
const [day, setDay] = useState(1);
return (
<PublicShell active="events" cta="사전등록하기">
{/* 행사 히어로 */}
<section
className="kxp-hero"
aria-label="스마트팩토리 코리아 2026"
style={{ backgroundColor: 'var(--color-canvas-bg)' }}
>
<div
className="kxp-hero__bg"
style={{ background: 'linear-gradient(135deg, #101828 0%, #1f3a5f 60%, #0066b3 120%)' }}
aria-hidden
/>
<div className="kxp-hero__scrim" aria-hidden />
<div className="kxp-hero__inner">
<div className="kxp-hero__content">
<span className="kxp-hero__eyebrow">Global Manufacturing Expo</span>
<h1 className="kxp-hero__title"> 2026</h1>
<p className="kxp-hero__lead">AI .</p>
<div className="kxp-eventhero__facts kxp-hero__meta">
<span className="kxp-hero__metacard">
<IconCalendar width={18} height={18} />
<span>
<small></small>
<strong>2026.09.05 09.08</strong>
</span>
</span>
<span className="kxp-hero__metacard">
<IconPin width={18} height={18} />
<span>
<small></small>
<strong> 2 7</strong>
</span>
</span>
</div>
<div className="kxp-hero__actions">
<a className="kxp-btn kxp-btn--primary kxp-btn--lg" href="#program">
</a>
<a className="kxp-btn kxp-btn--ghost kxp-btn--lg" href="#intro">
</a>
</div>
</div>
</div>
</section>
{/* 서브탭(앵커) */}
<nav className="kxp-subtabs" aria-label="행사 상세 내비게이션">
<div className="kxp-subtabs__inner">
{TABS.map((t) => (
<a
key={t.key}
href={`#${t.key}`}
className={`kxp-subtab${activeTab === t.key ? ' is-active' : ''}`}
onClick={() => setActiveTab(t.key)}
>
{t.label}
</a>
))}
</div>
</nav>
{/* 공유 사이드 */}
<div className="kxp-share" aria-label="공유">
<button type="button" className="kxp-share__btn" title="공유하기">
<IconShare />
</button>
<button type="button" className="kxp-share__btn" title="링크 복사">
<IconLink />
</button>
</div>
{/* 소개 */}
<section className="kxp-section" id="intro" aria-label="전시회 개요">
<div className="kxp-wrap">
<div className="kxp-detail">
<div>
<h2 className="kxp-section__title"> </h2>
<span className="kxp-sample" style={{ marginBottom: 16, display: 'inline-flex' }}>
</span>
<p className="kxp-detail__lead">
2026 . AI, , IoT,
.
500 .
</p>
<div className="kxp-detail__stats">
<div className="kxp-statcard">
<IconFactory width={28} height={28} />
<strong>500+</strong>
<span> </span>
</div>
<div className="kxp-statcard">
<IconRobot width={28} height={28} />
<strong>30,000+</strong>
<span> </span>
</div>
</div>
</div>
<div
className="kxp-detail__img"
style={{ background: 'linear-gradient(135deg, #0066b3 0%, #6d4aff 120%)' }}
role="img"
aria-label="전시장 전경"
/>
</div>
<h3 className="kxp-section__title" style={{ fontSize: 22, textAlign: 'center', marginTop: 48 }}>
</h3>
<div className="kxp-themes">
{THEMES.map((t) => (
<div className="kxp-theme" key={t.title}>
{t.ic}
<h4>{t.title}</h4>
<p>{t.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* 프로그램 */}
<section className="kxp-section kxp-section--alt" id="program" aria-label="프로그램 일정">
<div className="kxp-wrap">
<div className="kxp-section__head">
<h2 className="kxp-section__title"> </h2>
<div className="kxp-daytabs" role="tablist" aria-label="일자 선택">
<button
type="button"
role="tab"
aria-selected={day === 1}
className={`kxp-daytab${day === 1 ? ' is-active' : ''}`}
onClick={() => setDay(1)}
>
9 5 (Day 1)
</button>
<button
type="button"
role="tab"
aria-selected={day === 2}
className={`kxp-daytab${day === 2 ? ' is-active' : ''}`}
onClick={() => setDay(2)}
>
9 6 (Day 2)
</button>
</div>
</div>
<div className="kxp-agenda">
{AGENDA.map((a) => (
<div className="kxp-agenda__item" key={a.title}>
<div className="kxp-agenda__time">
{a.time}
<small>{a.room}</small>
</div>
<div>
<span className="kxp-tag" style={{ marginBottom: 8, display: 'inline-block' }}>
{a.tag}
</span>
<h4 className="kxp-agenda__title">{a.title}</h4>
<p className="kxp-agenda__desc">{a.desc}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* 연사 */}
<section className="kxp-section" id="speakers" aria-label="주요 연사">
<div className="kxp-wrap">
<div className="kxp-section__head" style={{ justifyContent: 'center' }}>
<h2 className="kxp-section__title"> </h2>
</div>
<div className="kxp-speakers">
{SPEAKERS.map((s, i) => (
<div className="kxp-speaker" key={s.name}>
<div
className="kxp-speaker__photo"
style={{
background: `linear-gradient(135deg, ${
['#0066b3', '#6d4aff', '#0e8a5f', '#004c86'][i % 4]
} 0%, #101828 160%)`,
}}
role="img"
aria-label={`${s.name} 프로필`}
/>
<h5>{s.name}</h5>
<p>{s.role}</p>
</div>
))}
</div>
</div>
</section>
{/* FAQ (요약) */}
<section className="kxp-section kxp-section--tint" id="faq" aria-label="자주 묻는 질문">
<div className="kxp-wrap">
<div className="kxp-section__head">
<h2 className="kxp-section__title"> </h2>
</div>
<div className="kxp-eventgrid" style={{ gridTemplateColumns: '1fr' }}>
{[
['사전등록은 어떻게 하나요?', '상단 "사전등록하기" 버튼에서 유형 선택 후 정보를 입력하면 모바일 배지가 발급됩니다.'],
['입장료가 있나요?', '일반 관람객은 사전등록 시 무료입니다. 현장 등록은 유료로 운영될 수 있습니다.'],
['주차 공간이 있나요?', '킨텍스 제1·2전시장에 최대 4,000대 동시 주차가 가능합니다.'],
].map(([q, a]) => (
<div className="kxp-boothtype__body" key={q} style={{ background: 'var(--color-white)', border: 'var(--border-card)', borderRadius: 8 }}>
<h4 style={{ display: 'flex', gap: 8, alignItems: 'center', margin: 0, color: 'var(--color-neutral-900)', fontSize: 'var(--fs-h3)' }}>
<IconChat width={18} height={18} /> {q}
</h4>
<p style={{ margin: 0, color: 'var(--color-neutral-500)', fontSize: 'var(--fs-body)' }}>{a}</p>
</div>
))}
</div>
</div>
</section>
{/* 사전등록 고정 CTA */}
<a className="kxp-btn kxp-btn--primary kxp-btn--lg kxp-fab" href="#program">
<IconChevronRight width={18} height={18} />
</a>
</PublicShell>
);
}

View File

@ -1,270 +0,0 @@
/*
* SCR-P3 (M12·M2) .
* design.md §3B. : 검색/ + ( N1) / : 인터랙티브 + + .
* 데이터: 공개 API .
*/
import { useMemo, useState } from 'react';
import { PublicShell } from './PublicShell';
import {
IconSearch,
IconChevronRight,
IconClose,
IconArrowRight,
IconZoomIn,
IconZoomOut,
IconReset,
IconTarget,
} from './publicIcons';
type BoothStatus = 'available' | 'reserved' | 'public' | 'pending';
interface Booth {
code: string;
name: string;
category: string;
status: BoothStatus;
/** 그리드 배치 (col span, row span) */
span?: number;
}
const CATEGORIES = ['AI 솔루션', '로보틱스', 'IoT', '빅데이터'];
const BOOTHS: Booth[] = [
{ code: 'A-102', name: '(주)테크솔루션', category: 'AI 솔루션', status: 'available' },
{ code: 'A-103', name: '스마트비전', category: 'AI 솔루션', status: 'available' },
{ code: '공용부', name: '휴게 공간', category: '공용부', status: 'public', span: 2 },
{ code: 'A-104', name: '넥스트로직', category: 'AI 솔루션', status: 'available' },
{ code: 'C-110', name: '미래로보틱스', category: '로보틱스', status: 'available' },
{ code: 'A-105', name: '오토메이션랩', category: 'IoT', status: 'available' },
{ code: 'Stage', name: '메인 스테이지', category: '공용부', status: 'public', span: 3 },
{ code: 'A-106', name: '데이터브릿지', category: 'IoT', status: 'available' },
{ code: 'C-111', name: '로보다인', category: '로보틱스', status: 'available' },
{ code: 'B-201', name: '예약 부스', category: '예약', status: 'reserved' },
{ code: 'B-202', name: '예약 부스', category: '예약', status: 'reserved' },
{ code: 'B-205', name: '데이터랩스', category: '빅데이터', status: 'available' },
{ code: 'B-206', name: '인사이트AI', category: '빅데이터', status: 'available' },
{ code: 'Desk', name: '인포메이션', category: '공용부', status: 'public', span: 2 },
{ code: 'Hall', name: '입구 통로', category: '공용부', status: 'public', span: 4 },
{ code: 'C-120', name: '커넥트봇', category: '로보틱스', status: 'available' },
{ code: 'C-121', name: '글로벌커넥트', category: '로보틱스', status: 'pending' },
];
const LIST = BOOTHS.filter((b) => b.status !== 'public');
export function PublicFloorplanPage() {
const [query, setQuery] = useState('');
const [cat, setCat] = useState<string | null>('AI 솔루션');
const [selected, setSelected] = useState<Booth | null>(null);
const [scale, setScale] = useState(1);
const filtered = useMemo(() => {
return LIST.filter((b) => {
const q = query.trim();
const matchQ = !q || b.name.includes(q) || b.code.includes(q);
const matchC = !cat || b.category === cat;
return matchQ && matchC;
});
}, [query, cat]);
const boothClass = (b: Booth) => {
let c = 'kxp-booth';
if (b.status === 'reserved') c += ' kxp-booth--reserved';
if (b.status === 'public') c += ' kxp-booth--public';
if (selected?.code === b.code) c += ' is-selected';
return c;
};
return (
<PublicShell active="visit" cta="입장권 예매">
<div className="kxp-fp">
{/* 좌측 검색/필터/목록 */}
<aside className="kxp-fp__side" aria-label="부스 검색 및 목록">
<div className="kxp-fp__filters">
<h2> · · </h2>
<div className="kxp-fp__searchbox">
<IconSearch width={18} height={18} />
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="참가업체 또는 부스번호 검색"
aria-label="부스 검색"
/>
</div>
<div>
<p className="kxp-fp__label"> </p>
<div className="kxp-fp__chips">
{CATEGORIES.map((c) => (
<button
key={c}
type="button"
className={`kxp-fp__chip${cat === c ? ' is-active' : ''}`}
aria-pressed={cat === c}
onClick={() => setCat(cat === c ? null : c)}
>
{c}
</button>
))}
</div>
</div>
<div>
<p className="kxp-fp__label"> </p>
<select className="kxp-select" aria-label="전시장 선택">
<option> 1 - Hall 1-A</option>
<option> 1 - Hall 1-B</option>
<option> 2 - Hall 6</option>
</select>
</div>
<span className="kxp-sample"> </span>
</div>
<div className="kxp-fp__list" aria-label="부스 목록 (비시각 대안)">
<p className="kxp-fp__listhint"> ({filtered.length})</p>
{filtered.length === 0 && <p className="kxp-empty"> .</p>}
{filtered.map((b) => (
<button
key={b.code}
type="button"
className={`kxp-fp__row${
b.status === 'reserved' ? ' kxp-fp__row--reserved' : ''
}${b.status === 'pending' ? ' kxp-fp__row--pending' : ''}${
selected?.code === b.code ? ' is-active' : ''
}`}
onClick={() => b.status !== 'pending' && setSelected(b)}
disabled={b.status === 'pending'}
>
<span>
<span className="kxp-fp__row-code">{b.code}</span>
<span className="kxp-fp__row-name">{b.name}</span>
<span className="kxp-fp__row-cat">
{b.status === 'pending' ? '준비 중' : b.category}
</span>
</span>
{b.status === 'pending' ? (
<span className="kxp-tag"></span>
) : (
<IconChevronRight width={18} height={18} />
)}
</button>
))}
</div>
</aside>
{/* 우측 인터랙티브 맵 */}
<section className="kxp-fp__stage" aria-label="인터랙티브 홀 지도">
<div className="kxp-fp__canvaswrap">
<div
className="kxp-fp__canvas"
style={{ transform: `scale(${scale})` }}
role="group"
aria-label="Hall 1-A 부스 배치"
>
{BOOTHS.map((b, i) => (
<button
key={`${b.code}-${i}`}
type="button"
className={boothClass(b)}
style={b.span ? { gridColumn: `span ${b.span}` } : undefined}
onClick={() => b.status !== 'public' && b.status !== 'pending' && setSelected(b)}
disabled={b.status === 'public' || b.status === 'pending'}
aria-label={`부스 ${b.code} ${b.name}`}
>
{b.code}
{b.status === 'reserved' && <span className="kxp-booth__badge">R</span>}
</button>
))}
</div>
</div>
{/* 팝오버 */}
{selected && (
<div
className="kxp-fp__pop"
style={{ left: 24, top: 24 }}
role="dialog"
aria-label={`${selected.name} 정보`}
>
<div className="kxp-fp__pop-head">
<div className="kxp-fp__pop-top">
<span className="kxp-chip">{selected.code}</span>
<button
type="button"
className="kxp-fp__pop-close"
aria-label="닫기"
onClick={() => setSelected(null)}
>
<IconClose width={18} height={18} />
</button>
</div>
<h5 className="kxp-fp__pop-name">{selected.name}</h5>
<p className="kxp-fp__pop-cat"> · {selected.category}</p>
</div>
<div className="kxp-fp__pop-foot">
<a className="kxp-fp__pop-link" href="#top">
<IconArrowRight width={14} height={14} />
</a>
<a className="kxp-btn kxp-btn--outline" href="#top" style={{ padding: '6px 12px', fontSize: 12 }}>
</a>
</div>
</div>
)}
{/* 줌 컨트롤 */}
<div className="kxp-fp__zoom">
<div className="kxp-fp__zoomgroup">
<button
type="button"
className="kxp-fp__zoombtn"
aria-label="확대"
onClick={() => setScale((s) => Math.min(2, s + 0.1))}
>
<IconZoomIn />
</button>
<button
type="button"
className="kxp-fp__zoombtn"
aria-label="축소"
onClick={() => setScale((s) => Math.max(0.6, s - 0.1))}
>
<IconZoomOut />
</button>
<button
type="button"
className="kxp-fp__zoombtn"
aria-label="초기화"
onClick={() => {
setScale(1);
setSelected(null);
}}
>
<IconReset />
</button>
</div>
<a
className="kxp-btn kxp-btn--primary"
href="#top"
aria-label="내 위치"
style={{ padding: 12 }}
>
<IconTarget />
</a>
</div>
{/* 범례 */}
<div className="kxp-fp__legend" aria-label="범례">
<span>
<span className="kxp-fp__dot kxp-fp__dot--avail" /> (Available)
</span>
<span>
<span className="kxp-fp__dot kxp-fp__dot--reserved" /> (Reserved)
</span>
<span>
<span className="kxp-fp__dot kxp-fp__dot--public" /> (Public)
</span>
</div>
</section>
</div>
</PublicShell>
);
}

View File

@ -1,248 +0,0 @@
/*
* SCR-P1 (M12) .
* design.md §3B. + / + (GTX-A) .
* 데이터: 공개 API (_workspace/port_public.md ).
*/
import { PublicShell } from './PublicShell';
import {
IconSparkles,
IconCalendar,
IconPin,
IconArrowRight,
IconTrain,
IconBus,
IconParking,
IconShuttle,
IconMap,
} from './publicIcons';
interface SampleEvent {
id: string;
title: string;
dates: string;
hall: string;
tags: string[];
dday: number;
ai?: boolean;
accent: 'primary' | 'ai';
}
const SAMPLE_EVENTS: SampleEvent[] = [
{
id: 'smart-factory-2026',
title: '스마트팩토리 & AI 엑스포 2026',
dates: '2026.08.18 08.21',
hall: '제1전시장 1~5홀',
tags: ['EXHIBITION', 'TECH'],
dday: 15,
ai: true,
accent: 'primary',
},
{
id: 'comic-world-summer',
title: '코믹월드 썸머 2026',
dates: '2026.07.24 07.26',
hall: '제2전시장 7, 8홀',
tags: ['CULTURE', 'FESTIVAL'],
dday: 3,
accent: 'ai',
},
{
id: 'boat-show-2026',
title: '경기 국제 보트쇼 2026',
dates: '2026.04.11 04.14',
hall: '제1전시장 3, 4, 5홀',
tags: ['EXHIBITION', 'LEISURE'],
dday: 45,
accent: 'primary',
},
];
function ddayClass(d: number) {
if (d <= 3) return 'kxp-dday kxp-dday--soon';
if (d <= 7) return 'kxp-dday kxp-dday--warn';
return 'kxp-dday';
}
export function PublicHomePage() {
return (
<PublicShell active="events">
{/* 히어로 */}
<section
className="kxp-hero"
aria-label="킨텍스 소개"
style={{
backgroundColor: 'var(--color-canvas-bg)',
}}
>
<div
className="kxp-hero__bg"
style={{
background:
'linear-gradient(135deg, #12233b 0%, #1f3a5f 55%, #0066b3 130%)',
}}
aria-hidden
/>
<div className="kxp-hero__scrim" aria-hidden />
<div className="kxp-hero__inner">
<div className="kxp-hero__content">
<span className="kxp-hero__eyebrow">
<IconSparkles width={16} height={16} /> Global Exhibition Platform
</span>
<h1 className="kxp-hero__title">
,
<br />
<em></em>
</h1>
<p className="kxp-hero__lead">
KINTEX에서
.
</p>
<span className="kxp-sample" title="공개 카탈로그 API 연동 전 임시 데이터">
</span>
<div className="kxp-hero__actions">
<a className="kxp-btn kxp-btn--primary kxp-btn--lg" href="#events">
</a>
<a className="kxp-btn kxp-btn--ghost kxp-btn--lg" href="#transport">
</a>
</div>
</div>
</div>
</section>
{/* 진행/예정 행사 */}
<section className="kxp-section" id="events" aria-label="진행중 예정 행사">
<div className="kxp-wrap">
<div className="kxp-section__head">
<div>
<h2 className="kxp-section__title"> · </h2>
<p className="kxp-section__sub"> .</p>
</div>
<a className="kxp-section__more" href="#events">
<IconArrowRight width={16} height={16} />
</a>
</div>
<div className="kxp-eventgrid">
{SAMPLE_EVENTS.map((ev) => (
<article
key={ev.id}
className="kxp-ecard"
style={ev.accent === 'ai' ? { borderLeftColor: 'var(--color-ai-accent)' } : undefined}
>
<div className="kxp-ecard__media">
<div
style={{
width: '100%',
height: '100%',
background:
ev.accent === 'ai'
? 'linear-gradient(135deg, #6d4aff 0%, #1f29fc 100%)'
: 'linear-gradient(135deg, #0066b3 0%, #004c86 100%)',
}}
aria-hidden
/>
{ev.ai && (
<span className="kxp-ecard__topright kxp-chip kxp-chip--ai">
<IconSparkles width={13} height={13} /> AI
</span>
)}
<span className={`kxp-ecard__botleft ${ddayClass(ev.dday)}`}>D-{ev.dday}</span>
</div>
<div className="kxp-ecard__body">
<div className="kxp-ecard__tags">
{ev.tags.map((t) => (
<span key={t} className="kxp-tag">
{t}
</span>
))}
</div>
<h3 className="kxp-ecard__title">{ev.title}</h3>
<div className="kxp-ecard__meta">
<span>
<IconCalendar width={18} height={18} /> {ev.dates}
</span>
<span>
<IconPin width={18} height={18} /> {ev.hall}
</span>
</div>
<a className="kxp-btn kxp-btn--primary kxp-btn--block" href="#events">
</a>
</div>
</article>
))}
</div>
</div>
</section>
{/* 교통 */}
<section className="kxp-section kxp-section--alt" id="transport" aria-label="오시는 길">
<div className="kxp-wrap">
<div className="kxp-transport">
<div>
<h2 className="kxp-section__title"> </h2>
<p className="kxp-section__sub"> .</p>
<div className="kxp-transport__gtx">
<span className="kxp-transport__gtx-ic">
<IconTrain width={30} height={30} />
</span>
<div>
<h4>GTX-A </h4>
<p> 3 </p>
</div>
</div>
<div className="kxp-transport__opts">
<div className="kxp-transport__opt">
<IconBus />
<h5></h5>
<span> </span>
</div>
<div className="kxp-transport__opt">
<IconParking />
<h5></h5>
<span>4,000 </span>
</div>
<div className="kxp-transport__opt">
<IconShuttle />
<h5></h5>
<span> </span>
</div>
</div>
</div>
<div
className="kxp-transport__map"
style={{
background:
'linear-gradient(135deg, #d9eaf7 0%, #eff6fc 60%, #ffffff 100%)',
}}
role="img"
aria-label="킨텍스 위치 지도"
>
<div className="kxp-transport__mapcard">
<div>
<small> 217-60</small>
<strong>KINTEX 1, 2</strong>
</div>
<a
className="kxp-btn kxp-btn--primary"
href="#transport"
aria-label="지도 보기"
style={{ padding: 12, borderRadius: '9999px' }}
>
<IconMap />
</a>
</div>
</div>
</div>
</div>
</section>
</PublicShell>
);
}

View File

@ -1,220 +0,0 @@
/*
* SCR-P6 / (M12) .
* design.md §3B. ( ·) + + + .
* "준비 중" . () .
*/
import { useState } from 'react';
import { PublicShell } from './PublicShell';
import { IconPhone, IconMail, IconPin, IconSend, IconArrowRight, IconCheckCircle } from './publicIcons';
const BOOTH_TYPES = [
{
tier: 'Entry Tier',
title: '조립부스 (Shell Scheme)',
desc: '기본 벽체와 바닥이 제공되는 가장 경제적인 선택. 소규모 기업에 추천합니다.',
price: '₩ 2,500,000 / unit',
},
{
tier: 'Design Freedom',
title: '독립부스 (Raw Space)',
desc: '공간만 제공되어 브랜드에 맞는 자유로운 디자인 설계가 가능합니다.',
price: '₩ 2,000,000 / unit',
},
{
tier: 'Full Solution',
title: '프리미엄 (Premium)',
desc: 'AI 최적화 동선 설계 및 맞춤형 브랜딩 패키지가 포함된 올인원 솔루션입니다.',
price: '별도 문의',
},
];
export function PublicInquiryPage() {
const [agree, setAgree] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState('');
const submit = () => {
setError('');
if (!agree) {
setError('개인정보 수집 및 이용에 동의해 주세요.');
return;
}
setSent(true);
};
return (
<PublicShell active="exhibit" cta="참가 문의" search={false}>
{/* 히어로 */}
<section
className="kxp-hero"
aria-label="참가 문의"
style={{ backgroundColor: 'var(--color-canvas-bg)', minHeight: 360 }}
>
<div
className="kxp-hero__bg"
style={{ background: 'linear-gradient(120deg, #004c86 0%, #0066b3 60%, #12395f 130%)' }}
aria-hidden
/>
<div className="kxp-hero__scrim" aria-hidden />
<div className="kxp-hero__inner">
<div className="kxp-hero__content">
<h1 className="kxp-hero__title" style={{ fontSize: 'clamp(28px, 5vw, 40px)' }}>
</h1>
<p className="kxp-hero__lead">
KINTEX와 .
.
</p>
</div>
</div>
</section>
{/* 참가 안내 */}
<section className="kxp-section" aria-label="참가 안내">
<div className="kxp-wrap">
<div className="kxp-section__head" style={{ justifyContent: 'center', flexDirection: 'column', textAlign: 'center' }}>
<h2 className="kxp-section__title"> </h2>
<span className="kxp-sample"> </span>
</div>
<div className="kxp-boothtypes">
{BOOTH_TYPES.map((b) => (
<article className="kxp-boothtype" key={b.title}>
<div
className="kxp-boothtype__media"
style={{ background: 'linear-gradient(135deg, #d9eaf7 0%, #eff6fc 100%)' }}
role="img"
aria-label={`${b.title} 예시`}
/>
<div className="kxp-boothtype__body">
<span className="kxp-tag">{b.tier}</span>
<h3>{b.title}</h3>
<p>{b.desc}</p>
<div className="kxp-boothtype__price">{b.price}</div>
</div>
</article>
))}
</div>
</div>
</section>
{/* 문의 폼 */}
<section className="kxp-section kxp-section--alt" aria-label="문의하기">
<div className="kxp-wrap">
<div className="kxp-inquiry">
{/* 좌: 안내·연락처 */}
<div>
<h2 className="kxp-section__title" style={{ fontSize: 24 }}>
</h2>
<p className="kxp-section__sub" style={{ marginBottom: 20 }}>
. 24
.
</p>
<div className="kxp-contacts">
<div className="kxp-contact">
<span className="kxp-contact__ic">
<IconPhone width={18} height={18} />
</span>
<div>
<small> </small>
<strong>+82-31-810-8114</strong>
</div>
</div>
<div className="kxp-contact">
<span className="kxp-contact__ic">
<IconMail width={18} height={18} />
</span>
<div>
<small> </small>
<strong>exhibit@kintex.com</strong>
</div>
</div>
<div className="kxp-contact">
<span className="kxp-contact__ic">
<IconPin width={18} height={18} />
</span>
<div>
<small> </small>
<strong> 217-60</strong>
</div>
</div>
</div>
<div className="kxp-login-hint" style={{ marginTop: 16 }}>
<p> ?</p>
<a href="#top">
<IconArrowRight width={16} height={16} />
</a>
</div>
</div>
{/* 우: 폼 */}
<div className="kxp-formcard">
{sent && (
<div className="kxp-alert kxp-alert--success" role="status">
<IconCheckCircle width={18} height={18} /> . ( )
</div>
)}
<div className="kxp-formgrid kxp-formgrid--2">
<div className="kxp-field">
<label htmlFor="iq-company"></label>
<input id="iq-company" className="kxp-input" placeholder="회사명을 입력하세요" />
</div>
<div className="kxp-field">
<label htmlFor="iq-name"></label>
<input id="iq-name" className="kxp-input" placeholder="성함을 입력하세요" />
</div>
</div>
<div className="kxp-field">
<label htmlFor="iq-contact"> ( )</label>
<input id="iq-contact" className="kxp-input" placeholder="연락 가능한 정보를 입력하세요" />
</div>
<div className="kxp-formgrid kxp-formgrid--2">
<div className="kxp-field">
<label htmlFor="iq-scale"> </label>
<select id="iq-scale" className="kxp-select">
<option value=""></option>
<option>1 (9sqm)</option>
<option>2-4 (18-36sqm)</option>
<option>5-9 (45-81sqm)</option>
<option>10 </option>
</select>
</div>
<div className="kxp-field">
<label htmlFor="iq-hall"> </label>
<select id="iq-hall" className="kxp-select">
<option value=""> </option>
{Array.from({ length: 10 }, (_, i) => (
<option key={i}>Hall {i + 1}</option>
))}
</select>
</div>
</div>
<div className="kxp-field">
<label htmlFor="iq-msg"> </label>
<textarea
id="iq-msg"
className="kxp-textarea"
placeholder="참가 목적이나 특별 요청사항이 있다면 남겨주세요."
/>
</div>
<label className="kxp-check">
<input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} />
<span>
. <span className="kxp-check__req">()</span>
</span>
</label>
{error && (
<p role="alert" style={{ color: 'var(--color-error)', fontSize: 'var(--fs-body)', margin: 0 }}>
{error}
</p>
)}
<button type="button" className="kxp-btn kxp-btn--primary kxp-btn--block kxp-btn--lg" onClick={submit}>
<IconSend width={18} height={18} />
</button>
</div>
</div>
</div>
</section>
</PublicShell>
);
}

View File

@ -1,154 +0,0 @@
/*
* SCR-P5 (M17) .
* design.md §3B. + ( ) + + AI () + CTA.
* 데이터: 공개 API .
*/
import { PublicShell } from './PublicShell';
import { IconPin, IconMap, IconArrowRight } from './publicIcons';
const PRODUCTS = [
{
tag: 'Industrial COBOT',
title: 'HR-A1 협동 로봇',
desc: '고도의 정밀도와 안전성을 갖춘 산업용 협동 로봇. 인간과의 협업을 최우선으로 설계했습니다.',
accent: '#0066b3',
},
{
tag: 'Smart Logistics',
title: 'HR-M3 자율주행 AMR',
desc: '스마트 팩토리 물류 효율을 극대화하는 자율 이동 로봇. 실시간 장애물 회피와 경로 최적화.',
accent: '#6d4aff',
},
{
tag: 'AI Vision',
title: 'HR-Q Vision 스캐너',
desc: 'AI 딥러닝 기반 완벽한 품질 검수. 미세 결함도 놓치지 않는 초정밀 3D 스캐닝.',
accent: '#0e8a5f',
},
];
const BRAND = '(주)한빛로보틱스';
export function PublicMicrositePage() {
return (
<PublicShell active="exhibit" brand={BRAND} cta="문의하기" search={false}>
{/* 브랜드 히어로 */}
<section
className="kxp-hero"
aria-label={`${BRAND} 소개`}
style={{ backgroundColor: 'var(--color-canvas-bg)' }}
>
<div
className="kxp-hero__bg"
style={{ background: 'linear-gradient(120deg, #0b1b2e 0%, #12395f 55%, #0066b3 130%)' }}
aria-hidden
/>
<div className="kxp-hero__scrim" aria-hidden />
<div className="kxp-hero__inner">
<div className="kxp-hero__content">
<span className="kxp-hero__eyebrow">Global Robotics Leader</span>
<h1 className="kxp-hero__title">{BRAND}</h1>
<p className="kxp-hero__lead"> </p>
<div className="kxp-hero__actions">
<a className="kxp-btn kxp-btn--primary kxp-btn--lg" href="#meeting">
</a>
<a className="kxp-btn kxp-btn--ghost kxp-btn--lg" href="#products">
</a>
</div>
</div>
</div>
</section>
{/* 부스 위치 카드 (플로어플랜 딥링크) */}
<div className="kxp-ms-booth">
<div className="kxp-ms-booth__card">
<div className="kxp-ms-booth__left">
<span className="kxp-ms-booth__ic">
<IconPin />
</span>
<div>
<div style={{ fontSize: 'var(--fs-caption)', color: 'var(--color-neutral-500)' }}>
KINTEX
</div>
<div className="kxp-ms-booth__loc"> A-102 · 7</div>
</div>
</div>
<a className="kxp-btn kxp-btn--outline" href="#top">
<IconMap width={18} height={18} />
</a>
</div>
</div>
{/* 제품 라인업 */}
<section className="kxp-section" id="products" aria-label="핵심 제품 라인업">
<div className="kxp-wrap">
<div className="kxp-section__head">
<div>
<h2 className="kxp-section__title"> </h2>
<p className="kxp-section__sub"> .</p>
</div>
<span className="kxp-sample"> </span>
</div>
<div className="kxp-products">
{PRODUCTS.map((p) => (
<article className="kxp-product" key={p.title} style={{ borderLeftColor: p.accent }}>
<div
className="kxp-product__media"
style={{ background: `linear-gradient(135deg, ${p.accent} 0%, #101828 170%)` }}
role="img"
aria-label={`${p.title} 이미지`}
/>
<div className="kxp-product__body">
<span className="kxp-tag">{p.tag}</span>
<h4>{p.title}</h4>
<p>{p.desc}</p>
<a className="kxp-btn kxp-btn--outline kxp-btn--block" href="#top">
</a>
</div>
</article>
))}
</div>
</div>
</section>
{/* AI 부스 예상샷 갤러리 (워터마크 고지) */}
<section className="kxp-section kxp-section--alt" aria-label="부스 미리보기">
<div className="kxp-wrap">
<div className="kxp-section__head" style={{ justifyContent: 'center', textAlign: 'center', flexDirection: 'column' }}>
<h2 className="kxp-section__title"> </h2>
<p className="kxp-section__sub">
AI가 . .
</p>
</div>
<div className="kxp-gallery">
{[
{ g: 'linear-gradient(135deg, #1f3a5f 0%, #0066b3 100%)', label: '메인 전시 존' },
{ g: 'linear-gradient(135deg, #2a2350 0%, #6d4aff 120%)', label: 'VIP 라운지' },
].map((it) => (
<div
key={it.label}
className="kxp-gallery__item"
style={{ background: it.g }}
role="img"
aria-label={`${it.label} AI 생성 예상 이미지`}
>
<span className="kxp-aiwm">AI </span>
</div>
))}
</div>
</div>
</section>
{/* 미팅 예약 CTA */}
<section className="kxp-cta-banner" id="meeting" aria-label="미팅 예약">
<h2> .</h2>
<a className="kxp-btn kxp-btn--outline kxp-btn--lg" href="#top">
<IconArrowRight width={18} height={18} />
</a>
</section>
</PublicShell>
);
}

View File

@ -1,259 +0,0 @@
/*
* SCR-P4 (M10) .
* design.md §3B. 4 (). "준비 중".
* () . QR .
*/
import { useState } from 'react';
import { PublicShell } from './PublicShell';
import {
IconPerson,
IconBuyer,
IconVip,
IconCheckCircle,
IconQr,
IconArrowRight,
IconChevronRight,
IconBell,
} from './publicIcons';
const STEPS = ['유형', '정보', '관심분야', '완료'];
const TYPES = [
{ key: 'general', ic: <IconPerson width={26} height={26} />, title: '일반 관람객', desc: '전시 및 공개 세션 참관이 가능한 일반 방문객' },
{ key: 'buyer', ic: <IconBuyer width={26} height={26} />, title: '바이어', desc: 'B2B 매칭 및 비즈니스 상담을 위한 기업 담당자' },
{ key: 'vip', ic: <IconVip width={26} height={26} />, title: 'VIP', desc: '초청 인사 전용 라운지 및 전 세션 입장 귀빈' },
];
const INTERESTS = ['AI 솔루션', '스마트 팩토리', '로보틱스', '빅데이터', '클라우드'];
const TYPE_LABEL: Record<string, string> = {
general: '일반 관람객',
buyer: '바이어',
vip: 'VIP',
};
export function PublicRegistrationPage() {
const [step, setStep] = useState(0); // 0..3
const [type, setType] = useState('general');
const [name, setName] = useState('');
const [interests, setInterests] = useState<string[]>([]);
const [agreePrivacy, setAgreePrivacy] = useState(false);
const [agreeMkt, setAgreeMkt] = useState(false);
const [error, setError] = useState('');
const toggleInterest = (v: string) =>
setInterests((prev) => (prev.includes(v) ? prev.filter((i) => i !== v) : [...prev, v]));
const next = () => {
setError('');
if (step === 2 && !agreePrivacy) {
setError('개인정보 수집·이용 동의(필수)에 체크해 주세요.');
return;
}
setStep((s) => Math.min(3, s + 1));
};
const prev = () => {
setError('');
setStep((s) => Math.max(0, s - 1));
};
const fillPct = (step / (STEPS.length - 1)) * 100;
return (
<PublicShell active="visit" cta="사전등록" search={false}>
<div className="kxp-reg">
{/* 진행 인디케이터 */}
<div className="kxp-steps" role="list" aria-label="사전등록 단계">
<div className="kxp-steps__track" aria-hidden />
<div className="kxp-steps__fill" style={{ width: `calc(${fillPct}% - 48px * ${fillPct / 100})` }} aria-hidden />
{STEPS.map((label, i) => (
<div
key={label}
className={`kxp-step${i === step ? ' is-active' : ''}${i < step ? ' is-done' : ''}`}
role="listitem"
aria-current={i === step ? 'step' : undefined}
>
<span className="kxp-step__dot">{i < step ? <IconCheckCircle width={20} height={20} /> : i + 1}</span>
<span className="kxp-step__label">{label}</span>
</div>
))}
</div>
<div className="kxp-regcard">
{/* Step 1: 유형 */}
{step === 0 && (
<div>
<h2 className="kxp-regcard__title"> </h2>
<p className="kxp-regcard__sub">KINTEX .</p>
<div className="kxp-typegrid" role="radiogroup" aria-label="참가 유형">
{TYPES.map((t) => (
<button
key={t.key}
type="button"
role="radio"
aria-checked={type === t.key}
className={`kxp-typecard${type === t.key ? ' is-active' : ''}`}
onClick={() => setType(t.key)}
>
<span className="kxp-typecard__ic">{t.ic}</span>
<h3>{t.title}</h3>
<p>{t.desc}</p>
</button>
))}
</div>
</div>
)}
{/* Step 2: 정보 */}
{step === 1 && (
<div>
<h2 className="kxp-regcard__title" style={{ fontSize: 'var(--fs-h2)' }}>
</h2>
<p className="kxp-regcard__sub"> .</p>
<div className="kxp-formgrid kxp-formgrid--2">
<div className="kxp-field">
<label htmlFor="reg-name"></label>
<input
id="reg-name"
className="kxp-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="성함을 입력하세요"
/>
</div>
<div className="kxp-field">
<label htmlFor="reg-tel"> (Mobile)</label>
<input id="reg-tel" className="kxp-input" type="tel" placeholder="010-0000-0000" />
</div>
<div className="kxp-field kxp-formgrid__full">
<label htmlFor="reg-email"></label>
<input id="reg-email" className="kxp-input" type="email" placeholder="example@domain.com" />
</div>
<div className="kxp-field kxp-formgrid__full">
<label htmlFor="reg-org"> (/)</label>
<input id="reg-org" className="kxp-input" placeholder="회사명 또는 학교명" />
</div>
</div>
</div>
)}
{/* Step 3: 관심분야 + 동의 */}
{step === 2 && (
<div>
<h2 className="kxp-regcard__title" style={{ fontSize: 'var(--fs-h2)' }}>
</h2>
<p className="kxp-regcard__sub"> .</p>
<div className="kxp-interests" role="group" aria-label="관심 분야">
{INTERESTS.map((v) => (
<button
key={v}
type="button"
aria-pressed={interests.includes(v)}
className={`kxp-interest${interests.includes(v) ? ' is-active' : ''}`}
onClick={() => toggleInterest(v)}
>
{v}
</button>
))}
</div>
<div className="kxp-consent">
<div className="kxp-consent__row">
<label className="kxp-check">
<input
type="checkbox"
checked={agreePrivacy}
onChange={(e) => setAgreePrivacy(e.target.checked)}
/>
<span>
· <span className="kxp-check__req">()</span>
</span>
</label>
<a className="kxp-consent__more" href="#top">
</a>
</div>
<div className="kxp-consent__row">
<label className="kxp-check">
<input type="checkbox" checked={agreeMkt} onChange={(e) => setAgreeMkt(e.target.checked)} />
<span> ()</span>
</label>
<a className="kxp-consent__more" href="#top">
</a>
</div>
</div>
{error && (
<p role="alert" style={{ color: 'var(--color-error)', fontSize: 'var(--fs-body)', marginTop: 12 }}>
{error}
</p>
)}
</div>
)}
{/* Step 4: 완료 */}
{step === 3 && (
<div className="kxp-done">
<div className="kxp-done__ic">
<IconCheckCircle width={40} height={40} />
</div>
<h2 className="kxp-regcard__title"> </h2>
<p className="kxp-regcard__sub">
<b> </b>. .
</p>
<div className="kxp-badge" aria-label="모바일 배지 미리보기">
<div className="kxp-badge__top">
<span className="kxp-badge__pass">PASS</span>
<span className="kxp-badge__role">{TYPE_LABEL[type]}</span>
</div>
<div className="kxp-badge__name">{name || '홍길동'}</div>
<div className="kxp-badge__qr">
<div className="kxp-badge__qrbox">
<IconQr />
</div>
<div className="kxp-badge__id">Registration ID</div>
<div className="kxp-badge__idval">KTX-2026-0000-DEMO</div>
</div>
</div>
<div className="kxp-donenote">
<span style={{ display: 'inline-flex', gap: 8, alignItems: 'center' }}>
<IconBell width={18} height={18} /> QR을 .
</span>
<span> .</span>
<span className="kxp-sample" style={{ alignSelf: 'flex-start' }}>
·
</span>
</div>
</div>
)}
{/* 내비게이션 */}
{step < 3 ? (
<div className="kxp-regnav">
{step > 0 && (
<button type="button" className="kxp-btn kxp-btn--outline" onClick={prev}>
</button>
)}
<button type="button" className="kxp-btn kxp-btn--primary" onClick={next}>
{step === 2 ? '등록하기' : '다음'} <IconChevronRight width={18} height={18} />
</button>
</div>
) : (
<div className="kxp-regnav">
<a className="kxp-btn kxp-btn--outline" href="#top">
</a>
<a className="kxp-btn kxp-btn--primary" href="#top">
<IconArrowRight width={18} height={18} />
</a>
</div>
)}
</div>
</div>
</PublicShell>
);
}

View File

@ -1,178 +0,0 @@
/*
* GNB + . (AppShell , )
* design.md §3B: 전통 + SEO· ().
*/
import { useState, type ReactNode } from 'react';
import { IconGlobe, IconMenu, IconClose, IconSearch, IconChevronDown } from './publicIcons';
import './public.css';
const GNB = [
{ key: 'events', label: '행사' },
{ key: 'exhibit', label: '참가안내' },
{ key: 'visit', label: '관람안내' },
{ key: 'transport', label: '교통' },
];
const LANGS = ['한국어', 'English', '中文', '日本語'];
interface PublicShellProps {
children: ReactNode;
/** 현재 활성 GNB 키 (하이라이트) */
active?: string;
/** 헤더 우측 주요 CTA 라벨 (없으면 사전등록) */
cta?: string;
/** 헤더에 검색 노출 여부 */
search?: boolean;
/** 헤더 브랜드명 (마이크로사이트는 업체명) */
brand?: string;
}
export function PublicShell({
children,
active,
cta = '관람 사전등록',
search = true,
brand = 'KINTEX',
}: PublicShellProps) {
const [menuOpen, setMenuOpen] = useState(false);
const [langOpen, setLangOpen] = useState(false);
const [lang, setLang] = useState('한국어');
return (
<div className="kxp">
<header className="kxp-header">
<div className="kxp-header__inner">
<div className="kxp-header__left">
<a className="kxp-brand" href="#top" aria-label={`${brand}`}>
{brand}
</a>
<nav className="kxp-gnb" aria-label="주요 메뉴">
{GNB.map((it) => (
<a
key={it.key}
href="#top"
className={`kxp-gnb__link${active === it.key ? ' is-active' : ''}`}
aria-current={active === it.key ? 'page' : undefined}
>
{it.label}
</a>
))}
</nav>
</div>
<div className="kxp-header__right">
{search && (
<div className="kxp-search" role="search">
<IconSearch className="kxp-search__icon" />
<input
className="kxp-search__input"
type="search"
placeholder="행사 검색"
aria-label="행사 검색"
/>
</div>
)}
<div className="kxp-lang">
<button
type="button"
className="kxp-lang__btn"
aria-haspopup="listbox"
aria-expanded={langOpen}
onClick={() => setLangOpen((v) => !v)}
>
<IconGlobe width={18} height={18} />
<span>{lang}</span>
<IconChevronDown width={16} height={16} />
</button>
{langOpen && (
<ul className="kxp-lang__menu" role="listbox" aria-label="언어 선택">
{LANGS.map((l) => (
<li key={l} role="option" aria-selected={l === lang}>
<button
type="button"
className="kxp-lang__opt"
onClick={() => {
setLang(l);
setLangOpen(false);
}}
>
{l}
</button>
</li>
))}
</ul>
)}
</div>
<a className="kxp-btn kxp-btn--primary kxp-header__cta" href="#top">
{cta}
</a>
<button
type="button"
className="kxp-header__burger"
aria-label={menuOpen ? '메뉴 닫기' : '메뉴 열기'}
aria-expanded={menuOpen}
onClick={() => setMenuOpen((v) => !v)}
>
{menuOpen ? <IconClose /> : <IconMenu />}
</button>
</div>
</div>
{menuOpen && (
<nav className="kxp-mobilenav" aria-label="모바일 메뉴">
{GNB.map((it) => (
<a key={it.key} href="#top" className="kxp-mobilenav__link">
{it.label}
</a>
))}
<a className="kxp-btn kxp-btn--primary kxp-mobilenav__cta" href="#top">
{cta}
</a>
</nav>
)}
</header>
<main id="top" className="kxp-main">
{children}
</main>
<footer className="kxp-footer">
<div className="kxp-footer__inner">
<div className="kxp-footer__brand">
<div className="kxp-footer__logo">KINTEX</div>
<p className="kxp-footer__desc">
. .
</p>
</div>
<div className="kxp-footer__cols">
<div className="kxp-footer__col">
<h4></h4>
<a href="#top"> </a>
<a href="#top"> </a>
<a href="#top"> </a>
<a href="#top"></a>
</div>
<div className="kxp-footer__col">
<h4></h4>
<a href="#top"></a>
<a href="#top"></a>
<a href="#top"></a>
</div>
<div className="kxp-footer__col">
<h4></h4>
<span> 217-60</span>
<span> 1899-1001</span>
<span>exhibition@kintex.com</span>
</div>
</div>
</div>
<div className="kxp-footer__bottom">
© KINTEX. All Rights Reserved. (10390) 217-60, Kintex-ro, Ilsanseo-gu, Goyang-si
</div>
</footer>
</div>
);
}

View File

@ -1,559 +0,0 @@
/*
* SCR-P7 (M10·M9) .
* design.md §3B. () + ·(manage) .
* PG ( UI , PG ). PII .
* 데이터: 티켓·· API .
*/
import { useMemo, useState } from 'react';
import { PublicShell } from './PublicShell';
import {
IconTicket,
IconCalendar,
IconPin,
IconPlus,
IconMinus,
IconShield,
IconInfo,
IconCheckCircle,
IconQr,
IconArrowRight,
IconSparkles,
IconPerson,
} from './publicIcons';
interface TicketType {
key: string;
name: string;
desc: string;
price: number;
priceLabel: string;
badge?: { label: string; kind: 'ai' | 'off' };
soldout?: boolean;
accent: string;
}
const TICKETS: TicketType[] = [
{ key: 'general', name: '일반권', desc: '일반 관람객 및 개인 참가자', price: 0, priceLabel: '₩0 (사전등록 할인가)', accent: '#0066b3' },
{ key: 'buyer', name: '바이어권', desc: '유관 기업 종사자 및 바이어 전용', price: 0, priceLabel: '무료 · 자격심사 필요', badge: { label: 'AI OPTIMIZED', kind: 'ai' }, accent: '#6d4aff' },
{ key: 'group', name: '단체권 (10매 이상)', desc: '기업 단체 관람 전용', price: 9000, priceLabel: '₩9,000 / 1인', badge: { label: '10% OFF', kind: 'off' }, accent: '#0e8a5f' },
{ key: 'vip', name: 'VIP권', desc: '전시회 올패스 + VIP 라운지 이용권', price: 50000, priceLabel: '₩50,000', soldout: true, accent: '#101828' },
];
const STEP_LABELS = ['행사·권종', '예매자', '결제', '완료'];
const won = (n: number) => `${n.toLocaleString('ko-KR')}`;
function EventBanner() {
return (
<div className="kxp-ticket__banner">
<div
className="kxp-ticket__poster"
style={{ background: 'linear-gradient(135deg, #0066b3 0%, #6d4aff 130%)' }}
role="img"
aria-label="행사 포스터"
/>
<div>
<h1> 2026</h1>
<div className="kxp-ticket__facts">
<span>
<IconCalendar width={16} height={16} /> 2026.09.05 09.08
</span>
<span>
<IconPin width={16} height={16} /> 2 7
</span>
</div>
</div>
</div>
);
}
function PurchaseView() {
const [step, setStep] = useState(0);
const [qty, setQty] = useState<Record<string, number>>({});
const [pay, setPay] = useState('card');
const setCount = (key: string, delta: number) =>
setQty((prev) => {
const v = Math.max(0, (prev[key] ?? 0) + delta);
return { ...prev, [key]: v };
});
const { subtotal, discount, total, count } = useMemo(() => {
let sub = 0;
let disc = 0;
let cnt = 0;
for (const t of TICKETS) {
const q = qty[t.key] ?? 0;
cnt += q;
if (t.key === 'group') {
const base = 10000 * q; // 정가 기준 예시
sub += base;
disc += base - t.price * q;
} else {
sub += t.price * q;
}
}
return { subtotal: sub, discount: disc, total: sub - disc, count: cnt };
}, [qty]);
const isFree = total === 0;
const next = () => {
// 무료 권종만이면 결제 단계 자동 스킵
if (step === 1 && isFree) {
setStep(3);
return;
}
setStep((s) => Math.min(3, s + 1));
};
const prev = () => {
if (step === 3 && isFree) {
setStep(1);
return;
}
setStep((s) => Math.max(0, s - 1));
};
return (
<>
<EventBanner />
{/* 스텝 인디케이터 */}
<div className="kxp-ticket__steps" role="list" aria-label="예매 단계">
{STEP_LABELS.map((l, i) => (
<span
key={l}
className={`kxp-ticket__step${i === step ? ' is-active' : ''}`}
role="listitem"
aria-current={i === step ? 'step' : undefined}
>
{`0${i + 1} `}
{l}
</span>
))}
</div>
{/* 로그인 배너 */}
<div className="kxp-loginbanner">
<IconInfo width={18} height={18} /> .
<a href="#top"></a>
</div>
<div className="kxp-ticket__grid">
<div className="kxp-ticket__main">
{/* Step 1: 권종 */}
{step === 0 && (
<section>
<h2 className="kxp-ticket__h2">
<IconTicket width={20} height={20} />
<span className="kxp-sample" style={{ marginLeft: 'auto' }}>
</span>
</h2>
<div className="kxp-tickrows">
{TICKETS.map((t) => (
<div
key={t.key}
className={`kxp-tickrow${t.soldout ? ' kxp-tickrow--soldout' : ''}`}
style={{ borderLeftColor: t.soldout ? undefined : t.accent }}
>
<div className="kxp-tickrow__info">
<h3>
{t.name}
{t.badge && (
<span className={t.badge.kind === 'ai' ? 'kxp-chip kxp-chip--ai' : 'kxp-chip'}>
{t.badge.kind === 'ai' && <IconSparkles width={12} height={12} />}
{t.badge.label}
</span>
)}
{t.soldout && <span className="kxp-status kxp-status--soldout"></span>}
</h3>
<p>{t.desc}</p>
<div className="kxp-tickrow__price">{t.priceLabel}</div>
</div>
<div className="kxp-stepper" aria-label={`${t.name} 수량`}>
<button
type="button"
aria-label="감소"
disabled={t.soldout}
onClick={() => setCount(t.key, -1)}
>
<IconMinus width={16} height={16} />
</button>
<span>{qty[t.key] ?? 0}</span>
<button
type="button"
aria-label="증가"
disabled={t.soldout}
onClick={() => setCount(t.key, 1)}
>
<IconPlus width={16} height={16} />
</button>
</div>
</div>
))}
</div>
</section>
)}
{/* Step 2: 예매자 */}
{step === 1 && (
<section>
<h2 className="kxp-ticket__h2">
<IconPerson width={20} height={20} />
</h2>
<div className="kxp-formcard">
<div className="kxp-formgrid kxp-formgrid--2">
<div className="kxp-field">
<label htmlFor="tk-name"></label>
<input id="tk-name" className="kxp-input" placeholder="성함" />
</div>
<div className="kxp-field">
<label htmlFor="tk-tel"></label>
<input id="tk-tel" className="kxp-input" type="tel" placeholder="010-0000-0000" />
</div>
<div className="kxp-field kxp-formgrid__full">
<label htmlFor="tk-email"> ( )</label>
<input id="tk-email" className="kxp-input" type="email" placeholder="example@domain.com" />
</div>
</div>
<div className="kxp-consent">
<label className="kxp-check">
<input type="checkbox" />
<span>
· <span className="kxp-check__req">()</span>
</span>
</label>
<label className="kxp-check">
<input type="checkbox" />
<span> ()</span>
</label>
</div>
</div>
</section>
)}
{/* Step 3: 결제 */}
{step === 2 && (
<section>
<h2 className="kxp-ticket__h2">
<IconShield width={20} height={20} />
</h2>
<div className="kxp-paychips" role="group" aria-label="결제 수단">
{[
['card', '신용카드'],
['easy', '간편결제'],
['bank', '계좌이체'],
].map(([k, label]) => (
<button
key={k}
type="button"
className={`kxp-paychip${pay === k ? ' is-active' : ''}`}
aria-pressed={pay === k}
onClick={() => setPay(k)}
>
{label}
</button>
))}
</div>
<div className="kxp-pgnote">
<IconShield width={18} height={18} />
<span>
PG사 , .
256 SSL . (PG )
</span>
</div>
</section>
)}
{/* Step 4: 완료 */}
{step === 3 && (
<section className="kxp-tikdone">
<div className="kxp-done__ic" style={{ margin: '0 auto 16px' }}>
<IconCheckCircle width={40} height={40} />
</div>
<h2 className="kxp-regcard__title"> </h2>
<div className="kxp-tikdone__no">KTX-2026-0000-DEMO</div>
<p className="kxp-regcard__sub">
. ( ) {count} · {won(total)}
</p>
<div className="kxp-tikdone__qr">
<IconQr width={110} height={110} />
</div>
<span className="kxp-sample"> · </span>
<div className="kxp-tikdone__actions">
<a className="kxp-btn kxp-btn--outline" href="#top">
</a>
<a className="kxp-btn kxp-btn--primary" href="#top">
/ <IconArrowRight width={18} height={18} />
</a>
</div>
</section>
)}
</div>
{/* 우측 요약 */}
{step < 3 && (
<aside className="kxp-ticket__summary" aria-label="예매 내역 요약">
<h3> </h3>
<div className="kxp-sumrow">
<span> </span>
<span>{count}</span>
</div>
<div className="kxp-sumrow">
<span></span>
<span>{won(subtotal)}</span>
</div>
<div className="kxp-sumrow kxp-sumrow--discount">
<span></span>
<span>-{won(discount)}</span>
</div>
<div className="kxp-sumtotal">
<small> </small>
<strong>{won(total)}</strong>
</div>
<button
type="button"
className="kxp-btn kxp-btn--primary kxp-btn--block kxp-btn--lg"
disabled={count === 0}
onClick={next}
>
{step === 0 ? '다음 단계로 이동' : step === 1 ? (isFree ? '무료 예매 완료' : '결제하기') : '결제 진행'}
</button>
{step > 0 && (
<button
type="button"
className="kxp-btn kxp-btn--outline kxp-btn--block"
style={{ marginTop: 10 }}
onClick={prev}
>
</button>
)}
<p style={{ marginTop: 14, fontSize: 11, textAlign: 'center', color: 'var(--color-neutral-500)' }}>
1588-0000 ( 0918)
</p>
</aside>
)}
</div>
</>
);
}
function ManageView() {
const [looked, setLooked] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
return (
<>
<header style={{ textAlign: 'center', marginBottom: 24 }}>
<h1 className="kxp-regcard__title"> · </h1>
<p className="kxp-regcard__sub" style={{ marginBottom: 0 }}>
.
</p>
</header>
{/* 조회 폼 */}
<div className="kxp-manage__lookup">
<div className="kxp-manage__lookrow">
<div className="kxp-field">
<label htmlFor="mg-no"></label>
<input id="mg-no" className="kxp-input" placeholder="KTX-YYYY-XXXXXX" />
</div>
<div className="kxp-field">
<label htmlFor="mg-email"></label>
<input id="mg-email" className="kxp-input" type="email" placeholder="example@email.com" />
</div>
<button type="button" className="kxp-btn kxp-btn--primary" onClick={() => setLooked(true)}>
</button>
</div>
<div style={{ textAlign: 'center', marginTop: 14 }}>
<a href="#top" style={{ fontSize: 'var(--fs-caption)', color: 'var(--color-neutral-500)', textDecoration: 'underline' }}>
</a>
</div>
</div>
{!looked ? (
<p className="kxp-empty"> . (샘플: 아무 )</p>
) : (
<div className="kxp-manage__grid">
{/* 예매 상세 */}
<div>
<article className="kxp-bookcard">
<div className="kxp-bookcard__head">
<div>
<span className="kxp-status kxp-status--done"></span>
<h2> 2026</h2>
<span className="kxp-bookcard__no">
<b>KTX-2026-018245</b>
</span>
</div>
<div className="kxp-bookcard__paid">
<small></small>
<br />
<b>2025-10-24 14:32</b>
</div>
</div>
<table className="kxp-ticktable">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>
<div className="kxp-ticktable__cell">
<span className="kxp-ticktable__qr">
<IconQr width={22} height={22} />
</span>
(Early Bird)
</div>
</td>
<td>2</td>
<td> 30,000</td>
</tr>
<tr>
<td>
<div className="kxp-ticktable__cell">
<span className="kxp-ticktable__qr">
<IconQr width={22} height={22} />
</span>
/
</div>
</td>
<td>1</td>
<td> 10,000</td>
</tr>
</tbody>
</table>
<div className="kxp-booker">
<div className="kxp-booker__id">
<IconPerson width={20} height={20} />
<div>
<small> </small>
<br />
<b>jo***@ex***.com · 010-****-1234</b>
</div>
</div>
<div style={{ textAlign: 'right' }}>
<small style={{ fontSize: 'var(--fs-caption)', color: 'var(--color-neutral-500)' }}> </small>
<div style={{ fontSize: 'var(--fs-h2)', fontWeight: 800, color: 'var(--color-primary-700)' }}>
40,000
</div>
</div>
</div>
</article>
</div>
{/* 환불 정책 */}
<aside>
<div className="kxp-refund">
<h3>
<IconInfo width={18} height={18} /> ·
</h3>
<div className="kxp-refund__table">
<div className="kxp-refund__row kxp-refund__row--full">
<span> 7 (D-7)</span>
<b>100% </b>
</div>
<div className="kxp-refund__row kxp-refund__row--half">
<span> 3 (D-3)</span>
<b>50% </b>
</div>
<div className="kxp-refund__row kxp-refund__row--none">
<span> </span>
<b>0% ()</b>
</div>
</div>
<div className="kxp-refund__est">
<span> </span>
<strong> 40,000</strong>
</div>
<button
type="button"
className="kxp-btn kxp-btn--danger kxp-btn--block"
onClick={() => setConfirmOpen(true)}
>
</button>
<p className="kxp-refund__note"> PG사를 .</p>
</div>
<div className="kxp-ai-helper">
<IconSparkles width={18} height={18} />
<div>
<strong>AI </strong>
<p>
35
.
</p>
</div>
</div>
</aside>
</div>
)}
{confirmOpen && (
<div className="kxp-modal" role="dialog" aria-modal="true" aria-label="예매 취소 확인">
<div className="kxp-modal__scrim" onClick={() => setConfirmOpen(false)} aria-hidden />
<div className="kxp-modal__box">
<h3> ?</h3>
<p>
D-15 100% (40,000) . PG사를 ,
.
</p>
<div className="kxp-modal__actions">
<button type="button" className="kxp-btn kxp-btn--outline" onClick={() => setConfirmOpen(false)}>
</button>
<button type="button" className="kxp-btn kxp-btn--danger" onClick={() => setConfirmOpen(false)}>
</button>
</div>
</div>
</div>
)}
</>
);
}
export function PublicTicketPage() {
const [view, setView] = useState<'purchase' | 'manage'>('purchase');
return (
<PublicShell active="visit" cta="입장권 예매" search={false}>
<div className={view === 'purchase' ? 'kxp-ticket' : 'kxp-manage'}>
{/* 뷰 전환 */}
<div className="kxp-daytabs" style={{ justifyContent: 'center', marginBottom: 24 }} role="tablist" aria-label="티켓 화면 전환">
<button
type="button"
role="tab"
aria-selected={view === 'purchase'}
className={`kxp-daytab${view === 'purchase' ? ' is-active' : ''}`}
onClick={() => setView('purchase')}
>
</button>
<button
type="button"
role="tab"
aria-selected={view === 'manage'}
className={`kxp-daytab${view === 'manage' ? ' is-active' : ''}`}
onClick={() => setView('manage')}
>
·
</button>
</div>
{view === 'purchase' ? <PurchaseView /> : <ManageView />}
</div>
</PublicShell>
);
}

View File

@ -1,12 +0,0 @@
/*
* (SCR-P1~P7) ( ) .
* . .
*/
export { PublicShell } from './PublicShell';
export { PublicHomePage } from './PublicHomePage'; // SCR-P1 /public
export { PublicEventDetailPage } from './PublicEventDetailPage'; // SCR-P2 /public/events/:eventId
export { PublicFloorplanPage } from './PublicFloorplanPage'; // SCR-P3 /public/events/:eventId/floorplan
export { PublicRegistrationPage } from './PublicRegistrationPage'; // SCR-P4 /public/events/:eventId/register
export { PublicMicrositePage } from './PublicMicrositePage'; // SCR-P5 /public/exhibitors/:exhibitorId
export { PublicInquiryPage } from './PublicInquiryPage'; // SCR-P6 /public/exhibit-inquiry
export { PublicTicketPage } from './PublicTicketPage'; // SCR-P7 /tickets/:eventId/purchase · /tickets/lookup

File diff suppressed because it is too large Load Diff

View File

@ -1,314 +0,0 @@
/*
* SVG (stroke , ).
* design.md §1 . fill:none·stroke:currentColor .
*/
import type { SVGProps } from 'react';
type IconProps = SVGProps<SVGSVGElement>;
function base(props: IconProps) {
return {
width: 20,
height: 20,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.8,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
'aria-hidden': true,
focusable: false,
...props,
};
}
export const IconSearch = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
);
export const IconGlobe = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3c2.5 2.5 2.5 15 0 18M12 3c-2.5 2.5-2.5 15 0 18" />
</svg>
);
export const IconCalendar = (p: IconProps) => (
<svg {...base(p)}>
<rect x="3" y="4" width="18" height="17" rx="2" />
<path d="M3 9h18M8 2v4M16 2v4" />
</svg>
);
export const IconPin = (p: IconProps) => (
<svg {...base(p)}>
<path d="M12 21s-7-5.5-7-11a7 7 0 0 1 14 0c0 5.5-7 11-7 11Z" />
<circle cx="12" cy="10" r="2.5" />
</svg>
);
export const IconArrowRight = (p: IconProps) => (
<svg {...base(p)}>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
);
export const IconChevronRight = (p: IconProps) => (
<svg {...base(p)}>
<path d="m9 6 6 6-6 6" />
</svg>
);
export const IconChevronDown = (p: IconProps) => (
<svg {...base(p)}>
<path d="m6 9 6 6 6-6" />
</svg>
);
export const IconClose = (p: IconProps) => (
<svg {...base(p)}>
<path d="M6 6l12 12M18 6 6 18" />
</svg>
);
export const IconCheck = (p: IconProps) => (
<svg {...base(p)}>
<path d="m5 13 4 4L19 7" />
</svg>
);
export const IconCheckCircle = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="12" r="9" />
<path d="m8.5 12 2.5 2.5 4.5-5" />
</svg>
);
export const IconPlus = (p: IconProps) => (
<svg {...base(p)}>
<path d="M12 5v14M5 12h14" />
</svg>
);
export const IconMinus = (p: IconProps) => (
<svg {...base(p)}>
<path d="M5 12h14" />
</svg>
);
export const IconTrain = (p: IconProps) => (
<svg {...base(p)}>
<rect x="5" y="3" width="14" height="13" rx="3" />
<path d="M5 10h14M9 20l-2 2M15 20l2 2" />
<circle cx="9" cy="13" r="0.6" />
<circle cx="15" cy="13" r="0.6" />
</svg>
);
export const IconBus = (p: IconProps) => (
<svg {...base(p)}>
<rect x="4" y="4" width="16" height="13" rx="2" />
<path d="M4 11h16M8 21v-2M16 21v-2" />
<circle cx="8" cy="14" r="0.6" />
<circle cx="16" cy="14" r="0.6" />
</svg>
);
export const IconParking = (p: IconProps) => (
<svg {...base(p)}>
<rect x="4" y="4" width="16" height="16" rx="3" />
<path d="M9 16V8h3.5a2.5 2.5 0 0 1 0 5H9" />
</svg>
);
export const IconShuttle = (p: IconProps) => (
<svg {...base(p)}>
<path d="M3 13l2-6a2 2 0 0 1 2-1.5h8A2 2 0 0 1 19 7l2 6v4h-2M3 17v-4M3 17h14" />
<circle cx="7" cy="17" r="1.5" />
<circle cx="17" cy="17" r="1.5" />
</svg>
);
export const IconMap = (p: IconProps) => (
<svg {...base(p)}>
<path d="M9 4 3 6v14l6-2 6 2 6-2V4l-6 2-6-2Z" />
<path d="M9 4v14M15 6v14" />
</svg>
);
export const IconMenu = (p: IconProps) => (
<svg {...base(p)}>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
);
export const IconPerson = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="8" r="3.5" />
<path d="M5 20a7 7 0 0 1 14 0" />
</svg>
);
export const IconBuyer = (p: IconProps) => (
<svg {...base(p)}>
<path d="M5 7h14l-1.5 10.5a2 2 0 0 1-2 1.5h-7a2 2 0 0 1-2-1.5L5 7Z" />
<path d="M9 7a3 3 0 0 1 6 0" />
</svg>
);
export const IconVip = (p: IconProps) => (
<svg {...base(p)}>
<path d="m12 3 2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4L4.2 8.7l5.4-.8L12 3Z" />
</svg>
);
export const IconSparkles = (p: IconProps) => (
<svg {...base(p)}>
<path d="M12 4v6M9 7h6" opacity="0" />
<path d="M12 3l1.5 4L18 8.5 13.5 10 12 14l-1.5-4L6 8.5 10.5 7 12 3Z" />
<path d="M18 14l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8.8-2Z" />
</svg>
);
export const IconQr = (p: IconProps) => (
<svg {...base(p)}>
<rect x="4" y="4" width="6" height="6" rx="1" />
<rect x="14" y="4" width="6" height="6" rx="1" />
<rect x="4" y="14" width="6" height="6" rx="1" />
<path d="M14 14h3v3M20 14v6M14 20h3" />
</svg>
);
export const IconInfo = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5M12 8h.01" />
</svg>
);
export const IconSend = (p: IconProps) => (
<svg {...base(p)}>
<path d="M21 3 3 10.5l7 2.5 2.5 7L21 3Z" />
<path d="M10 13.5 21 3" />
</svg>
);
export const IconPhone = (p: IconProps) => (
<svg {...base(p)}>
<path d="M5 3h3l1.5 5-2 1.5a12 12 0 0 0 5 5l1.5-2 5 1.5v3a2 2 0 0 1-2 2A16 16 0 0 1 3 5a2 2 0 0 1 2-2Z" />
</svg>
);
export const IconMail = (p: IconProps) => (
<svg {...base(p)}>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m3 7 9 6 9-6" />
</svg>
);
export const IconTicket = (p: IconProps) => (
<svg {...base(p)}>
<path d="M4 8a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2 2 2 0 0 0 0 4 2 2 0 0 1-2 2H6a2 2 0 0 1-2-2 2 2 0 0 0 0-4Z" />
<path d="M14 6v12" strokeDasharray="2 2" />
</svg>
);
export const IconShield = (p: IconProps) => (
<svg {...base(p)}>
<path d="M12 3 5 6v5c0 4.5 3 7.5 7 9 4-1.5 7-4.5 7-9V6l-7-3Z" />
<path d="m9 12 2 2 4-4" />
</svg>
);
export const IconShare = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="6" cy="12" r="2.5" />
<circle cx="18" cy="6" r="2.5" />
<circle cx="18" cy="18" r="2.5" />
<path d="m8 11 8-4M8 13l8 4" />
</svg>
);
export const IconChat = (p: IconProps) => (
<svg {...base(p)}>
<path d="M4 5h16v10H9l-4 4V5Z" />
</svg>
);
export const IconLink = (p: IconProps) => (
<svg {...base(p)}>
<path d="M9 15 15 9" />
<path d="M8 12H6a3 3 0 0 1 0-6h3M16 12h2a3 3 0 0 1 0 6h-3" />
</svg>
);
export const IconZoomIn = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5M11 8v6M8 11h6" />
</svg>
);
export const IconZoomOut = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5M8 11h6" />
</svg>
);
export const IconReset = (p: IconProps) => (
<svg {...base(p)}>
<path d="M20 12a8 8 0 1 1-2.3-5.6M20 4v3.5h-3.5" />
</svg>
);
export const IconTarget = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="12" r="8" />
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
</svg>
);
export const IconBell = (p: IconProps) => (
<svg {...base(p)}>
<path d="M6 9a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6Z" />
<path d="M10 19a2 2 0 0 0 4 0" />
</svg>
);
export const IconFactory = (p: IconProps) => (
<svg {...base(p)}>
<path d="M3 21V10l6 4V10l6 4V6l6 4v11H3Z" />
<path d="M7 21v-4M13 21v-4M18 21v-4" />
</svg>
);
export const IconRobot = (p: IconProps) => (
<svg {...base(p)}>
<rect x="5" y="8" width="14" height="11" rx="2" />
<path d="M12 4v4M8 13h.01M16 13h.01M9 16h6" />
<circle cx="12" cy="4" r="1" />
</svg>
);
export const IconCloud = (p: IconProps) => (
<svg {...base(p)}>
<path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.6-1.5A3.5 3.5 0 0 1 18 18H7Z" />
</svg>
);
export const IconLeaf = (p: IconProps) => (
<svg {...base(p)}>
<path d="M4 20c0-9 7-15 16-15 0 9-6 15-15 15M8 16c2-4 5-6 9-7" />
</svg>
);
export const IconVenue = (p: IconProps) => (
<svg {...base(p)}>
<path d="M3 21h18M5 21V9l7-5 7 5v12M9 21v-6h6v6" />
</svg>
);

View File

@ -1,226 +0,0 @@
import { useMemo, useState } from 'react';
import { AiLabel } from '../../components/ui/Badge';
import { Button } from '../../components/ui/Button';
import { IconDownload, IconSpark } from '../../components/ui/icons';
import { LEAD_KPIS, LEADS, type Lead } from './sampleVisitor';
import './visitor.css';
/*
* SCR-32 ·AI (M10). Stitch scr_32_lead_scoring .
* M10 API , sampleVisitor.LEADS .
* 불변: 리드 ·· (PII · R10).
*/
export function LeadScoringPage() {
const [hotOnly, setHotOnly] = useState(false);
const [selectedId, setSelectedId] = useState<string>(LEADS[0]?.id ?? '');
const rows = useMemo(
() => (hotOnly ? LEADS.filter((l) => l.score >= 80) : LEADS),
[hotOnly],
);
const selected = LEADS.find((l) => l.id === selectedId) ?? rows[0] ?? LEADS[0];
return (
<div className="kx-vis">
<header className="kx-vis__head">
<div>
<h1 className="kx-vis__title"> · AI </h1>
<p className="kx-vis__subtitle"> · · </p>
</div>
<div className="kx-vis__head-actions">
<span className="kx-sample" title="M10 리드 모듈 미연동 — 시연 데이터"> </span>
<Button variant="secondary" leadingIcon={<IconDownload size={16} />}>
CSV
</Button>
</div>
</header>
<section className="kx-vis__kpis kx-vis__kpis--4" aria-label="리드 핵심 지표">
{LEAD_KPIS.map((k) => (
<div key={k.label} className={`kx-kpi ${k.ai ? 'kx-kpi--ai' : ''}`}>
<span className="kx-kpi__label">
{k.label}
{k.ai && (
<span className="kx-kpi__ai" aria-hidden="true">
<IconSpark size={12} />
</span>
)}
</span>
<strong className="kx-kpi__value tnum">{k.value}</strong>
{k.sub && <span className="kx-kpi__sub">{k.sub}</span>}
</div>
))}
</section>
<div className="kx-vis__lead-split">
{/* 리드 테이블 */}
<section className="kx-card kx-vis__lead-table-card" aria-label="리드 목록">
<div className="kx-vis__filters" role="group" aria-label="리드 필터">
<span className="kx-vis__filters-label"></span>
<button
type="button"
className={`kx-chip ${hotOnly ? 'is-active' : ''}`}
aria-pressed={hotOnly}
onClick={() => setHotOnly((v) => !v)}
>
80+ {hotOnly && <span aria-hidden="true">×</span>}
</button>
<span className="kx-vis__filters-count tnum">{rows.length}</span>
</div>
<div className="kx-table-scroll">
<table className="kx-table">
<thead>
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"> </th>
<th scope="col"></th>
<th scope="col">AI </th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{rows.map((l) => {
const isSel = l.id === selected?.id;
return (
<tr
key={l.id}
className={`kx-vis__lead-row ${isSel ? 'is-selected' : ''}`}
onClick={() => setSelectedId(l.id)}
tabIndex={0}
aria-selected={isSel}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSelectedId(l.id);
}
}}
>
<td className="kx-vis__lead-name">{l.nameMasked}</td>
<td>{l.company}</td>
<td>{l.product}</td>
<td><Stars value={l.interest} /></td>
<td><ScoreGauge score={l.score} /></td>
<td className="tnum">{l.collectedAt}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="kx-vis__table-foot">
<span className="kx-vis__count"> 342 {rows.length} </span>
</div>
</section>
{/* 리드 상세·팔로업 */}
{selected && <LeadDetail lead={selected} />}
</div>
</div>
);
}
function LeadDetail({ lead }: { lead: Lead }) {
const hot = lead.score >= 80;
return (
<aside className="kx-vis__lead-detail" aria-label="리드 상세">
<div className="kx-card">
<div className="kx-vis__lead-profile">
<span className="kx-vis__avatar kx-vis__avatar--lg" aria-hidden="true">
{lead.nameMasked.charAt(0)}
</span>
<div>
<div className="kx-vis__lead-profile-top">
<h2>{lead.nameMasked}</h2>
{hot && <span className="kx-vis__hot">HOT</span>}
</div>
<p className="kx-vis__lead-role">{lead.role} · {lead.company}</p>
<p className="kx-vis__lead-contact">
{lead.phoneMasked} · {lead.emailMasked}
</p>
</div>
</div>
{/* AI 스코어 분석 */}
<div className="kx-vis__ai-card">
<div className="kx-vis__ai-card-head">
<span className="kx-vis__ai-card-title">
<AiLabel>AI </AiLabel>
</span>
<span className="kx-vis__ai-score tnum">{lead.score}</span>
</div>
<ul className="kx-vis__ai-reasons">
{lead.aiReasons.map((r, i) => (
<li key={i}>
<span className="kx-vis__ai-check" aria-hidden="true"></span>
{r}
</li>
))}
</ul>
</div>
{/* AI 추천 팔로업 */}
<div className="kx-vis__followup">
<label className="kx-vis__followup-label" htmlFor="kx-followup">
<IconSpark size={14} /> AI
</label>
<textarea id="kx-followup" className="kx-vis__followup-input" defaultValue={lead.followupDraft} rows={7} />
</div>
<div className="kx-vis__lead-actions">
<Button block>EDM </Button>
<div className="kx-vis__lead-actions-row">
<Button variant="secondary" leadingIcon={<IconDownload size={16} />}>CSV </Button>
<Button variant="secondary"> </Button>
</div>
</div>
</div>
<div className="kx-card">
<div className="kx-card__head">
<h2> </h2>
</div>
<ul className="kx-vis__activity">
{lead.activity.map((a, i) => (
<li key={i}>
<span className="kx-dot" />
<span className="kx-vis__activity-text">{a.text}</span>
<span className="kx-vis__activity-at">{a.at}</span>
</li>
))}
</ul>
</div>
</aside>
);
}
/** 관심도 별점(1~5, 선 SVG). */
function Stars({ value }: { value: number }) {
return (
<span className="kx-stars" role="img" aria-label={`관심도 ${value}점 / 5점`}>
{[1, 2, 3, 4, 5].map((n) => (
<StarGlyph key={n} filled={n <= value} />
))}
</span>
);
}
function StarGlyph({ filled }: { filled: boolean }) {
return (
<svg width={16} height={16} viewBox="0 0 24 24" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth={filled ? 0 : 1.6} className={filled ? 'is-filled' : 'is-empty'} aria-hidden="true">
<path d="M12 3l2.6 5.9 6.4.6-4.8 4.3 1.4 6.3L12 17.8 6.4 20.4l1.4-6.3L3 9.8l6.4-.6L12 3z" />
</svg>
);
}
/** AI 스코어 게이지(0~100). 80+ 는 AI 액센트(보라). */
function ScoreGauge({ score }: { score: number }) {
const hot = score >= 80;
return (
<span className="kx-gauge">
<span className="kx-gauge__track">
<span className={`kx-gauge__fill ${hot ? 'is-hot' : ''}`} style={{ width: `${score}%` }} />
</span>
<span className={`kx-gauge__num tnum ${hot ? 'is-hot' : ''}`}>{score}</span>
</span>
);
}

View File

@ -1,277 +0,0 @@
import {
Area,
AreaChart,
CartesianGrid,
Cell,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { AiLabel } from '../../components/ui/Badge';
import { Button } from '../../components/ui/Button';
import { IconDownload, IconSpark } from '../../components/ui/icons';
import { CHART } from '../chartColors';
import {
CHECKIN_LABEL,
REG_FORMS,
REG_KPIS,
REG_TREND,
REG_TYPES,
REG_TYPE_LABEL,
REGISTRANTS,
type CheckinState,
type RegVisitorType,
} from './sampleVisitor';
import './visitor.css';
/*
* SCR-30 (M10·M11). Stitch scr_30_visitor_reg_dashboard .
* M10/M11 API , sampleVisitor ("샘플 데이터" ).
* 불변: 관람객 ·· (PII , N2/R10).
*/
export function VisitorRegistrationDashboardPage() {
return (
<div className="kx-vis">
<header className="kx-vis__head">
<div>
<h1 className="kx-vis__title"> </h1>
<p className="kx-vis__subtitle"> · · </p>
</div>
<div className="kx-vis__head-actions">
<span className="kx-sample" title="M10 관람 모듈 미연동 — 시연 데이터"> </span>
<Button variant="secondary" leadingIcon={<IconDownload size={16} />}>
</Button>
</div>
</header>
{/* KPI 밴드 */}
<section className="kx-vis__kpis" aria-label="관람객 핵심 지표">
{REG_KPIS.map((k) => (
<div key={k.label} className={`kx-kpi ${k.ai ? 'kx-kpi--ai' : ''}`}>
<span className="kx-kpi__label">
{k.label}
{k.ai && (
<span className="kx-kpi__ai" aria-hidden="true">
<IconSpark size={12} />
</span>
)}
</span>
<strong className="kx-kpi__value tnum">{k.value}</strong>
{k.delta && (
<span className={`kx-delta kx-delta--${k.trend === 'down' ? 'down' : 'up'}`}>
{k.trend === 'down' ? '▼' : '▲'} {k.delta}
</span>
)}
{k.sub && <span className="kx-kpi__sub">{k.sub}</span>}
</div>
))}
</section>
{/* 추이(2/3) + 폼·배지(1/3) */}
<div className="kx-vis__split">
<section className="kx-card kx-vis__trend" aria-label="등록·체크인 추이">
<div className="kx-card__head">
<h2> · </h2>
<ul className="kx-vis__legend" aria-hidden="true">
<li>
<span className="kx-dot" style={{ background: CHART.primary600 }} />
</li>
<li>
<span className="kx-dot" style={{ background: CHART.slate }} />
</li>
</ul>
</div>
<p className="kx-card__hint"> D-14 · .</p>
<div className="kx-vis__chart">
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={REG_TREND} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<defs>
<linearGradient id="gPreReg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={CHART.primary600} stopOpacity={0.35} />
<stop offset="100%" stopColor={CHART.primary600} stopOpacity={0.02} />
</linearGradient>
<linearGradient id="gCheckIn" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={CHART.slate} stopOpacity={0.25} />
<stop offset="100%" stopColor={CHART.slate} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid stroke={CHART.neutral200} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={{ stroke: CHART.neutral200 }} />
<YAxis tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={false} width={48} tickFormatter={(v) => `${Math.round((v as number) / 1000)}`} />
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(value, name) => [`${(value as number).toLocaleString()}`, name]}
/>
<Area type="monotone" dataKey="preReg" name="사전등록" stroke={CHART.primary600} fill="url(#gPreReg)" strokeWidth={2} />
<Area type="monotone" dataKey="checkIn" name="체크인" stroke={CHART.slate} fill="url(#gCheckIn)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
<div className="kx-vis__donut-row">
<div className="kx-vis__donut">
<ResponsiveContainer width="100%" height={140}>
<PieChart>
<Pie data={REG_TYPES} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={38} outerRadius={58} paddingAngle={2} strokeWidth={0}>
{REG_TYPES.map((s) => (
<Cell key={s.name} fill={s.color} />
))}
</Pie>
<Tooltip contentStyle={TOOLTIP_STYLE} formatter={(value, name) => [`${value}%`, name]} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="kx-vis__donut-legend">
<p className="kx-vis__donut-title"> </p>
<ul>
{REG_TYPES.map((s) => (
<li key={s.name}>
<span className="kx-dot" style={{ background: s.color }} />
{s.name}
<strong className="tnum">{s.value}%</strong>
</li>
))}
</ul>
</div>
</div>
</section>
<aside className="kx-vis__aside">
<section className="kx-card" aria-label="등록 폼 관리">
<div className="kx-card__head">
<h2> </h2>
</div>
<ul className="kx-vis__forms">
{REG_FORMS.map((f) => (
<li key={f.id} className="kx-vis__form">
<span className="kx-vis__form-name">
<span className={`kx-dot ${f.active ? 'is-on' : 'is-off'}`} />
{f.name}
</span>
<span className="kx-vis__form-count tnum">{f.count.toLocaleString()}</span>
<span className={`kx-vis__form-state ${f.active ? 'is-on' : ''}`}>
{f.active ? '활성' : '비활성'}
</span>
</li>
))}
</ul>
<Button variant="ai" block leadingIcon={<IconSpark size={16} />}>
AI
</Button>
<p className="kx-vis__ai-note">
<AiLabel>AI </AiLabel> .
</p>
</section>
<section className="kx-card" aria-label="배지 템플릿">
<div className="kx-card__head">
<h2> 릿</h2>
</div>
<div className="kx-vis__badge-preview">
<div className="kx-badgecard">
<div className="kx-badgecard__top">KINTEX VISITOR</div>
<div className="kx-badgecard__qr" aria-hidden="true">
<QrGlyph />
</div>
<p className="kx-badgecard__name"> **</p>
<p className="kx-badgecard__org">Samsung Electronics</p>
<div className="kx-badgecard__foot">
<span className="kx-badgecard__tag">BUYER</span>
<span className="kx-badgecard__no tnum">2026-V01</span>
</div>
</div>
</div>
<Button variant="secondary" block> </Button>
</section>
</aside>
</div>
{/* 등록자 명단 */}
<section className="kx-card kx-vis__table-card" aria-label="전체 신청자 명단">
<div className="kx-card__head">
<h2> </h2>
<span className="kx-vis__pii-note"> · </span>
</div>
<div className="kx-table-scroll">
<table className="kx-table kx-table--zebra">
<thead>
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{REGISTRANTS.map((r) => (
<tr key={r.id}>
<td>
<span className="kx-vis__person">
<span className="kx-vis__avatar" aria-hidden="true">{r.initial}</span>
{r.nameMasked}
</span>
</td>
<td><TypePill type={r.type} /></td>
<td>{r.company}</td>
<td className="tnum">{r.registeredAt}</td>
<td><CheckinPill state={r.checkin} /></td>
<td>
{r.badgeIssued ? (
<span className="kx-vis__badge-issued" title="배지 발급 완료">
<QrGlyph size={16} />
</span>
) : (
<span className="kx-vis__badge-none"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="kx-vis__table-foot">
<span className="kx-vis__count"> 12,480 16 </span>
</div>
</section>
</div>
);
}
const TOOLTIP_STYLE = {
fontSize: 12,
borderRadius: 8,
border: '1px solid #E4E7EC',
boxShadow: '0 4px 12px rgba(16,24,40,0.1)',
} as const;
const TYPE_TONE: Record<RegVisitorType, string> = {
visitor: 'visitor',
buyer: 'buyer',
vip: 'vip',
};
function TypePill({ type }: { type: RegVisitorType }) {
return <span className={`kx-vis__type is-${TYPE_TONE[type]}`}>{REG_TYPE_LABEL[type]}</span>;
}
function CheckinPill({ state }: { state: CheckinState }) {
return (
<span className={`kx-vis__checkin is-${state}`}>
<span className="kx-dot" /> {CHECKIN_LABEL[state]}
</span>
);
}
/** QR 코드 글리프 (선 SVG, 장식용). */
function QrGlyph({ size = 40 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M3 3h7v7H3V3zm2 2v3h3V5H5zm-2 9h7v7H3v-7zm2 2v3h3v-3H5zM14 3h7v7h-7V3zm2 2v3h3V5h-3zM14 14h3v3h-3v-3zm4 0h3v3h-3v-3zm-4 4h3v3h-3v-3zm4 0h3v3h-3v-3z" />
</svg>
);
}

View File

@ -1,233 +0,0 @@
/*
* SCR-30 · SCR-32 (M10/M11).
* M10/M11 API ("샘플 데이터" ).
* (PII ): ·· /.
* PII API (N2/R10).
*/
// ── SCR-30 관람객 등록 대시보드 ──
export interface VisitorKpi {
label: string;
value: string;
delta?: string;
trend?: 'up' | 'down' | 'flat';
ai?: boolean;
sub?: string;
}
export const REG_KPIS: VisitorKpi[] = [
{ label: '사전등록', value: '12,480', delta: '+12.4%', trend: 'up' },
{ label: '체크인', value: '8,210', sub: '진행률 65.7%' },
{ label: '노쇼율', value: '12%', delta: '주의', trend: 'down' },
{ label: '바이어 비중', value: '34%', sub: '목표 30%' },
{ label: '리드 생성', value: '2,140', ai: true, sub: 'AI 예측 성과' },
];
export interface RegTrendPoint {
label: string;
preReg: number;
checkIn: number;
}
/** 개장 D-14 ~ 당일 누적 사전등록/체크인 추이. */
export const REG_TREND: RegTrendPoint[] = [
{ label: 'D-14', preReg: 4200, checkIn: 0 },
{ label: 'D-12', preReg: 5600, checkIn: 0 },
{ label: 'D-10', preReg: 6800, checkIn: 0 },
{ label: 'D-7', preReg: 8300, checkIn: 0 },
{ label: 'D-5', preReg: 9700, checkIn: 0 },
{ label: 'D-3', preReg: 11050, checkIn: 0 },
{ label: 'D-1', preReg: 12100, checkIn: 1240 },
{ label: '당일', preReg: 12480, checkIn: 8210 },
];
export interface RegTypeSlice {
name: string;
value: number;
color: string;
}
export const REG_TYPES: RegTypeSlice[] = [
{ name: '관람객', value: 62, color: '#0066B3' },
{ name: '바이어', value: 34, color: '#6D4AFF' },
{ name: 'VIP', value: 4, color: '#0E8A5F' },
];
export interface RegForm {
id: string;
name: string;
active: boolean;
count: number;
}
export const REG_FORMS: RegForm[] = [
{ id: 'general', name: '일반 참관객', active: true, count: 7740 },
{ id: 'buyer', name: '비즈니스 바이어', active: true, count: 4210 },
{ id: 'vip', name: 'VIP 초청', active: false, count: 530 },
];
export type RegVisitorType = 'visitor' | 'buyer' | 'vip';
export type CheckinState = 'done' | 'waiting' | 'cancelled';
export interface Registrant {
id: string;
/** 마스킹된 이름. */
nameMasked: string;
initial: string;
type: RegVisitorType;
company: string;
registeredAt: string;
checkin: CheckinState;
badgeIssued: boolean;
}
export const REGISTRANTS: Registrant[] = [
{ id: 'v1', nameMasked: '김*희', initial: 'K', type: 'visitor', company: 'LG전자', registeredAt: '2026.10.12', checkin: 'done', badgeIssued: true },
{ id: 'v2', nameMasked: '이*준', initial: 'L', type: 'buyer', company: '삼성전자', registeredAt: '2026.10.14', checkin: 'waiting', badgeIssued: true },
{ id: 'v3', nameMasked: '박*서', initial: 'P', type: 'vip', company: '현대자동차', registeredAt: '2026.10.15', checkin: 'done', badgeIssued: true },
{ id: 'v4', nameMasked: '최*훈', initial: 'C', type: 'visitor', company: '개인 참관', registeredAt: '2026.10.15', checkin: 'cancelled', badgeIssued: false },
{ id: 'v5', nameMasked: '정*아', initial: 'J', type: 'buyer', company: '(주)미래기술', registeredAt: '2026.10.16', checkin: 'done', badgeIssued: true },
{ id: 'v6', nameMasked: '한*수', initial: 'H', type: 'visitor', company: '두산로보틱스', registeredAt: '2026.10.16', checkin: 'waiting', badgeIssued: true },
];
export const REG_TYPE_LABEL: Record<RegVisitorType, string> = {
visitor: '관람객',
buyer: '바이어',
vip: 'VIP',
};
export const CHECKIN_LABEL: Record<CheckinState, string> = {
done: '완료',
waiting: '미입장',
cancelled: '취소',
};
// ── SCR-32 리드 관리·AI 스코어링 ──
export const LEAD_KPIS: VisitorKpi[] = [
{ label: '총 리드', value: '342' },
{ label: '핫리드', value: '68', sub: 'AI 스코어 80+' },
{ label: '팔로업 대기', value: '120' },
{ label: '전환 추정', value: '24', ai: true },
];
export interface Lead {
id: string;
nameMasked: string;
role: string;
company: string;
product: string;
interest: number; // 1~5 별점
score: number; // 0~100 AI 스코어
collectedAt: string;
/** 마스킹된 연락처(표기용). */
phoneMasked: string;
emailMasked: string;
aiReasons: string[];
activity: { text: string; at: string }[];
followupDraft: string;
}
export const LEADS: Lead[] = [
{
id: 'l1',
nameMasked: '김**',
role: '구매 담당자',
company: '(주)테크솔루션',
product: '협동로봇 HR-A1',
interest: 5,
score: 87,
collectedAt: '14:22:05',
phoneMasked: '010-****-3021',
emailMasked: 'kim***@techsol.co.kr',
aiReasons: [
'과거 유사 전시회 로봇 구매 이력 있음',
'당사 부스 체류 시간 평균 대비 240% 높음',
'기술 사양서(PDF) 다운로드 2회 기록',
],
activity: [
{ text: '제품 카탈로그 QR 스캔', at: '오늘 14:22' },
{ text: '부스 내 HR-A1 구동 시연 참관', at: '오늘 14:15' },
],
followupDraft:
'안녕하세요, (주)테크솔루션 김** 팀장님.\n\nKINTEX 부스에 방문해주셔서 감사합니다. 관심을 보이셨던 협동로봇 HR-A1의 상세 기술 사양과 귀사 공정 최적화를 위한 커스텀 제안서를 준비했습니다. 다음 주 중 편하신 시간에 짧은 미팅이 가능하실까요?',
},
{
id: 'l2',
nameMasked: '이**',
role: '기술 기획',
company: '넥스트이노베이션',
product: '스마트 물류 시스템',
interest: 3,
score: 65,
collectedAt: '14:15:30',
phoneMasked: '010-****-7742',
emailMasked: 'lee***@nextinno.com',
aiReasons: [
'물류 자동화 세션 참석 이력',
'부스 체류 시간 평균 수준',
],
activity: [
{ text: '스마트 물류 데모 영상 시청', at: '오늘 14:15' },
{ text: '브로슈어 다운로드', at: '오늘 14:03' },
],
followupDraft:
'안녕하세요, 넥스트이노베이션 이** 님.\n\n스마트 물류 시스템에 관심 가져주셔서 감사합니다. 도입 사례집과 ROI 시뮬레이션 자료를 보내드립니다.',
},
{
id: 'l3',
nameMasked: '박**',
role: '연구소장',
company: '미래제조연구소',
product: 'AI 비전 검사 모듈',
interest: 4,
score: 82,
collectedAt: '14:02:11',
phoneMasked: '010-****-1188',
emailMasked: 'park***@fmlab.re.kr',
aiReasons: [
'AI 비전 검사 데모 3회 재방문',
'구매 결정권자(연구소장) 직급',
'견적 요청 폼 작성 완료',
],
activity: [
{ text: '견적 요청 폼 제출', at: '오늘 14:02' },
{ text: 'AI 비전 모듈 상세 상담', at: '오늘 13:50' },
],
followupDraft:
'안녕하세요, 미래제조연구소 박** 소장님.\n\n요청하신 AI 비전 검사 모듈 견적서를 첨부드립니다. 파일럿 도입 프로그램도 안내드리겠습니다.',
},
{
id: 'l4',
nameMasked: '정**',
role: '설비 담당',
company: '글로벌테크',
product: '자율주행 AMR',
interest: 2,
score: 44,
collectedAt: '13:55:42',
phoneMasked: '010-****-9003',
emailMasked: 'jung***@globaltech.io',
aiReasons: ['AMR 부스 단순 방문', '자료 열람 이력 없음'],
activity: [{ text: '부스 QR 스캔', at: '오늘 13:55' }],
followupDraft:
'안녕하세요, 글로벌테크 정** 님.\n\n자율주행 AMR 소개 자료를 보내드립니다. 궁금하신 점이 있으시면 언제든 문의해 주세요.',
},
{
id: 'l5',
nameMasked: '강**',
role: '인프라 엔지니어',
company: 'SCT시스템',
product: '클라우드 모니터링',
interest: 1,
score: 28,
collectedAt: '13:48:19',
phoneMasked: '010-****-2250',
emailMasked: 'kang***@sctsys.kr',
aiReasons: ['짧은 부스 체류', '경쟁사 부스 위주 관람'],
activity: [{ text: '부스 QR 스캔', at: '오늘 13:48' }],
followupDraft:
'안녕하세요, SCT시스템 강** 님.\n\n클라우드 모니터링 솔루션 소개서를 보내드립니다.',
},
];

View File

@ -1,658 +0,0 @@
@import '../shared.css';
/* SCR-30 관람객 등록 대시보드 · SCR-32 리드 관리 (M10/M11) 공통 스타일. */
.kx-vis {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.kx-vis__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
}
.kx-vis__title {
font-size: var(--fs-h1);
line-height: var(--lh-h1);
color: var(--color-neutral-900);
}
.kx-vis__subtitle {
font-size: var(--fs-body);
color: var(--color-neutral-500);
margin-top: 2px;
}
.kx-vis__head-actions {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
/* 샘플 데이터 배지 */
.kx-sample {
font-size: 11px;
font-weight: 700;
color: var(--color-warning);
background: #fff4e5;
border: 1px solid #fcd9a8;
padding: 3px 10px;
border-radius: var(--radius-pill);
white-space: nowrap;
}
/* KPI 밴드 */
.kx-vis__kpis {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--space-4);
}
.kx-vis__kpis--4 {
grid-template-columns: repeat(4, 1fr);
}
.kx-kpi--ai {
border-left: var(--accent-ai);
}
.kx-kpi__sub {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
/* 도트 */
.kx-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
background: var(--color-neutral-500);
}
.kx-dot.is-on {
background: var(--color-primary-600);
}
.kx-dot.is-off {
background: var(--color-neutral-200);
}
/* 추이 + aside 분할 */
.kx-vis__split {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--space-4);
align-items: start;
}
.kx-vis__legend {
display: flex;
gap: var(--space-3);
list-style: none;
margin: 0;
padding: 0;
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-vis__legend li {
display: flex;
align-items: center;
gap: 5px;
}
.kx-vis__chart {
width: 100%;
}
/* 등록 유형 도넛 */
.kx-vis__donut-row {
display: grid;
grid-template-columns: 140px 1fr;
align-items: center;
gap: var(--space-4);
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--color-neutral-100);
}
.kx-vis__donut {
width: 140px;
}
.kx-vis__donut-title {
font-size: var(--fs-caption);
font-weight: 600;
color: var(--color-neutral-700);
margin-bottom: var(--space-2);
}
.kx-vis__donut-legend ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.kx-vis__donut-legend li {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--fs-body);
color: var(--color-neutral-700);
}
.kx-vis__donut-legend strong {
margin-left: auto;
color: var(--color-neutral-900);
}
/* aside */
.kx-vis__aside {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
/* 등록 폼 관리 */
.kx-vis__forms {
list-style: none;
margin: 0 0 var(--space-4);
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.kx-vis__form {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-2);
border-radius: var(--radius-sm);
}
.kx-vis__form:hover {
background: var(--color-neutral-050);
}
.kx-vis__form-name {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--fs-body);
color: var(--color-neutral-900);
}
.kx-vis__form-count {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-vis__form-state {
font-size: 11px;
font-weight: 600;
color: var(--color-neutral-500);
background: var(--color-neutral-050);
border: 1px solid var(--color-neutral-200);
padding: 2px 8px;
border-radius: var(--radius-pill);
}
.kx-vis__form-state.is-on {
color: var(--color-primary-600);
background: rgba(0, 102, 179, 0.08);
border-color: transparent;
}
.kx-vis__ai-note {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--fs-caption);
color: var(--color-neutral-500);
margin-top: var(--space-3);
}
/* 배지 템플릿 프리뷰 */
.kx-vis__badge-preview {
display: flex;
justify-content: center;
padding: var(--space-4);
background: var(--color-neutral-050);
border-radius: var(--radius-lg);
margin-bottom: var(--space-3);
}
.kx-badgecard {
width: 132px;
background: var(--color-white);
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-sm);
overflow: hidden;
box-shadow: var(--shadow-level2);
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: var(--space-3);
}
.kx-badgecard__top {
width: 100%;
background: var(--color-primary-600);
color: var(--color-white);
font-size: 8px;
font-weight: 700;
letter-spacing: 0.14em;
text-align: center;
padding: 5px 0;
}
.kx-badgecard__qr {
color: var(--color-neutral-700);
margin: var(--space-3) 0 var(--space-2);
}
.kx-badgecard__name {
font-size: var(--fs-caption);
font-weight: 700;
color: var(--color-primary-700);
}
.kx-badgecard__org {
font-size: 9px;
color: var(--color-neutral-500);
margin-top: 2px;
}
.kx-badgecard__foot {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0 var(--space-3);
margin-top: var(--space-3);
}
.kx-badgecard__tag {
font-size: 7px;
font-weight: 800;
color: var(--color-white);
background: var(--color-ai-accent);
padding: 2px 5px;
border-radius: 3px;
}
.kx-badgecard__no {
font-size: 8px;
color: var(--color-neutral-500);
}
/* 등록자 테이블 */
.kx-vis__table-card {
overflow: hidden;
}
.kx-vis__pii-note {
font-size: 11px;
color: var(--color-neutral-500);
}
.kx-vis__person {
display: flex;
align-items: center;
gap: var(--space-3);
color: var(--color-neutral-900);
font-weight: 500;
}
.kx-vis__avatar {
width: 30px;
height: 30px;
flex-shrink: 0;
border-radius: 50%;
background: var(--color-primary-100);
color: var(--color-primary-700);
font-size: var(--fs-caption);
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
}
.kx-vis__avatar--lg {
width: 56px;
height: 56px;
font-size: var(--fs-h2);
border: 2px solid var(--color-ai-accent);
}
/* 유형 pill */
.kx-vis__type {
display: inline-block;
font-size: 11px;
font-weight: 700;
padding: 2px 10px;
border-radius: var(--radius-pill);
}
.kx-vis__type.is-visitor {
color: var(--color-primary-700);
background: var(--color-primary-100);
}
.kx-vis__type.is-buyer {
color: #4500d8;
background: #e9e3ff;
}
.kx-vis__type.is-vip {
color: var(--color-success);
background: #dcf3e9;
}
/* 체크인 pill */
.kx-vis__checkin {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-body);
font-weight: 600;
}
.kx-vis__checkin.is-done {
color: var(--color-success);
}
.kx-vis__checkin.is-done .kx-dot {
background: var(--color-success);
}
.kx-vis__checkin.is-waiting {
color: var(--color-neutral-500);
}
.kx-vis__checkin.is-waiting .kx-dot {
background: var(--color-neutral-500);
}
.kx-vis__checkin.is-cancelled {
color: var(--color-error);
}
.kx-vis__checkin.is-cancelled .kx-dot {
background: var(--color-error);
}
.kx-vis__badge-issued {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: var(--fs-caption);
color: var(--color-primary-600);
font-weight: 600;
}
.kx-vis__badge-none {
font-size: var(--fs-caption);
color: var(--color-neutral-200);
}
.kx-vis__table-foot {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: var(--space-3);
margin-top: var(--space-2);
border-top: 1px solid var(--color-neutral-100);
}
.kx-vis__count {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
/* ── SCR-32 리드 관리 ── */
.kx-vis__lead-split {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--space-4);
align-items: start;
}
.kx-vis__filters {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.kx-vis__filters-label {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-vis__filters-count {
margin-left: auto;
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--fs-caption);
font-weight: 600;
color: var(--color-neutral-700);
background: var(--color-neutral-100);
border: 1px solid transparent;
border-radius: var(--radius-pill);
padding: 4px 12px;
cursor: pointer;
}
.kx-chip.is-active {
color: var(--color-primary-700);
background: var(--color-primary-100);
}
.kx-vis__lead-row {
cursor: pointer;
}
.kx-vis__lead-row.is-selected {
background: var(--color-ai-surface);
box-shadow: inset 4px 0 0 var(--color-ai-accent);
}
.kx-vis__lead-row:focus-visible {
outline: 2px solid var(--color-primary-600);
outline-offset: -2px;
}
.kx-vis__lead-name {
font-weight: 700;
color: var(--color-neutral-900);
}
/* 별점 */
.kx-stars {
display: inline-flex;
gap: 1px;
color: var(--color-ai-accent);
}
.kx-stars .is-empty {
color: var(--color-neutral-200);
}
/* AI 스코어 게이지 */
.kx-gauge {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 110px;
}
.kx-gauge__track {
flex: 1;
height: 8px;
border-radius: var(--radius-pill);
background: var(--color-neutral-100);
overflow: hidden;
}
.kx-gauge__fill {
display: block;
height: 100%;
border-radius: var(--radius-pill);
background: var(--color-primary-600);
}
.kx-gauge__fill.is-hot {
background: var(--color-ai-accent);
}
.kx-gauge__num {
font-size: 11px;
font-weight: 700;
color: var(--color-white);
background: var(--color-primary-600);
border-radius: var(--radius-pill);
padding: 1px 8px;
min-width: 30px;
text-align: center;
}
.kx-gauge__num.is-hot {
background: var(--color-ai-accent);
}
/* 리드 상세 패널 */
.kx-vis__lead-detail {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.kx-vis__lead-profile {
display: flex;
gap: var(--space-3);
align-items: center;
margin-bottom: var(--space-4);
}
.kx-vis__lead-profile-top {
display: flex;
align-items: center;
gap: var(--space-2);
}
.kx-vis__lead-profile-top h2 {
font-size: var(--fs-h2);
color: var(--color-neutral-900);
}
.kx-vis__hot {
font-size: 10px;
font-weight: 800;
color: #4500d8;
background: #e9e3ff;
padding: 2px 7px;
border-radius: 4px;
}
.kx-vis__lead-role {
font-size: var(--fs-body);
color: var(--color-neutral-700);
margin-top: 2px;
}
.kx-vis__lead-contact {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
margin-top: 2px;
}
.kx-vis__ai-card {
background: var(--color-ai-surface);
border: 1px solid #d9cfff;
border-radius: var(--radius-lg);
padding: var(--space-4);
margin-bottom: var(--space-4);
}
.kx-vis__ai-card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3);
}
.kx-vis__ai-score {
font-size: var(--fs-display);
line-height: 1;
font-weight: 700;
color: var(--color-ai-accent);
}
.kx-vis__ai-reasons {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.kx-vis__ai-reasons li {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: var(--fs-body);
color: var(--color-neutral-700);
}
.kx-vis__ai-check {
color: var(--color-ai-accent);
font-weight: 700;
flex-shrink: 0;
}
.kx-vis__followup {
margin-bottom: var(--space-4);
}
.kx-vis__followup-label {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--fs-caption);
font-weight: 700;
color: var(--color-neutral-900);
margin-bottom: var(--space-2);
}
.kx-vis__followup-label svg {
color: var(--color-ai-accent);
}
.kx-vis__followup-input {
width: 100%;
border: var(--border-card);
border-radius: var(--radius-sm);
padding: var(--space-3);
font-size: var(--fs-body);
line-height: var(--lh-body);
color: var(--color-neutral-700);
background: var(--color-neutral-050);
resize: vertical;
font-family: inherit;
}
.kx-vis__followup-input:focus-visible {
outline: 2px solid var(--color-primary-600);
outline-offset: -1px;
}
.kx-vis__lead-actions {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-vis__lead-actions-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
/* 활동 로그 */
.kx-vis__activity {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-vis__activity li {
display: grid;
grid-template-columns: 8px 1fr auto;
align-items: baseline;
gap: var(--space-2);
}
.kx-vis__activity .kx-dot {
background: var(--color-ai-accent);
align-self: center;
}
.kx-vis__activity-text {
font-size: var(--fs-body);
color: var(--color-neutral-700);
}
.kx-vis__activity-at {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
white-space: nowrap;
}
/* 반응형 */
@media (max-width: 1200px) {
.kx-vis__kpis {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 1100px) {
.kx-vis__split,
.kx-vis__lead-split {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.kx-vis__kpis,
.kx-vis__kpis--4 {
grid-template-columns: repeat(2, 1fr);
}
.kx-vis__donut-row {
grid-template-columns: 1fr;
justify-items: center;
}
}