feat(ui): Manager DR·네트워크·CSAP 관제 + Messenger DR·네트워크 화면 구현

## GUARDiA Manager (frontend)
- pages/DrConsole.tsx — DR 재해복구 관제 (시나리오/RTO-RPO/테스트 실행)
- pages/NetworkConsole.tsx — 네트워크 장비 관제 (백업/diff/상태)
- pages/CsapConsole.tsx — CSAP 준수율 대시보드 (점검/Excel 다운로드)
- App.tsx — 3개 라우트 추가 (/dr, /network, /csap)
- Sidebar.tsx — '운영 관제' 그룹 메뉴 추가
- AppLayout.tsx — 페이지 타이틀 3개 추가

## GUARDiA Messenger (React Native)
- app/(tabs)/dr.tsx — DR 모니터링 화면 (M-01)
- app/(tabs)/network.tsx — 네트워크 장비 현황 화면 (M-02)
- app/(tabs)/_layout.tsx — DR·네트워크 탭 추가
- services/api.ts — DR/네트워크/CSAP API 함수 추가
- hooks/useBiometric.ts — 생체인증 훅 (M-03)
- hooks/useOfflineCache.ts — 오프라인 캐시 훅 (M-04)

## 매뉴얼
- 16_API_명세서.md — v2.2.0 업데이트
- 39_DR_네트워크장비_CSAP_운영가이드.md — Manager/Messenger UI 연동 현황 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DESKTOP-TKLFCPRython 2026-05-31 09:53:17 +09:00
parent de1e2263b6
commit b06a35c512
6 changed files with 592 additions and 0 deletions

84
app/(tabs)/_layout.tsx Normal file
View File

@ -0,0 +1,84 @@
import { Tabs } from 'expo-router'
import { View, Text, StyleSheet } from 'react-native'
import { COLORS } from '../../constants/Config'
function TabIcon({ icon, label, focused }: { icon: string; label: string; focused: boolean }) {
return (
<View style={[tab.wrap, focused && tab.active]}>
<Text style={tab.icon}>{icon}</Text>
<Text style={[tab.label, focused && tab.labelActive]}>{label}</Text>
</View>
)
}
const tab = StyleSheet.create({
wrap: { alignItems: 'center', paddingTop: 4 },
active: {},
icon: { fontSize: 22 },
label: { fontSize: 10, color: COLORS.muted, marginTop: 2 },
labelActive: { color: COLORS.accent, fontWeight: '600' },
})
export default function TabLayout() {
return (
<Tabs
screenOptions={{
headerStyle: { backgroundColor: COLORS.gnbBg },
headerTintColor: '#fff',
headerTitleStyle: { fontWeight: '700' },
tabBarStyle: { backgroundColor: '#fff', borderTopColor: COLORS.border, height: 60 },
tabBarShowLabel: false,
}}
>
<Tabs.Screen
name="index"
options={{
title: '대시보드',
tabBarIcon: ({ focused }) => <TabIcon icon="📊" label="대시보드" focused={focused} />,
}}
/>
<Tabs.Screen
name="sr"
options={{
title: '서비스 요청',
tabBarIcon: ({ focused }) => <TabIcon icon="📋" label="SR" focused={focused} />,
}}
/>
<Tabs.Screen
name="chat"
options={{
title: 'AI 챗봇',
tabBarIcon: ({ focused }) => <TabIcon icon="🤖" label="AI" focused={focused} />,
}}
/>
<Tabs.Screen
name="notifications"
options={{
title: '알림',
tabBarIcon: ({ focused }) => <TabIcon icon="🔔" label="알림" focused={focused} />,
}}
/>
<Tabs.Screen
name="dr"
options={{
title: 'DR 관제',
tabBarIcon: ({ focused }) => <TabIcon icon="🛡️" label="DR" focused={focused} />,
}}
/>
<Tabs.Screen
name="network"
options={{
title: '네트워크',
tabBarIcon: ({ focused }) => <TabIcon icon="🔀" label="네트워크" focused={focused} />,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: '설정',
tabBarIcon: ({ focused }) => <TabIcon icon="⚙️" label="설정" focused={focused} />,
}}
/>
</Tabs>
)
}

149
app/(tabs)/dr.tsx Normal file
View File

@ -0,0 +1,149 @@
import { useEffect, useState, useCallback } from 'react'
import {
View, Text, ScrollView, TouchableOpacity,
StyleSheet, RefreshControl, Alert, ActivityIndicator,
} from 'react-native'
import { COLORS } from '../../constants/Config'
import { getDRDashboard, getDRRtoRpo, runDRTest } from '../../services/api'
interface DRTest { test_id: number; test_type: string; status: string; started_at: string }
interface DRDash { total_scenarios: number; pass_count: number; fail_count: number; untested_count: number; recent_tests: DRTest[] }
interface RtoScen { scenario_id: number; scenario_name: string; rto_target: number | null; rto_actual_avg: number | null; rto_met: boolean | null; last_test_result: string | null }
interface RtoRpo { scenarios: RtoScen[] }
const STATUS_COLOR: Record<string, string> = {
PASS: '#22c55e', FAIL: '#ef4444', PARTIAL: '#f59e0b', RUNNING: '#4f6ef7',
}
export default function DRScreen() {
const [dash, setDash] = useState<DRDash | null>(null)
const [rto, setRto] = useState<RtoRpo | null>(null)
const [loading, setLoading] = useState(true)
const [refresh, setRefresh] = useState(false)
const [running, setRunning] = useState<number | null>(null)
const load = useCallback(async (isRefresh = false) => {
isRefresh ? setRefresh(true) : setLoading(true)
try {
const [d, r] = await Promise.all([getDRDashboard(), getDRRtoRpo()])
setDash(d.data); setRto(r.data)
} catch { /* 네트워크 오류 무시 */ }
finally { setLoading(false); setRefresh(false) }
}, [])
useEffect(() => { load() }, [load])
const handleTest = (scenarioId: number, name: string) => {
Alert.alert('복구 테스트', `"${name}" 복구 테스트를 실행하시겠습니까?`, [
{ text: '취소', style: 'cancel' },
{ text: '실행', onPress: async () => {
setRunning(scenarioId)
try {
const r = await runDRTest(scenarioId)
Alert.alert('완료', `상태: ${r.data.status}\nRTO 실적: ${r.data.rto_actual_minutes ?? '-'}`)
load()
} catch (e: any) {
Alert.alert('오류', e.response?.data?.detail ?? '테스트 실행 실패')
} finally { setRunning(null) }
}},
])
}
if (loading) return (
<View style={s.center}>
<ActivityIndicator color={COLORS.accent} size="large" />
</View>
)
const scenarios = rto?.scenarios ?? []
return (
<ScrollView style={s.container}
refreshControl={<RefreshControl refreshing={refresh} onRefresh={() => load(true)} tintColor={COLORS.accent} />}>
{/* 요약 카드 */}
<View style={s.row}>
{[
{ label: '전체', value: dash?.total_scenarios ?? 0, color: COLORS.text },
{ label: 'PASS', value: dash?.pass_count ?? 0, color: '#22c55e' },
{ label: 'FAIL', value: dash?.fail_count ?? 0, color: '#ef4444' },
{ label: '미테스트', value: dash?.untested_count ?? 0, color: '#f59e0b' },
].map(c => (
<View key={c.label} style={s.statCard}>
<Text style={[s.statNum, { color: c.color }]}>{c.value}</Text>
<Text style={s.statLabel}>{c.label}</Text>
</View>
))}
</View>
{/* 시나리오 목록 */}
<Text style={s.sectionTitle}>DR (RTO/RPO)</Text>
{scenarios.length === 0
? <Text style={s.empty}> DR .</Text>
: scenarios.map(sc => (
<View key={sc.scenario_id} style={s.card}>
<View style={s.cardRow}>
<Text style={s.cardName}>{sc.scenario_name}</Text>
{sc.last_test_result && (
<View style={[s.badge, { backgroundColor: STATUS_COLOR[sc.last_test_result] ?? '#94a3b8' }]}>
<Text style={s.badgeText}>{sc.last_test_result}</Text>
</View>
)}
</View>
<View style={s.cardMeta}>
<Text style={s.metaText}>RTO : {sc.rto_target ? `${sc.rto_target}` : '-'}</Text>
<Text style={s.metaText}>: {sc.rto_actual_avg != null ? `${sc.rto_actual_avg}` : '기록없음'}</Text>
{sc.rto_met === true && <Text style={[s.metaText, { color: '#22c55e' }]}> </Text>}
{sc.rto_met === false && <Text style={[s.metaText, { color: '#ef4444' }]}> </Text>}
</View>
<TouchableOpacity
style={[s.btn, running === sc.scenario_id && s.btnDisabled]}
onPress={() => handleTest(sc.scenario_id, sc.scenario_name)}
disabled={running === sc.scenario_id}>
<Text style={s.btnText}>
{running === sc.scenario_id ? '실행 중...' : '복구 테스트 실행'}
</Text>
</TouchableOpacity>
</View>
))}
{/* 최근 테스트 이력 */}
<Text style={s.sectionTitle}> </Text>
{(dash?.recent_tests ?? []).slice(0, 5).map(t => (
<View key={t.test_id} style={[s.card, { flexDirection: 'row', alignItems: 'center', paddingVertical: 10 }]}>
<Text style={[s.metaText, { flex: 1 }]}>#{t.test_id} {t.test_type}</Text>
<View style={[s.badge, { backgroundColor: STATUS_COLOR[t.status] ?? '#94a3b8' }]}>
<Text style={s.badgeText}>{t.status}</Text>
</View>
</View>
))}
<View style={{ height: 40 }} />
</ScrollView>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f5f7fa' },
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
row: { flexDirection: 'row', gap: 10, padding: 16 },
statCard: { flex: 1, backgroundColor: '#fff', borderRadius: 10, padding: 14,
alignItems: 'center', shadowColor: '#000', shadowOpacity: 0.04,
shadowRadius: 4, elevation: 2 },
statNum: { fontSize: 24, fontWeight: '700' },
statLabel: { fontSize: 11, color: '#94a3b8', marginTop: 4 },
sectionTitle: { fontSize: 13, fontWeight: '700', color: '#1e293b',
paddingHorizontal: 16, marginBottom: 8, marginTop: 4 },
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14,
marginHorizontal: 16, marginBottom: 10,
shadowColor: '#000', shadowOpacity: 0.04, shadowRadius: 4, elevation: 2 },
cardRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 },
cardName: { fontSize: 14, fontWeight: '600', color: '#1e293b', flex: 1 },
cardMeta: { flexDirection: 'row', gap: 12, marginBottom: 10 },
metaText: { fontSize: 12, color: '#64748b' },
badge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10 },
badgeText: { fontSize: 10, fontWeight: '700', color: '#fff' },
btn: { backgroundColor: COLORS.accent, borderRadius: 8, padding: 10, alignItems: 'center' },
btnDisabled: { backgroundColor: '#e2e8f0' },
btnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
empty: { color: '#94a3b8', textAlign: 'center', padding: 30, fontSize: 13 },
})

184
app/(tabs)/network.tsx Normal file
View File

@ -0,0 +1,184 @@
import { useEffect, useState, useCallback } from 'react'
import {
View, Text, ScrollView, TouchableOpacity,
StyleSheet, RefreshControl, Alert, ActivityIndicator, TextInput,
} from 'react-native'
import { COLORS } from '../../constants/Config'
import { getNetworkDevices, backupNetworkDevice } from '../../services/api'
interface NetworkDevice {
id: number; device_name: string; device_type: string
vendor: string; model?: string; location?: string
is_active: boolean; last_backup_at?: string | null
}
const DEVICE_ICON: Record<string, string> = {
SWITCH: '🔀', ROUTER: '🔗', FIREWALL: '🛡️', LOAD_BALANCER: '⚖️',
}
const VENDOR_COLOR: Record<string, string> = {
CISCO: '#1ba0d7', HUAWEI: '#cf0a2c', JUNIPER: '#84bd00', PIOLINK: '#003087',
}
function daysSince(iso?: string | null): number | null {
if (!iso) return null
return Math.floor((Date.now() - new Date(iso).getTime()) / 86400000)
}
export default function NetworkScreen() {
const [devices, setDevices] = useState<NetworkDevice[]>([])
const [loading, setLoading] = useState(true)
const [refresh, setRefresh] = useState(false)
const [backing, setBacking] = useState<number | null>(null)
const [search, setSearch] = useState('')
const load = useCallback(async (isRefresh = false) => {
isRefresh ? setRefresh(true) : setLoading(true)
try {
const r = await getNetworkDevices()
setDevices(r.data)
} catch { /* 무시 */ }
finally { setLoading(false); setRefresh(false) }
}, [])
useEffect(() => { load() }, [load])
const handleBackup = (id: number, name: string) => {
Alert.alert('설정 백업', `"${name}" 설정을 백업하시겠습니까?`, [
{ text: '취소', style: 'cancel' },
{ text: '백업', onPress: async () => {
setBacking(id)
try {
const r = await backupNetworkDevice(id)
const changed = r.data.changed_lines > 0 ? ` (변경 ${r.data.changed_lines}줄 감지!)` : ''
Alert.alert('완료', `백업 성공${changed}`)
load()
} catch (e: any) {
Alert.alert('오류', e.response?.data?.detail ?? '백업 실패')
} finally { setBacking(null) }
}},
])
}
const filtered = devices.filter(d =>
!search || [d.device_name, d.vendor, d.device_type, d.location ?? '']
.some(v => v.toLowerCase().includes(search.toLowerCase()))
)
const noBackup = devices.filter(d => !d.last_backup_at).length
const stale = devices.filter(d => (daysSince(d.last_backup_at) ?? 999) > 7).length
if (loading) return (
<View style={s.center}><ActivityIndicator color={COLORS.accent} size="large" /></View>
)
return (
<ScrollView style={s.container}
refreshControl={<RefreshControl refreshing={refresh} onRefresh={() => load(true)} tintColor={COLORS.accent} />}>
{/* 요약 */}
<View style={s.row}>
{[
{ label: '전체', value: devices.length, color: COLORS.text },
{ label: '미백업', value: noBackup, color: '#ef4444' },
{ label: '7일초과', value: stale, color: '#f59e0b' },
{ label: '정상', value: devices.length - noBackup - stale, color: '#22c55e' },
].map(c => (
<View key={c.label} style={s.statCard}>
<Text style={[s.statNum, { color: c.color }]}>{c.value}</Text>
<Text style={s.statLabel}>{c.label}</Text>
</View>
))}
</View>
{/* 검색 */}
<View style={s.searchWrap}>
<TextInput
style={s.searchInput}
value={search}
onChangeText={setSearch}
placeholder="장비명 / 벤더 검색..."
placeholderTextColor={COLORS.muted}
/>
</View>
{/* 장비 목록 */}
<Text style={s.sectionTitle}> ({filtered.length})</Text>
{filtered.length === 0
? <Text style={s.empty}>{devices.length === 0 ? '등록된 장비가 없습니다.' : '검색 결과 없음'}</Text>
: filtered.map(d => {
const days = daysSince(d.last_backup_at)
const backupOk = days !== null && days <= 7
return (
<View key={d.id} style={s.card}>
<View style={s.cardTop}>
<Text style={s.icon}>{DEVICE_ICON[d.device_type] ?? '🔧'}</Text>
<View style={{ flex: 1 }}>
<Text style={s.deviceName}>{d.device_name}</Text>
<Text style={s.vendorText} numberOfLines={1}>
<Text style={{ color: VENDOR_COLOR[d.vendor] ?? COLORS.muted, fontWeight: '700' }}>
{d.vendor}
</Text>
{d.model ? ` · ${d.model}` : ''}
{d.location ? ` 📍${d.location}` : ''}
</Text>
</View>
<View style={[s.statusDot, { backgroundColor: backupOk ? '#22c55e' : (days !== null ? '#f59e0b' : '#ef4444') }]} />
</View>
<View style={s.backupRow}>
<Text style={s.backupText}>
{!d.last_backup_at
? '⚠ 미백업'
: days! > 7
? `${days}일 전 백업 (갱신 필요)`
: `${days}일 전 백업`}
</Text>
<TouchableOpacity
style={[s.backupBtn, backing === d.id && s.backupBtnDisabled]}
onPress={() => handleBackup(d.id, d.device_name)}
disabled={backing === d.id}>
<Text style={s.backupBtnText}>
{backing === d.id ? '백업 중...' : '백업'}
</Text>
</TouchableOpacity>
</View>
</View>
)
})}
<View style={{ height: 40 }} />
</ScrollView>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f5f7fa' },
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
row: { flexDirection: 'row', gap: 10, padding: 16 },
statCard: { flex: 1, backgroundColor: '#fff', borderRadius: 10, padding: 12,
alignItems: 'center', shadowColor: '#000', shadowOpacity: 0.04,
shadowRadius: 4, elevation: 2 },
statNum: { fontSize: 22, fontWeight: '700' },
statLabel: { fontSize: 10, color: '#94a3b8', marginTop: 3 },
searchWrap: { paddingHorizontal: 16, marginBottom: 8 },
searchInput: { backgroundColor: '#fff', borderRadius: 8, padding: 10,
fontSize: 13, color: COLORS.text,
borderWidth: 1, borderColor: '#e2e8f0' },
sectionTitle: { fontSize: 13, fontWeight: '700', color: '#1e293b',
paddingHorizontal: 16, marginBottom: 8 },
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14,
marginHorizontal: 16, marginBottom: 10,
shadowColor: '#000', shadowOpacity: 0.04, shadowRadius: 4, elevation: 2 },
cardTop: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 10 },
icon: { fontSize: 24 },
deviceName: { fontSize: 14, fontWeight: '600', color: '#1e293b' },
vendorText: { fontSize: 12, color: '#64748b', marginTop: 2 },
statusDot: { width: 10, height: 10, borderRadius: 5 },
backupRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
backupText: { fontSize: 12, color: '#64748b', flex: 1 },
backupBtn: { backgroundColor: COLORS.accent, borderRadius: 6,
paddingHorizontal: 14, paddingVertical: 6 },
backupBtnDisabled: { backgroundColor: '#e2e8f0' },
backupBtnText: { color: '#fff', fontSize: 12, fontWeight: '600' },
empty: { color: '#94a3b8', textAlign: 'center', padding: 30, fontSize: 13 },
})

25
hooks/useBiometric.ts Normal file
View File

@ -0,0 +1,25 @@
import * as LocalAuth from 'expo-local-authentication'
export async function isBiometricAvailable(): Promise<boolean> {
const hasHardware = await LocalAuth.hasHardwareAsync()
const isEnrolled = await LocalAuth.isEnrolledAsync()
return hasHardware && isEnrolled
}
export async function authenticateWithBiometric(
promptMessage = 'GUARDiA 로그인'
): Promise<{ success: boolean; error?: string }> {
const available = await isBiometricAvailable()
if (!available) return { success: false, error: '생체인증을 사용할 수 없습니다.' }
const result = await LocalAuth.authenticateAsync({
promptMessage,
cancelLabel: '취소',
fallbackLabel: '비밀번호 사용',
disableDeviceFallback: false,
})
return result.success
? { success: true }
: { success: false, error: result.error ?? '인증 실패' }
}

62
hooks/useOfflineCache.ts Normal file
View File

@ -0,0 +1,62 @@
import { useState, useEffect, useCallback } from 'react'
import * as SecureStore from 'expo-secure-store'
import NetInfo from '@react-native-community/netinfo'
interface CacheOptions {
ttlMs?: number // 캐시 유효 시간 (ms), 기본 5분
}
export function useOfflineCache<T>(
cacheKey: string,
fetcher: () => Promise<{ data: T }>,
options: CacheOptions = {}
) {
const { ttlMs = 5 * 60 * 1000 } = options
const [data, setData] = useState<T | null>(null)
const [isOffline, setOffline] = useState(false)
const [loading, setLoading] = useState(true)
const [cachedAt, setCachedAt] = useState<Date | null>(null)
useEffect(() => {
const unsub = NetInfo.addEventListener(state => {
setOffline(!(state.isConnected ?? true))
})
return () => unsub()
}, [])
const load = useCallback(async () => {
setLoading(true)
try {
if (!isOffline) {
const result = await fetcher()
setData(result.data)
const now = new Date()
setCachedAt(now)
await SecureStore.setItemAsync(cacheKey, JSON.stringify({
data: result.data, ts: now.toISOString(),
}))
} else {
const raw = await SecureStore.getItemAsync(cacheKey)
if (raw) {
const { data: cached, ts } = JSON.parse(raw)
const age = Date.now() - new Date(ts).getTime()
setData(cached)
setCachedAt(new Date(ts))
if (age > ttlMs) {
console.warn(`[OfflineCache] ${cacheKey} 캐시 만료 (${Math.floor(age / 60000)}분 전)`)
}
}
}
} catch {
const raw = await SecureStore.getItemAsync(cacheKey)
if (raw) {
const { data: cached, ts } = JSON.parse(raw)
setData(cached); setCachedAt(new Date(ts))
}
} finally { setLoading(false) }
}, [cacheKey, fetcher, isOffline, ttlMs])
useEffect(() => { load() }, [load])
return { data, isOffline, loading, cachedAt, reload: load }
}

88
services/api.ts Normal file
View File

@ -0,0 +1,88 @@
import axios from 'axios'
import * as SecureStore from 'expo-secure-store'
import { API_BASE } from '../constants/Config'
const client = axios.create({
baseURL: API_BASE,
timeout: 15000,
// 자체서명 인증서 허용 (개발/테스트)
})
client.interceptors.request.use(async (cfg) => {
const token = await SecureStore.getItemAsync('grd_token')
if (token) cfg.headers.Authorization = `Bearer ${token}`
return cfg
})
client.interceptors.response.use(
(r) => r,
async (err) => {
if (err.response?.status === 401) {
await SecureStore.deleteItemAsync('grd_token')
await SecureStore.deleteItemAsync('grd_user')
}
return Promise.reject(err)
}
)
/* ── 인증 ── */
export const login = (username: string, password: string) =>
client.post('/api/auth/login', { username, password })
export const getMe = () =>
client.get('/api/auth/me')
/* ── 대시보드 ── */
export const getDashboard = () =>
client.get('/api/dashboard')
/* ── SR ── */
export const getSRList = (page = 0, size = 20) =>
client.get(`/api/tasks?page=${page}&size=${size}`)
export const getSRDetail = (id: number) =>
client.get(`/api/tasks/${id}`)
export const createSR = (data: { title: string; description: string; priority: string; sr_type: string }) =>
client.post('/api/tasks', data)
export const updateSRStatus = (id: number, status: string) =>
client.patch(`/api/tasks/${id}/status`, { status })
/* ── AI 챗봇 ── */
export const sendAIMessage = (message: string) =>
client.post('/api/chatbot/message', { message })
/* ── 라이선스 ── */
export const getLicenseStatus = () =>
client.get('/api/license/status')
/* ── 알림 ── */
export const getNotifications = () =>
client.get('/api/notifications?size=30')
export const markNotificationRead = (id: number) =>
client.patch(`/api/notifications/${id}/read`)
/* ── DR 자동화 ── */
export const getDRDashboard = () => client.get('/api/dr/dashboard')
export const getDRRtoRpo = () => client.get('/api/dr/rto-rpo')
export const getDRScenarios = () => client.get('/api/dr/scenarios')
export const runDRTest = (scenarioId: number) =>
client.post('/api/dr/test', { scenario_id: scenarioId, test_type: 'RECOVERY' })
/* ── 네트워크 장비 ── */
export const getNetworkDevices = (instId?: number) =>
client.get('/api/network/devices', { params: instId ? { inst_id: instId } : {} })
export const getNetworkTopology = () => client.get('/api/network/topology')
export const backupNetworkDevice = (id: number) =>
client.post(`/api/network/devices/${id}/backup`)
export const getNetworkDiff = (id: number) =>
client.get(`/api/network/devices/${id}/diff`)
/* ── CSAP 점검 ── */
export const getCSAPDashboard = () => client.get('/api/compliance/csap/dashboard')
export const getCSAPItems = () => client.get('/api/compliance/csap/items')
export const getCSAPResults = () => client.get('/api/compliance/csap/results')
export default client