201 lines
8.6 KiB
TypeScript
201 lines
8.6 KiB
TypeScript
import { useEffect, useState } from 'react'
|
||
import {
|
||
View, Text, TextInput, TouchableOpacity, StyleSheet, ScrollView,
|
||
ActivityIndicator, Alert, Image, ToastAndroid, Platform,
|
||
} from 'react-native'
|
||
import { router } from 'expo-router'
|
||
import * as ImagePicker from 'expo-image-picker'
|
||
import { COLORS, PRIORITY_COLOR } from '../../constants/Config'
|
||
import { createSRRaw } from '../../services/api'
|
||
import { useDuplicateSR } from '../../hooks/useDuplicateSR'
|
||
import { useAIClassify } from '../../hooks/useAIClassify'
|
||
import SRTemplates, { SRTemplate } from '../../components/SRTemplates'
|
||
|
||
const CATEGORIES = ['DEPLOY', 'RESTART', 'LOG', 'INQUIRY', 'OTHER']
|
||
|
||
function toast(msg: string) {
|
||
if (Platform.OS === 'android') ToastAndroid.show(msg, ToastAndroid.LONG)
|
||
else Alert.alert(msg)
|
||
}
|
||
|
||
/**
|
||
* 기능 #1 — 빠른 SR 등록 (3-tap: 제목 + 카테고리 + 사진)
|
||
* + 기능 #9 중복 감지, #10 템플릿, #11 AI 자동 분류 연동
|
||
*/
|
||
export default function SRQuickScreen() {
|
||
const [title, setTitle] = useState('')
|
||
const [category, setCategory] = useState('OTHER')
|
||
const [priority, setPriority] = useState('MEDIUM')
|
||
const [photo, setPhoto] = useState<string | null>(null)
|
||
const [saving, setSaving] = useState(false)
|
||
const [tplOpen, setTplOpen] = useState(false)
|
||
const [aiApplied, setAiApplied] = useState(false)
|
||
|
||
const { duplicates, hasDuplicates } = useDuplicateSR(title)
|
||
const ai = useAIClassify(title)
|
||
|
||
// AI 분류 결과 자동 채움 (사용자가 아직 손대지 않았을 때만)
|
||
useEffect(() => {
|
||
if (!aiApplied && (ai.category || ai.priority)) {
|
||
if (ai.category) setCategory(ai.category)
|
||
if (ai.priority) setPriority(ai.priority)
|
||
}
|
||
}, [ai.category, ai.priority, aiApplied])
|
||
|
||
const pickPhoto = async () => {
|
||
try {
|
||
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync()
|
||
if (!perm.granted) { Alert.alert('권한 필요', '사진 접근 권한을 허용해주세요.'); return }
|
||
const result = await ImagePicker.launchImageLibraryAsync({
|
||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||
quality: 0.6,
|
||
})
|
||
if (!result.canceled && result.assets && result.assets[0]) {
|
||
setPhoto(result.assets[0].uri)
|
||
}
|
||
} catch {
|
||
Alert.alert('오류', '사진을 불러올 수 없습니다.')
|
||
}
|
||
}
|
||
|
||
const applyTemplate = (tpl: SRTemplate) => {
|
||
if (tpl.title) setTitle(tpl.title)
|
||
if (tpl.category || tpl.sr_type) setCategory((tpl.category ?? tpl.sr_type)!)
|
||
if (tpl.priority) setPriority(tpl.priority)
|
||
setAiApplied(true)
|
||
}
|
||
|
||
const submit = async () => {
|
||
if (!title.trim()) { Alert.alert('제목을 입력하세요.'); return }
|
||
setSaving(true)
|
||
try {
|
||
const payload: Record<string, unknown> = {
|
||
title: title.trim(),
|
||
sr_type: category,
|
||
priority,
|
||
description: '',
|
||
}
|
||
if (photo) payload.attachment_uri = photo
|
||
const res = await createSRRaw(payload)
|
||
const srId = res.data?.sr_id ?? res.data?.id ?? ''
|
||
toast(`SR ${srId} 등록 완료`)
|
||
router.back()
|
||
} catch (e: any) {
|
||
Alert.alert('오류', e.response?.data?.detail ?? 'SR 등록 실패')
|
||
} finally { setSaving(false) }
|
||
}
|
||
|
||
return (
|
||
<ScrollView style={{ flex: 1, backgroundColor: COLORS.bg }} contentContainerStyle={{ padding: 20 }}>
|
||
<View style={s.headerRow}>
|
||
<Text style={s.heading}>빠른 SR 등록</Text>
|
||
<TouchableOpacity style={s.tplBtn} onPress={() => setTplOpen(true)}>
|
||
<Text style={s.tplBtnText}>📑 템플릿</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{/* 1) 제목 */}
|
||
<Text style={s.label}>① 제목 *</Text>
|
||
<TextInput
|
||
style={s.input}
|
||
value={title}
|
||
onChangeText={(v) => { setTitle(v); setAiApplied(false) }}
|
||
placeholder="무엇을 요청하시나요?"
|
||
placeholderTextColor={COLORS.muted}
|
||
/>
|
||
{ai.loading && <Text style={s.aiHint}>🤖 AI가 분류 중...</Text>}
|
||
{!ai.loading && (ai.category || ai.priority) && (
|
||
<Text style={s.aiHint}>🤖 AI 추천: {ai.category} / {ai.priority}</Text>
|
||
)}
|
||
|
||
{/* 중복 경고 (#9) */}
|
||
{hasDuplicates && (
|
||
<View style={s.dupBox}>
|
||
<Text style={s.dupTitle}>⚠️ 유사한 미처리 SR이 있습니다</Text>
|
||
{duplicates.map(d => (
|
||
<TouchableOpacity
|
||
key={d.id}
|
||
onPress={() => router.push({ pathname: '/(tabs)/sr_detail', params: { id: String(d.id) } })}
|
||
>
|
||
<Text style={s.dupItem} numberOfLines={1}>• {d.sr_id ?? `#${d.id}`} {d.title}</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
)}
|
||
|
||
{/* 2) 카테고리 */}
|
||
<Text style={s.label}>② 카테고리</Text>
|
||
<View style={s.chips}>
|
||
{CATEGORIES.map(c => (
|
||
<TouchableOpacity
|
||
key={c}
|
||
style={[s.chip, category === c && s.chipActive]}
|
||
onPress={() => { setCategory(c); setAiApplied(true) }}
|
||
>
|
||
<Text style={[s.chipText, category === c && s.chipTextActive]}>{c}</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
|
||
<Text style={s.label}>우선순위</Text>
|
||
<View style={s.chips}>
|
||
{['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'].map(p => (
|
||
<TouchableOpacity
|
||
key={p}
|
||
style={[s.chip, priority === p && { backgroundColor: PRIORITY_COLOR[p], borderColor: PRIORITY_COLOR[p] }]}
|
||
onPress={() => { setPriority(p); setAiApplied(true) }}
|
||
>
|
||
<Text style={[s.chipText, priority === p && s.chipTextActive]}>{p}</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
|
||
{/* 3) 사진 */}
|
||
<Text style={s.label}>③ 사진 첨부</Text>
|
||
{photo ? (
|
||
<View style={s.photoWrap}>
|
||
<Image source={{ uri: photo }} style={s.photo} />
|
||
<TouchableOpacity style={s.removePhoto} onPress={() => setPhoto(null)}>
|
||
<Text style={{ color: '#fff', fontWeight: '700' }}>✕ 삭제</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
) : (
|
||
<TouchableOpacity style={s.photoBtn} onPress={pickPhoto}>
|
||
<Text style={s.photoBtnText}>📷 사진 선택</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
|
||
<TouchableOpacity style={[s.submit, saving && { opacity: 0.6 }]} onPress={submit} disabled={saving}>
|
||
{saving ? <ActivityIndicator color="#fff" /> : <Text style={s.submitText}>SR 등록</Text>}
|
||
</TouchableOpacity>
|
||
|
||
<SRTemplates visible={tplOpen} onClose={() => setTplOpen(false)} onSelect={applyTemplate} />
|
||
</ScrollView>
|
||
)
|
||
}
|
||
|
||
const s = StyleSheet.create({
|
||
headerRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 },
|
||
heading: { fontSize: 18, fontWeight: '800', color: COLORS.text },
|
||
tplBtn: { backgroundColor: COLORS.light, paddingHorizontal: 12, paddingVertical: 7, borderRadius: 8 },
|
||
tplBtnText: { color: COLORS.accent, fontWeight: '700', fontSize: 12 },
|
||
label: { fontSize: 12, fontWeight: '700', color: COLORS.muted, marginTop: 16, marginBottom: 7, textTransform: 'uppercase', letterSpacing: 0.4 },
|
||
input: { borderWidth: 1.5, borderColor: COLORS.border, borderRadius: 10, padding: 13, fontSize: 15, color: COLORS.text, backgroundColor: '#fff' },
|
||
aiHint: { fontSize: 12, color: COLORS.accent, marginTop: 6 },
|
||
dupBox: { backgroundColor: '#FFF7ED', borderRadius: 10, padding: 12, marginTop: 10, borderWidth: 1, borderColor: COLORS.warning },
|
||
dupTitle: { fontSize: 12, fontWeight: '700', color: COLORS.warning, marginBottom: 6 },
|
||
dupItem: { fontSize: 12, color: COLORS.text, paddingVertical: 3 },
|
||
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||
chip: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: 20, borderWidth: 1, borderColor: COLORS.border, backgroundColor: '#fff' },
|
||
chipActive: { backgroundColor: COLORS.accent, borderColor: COLORS.accent },
|
||
chipText: { fontSize: 13, color: COLORS.text },
|
||
chipTextActive: { color: '#fff', fontWeight: '700' },
|
||
photoBtn: { borderWidth: 1.5, borderColor: COLORS.border, borderStyle: 'dashed', borderRadius: 10, padding: 22, alignItems: 'center', backgroundColor: '#fff' },
|
||
photoBtnText:{ color: COLORS.muted, fontSize: 14 },
|
||
photoWrap: { position: 'relative' },
|
||
photo: { width: '100%', height: 180, borderRadius: 10, backgroundColor: COLORS.border },
|
||
removePhoto: { position: 'absolute', top: 8, right: 8, backgroundColor: 'rgba(0,0,0,0.6)', paddingHorizontal: 10, paddingVertical: 5, borderRadius: 8 },
|
||
submit: { backgroundColor: COLORS.primary, borderRadius: 12, padding: 16, alignItems: 'center', marginTop: 28, marginBottom: 40 },
|
||
submitText: { color: '#fff', fontSize: 16, fontWeight: '800' },
|
||
})
|