@obisoft/ui 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -0
- package/package.json +52 -0
- package/src/components/AppHeader.tsx +67 -0
- package/src/components/BottomSheet.tsx +110 -0
- package/src/components/Button.tsx +117 -0
- package/src/components/Card.tsx +46 -0
- package/src/components/CenteredState.tsx +35 -0
- package/src/components/Chip.tsx +47 -0
- package/src/components/ChoiceField.tsx +44 -0
- package/src/components/DateRangeFilter.tsx +226 -0
- package/src/components/EmptyState.tsx +32 -0
- package/src/components/ErrorState.tsx +62 -0
- package/src/components/Fab.tsx +36 -0
- package/src/components/FormRow.tsx +39 -0
- package/src/components/FormWorkspaceShell.tsx +126 -0
- package/src/components/GlassTabBar.tsx +39 -0
- package/src/components/Icon.tsx +259 -0
- package/src/components/IconButton.tsx +73 -0
- package/src/components/ListFilterBar.tsx +134 -0
- package/src/components/ListRow.tsx +198 -0
- package/src/components/ListWorkspace.tsx +76 -0
- package/src/components/LoadingState.tsx +37 -0
- package/src/components/MetricCard.tsx +74 -0
- package/src/components/MetricsGrid.tsx +25 -0
- package/src/components/MiniBarChart.tsx +42 -0
- package/src/components/NavigationDrawer.tsx +243 -0
- package/src/components/QueryState.tsx +82 -0
- package/src/components/QueryWorkspace.tsx +74 -0
- package/src/components/ScanInputBar.tsx +69 -0
- package/src/components/ScannerShell.tsx +76 -0
- package/src/components/Screen.tsx +44 -0
- package/src/components/SearchArea.tsx +34 -0
- package/src/components/SegmentedControl.tsx +64 -0
- package/src/components/Skeleton.tsx +42 -0
- package/src/components/StatusBadge.tsx +23 -0
- package/src/components/SuccessState.tsx +41 -0
- package/src/components/TextField.tsx +47 -0
- package/src/components/ToggleRow.tsx +35 -0
- package/src/components/record-detail/RecordDetail.tsx +60 -0
- package/src/components/record-detail/RecordDetailActions.tsx +88 -0
- package/src/components/record-detail/RecordDetailField.tsx +43 -0
- package/src/components/record-detail/RecordDetailHero.tsx +46 -0
- package/src/components/record-detail/RecordDetailLine.tsx +36 -0
- package/src/components/record-detail/RecordDetailSection.tsx +36 -0
- package/src/components/record-detail/index.ts +7 -0
- package/src/components/record-detail/status.ts +16 -0
- package/src/gallery/catalog.ts +31 -0
- package/src/glass/AmbientBackground.tsx +29 -0
- package/src/glass/GlassSurface.tsx +107 -0
- package/src/index.ts +66 -0
- package/src/layout/Box.tsx +62 -0
- package/src/layout/Divider.tsx +8 -0
- package/src/layout/Row.tsx +7 -0
- package/src/layout/Stack.tsx +7 -0
- package/src/provider/ObiUIProvider.tsx +45 -0
- package/src/styles/useObiStyles.ts +288 -0
- package/src/theme/effects.ts +219 -0
- package/src/theme/index.ts +3 -0
- package/src/theme/theme.ts +53 -0
- package/src/theme/tokens.ts +133 -0
- package/src/typography/ObiText.tsx +52 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { Screen } from './Screen';
|
|
4
|
+
import { SearchArea } from './SearchArea';
|
|
5
|
+
import { QueryState, type QueryStateProps } from './QueryState';
|
|
6
|
+
|
|
7
|
+
export type ListWorkspaceProps = {
|
|
8
|
+
filter?: React.ReactNode;
|
|
9
|
+
loading?: boolean;
|
|
10
|
+
error?: unknown;
|
|
11
|
+
onRetry?: () => void;
|
|
12
|
+
errorTitle?: string;
|
|
13
|
+
errorMessage?: string;
|
|
14
|
+
/** When true (and not loading/error), shows EmptyState instead of children. */
|
|
15
|
+
empty?: boolean;
|
|
16
|
+
emptyTitle?: string;
|
|
17
|
+
emptyDescription?: string;
|
|
18
|
+
emptyActionLabel?: string;
|
|
19
|
+
onEmptyAction?: () => void;
|
|
20
|
+
loadingMessage?: string;
|
|
21
|
+
/** Optional footer below content (above FAB), for non-FlashList footers. */
|
|
22
|
+
footer?: React.ReactNode;
|
|
23
|
+
fab?: React.ReactNode;
|
|
24
|
+
children?: React.ReactNode;
|
|
25
|
+
contentStyle?: StyleProp<ViewStyle>;
|
|
26
|
+
style?: StyleProp<ViewStyle>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* High-level chrome for master/list screens:
|
|
31
|
+
* Screen → optional SearchArea/filter → QueryState → list children → footer → FAB.
|
|
32
|
+
*/
|
|
33
|
+
export function ListWorkspace({
|
|
34
|
+
filter,
|
|
35
|
+
loading,
|
|
36
|
+
error,
|
|
37
|
+
onRetry,
|
|
38
|
+
errorTitle,
|
|
39
|
+
errorMessage,
|
|
40
|
+
empty,
|
|
41
|
+
emptyTitle,
|
|
42
|
+
emptyDescription,
|
|
43
|
+
emptyActionLabel,
|
|
44
|
+
onEmptyAction,
|
|
45
|
+
loadingMessage,
|
|
46
|
+
footer,
|
|
47
|
+
fab,
|
|
48
|
+
children,
|
|
49
|
+
contentStyle,
|
|
50
|
+
style,
|
|
51
|
+
}: ListWorkspaceProps) {
|
|
52
|
+
const queryProps: QueryStateProps = {
|
|
53
|
+
loading,
|
|
54
|
+
error,
|
|
55
|
+
onRetry,
|
|
56
|
+
errorTitle,
|
|
57
|
+
errorMessage,
|
|
58
|
+
empty,
|
|
59
|
+
emptyTitle,
|
|
60
|
+
emptyDescription,
|
|
61
|
+
emptyActionLabel,
|
|
62
|
+
onEmptyAction,
|
|
63
|
+
loadingMessage,
|
|
64
|
+
style: contentStyle,
|
|
65
|
+
children,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Screen style={style}>
|
|
70
|
+
{filter ? <SearchArea>{filter}</SearchArea> : null}
|
|
71
|
+
<QueryState {...queryProps} />
|
|
72
|
+
{footer ? <View>{footer}</View> : null}
|
|
73
|
+
{fab}
|
|
74
|
+
</Screen>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ActivityIndicator, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { useObiTheme } from '../provider/ObiUIProvider';
|
|
4
|
+
import { ObiText } from '../typography/ObiText';
|
|
5
|
+
|
|
6
|
+
export type LoadingStateProps = {
|
|
7
|
+
message?: string;
|
|
8
|
+
compact?: boolean;
|
|
9
|
+
style?: StyleProp<ViewStyle>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function LoadingState({ message, compact = false, style }: LoadingStateProps) {
|
|
13
|
+
const theme = useObiTheme();
|
|
14
|
+
return (
|
|
15
|
+
<View
|
|
16
|
+
style={[
|
|
17
|
+
compact
|
|
18
|
+
? {
|
|
19
|
+
padding: theme.spacing.md,
|
|
20
|
+
alignItems: 'center',
|
|
21
|
+
justifyContent: 'center',
|
|
22
|
+
}
|
|
23
|
+
: {
|
|
24
|
+
flex: 1,
|
|
25
|
+
alignItems: 'center',
|
|
26
|
+
justifyContent: 'center',
|
|
27
|
+
padding: theme.spacing.xxl,
|
|
28
|
+
gap: theme.spacing.md,
|
|
29
|
+
},
|
|
30
|
+
style,
|
|
31
|
+
]}
|
|
32
|
+
>
|
|
33
|
+
<ActivityIndicator size={compact ? 'small' : 'large'} color={theme.colors.primary} />
|
|
34
|
+
{message ? <ObiText muted style={{ textAlign: 'center' }}>{message}</ObiText> : null}
|
|
35
|
+
</View>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
import { useObiTheme } from '../provider/ObiUIProvider';
|
|
4
|
+
import { shadow } from '../theme/effects';
|
|
5
|
+
import { ObiText } from '../typography/ObiText';
|
|
6
|
+
|
|
7
|
+
export type MetricFormat = 'currency' | 'number' | 'text';
|
|
8
|
+
|
|
9
|
+
export type MetricCardProps = {
|
|
10
|
+
label: string;
|
|
11
|
+
value: number | string;
|
|
12
|
+
trend?: number | null;
|
|
13
|
+
format?: MetricFormat;
|
|
14
|
+
locale?: string;
|
|
15
|
+
currency?: string;
|
|
16
|
+
formatter?: (value: number | string) => string;
|
|
17
|
+
trendCaption?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function defaultFormat(value: number | string, format: MetricFormat, locale: string, currency: string) {
|
|
21
|
+
if (format === 'text' || typeof value === 'string') return String(value);
|
|
22
|
+
if (format === 'currency') {
|
|
23
|
+
return new Intl.NumberFormat(locale, { style: 'currency', currency, minimumFractionDigits: 2 }).format(value);
|
|
24
|
+
}
|
|
25
|
+
return new Intl.NumberFormat(locale).format(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function MetricCard({
|
|
29
|
+
label,
|
|
30
|
+
value,
|
|
31
|
+
trend,
|
|
32
|
+
format = 'currency',
|
|
33
|
+
locale = 'es-DO',
|
|
34
|
+
currency = 'DOP',
|
|
35
|
+
formatter,
|
|
36
|
+
trendCaption = 'vs. período anterior',
|
|
37
|
+
}: MetricCardProps) {
|
|
38
|
+
const theme = useObiTheme();
|
|
39
|
+
const trendText = trend == null ? null : `${trend >= 0 ? '+' : ''}${trend.toFixed(1)}%`;
|
|
40
|
+
const display = formatter ? formatter(value) : defaultFormat(value, format, locale, currency);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<View
|
|
44
|
+
style={{
|
|
45
|
+
flex: 1,
|
|
46
|
+
minWidth: 0,
|
|
47
|
+
backgroundColor: theme.glass.surface,
|
|
48
|
+
borderRadius: theme.radius.lg,
|
|
49
|
+
padding: theme.spacing.md,
|
|
50
|
+
gap: theme.spacing.xs,
|
|
51
|
+
borderWidth: 1,
|
|
52
|
+
borderColor: theme.glass.border,
|
|
53
|
+
...shadow.glass,
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
<View style={{ backgroundColor: 'transparent', gap: 2 }}>
|
|
57
|
+
<ObiText variant="caption" muted numberOfLines={1}>{label.toUpperCase()}</ObiText>
|
|
58
|
+
<ObiText variant="subtitle" weight="900" numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.7}>
|
|
59
|
+
{display}
|
|
60
|
+
</ObiText>
|
|
61
|
+
{trendText ? (
|
|
62
|
+
<ObiText
|
|
63
|
+
variant="caption"
|
|
64
|
+
weight="800"
|
|
65
|
+
numberOfLines={1}
|
|
66
|
+
style={{ color: trend! >= 0 ? theme.colors.success : theme.colors.danger, marginTop: theme.spacing.xs }}
|
|
67
|
+
>
|
|
68
|
+
{trendText} {trendCaption}
|
|
69
|
+
</ObiText>
|
|
70
|
+
) : null}
|
|
71
|
+
</View>
|
|
72
|
+
</View>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
import { useObiTheme } from '../provider/ObiUIProvider';
|
|
4
|
+
|
|
5
|
+
export function MetricsGrid({ children, columns = 2 }: { children: React.ReactNode; columns?: number }) {
|
|
6
|
+
const theme = useObiTheme();
|
|
7
|
+
const items = React.Children.toArray(children).filter(Boolean);
|
|
8
|
+
const rows: React.ReactNode[][] = [];
|
|
9
|
+
for (let i = 0; i < items.length; i += columns) rows.push(items.slice(i, i + columns));
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<View style={{ gap: theme.spacing.md }}>
|
|
13
|
+
{rows.map((row, index) => (
|
|
14
|
+
<View key={index} style={{ flexDirection: 'row', gap: theme.spacing.md }}>
|
|
15
|
+
{row}
|
|
16
|
+
{row.length < columns
|
|
17
|
+
? Array.from({ length: columns - row.length }).map((_, spacerIndex) => (
|
|
18
|
+
<View key={`spacer-${spacerIndex}`} style={{ flex: 1, minWidth: 0 }} />
|
|
19
|
+
))
|
|
20
|
+
: null}
|
|
21
|
+
</View>
|
|
22
|
+
))}
|
|
23
|
+
</View>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
import { useObiTheme } from '../provider/ObiUIProvider';
|
|
4
|
+
import { ObiText } from '../typography/ObiText';
|
|
5
|
+
|
|
6
|
+
export type MiniBarChartItem = { label: string; value: number };
|
|
7
|
+
|
|
8
|
+
export function MiniBarChart({
|
|
9
|
+
items,
|
|
10
|
+
formatValue,
|
|
11
|
+
emptyText = 'No hay datos para el período.',
|
|
12
|
+
}: {
|
|
13
|
+
items: MiniBarChartItem[];
|
|
14
|
+
formatValue?: (value: number) => string;
|
|
15
|
+
emptyText?: string;
|
|
16
|
+
}) {
|
|
17
|
+
const theme = useObiTheme();
|
|
18
|
+
const max = Math.max(1, ...items.map(item => Math.abs(item.value)));
|
|
19
|
+
const format = formatValue ?? ((value: number) => new Intl.NumberFormat('es-DO').format(value));
|
|
20
|
+
if (!items.length) return <ObiText muted>{emptyText}</ObiText>;
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<View style={{ gap: theme.spacing.sm }}>
|
|
24
|
+
{items.map((item, index) => (
|
|
25
|
+
<View key={`${item.label}-${index}`} style={{ flexDirection: 'row', alignItems: 'center', gap: theme.spacing.sm }}>
|
|
26
|
+
<ObiText variant="caption" muted style={{ width: 72 }}>{item.label}</ObiText>
|
|
27
|
+
<View style={{ height: 10, flex: 1, backgroundColor: theme.colors.borderStrong, borderRadius: theme.radius.pill, overflow: 'hidden' }}>
|
|
28
|
+
<View
|
|
29
|
+
style={{
|
|
30
|
+
width: `${Math.max(2, Math.min(100, Math.abs(item.value) / max * 100))}%`,
|
|
31
|
+
height: '100%',
|
|
32
|
+
backgroundColor: theme.colors.primary,
|
|
33
|
+
borderRadius: theme.radius.pill,
|
|
34
|
+
}}
|
|
35
|
+
/>
|
|
36
|
+
</View>
|
|
37
|
+
<ObiText variant="caption" weight="800" style={{ minWidth: 64, textAlign: 'right' }}>{format(item.value)}</ObiText>
|
|
38
|
+
</View>
|
|
39
|
+
))}
|
|
40
|
+
</View>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Pressable, ScrollView, Text, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
4
|
+
import { useObiTheme } from '../provider/ObiUIProvider';
|
|
5
|
+
import { shadow } from '../theme/effects';
|
|
6
|
+
import { Icon, type IconName } from './Icon';
|
|
7
|
+
|
|
8
|
+
export type NavigationDrawerLeaf = {
|
|
9
|
+
type: 'item';
|
|
10
|
+
key: string;
|
|
11
|
+
label: string;
|
|
12
|
+
icon: IconName;
|
|
13
|
+
onPress: () => void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type NavigationDrawerGroup = {
|
|
17
|
+
type: 'group';
|
|
18
|
+
key: string;
|
|
19
|
+
label: string;
|
|
20
|
+
icon: IconName;
|
|
21
|
+
children: Array<{ key: string; label: string; icon?: IconName; onPress: () => void }>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type NavigationDrawerDivider = { type: 'divider'; key: string };
|
|
25
|
+
export type NavigationDrawerEntry = NavigationDrawerLeaf | NavigationDrawerGroup | NavigationDrawerDivider;
|
|
26
|
+
|
|
27
|
+
export type NavigationDrawerProps = {
|
|
28
|
+
profile: {
|
|
29
|
+
name: string;
|
|
30
|
+
subtitle?: string;
|
|
31
|
+
initial?: string;
|
|
32
|
+
};
|
|
33
|
+
entries: NavigationDrawerEntry[];
|
|
34
|
+
footerAction?: {
|
|
35
|
+
label: string;
|
|
36
|
+
icon: IconName;
|
|
37
|
+
onPress: () => void;
|
|
38
|
+
tone?: 'default' | 'danger';
|
|
39
|
+
};
|
|
40
|
+
style?: StyleProp<ViewStyle>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function NavigationDrawer({ profile, entries, footerAction, style }: NavigationDrawerProps) {
|
|
44
|
+
const theme = useObiTheme();
|
|
45
|
+
const insets = useSafeAreaInsets();
|
|
46
|
+
const [openKeys, setOpenKeys] = useState<Record<string, boolean>>({});
|
|
47
|
+
const pressedColor = theme.colors.surfacePressed;
|
|
48
|
+
|
|
49
|
+
const toggle = (key: string) => {
|
|
50
|
+
setOpenKeys(current => ({ ...current, [key]: !current[key] }));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const itemIcon = (icon: IconName, color = theme.colors.textMuted, size = 18) => (
|
|
54
|
+
<View style={{ width: 24, alignItems: 'center', justifyContent: 'center' }}>
|
|
55
|
+
<Icon name={icon} size={size} color={color} />
|
|
56
|
+
</View>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<View style={[{ flex: 1, backgroundColor: 'transparent' }, style]}>
|
|
61
|
+
<View
|
|
62
|
+
style={{
|
|
63
|
+
flexDirection: 'row',
|
|
64
|
+
alignItems: 'center',
|
|
65
|
+
gap: theme.spacing.md,
|
|
66
|
+
paddingHorizontal: theme.spacing.lg,
|
|
67
|
+
paddingTop: insets.top + theme.spacing.lg,
|
|
68
|
+
paddingBottom: theme.spacing.lg,
|
|
69
|
+
backgroundColor: theme.glass.chrome,
|
|
70
|
+
borderBottomWidth: 1,
|
|
71
|
+
borderBottomColor: theme.glass.borderSoft,
|
|
72
|
+
...shadow.glassSm,
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
<View
|
|
76
|
+
style={{
|
|
77
|
+
width: 44,
|
|
78
|
+
height: 44,
|
|
79
|
+
borderRadius: 22,
|
|
80
|
+
alignItems: 'center',
|
|
81
|
+
justifyContent: 'center',
|
|
82
|
+
backgroundColor: theme.glass.primaryTintStrong,
|
|
83
|
+
borderWidth: 1,
|
|
84
|
+
borderColor: theme.glass.border,
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
<Text style={{ fontSize: 18, fontWeight: '900', color: theme.colors.primary }}>
|
|
88
|
+
{(profile.initial || profile.name || 'U').charAt(0).toUpperCase()}
|
|
89
|
+
</Text>
|
|
90
|
+
</View>
|
|
91
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
92
|
+
<Text style={{ fontSize: theme.type.body, fontWeight: '800', color: theme.colors.text }} numberOfLines={1}>
|
|
93
|
+
{profile.name}
|
|
94
|
+
</Text>
|
|
95
|
+
{profile.subtitle ? (
|
|
96
|
+
<Text style={{ fontSize: 12, color: theme.colors.textMuted, marginTop: 2 }} numberOfLines={1}>
|
|
97
|
+
{profile.subtitle}
|
|
98
|
+
</Text>
|
|
99
|
+
) : null}
|
|
100
|
+
</View>
|
|
101
|
+
</View>
|
|
102
|
+
|
|
103
|
+
<ScrollView
|
|
104
|
+
style={{ flex: 1 }}
|
|
105
|
+
contentContainerStyle={{ paddingVertical: theme.spacing.sm }}
|
|
106
|
+
showsVerticalScrollIndicator={false}
|
|
107
|
+
>
|
|
108
|
+
{entries.map(entry => {
|
|
109
|
+
if (entry.type === 'divider') {
|
|
110
|
+
return (
|
|
111
|
+
<View
|
|
112
|
+
key={entry.key}
|
|
113
|
+
style={{
|
|
114
|
+
height: 1,
|
|
115
|
+
backgroundColor: theme.colors.separator,
|
|
116
|
+
marginHorizontal: theme.spacing.lg,
|
|
117
|
+
marginVertical: theme.spacing.sm,
|
|
118
|
+
}}
|
|
119
|
+
/>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (entry.type === 'item') {
|
|
124
|
+
return (
|
|
125
|
+
<Pressable
|
|
126
|
+
key={entry.key}
|
|
127
|
+
accessibilityRole="button"
|
|
128
|
+
onPress={entry.onPress}
|
|
129
|
+
style={({ pressed }) => ({
|
|
130
|
+
flexDirection: 'row',
|
|
131
|
+
alignItems: 'center',
|
|
132
|
+
paddingHorizontal: theme.spacing.lg,
|
|
133
|
+
paddingVertical: theme.spacing.sm + 2,
|
|
134
|
+
minHeight: 44,
|
|
135
|
+
gap: theme.spacing.sm,
|
|
136
|
+
borderRadius: theme.radius.md,
|
|
137
|
+
marginHorizontal: theme.spacing.xs,
|
|
138
|
+
backgroundColor: pressed ? pressedColor : 'transparent',
|
|
139
|
+
})}
|
|
140
|
+
>
|
|
141
|
+
{itemIcon(entry.icon)}
|
|
142
|
+
<Text style={{ fontSize: theme.type.body, color: theme.colors.text, flex: 1, fontWeight: '600' }}>
|
|
143
|
+
{entry.label}
|
|
144
|
+
</Text>
|
|
145
|
+
</Pressable>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const open = Boolean(openKeys[entry.key]);
|
|
150
|
+
return (
|
|
151
|
+
<View key={entry.key}>
|
|
152
|
+
<Pressable
|
|
153
|
+
accessibilityRole="button"
|
|
154
|
+
accessibilityState={{ expanded: open }}
|
|
155
|
+
onPress={() => toggle(entry.key)}
|
|
156
|
+
style={({ pressed }) => ({
|
|
157
|
+
flexDirection: 'row',
|
|
158
|
+
alignItems: 'center',
|
|
159
|
+
paddingHorizontal: theme.spacing.lg,
|
|
160
|
+
paddingVertical: theme.spacing.sm + 2,
|
|
161
|
+
minHeight: 44,
|
|
162
|
+
gap: theme.spacing.sm,
|
|
163
|
+
borderRadius: theme.radius.md,
|
|
164
|
+
marginHorizontal: theme.spacing.xs,
|
|
165
|
+
backgroundColor: pressed ? pressedColor : 'transparent',
|
|
166
|
+
})}
|
|
167
|
+
>
|
|
168
|
+
{itemIcon(entry.icon)}
|
|
169
|
+
<Text style={{ flex: 1, fontSize: 15, fontWeight: '800', color: theme.colors.text }}>
|
|
170
|
+
{entry.label}
|
|
171
|
+
</Text>
|
|
172
|
+
<Icon name={open ? 'chevron-down' : 'chevron-right'} size={14} color={theme.colors.textMuted} />
|
|
173
|
+
</Pressable>
|
|
174
|
+
|
|
175
|
+
{open ? entry.children.map(child => (
|
|
176
|
+
<Pressable
|
|
177
|
+
key={child.key}
|
|
178
|
+
accessibilityRole="button"
|
|
179
|
+
onPress={child.onPress}
|
|
180
|
+
style={({ pressed }) => ({
|
|
181
|
+
flexDirection: 'row',
|
|
182
|
+
alignItems: 'center',
|
|
183
|
+
paddingLeft: theme.spacing.xl + theme.spacing.md,
|
|
184
|
+
paddingRight: theme.spacing.lg,
|
|
185
|
+
paddingVertical: theme.spacing.sm,
|
|
186
|
+
minHeight: 40,
|
|
187
|
+
gap: theme.spacing.sm,
|
|
188
|
+
borderRadius: theme.radius.md,
|
|
189
|
+
marginHorizontal: theme.spacing.xs,
|
|
190
|
+
backgroundColor: pressed ? pressedColor : 'transparent',
|
|
191
|
+
})}
|
|
192
|
+
>
|
|
193
|
+
{itemIcon(child.icon ?? 'dot', theme.colors.textMuted, 16)}
|
|
194
|
+
<Text style={{ flex: 1, fontSize: 14, fontWeight: '600', color: theme.colors.text }}>{child.label}</Text>
|
|
195
|
+
</Pressable>
|
|
196
|
+
)) : null}
|
|
197
|
+
</View>
|
|
198
|
+
);
|
|
199
|
+
})}
|
|
200
|
+
</ScrollView>
|
|
201
|
+
|
|
202
|
+
{footerAction ? (
|
|
203
|
+
<View
|
|
204
|
+
style={{
|
|
205
|
+
borderTopWidth: 1,
|
|
206
|
+
borderTopColor: theme.colors.separator,
|
|
207
|
+
padding: theme.spacing.md,
|
|
208
|
+
paddingBottom: theme.spacing.md + insets.bottom,
|
|
209
|
+
}}
|
|
210
|
+
>
|
|
211
|
+
<Pressable
|
|
212
|
+
accessibilityRole="button"
|
|
213
|
+
onPress={footerAction.onPress}
|
|
214
|
+
style={({ pressed }) => ({
|
|
215
|
+
flexDirection: 'row',
|
|
216
|
+
alignItems: 'center',
|
|
217
|
+
gap: theme.spacing.md,
|
|
218
|
+
paddingHorizontal: theme.spacing.lg,
|
|
219
|
+
paddingVertical: theme.spacing.md,
|
|
220
|
+
minHeight: 48,
|
|
221
|
+
borderRadius: theme.radius.md,
|
|
222
|
+
backgroundColor: pressed ? pressedColor : 'transparent',
|
|
223
|
+
})}
|
|
224
|
+
>
|
|
225
|
+
{itemIcon(
|
|
226
|
+
footerAction.icon,
|
|
227
|
+
footerAction.tone === 'danger' ? theme.colors.danger : theme.colors.textMuted,
|
|
228
|
+
)}
|
|
229
|
+
<Text
|
|
230
|
+
style={{
|
|
231
|
+
fontSize: theme.type.body,
|
|
232
|
+
color: footerAction.tone === 'danger' ? theme.colors.danger : theme.colors.text,
|
|
233
|
+
fontWeight: '600',
|
|
234
|
+
}}
|
|
235
|
+
>
|
|
236
|
+
{footerAction.label}
|
|
237
|
+
</Text>
|
|
238
|
+
</Pressable>
|
|
239
|
+
</View>
|
|
240
|
+
) : null}
|
|
241
|
+
</View>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { LoadingState } from './LoadingState';
|
|
4
|
+
import { ErrorState } from './ErrorState';
|
|
5
|
+
import { EmptyState } from './EmptyState';
|
|
6
|
+
import type { IconName } from './Icon';
|
|
7
|
+
|
|
8
|
+
export type QueryStateProps = {
|
|
9
|
+
loading?: boolean;
|
|
10
|
+
error?: unknown;
|
|
11
|
+
errorTitle?: string;
|
|
12
|
+
errorMessage?: string;
|
|
13
|
+
onRetry?: () => void;
|
|
14
|
+
empty?: boolean;
|
|
15
|
+
emptyTitle?: string;
|
|
16
|
+
emptyDescription?: string;
|
|
17
|
+
emptyIcon?: IconName;
|
|
18
|
+
emptyActionLabel?: string;
|
|
19
|
+
onEmptyAction?: () => void;
|
|
20
|
+
loadingMessage?: string;
|
|
21
|
+
children?: React.ReactNode;
|
|
22
|
+
style?: StyleProp<ViewStyle>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function resolveErrorMessage(error: unknown, fallback?: string): string | undefined {
|
|
26
|
+
if (fallback) return fallback;
|
|
27
|
+
if (!error) return undefined;
|
|
28
|
+
const custom = (error as { customMessage?: string })?.customMessage;
|
|
29
|
+
if (custom) return custom;
|
|
30
|
+
if (error instanceof Error && error.message) return error.message;
|
|
31
|
+
return 'No se pudo cargar la información.';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Orchestrates loading / error / empty / content for list and detail screens.
|
|
36
|
+
* Keeps FlashList / React Query outside the design system.
|
|
37
|
+
*/
|
|
38
|
+
export function QueryState({
|
|
39
|
+
loading = false,
|
|
40
|
+
error,
|
|
41
|
+
errorTitle,
|
|
42
|
+
errorMessage,
|
|
43
|
+
onRetry,
|
|
44
|
+
empty = false,
|
|
45
|
+
emptyTitle = 'Sin resultados',
|
|
46
|
+
emptyDescription,
|
|
47
|
+
emptyIcon,
|
|
48
|
+
emptyActionLabel,
|
|
49
|
+
onEmptyAction,
|
|
50
|
+
loadingMessage,
|
|
51
|
+
children,
|
|
52
|
+
style,
|
|
53
|
+
}: QueryStateProps) {
|
|
54
|
+
if (loading) {
|
|
55
|
+
return <LoadingState message={loadingMessage} style={style} />;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (error) {
|
|
59
|
+
return (
|
|
60
|
+
<ErrorState
|
|
61
|
+
title={errorTitle}
|
|
62
|
+
message={resolveErrorMessage(error, errorMessage)}
|
|
63
|
+
onRetry={onRetry}
|
|
64
|
+
style={style}
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (empty) {
|
|
70
|
+
return (
|
|
71
|
+
<EmptyState
|
|
72
|
+
title={emptyTitle}
|
|
73
|
+
description={emptyDescription}
|
|
74
|
+
icon={emptyIcon}
|
|
75
|
+
actionLabel={emptyActionLabel}
|
|
76
|
+
onAction={onEmptyAction}
|
|
77
|
+
/>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return <View style={[{ flex: 1 }, style]}>{children}</View>;
|
|
82
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { Screen } from './Screen';
|
|
4
|
+
import { SearchArea } from './SearchArea';
|
|
5
|
+
import { QueryState, type QueryStateProps } from './QueryState';
|
|
6
|
+
|
|
7
|
+
export type QueryWorkspaceProps = {
|
|
8
|
+
filter?: React.ReactNode;
|
|
9
|
+
loading?: boolean;
|
|
10
|
+
error?: unknown;
|
|
11
|
+
onRetry?: () => void;
|
|
12
|
+
errorTitle?: string;
|
|
13
|
+
errorMessage?: string;
|
|
14
|
+
/** When true (and not loading/error), shows EmptyState instead of children. */
|
|
15
|
+
empty?: boolean;
|
|
16
|
+
emptyTitle?: string;
|
|
17
|
+
emptyDescription?: string;
|
|
18
|
+
emptyActionLabel?: string;
|
|
19
|
+
onEmptyAction?: () => void;
|
|
20
|
+
loadingMessage?: string;
|
|
21
|
+
/** Optional footer below content. */
|
|
22
|
+
footer?: React.ReactNode;
|
|
23
|
+
children?: React.ReactNode;
|
|
24
|
+
contentStyle?: StyleProp<ViewStyle>;
|
|
25
|
+
style?: StyleProp<ViewStyle>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* High-level chrome for scroll/metrics query screens (reports, statements):
|
|
30
|
+
* Screen → optional SearchArea/filter → QueryState → children → footer.
|
|
31
|
+
* Prefer ListWorkspace for FlashList master screens.
|
|
32
|
+
*/
|
|
33
|
+
export function QueryWorkspace({
|
|
34
|
+
filter,
|
|
35
|
+
loading,
|
|
36
|
+
error,
|
|
37
|
+
onRetry,
|
|
38
|
+
errorTitle,
|
|
39
|
+
errorMessage,
|
|
40
|
+
empty,
|
|
41
|
+
emptyTitle,
|
|
42
|
+
emptyDescription,
|
|
43
|
+
emptyActionLabel,
|
|
44
|
+
onEmptyAction,
|
|
45
|
+
loadingMessage,
|
|
46
|
+
footer,
|
|
47
|
+
children,
|
|
48
|
+
contentStyle,
|
|
49
|
+
style,
|
|
50
|
+
}: QueryWorkspaceProps) {
|
|
51
|
+
const queryProps: QueryStateProps = {
|
|
52
|
+
loading,
|
|
53
|
+
error,
|
|
54
|
+
onRetry,
|
|
55
|
+
errorTitle,
|
|
56
|
+
errorMessage,
|
|
57
|
+
empty,
|
|
58
|
+
emptyTitle,
|
|
59
|
+
emptyDescription,
|
|
60
|
+
emptyActionLabel,
|
|
61
|
+
onEmptyAction,
|
|
62
|
+
loadingMessage,
|
|
63
|
+
style: contentStyle,
|
|
64
|
+
children,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<Screen style={style}>
|
|
69
|
+
{filter ? <SearchArea>{filter}</SearchArea> : null}
|
|
70
|
+
<QueryState {...queryProps} />
|
|
71
|
+
{footer ? <View>{footer}</View> : null}
|
|
72
|
+
</Screen>
|
|
73
|
+
);
|
|
74
|
+
}
|