132 lines
5.2 KiB
TypeScript
132 lines
5.2 KiB
TypeScript
import { useState } from 'react'
|
|
import {
|
|
Modal, View, Text, TextInput, TouchableOpacity,
|
|
StyleSheet, ActivityIndicator, KeyboardAvoidingView, Platform,
|
|
} from 'react-native'
|
|
import { COLORS } from '../constants/Config'
|
|
|
|
/**
|
|
* 기능 #66 — 반려 사유 입력 모달
|
|
* - 반려 사유 템플릿 빠른 선택 + 직접 입력
|
|
* - 최소 10자 검증 (빈 값/10자 미만이면 제출 차단)
|
|
*/
|
|
|
|
const MIN_LEN = 10
|
|
|
|
const TEMPLATES = [
|
|
'요청 정보가 불충분하여 반려합니다.',
|
|
'변경 일정이 운영 정책과 충돌합니다.',
|
|
'영향 범위 분석이 누락되어 보완이 필요합니다.',
|
|
'롤백 계획이 명시되지 않았습니다.',
|
|
'승인 권한 범위를 초과하는 요청입니다.',
|
|
]
|
|
|
|
interface Props {
|
|
visible: boolean
|
|
targetTitle?: string
|
|
onClose: () => void
|
|
/** 반려 확정 — reason 은 10자 이상 보장됨 */
|
|
onSubmit: (reason: string) => Promise<void> | void
|
|
}
|
|
|
|
export default function RejectReason({ visible, targetTitle, onClose, onSubmit }: Props) {
|
|
const [reason, setReason] = useState('')
|
|
const [submitting, setSubmitting] = useState(false)
|
|
|
|
const trimmed = reason.trim()
|
|
const valid = trimmed.length >= MIN_LEN
|
|
|
|
const reset = () => { setReason(''); setSubmitting(false) }
|
|
|
|
const handleClose = () => { reset(); onClose() }
|
|
|
|
const handleSubmit = async () => {
|
|
if (!valid || submitting) return
|
|
setSubmitting(true)
|
|
try {
|
|
await onSubmit(trimmed)
|
|
reset()
|
|
} catch {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={handleClose}>
|
|
<KeyboardAvoidingView
|
|
style={s.overlay}
|
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
|
<View style={s.sheet}>
|
|
<View style={s.handle} />
|
|
<Text style={s.title}>반려 사유 입력</Text>
|
|
{!!targetTitle && <Text style={s.subtitle} numberOfLines={1}>{targetTitle}</Text>}
|
|
|
|
<Text style={s.label}>빠른 템플릿</Text>
|
|
<View style={s.chips}>
|
|
{TEMPLATES.map((t, i) => (
|
|
<TouchableOpacity key={i} style={s.chip} onPress={() => setReason(t)}>
|
|
<Text style={s.chipText} numberOfLines={1}>{t}</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
|
|
<Text style={s.label}>사유 (최소 {MIN_LEN}자)</Text>
|
|
<TextInput
|
|
style={s.input}
|
|
value={reason}
|
|
onChangeText={setReason}
|
|
placeholder="반려 사유를 입력하세요"
|
|
placeholderTextColor={COLORS.muted}
|
|
multiline
|
|
textAlignVertical="top"
|
|
/>
|
|
<Text style={[s.counter, valid ? s.counterOk : s.counterBad]}>
|
|
{trimmed.length} / {MIN_LEN}자 {valid ? '✔' : '(부족)'}
|
|
</Text>
|
|
|
|
<View style={s.actions}>
|
|
<TouchableOpacity style={[s.btn, s.btnGhost]} onPress={handleClose} disabled={submitting}>
|
|
<Text style={s.btnGhostText}>취소</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[s.btn, s.btnDanger, (!valid || submitting) && s.btnDisabled]}
|
|
onPress={handleSubmit}
|
|
disabled={!valid || submitting}>
|
|
{submitting
|
|
? <ActivityIndicator color="#fff" size="small" />
|
|
: <Text style={s.btnDangerText}>반려</Text>}
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</KeyboardAvoidingView>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
const s = StyleSheet.create({
|
|
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.4)', justifyContent: 'flex-end' },
|
|
sheet: { backgroundColor: '#fff', borderTopLeftRadius: 18, borderTopRightRadius: 18,
|
|
paddingHorizontal: 18, paddingTop: 10, paddingBottom: 28 },
|
|
handle: { alignSelf: 'center', width: 40, height: 4, borderRadius: 2,
|
|
backgroundColor: COLORS.border, marginBottom: 12 },
|
|
title: { fontSize: 16, fontWeight: '800', color: COLORS.text },
|
|
subtitle: { fontSize: 12, color: COLORS.muted, marginTop: 2 },
|
|
label: { fontSize: 12, fontWeight: '700', color: COLORS.text, marginTop: 16, marginBottom: 8 },
|
|
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
|
chip: { backgroundColor: COLORS.light, borderRadius: 14, paddingHorizontal: 10,
|
|
paddingVertical: 6, maxWidth: '100%' },
|
|
chipText: { fontSize: 11, color: COLORS.blue },
|
|
input: { borderWidth: 1, borderColor: COLORS.border, borderRadius: 10, padding: 12,
|
|
minHeight: 90, fontSize: 13, color: COLORS.text, backgroundColor: '#fff' },
|
|
counter: { fontSize: 11, marginTop: 6, textAlign: 'right' },
|
|
counterOk: { color: COLORS.success },
|
|
counterBad: { color: COLORS.danger },
|
|
actions: { flexDirection: 'row', gap: 10, marginTop: 18 },
|
|
btn: { flex: 1, borderRadius: 10, paddingVertical: 13, alignItems: 'center' },
|
|
btnGhost: { backgroundColor: '#f1f5f9' },
|
|
btnGhostText: { color: COLORS.text, fontWeight: '700', fontSize: 14 },
|
|
btnDanger: { backgroundColor: COLORS.danger },
|
|
btnDangerText: { color: '#fff', fontWeight: '700', fontSize: 14 },
|
|
btnDisabled: { opacity: 0.45 },
|
|
})
|