feat(itms): 모바일 앱 신규 (Expo, 12화면, OAuth password grant)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
53b8f42c76
commit
efc3e66635
17
mobile/.gitignore
vendored
Normal file
17
mobile/.gitignore
vendored
Normal 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
39
mobile/README.md
Normal 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
73
mobile/app.config.js
Normal 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',
|
||||
},
|
||||
}
|
||||
75
mobile/app/(auth)/login.tsx
Normal file
75
mobile/app/(auth)/login.tsx
Normal 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 },
|
||||
})
|
||||
27
mobile/app/(tabs)/_layout.tsx
Normal file
27
mobile/app/(tabs)/_layout.tsx
Normal 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
160
mobile/app/(tabs)/home.tsx
Normal 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 },
|
||||
})
|
||||
82
mobile/app/(tabs)/incidents.tsx
Normal file
82
mobile/app/(tabs)/incidents.tsx
Normal 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
104
mobile/app/(tabs)/me.tsx
Normal 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' },
|
||||
})
|
||||
60
mobile/app/(tabs)/notices.tsx
Normal file
60
mobile/app/(tabs)/notices.tsx
Normal 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 },
|
||||
})
|
||||
89
mobile/app/(tabs)/requests.tsx
Normal file
89
mobile/app/(tabs)/requests.tsx
Normal 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
44
mobile/app/_layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
71
mobile/app/asset-detail.tsx
Normal file
71
mobile/app/asset-detail.tsx
Normal 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
81
mobile/app/assets.tsx
Normal 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 },
|
||||
})
|
||||
74
mobile/app/incident-detail.tsx
Normal file
74
mobile/app/incident-detail.tsx
Normal 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
24
mobile/app/index.tsx
Normal 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
250
mobile/app/itmsApi.ts
Normal 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')
|
||||
77
mobile/app/notice-detail.tsx
Normal file
77
mobile/app/notice-detail.tsx
Normal 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' },
|
||||
})
|
||||
73
mobile/app/request-detail.tsx
Normal file
73
mobile/app/request-detail.tsx
Normal 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
102
mobile/app/request-new.tsx
Normal 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' },
|
||||
})
|
||||
BIN
mobile/assets/adaptive-icon.png
Normal file
BIN
mobile/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
BIN
mobile/assets/icon.png
Normal file
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
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
6
mobile/babel.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = function(api) {
|
||||
api.cache(true)
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
}
|
||||
}
|
||||
23
mobile/components/BrandSplash.tsx
Normal file
23
mobile/components/BrandSplash.tsx
Normal 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
131
mobile/components/ui.tsx
Normal 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 }
|
||||
26
mobile/constants/Config.ts
Normal file
26
mobile/constants/Config.ts
Normal 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'
|
||||
73
mobile/constants/ItmsTheme.ts
Normal file
73
mobile/constants/ItmsTheme.ts
Normal 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')
|
||||
68
mobile/constants/ThemeContext.tsx
Normal file
68
mobile/constants/ThemeContext.tsx
Normal 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
48
mobile/constants/rows.ts
Normal 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
32
mobile/eas.json
Normal 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
3
mobile/expo-env.d.ts
vendored
Normal 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
5
mobile/metro.config.js
Normal 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
11861
mobile/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
mobile/package.json
Normal file
44
mobile/package.json
Normal 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
|
||||
}
|
||||
20
mobile/plugins/withGradleProps.js
Normal file
20
mobile/plugins/withGradleProps.js
Normal 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
|
||||
})
|
||||
}
|
||||
90
mobile/scripts/gen_icon.js
Normal file
90
mobile/scripts/gen_icon.js
Normal 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
15
mobile/tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user