diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f0c30a5..e596b96 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,7 +28,10 @@ const KpiDashboard = lazy(() => import('./pages/KpiDashboard')) const BiAnalytics = lazy(() => import('./pages/BiAnalytics')) const BillingManage = lazy(() => import('./pages/BillingManage')) const IntegrationHub = lazy(() => import('./pages/IntegrationHub')) -const AiPlatform = lazy(() => import('./pages/AiPlatform')) +const AiPlatform = lazy(() => import('./pages/AiPlatform')) +// ── GUARDiA 기능 개선 v4 ── +const AppDistribution = lazy(() => import('./pages/AppDistribution')) +const NotificationRules = lazy(() => import('./pages/NotificationRules')) function Loading() { return ( @@ -73,6 +76,9 @@ export default function App() { } /> } /> } /> + {/* GUARDiA 기능 개선 v4 */} + } /> + } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 7730287..21cd425 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -43,6 +43,9 @@ const NAV: NavItem[] = [ { label: 'AI 플랫폼', icon: '🧠', path: '/ai-platform' }, { label: '외부 연동', icon: '🔗', path: '/integrations' }, { label: '구독 · 과금', icon: '💰', path: '/billing' }, + // ── GUARDiA 기능 개선 v4 ── + { label: '앱 배포', icon: '📱', path: '/app-distribution' }, + { label: '알림 규칙', icon: '🔔', path: '/notification-rules' }, ] /* Variant 스타일 색상 상수 */ diff --git a/frontend/src/pages/AppDistribution.tsx b/frontend/src/pages/AppDistribution.tsx new file mode 100644 index 0000000..b74f733 --- /dev/null +++ b/frontend/src/pages/AppDistribution.tsx @@ -0,0 +1,222 @@ +import { useEffect, useState, useRef } from 'react' +import { guardiaApi } from '../api/clients' + +interface AppVersion { + id: number; version: string; platform: string + download_count: number; is_latest: boolean + qr_url: string; landing_url: string + file_size_mb: number; release_notes: string; created_at: string +} + +export default function AppDistribution() { + const [versions, setVersions] = useState([]) + const [latest, setLatest] = useState(null) + const [stats, setStats] = useState(null) + const [uploading, setUploading] = useState(false) + const [version, setVersion] = useState('') + const [notes, setNotes] = useState('') + const [iosUrl, setIosUrl] = useState('') + const [externalUrl, setExternalUrl] = useState('') + const [tab, setTab] = useState<'upload'|'url'>('upload') + const fileRef = useRef(null) + + useEffect(() => { load() }, []) + + async function load() { + const [v, l, s] = await Promise.all([ + guardiaApi.get('/api/app/versions').then((r: any) => r.data).catch(() => []), + guardiaApi.get('/api/app/latest').then((r: any) => r.data).catch(() => null), + guardiaApi.get('/api/app/stats').then((r: any) => r.data).catch(() => null), + ]) + setVersions(v); setLatest(l); setStats(s) + } + + async function uploadApk() { + const file = fileRef.current?.files?.[0] + if (!file || !version) return alert('APK 파일과 버전을 입력하세요') + setUploading(true) + try { + const form = new FormData() + form.append('file', file) + form.append('version', version) + form.append('release_notes', notes) + form.append('ios_url', iosUrl) + const r: any = await guardiaApi.post('/api/app/upload', form, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + alert(`✅ 배포 완료! 버전 ${version}\nQR 코드가 생성됐습니다.`) + setVersion(''); setNotes(''); setIosUrl('') + if (fileRef.current) fileRef.current.value = '' + load() + } catch (e: any) { + alert(`오류: ${e.response?.data?.detail || e.message}`) + } finally { setUploading(false) } + } + + async function setUrl() { + if (!externalUrl || !version) return alert('URL과 버전을 입력하세요') + await guardiaApi.post('/api/app/url', { + android_url: externalUrl, version, release_notes: notes, ios_url: iosUrl || undefined + }) + alert('QR 코드 생성됨'); setExternalUrl(''); setVersion(''); setNotes('') + load() + } + + async function deleteVersion(id: number) { + if (!confirm('이 버전을 삭제하시겠습니까?')) return + await guardiaApi.delete(`/api/app/versions/${id}`) + load() + } + + const S = { card: { background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20, marginBottom: 16 } } + + return ( +
+

📱 모바일 앱 직접 배포

+

APK를 업로드하면 QR 코드가 생성됩니다. 사용자는 QR 스캔만으로 앱을 설치할 수 있습니다 (앱스토어 불필요).

+ + {/* 현재 최신 버전 + QR */} + {latest?.has_version && ( +
+
+
현재 최신 버전
+
v{latest.version}
+
{latest.platform} · 총 {latest.download_count}회 다운로드
+ {latest.release_notes &&
{latest.release_notes}
} + +
+ {/* QR 이미지 */} +
+ QR { e.target.style.display = 'none' }} /> +
스캔하여 설치
+
+
+ )} + + {/* 통계 */} + {stats && ( +
+ {[ + { label: '총 다운로드', val: stats.total_downloads, icon: '📥' }, + { label: 'Android', val: stats.android, icon: '🤖' }, + { label: 'iOS', val: stats.ios, icon: '🍎' }, + ].map(s => ( +
+
{s.icon}
+
{s.val}
+
{s.label}
+
+ ))} +
+ )} + + {/* 업로드 폼 */} +
+

새 버전 배포

+
+ {[{id:'upload',label:'APK 파일 업로드'},{id:'url',label:'외부 URL 연결'}].map(t => ( + + ))} +
+ +
+
+ + setVersion(e.target.value)} placeholder="예: 1.2.3" + style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }} /> +
+
+ + setIosUrl(e.target.value)} placeholder="TestFlight URL" + style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }} /> +
+
+ + {tab === 'upload' ? ( +
+ + +
+ ) : ( +
+ + setExternalUrl(e.target.value)} placeholder="https://expo.dev/... 또는 직접 APK URL" + style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box', marginBottom: 8 }} /> +
+ )} + +
+ +