guardia-messenger/app/(tabs)/sr_quick.tsx

201 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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' },
})