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

211 lines
8.2 KiB
TypeScript

import React, { useState, useCallback } from 'react'
import {
View, Text, FlatList, TouchableOpacity, StyleSheet,
RefreshControl, Alert, Animated,
} from 'react-native'
import { GestureHandlerRootView, PanGestureHandler, State } from 'react-native-gesture-handler'
import { COLORS } from '../../constants/Config'
import { getApprovals, approveRequest, rejectRequest, cancelApproval } from '../../services/api'
import { useFocusEffect } from 'expo-router'
import RejectReason from '../../components/RejectReason'
import ApprovalStages from '../../components/ApprovalStages'
type Tab = 'pending' | 'approved' | 'rejected'
interface ApprovalItem {
id: number
title: string
requester: string
created_at: string
status: string
type?: string
}
export default function ApprovalScreen() {
const [tab, setTab] = useState<Tab>('pending')
const [items, setItems] = useState<ApprovalItem[]>([])
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<Set<number>>(new Set())
const [rejectId, setRejectId] = useState<number | null>(null)
const [stagesId, setStagesId] = useState<number | null>(null)
const [undoId, setUndoId] = useState<number | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await getApprovals(tab)
setItems(r.data?.items ?? r.data ?? [])
} catch { setItems([]) }
finally { setLoading(false) }
}, [tab])
useFocusEffect(useCallback(() => { load() }, [load]))
const doApprove = async (id: number) => {
try {
await approveRequest(id, '')
setUndoId(id)
setTimeout(() => setUndoId(null), 3000)
load()
} catch { Alert.alert('오류', '승인 처리 중 오류가 발생했습니다.') }
}
const doUndo = async () => {
if (!undoId) return
try { await cancelApproval(undoId); setUndoId(null); load() } catch {}
}
const bulkApprove = async () => {
const ids = [...selected]
if (!ids.length) return
Alert.alert('일괄 승인', `${ids.length}건을 승인하시겠습니까?`, [
{ text: '취소', style: 'cancel' },
{ text: '승인', onPress: async () => {
await Promise.all(ids.map(id => approveRequest(id, '')))
setSelected(new Set())
load()
}},
])
}
const toggleSelect = (id: number) => {
const next = new Set(selected)
next.has(id) ? next.delete(id) : next.add(id)
setSelected(next)
}
const renderItem = ({ item }: { item: ApprovalItem }) => (
<SwipeCard
item={item}
onApprove={() => doApprove(item.id)}
onReject={() => setRejectId(item.id)}
onDetail={() => setStagesId(item.id)}
selected={selected.has(item.id)}
onSelect={() => toggleSelect(item.id)}
/>
)
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<View style={s.container}>
{/* 탭 */}
<View style={s.tabs}>
{(['pending','approved','rejected'] as Tab[]).map(t => (
<TouchableOpacity key={t} style={[s.tab, tab===t && s.tabActive]} onPress={() => setTab(t)}>
<Text style={[s.tabText, tab===t && s.tabTextActive]}>
{t === 'pending' ? '대기' : t === 'approved' ? '승인' : '반려'}
</Text>
</TouchableOpacity>
))}
</View>
{/* 일괄 승인 버튼 */}
{selected.size > 0 && (
<TouchableOpacity style={s.bulkBtn} onPress={bulkApprove}>
<Text style={s.bulkBtnText}>{selected.size} </Text>
</TouchableOpacity>
)}
<FlatList
data={items}
keyExtractor={i => String(i.id)}
renderItem={renderItem}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={<Text style={s.empty}> .</Text>}
contentContainerStyle={{ paddingBottom: 80 }}
/>
{/* 언두 스낵바 */}
{undoId && (
<View style={s.snack}>
<Text style={s.snackText}></Text>
<TouchableOpacity onPress={doUndo}><Text style={s.snackUndo}></Text></TouchableOpacity>
</View>
)}
{/* 반려 사유 모달 */}
{rejectId && (
<RejectReason
visible={true}
onSubmit={async (reason) => {
await rejectRequest(rejectId, reason)
setRejectId(null)
load()
}}
onClose={() => setRejectId(null)}
/>
)}
{/* 다단계 승인 */}
{stagesId && (
<ApprovalStages
approvalId={stagesId}
/>
)}
</View>
</GestureHandlerRootView>
)
}
function SwipeCard({ item, onApprove, onReject, onDetail, selected, onSelect }: any) {
const x = React.useRef(new Animated.Value(0)).current
const onGesture = ({ nativeEvent }: any) => {
if (nativeEvent.state === State.END) {
if (nativeEvent.translationX > 80) {
Animated.spring(x, { toValue: 0, useNativeDriver: true }).start()
onApprove()
} else if (nativeEvent.translationX < -80) {
Animated.spring(x, { toValue: 0, useNativeDriver: true }).start()
onReject()
} else {
Animated.spring(x, { toValue: 0, useNativeDriver: true }).start()
}
} else {
x.setValue(nativeEvent.translationX)
}
}
return (
<View style={s.cardWrap}>
<View style={[s.swipeBg, { backgroundColor: COLORS.success }]}><Text style={s.swipeHint}> </Text></View>
<View style={[s.swipeBg, { backgroundColor: COLORS.danger, right: 0, left: 'auto' }]}><Text style={s.swipeHint}> </Text></View>
<PanGestureHandler onHandlerStateChange={onGesture} onGestureEvent={({ nativeEvent }: any) => x.setValue(nativeEvent.translationX)}>
<Animated.View style={[s.card, { transform: [{ translateX: x }] }]}>
<TouchableOpacity style={s.selectBox} onPress={onSelect}>
<View style={[s.checkbox, selected && s.checkboxSelected]} />
</TouchableOpacity>
<TouchableOpacity style={{ flex: 1 }} onPress={onDetail}>
<Text style={s.title} numberOfLines={2}>{item.title}</Text>
<Text style={s.meta}>{item.requester} · {item.created_at?.slice(0, 10)}</Text>
</TouchableOpacity>
</Animated.View>
</PanGestureHandler>
</View>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
tabs: { flexDirection: 'row', backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: COLORS.border },
tab: { flex: 1, paddingVertical: 12, alignItems: 'center' },
tabActive: { borderBottomWidth: 2, borderBottomColor: COLORS.accent },
tabText: { fontSize: 14, color: COLORS.muted },
tabTextActive: { color: COLORS.accent, fontWeight: '700' },
bulkBtn: { margin: 8, backgroundColor: COLORS.accent, borderRadius: 8, padding: 10, alignItems: 'center' },
bulkBtnText: { color: '#fff', fontWeight: '700' },
cardWrap: { marginHorizontal: 12, marginVertical: 4, borderRadius: 10, overflow: 'hidden', height: 80 },
swipeBg: { position: 'absolute', top: 0, bottom: 0, left: 0, width: 80, justifyContent: 'center', alignItems: 'center' },
swipeHint: { color: '#fff', fontWeight: '700', fontSize: 12 },
card: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#fff', padding: 14, borderRadius: 10, height: 80, elevation: 1 },
selectBox: { marginRight: 10 },
checkbox: { width: 22, height: 22, borderRadius: 4, borderWidth: 2, borderColor: COLORS.border },
checkboxSelected: { backgroundColor: COLORS.accent, borderColor: COLORS.accent },
title: { fontSize: 14, fontWeight: '600', color: COLORS.text },
meta: { fontSize: 12, color: COLORS.muted, marginTop: 2 },
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
snack: { position: 'absolute', bottom: 20, left: 20, right: 20, backgroundColor: '#1E293B', borderRadius: 8, padding: 12, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
snackText: { color: '#fff', fontSize: 14 },
snackUndo: { color: COLORS.accent, fontWeight: '700' },
})