guardia-messenger/app/(tabs)/narasajang_sw.tsx

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 },
});