itsm_ysm/mobile/components/ui.tsx
itms-merge-dev efc3e66635 feat(itms): 모바일 앱 신규 (Expo, 12화면, OAuth password grant)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:10:46 +09:00

132 lines
5.6 KiB
TypeScript

import { type ReactNode } from 'react'
import {
View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, TextInput,
type StyleProp, type ViewStyle, type TextStyle,
} from 'react-native'
import { Feather } from '@expo/vector-icons'
import { useTheme } from '../constants/ThemeContext'
import { statusColor, type ThemeTokens } from '../constants/ItmsTheme'
/** 카드 컨테이너. */
export function Card({ children, style }: { children: ReactNode; style?: StyleProp<ViewStyle> }) {
const { t } = useTheme()
return (
<View style={[{ backgroundColor: t.card, borderRadius: 14, borderWidth: 1, borderColor: t.border, padding: 16 }, style]}>
{children}
</View>
)
}
/** 진행상태 배지(라벨 부분일치 색). */
export function StatusPill({ label }: { label?: string | null }) {
const { t } = useTheme()
const text = (label ?? '').toString() || '-'
const color = statusColor(label, t)
return (
<View style={{ flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', gap: 5, backgroundColor: color + '22', borderRadius: 8, paddingHorizontal: 8, paddingVertical: 3 }}>
<View style={{ width: 7, height: 7, borderRadius: 4, backgroundColor: color }} />
<Text style={{ color, fontSize: 11, fontWeight: '800' }}>{text}</Text>
</View>
)
}
/** 기본 버튼(primary/ghost/danger/outline). */
export function Btn({
label, onPress, kind = 'primary', busy, disabled, icon, style,
}: {
label: string; onPress: () => void; kind?: 'primary' | 'ghost' | 'danger' | 'outline'
busy?: boolean; disabled?: boolean; icon?: keyof typeof Feather.glyphMap; style?: StyleProp<ViewStyle>
}) {
const { t } = useTheme()
const bg = kind === 'primary' ? t.brand : kind === 'danger' ? t.danger : 'transparent'
const fg = kind === 'ghost' || kind === 'outline' ? t.text : t.onBrand
const border = kind === 'outline' ? t.border : 'transparent'
return (
<TouchableOpacity
onPress={onPress}
disabled={busy || disabled}
style={[{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 7, backgroundColor: bg, borderColor: border, borderWidth: kind === 'outline' ? 1 : 0, borderRadius: 12, paddingVertical: 13, paddingHorizontal: 16, opacity: busy || disabled ? 0.5 : 1 }, style]}
>
{busy ? <ActivityIndicator color={fg} /> : (
<>
{icon && <Feather name={icon} size={16} color={fg} />}
<Text style={{ color: fg, fontSize: 15, fontWeight: '800' }}>{label}</Text>
</>
)}
</TouchableOpacity>
)
}
/** 라벨 + 입력 필드. */
export function Field({
label, value, onChangeText, placeholder, keyboardType, multiline, secureTextEntry, autoCapitalize,
}: {
label?: string; value: string; onChangeText: (s: string) => void; placeholder?: string
keyboardType?: 'default' | 'number-pad' | 'email-address'; multiline?: boolean; secureTextEntry?: boolean
autoCapitalize?: 'none' | 'sentences'
}) {
const { t } = useTheme()
return (
<View style={{ marginTop: 12 }}>
{!!label && <Text style={{ color: t.muted, fontSize: 12, marginBottom: 6 }}>{label}</Text>}
<TextInput
value={value} onChangeText={onChangeText} placeholder={placeholder} placeholderTextColor={t.muted}
keyboardType={keyboardType} multiline={multiline} secureTextEntry={secureTextEntry} autoCapitalize={autoCapitalize}
autoCorrect={false}
style={{ backgroundColor: t.cardAlt, borderRadius: 10, borderWidth: 1, borderColor: t.border, color: t.text, padding: 12, fontSize: 15, minHeight: multiline ? 96 : undefined, textAlignVertical: multiline ? 'top' : 'center' }}
/>
</View>
)
}
/** 빈 상태 안내(서버 미기동/무결과 공통). */
export function EmptyState({ icon = 'inbox', text }: { icon?: keyof typeof Feather.glyphMap; text: string }) {
const { t } = useTheme()
return (
<View style={{ alignItems: 'center', paddingVertical: 48 }}>
<Feather name={icon} size={40} color={t.muted} />
<Text style={{ color: t.muted, fontSize: 14, marginTop: 12, textAlign: 'center', paddingHorizontal: 24 }}>{text}</Text>
</View>
)
}
/** 전체 화면 로딩. */
export function Loading() {
const { t } = useTheme()
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: t.bg }}>
<ActivityIndicator color={t.accent} size="large" />
</View>
)
}
/** 라벨:값 한 줄. */
export function Row({ label, value }: { label: string; value?: ReactNode }) {
const { t } = useTheme()
return (
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: t.border }}>
<Text style={{ color: t.muted, fontSize: 13 }}>{label}</Text>
<View style={{ flexShrink: 1, marginLeft: 12 }}>
{typeof value === 'string' || typeof value === 'number'
? <Text style={{ color: t.text, fontSize: 14, fontWeight: '600', textAlign: 'right' }}>{value || '-'}</Text>
: (value ?? <Text style={{ color: t.text }}>-</Text>)}
</View>
</View>
)
}
/** 섹션 제목. */
export function SectionTitle({ children, style }: { children: ReactNode; style?: StyleProp<TextStyle> }) {
const { t } = useTheme()
return <Text style={[{ color: t.text, fontSize: 15, fontWeight: '900', marginBottom: 10 }, style]}>{children}</Text>
}
/** 인라인 에러 텍스트. */
export function ErrText({ msg }: { msg?: string }) {
const { t } = useTheme()
if (!msg) return null
return <Text style={{ color: t.danger, fontSize: 12, marginTop: 10 }}>{msg}</Text>
}
export type { ThemeTokens }