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

157 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* #38 멀티기관 계정 전환 (feature-screen-dev와 협업)
* GET /api/institutions/ — 접근 가능 기관 목록
* POST /api/auth/switch-tenant — { tenant_id } → { access_token, tenant_name }
* 성공: SecureStore 'grd_token' 갱신 + 토스트 + router.replace('/(tabs)')
*/
import { useCallback, useEffect, useState } from 'react'
import {
View, Text, FlatList, TouchableOpacity, StyleSheet,
RefreshControl, ActivityIndicator, ToastAndroid, Platform, Alert,
} from 'react-native'
import { useRouter } from 'expo-router'
import * as SecureStore from 'expo-secure-store'
import { COLORS } from '../../constants/Config'
import { getInstitutions, switchTenant } from '../../services/api'
import LineIcon from '../../components/LineIcon'
interface Institution {
id?: string | number
inst_id?: string | number
tenant_id?: string | number
name?: string
inst_name?: string
region?: string
is_current?: boolean
current?: boolean
}
function toast(msg: string) {
if (Platform.OS === 'android') ToastAndroid.show(msg, ToastAndroid.SHORT)
else Alert.alert('', msg)
}
export default function MultiTenantScreen() {
const router = useRouter()
const [items, setItems] = useState<Institution[]>([])
const [loading, setLoading] = useState(true)
const [refresh, setRefresh] = useState(false)
const [switching, setSwitching] = useState<string | number | null>(null)
const load = useCallback(async (isRefresh = false) => {
isRefresh ? setRefresh(true) : setLoading(true)
try {
const r = await getInstitutions()
const list: Institution[] = Array.isArray(r.data) ? r.data : r.data?.items ?? []
setItems(list)
} catch {
setItems([])
} finally {
setLoading(false)
setRefresh(false)
}
}, [])
useEffect(() => { load() }, [load])
const idOf = (inst: Institution) => inst.tenant_id ?? inst.inst_id ?? inst.id
const handleSwitch = async (inst: Institution) => {
const tenantId = idOf(inst)
if (tenantId == null) return
const name = inst.name ?? inst.inst_name ?? '기관'
setSwitching(tenantId)
try {
const r = await switchTenant(tenantId)
const { access_token, tenant_name } = r.data ?? {}
if (access_token) {
await SecureStore.setItemAsync('grd_token', access_token)
}
toast(`${tenant_name ?? name}으로 전환됨`)
router.replace('/(tabs)')
} catch (e: any) {
Alert.alert('전환 실패', e.response?.data?.detail ?? '기관 전환에 실패했습니다.')
} finally {
setSwitching(null)
}
}
if (loading) {
return (
<View style={s.center}>
<ActivityIndicator color={COLORS.accent} size="large" />
</View>
)
}
return (
<FlatList
style={{ flex: 1, backgroundColor: COLORS.bg }}
data={items}
keyExtractor={(it, i) => String(idOf(it) ?? i)}
refreshControl={<RefreshControl refreshing={refresh} onRefresh={() => load(true)} tintColor={COLORS.accent} />}
ListHeaderComponent={
<View style={s.header}>
<Text style={s.headerTitle}> </Text>
<Text style={s.headerSub}> {items.length} · </Text>
</View>
}
ListEmptyComponent={
<View style={s.empty}><Text style={s.emptyText}> .</Text></View>
}
contentContainerStyle={items.length === 0 ? { flexGrow: 1 } : undefined}
renderItem={({ item }) => {
const isCurrent = item.is_current ?? item.current ?? false
const tid = idOf(item)
return (
<TouchableOpacity
style={[s.card, isCurrent && s.cardCurrent]}
disabled={isCurrent || switching != null}
onPress={() => handleSwitch(item)}
>
<View style={s.iconBox}>
<LineIcon name="building" size={20} color={COLORS.accent} />
</View>
<View style={{ flex: 1 }}>
<Text style={s.name}>{item.name ?? item.inst_name ?? '기관'}</Text>
{!!item.region && <Text style={s.meta}>{item.region}</Text>}
</View>
{isCurrent ? (
<View style={s.currentBadge}><Text style={s.currentText}></Text></View>
) : switching === tid ? (
<ActivityIndicator size="small" color={COLORS.accent} />
) : (
<Text style={s.chevron}></Text>
)}
</TouchableOpacity>
)
}}
/>
)
}
const s = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: COLORS.bg },
header: { padding: 20, paddingBottom: 8 },
headerTitle: { fontSize: 18, fontWeight: '800', color: COLORS.text },
headerSub: { fontSize: 12, color: COLORS.muted, marginTop: 4 },
card: {
flexDirection: 'row', alignItems: 'center', gap: 12,
backgroundColor: '#fff', marginHorizontal: 16, marginTop: 10,
borderRadius: 14, padding: 16,
borderWidth: 1, borderColor: COLORS.border,
},
cardCurrent: { borderColor: COLORS.accent, backgroundColor: '#E8F7FB' },
iconBox: {
width: 42, height: 42, borderRadius: 11,
backgroundColor: 'rgba(0,160,200,.08)', alignItems: 'center', justifyContent: 'center',
},
name: { fontSize: 15, fontWeight: '700', color: COLORS.text },
meta: { fontSize: 12, color: COLORS.muted, marginTop: 2 },
currentBadge: { backgroundColor: COLORS.accent, paddingHorizontal: 10, paddingVertical: 3, borderRadius: 8 },
currentText: { fontSize: 11, fontWeight: '700', color: '#fff' },
chevron: { fontSize: 22, color: COLORS.muted },
empty: { flex: 1, alignItems: 'center', justifyContent: 'center' },
emptyText: { color: COLORS.muted, fontSize: 14 },
})