@obisoft/ventas-ui 0.7.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 ADDED
@@ -0,0 +1,30 @@
1
+ # @obisoft/ventas-ui
2
+
3
+ Componentes presentacionales del dominio Ventas, construidos exclusivamente sobre `@obisoft/ui`.
4
+
5
+ ## Estado
6
+
7
+ Versión: **`0.7.0`** (peer `@obisoft/ui >= 0.8.0`). Paquete npm público (`access: public`).
8
+
9
+ - Monorepo: `file:packages/obisoft-ventas-ui`
10
+ - Externo: `npm i @obisoft/ventas-ui@0.7.0` (con `NPM_TOKEN`)
11
+
12
+ Ver [docs/OBISOFT_UI_PUBLISH.md](../../docs/OBISOFT_UI_PUBLISH.md).
13
+
14
+ ## Módulos
15
+
16
+ - **Dashboard** — secciones, quick actions, activity, company status
17
+ - **POS** — tiles, dock, tabs, ticket, quantity stepper
18
+ - **Checkout** — drawer frame, summary, payment rows, select lists
19
+ - **Chrome** — `SectionBlock`, `DetailRowsCard`, `ActionChipBar`
20
+ - **Styles** — `useVentasStyles`
21
+
22
+ ## Storybook
23
+
24
+ ```bash
25
+ npm run storybook
26
+ ```
27
+
28
+ ## Regla
29
+
30
+ Solo puede depender de `@obisoft/ui` y peers de React Native. Nunca importa `src/app`, `features`, `entities` ni `pages`. Validar con `npm run audit:ui`.
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@obisoft/ventas-ui",
3
+ "version": "0.7.0",
4
+ "description": "Presentational React Native components for Obisoft Ventas built on @obisoft/ui.",
5
+ "license": "UNLICENSED",
6
+ "sideEffects": false,
7
+ "main": "./src/index.ts",
8
+ "react-native": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "exports": {
11
+ ".": {
12
+ "react-native": "./src/index.ts",
13
+ "types": "./src/index.ts",
14
+ "default": "./src/index.ts"
15
+ }
16
+ },
17
+ "files": [
18
+ "src",
19
+ "README.md"
20
+ ],
21
+ "keywords": [
22
+ "react-native",
23
+ "obisoft",
24
+ "ventas",
25
+ "ui"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public",
29
+ "registry": "https://registry.npmjs.org/"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/obisoft/obi-projects.git",
34
+ "directory": "obi-ventas-mobile/packages/obisoft-ventas-ui"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/obisoft/obi-projects/issues"
38
+ },
39
+ "homepage": "https://github.com/obisoft/obi-projects/tree/main/obi-ventas-mobile/packages/obisoft-ventas-ui#readme",
40
+ "peerDependencies": {
41
+ "react": ">=19.0.0",
42
+ "react-native": ">=0.80.0",
43
+ "@obisoft/ui": ">=0.8.0",
44
+ "react-native-safe-area-context": ">=5.0.0",
45
+ "react-native-gesture-handler": ">=3.0.0",
46
+ "react-native-linear-gradient": ">=2.8.0"
47
+ }
48
+ }
@@ -0,0 +1,82 @@
1
+ import React, { useEffect, useMemo, useRef } from 'react';
2
+ import { Animated, Keyboard, Modal, Pressable, useWindowDimensions, View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { GestureHandlerRootView } from 'react-native-gesture-handler';
4
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
+ import { useObiTheme } from '@obisoft/ui';
6
+
7
+ export function CheckoutDrawerFrame({
8
+ visible,
9
+ onClose,
10
+ header,
11
+ children,
12
+ footer,
13
+ maxWidth = 420,
14
+ widthRatio = 0.88,
15
+ bodyStyle,
16
+ }: {
17
+ visible: boolean;
18
+ onClose: () => void;
19
+ header?: React.ReactNode;
20
+ children: React.ReactNode;
21
+ footer?: React.ReactNode;
22
+ maxWidth?: number;
23
+ widthRatio?: number;
24
+ bodyStyle?: StyleProp<ViewStyle>;
25
+ }) {
26
+ const theme = useObiTheme();
27
+ const insets = useSafeAreaInsets();
28
+ const { width } = useWindowDimensions();
29
+ const panelWidth = useMemo(() => Math.min(maxWidth, Math.round(width * widthRatio)), [maxWidth, width, widthRatio]);
30
+ const slide = useRef(new Animated.Value(panelWidth)).current;
31
+
32
+ useEffect(() => {
33
+ if (!visible) {
34
+ slide.setValue(panelWidth);
35
+ return;
36
+ }
37
+ slide.setValue(panelWidth);
38
+ Animated.timing(slide, { toValue: 0, duration: 220, useNativeDriver: true }).start();
39
+ }, [panelWidth, slide, visible]);
40
+
41
+ const closeAnimated = () => {
42
+ Keyboard.dismiss();
43
+ Animated.timing(slide, { toValue: panelWidth, duration: 180, useNativeDriver: true }).start(({ finished }) => {
44
+ if (finished) onClose();
45
+ });
46
+ };
47
+
48
+ return (
49
+ <Modal visible={visible} transparent animationType="none" onRequestClose={closeAnimated} statusBarTranslucent>
50
+ <GestureHandlerRootView style={{ flex: 1 }}>
51
+ <View style={{ flex: 1, flexDirection: 'row', justifyContent: 'flex-end', backgroundColor: theme.glass.backdropStrong }}>
52
+ <Pressable style={{ flex: 1 }} onPress={closeAnimated} accessibilityLabel="Cerrar cobro" />
53
+ <Animated.View style={{ width: panelWidth, height: '100%', transform: [{ translateX: slide }] }}>
54
+ <View
55
+ style={{
56
+ flex: 1,
57
+ paddingTop: insets.top,
58
+ paddingBottom: insets.bottom,
59
+ backgroundColor: theme.glass.panel,
60
+ borderTopLeftRadius: theme.radius.lg,
61
+ borderBottomLeftRadius: theme.radius.lg,
62
+ overflow: 'hidden',
63
+ }}
64
+ >
65
+ {header ? (
66
+ <View style={{ paddingHorizontal: theme.spacing.lg, paddingTop: theme.spacing.lg, paddingBottom: theme.spacing.md, gap: theme.spacing.sm }}>
67
+ {header}
68
+ </View>
69
+ ) : null}
70
+ <View style={[{ flex: 1 }, bodyStyle]}>{children}</View>
71
+ {footer ? (
72
+ <View style={{ paddingHorizontal: theme.spacing.lg, paddingTop: theme.spacing.md, paddingBottom: theme.spacing.lg, gap: theme.spacing.sm }}>
73
+ {footer}
74
+ </View>
75
+ ) : null}
76
+ </View>
77
+ </Animated.View>
78
+ </View>
79
+ </GestureHandlerRootView>
80
+ </Modal>
81
+ );
82
+ }
@@ -0,0 +1,31 @@
1
+ import React from 'react';
2
+ import { Pressable } from 'react-native';
3
+ import { AppText, Icon, shadow, useObiTheme } from '@obisoft/ui';
4
+
5
+ export function CheckoutPickerButton({ label, onPress, open }: { label: string; onPress: () => void; open?: boolean }) {
6
+ const theme = useObiTheme();
7
+ return (
8
+ <Pressable
9
+ accessibilityRole="button"
10
+ accessibilityState={{ expanded: Boolean(open) }}
11
+ onPress={onPress}
12
+ style={({ pressed }) => ({
13
+ minHeight: 48,
14
+ borderWidth: 1,
15
+ borderColor: open ? theme.colors.primary : theme.glass.border,
16
+ borderRadius: theme.radius.md,
17
+ backgroundColor: theme.glass.input,
18
+ paddingHorizontal: theme.spacing.md,
19
+ flexDirection: 'row',
20
+ alignItems: 'center',
21
+ justifyContent: 'space-between',
22
+ gap: theme.spacing.sm,
23
+ opacity: pressed ? 0.76 : 1,
24
+ ...shadow.glassSm,
25
+ })}
26
+ >
27
+ <AppText numberOfLines={1} style={{ flex: 1 }}>{label}</AppText>
28
+ <Icon name={open ? 'chevron-up' : 'chevron-down'} size={18} color={theme.colors.textMuted} />
29
+ </Pressable>
30
+ );
31
+ }
@@ -0,0 +1,70 @@
1
+ import React from 'react';
2
+ import { Pressable, ScrollView, View } from 'react-native';
3
+ import { AppText, shadow, useObiTheme } from '@obisoft/ui';
4
+
5
+ export type CheckoutSelectOption = {
6
+ key: string;
7
+ label: string;
8
+ subtitle?: string;
9
+ };
10
+
11
+ export function CheckoutSelectList({
12
+ items,
13
+ selectedKey,
14
+ onSelect,
15
+ maxHeight = 180,
16
+ emptyLabel = 'Sin resultados',
17
+ }: {
18
+ items: CheckoutSelectOption[];
19
+ selectedKey?: string | null;
20
+ onSelect: (key: string) => void;
21
+ maxHeight?: number;
22
+ emptyLabel?: string;
23
+ }) {
24
+ const theme = useObiTheme();
25
+ return (
26
+ <View
27
+ style={{
28
+ maxHeight,
29
+ borderWidth: 1,
30
+ borderColor: theme.glass.border,
31
+ borderRadius: theme.radius.md,
32
+ overflow: 'hidden',
33
+ backgroundColor: theme.glass.surface,
34
+ ...shadow.glassSm,
35
+ }}
36
+ >
37
+ <ScrollView nestedScrollEnabled keyboardShouldPersistTaps="handled">
38
+ {items.length === 0 ? (
39
+ <View style={{ paddingHorizontal: theme.spacing.md, paddingVertical: theme.spacing.lg }}>
40
+ <AppText muted>{emptyLabel}</AppText>
41
+ </View>
42
+ ) : null}
43
+ {items.map((item, index) => {
44
+ const selected = item.key === selectedKey;
45
+ return (
46
+ <Pressable
47
+ key={item.key}
48
+ onPress={() => onSelect(item.key)}
49
+ style={({ pressed }) => ({
50
+ paddingHorizontal: theme.spacing.md,
51
+ paddingVertical: theme.spacing.sm,
52
+ borderBottomWidth: index === items.length - 1 ? 0 : 1,
53
+ borderBottomColor: theme.glass.separator,
54
+ backgroundColor: selected
55
+ ? theme.glass.primaryTint
56
+ : pressed
57
+ ? theme.glass.controlPressed
58
+ : 'transparent',
59
+ gap: 2,
60
+ })}
61
+ >
62
+ <AppText weight="700" numberOfLines={1}>{item.label}</AppText>
63
+ {item.subtitle ? <AppText variant="caption" muted numberOfLines={1}>{item.subtitle}</AppText> : null}
64
+ </Pressable>
65
+ );
66
+ })}
67
+ </ScrollView>
68
+ </View>
69
+ );
70
+ }
@@ -0,0 +1,33 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import { AppText, useObiTheme } from '@obisoft/ui';
4
+
5
+ export type CheckoutSummaryItem = {
6
+ key: string;
7
+ label: string;
8
+ value: string;
9
+ tone?: 'default' | 'warning' | 'success' | 'danger';
10
+ strong?: boolean;
11
+ };
12
+
13
+ export function CheckoutSummary({ items }: { items: CheckoutSummaryItem[] }) {
14
+ const theme = useObiTheme();
15
+ const toneColor = (tone: CheckoutSummaryItem['tone']) => {
16
+ if (tone === 'warning') return theme.colors.warning;
17
+ if (tone === 'success') return theme.colors.success;
18
+ if (tone === 'danger') return theme.colors.danger;
19
+ return theme.colors.text;
20
+ };
21
+ return (
22
+ <View style={{ gap: theme.spacing.sm }}>
23
+ {items.map(item => (
24
+ <View key={item.key} style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: theme.spacing.md }}>
25
+ <AppText muted={item.tone == null || item.tone === 'default'} style={{ color: item.tone && item.tone !== 'default' ? toneColor(item.tone) : undefined }}>
26
+ {item.label}
27
+ </AppText>
28
+ <AppText weight={item.strong ? '900' : '800'} style={{ color: toneColor(item.tone) }}>{item.value}</AppText>
29
+ </View>
30
+ ))}
31
+ </View>
32
+ );
33
+ }
@@ -0,0 +1,45 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import { AppText, TextField, shadow, useObiTheme } from '@obisoft/ui';
4
+
5
+ export function PaymentAmountRow({
6
+ label,
7
+ value,
8
+ placeholder = '0.00',
9
+ onChangeText,
10
+ }: {
11
+ label: string;
12
+ value: string;
13
+ placeholder?: string;
14
+ onChangeText: (value: string) => void;
15
+ }) {
16
+ const theme = useObiTheme();
17
+ return (
18
+ <View
19
+ style={{
20
+ flexDirection: 'row',
21
+ alignItems: 'center',
22
+ gap: theme.spacing.md,
23
+ minHeight: 48,
24
+ paddingHorizontal: theme.spacing.md,
25
+ paddingVertical: theme.spacing.sm,
26
+ borderRadius: theme.radius.md,
27
+ backgroundColor: theme.glass.surface,
28
+ borderWidth: 1,
29
+ borderColor: theme.glass.border,
30
+ ...shadow.glassSm,
31
+ }}
32
+ >
33
+ <AppText weight="800" style={{ flex: 1 }} numberOfLines={1}>{label}</AppText>
34
+ <View style={{ width: 132, flexShrink: 0 }}>
35
+ <TextField
36
+ keyboardType="decimal-pad"
37
+ value={value}
38
+ placeholder={placeholder}
39
+ onChangeText={onChangeText}
40
+ style={{ width: '100%', minHeight: 40, margin: 0, paddingHorizontal: theme.spacing.sm, textAlign: 'right', fontWeight: '700' }}
41
+ />
42
+ </View>
43
+ </View>
44
+ );
45
+ }
@@ -0,0 +1,5 @@
1
+ export { CheckoutDrawerFrame } from './CheckoutDrawerFrame';
2
+ export { CheckoutSelectList, type CheckoutSelectOption } from './CheckoutSelectList';
3
+ export { CheckoutPickerButton } from './CheckoutPickerButton';
4
+ export { PaymentAmountRow } from './PaymentAmountRow';
5
+ export { CheckoutSummary, type CheckoutSummaryItem } from './CheckoutSummary';
@@ -0,0 +1,20 @@
1
+ import React from 'react';
2
+ import { View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { useObiStyles } from '@obisoft/ui';
4
+ import { SectionBlock } from './SectionBlock';
5
+
6
+ export type ActionChipBarProps = {
7
+ title?: string;
8
+ children: React.ReactNode;
9
+ style?: StyleProp<ViewStyle>;
10
+ };
11
+
12
+ /** Wrap action button groups (export / PDF / print) under an optional section title. */
13
+ export function ActionChipBar({ title = 'Acciones', children, style }: ActionChipBarProps) {
14
+ const ui = useObiStyles();
15
+ return (
16
+ <SectionBlock title={title} style={style}>
17
+ <View style={ui.chipWrap}>{children}</View>
18
+ </SectionBlock>
19
+ );
20
+ }
@@ -0,0 +1,31 @@
1
+ import React from 'react';
2
+ import { View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { AppText, Card, useObiStyles } from '@obisoft/ui';
4
+
5
+ export type DetailAmountRow = {
6
+ label: string;
7
+ value: string;
8
+ strong?: boolean;
9
+ };
10
+
11
+ export type DetailRowsCardProps = {
12
+ rows: DetailAmountRow[];
13
+ style?: StyleProp<ViewStyle>;
14
+ };
15
+
16
+ /** Card of label/value rows used in cash reconciliation and similar summaries. */
17
+ export function DetailRowsCard({ rows, style }: DetailRowsCardProps) {
18
+ const ui = useObiStyles();
19
+ return (
20
+ <Card style={[ui.gapSm, style]}>
21
+ {rows.map(row => (
22
+ <View key={`${row.label}-${row.value}`} style={ui.formRow}>
23
+ <AppText muted={!row.strong} weight={row.strong ? '900' : undefined}>
24
+ {row.label}
25
+ </AppText>
26
+ <AppText weight="900">{row.value}</AppText>
27
+ </View>
28
+ ))}
29
+ </Card>
30
+ );
31
+ }
@@ -0,0 +1,20 @@
1
+ import React from 'react';
2
+ import { View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { AppText, useObiStyles } from '@obisoft/ui';
4
+
5
+ export type SectionBlockProps = {
6
+ title: string;
7
+ children: React.ReactNode;
8
+ style?: StyleProp<ViewStyle>;
9
+ };
10
+
11
+ /** Domain section chrome: uppercase label + content gap. */
12
+ export function SectionBlock({ title, children, style }: SectionBlockProps) {
13
+ const ui = useObiStyles();
14
+ return (
15
+ <View style={[ui.formSection, style]}>
16
+ <AppText style={ui.sectionLabel}>{title}</AppText>
17
+ {children}
18
+ </View>
19
+ );
20
+ }
@@ -0,0 +1,3 @@
1
+ export { SectionBlock, type SectionBlockProps } from './SectionBlock';
2
+ export { DetailRowsCard, type DetailRowsCardProps, type DetailAmountRow } from './DetailRowsCard';
3
+ export { ActionChipBar, type ActionChipBarProps } from './ActionChipBar';
@@ -0,0 +1,41 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import { Card, ObiText, useObiTheme } from '@obisoft/ui';
4
+
5
+ export type DashboardActivityItem = {
6
+ key: string;
7
+ title: string;
8
+ subtitle: string;
9
+ amount: string;
10
+ };
11
+
12
+ export function ActivityList({ items, emptyText = 'No hay actividad reciente para mostrar.' }: { items: DashboardActivityItem[]; emptyText?: string }) {
13
+ const theme = useObiTheme();
14
+ return (
15
+ <Card variant="glass" padding={0}>
16
+ {items.length ? items.map((item, index) => (
17
+ <View
18
+ key={item.key}
19
+ style={{
20
+ flexDirection: 'row',
21
+ alignItems: 'center',
22
+ gap: theme.spacing.md,
23
+ padding: theme.spacing.md,
24
+ borderBottomWidth: index < items.length - 1 ? 1 : 0,
25
+ borderBottomColor: theme.colors.separator,
26
+ }}
27
+ >
28
+ <View style={{ flex: 1, minWidth: 0 }}>
29
+ <ObiText weight="800" numberOfLines={1}>{item.title}</ObiText>
30
+ <ObiText variant="caption" muted numberOfLines={1}>{item.subtitle}</ObiText>
31
+ </View>
32
+ <ObiText weight="800">{item.amount}</ObiText>
33
+ </View>
34
+ )) : (
35
+ <View style={{ padding: theme.spacing.md }}>
36
+ <ObiText muted>{emptyText}</ObiText>
37
+ </View>
38
+ )}
39
+ </Card>
40
+ );
41
+ }
@@ -0,0 +1,34 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import { ObiText, shadow, useObiTheme } from '@obisoft/ui';
4
+
5
+ export type CompanyStatusItem = { label: string; value: string };
6
+
7
+ export function CompanyStatusGrid({ items }: { items: CompanyStatusItem[] }) {
8
+ const theme = useObiTheme();
9
+ return (
10
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }}>
11
+ {items.map(item => (
12
+ <View
13
+ key={item.label}
14
+ style={{
15
+ flex: 1,
16
+ minWidth: 120,
17
+ backgroundColor: theme.glass.surface,
18
+ borderRadius: theme.radius.lg,
19
+ padding: theme.spacing.md,
20
+ gap: theme.spacing.xs,
21
+ borderWidth: 1,
22
+ borderColor: theme.glass.border,
23
+ ...shadow.glassSm,
24
+ }}
25
+ >
26
+ <ObiText variant="caption" weight="700" muted style={{ letterSpacing: 0.8, textTransform: 'uppercase' }}>
27
+ {item.label}
28
+ </ObiText>
29
+ <ObiText weight="800">{item.value}</ObiText>
30
+ </View>
31
+ ))}
32
+ </View>
33
+ );
34
+ }
@@ -0,0 +1,41 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import { GlassSurface, IconButton, ObiText, useObiTheme } from '@obisoft/ui';
4
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
+
6
+ export function DashboardHeader({
7
+ greeting,
8
+ company,
9
+ onMenuPress,
10
+ right,
11
+ }: {
12
+ greeting: string;
13
+ company: string;
14
+ onMenuPress: () => void;
15
+ right?: React.ReactNode;
16
+ }) {
17
+ const theme = useObiTheme();
18
+ const insets = useSafeAreaInsets();
19
+ return (
20
+ <GlassSurface
21
+ variant="chrome"
22
+ style={{
23
+ flexDirection: 'row',
24
+ alignItems: 'center',
25
+ gap: theme.spacing.md,
26
+ paddingHorizontal: theme.spacing.lg,
27
+ paddingTop: insets.top + theme.spacing.md,
28
+ paddingBottom: theme.spacing.md,
29
+ borderBottomWidth: 1,
30
+ borderBottomColor: theme.glass.borderSoft,
31
+ }}
32
+ >
33
+ <IconButton icon="menu" accessibilityLabel="Abrir menú" onPress={onMenuPress} variant="ghost" />
34
+ <View style={{ flex: 1, minWidth: 0 }}>
35
+ <ObiText variant="subtitle" weight="900" numberOfLines={1}>{greeting}</ObiText>
36
+ <ObiText variant="caption" muted numberOfLines={1}>{company}</ObiText>
37
+ </View>
38
+ {right}
39
+ </GlassSurface>
40
+ );
41
+ }
@@ -0,0 +1,15 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import { ObiText, useObiTheme } from '@obisoft/ui';
4
+
5
+ export function DashboardSection({ title, children }: { title: string; children: React.ReactNode }) {
6
+ const theme = useObiTheme();
7
+ return (
8
+ <View style={{ gap: theme.spacing.md }}>
9
+ <ObiText variant="caption" weight="800" muted style={{ letterSpacing: 0.8, textTransform: 'uppercase' }}>
10
+ {title}
11
+ </ObiText>
12
+ {children}
13
+ </View>
14
+ );
15
+ }
@@ -0,0 +1,59 @@
1
+ import React from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+ import { Icon, type IconName, ObiText, shadow, useObiTheme } from '@obisoft/ui';
4
+
5
+ export type DashboardQuickAction = {
6
+ key: string;
7
+ label: string;
8
+ icon: IconName;
9
+ color: string;
10
+ onPress: () => void;
11
+ };
12
+
13
+ export function QuickActionGrid({ actions }: { actions: DashboardQuickAction[] }) {
14
+ const theme = useObiTheme();
15
+ return (
16
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.sm }}>
17
+ {actions.map(action => (
18
+ <Pressable
19
+ key={action.key}
20
+ accessibilityRole="button"
21
+ accessibilityLabel={action.label}
22
+ onPress={action.onPress}
23
+ style={({ pressed }) => ({
24
+ width: '31%',
25
+ minWidth: 96,
26
+ flexGrow: 1,
27
+ minHeight: 92,
28
+ alignItems: 'center',
29
+ justifyContent: 'center',
30
+ gap: theme.spacing.sm,
31
+ padding: theme.spacing.md,
32
+ borderRadius: theme.radius.lg,
33
+ backgroundColor: theme.glass.surface,
34
+ borderWidth: 1,
35
+ borderColor: theme.glass.border,
36
+ opacity: pressed ? 0.74 : 1,
37
+ ...shadow.glassSm,
38
+ })}
39
+ >
40
+ <View
41
+ style={{
42
+ width: 42,
43
+ height: 42,
44
+ borderRadius: theme.radius.md,
45
+ alignItems: 'center',
46
+ justifyContent: 'center',
47
+ backgroundColor: `${action.color}18`,
48
+ }}
49
+ >
50
+ <Icon name={action.icon} size={22} color={action.color} />
51
+ </View>
52
+ <ObiText variant="caption" weight="800" style={{ textAlign: 'center' }} numberOfLines={2}>
53
+ {action.label}
54
+ </ObiText>
55
+ </Pressable>
56
+ ))}
57
+ </View>
58
+ );
59
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { DashboardSection } from './dashboard/DashboardSection';
2
+ export { QuickActionGrid, type DashboardQuickAction } from './dashboard/QuickActionGrid';
3
+ export { ActivityList, type DashboardActivityItem } from './dashboard/ActivityList';
4
+ export { CompanyStatusGrid, type CompanyStatusItem } from './dashboard/CompanyStatusGrid';
5
+ export { DashboardHeader } from './dashboard/DashboardHeader';
6
+
7
+ export * from './pos';
8
+ export * from './checkout';
9
+ export * from './chrome';
10
+ export { useVentasStyles } from './styles/useVentasStyles';
@@ -0,0 +1,76 @@
1
+ import React from 'react';
2
+ import { Pressable, ScrollView } from 'react-native';
3
+ import { AppText, Icon, type IconName, shadow, useObiTheme } from '@obisoft/ui';
4
+
5
+ export type PosCategoryChip = {
6
+ key: string;
7
+ label: string;
8
+ icon?: IconName;
9
+ };
10
+
11
+ export function PosCategoryChips({
12
+ items,
13
+ selectedKey,
14
+ onSelect,
15
+ }: {
16
+ items: PosCategoryChip[];
17
+ selectedKey: string;
18
+ onSelect: (key: string) => void;
19
+ }) {
20
+ const theme = useObiTheme();
21
+ return (
22
+ <ScrollView
23
+ horizontal
24
+ showsHorizontalScrollIndicator={false}
25
+ keyboardShouldPersistTaps="handled"
26
+ style={{ flexGrow: 0, flexShrink: 0 }}
27
+ contentContainerStyle={{ gap: 6, alignItems: 'center' }}
28
+ >
29
+ {items.map(item => {
30
+ const selected = item.key === selectedKey;
31
+ return (
32
+ <Pressable
33
+ key={item.key}
34
+ accessibilityRole="button"
35
+ accessibilityState={{ selected }}
36
+ onPress={() => onSelect(item.key)}
37
+ style={({ pressed }) => [
38
+ {
39
+ flexDirection: 'row',
40
+ alignItems: 'center',
41
+ gap: 5,
42
+ paddingHorizontal: 10,
43
+ paddingVertical: 5,
44
+ borderRadius: theme.radius.pill,
45
+ backgroundColor: selected ? theme.colors.primary : theme.glass.surface,
46
+ borderWidth: 1,
47
+ borderColor: selected ? theme.colors.primary : theme.glass.border,
48
+ maxWidth: 180,
49
+ opacity: pressed ? 0.74 : 1,
50
+ },
51
+ selected ? null : shadow.glassSm,
52
+ ]}
53
+ >
54
+ {item.icon ? (
55
+ <Icon
56
+ name={item.icon}
57
+ size={14}
58
+ color={selected ? theme.colors.onPrimary : theme.colors.textMuted}
59
+ />
60
+ ) : null}
61
+ <AppText
62
+ numberOfLines={1}
63
+ weight="700"
64
+ style={{
65
+ fontSize: 12,
66
+ color: selected ? theme.colors.onPrimary : theme.colors.textMuted,
67
+ }}
68
+ >
69
+ {item.label}
70
+ </AppText>
71
+ </Pressable>
72
+ );
73
+ })}
74
+ </ScrollView>
75
+ );
76
+ }
@@ -0,0 +1,42 @@
1
+ import React from 'react';
2
+ import { Pressable } from 'react-native';
3
+ import { AppText, useObiTheme } from '@obisoft/ui';
4
+
5
+ export function PosCategoryTile({
6
+ name,
7
+ backgroundColor,
8
+ foregroundColor,
9
+ selected,
10
+ onPress,
11
+ }: {
12
+ name: string;
13
+ backgroundColor: string;
14
+ foregroundColor: string;
15
+ selected?: boolean;
16
+ onPress: () => void;
17
+ }) {
18
+ const theme = useObiTheme();
19
+ return (
20
+ <Pressable
21
+ accessibilityRole="button"
22
+ accessibilityState={{ selected: Boolean(selected) }}
23
+ onPress={onPress}
24
+ style={({ pressed }) => ({
25
+ flex: 1,
26
+ minHeight: 72,
27
+ borderRadius: 8,
28
+ padding: theme.spacing.sm,
29
+ justifyContent: 'center',
30
+ overflow: 'hidden',
31
+ backgroundColor,
32
+ borderWidth: selected ? 2 : 0,
33
+ borderColor: selected ? theme.colors.primary : 'transparent',
34
+ opacity: pressed ? 0.74 : 1,
35
+ })}
36
+ >
37
+ <AppText numberOfLines={3} style={{ fontSize: 16, fontWeight: '700', textAlign: 'center', color: foregroundColor }}>
38
+ {name}
39
+ </AppText>
40
+ </Pressable>
41
+ );
42
+ }
@@ -0,0 +1,30 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import { GlassSurface, shadow, useObiTheme } from '@obisoft/ui';
4
+
5
+ export function PosDock({ children }: { children: React.ReactNode }) {
6
+ const theme = useObiTheme();
7
+ return (
8
+ <GlassSurface
9
+ variant="dock"
10
+ style={{
11
+ flexDirection: 'row',
12
+ alignItems: 'stretch',
13
+ width: '100%',
14
+ gap: theme.spacing.sm,
15
+ paddingHorizontal: theme.spacing.sm,
16
+ paddingTop: theme.spacing.sm,
17
+ paddingBottom: theme.spacing.md,
18
+ backgroundColor: 'transparent',
19
+ zIndex: 20,
20
+ borderTopWidth: 1,
21
+ borderTopColor: theme.glass.borderSoft,
22
+ ...shadow.top,
23
+ }}
24
+ >
25
+ <View style={{ flexDirection: 'row', width: '100%', gap: theme.spacing.sm }} collapsable={false}>
26
+ {children}
27
+ </View>
28
+ </GlassSurface>
29
+ );
30
+ }
@@ -0,0 +1,124 @@
1
+ import React from 'react';
2
+ import { Image, Pressable, View } from 'react-native';
3
+ import LinearGradient from 'react-native-linear-gradient';
4
+ import { AppText, useObiTheme } from '@obisoft/ui';
5
+
6
+ export type PosProductTileProps = {
7
+ name: string;
8
+ priceLabel: string;
9
+ stockLabel: string;
10
+ backgroundColor: string;
11
+ foregroundColor: string;
12
+ mutedForegroundColor: string;
13
+ imageUri?: string | null;
14
+ initial?: string;
15
+ available?: boolean;
16
+ selected?: boolean;
17
+ onPress: () => void;
18
+ onImageError?: () => void;
19
+ };
20
+
21
+ export function PosProductTile({
22
+ name,
23
+ priceLabel,
24
+ stockLabel,
25
+ backgroundColor,
26
+ foregroundColor,
27
+ mutedForegroundColor,
28
+ imageUri,
29
+ initial = 'P',
30
+ available = true,
31
+ selected,
32
+ onPress,
33
+ onImageError,
34
+ }: PosProductTileProps) {
35
+ const theme = useObiTheme();
36
+ return (
37
+ <Pressable
38
+ accessibilityRole="button"
39
+ accessibilityState={{ disabled: !available, selected: Boolean(selected) }}
40
+ disabled={!available}
41
+ onPress={onPress}
42
+ style={({ pressed }) => ({
43
+ flex: 1,
44
+ minHeight: 72,
45
+ borderRadius: 8,
46
+ overflow: 'hidden',
47
+ justifyContent: 'flex-end',
48
+ backgroundColor,
49
+ borderWidth: selected ? 2 : 0,
50
+ borderColor: selected ? theme.colors.primary : 'transparent',
51
+ opacity: !available ? 0.42 : pressed ? 0.76 : 1,
52
+ })}
53
+ >
54
+ {imageUri ? (
55
+ <>
56
+ <Image
57
+ source={{ uri: imageUri }}
58
+ resizeMode="cover"
59
+ pointerEvents="none"
60
+ style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 }}
61
+ onError={onImageError}
62
+ />
63
+ <LinearGradient
64
+ pointerEvents="none"
65
+ colors={['rgba(15,23,42,0)', 'rgba(15,23,42,0.18)', 'rgba(15,23,42,0.72)', 'rgba(15,23,42,0.92)']}
66
+ locations={[0, 0.34, 0.72, 1]}
67
+ style={{ position: 'absolute', left: 0, right: 0, top: '24%', bottom: 0 }}
68
+ />
69
+ </>
70
+ ) : (
71
+ <View
72
+ pointerEvents="none"
73
+ style={{
74
+ position: 'absolute',
75
+ left: 0,
76
+ right: 0,
77
+ top: 0,
78
+ bottom: 0,
79
+ alignItems: 'center',
80
+ justifyContent: 'center',
81
+ backgroundColor,
82
+ }}
83
+ >
84
+ <AppText style={{ fontSize: 22, fontWeight: '800', color: foregroundColor }}>{initial}</AppText>
85
+ </View>
86
+ )}
87
+
88
+ <View style={{ zIndex: 2, paddingHorizontal: 6, paddingTop: 4, paddingBottom: 5, gap: 2 }}>
89
+ <AppText
90
+ numberOfLines={1}
91
+ style={{
92
+ fontSize: 11,
93
+ fontWeight: '700',
94
+ lineHeight: 13,
95
+ color: foregroundColor,
96
+ textShadowColor: imageUri ? 'rgba(15,23,42,0.45)' : 'transparent',
97
+ textShadowOffset: { width: 0, height: 1 },
98
+ textShadowRadius: imageUri ? 2 : 0,
99
+ }}
100
+ >
101
+ {name}
102
+ </AppText>
103
+ <View style={{ flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'space-between', gap: theme.spacing.sm }}>
104
+ <View style={{ flex: 1, minWidth: 0 }}>
105
+ <AppText style={{ fontSize: 8, fontWeight: '700', letterSpacing: 0.3, textTransform: 'uppercase', color: mutedForegroundColor }}>
106
+ Precio
107
+ </AppText>
108
+ <AppText numberOfLines={1} style={{ fontSize: 11, fontWeight: '800', lineHeight: 13, color: foregroundColor }}>
109
+ {priceLabel}
110
+ </AppText>
111
+ </View>
112
+ <View style={{ alignItems: 'flex-end', minWidth: 0 }}>
113
+ <AppText style={{ fontSize: 8, fontWeight: '700', letterSpacing: 0.3, textTransform: 'uppercase', color: mutedForegroundColor }}>
114
+ Existencia
115
+ </AppText>
116
+ <AppText numberOfLines={1} style={{ fontSize: 11, fontWeight: '800', lineHeight: 13, color: foregroundColor }}>
117
+ {stockLabel}
118
+ </AppText>
119
+ </View>
120
+ </View>
121
+ </View>
122
+ </Pressable>
123
+ );
124
+ }
@@ -0,0 +1,67 @@
1
+ import React from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+ import { AppText, type IconName, Icon, useObiTheme } from '@obisoft/ui';
4
+
5
+ export type PosTabItem = {
6
+ key: string;
7
+ label: string;
8
+ selected?: boolean;
9
+ icon?: IconName;
10
+ onPress: () => void;
11
+ };
12
+
13
+ export function PosTabBar({ items }: { items: PosTabItem[] }) {
14
+ const theme = useObiTheme();
15
+ return (
16
+ <View
17
+ style={{
18
+ flexDirection: 'row',
19
+ backgroundColor: theme.glass.chrome,
20
+ borderBottomWidth: 1,
21
+ borderBottomColor: theme.glass.borderSoft,
22
+ }}
23
+ >
24
+ {items.map(item => (
25
+ <Pressable
26
+ key={item.key}
27
+ accessibilityRole="tab"
28
+ accessibilityState={{ selected: Boolean(item.selected) }}
29
+ onPress={item.onPress}
30
+ style={({ pressed }) => ({
31
+ flex: 1,
32
+ minHeight: 44,
33
+ paddingVertical: theme.spacing.sm,
34
+ paddingHorizontal: theme.spacing.xs,
35
+ alignItems: 'center',
36
+ justifyContent: 'center',
37
+ flexDirection: 'row',
38
+ gap: 6,
39
+ borderBottomWidth: 2,
40
+ borderBottomColor: item.selected ? theme.colors.primary : 'transparent',
41
+ opacity: pressed ? 0.7 : 1,
42
+ })}
43
+ >
44
+ {item.icon ? (
45
+ <Icon
46
+ name={item.icon}
47
+ size={15}
48
+ color={item.selected ? theme.colors.primary : theme.colors.textMuted}
49
+ />
50
+ ) : null}
51
+ <AppText
52
+ numberOfLines={1}
53
+ style={{
54
+ fontSize: 12,
55
+ fontWeight: '800',
56
+ letterSpacing: 0.7,
57
+ color: item.selected ? theme.colors.primary : theme.colors.textMuted,
58
+ textTransform: 'uppercase',
59
+ }}
60
+ >
61
+ {item.label}
62
+ </AppText>
63
+ </Pressable>
64
+ ))}
65
+ </View>
66
+ );
67
+ }
@@ -0,0 +1,150 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Pressable, ScrollView, Vibration, View } from 'react-native';
3
+ import { AppText, Icon, useObiTheme } from '@obisoft/ui';
4
+
5
+ export type PosTicketItem = {
6
+ id: string;
7
+ quantity: number;
8
+ title: string;
9
+ };
10
+
11
+ export function PosTicket({
12
+ items,
13
+ totalLabel,
14
+ taxLabel,
15
+ emptyLabel = 'Toca un producto para agregarlo',
16
+ onEdit,
17
+ onRemove,
18
+ }: {
19
+ items: PosTicketItem[];
20
+ totalLabel: string;
21
+ taxLabel?: string;
22
+ emptyLabel?: string;
23
+ onEdit?: (id: string) => void;
24
+ onRemove?: (id: string) => void;
25
+ }) {
26
+ const theme = useObiTheme();
27
+ const [menuId, setMenuId] = useState<string | null>(null);
28
+
29
+ useEffect(() => {
30
+ if (menuId && !items.some(item => item.id === menuId)) setMenuId(null);
31
+ }, [items, menuId]);
32
+
33
+ useEffect(() => {
34
+ if (!menuId) return;
35
+ const timer = setTimeout(() => setMenuId(null), 3500);
36
+ return () => clearTimeout(timer);
37
+ }, [menuId]);
38
+
39
+ return (
40
+ <View
41
+ style={{
42
+ flexDirection: 'row',
43
+ alignItems: 'flex-start',
44
+ backgroundColor: theme.glass.chrome,
45
+ paddingHorizontal: theme.spacing.lg,
46
+ paddingVertical: theme.spacing.md,
47
+ minHeight: 116,
48
+ maxHeight: 164,
49
+ gap: theme.spacing.md,
50
+ borderBottomWidth: 1,
51
+ borderBottomColor: theme.colors.separator,
52
+ }}
53
+ >
54
+ <ScrollView
55
+ style={{ flex: 1, maxHeight: 140 }}
56
+ contentContainerStyle={{ gap: theme.spacing.xs }}
57
+ showsVerticalScrollIndicator={false}
58
+ keyboardShouldPersistTaps="handled"
59
+ onScrollBeginDrag={() => setMenuId(null)}
60
+ >
61
+ {items.length ? items.map(item => {
62
+ const menuOpen = menuId === item.id;
63
+ return (
64
+ <View key={item.id} style={{ flexDirection: 'row', alignItems: 'center', gap: 6, minHeight: 28 }}>
65
+ <Pressable
66
+ accessibilityRole="button"
67
+ accessibilityLabel={`${item.quantity} × ${item.title}`}
68
+ accessibilityHint="Toca para editar. Mantén pulsado para eliminar."
69
+ delayLongPress={320}
70
+ onPress={() => {
71
+ if (menuOpen) {
72
+ setMenuId(null);
73
+ return;
74
+ }
75
+ onEdit?.(item.id);
76
+ }}
77
+ onLongPress={() => {
78
+ if (!onRemove) return;
79
+ Vibration.vibrate(12);
80
+ setMenuId(item.id);
81
+ }}
82
+ style={({ pressed }) => ({ flex: 1, minWidth: 0, paddingVertical: 3, opacity: pressed ? 0.7 : 1 })}
83
+ >
84
+ <AppText
85
+ numberOfLines={1}
86
+ style={{
87
+ fontSize: 16,
88
+ fontWeight: '800',
89
+ color: theme.colors.primary,
90
+ textDecorationLine: onEdit ? 'underline' : 'none',
91
+ textDecorationColor: theme.colors.primary,
92
+ }}
93
+ >
94
+ {item.quantity} × {item.title}
95
+ </AppText>
96
+ </Pressable>
97
+ <View style={{ width: 36, height: 36, alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
98
+ {menuOpen && onRemove ? (
99
+ <Pressable
100
+ accessibilityRole="button"
101
+ accessibilityLabel={`Eliminar ${item.title}`}
102
+ hitSlop={6}
103
+ onPress={() => {
104
+ onRemove(item.id);
105
+ setMenuId(null);
106
+ }}
107
+ style={({ pressed }) => ({
108
+ width: 36,
109
+ height: 36,
110
+ borderRadius: theme.radius.sm,
111
+ alignItems: 'center',
112
+ justifyContent: 'center',
113
+ backgroundColor: theme.colors.dangerSoft,
114
+ borderWidth: 1,
115
+ borderColor: theme.colors.dangerBorder,
116
+ opacity: pressed ? 0.72 : 1,
117
+ })}
118
+ >
119
+ <Icon name="trash-2" size={16} color={theme.colors.danger} />
120
+ </Pressable>
121
+ ) : onEdit ? (
122
+ <Icon name="pencil" size={14} color={theme.colors.textMuted} />
123
+ ) : null}
124
+ </View>
125
+ </View>
126
+ );
127
+ }) : (
128
+ <AppText muted style={{ paddingTop: theme.spacing.xs }}>{emptyLabel}</AppText>
129
+ )}
130
+ </ScrollView>
131
+ <View style={{ minWidth: 118, alignItems: 'flex-end', justifyContent: 'flex-start' }}>
132
+ <AppText
133
+ numberOfLines={1}
134
+ adjustsFontSizeToFit
135
+ minimumFontScale={0.7}
136
+ style={{
137
+ fontSize: 46,
138
+ lineHeight: 50,
139
+ fontWeight: '800',
140
+ letterSpacing: -1.4,
141
+ color: theme.colors.text,
142
+ }}
143
+ >
144
+ {totalLabel}
145
+ </AppText>
146
+ {taxLabel ? <AppText muted style={{ marginTop: 2, fontSize: 11, fontWeight: '700' }}>{taxLabel}</AppText> : null}
147
+ </View>
148
+ </View>
149
+ );
150
+ }
@@ -0,0 +1,60 @@
1
+ import React from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+ import { AppText, Icon, shadow, useObiTheme } from '@obisoft/ui';
4
+
5
+ export function QuantityStepper({
6
+ value,
7
+ onDecrement,
8
+ onIncrement,
9
+ disabled,
10
+ decrementDisabled,
11
+ incrementDisabled,
12
+ }: {
13
+ value: string | number;
14
+ onDecrement: () => void;
15
+ onIncrement: () => void;
16
+ disabled?: boolean;
17
+ decrementDisabled?: boolean;
18
+ incrementDisabled?: boolean;
19
+ }) {
20
+ const theme = useObiTheme();
21
+ const makeButtonStyle = (buttonDisabled: boolean) => ({ pressed }: { pressed: boolean }) => ({
22
+ width: 36,
23
+ height: 36,
24
+ borderRadius: theme.radius.sm,
25
+ alignItems: 'center' as const,
26
+ justifyContent: 'center' as const,
27
+ backgroundColor: theme.colors.surfaceMuted,
28
+ opacity: buttonDisabled ? 0.35 : pressed ? 0.72 : 1,
29
+ });
30
+ const decDisabled = Boolean(disabled || decrementDisabled);
31
+ const incDisabled = Boolean(disabled || incrementDisabled);
32
+
33
+ return (
34
+ <View
35
+ style={{
36
+ flexDirection: 'row',
37
+ alignItems: 'center',
38
+ gap: theme.spacing.sm,
39
+ minHeight: 48,
40
+ borderWidth: 1,
41
+ borderColor: theme.colors.border,
42
+ borderRadius: theme.radius.md,
43
+ backgroundColor: theme.glass.input,
44
+ paddingHorizontal: theme.spacing.sm,
45
+ opacity: disabled ? 0.55 : 1,
46
+ ...shadow.xs,
47
+ }}
48
+ >
49
+ <Pressable disabled={decDisabled} onPress={onDecrement} style={makeButtonStyle(decDisabled)} accessibilityLabel="Disminuir cantidad">
50
+ <Icon name="minus" size={16} color={theme.colors.text} />
51
+ </Pressable>
52
+ <AppText style={{ flex: 1, textAlign: 'center', fontSize: 18, fontWeight: '800', color: theme.colors.text }}>
53
+ {value}
54
+ </AppText>
55
+ <Pressable disabled={incDisabled} onPress={onIncrement} style={makeButtonStyle(incDisabled)} accessibilityLabel="Aumentar cantidad">
56
+ <Icon name="plus" size={16} color={theme.colors.text} />
57
+ </Pressable>
58
+ </View>
59
+ );
60
+ }
@@ -0,0 +1,7 @@
1
+ export { PosTabBar, type PosTabItem } from './PosTabBar';
2
+ export { PosCategoryChips, type PosCategoryChip } from './PosCategoryChips';
3
+ export { PosProductTile, type PosProductTileProps } from './PosProductTile';
4
+ export { PosCategoryTile } from './PosCategoryTile';
5
+ export { PosDock } from './PosDock';
6
+ export { PosTicket, type PosTicketItem } from './PosTicket';
7
+ export { QuantityStepper } from './QuantityStepper';
@@ -0,0 +1,34 @@
1
+ import { useMemo } from 'react';
2
+ import { StyleSheet } from 'react-native';
3
+ import { useObiTheme } from '@obisoft/ui';
4
+
5
+ export function useVentasStyles() {
6
+ const theme = useObiTheme();
7
+ return useMemo(() => StyleSheet.create({
8
+ cartEditorLabel: {
9
+ fontSize: 11,
10
+ fontWeight: '700',
11
+ color: theme.colors.textMuted,
12
+ letterSpacing: 0.4,
13
+ textTransform: 'uppercase',
14
+ },
15
+ cartEditorTotalRow: {
16
+ flexDirection: 'row',
17
+ alignItems: 'baseline',
18
+ justifyContent: 'space-between',
19
+ paddingVertical: theme.spacing.sm,
20
+ },
21
+ cartEditorTotalValue: {
22
+ fontSize: 22,
23
+ fontWeight: '800',
24
+ color: theme.colors.text,
25
+ },
26
+ posRoot: { flex: 1, backgroundColor: 'transparent' },
27
+ posCatalogHeader: {
28
+ paddingHorizontal: theme.spacing.sm,
29
+ paddingTop: theme.spacing.sm,
30
+ paddingBottom: 4,
31
+ gap: 6,
32
+ },
33
+ }), [theme]);
34
+ }