211 lines
8.2 KiB
TypeScript
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' },
|
|
})
|