Compare commits
2 Commits
345082c251
...
becbc55152
| Author | SHA1 | Date | |
|---|---|---|---|
| becbc55152 | |||
| d949f322d2 |
@ -31,6 +31,8 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="checklist" options={{ title: '현장 체크리스트' }} />
|
<Stack.Screen name="checklist" options={{ title: '현장 체크리스트' }} />
|
||||||
<Stack.Screen name="inspection" options={{ title: '현장 검수' }} />
|
<Stack.Screen name="inspection" options={{ title: '현장 검수' }} />
|
||||||
|
<Stack.Screen name="tickets/index" options={{ title: '내 티켓' }} />
|
||||||
|
<Stack.Screen name="tickets/select" options={{ title: '티켓 예매' }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
|
|||||||
188
mobile/app/tickets/index.tsx
Normal file
188
mobile/app/tickets/index.tsx
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
/*
|
||||||
|
* 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' },
|
||||||
|
});
|
||||||
362
mobile/app/tickets/select.tsx
Normal file
362
mobile/app/tickets/select.tsx
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
/*
|
||||||
|
* 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' },
|
||||||
|
});
|
||||||
122
mobile/components/tickets/QrPlaceholder.tsx
Normal file
122
mobile/components/tickets/QrPlaceholder.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
/*
|
||||||
|
* 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' },
|
||||||
|
});
|
||||||
177
mobile/components/tickets/QrViewerModal.tsx
Normal file
177
mobile/components/tickets/QrViewerModal.tsx
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
/*
|
||||||
|
* 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 },
|
||||||
|
});
|
||||||
165
mobile/components/tickets/TicketCard.tsx
Normal file
165
mobile/components/tickets/TicketCard.tsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
/*
|
||||||
|
* 티켓 카드 — 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
78
mobile/components/tickets/sampleTickets.ts
Normal file
78
mobile/components/tickets/sampleTickets.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
* 샘플 티켓 데이터 — 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',
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
import { Navigate, Outlet, Route, Routes, useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from './store/authStore';
|
import { useAuthStore } from './store/authStore';
|
||||||
import { LoginPage } from './screens/login/LoginPage';
|
import { LoginPage } from './screens/login/LoginPage';
|
||||||
import { OtpSetupPage } from './screens/login/OtpSetupPage';
|
import { OtpSetupPage } from './screens/login/OtpSetupPage';
|
||||||
@ -31,8 +31,41 @@ import { MeetingPage } from './screens/work/MeetingPage';
|
|||||||
import { ReportPage } from './screens/work/ReportPage';
|
import { ReportPage } from './screens/work/ReportPage';
|
||||||
import { NotificationCenterPage } from './screens/work/NotificationCenterPage';
|
import { NotificationCenterPage } from './screens/work/NotificationCenterPage';
|
||||||
import { MyPage } from './screens/work/MyPage';
|
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';
|
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 }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
@ -130,8 +163,43 @@ export function App() {
|
|||||||
{/* SCR-47 알림센터 · SCR-48 마이페이지 */}
|
{/* SCR-47 알림센터 · SCR-48 마이페이지 */}
|
||||||
<Route path="/notifications" element={<NotificationCenterPage />} />
|
<Route path="/notifications" element={<NotificationCenterPage />} />
|
||||||
<Route path="/me" element={<MyPage />} />
|
<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>
|
</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) — 셸 없이 전폭, 조회·현장 */}
|
{/* 모바일 전용 (390) — 셸 없이 전폭, 조회·현장 */}
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
|
|||||||
@ -32,11 +32,22 @@ const NAV_ITEMS: { key: string; label: string; Icon: ComponentType<IconProps>; t
|
|||||||
{ key: 'schedule', label: '전시 일정', Icon: IconCalendar, to: '/schedule' },
|
{ key: 'schedule', label: '전시 일정', Icon: IconCalendar, to: '/schedule' },
|
||||||
{ key: 'operations', label: '현장운영', Icon: IconOperations, to: '/ops/operations' },
|
{ key: 'operations', label: '현장운영', Icon: IconOperations, to: '/ops/operations' },
|
||||||
{ key: 'analytics', label: '경영분석', Icon: IconAnalytics, to: '/analytics' },
|
{ key: 'analytics', label: '경영분석', Icon: IconAnalytics, to: '/analytics' },
|
||||||
{ key: 'documents', label: '서류·마일스톤', Icon: IconDocument },
|
{ key: 'documents', label: '서류·마일스톤', Icon: IconDocument, to: '/docs' },
|
||||||
{ key: 'settlement', label: '정산', Icon: IconSettlement },
|
{ key: 'settlement', label: '정산', Icon: IconSettlement },
|
||||||
{ key: 'admin', label: '관리자', Icon: IconSettings, to: '/admin' },
|
{ 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) — 전 역할 공통 영역. */
|
/** §5B 공통 업무 기능(SCR-39~48) — 전 역할 공통 영역. */
|
||||||
const WORK_NAV: { key: string; label: string; Icon: ComponentType<IconProps>; to: string }[] = [
|
const WORK_NAV: { key: string; label: string; Icon: ComponentType<IconProps>; to: string }[] = [
|
||||||
{ key: 'worklog', label: '업무일지', Icon: IconDocument, to: '/work/worklog' },
|
{ key: 'worklog', label: '업무일지', Icon: IconDocument, to: '/work/worklog' },
|
||||||
@ -100,6 +111,21 @@ export function AppShell() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
<div className="kx-shell__nav-label">업무 공통</div>
|
||||||
<ul className="kx-shell__nav">
|
<ul className="kx-shell__nav">
|
||||||
{WORK_NAV.map((item) => (
|
{WORK_NAV.map((item) => (
|
||||||
|
|||||||
308
src/frontend/src/screens/admin/AuditLogPage.tsx
Normal file
308
src/frontend/src/screens/admin/AuditLogPage.tsx
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
164
src/frontend/src/screens/admin/RulesetVersionsPage.tsx
Normal file
164
src/frontend/src/screens/admin/RulesetVersionsPage.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
/*
|
||||||
|
* 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.5–8.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>;
|
||||||
|
}
|
||||||
335
src/frontend/src/screens/admin/SystemSettingsPage.tsx
Normal file
335
src/frontend/src/screens/admin/SystemSettingsPage.tsx
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
/*
|
||||||
|
* 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));
|
||||||
|
}
|
||||||
129
src/frontend/src/screens/admin/TenantAdminPage.tsx
Normal file
129
src/frontend/src/screens/admin/TenantAdminPage.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
/*
|
||||||
|
* 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>;
|
||||||
|
}
|
||||||
406
src/frontend/src/screens/admin/admin-modules.css
Normal file
406
src/frontend/src/screens/admin/admin-modules.css
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
/*
|
||||||
|
* 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; }
|
||||||
|
}
|
||||||
100
src/frontend/src/screens/admin/adminModulesApi.ts
Normal file
100
src/frontend/src/screens/admin/adminModulesApi.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
* 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)}`),
|
||||||
|
};
|
||||||
206
src/frontend/src/screens/auction/AuctionDetailPage.tsx
Normal file
206
src/frontend/src/screens/auction/AuctionDetailPage.tsx
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
/*
|
||||||
|
* 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)}`;
|
||||||
|
}
|
||||||
339
src/frontend/src/screens/auction/AuctionListPage.tsx
Normal file
339
src/frontend/src/screens/auction/AuctionListPage.tsx
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
/*
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
283
src/frontend/src/screens/auction/AwardComparePage.tsx
Normal file
283
src/frontend/src/screens/auction/AwardComparePage.tsx
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
/*
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/frontend/src/screens/auction/aucShared.tsx
Normal file
29
src/frontend/src/screens/auction/aucShared.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
/* 옥션 화면 공용 소도구 — 데모 토스트(뮤테이션 로컬 처리 안내). */
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
998
src/frontend/src/screens/auction/auction.css
Normal file
998
src/frontend/src/screens/auction/auction.css
Normal file
@ -0,0 +1,998 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
210
src/frontend/src/screens/auction/sampleAuction.ts
Normal file
210
src/frontend/src/screens/auction/sampleAuction.ts
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
/*
|
||||||
|
* 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')}`;
|
||||||
|
}
|
||||||
306
src/frontend/src/screens/cms/CmsWorkflowPage.tsx
Normal file
306
src/frontend/src/screens/cms/CmsWorkflowPage.tsx
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
/*
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
359
src/frontend/src/screens/cms/MicrositeBuilderPage.tsx
Normal file
359
src/frontend/src/screens/cms/MicrositeBuilderPage.tsx
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
/*
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
287
src/frontend/src/screens/cms/MultilingualCmsPage.tsx
Normal file
287
src/frontend/src/screens/cms/MultilingualCmsPage.tsx
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
/*
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1128
src/frontend/src/screens/cms/cms.css
Normal file
1128
src/frontend/src/screens/cms/cms.css
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,286 @@
|
|||||||
|
/*
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
308
src/frontend/src/screens/contractor/contractor.css
Normal file
308
src/frontend/src/screens/contractor/contractor.css
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
/*
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
326
src/frontend/src/screens/docs/DocsMilestonePage.tsx
Normal file
326
src/frontend/src/screens/docs/DocsMilestonePage.tsx
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
/*
|
||||||
|
* SCR-22 서류·마일스톤 워크플로 [M6]. Stitch scr_22_docs_milestones 이식.
|
||||||
|
* 진입: 주최자 대시보드 마일스톤 노드 / 사이드바 "서류·마일스톤" (design.md §3 SCR-22).
|
||||||
|
* 구성: ①상단 마일스톤 진행 바(D-150→D-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>
|
||||||
|
);
|
||||||
|
}
|
||||||
443
src/frontend/src/screens/docs/ReportAuthoringPage.tsx
Normal file
443
src/frontend/src/screens/docs/ReportAuthoringPage.tsx
Normal file
@ -0,0 +1,443 @@
|
|||||||
|
/*
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
600
src/frontend/src/screens/docs/docs.css
Normal file
600
src/frontend/src/screens/docs/docs.css
Normal file
@ -0,0 +1,600 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
235
src/frontend/src/screens/marketing/EdmCampaignPage.tsx
Normal file
235
src/frontend/src/screens/marketing/EdmCampaignPage.tsx
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
src/frontend/src/screens/marketing/SponsorshipPage.tsx
Normal file
173
src/frontend/src/screens/marketing/SponsorshipPage.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
690
src/frontend/src/screens/marketing/marketing.css
Normal file
690
src/frontend/src/screens/marketing/marketing.css
Normal file
@ -0,0 +1,690 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
185
src/frontend/src/screens/marketing/sampleMarketing.ts
Normal file
185
src/frontend/src/screens/marketing/sampleMarketing.ts
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
/*
|
||||||
|
* 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 },
|
||||||
|
];
|
||||||
395
src/frontend/src/screens/movein/DockReservationPage.tsx
Normal file
395
src/frontend/src/screens/movein/DockReservationPage.tsx
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
/*
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
373
src/frontend/src/screens/movein/movein.css
Normal file
373
src/frontend/src/screens/movein/movein.css
Normal file
@ -0,0 +1,373 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
297
src/frontend/src/screens/public/PublicEventDetailPage.tsx
Normal file
297
src/frontend/src/screens/public/PublicEventDetailPage.tsx
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
/*
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
270
src/frontend/src/screens/public/PublicFloorplanPage.tsx
Normal file
270
src/frontend/src/screens/public/PublicFloorplanPage.tsx
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
/*
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
src/frontend/src/screens/public/PublicHomePage.tsx
Normal file
248
src/frontend/src/screens/public/PublicHomePage.tsx
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
/*
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
src/frontend/src/screens/public/PublicInquiryPage.tsx
Normal file
220
src/frontend/src/screens/public/PublicInquiryPage.tsx
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
/*
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
src/frontend/src/screens/public/PublicMicrositePage.tsx
Normal file
154
src/frontend/src/screens/public/PublicMicrositePage.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
/*
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
259
src/frontend/src/screens/public/PublicRegistrationPage.tsx
Normal file
259
src/frontend/src/screens/public/PublicRegistrationPage.tsx
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
/*
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
src/frontend/src/screens/public/PublicShell.tsx
Normal file
178
src/frontend/src/screens/public/PublicShell.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
/*
|
||||||
|
* 공개 홍보 사이트 공통 셸 — 헤더 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
559
src/frontend/src/screens/public/PublicTicketPage.tsx
Normal file
559
src/frontend/src/screens/public/PublicTicketPage.tsx
Normal file
@ -0,0 +1,559 @@
|
|||||||
|
/*
|
||||||
|
* 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 (평일 09–18시)
|
||||||
|
</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>
|
||||||
|
신용카드 할부 결제 건은 취소 시 카드사를 통해 한도 복구까지 약 3–5영업일이 소요될 수
|
||||||
|
있습니다.
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/frontend/src/screens/public/index.ts
Normal file
12
src/frontend/src/screens/public/index.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
* 공개 홍보 사이트(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
|
||||||
2626
src/frontend/src/screens/public/public.css
Normal file
2626
src/frontend/src/screens/public/public.css
Normal file
File diff suppressed because it is too large
Load Diff
314
src/frontend/src/screens/public/publicIcons.tsx
Normal file
314
src/frontend/src/screens/public/publicIcons.tsx
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
/*
|
||||||
|
* 공개 홍보 사이트 전용 인라인 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>
|
||||||
|
);
|
||||||
226
src/frontend/src/screens/visitor/LeadScoringPage.tsx
Normal file
226
src/frontend/src/screens/visitor/LeadScoringPage.tsx
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,277 @@
|
|||||||
|
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명 중 1–6 표시</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
233
src/frontend/src/screens/visitor/sampleVisitor.ts
Normal file
233
src/frontend/src/screens/visitor/sampleVisitor.ts
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
/*
|
||||||
|
* 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클라우드 모니터링 솔루션 소개서를 보내드립니다.',
|
||||||
|
},
|
||||||
|
];
|
||||||
658
src/frontend/src/screens/visitor/visitor.css
Normal file
658
src/frontend/src/screens/visitor/visitor.css
Normal file
@ -0,0 +1,658 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user