377 lines
16 KiB
TypeScript
377 lines
16 KiB
TypeScript
import React, { useState, useCallback } from 'react';
|
|
import {
|
|
View, Text, StyleSheet, TouchableOpacity, FlatList,
|
|
TextInput, ActivityIndicator, RefreshControl, Modal,
|
|
ScrollView,
|
|
} from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { apiClient } from '../../services/api';
|
|
|
|
interface G2BProject {
|
|
bid_no: string;
|
|
title: string;
|
|
org: string;
|
|
budget_krw: number | null;
|
|
deadline: string | null;
|
|
announce_date: string | null;
|
|
guardia_score: number;
|
|
guardia_modules: string[];
|
|
project_type: string;
|
|
guardia_proposal: string;
|
|
}
|
|
|
|
interface CategorySummary {
|
|
category: string;
|
|
count: number;
|
|
avg_budget_billion: number;
|
|
avg_guardia_score: number;
|
|
}
|
|
|
|
const SCORE_COLOR = (score: number) => {
|
|
if (score >= 90) return '#00D4AA';
|
|
if (score >= 75) return '#00A0C8';
|
|
if (score >= 60) return '#F59E0B';
|
|
return '#6B7280';
|
|
};
|
|
|
|
const TYPE_COLORS: Record<string, string> = {
|
|
ITSM: '#00D4AA',
|
|
SM: '#00C896',
|
|
보안: '#EF4444',
|
|
클라우드: '#3B82F6',
|
|
AI: '#8B5CF6',
|
|
ERP: '#F59E0B',
|
|
MES: '#10B981',
|
|
SI: '#6B7280',
|
|
};
|
|
|
|
function formatBudget(krw: number | null): string {
|
|
if (!krw) return '미정';
|
|
if (krw >= 1_000_000_000) return `${(krw / 1_000_000_000).toFixed(1)}억`;
|
|
if (krw >= 1_000_000) return `${(krw / 1_000_000).toFixed(0)}백만`;
|
|
return `${krw.toLocaleString()}원`;
|
|
}
|
|
|
|
function ScoreBadge({ score }: { score: number }) {
|
|
return (
|
|
<View style={[styles.scoreBadge, { backgroundColor: SCORE_COLOR(score) + '22', borderColor: SCORE_COLOR(score) }]}>
|
|
<Text style={[styles.scoreText, { color: SCORE_COLOR(score) }]}>
|
|
GUARDiA {score}점
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function ProjectCard({
|
|
item,
|
|
onPress,
|
|
}: {
|
|
item: G2BProject;
|
|
onPress: () => void;
|
|
}) {
|
|
const typeColor = TYPE_COLORS[item.project_type] ?? '#6B7280';
|
|
return (
|
|
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.85}>
|
|
<View style={styles.cardHeader}>
|
|
<View style={[styles.typeBadge, { backgroundColor: typeColor + '22', borderColor: typeColor }]}>
|
|
<Text style={[styles.typeText, { color: typeColor }]}>{item.project_type}</Text>
|
|
</View>
|
|
<ScoreBadge score={item.guardia_score} />
|
|
</View>
|
|
|
|
<Text style={styles.cardTitle} numberOfLines={2}>{item.title}</Text>
|
|
<Text style={styles.cardOrg}>{item.org}</Text>
|
|
|
|
<View style={styles.cardMeta}>
|
|
<View style={styles.metaItem}>
|
|
<Ionicons name="wallet-outline" size={13} color="#9CA3AF" />
|
|
<Text style={styles.metaText}>{formatBudget(item.budget_krw)}</Text>
|
|
</View>
|
|
{item.deadline && (
|
|
<View style={styles.metaItem}>
|
|
<Ionicons name="calendar-outline" size={13} color="#9CA3AF" />
|
|
<Text style={styles.metaText}>{item.deadline}</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{item.guardia_score >= 80 && (
|
|
<View style={styles.highlightBanner}>
|
|
<Ionicons name="star" size={12} color="#F59E0B" />
|
|
<Text style={styles.highlightText}>GUARDiA 고적합 사업</Text>
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
);
|
|
}
|
|
|
|
function DetailModal({
|
|
item,
|
|
visible,
|
|
onClose,
|
|
}: {
|
|
item: G2BProject | null;
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
}) {
|
|
if (!item) return null;
|
|
const typeColor = TYPE_COLORS[item.project_type] ?? '#6B7280';
|
|
|
|
return (
|
|
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
|
|
<View style={styles.modalOverlay}>
|
|
<View style={styles.modalBox}>
|
|
<View style={styles.modalHeader}>
|
|
<Text style={styles.modalTitle} numberOfLines={2}>{item.title}</Text>
|
|
<TouchableOpacity onPress={onClose}>
|
|
<Ionicons name="close" size={22} color="#9CA3AF" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<ScrollView showsVerticalScrollIndicator={false}>
|
|
<View style={styles.detailRow}>
|
|
<Text style={styles.detailLabel}>발주 기관</Text>
|
|
<Text style={styles.detailValue}>{item.org}</Text>
|
|
</View>
|
|
<View style={styles.detailRow}>
|
|
<Text style={styles.detailLabel}>예산 규모</Text>
|
|
<Text style={styles.detailValue}>{formatBudget(item.budget_krw)}</Text>
|
|
</View>
|
|
<View style={styles.detailRow}>
|
|
<Text style={styles.detailLabel}>마감일</Text>
|
|
<Text style={styles.detailValue}>{item.deadline ?? '-'}</Text>
|
|
</View>
|
|
<View style={styles.detailRow}>
|
|
<Text style={styles.detailLabel}>프로젝트 유형</Text>
|
|
<View style={[styles.typeBadge, { backgroundColor: typeColor + '22', borderColor: typeColor }]}>
|
|
<Text style={[styles.typeText, { color: typeColor }]}>{item.project_type}</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.divider} />
|
|
|
|
<Text style={styles.sectionTitle}>GUARDiA 적합성 분석</Text>
|
|
<View style={styles.scoreRow}>
|
|
<Text style={styles.scoreLabel}>적합성 점수</Text>
|
|
<View style={styles.scoreBar}>
|
|
<View style={[styles.scoreBarFill, {
|
|
width: `${item.guardia_score}%`,
|
|
backgroundColor: SCORE_COLOR(item.guardia_score),
|
|
}]} />
|
|
</View>
|
|
<Text style={[styles.scoreBig, { color: SCORE_COLOR(item.guardia_score) }]}>
|
|
{item.guardia_score}
|
|
</Text>
|
|
</View>
|
|
|
|
<Text style={styles.sectionTitle}>적용 모듈</Text>
|
|
<View style={styles.moduleGrid}>
|
|
{item.guardia_modules.map((m) => (
|
|
<View key={m} style={styles.moduleChip}>
|
|
<Text style={styles.moduleText}>{m}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
|
|
<Text style={styles.sectionTitle}>GUARDiA 제안</Text>
|
|
<Text style={styles.proposalText}>{item.guardia_proposal}</Text>
|
|
</ScrollView>
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
export default function NarasajangSW() {
|
|
const [projects, setProjects] = useState<G2BProject[]>([]);
|
|
const [summary, setSummary] = useState<CategorySummary[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [keyword, setKeyword] = useState('');
|
|
const [selected, setSelected] = useState<G2BProject | null>(null);
|
|
const [tab, setTab] = useState<'list' | 'summary'>('list');
|
|
|
|
const fetchProjects = useCallback(async (kw = '') => {
|
|
setLoading(true);
|
|
try {
|
|
const params = kw ? { keyword: kw } : {};
|
|
const [pRes, sRes] = await Promise.all([
|
|
apiClient.get('/api/g2b-opportunity/projects', { params }),
|
|
apiClient.get('/api/g2b-opportunity/summary/by-category'),
|
|
]);
|
|
setProjects(pRes.data.projects ?? pRes.data);
|
|
setSummary(sRes.data.summary ?? sRes.data);
|
|
} catch {
|
|
// 오류 시 빈 목록 유지
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
}
|
|
}, []);
|
|
|
|
React.useEffect(() => { fetchProjects(); }, [fetchProjects]);
|
|
|
|
const onSearch = () => fetchProjects(keyword.trim());
|
|
const onRefresh = () => { setRefreshing(true); fetchProjects(keyword.trim()); };
|
|
|
|
const highScore = projects.filter((p) => p.guardia_score >= 80).length;
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
{/* 헤더 */}
|
|
<View style={styles.header}>
|
|
<Ionicons name="briefcase-outline" size={20} color="#00A0C8" />
|
|
<Text style={styles.headerTitle}>나라장터 SW 공고</Text>
|
|
{highScore > 0 && (
|
|
<View style={styles.headerBadge}>
|
|
<Text style={styles.headerBadgeText}>고적합 {highScore}</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* 검색바 */}
|
|
<View style={styles.searchRow}>
|
|
<View style={styles.searchBox}>
|
|
<Ionicons name="search-outline" size={16} color="#9CA3AF" />
|
|
<TextInput
|
|
style={styles.searchInput}
|
|
placeholder="키워드 검색 (예: IT운영유지보수)"
|
|
placeholderTextColor="#6B7280"
|
|
value={keyword}
|
|
onChangeText={setKeyword}
|
|
onSubmitEditing={onSearch}
|
|
returnKeyType="search"
|
|
/>
|
|
</View>
|
|
<TouchableOpacity style={styles.searchBtn} onPress={onSearch}>
|
|
<Text style={styles.searchBtnText}>검색</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* 탭 */}
|
|
<View style={styles.tabRow}>
|
|
{(['list', 'summary'] as const).map((t) => (
|
|
<TouchableOpacity
|
|
key={t}
|
|
style={[styles.tab, tab === t && styles.tabActive]}
|
|
onPress={() => setTab(t)}
|
|
>
|
|
<Text style={[styles.tabText, tab === t && styles.tabTextActive]}>
|
|
{t === 'list' ? `공고 목록 (${projects.length})` : '카테고리 요약'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
|
|
{loading && !refreshing ? (
|
|
<View style={styles.center}>
|
|
<ActivityIndicator size="large" color="#00A0C8" />
|
|
<Text style={styles.loadingText}>나라장터 공고 분석 중...</Text>
|
|
</View>
|
|
) : tab === 'list' ? (
|
|
<FlatList
|
|
data={projects}
|
|
keyExtractor={(item) => item.bid_no}
|
|
renderItem={({ item }) => (
|
|
<ProjectCard item={item} onPress={() => setSelected(item)} />
|
|
)}
|
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#00A0C8" />}
|
|
contentContainerStyle={styles.list}
|
|
ListEmptyComponent={
|
|
<View style={styles.center}>
|
|
<Ionicons name="document-outline" size={40} color="#4B5563" />
|
|
<Text style={styles.emptyText}>공고가 없습니다</Text>
|
|
</View>
|
|
}
|
|
/>
|
|
) : (
|
|
<ScrollView contentContainerStyle={styles.list}>
|
|
{summary.map((cat) => (
|
|
<View key={cat.category} style={styles.summaryCard}>
|
|
<View style={styles.summaryHeader}>
|
|
<Text style={styles.summaryCategory}>{cat.category}</Text>
|
|
<Text style={styles.summaryCount}>{cat.count}건</Text>
|
|
</View>
|
|
<View style={styles.summaryRow}>
|
|
<View style={styles.summaryItem}>
|
|
<Text style={styles.summaryLabel}>평균 예산</Text>
|
|
<Text style={styles.summaryValue}>{cat.avg_budget_billion.toFixed(1)}억</Text>
|
|
</View>
|
|
<View style={styles.summaryItem}>
|
|
<Text style={styles.summaryLabel}>평균 GUARDiA 점수</Text>
|
|
<Text style={[styles.summaryValue, { color: SCORE_COLOR(cat.avg_guardia_score) }]}>
|
|
{cat.avg_guardia_score.toFixed(0)}점
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
))}
|
|
</ScrollView>
|
|
)}
|
|
|
|
<DetailModal item={selected} visible={!!selected} onClose={() => setSelected(null)} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: '#0F172A' },
|
|
header: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 16, paddingTop: 20 },
|
|
headerTitle: { fontSize: 18, fontWeight: '700', color: '#F1F5F9', flex: 1 },
|
|
headerBadge: { backgroundColor: '#F59E0B22', borderRadius: 12, paddingHorizontal: 8, paddingVertical: 3, borderWidth: 1, borderColor: '#F59E0B' },
|
|
headerBadgeText: { color: '#F59E0B', fontSize: 11, fontWeight: '700' },
|
|
searchRow: { flexDirection: 'row', gap: 8, paddingHorizontal: 16, paddingBottom: 10 },
|
|
searchBox: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 8, backgroundColor: '#1E293B', borderRadius: 10, paddingHorizontal: 12, height: 40 },
|
|
searchInput: { flex: 1, color: '#F1F5F9', fontSize: 14 },
|
|
searchBtn: { backgroundColor: '#00A0C8', borderRadius: 10, paddingHorizontal: 14, height: 40, justifyContent: 'center' },
|
|
searchBtnText: { color: '#fff', fontWeight: '700', fontSize: 13 },
|
|
tabRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#1E293B', marginHorizontal: 16 },
|
|
tab: { flex: 1, paddingVertical: 10, alignItems: 'center' },
|
|
tabActive: { borderBottomWidth: 2, borderBottomColor: '#00A0C8' },
|
|
tabText: { color: '#6B7280', fontSize: 13 },
|
|
tabTextActive: { color: '#00A0C8', fontWeight: '700' },
|
|
list: { padding: 16, paddingBottom: 80 },
|
|
center: { alignItems: 'center', justifyContent: 'center', paddingVertical: 60 },
|
|
loadingText: { color: '#9CA3AF', marginTop: 8 },
|
|
emptyText: { color: '#6B7280', marginTop: 8 },
|
|
card: { backgroundColor: '#1E293B', borderRadius: 14, padding: 14, marginBottom: 10 },
|
|
cardHeader: { flexDirection: 'row', gap: 8, marginBottom: 8 },
|
|
typeBadge: { borderRadius: 8, paddingHorizontal: 8, paddingVertical: 3, borderWidth: 1 },
|
|
typeText: { fontSize: 11, fontWeight: '700' },
|
|
scoreBadge: { borderRadius: 8, paddingHorizontal: 8, paddingVertical: 3, borderWidth: 1 },
|
|
scoreText: { fontSize: 11, fontWeight: '700' },
|
|
cardTitle: { color: '#F1F5F9', fontSize: 14, fontWeight: '600', lineHeight: 20, marginBottom: 4 },
|
|
cardOrg: { color: '#9CA3AF', fontSize: 12, marginBottom: 8 },
|
|
cardMeta: { flexDirection: 'row', gap: 12 },
|
|
metaItem: { flexDirection: 'row', alignItems: 'center', gap: 4 },
|
|
metaText: { color: '#9CA3AF', fontSize: 12 },
|
|
highlightBanner: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 8, backgroundColor: '#F59E0B11', borderRadius: 6, padding: 6 },
|
|
highlightText: { color: '#F59E0B', fontSize: 11, fontWeight: '600' },
|
|
divider: { height: 1, backgroundColor: '#334155', marginVertical: 14 },
|
|
sectionTitle: { color: '#9CA3AF', fontSize: 12, fontWeight: '600', marginBottom: 8, marginTop: 4, textTransform: 'uppercase', letterSpacing: 0.5 },
|
|
detailRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: '#1E293B' },
|
|
detailLabel: { color: '#6B7280', fontSize: 13 },
|
|
detailValue: { color: '#F1F5F9', fontSize: 13, fontWeight: '600' },
|
|
scoreRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 },
|
|
scoreLabel: { color: '#9CA3AF', fontSize: 12, width: 70 },
|
|
scoreBar: { flex: 1, height: 6, backgroundColor: '#1E293B', borderRadius: 3, overflow: 'hidden' },
|
|
scoreBarFill: { height: '100%', borderRadius: 3 },
|
|
scoreBig: { fontSize: 18, fontWeight: '800', width: 36, textAlign: 'right' },
|
|
moduleGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginBottom: 12 },
|
|
moduleChip: { backgroundColor: '#00A0C822', borderRadius: 6, paddingHorizontal: 8, paddingVertical: 4, borderWidth: 1, borderColor: '#00A0C8' },
|
|
moduleText: { color: '#00A0C8', fontSize: 11, fontWeight: '600' },
|
|
proposalText: { color: '#CBD5E1', fontSize: 13, lineHeight: 20 },
|
|
modalOverlay: { flex: 1, backgroundColor: '#00000080', justifyContent: 'flex-end' },
|
|
modalBox: { backgroundColor: '#1E293B', borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 20, maxHeight: '85%' },
|
|
modalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 },
|
|
modalTitle: { flex: 1, color: '#F1F5F9', fontSize: 16, fontWeight: '700', lineHeight: 22, marginRight: 8 },
|
|
summaryCard: { backgroundColor: '#1E293B', borderRadius: 12, padding: 14, marginBottom: 10 },
|
|
summaryHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 10 },
|
|
summaryCategory: { color: '#F1F5F9', fontWeight: '700', fontSize: 15 },
|
|
summaryCount: { color: '#00A0C8', fontWeight: '700', fontSize: 15 },
|
|
summaryRow: { flexDirection: 'row', gap: 12 },
|
|
summaryItem: { flex: 1, backgroundColor: '#0F172A', borderRadius: 8, padding: 10 },
|
|
summaryLabel: { color: '#9CA3AF', fontSize: 11, marginBottom: 4 },
|
|
summaryValue: { color: '#F1F5F9', fontWeight: '700', fontSize: 16 },
|
|
});
|