feat(itms): 모바일 앱 신규 (Expo, 12화면, OAuth password grant)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
itms-merge-dev 2026-07-19 17:10:46 +09:00
parent 53b8f42c76
commit efc3e66635
37 changed files with 13967 additions and 0 deletions

17
mobile/.gitignore vendored Normal file
View File

@ -0,0 +1,17 @@
# ITMS mobile (Expo) — Node 프로젝트 (Gradle 컴포짓 무관)
node_modules/
.expo/
dist/
web-build/
*.log
.DS_Store
# EAS / 로컬 빌드 산출물
android/
ios/
*.apk
*.aab
# 로컬 env
.env
.env.local

39
mobile/README.md Normal file
View File

@ -0,0 +1,39 @@
# ITMS 모바일 앱 (Expo)
URP ITMS(IT 유지보수관리 시스템)의 독립 Expo 모바일 앱. 모노레포 `workspace/itms/`
순수 Node 프로젝트로, Gradle 컴포짓(`settings.gradle`)에 포함되지 않는다.
## 스택
- Expo SDK 51 · React Native 0.74 · expo-router · TypeScript
- 디자인: WISE 표준(시안 #11c3ff · 블루 #1f29fc · 카드 UI · 선 아이콘, 이모지 없음)
- 정본 레퍼런스: `workspace/camp/mobile`, `workspace/uiws/mobile`
## 인증 (실측 계약)
레거시 OAuth2 **password grant** — 웹 front 와 동일.
```
POST {BASE}/oauth/token?grant_type=password&username=<userId>&password=<pw>&ssoYn=N
Authorization: Basic base64(clientId:secretKey)
```
응답의 `access_token`(JWT) + 평탄화된 사용자 필드(userName·authorities 등)를 저장.
업무 API 는 `POST /xxx.do` + `Authorization: Bearer <jwt>`, 응답 payload 는 `data` 하위.
## 환경 분기 (app.config.js)
- `ITMS_ENV=dev``https://itms.zioinfo.co.kr`
- `ITMS_ENV=prod``https://itms.wise.ai.kr`
> ★인프라 전제: 현재 리버스 프록시 vhost 는 front(:11020)만 노출한다. 모바일이 동작하려면
> nginx 에 `/oauth/`(→:11000) 및 업무 경로(→:11010) 프록시 블록 활성화가 선결되어야 한다.
## 실행
```
cd workspace/itms/mobile
npm install
npm start # expo (Metro)
npm run typecheck # tsc --noEmit
```
## 화면
로그인 · 홈(대시보드) · 요청(목록/등록/상세) · 인시던트(목록/상세) · 공지(목록/상세) ·
내정보(프로필/테마/로그아웃) · 자산(조회/상세).
APK 빌드는 EAS 토큰이 필요하여 이 단계에서 보류(로컬 소스 + tsc 검증까지 완료).

73
mobile/app.config.js Normal file
View File

@ -0,0 +1,73 @@
/*
* ITMS 모바일 동적 설정(app.config.js).
* 빌드 프로파일별 base URL 분리: ITMS_ENV=dev|prod (eas.json build.<profile>.env 주입).
* dev https://itms.zioinfo.co.kr (개발서버 리버스 프록시)
* prod https://itms.wise.ai.kr (운영 도메인)
* 로컬 개발(expo start) ITMS_ENV 미지정 dev 기본.
* 보안: 내부 IP 하드코딩 금지 공개 도메인만 사용.
*
* OAuth2 client 식별자(clientId/secretKey) 인가서버 인메모리 클라이언트와 동일한 공개 등록값이며,
* 필요 아래 extra 오버라이드(사용자/서버 자격증명·AES 암호문 아님).
*/
const ITMS_ENV = process.env.ITMS_ENV === 'prod' ? 'prod' : 'dev'
const API_URL = ITMS_ENV === 'prod' ? 'https://itms.wise.ai.kr' : 'https://itms.zioinfo.co.kr'
module.exports = {
expo: {
name: 'ITMS',
slug: 'itms-app',
version: '1.0.0',
orientation: 'portrait',
icon: './assets/icon.png',
scheme: 'itms',
userInterfaceStyle: 'automatic',
newArchEnabled: false,
splash: {
image: './assets/splash.png',
resizeMode: 'contain',
backgroundColor: '#1f29fc',
},
ios: {
supportsTablet: true,
bundleIdentifier: 'kr.zioinfo.itms',
},
android: {
package: 'kr.zioinfo.itms',
versionCode: 1,
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
backgroundColor: '#1f29fc',
},
// 운영/개발 모두 HTTPS 도메인 → 평문 HTTP 비허용.
usesCleartextTraffic: false,
},
web: {
bundler: 'metro',
output: 'static',
},
plugins: [
'expo-router',
'expo-secure-store',
'./plugins/withGradleProps',
'expo-font',
[
'expo-splash-screen',
{
image: './assets/splash.png',
resizeMode: 'contain',
backgroundColor: '#1f29fc',
},
],
],
extra: {
itmsEnv: ITMS_ENV,
itmsApiUrl: API_URL,
// OAuth2 client 등록 식별자(공개값 — 인가서버와 1:1). 필요 시 EAS env 로 오버라이드.
itmsOauthClientId: process.env.ITMS_OAUTH_CLIENT_ID || 'clientId',
itmsOauthClientSecret: process.env.ITMS_OAUTH_CLIENT_SECRET || 'secretKey',
router: { origin: false },
eas: { projectId: process.env.ITMS_EAS_PROJECT_ID || undefined },
},
owner: 'infraurp',
},
}

View File

@ -0,0 +1,75 @@
import { useState } from 'react'
import {
View, Text, TextInput, TouchableOpacity, StyleSheet, ScrollView, ActivityIndicator, KeyboardAvoidingView, Platform,
} from 'react-native'
import { router } from 'expo-router'
import { useTheme } from '../../constants/ThemeContext'
import { type ThemeTokens } from '../../constants/ItmsTheme'
import { itmsLogin, itmsErrorMessage } from '../itmsApi'
/*
* ITMS OAuth2 password grant( front ).
* userId + password POST /oauth/token / /home.
* (TLS ). .
*/
export default function Login() {
const { t: th } = useTheme()
const s = makeStyles(th)
const [userId, setUserId] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const doLogin = async () => {
if (!userId.trim() || !password) { setErr('아이디와 비밀번호를 입력하세요.'); return }
setBusy(true); setErr('')
try {
await itmsLogin(userId.trim(), password)
router.replace('/home')
} catch (e) { setErr(itmsErrorMessage(e)) }
finally { setBusy(false) }
}
return (
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<ScrollView style={s.wrap} contentContainerStyle={s.content} keyboardShouldPersistTaps="handled">
<Text style={s.logo}>ITMS</Text>
<Text style={s.sub}>IT </Text>
<View style={s.card}>
<Text style={s.lbl}></Text>
<TextInput
style={s.input} value={userId} onChangeText={setUserId}
placeholder="사용자 아이디" placeholderTextColor={th.muted}
autoCapitalize="none" autoCorrect={false}
/>
<Text style={[s.lbl, { marginTop: 12 }]}></Text>
<TextInput
style={s.input} value={password} onChangeText={setPassword}
placeholder="비밀번호" placeholderTextColor={th.muted} secureTextEntry
onSubmitEditing={doLogin} returnKeyType="go"
/>
{!!err && <Text style={s.err}>{err}</Text>}
<TouchableOpacity style={[s.btn, busy && { opacity: 0.5 }]} onPress={doLogin} disabled={busy}>
{busy ? <ActivityIndicator color={th.onBrand} /> : <Text style={s.btnText}></Text>}
</TouchableOpacity>
</View>
<Text style={s.foot}> · WISE Platform</Text>
</ScrollView>
</KeyboardAvoidingView>
)
}
const makeStyles = (th: ThemeTokens) => StyleSheet.create({
wrap: { flex: 1, backgroundColor: th.bg },
content: { flexGrow: 1, justifyContent: 'center', padding: 24 },
logo: { color: th.primary, fontSize: 44, fontWeight: '900', textAlign: 'center', letterSpacing: 3 },
sub: { color: th.muted, fontSize: 13, textAlign: 'center', marginTop: 6, marginBottom: 28 },
card: { backgroundColor: th.card, borderRadius: 16, borderWidth: 1, borderColor: th.border, padding: 20 },
lbl: { color: th.muted, fontSize: 12, marginBottom: 6 },
input: { backgroundColor: th.cardAlt, borderRadius: 10, borderWidth: 1, borderColor: th.border, color: th.text, padding: 13, fontSize: 15 },
btn: { backgroundColor: th.brand, borderRadius: 12, paddingVertical: 14, alignItems: 'center', marginTop: 20 },
btnText: { color: th.onBrand, fontWeight: '800', fontSize: 15 },
err: { color: th.danger, fontSize: 12, marginTop: 12 },
foot: { color: th.muted, fontSize: 11, textAlign: 'center', marginTop: 28 },
})

View File

@ -0,0 +1,27 @@
import { Tabs } from 'expo-router'
import { Feather } from '@expo/vector-icons'
import { useTheme } from '../../constants/ThemeContext'
/*
* ITMS · · · · .
*/
export default function TabsLayout() {
const { t } = useTheme()
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: t.tabActive,
tabBarInactiveTintColor: t.tabIdle,
tabBarStyle: { backgroundColor: t.card, borderTopColor: t.border, height: 60, paddingBottom: 8, paddingTop: 6 },
tabBarLabelStyle: { fontSize: 11, fontWeight: '700' },
}}
>
<Tabs.Screen name="home" options={{ title: '홈', tabBarIcon: ({ color, size }: { color: string; size: number }) => <Feather name="home" size={size} color={color} /> }} />
<Tabs.Screen name="requests" options={{ title: '요청', tabBarIcon: ({ color, size }: { color: string; size: number }) => <Feather name="file-text" size={size} color={color} /> }} />
<Tabs.Screen name="incidents" options={{ title: '인시던트', tabBarIcon: ({ color, size }: { color: string; size: number }) => <Feather name="alert-circle" size={size} color={color} /> }} />
<Tabs.Screen name="notices" options={{ title: '공지', tabBarIcon: ({ color, size }: { color: string; size: number }) => <Feather name="bell" size={size} color={color} /> }} />
<Tabs.Screen name="me" options={{ title: '내정보', tabBarIcon: ({ color, size }: { color: string; size: number }) => <Feather name="user" size={size} color={color} /> }} />
</Tabs>
)
}

160
mobile/app/(tabs)/home.tsx Normal file
View File

@ -0,0 +1,160 @@
import { useCallback, useState } from 'react'
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, RefreshControl } from 'react-native'
import { router, useFocusEffect } from 'expo-router'
import { Feather } from '@expo/vector-icons'
import { useTheme } from '../../constants/ThemeContext'
import { type ThemeTokens } from '../../constants/ItmsTheme'
import { StatusPill, SectionTitle } from '../../components/ui'
import { rowTitle, rowStatus, rowDate, reqIncidentNum } from '../../constants/rows'
import {
itmsProfile, itmsMyIncidents, itmsMyReports, itmsMyTodo, itmsResourceStat, listRows,
type ItmsProfile, type RowMap,
} from '../itmsApi'
export default function Home() {
const { t } = useTheme()
const s = makeStyles(t)
const [me, setMe] = useState<ItmsProfile | null>(null)
const [incidents, setIncidents] = useState<RowMap[]>([])
const [reports, setReports] = useState<RowMap[]>([])
const [todo, setTodo] = useState<RowMap[]>([])
const [statRows, setStatRows] = useState<RowMap[]>([])
const [refreshing, setRefreshing] = useState(false)
const load = useCallback(async () => {
// 각 호출 독립 — 하나 실패해도 나머지 표시(서버 미기동 시 빈 상태).
try { setMe(await itmsProfile()) } catch { /* graceful */ }
try { setIncidents(listRows(await itmsMyIncidents())) } catch { setIncidents([]) }
try { setReports(listRows(await itmsMyReports())) } catch { setReports([]) }
try { setTodo(listRows(await itmsMyTodo())) } catch { setTodo([]) }
try { setStatRows(listRows(await itmsResourceStat())) } catch { setStatRows([]) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
const onRefresh = useCallback(async () => { setRefreshing(true); await load(); setRefreshing(false) }, [load])
const kpis = [
{ key: 'todo', label: '내 할 일', count: todo.length, color: t.brand, icon: 'check-square' as const },
{ key: 'inc', label: '내 인시던트', count: incidents.length, color: t.info, icon: 'alert-circle' as const },
{ key: 'rep', label: '내 보고', count: reports.length, color: t.warning, icon: 'file-text' as const },
]
const quick = [
{ key: 'new', icon: 'plus-circle' as const, label: '요청 등록', go: () => router.push('/request-new') },
{ key: 'req', icon: 'file-text' as const, label: '내 요청', go: () => router.push('/requests') },
{ key: 'inc', icon: 'alert-circle' as const, label: '인시던트', go: () => router.push('/incidents') },
{ key: 'asset', icon: 'server' as const, label: '자산 조회', go: () => router.push('/assets') },
]
return (
<ScrollView
style={s.wrap} contentContainerStyle={s.content}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={t.accent} />}
>
<View style={s.header}>
<View>
<Text style={s.greeting}>,</Text>
<Text style={s.userName}>{me?.userNm || 'ITMS'} </Text>
</View>
<TouchableOpacity onPress={() => router.push('/notices')} style={s.iconBtn}>
<Feather name="bell" size={20} color={t.text} />
</TouchableOpacity>
</View>
{/* KPI 3분할 */}
<View style={s.kpiRow}>
{kpis.map((k) => (
<View key={k.key} style={s.kpiTile}>
<View style={[s.kpiIcon, { backgroundColor: k.color + '1e' }]}>
<Feather name={k.icon} size={16} color={k.color} />
</View>
<Text style={[s.kpiCount, { color: k.color }]}>{k.count}</Text>
<Text style={s.kpiLbl} numberOfLines={1}>{k.label}</Text>
</View>
))}
</View>
{/* 자산 요약 통계(있을 때만) */}
{statRows.length > 0 && (
<View style={s.statCard}>
<SectionTitle> </SectionTitle>
{statRows.slice(0, 6).map((r, i) => (
<View key={i} style={s.statRow}>
<Text style={s.statLbl} numberOfLines={1}>{rowTitle(r)}</Text>
<Text style={s.statVal}>{r.cnt ?? r.totCnt ?? r.count ?? r.value ?? '-'}</Text>
</View>
))}
</View>
)}
{/* 퀵액션 */}
<View style={s.quickGrid}>
{quick.map((q) => (
<TouchableOpacity key={q.key} style={s.quickCell} onPress={q.go}>
<View style={s.quickIcon}><Feather name={q.icon} size={22} color={t.brand} /></View>
<Text style={s.quickLbl}>{q.label}</Text>
</TouchableOpacity>
))}
</View>
{/* 내 할 일 */}
<SectionTitle style={s.mt}> ({todo.length})</SectionTitle>
{todo.length === 0 ? (
<Text style={s.empty}> .</Text>
) : todo.slice(0, 5).map((r, i) => (
<View key={i} style={s.rowCard}>
<View style={{ flex: 1 }}>
<Text style={s.rowTitle} numberOfLines={1}>{rowTitle(r)}</Text>
<Text style={s.rowSub}>{rowDate(r)}</Text>
</View>
<StatusPill label={rowStatus(r)} />
</View>
))}
{/* 내 인시던트 */}
<SectionTitle style={s.mt}> ({incidents.length})</SectionTitle>
{incidents.length === 0 ? (
<Text style={s.empty}> .</Text>
) : incidents.slice(0, 5).map((r, i) => (
<TouchableOpacity
key={i} style={s.rowCard}
onPress={() => router.push({ pathname: '/request-detail', params: { num: reqIncidentNum(r), raw: JSON.stringify(r) } })}
>
<View style={{ flex: 1 }}>
<Text style={s.rowTitle} numberOfLines={1}>{rowTitle(r)}</Text>
<Text style={s.rowSub}>{rowDate(r)}</Text>
</View>
<StatusPill label={rowStatus(r)} />
</TouchableOpacity>
))}
<View style={{ height: 24 }} />
</ScrollView>
)
}
const makeStyles = (t: ThemeTokens) => StyleSheet.create({
wrap: { flex: 1, backgroundColor: t.bg },
content: { padding: 16 },
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, marginTop: 8 },
greeting: { color: t.muted, fontSize: 13 },
userName: { color: t.text, fontSize: 20, fontWeight: '900', marginTop: 2 },
iconBtn: { width: 40, height: 40, borderRadius: 20, backgroundColor: t.card, borderWidth: 1, borderColor: t.border, justifyContent: 'center', alignItems: 'center' },
kpiRow: { flexDirection: 'row', gap: 10, marginBottom: 14 },
kpiTile: { flex: 1, backgroundColor: t.card, borderRadius: 12, borderWidth: 1, borderColor: t.border, paddingVertical: 14, alignItems: 'center', gap: 4 },
kpiIcon: { width: 30, height: 30, borderRadius: 15, justifyContent: 'center', alignItems: 'center', marginBottom: 2 },
kpiCount: { fontSize: 22, fontWeight: '900' },
kpiLbl: { color: t.muted, fontSize: 11, fontWeight: '600' },
statCard: { backgroundColor: t.card, borderRadius: 14, borderWidth: 1, borderColor: t.border, padding: 16, marginBottom: 14 },
statRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 6 },
statLbl: { color: t.muted, fontSize: 13, flex: 1, marginRight: 12 },
statVal: { color: t.text, fontSize: 14, fontWeight: '800' },
quickGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
quickCell: { width: '47.6%', backgroundColor: t.card, borderRadius: 14, borderWidth: 1, borderColor: t.border, paddingVertical: 18, alignItems: 'center', gap: 8 },
quickIcon: { width: 44, height: 44, borderRadius: 22, backgroundColor: t.brand + '18', justifyContent: 'center', alignItems: 'center' },
quickLbl: { color: t.text, fontSize: 13, fontWeight: '700' },
mt: { marginTop: 22 },
empty: { color: t.muted, fontSize: 13, paddingVertical: 12 },
rowCard: { flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: t.card, borderRadius: 12, borderWidth: 1, borderColor: t.border, padding: 13, marginBottom: 8 },
rowTitle: { color: t.text, fontSize: 14, fontWeight: '700' },
rowSub: { color: t.muted, fontSize: 12, marginTop: 2 },
})

View File

@ -0,0 +1,82 @@
import { useCallback, useState } from 'react'
import { View, Text, FlatList, TouchableOpacity, StyleSheet, TextInput, RefreshControl } from 'react-native'
import { useFocusEffect, router } from 'expo-router'
import { Feather } from '@expo/vector-icons'
import { useTheme } from '../../constants/ThemeContext'
import { type ThemeTokens } from '../../constants/ItmsTheme'
import { StatusPill, EmptyState } from '../../components/ui'
import { rowTitle, rowStatus, rowDate, rowAuthor, incidentSeq } from '../../constants/rows'
import { itmsSearchIncidents, listRows, itmsErrorMessage, type RowMap } from '../itmsApi'
/*
* (SvcDeskController /srm/searchIncident.do).
* / . + . .
*/
export default function Incidents() {
const { t } = useTheme()
const s = makeStyles(t)
const [rows, setRows] = useState<RowMap[]>([])
const [keyword, setKeyword] = useState('')
const [loading, setLoading] = useState(false)
const [err, setErr] = useState('')
const load = useCallback(async (kw?: string) => {
setLoading(true); setErr('')
try {
const filter: RowMap = {}
const k = (kw ?? keyword).trim()
if (k) { filter.searchKeyword = k; filter.searchWrd = k }
setRows(listRows(await itmsSearchIncidents(filter)))
} catch (e) { setErr(itmsErrorMessage(e)); setRows([]) }
finally { setLoading(false) }
}, [keyword])
useFocusEffect(useCallback(() => { load() }, [load]))
return (
<View style={s.wrap}>
<View style={s.head}>
<Text style={s.title}></Text>
<View style={s.searchBar}>
<Feather name="search" size={16} color={t.muted} />
<TextInput
style={s.searchInput} value={keyword} onChangeText={setKeyword}
placeholder="제목 검색" placeholderTextColor={t.muted}
returnKeyType="search" onSubmitEditing={() => load()}
/>
</View>
</View>
<FlatList
data={rows}
keyExtractor={(_, i) => String(i)}
contentContainerStyle={rows.length === 0 ? { flexGrow: 1 } : { padding: 16 }}
refreshControl={<RefreshControl refreshing={loading} onRefresh={() => load()} tintColor={t.accent} />}
ListEmptyComponent={loading ? null : <EmptyState icon="alert-circle" text={err || '표시할 인시던트가 없습니다.'} />}
renderItem={({ item }) => (
<TouchableOpacity
style={s.card}
onPress={() => router.push({ pathname: '/incident-detail', params: { seq: incidentSeq(item), raw: JSON.stringify(item) } })}
>
<View style={{ flex: 1 }}>
<Text style={s.cardTitle} numberOfLines={2}>{rowTitle(item)}</Text>
<Text style={s.cardSub}>{[rowAuthor(item), rowDate(item)].filter(Boolean).join(' · ')}</Text>
</View>
<StatusPill label={rowStatus(item)} />
</TouchableOpacity>
)}
/>
</View>
)
}
const makeStyles = (t: ThemeTokens) => StyleSheet.create({
wrap: { flex: 1, backgroundColor: t.bg },
head: { paddingHorizontal: 16, paddingTop: 16, paddingBottom: 8, gap: 12 },
title: { color: t.text, fontSize: 22, fontWeight: '900' },
searchBar: { flexDirection: 'row', alignItems: 'center', gap: 8, backgroundColor: t.card, borderRadius: 12, borderWidth: 1, borderColor: t.border, paddingHorizontal: 12 },
searchInput: { flex: 1, color: t.text, fontSize: 14, paddingVertical: 10 },
card: { flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: t.card, borderRadius: 12, borderWidth: 1, borderColor: t.border, padding: 14, marginBottom: 10 },
cardTitle: { color: t.text, fontSize: 15, fontWeight: '700' },
cardSub: { color: t.muted, fontSize: 12, marginTop: 4 },
})

104
mobile/app/(tabs)/me.tsx Normal file
View File

@ -0,0 +1,104 @@
import { useCallback, useState } from 'react'
import { View, Text, ScrollView, StyleSheet, TouchableOpacity } from 'react-native'
import { router, useFocusEffect } from 'expo-router'
import { Feather } from '@expo/vector-icons'
import { useTheme } from '../../constants/ThemeContext'
import { type PrefMode } from '../../constants/ThemeContext'
import { type ThemeTokens } from '../../constants/ItmsTheme'
import { Card, Row, SectionTitle, Btn } from '../../components/ui'
import { API_BASE, ITMS_ENV } from '../../constants/Config'
import { itmsProfile, clearItmsSession, type ItmsProfile } from '../itmsApi'
/*
* (userName·roles·email·org) + + .
* ITMS preferences API .
*/
export default function Me() {
const { t, mode, setMode } = useTheme()
const s = makeStyles(t)
const [me, setMe] = useState<ItmsProfile | null>(null)
useFocusEffect(useCallback(() => {
let alive = true
itmsProfile().then((p) => { if (alive) setMe(p) }).catch(() => { /* noop */ })
return () => { alive = false }
}, []))
const logout = async () => {
await clearItmsSession()
router.replace('/login')
}
const modes: { key: PrefMode; label: string; icon: keyof typeof Feather.glyphMap }[] = [
{ key: 'light', label: '라이트', icon: 'sun' },
{ key: 'dark', label: '다크', icon: 'moon' },
{ key: 'system', label: '시스템', icon: 'smartphone' },
]
return (
<ScrollView style={s.wrap} contentContainerStyle={s.content}>
<View style={s.profileHead}>
<View style={s.avatar}><Text style={s.avatarText}>{(me?.userNm || 'I').slice(0, 1)}</Text></View>
<Text style={s.name}>{me?.userNm || '-'}</Text>
<Text style={s.sub}>{me?.userId ? `@${me.userId}` : ''}</Text>
{me?.roles && me.roles.length > 0 && (
<View style={s.roleRow}>
{me.roles.map((r) => (
<View key={r} style={s.roleBadge}><Text style={s.roleText}>{r.replace(/^ROLE_/, '')}</Text></View>
))}
</View>
)}
</View>
<SectionTitle style={s.mt}> </SectionTitle>
<Card>
<Row label="아이디" value={me?.userId || '-'} />
<Row label="이메일" value={me?.email || '-'} />
<Row label="연락처" value={me?.phone || '-'} />
<Row label="조직 ID" value={me?.orgId || '-'} />
</Card>
<SectionTitle style={s.mt}> </SectionTitle>
<View style={s.modeRow}>
{modes.map((m) => {
const on = mode === m.key
return (
<TouchableOpacity key={m.key} style={[s.modeCell, on && { borderColor: t.brand, backgroundColor: t.brand + '14' }]} onPress={() => setMode(m.key)}>
<Feather name={m.icon} size={18} color={on ? t.brand : t.muted} />
<Text style={[s.modeLbl, { color: on ? t.brand : t.muted }]}>{m.label}</Text>
</TouchableOpacity>
)
})}
</View>
<SectionTitle style={s.mt}> </SectionTitle>
<Card>
<Row label="서버" value={API_BASE} />
<Row label="환경" value={ITMS_ENV} />
<Row label="버전" value="1.0.0" />
</Card>
<View style={{ marginTop: 24 }}>
<Btn label="로그아웃" kind="danger" icon="log-out" onPress={logout} />
</View>
<View style={{ height: 24 }} />
</ScrollView>
)
}
const makeStyles = (t: ThemeTokens) => StyleSheet.create({
wrap: { flex: 1, backgroundColor: t.bg },
content: { padding: 16 },
profileHead: { alignItems: 'center', paddingVertical: 20 },
avatar: { width: 72, height: 72, borderRadius: 36, backgroundColor: t.brand, justifyContent: 'center', alignItems: 'center' },
avatarText: { color: t.onBrand, fontSize: 30, fontWeight: '900' },
name: { color: t.text, fontSize: 20, fontWeight: '900', marginTop: 12 },
sub: { color: t.muted, fontSize: 13, marginTop: 2 },
roleRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 10, justifyContent: 'center' },
roleBadge: { backgroundColor: t.brand + '1a', borderRadius: 8, paddingHorizontal: 10, paddingVertical: 4 },
roleText: { color: t.brand, fontSize: 11, fontWeight: '800' },
mt: { marginTop: 22 },
modeRow: { flexDirection: 'row', gap: 10 },
modeCell: { flex: 1, backgroundColor: t.card, borderRadius: 12, borderWidth: 1, borderColor: t.border, paddingVertical: 16, alignItems: 'center', gap: 6 },
modeLbl: { fontSize: 12, fontWeight: '700' },
})

View File

@ -0,0 +1,60 @@
import { useCallback, useState } from 'react'
import { View, Text, FlatList, TouchableOpacity, StyleSheet, RefreshControl } from 'react-native'
import { useFocusEffect, router } from 'expo-router'
import { useTheme } from '../../constants/ThemeContext'
import { type ThemeTokens } from '../../constants/ItmsTheme'
import { EmptyState } from '../../components/ui'
import { rowTitle, rowDate, rowAuthor } from '../../constants/rows'
import { itmsBoardList, listRows, itmsErrorMessage, type RowMap } from '../itmsApi'
/*
* / (BBSAdminManageController /bbs/admin/selectBoardList.do).
* / .
*/
export default function Notices() {
const { t } = useTheme()
const s = makeStyles(t)
const [rows, setRows] = useState<RowMap[]>([])
const [loading, setLoading] = useState(false)
const [err, setErr] = useState('')
const load = useCallback(async () => {
setLoading(true); setErr('')
try { setRows(listRows(await itmsBoardList())) }
catch (e) { setErr(itmsErrorMessage(e)); setRows([]) }
finally { setLoading(false) }
}, [])
useFocusEffect(useCallback(() => { load() }, [load]))
return (
<View style={s.wrap}>
<View style={s.head}><Text style={s.title}></Text></View>
<FlatList
data={rows}
keyExtractor={(_, i) => String(i)}
contentContainerStyle={rows.length === 0 ? { flexGrow: 1 } : { padding: 16 }}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} tintColor={t.accent} />}
ListEmptyComponent={loading ? null : <EmptyState icon="bell" text={err || '표시할 공지가 없습니다.'} />}
renderItem={({ item }) => (
<TouchableOpacity
style={s.card}
onPress={() => router.push({ pathname: '/notice-detail', params: { raw: JSON.stringify(item) } })}
>
<Text style={s.cardTitle} numberOfLines={2}>{rowTitle(item)}</Text>
<Text style={s.cardSub}>{[rowAuthor(item), rowDate(item)].filter(Boolean).join(' · ')}</Text>
</TouchableOpacity>
)}
/>
</View>
)
}
const makeStyles = (t: ThemeTokens) => StyleSheet.create({
wrap: { flex: 1, backgroundColor: t.bg },
head: { paddingHorizontal: 16, paddingTop: 16, paddingBottom: 8 },
title: { color: t.text, fontSize: 22, fontWeight: '900' },
card: { backgroundColor: t.card, borderRadius: 12, borderWidth: 1, borderColor: t.border, padding: 14, marginBottom: 10 },
cardTitle: { color: t.text, fontSize: 15, fontWeight: '700' },
cardSub: { color: t.muted, fontSize: 12, marginTop: 4 },
})

View File

@ -0,0 +1,89 @@
import { useCallback, useState } from 'react'
import { View, Text, FlatList, TouchableOpacity, StyleSheet, TextInput, RefreshControl } from 'react-native'
import { router, useFocusEffect } from 'expo-router'
import { Feather } from '@expo/vector-icons'
import { useTheme } from '../../constants/ThemeContext'
import { type ThemeTokens } from '../../constants/ItmsTheme'
import { StatusPill, EmptyState } from '../../components/ui'
import { rowTitle, rowStatus, rowDate, rowAuthor, reqIncidentNum } from '../../constants/rows'
import { itmsSearchReqIncidents, listRows, itmsErrorMessage, type RowMap } from '../itmsApi'
/*
* (SvcDeskReqController /srm/searchReqIncident.do).
* + + (FAB). .
*/
export default function Requests() {
const { t } = useTheme()
const s = makeStyles(t)
const [rows, setRows] = useState<RowMap[]>([])
const [keyword, setKeyword] = useState('')
const [loading, setLoading] = useState(false)
const [err, setErr] = useState('')
const load = useCallback(async (kw?: string) => {
setLoading(true); setErr('')
try {
const filter: RowMap = {}
const k = (kw ?? keyword).trim()
if (k) { filter.searchKeyword = k; filter.searchWrd = k }
setRows(listRows(await itmsSearchReqIncidents(filter)))
} catch (e) { setErr(itmsErrorMessage(e)); setRows([]) }
finally { setLoading(false) }
}, [keyword])
useFocusEffect(useCallback(() => { load() }, [load]))
return (
<View style={s.wrap}>
<View style={s.head}>
<Text style={s.title}> </Text>
<View style={s.searchBar}>
<Feather name="search" size={16} color={t.muted} />
<TextInput
style={s.searchInput} value={keyword} onChangeText={setKeyword}
placeholder="제목 검색" placeholderTextColor={t.muted}
returnKeyType="search" onSubmitEditing={() => load()}
/>
</View>
</View>
<FlatList
data={rows}
keyExtractor={(_, i) => String(i)}
contentContainerStyle={rows.length === 0 ? { flexGrow: 1 } : { padding: 16 }}
refreshControl={<RefreshControl refreshing={loading} onRefresh={() => load()} tintColor={t.accent} />}
ListEmptyComponent={
loading ? null : <EmptyState icon="file-text" text={err || '표시할 요청이 없습니다.'} />
}
renderItem={({ item }) => (
<TouchableOpacity
style={s.card}
onPress={() => router.push({ pathname: '/request-detail', params: { num: reqIncidentNum(item), raw: JSON.stringify(item) } })}
>
<View style={{ flex: 1 }}>
<Text style={s.cardTitle} numberOfLines={2}>{rowTitle(item)}</Text>
<Text style={s.cardSub}>{[rowAuthor(item), rowDate(item)].filter(Boolean).join(' · ')}</Text>
</View>
<StatusPill label={rowStatus(item)} />
</TouchableOpacity>
)}
/>
<TouchableOpacity style={s.fab} onPress={() => router.push('/request-new')}>
<Feather name="plus" size={24} color={t.onBrand} />
</TouchableOpacity>
</View>
)
}
const makeStyles = (t: ThemeTokens) => StyleSheet.create({
wrap: { flex: 1, backgroundColor: t.bg },
head: { paddingHorizontal: 16, paddingTop: 16, paddingBottom: 8, gap: 12 },
title: { color: t.text, fontSize: 22, fontWeight: '900' },
searchBar: { flexDirection: 'row', alignItems: 'center', gap: 8, backgroundColor: t.card, borderRadius: 12, borderWidth: 1, borderColor: t.border, paddingHorizontal: 12 },
searchInput: { flex: 1, color: t.text, fontSize: 14, paddingVertical: 10 },
card: { flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: t.card, borderRadius: 12, borderWidth: 1, borderColor: t.border, padding: 14, marginBottom: 10 },
cardTitle: { color: t.text, fontSize: 15, fontWeight: '700' },
cardSub: { color: t.muted, fontSize: 12, marginTop: 4 },
fab: { position: 'absolute', right: 20, bottom: 24, width: 56, height: 56, borderRadius: 28, backgroundColor: t.brand, justifyContent: 'center', alignItems: 'center', elevation: 4, shadowColor: '#000', shadowOpacity: 0.2, shadowRadius: 6, shadowOffset: { width: 0, height: 3 } },
})

44
mobile/app/_layout.tsx Normal file
View File

@ -0,0 +1,44 @@
import { Stack } from 'expo-router'
import { View } from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { ThemeProvider, useTheme } from '../constants/ThemeContext'
/*
* ITMS ThemeProvider + .
* BrandSplash( View) (index ).
*/
function ItmsStack() {
const { t, resolved } = useTheme()
return (
<View style={{ flex: 1, backgroundColor: t.bg }}>
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
<Stack
screenOptions={{
headerStyle: { backgroundColor: t.bg },
headerTintColor: t.text,
headerTitleStyle: { fontWeight: '800', fontSize: 16 },
contentStyle: { backgroundColor: t.bg },
headerShadowVisible: false,
}}
>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="(auth)/login" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="request-detail" options={{ title: '요청 상세' }} />
<Stack.Screen name="request-new" options={{ title: '서비스 요청 등록' }} />
<Stack.Screen name="incident-detail" options={{ title: '인시던트 상세' }} />
<Stack.Screen name="notice-detail" options={{ title: '공지 상세' }} />
<Stack.Screen name="assets" options={{ title: '자산 조회' }} />
<Stack.Screen name="asset-detail" options={{ title: '자산 상세' }} />
</Stack>
</View>
)
}
export default function RootLayout() {
return (
<ThemeProvider>
<ItmsStack />
</ThemeProvider>
)
}

View File

@ -0,0 +1,71 @@
import { useEffect, useState } from 'react'
import { ScrollView, StyleSheet, Text, View } from 'react-native'
import { useLocalSearchParams } from 'expo-router'
import { useTheme } from '../constants/ThemeContext'
import { type ThemeTokens } from '../constants/ItmsTheme'
import { Card, Loading, StatusPill, ErrText, SectionTitle } from '../components/ui'
import { rowTitle, rowStatus, rowEntries, pick } from '../constants/rows'
import { itmsAssetInfo, itmsErrorMessage, type RowMap } from './itmsApi'
/*
* (ResourceManagerController /rem/getAssetInfo.do).
* raw ( raw ).
*/
export default function AssetDetail() {
const { raw } = useLocalSearchParams<{ raw?: string }>()
const { t } = useTheme()
const s = makeStyles(t)
const seed: RowMap = raw ? safeParse(raw) : {}
const [row, setRow] = useState<RowMap>(seed)
const [loading, setLoading] = useState(Object.keys(seed).length > 0)
const [err, setErr] = useState('')
useEffect(() => {
let alive = true
if (Object.keys(seed).length === 0) { setLoading(false); return }
;(async () => {
try {
const detail = await itmsAssetInfo(seed)
const merged = { ...seed, ...(detail?.resultVo ?? detail?.assetVo ?? detail ?? {}) }
if (alive) setRow(merged)
} catch (e) { if (alive) setErr(itmsErrorMessage(e)) }
finally { if (alive) setLoading(false) }
})()
return () => { alive = false }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [raw])
if (loading && Object.keys(seed).length === 0) return <Loading />
return (
<ScrollView style={s.wrap} contentContainerStyle={s.content}>
<Text style={s.title}>{rowTitle(row)}</Text>
<Text style={s.sub}>{pick(row, ['assetId', 'resrceId', 'resrceNo', 'mngNo', 'serialNo'])}</Text>
{!!rowStatus(row) && <View style={{ marginTop: 8 }}><StatusPill label={rowStatus(row)} /></View>}
<ErrText msg={err} />
<SectionTitle style={s.mt}> </SectionTitle>
<Card>
{rowEntries(row).map((e) => (
<View key={e.key} style={s.row}>
<Text style={s.k} numberOfLines={1}>{e.key}</Text>
<Text style={s.v} numberOfLines={4}>{e.value}</Text>
</View>
))}
</Card>
<View style={{ height: 24 }} />
</ScrollView>
)
}
function safeParse(raw: string): RowMap { try { return JSON.parse(raw) as RowMap } catch { return {} } }
const makeStyles = (t: ThemeTokens) => StyleSheet.create({
wrap: { flex: 1, backgroundColor: t.bg },
content: { padding: 16 },
title: { color: t.text, fontSize: 19, fontWeight: '900' },
sub: { color: t.muted, fontSize: 13, marginTop: 4 },
mt: { marginTop: 20 },
row: { flexDirection: 'row', justifyContent: 'space-between', gap: 12, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: t.border },
k: { color: t.muted, fontSize: 12, flexShrink: 0, maxWidth: '40%' },
v: { color: t.text, fontSize: 13, fontWeight: '600', flex: 1, textAlign: 'right' },
})

81
mobile/app/assets.tsx Normal file
View File

@ -0,0 +1,81 @@
import { useCallback, useState } from 'react'
import { View, Text, FlatList, TouchableOpacity, StyleSheet, TextInput, RefreshControl } from 'react-native'
import { router, useFocusEffect } from 'expo-router'
import { Feather } from '@expo/vector-icons'
import { useTheme } from '../constants/ThemeContext'
import { type ThemeTokens } from '../constants/ItmsTheme'
import { EmptyState, StatusPill } from '../components/ui'
import { rowTitle, rowStatus, pick } from '../constants/rows'
import { itmsSearchAssets, listRows, itmsErrorMessage, type RowMap } from './itmsApi'
/*
* (ResourceManagerController /rem/searchAsset.do). + .
* raw getAssetInfo .
*/
export default function Assets() {
const { t } = useTheme()
const s = makeStyles(t)
const [rows, setRows] = useState<RowMap[]>([])
const [keyword, setKeyword] = useState('')
const [loading, setLoading] = useState(false)
const [err, setErr] = useState('')
const load = useCallback(async (kw?: string) => {
setLoading(true); setErr('')
try {
const filter: RowMap = {}
const k = (kw ?? keyword).trim()
if (k) { filter.searchKeyword = k; filter.searchWrd = k }
setRows(listRows(await itmsSearchAssets(filter)))
} catch (e) { setErr(itmsErrorMessage(e)); setRows([]) }
finally { setLoading(false) }
}, [keyword])
useFocusEffect(useCallback(() => { load() }, [load]))
const assetCode = (r: RowMap) => pick(r, ['assetId', 'resrceId', 'assetCode', 'resrceNo', 'mngNo', 'serialNo'])
return (
<View style={s.wrap}>
<View style={s.head}>
<View style={s.searchBar}>
<Feather name="search" size={16} color={t.muted} />
<TextInput
style={s.searchInput} value={keyword} onChangeText={setKeyword}
placeholder="자산명·자산번호 검색" placeholderTextColor={t.muted}
returnKeyType="search" onSubmitEditing={() => load()}
/>
</View>
</View>
<FlatList
data={rows}
keyExtractor={(_, i) => String(i)}
contentContainerStyle={rows.length === 0 ? { flexGrow: 1 } : { padding: 16 }}
refreshControl={<RefreshControl refreshing={loading} onRefresh={() => load()} tintColor={t.accent} />}
ListEmptyComponent={loading ? null : <EmptyState icon="server" text={err || '표시할 자산이 없습니다.'} />}
renderItem={({ item }) => (
<TouchableOpacity
style={s.card}
onPress={() => router.push({ pathname: '/asset-detail', params: { raw: JSON.stringify(item) } })}
>
<View style={{ flex: 1 }}>
<Text style={s.cardTitle} numberOfLines={1}>{rowTitle(item)}</Text>
<Text style={s.cardSub}>{assetCode(item)}</Text>
</View>
{!!rowStatus(item) && <StatusPill label={rowStatus(item)} />}
</TouchableOpacity>
)}
/>
</View>
)
}
const makeStyles = (t: ThemeTokens) => StyleSheet.create({
wrap: { flex: 1, backgroundColor: t.bg },
head: { paddingHorizontal: 16, paddingTop: 16, paddingBottom: 8 },
searchBar: { flexDirection: 'row', alignItems: 'center', gap: 8, backgroundColor: t.card, borderRadius: 12, borderWidth: 1, borderColor: t.border, paddingHorizontal: 12 },
searchInput: { flex: 1, color: t.text, fontSize: 14, paddingVertical: 10 },
card: { flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: t.card, borderRadius: 12, borderWidth: 1, borderColor: t.border, padding: 14, marginBottom: 10 },
cardTitle: { color: t.text, fontSize: 15, fontWeight: '700' },
cardSub: { color: t.muted, fontSize: 12, marginTop: 4 },
})

View File

@ -0,0 +1,74 @@
import { useEffect, useState } from 'react'
import { ScrollView, StyleSheet, Text, View } from 'react-native'
import { useLocalSearchParams } from 'expo-router'
import { useTheme } from '../constants/ThemeContext'
import { type ThemeTokens } from '../constants/ItmsTheme'
import { Card, Loading, StatusPill, ErrText, SectionTitle } from '../components/ui'
import { rowTitle, rowStatus, rowEntries } from '../constants/rows'
import { itmsIncidentDetail, itmsErrorMessage, type RowMap } from './itmsApi'
/*
* (SvcDeskController /srm/getIncidentBySeq.do body {inIncidentSeq:int}).
* raw API .
*/
export default function IncidentDetail() {
const { seq, raw } = useLocalSearchParams<{ seq?: string; raw?: string }>()
const { t } = useTheme()
const s = makeStyles(t)
const seed: RowMap = raw ? safeParse(raw) : {}
const [row, setRow] = useState<RowMap>(seed)
const seqNum = seq ? Number(seq) : NaN
const [loading, setLoading] = useState(Number.isFinite(seqNum))
const [err, setErr] = useState('')
useEffect(() => {
let alive = true
if (!Number.isFinite(seqNum)) { setLoading(false); return }
;(async () => {
try {
const detail = await itmsIncidentDetail(seqNum)
const merged = { ...seed, ...(detail?.resultVo ?? detail?.incidentVo ?? detail ?? {}) }
if (alive) setRow(merged)
} catch (e) { if (alive) setErr(itmsErrorMessage(e)) }
finally { if (alive) setLoading(false) }
})()
return () => { alive = false }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [seq])
if (loading && Object.keys(seed).length === 0) return <Loading />
const entries = rowEntries(row)
return (
<ScrollView style={s.wrap} contentContainerStyle={s.content}>
<Text style={s.title}>{rowTitle(row)}</Text>
<View style={{ marginTop: 8 }}><StatusPill label={rowStatus(row)} /></View>
<ErrText msg={err} />
<SectionTitle style={s.mt}> </SectionTitle>
<Card>
{entries.length === 0 ? (
<Text style={s.empty}> .</Text>
) : entries.map((e) => (
<View key={e.key} style={s.row}>
<Text style={s.k} numberOfLines={1}>{e.key}</Text>
<Text style={s.v} numberOfLines={4}>{e.value}</Text>
</View>
))}
</Card>
<View style={{ height: 24 }} />
</ScrollView>
)
}
function safeParse(raw: string): RowMap { try { return JSON.parse(raw) as RowMap } catch { return {} } }
const makeStyles = (t: ThemeTokens) => StyleSheet.create({
wrap: { flex: 1, backgroundColor: t.bg },
content: { padding: 16 },
title: { color: t.text, fontSize: 19, fontWeight: '900' },
mt: { marginTop: 20 },
row: { flexDirection: 'row', justifyContent: 'space-between', gap: 12, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: t.border },
k: { color: t.muted, fontSize: 12, flexShrink: 0, maxWidth: '40%' },
v: { color: t.text, fontSize: 13, fontWeight: '600', flex: 1, textAlign: 'right' },
empty: { color: t.muted, fontSize: 13 },
})

24
mobile/app/index.tsx Normal file
View File

@ -0,0 +1,24 @@
import { useEffect, useState } from 'react'
import { Redirect } from 'expo-router'
import { hasItmsToken } from './itmsApi'
import { BrandSplash } from '../components/BrandSplash'
/*
* (/) (/home) (/login) .
* .
*/
export default function Index() {
const [dest, setDest] = useState<null | '/home' | '/login'>(null)
useEffect(() => {
let alive = true
;(async () => {
const authed = await hasItmsToken()
if (alive) setDest(authed ? '/home' : '/login')
})()
return () => { alive = false }
}, [])
if (!dest) return <BrandSplash />
return <Redirect href={dest} />
}

250
mobile/app/itmsApi.ts Normal file
View File

@ -0,0 +1,250 @@
import axios, { AxiosError, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import { encode as btoa } from 'base-64'
import * as SecureStore from 'expo-secure-store'
import { Platform } from 'react-native'
import { API_BASE, OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET } from '../constants/Config'
/*
* ITMS API OAuth2 password grant(인가서버 :11000)
* + REST(:11010, POST `/xxx.do` + Bearer JWT).
*
* (6 ):
* - 토큰: POST {BASE}/oauth/token?grant_type=password&username&password&ssoYn=N
* Authorization: Basic base64(clientId:secretKey). JSON OAuth2
* + CustomTokenEnhancer (userName·authorities( ,'-')·orgnztId ).
* - 리프레시: 동일 grant_type=refresh_token&refresh_token=..
* - API: POST( /main/EgovHeader.do PUT), body=JSON( {}),
* { data:{...}, status:200, code, message }. payload data.
* data.resultList(+totCnt/resultCnt), data.resultCd(0 ok/-1 fail)+resultMsg.
* 보안: 토큰을 console . ( , TLS ) .
*/
export const ITMS_ACCESS_KEY = 'itms_access'
export const ITMS_REFRESH_KEY = 'itms_refresh'
export const ITMS_PROFILE_KEY = 'itms_profile'
// 토큰/프로필 저장: 네이티브=expo-secure-store, 웹=localStorage 폴백(SDK51 secure-store 웹 미구현).
const isWeb = Platform.OS === 'web'
async function secureGet(key: string): Promise<string | null> {
if (isWeb) { try { return globalThis.localStorage?.getItem(key) ?? null } catch { return null } }
return SecureStore.getItemAsync(key)
}
async function secureSet(key: string, value: string): Promise<void> {
if (isWeb) { try { globalThis.localStorage?.setItem(key, value) } catch { /* noop */ } return }
await SecureStore.setItemAsync(key, value)
}
async function secureDelete(key: string): Promise<void> {
if (isWeb) { try { globalThis.localStorage?.removeItem(key) } catch { /* noop */ } return }
await SecureStore.deleteItemAsync(key)
}
// ───────────────────────── 타입 ─────────────────────────
export type RowMap = Record<string, any>
/** OAuth2 토큰 응답(표준 + CustomTokenEnhancer 평탄화 커스텀 필드). */
export interface OauthTokenResponse {
access_token: string
token_type: string
refresh_token?: string
expires_in?: number
scope?: string
userName?: string
strMEM_ID?: string
strMEM_NM?: string
strMEM_EML_ADDR?: string
strMDL_TELNO?: string
strMOVE_TELNO?: string
orgnztId?: string
uniqId?: string
authorities?: string
error?: string
error_description?: string
}
/** 앱 내부에서 쓰는 정규화 프로필. */
export interface ItmsProfile {
userId: string
userNm: string
email?: string
phone?: string
orgId?: string
uniqId?: string
roles: string[]
}
/** 리소스서버 응답 봉투. */
export interface ResultData<T = RowMap> { data: T; status: number; code?: string; message?: string; totalCount?: number | null }
/** 목록 payload(data 하위). */
export interface ListPayload { resultList?: RowMap[]; totCnt?: number; resultCnt?: number; [k: string]: any }
// ───────────────────────── 인증(OAuth2) ─────────────────────────
function basicAuth(): string { return `Basic ${btoa(`${OAUTH_CLIENT_ID}:${OAUTH_CLIENT_SECRET}`)}` }
/** authorities 단일 문자열("ROLE_ADMIN-ROLE_USER-") → 배열. */
function parseRoles(authorities?: string): string[] {
if (!authorities) return []
return authorities.split('-').map((r) => r.trim()).filter(Boolean)
}
function toProfile(tk: OauthTokenResponse): ItmsProfile {
return {
userId: tk.strMEM_ID ?? '',
userNm: tk.userName ?? tk.strMEM_NM ?? tk.strMEM_ID ?? '',
email: tk.strMEM_EML_ADDR ?? undefined,
phone: tk.strMDL_TELNO ?? tk.strMOVE_TELNO ?? undefined,
orgId: tk.orgnztId ?? undefined,
uniqId: tk.uniqId ?? undefined,
roles: parseRoles(tk.authorities),
}
}
/** 로그인 — password grant. 성공 시 토큰+프로필 저장, 프로필 반환. */
export async function itmsLogin(userId: string, password: string): Promise<ItmsProfile> {
const res = await axios.post<OauthTokenResponse>(
`${API_BASE}/oauth/token`,
null,
{
params: { grant_type: 'password', username: userId, password, ssoYn: 'N' },
headers: { Authorization: basicAuth() },
timeout: 30000,
}
)
const tk = res.data
if (tk.error || !tk.access_token) throw new Error(tk.error_description || tk.error || '로그인에 실패했습니다.')
await secureSet(ITMS_ACCESS_KEY, tk.access_token)
if (tk.refresh_token) await secureSet(ITMS_REFRESH_KEY, tk.refresh_token)
const profile = toProfile(tk)
await secureSet(ITMS_PROFILE_KEY, JSON.stringify(profile))
return profile
}
let refreshPromise: Promise<string> | null = null
async function doRefresh(): Promise<string> {
const refreshToken = await secureGet(ITMS_REFRESH_KEY)
if (!refreshToken) throw new Error('no refresh token')
const res = await axios.post<OauthTokenResponse>(
`${API_BASE}/oauth/token`,
null,
{
params: { grant_type: 'refresh_token', refresh_token: refreshToken },
headers: { Authorization: basicAuth() },
timeout: 30000,
}
)
const tk = res.data
if (!tk.access_token) throw new Error('refresh failed')
await secureSet(ITMS_ACCESS_KEY, tk.access_token)
if (tk.refresh_token) await secureSet(ITMS_REFRESH_KEY, tk.refresh_token)
return tk.access_token
}
export async function clearItmsSession(): Promise<void> {
await secureDelete(ITMS_ACCESS_KEY)
await secureDelete(ITMS_REFRESH_KEY)
await secureDelete(ITMS_PROFILE_KEY)
}
export async function hasItmsToken(): Promise<boolean> { return !!(await secureGet(ITMS_ACCESS_KEY)) }
export async function itmsProfile(): Promise<ItmsProfile | null> {
const raw = await secureGet(ITMS_PROFILE_KEY)
if (!raw) return null
try { return JSON.parse(raw) as ItmsProfile } catch { return null }
}
// ───────────────────────── 리소스서버 클라이언트 ─────────────────────────
const client = axios.create({ baseURL: API_BASE, timeout: 30000, headers: { 'Content-Type': 'application/json' } })
client.interceptors.request.use(async (cfg: InternalAxiosRequestConfig) => {
const token = await secureGet(ITMS_ACCESS_KEY)
if (token) cfg.headers.Authorization = `Bearer ${token}`
return cfg
})
client.interceptors.response.use(
(r: AxiosResponse) => r,
async (error: AxiosError) => {
const original = error.config as (InternalAxiosRequestConfig & { _retry?: boolean }) | undefined
const status = error.response?.status
if (status === 401 && original && !original._retry) {
original._retry = true
try {
if (!refreshPromise) refreshPromise = doRefresh().finally(() => { refreshPromise = null })
const newToken = await refreshPromise
original.headers.Authorization = `Bearer ${newToken}`
return client(original)
} catch {
await clearItmsSession()
return Promise.reject(error)
}
}
if (status === 401) await clearItmsSession()
return Promise.reject(error)
}
)
/** POST `/xxx.do` → data payload 언래핑(없으면 빈 객체). */
export async function apiPost<T = RowMap>(path: string, body: RowMap = {}): Promise<T> {
const r = await client.post<ResultData<T>>(path, body)
return (r.data?.data ?? ({} as T))
}
/** PUT `/xxx.do`(예: EgovHeader) → data 언래핑. */
export async function apiPut<T = RowMap>(path: string, body: RowMap = {}): Promise<T> {
const r = await client.put<ResultData<T>>(path, body)
return (r.data?.data ?? ({} as T))
}
/** 목록 payload → resultList 배열 평탄화(방어적). */
export function listRows(p: ListPayload | RowMap | null | undefined): RowMap[] {
const d: any = p
if (Array.isArray(d)) return d as RowMap[]
if (d && Array.isArray(d.resultList)) return d.resultList as RowMap[]
return []
}
export function itmsErrorMessage(error: unknown): string {
if (axios.isAxiosError(error)) {
const data = error.response?.data as any
if (typeof data?.error_description === 'string') return data.error_description
if (typeof data?.message === 'string' && data.message !== 'Success') return data.message
if (error.response?.status === 401) return '인증이 만료되었습니다. 다시 로그인해 주세요.'
if (error.message) return error.message
}
if (error instanceof Error) return error.message
return '요청 처리 중 오류가 발생했습니다.'
}
// ───────────────────────── 업무 엔드포인트(MVP) ─────────────────────────
// 공통 페이징(eGov 계열 추정 — 서버가 무시하면 기본 목록 반환). 필터는 caller 가 스프레드.
const paging = { pageIndex: 1, pageUnit: 20, pageSize: 20 }
// 홈 대시보드(HomeController — user 는 토큰에서 도출, body {})
export const itmsMyIncidents = () => apiPost<ListPayload>('/srm/getMyIncidents.do')
export const itmsMyReports = () => apiPost<ListPayload>('/srm/getMyReports.do')
export const itmsMyTodo = () => apiPost<ListPayload>('/srm/getMyTodo.do')
export const itmsResourceStat = () => apiPost<ListPayload>('/sta/getResouceTotalStat.do')
/** 헤더 메뉴(PUT). 실패 시 화면은 폴백 처리. */
export const itmsHeaderMenu = () => apiPut<RowMap>('/main/EgovHeader.do')
// 내 서비스 요청(SvcDeskReqController)
export const itmsSearchReqIncidents = (filter: RowMap = {}) => apiPost<ListPayload>('/srm/searchReqIncident.do', { ...paging, ...filter })
export const itmsReqIncidentDetail = (vcReqIncidentNum: string | number) => apiPost<RowMap>('/srm/getReqIncidentBySeq.do', { vcReqIncidentNum })
export interface ReqIncidentCreate { incidentReqVo: RowMap; fileList?: RowMap[] }
export const itmsAddReqIncident = (body: ReqIncidentCreate) => apiPost<RowMap>('/srm/addReqIncident.do', body)
// 서비스데스크 인시던트(SvcDeskController — 담당자/관리자)
export const itmsSearchIncidents = (filter: RowMap = {}) => apiPost<ListPayload>('/srm/searchIncident.do', { ...paging, ...filter })
export const itmsIncidentDetail = (inIncidentSeq: number) => apiPost<RowMap>('/srm/getIncidentBySeq.do', { inIncidentSeq })
export const itmsServiceCombo = () => apiPost<ListPayload>('/srm/getServiceCombo.do')
// 공지/게시판(BBSAdminManageController)
export const itmsBoardList = (boardVO: RowMap = {}) => apiPost<ListPayload>('/bbs/admin/selectBoardList.do', { boardVO: { ...paging, ...boardVO } })
export const itmsBoardArticle = (board: RowMap) => apiPost<RowMap>('/bbs/admin/selectBoardArticle.do', { board })
// 자산(ResourceManagerController)
export const itmsSearchAssets = (filter: RowMap = {}) => apiPost<ListPayload>('/rem/searchAsset.do', { ...paging, ...filter })
export const itmsAssetInfo = (params: RowMap) => apiPost<RowMap>('/rem/getAssetInfo.do', params)
// 공통 코드(CmmUseController) — codeId 콤마목록
export const itmsCmmCodes = (codeId: string) => apiPost<RowMap>('/cmm/selectCmmCodeDetail.do', { codeId })
// 내 정보(UserManageController) — 로그인 로그(permitAll)
export const itmsUserView = () => apiPost<RowMap>('/sys/UserSelectUpdtView.do')

View File

@ -0,0 +1,77 @@
import { useEffect, useState } from 'react'
import { ScrollView, StyleSheet, Text, View } from 'react-native'
import { useLocalSearchParams } from 'expo-router'
import { useTheme } from '../constants/ThemeContext'
import { type ThemeTokens } from '../constants/ItmsTheme'
import { Card, Loading, ErrText, SectionTitle } from '../components/ui'
import { rowTitle, rowDate, rowAuthor, pick, rowEntries } from '../constants/rows'
import { itmsBoardArticle, itmsErrorMessage, type RowMap } from './itmsApi'
/*
* (BBSAdminManageController /bbs/admin/selectBoardArticle.do body {board:{...}}).
* raw( ) board .
*/
export default function NoticeDetail() {
const { raw } = useLocalSearchParams<{ raw?: string }>()
const { t } = useTheme()
const s = makeStyles(t)
const seed: RowMap = raw ? safeParse(raw) : {}
const [row, setRow] = useState<RowMap>(seed)
const [loading, setLoading] = useState(Object.keys(seed).length > 0)
const [err, setErr] = useState('')
useEffect(() => {
let alive = true
if (Object.keys(seed).length === 0) { setLoading(false); return }
;(async () => {
try {
const detail = await itmsBoardArticle(seed)
const merged = { ...seed, ...(detail?.resultVo ?? detail?.board ?? detail ?? {}) }
if (alive) setRow(merged)
} catch (e) { if (alive) setErr(itmsErrorMessage(e)) }
finally { if (alive) setLoading(false) }
})()
return () => { alive = false }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [raw])
if (loading && !pick(seed, ['nttCn', 'contents', 'cn'])) return <Loading />
const content = pick(row, ['nttCn', 'contents', 'cn', 'articleCn', 'bdwrCn'])
return (
<ScrollView style={s.wrap} contentContainerStyle={s.content}>
<Text style={s.title}>{rowTitle(row)}</Text>
<Text style={s.meta}>{[rowAuthor(row), rowDate(row)].filter(Boolean).join(' · ')}</Text>
<ErrText msg={err} />
{!!content && (
<Card style={{ marginTop: 16 }}>
<Text style={s.body}>{content}</Text>
</Card>
)}
<SectionTitle style={s.mt}></SectionTitle>
<Card>
{rowEntries(row).map((e) => (
<View key={e.key} style={s.row}>
<Text style={s.k} numberOfLines={1}>{e.key}</Text>
<Text style={s.v} numberOfLines={4}>{e.value}</Text>
</View>
))}
</Card>
<View style={{ height: 24 }} />
</ScrollView>
)
}
function safeParse(raw: string): RowMap { try { return JSON.parse(raw) as RowMap } catch { return {} } }
const makeStyles = (t: ThemeTokens) => StyleSheet.create({
wrap: { flex: 1, backgroundColor: t.bg },
content: { padding: 16 },
title: { color: t.text, fontSize: 19, fontWeight: '900' },
meta: { color: t.muted, fontSize: 12, marginTop: 6 },
body: { color: t.text, fontSize: 14, lineHeight: 21 },
mt: { marginTop: 20 },
row: { flexDirection: 'row', justifyContent: 'space-between', gap: 12, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: t.border },
k: { color: t.muted, fontSize: 12, flexShrink: 0, maxWidth: '40%' },
v: { color: t.text, fontSize: 13, fontWeight: '600', flex: 1, textAlign: 'right' },
})

View File

@ -0,0 +1,73 @@
import { useEffect, useState } from 'react'
import { ScrollView, StyleSheet, Text, View } from 'react-native'
import { useLocalSearchParams } from 'expo-router'
import { useTheme } from '../constants/ThemeContext'
import { type ThemeTokens } from '../constants/ItmsTheme'
import { Card, Loading, StatusPill, ErrText, SectionTitle } from '../components/ui'
import { rowTitle, rowStatus, rowEntries } from '../constants/rows'
import { itmsReqIncidentDetail, itmsErrorMessage, type RowMap } from './itmsApi'
/*
* (SvcDeskReqController /srm/getReqIncidentBySeq.do).
* raw , API ( raw ).
*/
export default function RequestDetail() {
const { num, raw } = useLocalSearchParams<{ num?: string; raw?: string }>()
const { t } = useTheme()
const s = makeStyles(t)
const seed: RowMap = raw ? safeParse(raw) : {}
const [row, setRow] = useState<RowMap>(seed)
const [loading, setLoading] = useState(!!num)
const [err, setErr] = useState('')
useEffect(() => {
let alive = true
if (!num) { setLoading(false); return }
;(async () => {
try {
const detail = await itmsReqIncidentDetail(num)
const merged = { ...seed, ...(detail?.resultVo ?? detail?.incidentReqVo ?? detail ?? {}) }
if (alive) setRow(merged)
} catch (e) { if (alive) setErr(itmsErrorMessage(e)) }
finally { if (alive) setLoading(false) }
})()
return () => { alive = false }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [num])
if (loading && Object.keys(seed).length === 0) return <Loading />
const entries = rowEntries(row)
return (
<ScrollView style={s.wrap} contentContainerStyle={s.content}>
<Text style={s.title}>{rowTitle(row)}</Text>
<View style={{ marginTop: 8 }}><StatusPill label={rowStatus(row)} /></View>
<ErrText msg={err} />
<SectionTitle style={s.mt}> </SectionTitle>
<Card>
{entries.length === 0 ? (
<Text style={s.empty}> .</Text>
) : entries.map((e) => (
<View key={e.key} style={s.row}>
<Text style={s.k} numberOfLines={1}>{e.key}</Text>
<Text style={s.v} numberOfLines={4}>{e.value}</Text>
</View>
))}
</Card>
<View style={{ height: 24 }} />
</ScrollView>
)
}
function safeParse(raw: string): RowMap { try { return JSON.parse(raw) as RowMap } catch { return {} } }
const makeStyles = (t: ThemeTokens) => StyleSheet.create({
wrap: { flex: 1, backgroundColor: t.bg },
content: { padding: 16 },
title: { color: t.text, fontSize: 19, fontWeight: '900' },
mt: { marginTop: 20 },
row: { flexDirection: 'row', justifyContent: 'space-between', gap: 12, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: t.border },
k: { color: t.muted, fontSize: 12, flexShrink: 0, maxWidth: '40%' },
v: { color: t.text, fontSize: 13, fontWeight: '600', flex: 1, textAlign: 'right' },
empty: { color: t.muted, fontSize: 13 },
})

102
mobile/app/request-new.tsx Normal file
View File

@ -0,0 +1,102 @@
import { useEffect, useState } from 'react'
import { ScrollView, StyleSheet, Text, View, TouchableOpacity, Alert } from 'react-native'
import { router } from 'expo-router'
import { useTheme } from '../constants/ThemeContext'
import { type ThemeTokens } from '../constants/ItmsTheme'
import { Field, Btn, ErrText, SectionTitle } from '../components/ui'
import { pick } from '../constants/rows'
import { itmsAddReqIncident, itmsServiceCombo, listRows, itmsErrorMessage, type RowMap } from './itmsApi'
/*
* (SvcDeskReqController /srm/addReqIncident.do body {incidentReqVo:{...}}).
* / + () . VO ().
* 판정: resultCd===0( '0'). resultMsg .
*/
export default function RequestNew() {
const { t } = useTheme()
const s = makeStyles(t)
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [services, setServices] = useState<RowMap[]>([])
const [svc, setSvc] = useState<RowMap | null>(null)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
useEffect(() => {
let alive = true
itmsServiceCombo()
.then((p) => { if (alive) setServices(listRows(p)) })
.catch(() => { /* 콤보 없으면 서비스 선택 생략 */ })
return () => { alive = false }
}, [])
const svcId = (r: RowMap) => pick(r, ['svcSeq', 'serviceSeq', 'svcId', 'serviceId', 'inSvcSeq', 'seq'])
const svcNm = (r: RowMap) => pick(r, ['svcNm', 'serviceNm', 'svcName', 'name'])
const submit = async () => {
if (!title.trim()) { setErr('제목을 입력하세요.'); return }
if (!content.trim()) { setErr('요청 내용을 입력하세요.'); return }
setBusy(true); setErr('')
// 흔한 컬럼 별칭을 함께 채워 기관별 스키마 편차 흡수.
const vo: RowMap = {
reqTitle: title.trim(), title: title.trim(), incidentTitle: title.trim(),
reqCn: content.trim(), contents: content.trim(), incidentCn: content.trim(), reqContents: content.trim(),
}
if (svc) {
const id = svcId(svc)
Object.assign(vo, { svcSeq: id, serviceSeq: id, svcId: id, serviceId: id, svcNm: svcNm(svc) })
}
try {
const res = await itmsAddReqIncident({ incidentReqVo: vo, fileList: [] })
const ok = res?.resultCd === 0 || res?.resultCd === '0'
if (ok || res?.inIncidentSeq) {
Alert.alert('완료', '서비스 요청이 등록되었습니다.', [{ text: '확인', onPress: () => router.back() }])
} else {
setErr(String(res?.resultMsg || '등록에 실패했습니다.'))
}
} catch (e) { setErr(itmsErrorMessage(e)) }
finally { setBusy(false) }
}
return (
<ScrollView style={s.wrap} contentContainerStyle={s.content} keyboardShouldPersistTaps="handled">
<Field label="제목" value={title} onChangeText={setTitle} placeholder="요청 제목" />
<Field label="요청 내용" value={content} onChangeText={setContent} placeholder="상세 내용을 입력하세요" multiline />
{services.length > 0 && (
<>
<SectionTitle style={s.mt}> ()</SectionTitle>
<View style={s.chips}>
{services.slice(0, 20).map((r, i) => {
const on = svc && svcId(svc) === svcId(r)
return (
<TouchableOpacity
key={i}
style={[s.chip, on && { borderColor: t.brand, backgroundColor: t.brand + '14' }]}
onPress={() => setSvc(on ? null : r)}
>
<Text style={[s.chipText, { color: on ? t.brand : t.muted }]} numberOfLines={1}>{svcNm(r) || svcId(r)}</Text>
</TouchableOpacity>
)
})}
</View>
</>
)}
<ErrText msg={err} />
<View style={{ marginTop: 22 }}>
<Btn label="등록" icon="send" busy={busy} onPress={submit} />
</View>
<View style={{ height: 24 }} />
</ScrollView>
)
}
const makeStyles = (t: ThemeTokens) => StyleSheet.create({
wrap: { flex: 1, backgroundColor: t.bg },
content: { padding: 16 },
mt: { marginTop: 20 },
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: { borderWidth: 1, borderColor: t.border, borderRadius: 20, paddingHorizontal: 14, paddingVertical: 8, backgroundColor: t.card, maxWidth: '100%' },
chipText: { fontSize: 13, fontWeight: '700' },
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

BIN
mobile/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

BIN
mobile/assets/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

6
mobile/babel.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = function(api) {
api.cache(true)
return {
presets: ['babel-preset-expo'],
}
}

View File

@ -0,0 +1,23 @@
import { View, Text, ActivityIndicator, StyleSheet } from 'react-native'
import { useTheme } from '../constants/ThemeContext'
/*
* ITMS View( + ).
* (index) . WISE () .
*/
export function BrandSplash() {
const { t } = useTheme()
return (
<View style={[styles.wrap, { backgroundColor: t.brand }]}>
<Text style={styles.logo}>ITMS</Text>
<Text style={styles.sub}>IT </Text>
<ActivityIndicator color="#ffffff" style={{ marginTop: 24 }} />
</View>
)
}
const styles = StyleSheet.create({
wrap: { flex: 1, justifyContent: 'center', alignItems: 'center' },
logo: { color: '#ffffff', fontSize: 46, fontWeight: '900', letterSpacing: 4 },
sub: { color: '#dbe4ff', fontSize: 13, marginTop: 8, letterSpacing: 1 },
})

131
mobile/components/ui.tsx Normal file
View File

@ -0,0 +1,131 @@
import { type ReactNode } from 'react'
import {
View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, TextInput,
type StyleProp, type ViewStyle, type TextStyle,
} from 'react-native'
import { Feather } from '@expo/vector-icons'
import { useTheme } from '../constants/ThemeContext'
import { statusColor, type ThemeTokens } from '../constants/ItmsTheme'
/** 카드 컨테이너. */
export function Card({ children, style }: { children: ReactNode; style?: StyleProp<ViewStyle> }) {
const { t } = useTheme()
return (
<View style={[{ backgroundColor: t.card, borderRadius: 14, borderWidth: 1, borderColor: t.border, padding: 16 }, style]}>
{children}
</View>
)
}
/** 진행상태 배지(라벨 부분일치 색). */
export function StatusPill({ label }: { label?: string | null }) {
const { t } = useTheme()
const text = (label ?? '').toString() || '-'
const color = statusColor(label, t)
return (
<View style={{ flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', gap: 5, backgroundColor: color + '22', borderRadius: 8, paddingHorizontal: 8, paddingVertical: 3 }}>
<View style={{ width: 7, height: 7, borderRadius: 4, backgroundColor: color }} />
<Text style={{ color, fontSize: 11, fontWeight: '800' }}>{text}</Text>
</View>
)
}
/** 기본 버튼(primary/ghost/danger/outline). */
export function Btn({
label, onPress, kind = 'primary', busy, disabled, icon, style,
}: {
label: string; onPress: () => void; kind?: 'primary' | 'ghost' | 'danger' | 'outline'
busy?: boolean; disabled?: boolean; icon?: keyof typeof Feather.glyphMap; style?: StyleProp<ViewStyle>
}) {
const { t } = useTheme()
const bg = kind === 'primary' ? t.brand : kind === 'danger' ? t.danger : 'transparent'
const fg = kind === 'ghost' || kind === 'outline' ? t.text : t.onBrand
const border = kind === 'outline' ? t.border : 'transparent'
return (
<TouchableOpacity
onPress={onPress}
disabled={busy || disabled}
style={[{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 7, backgroundColor: bg, borderColor: border, borderWidth: kind === 'outline' ? 1 : 0, borderRadius: 12, paddingVertical: 13, paddingHorizontal: 16, opacity: busy || disabled ? 0.5 : 1 }, style]}
>
{busy ? <ActivityIndicator color={fg} /> : (
<>
{icon && <Feather name={icon} size={16} color={fg} />}
<Text style={{ color: fg, fontSize: 15, fontWeight: '800' }}>{label}</Text>
</>
)}
</TouchableOpacity>
)
}
/** 라벨 + 입력 필드. */
export function Field({
label, value, onChangeText, placeholder, keyboardType, multiline, secureTextEntry, autoCapitalize,
}: {
label?: string; value: string; onChangeText: (s: string) => void; placeholder?: string
keyboardType?: 'default' | 'number-pad' | 'email-address'; multiline?: boolean; secureTextEntry?: boolean
autoCapitalize?: 'none' | 'sentences'
}) {
const { t } = useTheme()
return (
<View style={{ marginTop: 12 }}>
{!!label && <Text style={{ color: t.muted, fontSize: 12, marginBottom: 6 }}>{label}</Text>}
<TextInput
value={value} onChangeText={onChangeText} placeholder={placeholder} placeholderTextColor={t.muted}
keyboardType={keyboardType} multiline={multiline} secureTextEntry={secureTextEntry} autoCapitalize={autoCapitalize}
autoCorrect={false}
style={{ backgroundColor: t.cardAlt, borderRadius: 10, borderWidth: 1, borderColor: t.border, color: t.text, padding: 12, fontSize: 15, minHeight: multiline ? 96 : undefined, textAlignVertical: multiline ? 'top' : 'center' }}
/>
</View>
)
}
/** 빈 상태 안내(서버 미기동/무결과 공통). */
export function EmptyState({ icon = 'inbox', text }: { icon?: keyof typeof Feather.glyphMap; text: string }) {
const { t } = useTheme()
return (
<View style={{ alignItems: 'center', paddingVertical: 48 }}>
<Feather name={icon} size={40} color={t.muted} />
<Text style={{ color: t.muted, fontSize: 14, marginTop: 12, textAlign: 'center', paddingHorizontal: 24 }}>{text}</Text>
</View>
)
}
/** 전체 화면 로딩. */
export function Loading() {
const { t } = useTheme()
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: t.bg }}>
<ActivityIndicator color={t.accent} size="large" />
</View>
)
}
/** 라벨:값 한 줄. */
export function Row({ label, value }: { label: string; value?: ReactNode }) {
const { t } = useTheme()
return (
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: t.border }}>
<Text style={{ color: t.muted, fontSize: 13 }}>{label}</Text>
<View style={{ flexShrink: 1, marginLeft: 12 }}>
{typeof value === 'string' || typeof value === 'number'
? <Text style={{ color: t.text, fontSize: 14, fontWeight: '600', textAlign: 'right' }}>{value || '-'}</Text>
: (value ?? <Text style={{ color: t.text }}>-</Text>)}
</View>
</View>
)
}
/** 섹션 제목. */
export function SectionTitle({ children, style }: { children: ReactNode; style?: StyleProp<TextStyle> }) {
const { t } = useTheme()
return <Text style={[{ color: t.text, fontSize: 15, fontWeight: '900', marginBottom: 10 }, style]}>{children}</Text>
}
/** 인라인 에러 텍스트. */
export function ErrText({ msg }: { msg?: string }) {
const { t } = useTheme()
if (!msg) return null
return <Text style={{ color: t.danger, fontSize: 12, marginTop: 10 }}>{msg}</Text>
}
export type { ThemeTokens }

View File

@ -0,0 +1,26 @@
import Constants from 'expo-constants'
/*
* ITMS base URL + OAuth2 .
* 주입: app.config.js extra.* (ITMS_ENV=dev|prod).
* dev https://itms.zioinfo.co.kr
* prod https://itms.wise.ai.kr
* dev . IP extra ( ).
*
* ( ): vhost front(:11020) .
* OAuth2 (:11000) REST API(:11010)
* nginx /oauth/ :11000, (/srm,/rem,/bbs,/cmm,/sta,/sys,/potl,/main)
* :11010 . ( )
*/
const extra = (Constants.expoConfig?.extra ?? {}) as Record<string, string | undefined>
export const API_BASE = extra.itmsApiUrl ?? 'https://itms.zioinfo.co.kr'
export const ITMS_ENV = extra.itmsEnv ?? 'dev'
/*
* OAuth2 password grant (auth) 1:1.
* / AES , OAuth2 client
* (auth Oauth2AuthorizationConfig ). extra .
*/
export const OAUTH_CLIENT_ID = extra.itmsOauthClientId ?? 'clientId'
export const OAUTH_CLIENT_SECRET = extra.itmsOauthClientSecret ?? 'secretKey'

View File

@ -0,0 +1,73 @@
/*
* ITMS WISE(UIMS) .
* 그라데이션: 시안 #11c3ff #1f29fc.
* (BLUE) × /. hex useTheme() .
* @expo/vector-icons(Feather, ) .
*/
export type ThemeMode = 'dark' | 'light'
export interface ThemeTokens {
bg: string; card: string; cardAlt: string; border: string; text: string; muted: string
brand: string; primary: string; accent: string
success: string; warning: string; danger: string; info: string
white: string
tabActive: string; tabIdle: string
onBrand: string
}
/** WISE 브랜드 컬러(고정). brand=주버튼/헤더, accent=포인트/하이라이트. */
const BRAND = '#1f29fc'
const ACCENT = '#11c3ff'
const darkBase: Omit<ThemeTokens, 'brand' | 'primary' | 'accent' | 'tabActive'> = {
bg: '#0b0f17',
card: '#141a26',
cardAlt: '#1c2433',
border: '#283142',
text: '#e6edf3',
muted: '#8b97a8',
success: '#22c55e',
warning: '#f59e0b',
danger: '#ef4444',
info: '#38bdf8',
white: '#ffffff',
tabIdle: '#6b7686',
onBrand: '#ffffff',
}
const lightBase: Omit<ThemeTokens, 'brand' | 'primary' | 'accent' | 'tabActive'> = {
bg: '#f4f6f9',
card: '#ffffff',
cardAlt: '#f4f6f9',
border: '#e6eaf0',
text: '#252525',
muted: '#6b7280',
success: '#16a34a',
warning: '#d97706',
danger: '#dc2626',
info: '#0284c7',
white: '#ffffff',
tabIdle: '#9aa3b2',
onBrand: '#ffffff',
}
export function makeTheme(mode: ThemeMode): ThemeTokens {
const base = mode === 'dark' ? darkBase : lightBase
return { ...base, brand: BRAND, primary: BRAND, accent: ACCENT, tabActive: BRAND }
}
/**
* SR/ ( ).
* / , muted.
*/
export function statusColor(label: string | null | undefined, t: ThemeTokens): string {
const s = (label ?? '').toString()
if (/완료|처리완료|종료|승인|정상|해결/.test(s)) return t.success
if (/반려|거부|취소|실패|오류|장애/.test(s)) return t.danger
if (/진행|처리중|접수|검토|대기/.test(s)) return t.info
if (/보류|지연|확인요청/.test(s)) return t.warning
return t.muted
}
export const DEFAULT_THEME = makeTheme('light')

View File

@ -0,0 +1,68 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
import { Appearance } from 'react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { makeTheme, type ThemeMode, type ThemeTokens } from './ItmsTheme'
/*
* ITMS (WISE ) × (LIGHT/DARK/SYSTEM).
* AsyncStorage (). ITMS preferences API .
*/
export type PrefMode = ThemeMode | 'system'
const MODE_KEY = 'itms_theme_mode'
interface ThemeContextValue {
t: ThemeTokens
resolved: ThemeMode
mode: PrefMode
setMode: (m: PrefMode) => void
}
function systemMode(): ThemeMode {
return Appearance.getColorScheme() === 'light' ? 'light' : 'dark'
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
export function ThemeProvider({ children }: { children: ReactNode }) {
const [mode, setModeState] = useState<PrefMode>('light')
const [sysScheme, setSysScheme] = useState<ThemeMode>(systemMode())
useEffect(() => {
let alive = true
AsyncStorage.getItem(MODE_KEY)
.then((v) => {
if (!alive) return
if (v === 'dark' || v === 'light' || v === 'system') setModeState(v)
})
.catch(() => { /* 접근 실패 무시 */ })
return () => { alive = false }
}, [])
useEffect(() => {
const sub = Appearance.addChangeListener(({ colorScheme }) => {
setSysScheme(colorScheme === 'light' ? 'light' : 'dark')
})
return () => sub.remove()
}, [])
const setMode = useCallback((m: PrefMode) => {
setModeState(m)
AsyncStorage.setItem(MODE_KEY, m).catch(() => { /* noop */ })
}, [])
const resolved: ThemeMode = mode === 'system' ? sysScheme : mode
const value = useMemo<ThemeContextValue>(
() => ({ t: makeTheme(resolved), resolved, mode, setMode }),
[resolved, mode, setMode]
)
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
/** 테마 훅. Provider 외부 호출 시 라이트 폴백(방어적). */
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext)
if (ctx) return ctx
return { t: makeTheme('light'), resolved: 'light', mode: 'light', setMode: () => { /* noop */ } }
}

48
mobile/constants/rows.ts Normal file
View File

@ -0,0 +1,48 @@
import type { RowMap } from '../app/itmsApi'
/*
* ITMS resultList Map( / ).
* , .
* ( · .)
*/
export function pick(row: RowMap | null | undefined, keys: string[]): string {
if (!row) return ''
for (const k of keys) {
const v = row[k]
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v)
}
return ''
}
const TITLE_KEYS = ['title', 'incidentTitle', 'reqTitle', 'nttSj', 'ntt_sj', 'sj', 'subject', 'svcNm', 'serviceNm', 'resrceNm', 'assetNm', 'name', 'contents', 'incidentCn']
const STATUS_KEYS = ['statusNm', 'sttusNm', 'sttusName', 'procSttusNm', 'progrsSttusNm', 'incidentSttusNm', 'sttus', 'status', 'procSttus', 'progrsSttus', 'state']
const DATE_KEYS = ['regDt', 'regDate', 'reqDt', 'reqDate', 'frstRegisterPnttm', 'createDt', 'createdAt', 'ntcrDt', 'registDt', 'occrDt']
const AUTHOR_KEYS = ['reqUserNm', 'reqrNm', 'registerNm', 'writerNm', 'ntcrNm', 'userName', 'userNm', 'chargerNm']
export const rowTitle = (row: RowMap) => pick(row, TITLE_KEYS) || '(제목 없음)'
export const rowStatus = (row: RowMap) => pick(row, STATUS_KEYS)
export const rowDate = (row: RowMap) => {
const d = pick(row, DATE_KEYS)
return d ? d.slice(0, 16) : ''
}
export const rowAuthor = (row: RowMap) => pick(row, AUTHOR_KEYS)
/** SR 요청 식별자(상세 조회용) 후보. */
export const reqIncidentNum = (row: RowMap) => pick(row, ['vcReqIncidentNum', 'reqIncidentNum', 'incidentReqNum', 'incidentNum'])
/** 인시던트 seq 후보. */
export const incidentSeq = (row: RowMap) => pick(row, ['inIncidentSeq', 'incidentSeq', 'seq', 'incidentId'])
/** row 를 라벨:값 나열용 항목으로 평탄화(내부 메타/빈값 제외). */
export function rowEntries(row: RowMap | null | undefined, limit = 40): { key: string; value: string }[] {
if (!row) return []
const out: { key: string; value: string }[] = []
for (const [k, v] of Object.entries(row)) {
if (v === null || v === undefined) continue
if (typeof v === 'object') continue
const s = String(v).trim()
if (!s) continue
out.push({ key: k, value: s })
if (out.length >= limit) break
}
return out
}

32
mobile/eas.json Normal file
View File

@ -0,0 +1,32 @@
{
"cli": {
"version": ">= 10.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"android": { "buildType": "apk" },
"env": { "ITMS_ENV": "dev" }
},
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk",
"image": "ubuntu-22.04-jdk-17-ndk-r26b"
},
"env": { "ITMS_ENV": "prod" }
},
"production": {
"distribution": "internal",
"android": {
"buildType": "apk",
"image": "ubuntu-22.04-jdk-17-ndk-r26b"
},
"env": { "ITMS_ENV": "prod" }
}
},
"submit": {
"production": {}
}
}

3
mobile/expo-env.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
/// <reference types="expo/types" />
// NOTE: This file should not be edited and should be committed with expo-router.

5
mobile/metro.config.js Normal file
View File

@ -0,0 +1,5 @@
const { getDefaultConfig } = require('expo/metro-config')
const config = getDefaultConfig(__dirname)
module.exports = config

11861
mobile/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
mobile/package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "itms-app",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@expo/metro-runtime": "~3.2.3",
"@expo/vector-icons": "^14.0.3",
"@react-native-async-storage/async-storage": "1.23.1",
"axios": "^1.7.7",
"base-64": "^1.0.0",
"expo": "^51.0.39",
"expo-constants": "~16.0.2",
"expo-font": "~12.0.10",
"expo-linking": "~6.3.1",
"expo-router": "~3.5.23",
"expo-secure-store": "~13.0.2",
"expo-splash-screen": "~0.27.7",
"expo-status-bar": "~1.12.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "0.74.5",
"react-native-gesture-handler": "~2.16.1",
"react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1",
"react-native-svg": "15.2.0",
"react-native-web": "~0.19.10"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@types/base-64": "^1.0.2",
"@types/react": "~18.2.79",
"typescript": "~5.3.3"
},
"overrides": {
"expo-font": "~12.0.10"
},
"private": true
}

View File

@ -0,0 +1,20 @@
const { withGradleProperties } = require('@expo/config-plugins')
/**
* PNG crunching 비활성화 + arm64-v8a 단일 아키텍처로 빌드 속도 향상.
* (CAMP/UIWS mobile 정본 복제 PIL PNG AAPT2 cruncher 충돌 방지, Gradle OOM 방지.)
*/
module.exports = function withGradleProps(config) {
return withGradleProperties(config, (cfg) => {
const props = cfg.modResults
const set = (key, value) => {
const idx = props.findIndex((p) => p.key === key)
if (idx !== -1) props[idx].value = value
else props.push({ type: 'property', key, value })
}
set('android.enablePngCrunchInReleaseBuilds', 'false')
set('reactNativeArchitectures', 'arm64-v8a')
set('org.gradle.jvmargs', '-Xmx4096m -XX:MaxMetaspaceSize=1024m')
return cfg
})
}

View File

@ -0,0 +1,90 @@
/* 자체 PNG 생성기 — 외부 의존 없이 WISE 블루(#1f29fc) 배경 + 흰색 'ITMS' 아이콘. */
const zlib = require('zlib')
const fs = require('fs')
const path = require('path')
const SIZE = 1024
const BG = [0x1f, 0x29, 0xfc, 0xff] // WISE blue
const FG = [0xff, 0xff, 0xff, 0xff] // white
// 5x7 비트맵 글리프
const GLYPHS = {
I: ['11111', '00100', '00100', '00100', '00100', '00100', '11111'],
T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'],
S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'],
}
const WORD = 'ITMS'
function makeBuffer() {
const buf = Buffer.alloc(SIZE * SIZE * 4)
for (let i = 0; i < SIZE * SIZE; i++) buf.set(BG, i * 4)
// 글리프 레이아웃: 4글자 * 5px + 간격 1px = 23 units 폭, 7 units 높이
const cols = WORD.length * 5 + (WORD.length - 1) // 23
const rows = 7
const scale = Math.floor((SIZE * 0.62) / cols) // 폭 62% 사용
const wpx = cols * scale
const hpx = rows * scale
const ox = Math.floor((SIZE - wpx) / 2)
const oy = Math.floor((SIZE - hpx) / 2)
const setPx = (x, y) => {
if (x < 0 || y < 0 || x >= SIZE || y >= SIZE) return
buf.set(FG, (y * SIZE + x) * 4)
}
let cx = ox
for (const ch of WORD) {
const g = GLYPHS[ch]
for (let r = 0; r < 7; r++) {
for (let c = 0; c < 5; c++) {
if (g[r][c] === '1') {
for (let dy = 0; dy < scale; dy++)
for (let dx = 0; dx < scale; dx++)
setPx(cx + c * scale + dx, oy + r * scale + dy)
}
}
}
cx += 6 * scale // 5 글자폭 + 1 간격
}
return buf
}
function crc32(buf) {
let c = ~0
for (let i = 0; i < buf.length; i++) {
c ^= buf[i]
for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1))
}
return ~c >>> 0
}
function chunk(type, data) {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0)
const t = Buffer.from(type, 'ascii')
const body = Buffer.concat([t, data])
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body), 0)
return Buffer.concat([len, body, crc])
}
function encodePng(rgba) {
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
const ihdr = Buffer.alloc(13)
ihdr.writeUInt32BE(SIZE, 0); ihdr.writeUInt32BE(SIZE, 4)
ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0
// 각 스캔라인 앞 filter byte 0
const raw = Buffer.alloc(SIZE * (SIZE * 4 + 1))
for (let y = 0; y < SIZE; y++) {
raw[y * (SIZE * 4 + 1)] = 0
rgba.copy(raw, y * (SIZE * 4 + 1) + 1, y * SIZE * 4, (y + 1) * SIZE * 4)
}
const idat = zlib.deflateSync(raw, { level: 9 })
return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))])
}
const png = encodePng(makeBuffer())
const outDir = path.join(__dirname, '..', 'assets')
fs.mkdirSync(outDir, { recursive: true })
for (const name of ['icon.png', 'adaptive-icon.png', 'splash.png']) {
fs.writeFileSync(path.join(outDir, name), png)
}
console.log('generated', png.length, 'bytes ->', outDir)

15
mobile/tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./*"]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}