first commit
This commit is contained in:
parent
e911c3c835
commit
bcaa1fbdaa
510
.cursor/rules/coding_statndard.mdc
Normal file
510
.cursor/rules/coding_statndard.mdc
Normal file
@ -0,0 +1,510 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
# SME Centralized Reporting System - Coding Standards & Development Patterns
|
||||||
|
|
||||||
|
## OVERVIEW
|
||||||
|
This document defines coding standards, development patterns, and implementation guidelines for the SME Centralized Reporting System React Native application.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. CODING STANDARDS & CONVENTIONS
|
||||||
|
|
||||||
|
### File Naming Conventions
|
||||||
|
- **Components**: PascalCase (`ZohoProjectsDashboardScreen.tsx`)
|
||||||
|
- **Services**: camelCase (`zohoProjectsAPI.ts`)
|
||||||
|
- **Store files**: camelCase (`zohoProjectsSlice.ts`)
|
||||||
|
- **Types**: PascalCase (`ProfileTypes.ts`)
|
||||||
|
- **Constants**: UPPER_SNAKE_CASE (`API_ENDPOINTS.ts`)
|
||||||
|
- **Utilities**: camelCase (`dateUtils.ts`)
|
||||||
|
- **Hooks**: camelCase with 'use' prefix (`useZohoProjectsData.ts`)
|
||||||
|
|
||||||
|
### Directory Naming Conventions
|
||||||
|
- **Modules**: camelCase (`auth`, `zohoProjects`, `hr`, `profile`)
|
||||||
|
- **Components**: camelCase (`widgets`, `forms`, `screens`)
|
||||||
|
- **Services**: camelCase (`integrations`, `analytics`)
|
||||||
|
|
||||||
|
### Module Directory Rules
|
||||||
|
- Place `screens`, `widgets`, and `forms` inside `components/` within each module.
|
||||||
|
- Keep `components/` for UI-only code. Do not add `utils` or `constants` under modules.
|
||||||
|
- Share cross-cutting helpers via `src/shared/utils` and global constants via `src/shared/constants`.
|
||||||
|
|
||||||
|
### Variable & Function Naming
|
||||||
|
```typescript
|
||||||
|
// Variables: camelCase
|
||||||
|
const projectList = [];
|
||||||
|
const isLoading = false;
|
||||||
|
const userPreferences = {};
|
||||||
|
|
||||||
|
// Functions: camelCase with descriptive verbs
|
||||||
|
const fetchZohoProjectsData = () => {};
|
||||||
|
const calculateOpenTasks = () => {};
|
||||||
|
const handleSubmitForm = () => {};
|
||||||
|
|
||||||
|
// Constants: UPPER_SNAKE_CASE
|
||||||
|
const API_BASE_URL = '';
|
||||||
|
const MAX_RETRY_ATTEMPTS = 3;
|
||||||
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
// Interfaces/Types: PascalCase
|
||||||
|
interface ZohoProject {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoadingState = 'idle' | 'loading' | 'succeeded' | 'failed';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Inline Commenting Guidelines
|
||||||
|
- Always add clear, concise inline comments for non-obvious logic, complex conditions, side-effects, and business rules.
|
||||||
|
- Prefer sentence-case, developer-oriented comments that explain "why" not just "what".
|
||||||
|
- Document assumptions, edge cases, units, and non-trivial parameter/return semantics.
|
||||||
|
- Keep comments up-to-date when changing code; remove stale comments immediately.
|
||||||
|
- For components, briefly annotate major sections (effects, handlers, conditional rendering) and tricky UI logic.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```typescript
|
||||||
|
// Fetch projects on mount and when filters change to ensure fresh data
|
||||||
|
useEffect(() => {
|
||||||
|
// Guard: avoid duplicate fetch while loading
|
||||||
|
if (loading) return;
|
||||||
|
dispatch(fetchZohoProjects());
|
||||||
|
}, [dispatch, loading, filters]);
|
||||||
|
|
||||||
|
// Compute SLA breach percentage (0-100). Includes only active tasks.
|
||||||
|
const breachRate = useMemo(() => {
|
||||||
|
if (totalTasks === 0) return 0; // Avoid divide-by-zero
|
||||||
|
return Math.round((breachedTasks / totalTasks) * 100);
|
||||||
|
}, [breachedTasks, totalTasks]);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. COMPONENT STRUCTURE TEMPLATES
|
||||||
|
|
||||||
|
### Screen Component Template
|
||||||
|
```typescript
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
ScrollView,
|
||||||
|
RefreshControl,
|
||||||
|
StyleSheet,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
|
import Icon from 'react-native-vector-icons/MaterialIcons';
|
||||||
|
|
||||||
|
// Shared imports
|
||||||
|
import { Container, LoadingSpinner, ErrorState } from '@/shared/components/ui';
|
||||||
|
import { COLORS, FONTS, SPACING } from '@/shared/styles/theme';
|
||||||
|
|
||||||
|
// Module imports
|
||||||
|
import { selectZohoProjects, selectZohoProjectsLoading } from '../store/selectors';
|
||||||
|
import { fetchZohoProjects } from '../store/zohoProjectsSlice';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { ZohoProjectsScreenProps } from '../types';
|
||||||
|
|
||||||
|
const ZohoProjectsDashboardScreen: React.FC<ZohoProjectsScreenProps> = ({
|
||||||
|
navigation,
|
||||||
|
route,
|
||||||
|
}) => {
|
||||||
|
// State
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// Redux
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const data = useSelector(selectZohoProjects);
|
||||||
|
const loading = useSelector(selectZohoProjectsLoading);
|
||||||
|
|
||||||
|
// Effects
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchZohoProjects());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await dispatch(fetchZohoProjects()).unwrap();
|
||||||
|
setRefreshing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNavigateToDetails = (id: string) => {
|
||||||
|
navigation.navigate('ProjectDetails', { id });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading && !data.length) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return <ErrorState onRetry={() => dispatch(fetchZohoProjects())} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.container}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Zoho Projects</Text>
|
||||||
|
<Icon
|
||||||
|
name="insights"
|
||||||
|
size={24}
|
||||||
|
color={COLORS.primary}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Dashboard content */}
|
||||||
|
<View style={styles.content}>
|
||||||
|
{/* Widgets and components */}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: COLORS.background,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: SPACING.md,
|
||||||
|
backgroundColor: COLORS.surface,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontFamily: FONTS.bold,
|
||||||
|
color: COLORS.text,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: SPACING.md,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ZohoProjectsDashboardScreen;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Widget Component Template
|
||||||
|
```typescript
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
import Icon from 'react-native-vector-icons/MaterialIcons';
|
||||||
|
import { COLORS, FONTS, SPACING, SHADOWS } from '@/shared/styles/theme';
|
||||||
|
|
||||||
|
interface RevenueChartProps {
|
||||||
|
data: RevenueData[];
|
||||||
|
period: 'month' | 'quarter' | 'year';
|
||||||
|
onPeriodChange: (period: 'month' | 'quarter' | 'year') => void;
|
||||||
|
onPress?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RevenueChart: React.FC<RevenueChartProps> = ({
|
||||||
|
data,
|
||||||
|
period,
|
||||||
|
onPeriodChange,
|
||||||
|
onPress,
|
||||||
|
}) => {
|
||||||
|
const totalRevenue = data.reduce((sum, item) => sum + item.amount, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.container}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.titleContainer}>
|
||||||
|
<Icon name="trending-up" size={20} color={COLORS.primary} />
|
||||||
|
<Text style={styles.title}>Revenue Analytics</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.periodSelector}>
|
||||||
|
{['month', 'quarter', 'year'].map((p) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={p}
|
||||||
|
style={[
|
||||||
|
styles.periodButton,
|
||||||
|
period === p && styles.periodButtonActive,
|
||||||
|
]}
|
||||||
|
onPress={() => onPeriodChange(p as any)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.periodText,
|
||||||
|
period === p && styles.periodTextActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{p.charAt(0).toUpperCase() + p.slice(1)}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.amount}>
|
||||||
|
${totalRevenue.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.subtitle}>Total Revenue</Text>
|
||||||
|
|
||||||
|
{/* Chart component would go here */}
|
||||||
|
<View style={styles.chartPlaceholder}>
|
||||||
|
<Text style={styles.chartText}>Chart Component</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: COLORS.surface,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: SPACING.md,
|
||||||
|
...SHADOWS.medium,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: SPACING.md,
|
||||||
|
},
|
||||||
|
titleContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: FONTS.medium,
|
||||||
|
color: COLORS.text,
|
||||||
|
marginLeft: SPACING.xs,
|
||||||
|
},
|
||||||
|
periodSelector: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: COLORS.background,
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: 2,
|
||||||
|
},
|
||||||
|
periodButton: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
periodButtonActive: {
|
||||||
|
backgroundColor: COLORS.primary,
|
||||||
|
},
|
||||||
|
periodText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: FONTS.regular,
|
||||||
|
color: COLORS.textLight,
|
||||||
|
},
|
||||||
|
periodTextActive: {
|
||||||
|
color: COLORS.surface,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
},
|
||||||
|
amount: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontFamily: FONTS.bold,
|
||||||
|
color: COLORS.text,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: FONTS.regular,
|
||||||
|
color: COLORS.textLight,
|
||||||
|
marginBottom: SPACING.md,
|
||||||
|
},
|
||||||
|
chartPlaceholder: {
|
||||||
|
width: '100%',
|
||||||
|
height: 120,
|
||||||
|
backgroundColor: COLORS.background,
|
||||||
|
borderRadius: 8,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
chartText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: FONTS.regular,
|
||||||
|
color: COLORS.textLight,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default RevenueChart;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. REDUX STATE MANAGEMENT PATTERNS
|
||||||
|
|
||||||
|
### Store Configuration
|
||||||
|
```typescript
|
||||||
|
// src/store/store.ts
|
||||||
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
|
import { persistStore, persistReducer } from 'redux-persist';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { combineReducers } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
// Import slices
|
||||||
|
import authSlice from '@/modules/auth/store/authSlice';
|
||||||
|
import hrSlice from '@/modules/hr/store/hrSlice';
|
||||||
|
import zohoProjectsSlice from '@/modules/zohoProjects/store/zohoProjectsSlice';
|
||||||
|
import profileSlice from '@/modules/profile/store/profileSlice';
|
||||||
|
import uiSlice from '@/shared/store/uiSlice';
|
||||||
|
|
||||||
|
const rootReducer = combineReducers({
|
||||||
|
auth: authSlice.reducer,
|
||||||
|
hr: hrSlice.reducer,
|
||||||
|
zohoProjects: zohoProjectsSlice.reducer,
|
||||||
|
profile: profileSlice.reducer,
|
||||||
|
ui: uiSlice.reducer,
|
||||||
|
});
|
||||||
|
|
||||||
|
const persistConfig = {
|
||||||
|
key: 'root',
|
||||||
|
storage: AsyncStorage,
|
||||||
|
whitelist: ['auth', 'hr', 'zohoProjects', 'profile'],
|
||||||
|
blacklist: ['ui'], // Don't persist UI state
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistedReducer = persistReducer(persistConfig, rootReducer);
|
||||||
|
|
||||||
|
export const store = configureStore({
|
||||||
|
reducer: persistedReducer,
|
||||||
|
middleware: (getDefaultMiddleware) =>
|
||||||
|
getDefaultMiddleware({
|
||||||
|
serializableCheck: {
|
||||||
|
ignoredActions: ['persist/FLUSH', 'persist/REHYDRATE', 'persist/PAUSE', 'persist/PERSIST', 'persist/PURGE', 'persist/REGISTER'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const persistor = persistStore(store);
|
||||||
|
|
||||||
|
export type RootState = ReturnType<typeof store.getState>;
|
||||||
|
export type AppDispatch = typeof store.dispatch;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Redux Slice Template (Zoho Projects example)
|
||||||
|
```typescript
|
||||||
|
// src/modules/zohoProjects/store/zohoProjectsSlice.ts
|
||||||
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import { zohoProjectsAPI } from '../services/zohoProjectsAPI';
|
||||||
|
import type { ZohoProjectsState, ZohoProject, ZohoProjectsFilters } from './types';
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
const initialState: ZohoProjectsState = {
|
||||||
|
projects: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
filters: { owner: 'all', status: 'all' },
|
||||||
|
lastUpdated: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Async thunks
|
||||||
|
export const fetchZohoProjects = createAsyncThunk(
|
||||||
|
'zohoProjects/fetch',
|
||||||
|
async () => {
|
||||||
|
const response = await zohoProjectsAPI.getProjects();
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const updateZohoProject = createAsyncThunk(
|
||||||
|
'zohoProjects/update',
|
||||||
|
async (project: Partial<ZohoProject>) => {
|
||||||
|
const response = await zohoProjectsAPI.updateProject(project);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Slice
|
||||||
|
const zohoProjectsSlice = createSlice({
|
||||||
|
name: 'zohoProjects',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setFilters: (state, action: PayloadAction<Partial<ZohoProjectsFilters>>) => {
|
||||||
|
state.filters = { ...state.filters, ...action.payload };
|
||||||
|
},
|
||||||
|
clearError: (state) => {
|
||||||
|
state.error = null;
|
||||||
|
},
|
||||||
|
resetState: () => initialState,
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
// add async case reducers here
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default zohoProjectsSlice;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Redux Slice Template
|
||||||
|
```typescript
|
||||||
|
// src/modules/finance/store/financeSlice.ts
|
||||||
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import { financeAPI } from '../services/financeAPI';
|
||||||
|
import type { FinanceState, FinanceData, FinanceFilters } from './types';
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
const initialState: FinanceState = {
|
||||||
|
data: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
filters: {
|
||||||
|
period: 'month',
|
||||||
|
category: 'all',
|
||||||
|
dateRange: null,
|
||||||
|
},
|
||||||
|
lastUpdated: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Async thunks
|
||||||
|
export const fetchFinanceData = createAsyncThunk(
|
||||||
|
'finance/fetchData',
|
||||||
|
async (params?: { refresh?: boolean }) => {
|
||||||
|
const response = await financeAPI.getData(params);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const updateFinanceData = createAsyncThunk(
|
||||||
|
'finance/updateData',
|
||||||
|
async (data: Partial<FinanceData>) => {
|
||||||
|
const response = await financeAPI.updateData(data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Slice
|
||||||
|
const financeSlice = createSlice({
|
||||||
|
name: 'finance',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setFilters: (state, action: PayloadAction<Partial<FinanceFilters>>) => {
|
||||||
|
state.filters = { ...state.filters, ...action.payload };
|
||||||
|
},
|
||||||
|
clearError: (state) => {
|
||||||
|
state.error = null;
|
||||||
|
},
|
||||||
|
resetState: () => initialState,
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
// Fetch data cases
|
||||||
|
.addCase(fetchFinanceData
|
||||||
522
.cursor/rules/project_structure.mdc
Normal file
522
.cursor/rules/project_structure.mdc
Normal file
@ -0,0 +1,522 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
# SME Centralized Reporting System - Project Architecture & Structure
|
||||||
|
|
||||||
|
## PROJECT OVERVIEW
|
||||||
|
**Application**: Centralized Reporting Dashboard for SMEs
|
||||||
|
**Platform**: React Native (Cross-platform iOS/Android)
|
||||||
|
**Architecture**: Modular Component-Based Architecture
|
||||||
|
**State Management**: Redux + Redux Persist + AsyncStorage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. PROJECT STRUCTURE
|
||||||
|
|
||||||
|
### Root Directory Structure
|
||||||
|
```
|
||||||
|
CentralizedReportingSystem/
|
||||||
|
├── src/
|
||||||
|
│ ├── modules/ # Core business modules
|
||||||
|
│ ├── shared/ # Shared components and utilities
|
||||||
|
│ ├── navigation/ # Navigation configuration
|
||||||
|
│ ├── store/ # Redux store configuration
|
||||||
|
│ ├── services/ # API services and integrations
|
||||||
|
│ ├── assets/ # Static assets
|
||||||
|
│ └── types/ # TypeScript type definitions
|
||||||
|
├── android/ # Android specific files
|
||||||
|
├── ios/ # iOS specific files
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Core Module Structure (Each module follows this pattern)
|
||||||
|
```
|
||||||
|
src/modules/[moduleName]/
|
||||||
|
├── screens/ # Screen components (module routes)
|
||||||
|
├── components/ # Reusable UI for this module (no screens)
|
||||||
|
│ ├── widgets/ # Dashboard/widgets
|
||||||
|
│ └── forms/ # Form components
|
||||||
|
├── navigation/ # Module-level stack navigator(s)
|
||||||
|
│ └── [ModuleName]Navigator.tsx
|
||||||
|
├── services/ # Module-specific API services
|
||||||
|
├── store/ # Redux slices and actions
|
||||||
|
│ ├── slice.ts # Redux toolkit slice
|
||||||
|
│ ├── selectors.ts # State selectors
|
||||||
|
│ └── types.ts # Module-specific types
|
||||||
|
└── index.ts # Module exports (optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Shared Directory Structure
|
||||||
|
```
|
||||||
|
src/shared/
|
||||||
|
├── components/ # Reusable UI components
|
||||||
|
│ ├── charts/ # Chart components
|
||||||
|
│ ├── forms/ # Form controls
|
||||||
|
│ ├── layout/ # Layout components
|
||||||
|
│ └── ui/ # Basic UI components
|
||||||
|
├── hooks/ # Custom React hooks
|
||||||
|
├── utils/ # Utility functions
|
||||||
|
├── constants/ # Global constants
|
||||||
|
├── styles/ # Global styles and themes
|
||||||
|
└── types/ # Shared type definitions
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. CORE MODULES DEFINITION
|
||||||
|
|
||||||
|
### Module 1: Authentication (`auth`)
|
||||||
|
```
|
||||||
|
src/modules/auth/
|
||||||
|
├── screens/
|
||||||
|
│ ├── LoginScreen.tsx
|
||||||
|
│ ├── RegisterScreen.tsx
|
||||||
|
│ └── ForgotPasswordScreen.tsx
|
||||||
|
├── components/
|
||||||
|
│ └── widgets/
|
||||||
|
├── navigation/
|
||||||
|
│ └── AuthNavigator.tsx # Stack with Login, Register, ForgotPassword
|
||||||
|
├── services/
|
||||||
|
│ ├── authAPI.ts
|
||||||
|
│ └── integrations/
|
||||||
|
└── store/
|
||||||
|
├── authSlice.ts
|
||||||
|
├── selectors.ts
|
||||||
|
└── types.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module 2: HR (`hr`)
|
||||||
|
```
|
||||||
|
src/modules/hr/
|
||||||
|
├── screens/
|
||||||
|
│ ├── HRDashboardScreen.tsx
|
||||||
|
│ ├── EmployeeMetricsScreen.tsx
|
||||||
|
│ ├── AttendanceScreen.tsx
|
||||||
|
│ └── RecruitmentScreen.tsx
|
||||||
|
├── components/
|
||||||
|
│ ├── widgets/
|
||||||
|
│ │ ├── EmployeeStatsWidget.tsx
|
||||||
|
│ │ ├── AttendanceChart.tsx
|
||||||
|
│ │ └── PerformanceWidget.tsx
|
||||||
|
│ └── forms/
|
||||||
|
│ └── EmployeeForm.tsx
|
||||||
|
├── navigation/
|
||||||
|
│ └── HRNavigator.tsx # Stack with HRDashboard, EmployeeMetrics, Attendance, Recruitment
|
||||||
|
├── services/
|
||||||
|
│ ├── hrAPI.ts
|
||||||
|
│ └── integrations/
|
||||||
|
│ ├── zohoPeopleService.ts
|
||||||
|
│ ├── bambooHRService.ts
|
||||||
|
│ └── kekaService.ts
|
||||||
|
└── store/
|
||||||
|
├── hrSlice.ts
|
||||||
|
├── selectors.ts
|
||||||
|
└── types.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module 3: Zoho Projects (`zohoProjects`)
|
||||||
|
```
|
||||||
|
src/modules/zohoProjects/
|
||||||
|
├── screens/
|
||||||
|
│ ├── ZohoProjectsDashboardScreen.tsx
|
||||||
|
│ ├── ProjectPerformanceScreen.tsx
|
||||||
|
│ ├── ResourceUtilizationScreen.tsx
|
||||||
|
│ └── ClientAnalyticsScreen.tsx
|
||||||
|
├── components/
|
||||||
|
│ ├── widgets/
|
||||||
|
│ │ ├── ProjectTimelineWidget.tsx
|
||||||
|
│ │ ├── ResourceAllocationChart.tsx
|
||||||
|
│ │ └── QualityMetricsWidget.tsx
|
||||||
|
│ └── forms/
|
||||||
|
│ └── ProjectForm.tsx
|
||||||
|
├── navigation/
|
||||||
|
│ └── ZohoProjectsNavigator.tsx # Stack with ZohoProjects screens
|
||||||
|
├── services/
|
||||||
|
│ ├── zohoProjectsAPI.ts
|
||||||
|
│ └── integrations/
|
||||||
|
│ └── zohoProjectsService.ts
|
||||||
|
└── store/
|
||||||
|
├── zohoProjectsSlice.ts
|
||||||
|
├── selectors.ts
|
||||||
|
└── types.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module 4: Profile (`profile`)
|
||||||
|
```
|
||||||
|
src/modules/profile/
|
||||||
|
├── screens/
|
||||||
|
│ ├── ProfileScreen.tsx
|
||||||
|
│ └── EditProfileScreen.tsx
|
||||||
|
├── components/
|
||||||
|
│ └── widgets/
|
||||||
|
├── navigation/
|
||||||
|
│ └── ProfileNavigator.tsx # Stack with Profile and EditProfile
|
||||||
|
├── services/
|
||||||
|
│ ├── profileAPI.ts
|
||||||
|
└── store/
|
||||||
|
├── profileSlice.ts
|
||||||
|
├── selectors.ts
|
||||||
|
└── types.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. NAVIGATION STRUCTURE
|
||||||
|
|
||||||
|
### Navigation Configuration
|
||||||
|
```typescript
|
||||||
|
// src/navigation/AppNavigator.tsx
|
||||||
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
|
import { createStackNavigator } from '@react-navigation/stack';
|
||||||
|
import Icon from 'react-native-vector-icons/MaterialIcons';
|
||||||
|
|
||||||
|
const Tab = createBottomTabNavigator();
|
||||||
|
const Stack = createStackNavigator();
|
||||||
|
|
||||||
|
const AppNavigator = () => (
|
||||||
|
<Tab.Navigator
|
||||||
|
screenOptions={({ route }) => ({
|
||||||
|
tabBarIcon: ({ focused, color, size }) => {
|
||||||
|
const iconName = getTabIconName(route.name);
|
||||||
|
return <Icon name={iconName} size={size} color={color} />;
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Tab.Screen name="ZohoProjects" component={ZohoProjectsStack} />
|
||||||
|
<Tab.Screen name="HR" component={HRStack} />
|
||||||
|
<Tab.Screen name="Profile" component={ProfileStack} />
|
||||||
|
</Tab.Navigator>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stack Navigators for Each Module
|
||||||
|
```typescript
|
||||||
|
// HR Stack
|
||||||
|
const HRStack = () => (
|
||||||
|
<Stack.Navigator>
|
||||||
|
<Stack.Screen name="HRDashboard" component={HRDashboardScreen} />
|
||||||
|
<Stack.Screen name="EmployeeMetrics" component={EmployeeMetricsScreen} />
|
||||||
|
<Stack.Screen name="Attendance" component={AttendanceScreen} />
|
||||||
|
<Stack.Screen name="Recruitment" component={RecruitmentScreen} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Zoho Projects Stack
|
||||||
|
const ZohoProjectsStack = () => (
|
||||||
|
<Stack.Navigator>
|
||||||
|
<Stack.Screen name="ZohoProjectsDashboard" component={ZohoProjectsDashboardScreen} />
|
||||||
|
<Stack.Screen name="ProjectPerformance" component={ProjectPerformanceScreen} />
|
||||||
|
<Stack.Screen name="ResourceUtilization" component={ResourceUtilizationScreen} />
|
||||||
|
<Stack.Screen name="ClientAnalytics" component={ClientAnalyticsScreen} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Profile Stack
|
||||||
|
const ProfileStack = () => (
|
||||||
|
<Stack.Navigator>
|
||||||
|
<Stack.Screen name="Profile" component={ProfileScreen} />
|
||||||
|
<Stack.Screen name="EditProfile" component={EditProfileScreen} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. INTEGRATION FRAMEWORK
|
||||||
|
|
||||||
|
### Primary Integration Services
|
||||||
|
|
||||||
|
#### HR Systems Integration
|
||||||
|
```
|
||||||
|
src/modules/hr/services/integrations/
|
||||||
|
├── zohoPeopleService.ts # Zoho People integration
|
||||||
|
├── bambooHRService.ts # BambooHR integration
|
||||||
|
├── workdayService.ts # Workday integration
|
||||||
|
└── kekaService.ts # Keka integration
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Project Management Integration
|
||||||
|
```
|
||||||
|
src/modules/zohoProjects/services/integrations/
|
||||||
|
└── zohoProjectsService.ts # Zoho Projects integration
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Communication & Collaboration
|
||||||
|
```
|
||||||
|
src/shared/services/integrations/
|
||||||
|
├── slackService.ts # Slack integration
|
||||||
|
├── teamsService.ts # Microsoft Teams integration
|
||||||
|
└── zoomService.ts # Zoom integration
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. DESIGN SYSTEM & STYLING
|
||||||
|
|
||||||
|
### Theme Configuration
|
||||||
|
```typescript
|
||||||
|
// src/shared/styles/theme.ts
|
||||||
|
export const COLORS = {
|
||||||
|
// Primary colors
|
||||||
|
primary: '#2C5F4A',
|
||||||
|
primaryLight: '#4A8B6A',
|
||||||
|
primaryDark: '#1A3D2E',
|
||||||
|
|
||||||
|
// Secondary colors
|
||||||
|
secondary: '#FF6B35',
|
||||||
|
secondaryLight: '#FF8F65',
|
||||||
|
secondaryDark: '#E55A2B',
|
||||||
|
|
||||||
|
// UI colors
|
||||||
|
background: '#F8F9FA',
|
||||||
|
surface: '#FFFFFF',
|
||||||
|
text: '#2D3748',
|
||||||
|
textLight: '#718096',
|
||||||
|
border: '#E2E8F0',
|
||||||
|
error: '#E53E3E',
|
||||||
|
success: '#38A169',
|
||||||
|
warning: '#D69E2E',
|
||||||
|
|
||||||
|
// Dashboard specific
|
||||||
|
chartPrimary: '#2C5F4A',
|
||||||
|
chartSecondary: '#FF6B35',
|
||||||
|
chartTertiary: '#4299E1',
|
||||||
|
chartQuaternary: '#48BB78',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FONTS = {
|
||||||
|
regular: 'Roboto-Regular',
|
||||||
|
medium: 'Roboto-Medium',
|
||||||
|
bold: 'Roboto-Bold',
|
||||||
|
light: 'Roboto-Light',
|
||||||
|
black: 'Roboto-Black',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FONT_SIZES = {
|
||||||
|
xs: 12,
|
||||||
|
sm: 14,
|
||||||
|
md: 16,
|
||||||
|
lg: 18,
|
||||||
|
xl: 20,
|
||||||
|
xxl: 24,
|
||||||
|
xxxl: 32,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SPACING = {
|
||||||
|
xs: 4,
|
||||||
|
sm: 8,
|
||||||
|
md: 16,
|
||||||
|
lg: 24,
|
||||||
|
xl: 32,
|
||||||
|
xxl: 48,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BORDER_RADIUS = {
|
||||||
|
sm: 4,
|
||||||
|
md: 8,
|
||||||
|
lg: 12,
|
||||||
|
xl: 16,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SHADOWS = {
|
||||||
|
light: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
medium: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
heavy: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Theme System and Context
|
||||||
|
```
|
||||||
|
src/shared/styles/
|
||||||
|
├── theme.ts # Semantic tokens (colors, spacing, fonts, shadows)
|
||||||
|
├── ThemeProvider.tsx # App-level provider with light/dark palettes
|
||||||
|
├── useTheme.ts # Hook to consume the theme
|
||||||
|
└── types.ts # Theme typing (Theme, Palette, SemanticTokens)
|
||||||
|
```
|
||||||
|
|
||||||
|
Guidelines
|
||||||
|
- Use semantic tokens from `theme.ts` (e.g., `COLORS.surface`, `COLORS.text`) instead of hard-coded hex values.
|
||||||
|
- Support light and dark palettes; default to Light as per the provided UI (white surfaces, cool blue header, soft gray background, subtle shadows).
|
||||||
|
- Wrap the app at the root with `ThemeProvider`; access tokens via `useTheme()` in components.
|
||||||
|
- Gradients: primary header/hero areas may use a cool-blue gradient (from `#3AA0FF` to `#2D6BFF`). Keep content surfaces white.
|
||||||
|
- Spacing and radii: use `SPACING` and `BORDER_RADIUS` tokens; avoid magic numbers.
|
||||||
|
- Elevation: use `SHADOWS.light|medium|heavy` for cards and bottom toolbars.
|
||||||
|
|
||||||
|
Palettes (inspired by the attached design)
|
||||||
|
- Light palette
|
||||||
|
- primary: `#2D6BFF` (buttons/icons), primaryLight: `#3AA0FF`
|
||||||
|
- background: `#F4F6F9`, surface: `#FFFFFF`
|
||||||
|
- text: `#1F2937`, textLight: `#6B7280`
|
||||||
|
- accent (success): `#22C55E`, accent (warning): `#F59E0B`, accent (error): `#EF4444`
|
||||||
|
- chartPrimary: `#2D6BFF`, chartSecondary: `#3AA0FF`, chartTertiary: `#10B981`, chartQuaternary: `#F59E0B`
|
||||||
|
- Dark palette (optional)
|
||||||
|
- background: `#0F172A`, surface: `#111827`, text: `#E5E7EB`, textLight: `#9CA3AF`, primary: `#60A5FA`
|
||||||
|
|
||||||
|
Provider responsibilities
|
||||||
|
- Expose `{ colors, spacing, fonts, shadows, isDark, setScheme }`.
|
||||||
|
- Persist the scheme preference with AsyncStorage.
|
||||||
|
- Mirror system scheme when `followSystem=true`.
|
||||||
|
|
||||||
|
Usage rules
|
||||||
|
- Import tokens from `useTheme()` or direct constants from `theme.ts` for static styles.
|
||||||
|
- Do not inline hex codes in components; never duplicate spacing or font sizes.
|
||||||
|
- For icons and charts, pull colors from `theme.colors` so they adapt to scheme changes.
|
||||||
|
|
||||||
|
Example usage
|
||||||
|
```typescript
|
||||||
|
// Inside a component
|
||||||
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
|
|
||||||
|
const MyCard = () => {
|
||||||
|
const { colors, spacing, shadows } = useTheme();
|
||||||
|
return (
|
||||||
|
<View style={{
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
padding: spacing.md,
|
||||||
|
borderRadius: 12,
|
||||||
|
...shadows.medium,
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. ASSETS ORGANIZATION
|
||||||
|
|
||||||
|
### Assets Directory Structure
|
||||||
|
```
|
||||||
|
src/assets/
|
||||||
|
├── fonts/ # Roboto font files
|
||||||
|
│ ├── Roboto-Regular.ttf
|
||||||
|
│ ├── Roboto-Medium.ttf
|
||||||
|
│ ├── Roboto-Bold.ttf
|
||||||
|
│ ├── Roboto-Light.ttf
|
||||||
|
│ └── Roboto-Black.ttf
|
||||||
|
├── images/ # Image assets
|
||||||
|
│ ├── logos/
|
||||||
|
│ ├── icons/
|
||||||
|
│ ├── illustrations/
|
||||||
|
│ └── backgrounds/
|
||||||
|
├── animations/ # Lottie animation files
|
||||||
|
└── data/ # Static data files
|
||||||
|
├── currencies.json
|
||||||
|
├── countries.json
|
||||||
|
└── industries.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. SHARED COMPONENTS LIBRARY
|
||||||
|
|
||||||
|
### UI Components Structure
|
||||||
|
```
|
||||||
|
src/shared/components/
|
||||||
|
├── ui/ # Basic UI components
|
||||||
|
│ ├── Button/
|
||||||
|
│ ├── Input/
|
||||||
|
│ ├── Card/
|
||||||
|
│ ├── Modal/
|
||||||
|
│ ├── LoadingSpinner/
|
||||||
|
│ └── EmptyState/
|
||||||
|
├── charts/ # Chart components
|
||||||
|
│ ├── LineChart/
|
||||||
|
│ ├── BarChart/
|
||||||
|
│ ├── PieChart/
|
||||||
|
│ ├── DonutChart/
|
||||||
|
│ └── ProgressChart/
|
||||||
|
├── forms/ # Form components
|
||||||
|
│ ├── FormInput/
|
||||||
|
│ ├── FormSelect/
|
||||||
|
│ ├── FormDatePicker/
|
||||||
|
│ ├── FormCheckbox/
|
||||||
|
│ └── FormRadio/
|
||||||
|
├── layout/ # Layout components
|
||||||
|
│ ├── Container/
|
||||||
|
│ ├── Header/
|
||||||
|
│ ├── Footer/
|
||||||
|
│ ├── Sidebar/
|
||||||
|
│ └── Grid/
|
||||||
|
└── widgets/ # Reusable widget components
|
||||||
|
├── KPIWidget/
|
||||||
|
├── ChartWidget/
|
||||||
|
├── ListWidget/
|
||||||
|
├── StatWidget/
|
||||||
|
└── FilterWidget/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. PERFORMANCE OPTIMIZATION GUIDELINES
|
||||||
|
|
||||||
|
### Code Organization for Performance
|
||||||
|
- Implement lazy loading for non-critical screens
|
||||||
|
- Use React.memo for expensive dashboard widgets
|
||||||
|
- Implement virtualized lists for large datasets
|
||||||
|
- Cache frequently accessed data using Redux Persist
|
||||||
|
- Use image optimization and caching strategies
|
||||||
|
|
||||||
|
### Data Management Optimization
|
||||||
|
- Implement pagination for large datasets (>100 records)
|
||||||
|
- Use memoization for expensive calculations
|
||||||
|
- Cache API responses with appropriate TTL
|
||||||
|
- Implement background data refresh strategies
|
||||||
|
- Use efficient data structures for complex operations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. SECURITY & DATA PROTECTION
|
||||||
|
|
||||||
|
### Data Security Implementation
|
||||||
|
```
|
||||||
|
src/shared/security/
|
||||||
|
├── encryption/ # Data encryption utilities
|
||||||
|
├── storage/ # Secure storage implementation
|
||||||
|
├── authentication/ # Auth security measures
|
||||||
|
└── validation/ # Input validation and sanitization
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Measures
|
||||||
|
- Encrypt sensitive data before storing in AsyncStorage
|
||||||
|
- Implement secure token storage
|
||||||
|
- Add certificate pinning for API communications
|
||||||
|
- Implement proper input validation and sanitization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. ACCESSIBILITY & INTERNATIONALIZATION
|
||||||
|
|
||||||
|
### Accessibility Structure
|
||||||
|
```
|
||||||
|
src/shared/accessibility/
|
||||||
|
├── labels.ts # Accessibility labels
|
||||||
|
├── hints.ts # Accessibility hints
|
||||||
|
├── roles.ts # Accessibility roles
|
||||||
|
└── utils.ts # Accessibility utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This architecture file provides the complete project structure and organization guidelines for building a maintainable, scalable SME reporting system.
|
||||||
152
App.tsx
152
App.tsx
@ -6,126 +6,50 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type {PropsWithChildren} from 'react';
|
import { Provider } from 'react-redux';
|
||||||
import {
|
import { PersistGate } from 'redux-persist/integration/react';
|
||||||
ScrollView,
|
import { useSelector } from 'react-redux';
|
||||||
StatusBar,
|
import { NavigationContainer } from '@react-navigation/native';
|
||||||
StyleSheet,
|
import AppNavigator from '@/navigation/AppNavigator';
|
||||||
Text,
|
import { store, persistor } from '@/store/store';
|
||||||
useColorScheme,
|
import { ThemeProvider } from '@/shared/styles/ThemeProvider';
|
||||||
View,
|
import LoadingSpinner from '@/shared/components/ui/LoadingSpinner';
|
||||||
} from 'react-native';
|
import AuthNavigator from '@/modules/auth/navigation/AuthNavigator';
|
||||||
|
import type { RootState } from '@/store/store';
|
||||||
|
import IntegrationsNavigator from '@/modules/integrations/navigation/IntegrationsNavigator';
|
||||||
|
import { StatusBar } from 'react-native';
|
||||||
|
|
||||||
import {
|
function AppContent(): React.JSX.Element {
|
||||||
Colors,
|
const isAuthenticated = useSelector((s: RootState) => Boolean(s.auth.token));
|
||||||
DebugInstructions,
|
const selectedService = useSelector((s: RootState) => s.integrations.selectedService);
|
||||||
Header,
|
return (
|
||||||
LearnMoreLinks,
|
<ThemeProvider>
|
||||||
ReloadInstructions,
|
<PersistGate loading={<LoadingSpinner />} persistor={persistor}>
|
||||||
} from 'react-native/Libraries/NewAppScreen';
|
<StatusBar backgroundColor={'#FFFFFF'} barStyle={'dark-content'} />
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<NavigationContainer>
|
||||||
|
<AuthNavigator />
|
||||||
|
</NavigationContainer>
|
||||||
|
) : (
|
||||||
|
!selectedService ? (
|
||||||
|
<NavigationContainer>
|
||||||
|
<IntegrationsNavigator/>
|
||||||
|
</NavigationContainer>
|
||||||
|
) : (
|
||||||
|
<AppNavigator />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</PersistGate>
|
||||||
|
</ThemeProvider>
|
||||||
|
|
||||||
type SectionProps = PropsWithChildren<{
|
|
||||||
title: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
function Section({children, title}: SectionProps): React.JSX.Element {
|
|
||||||
const isDarkMode = useColorScheme() === 'dark';
|
|
||||||
return (
|
|
||||||
<View style={styles.sectionContainer}>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.sectionTitle,
|
|
||||||
{
|
|
||||||
color: isDarkMode ? Colors.white : Colors.black,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.sectionDescription,
|
|
||||||
{
|
|
||||||
color: isDarkMode ? Colors.light : Colors.dark,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function App(): React.JSX.Element {
|
|
||||||
const isDarkMode = useColorScheme() === 'dark';
|
|
||||||
|
|
||||||
const backgroundStyle = {
|
|
||||||
backgroundColor: isDarkMode ? Colors.darker : Colors.lighter,
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
* To keep the template simple and small we're adding padding to prevent view
|
|
||||||
* from rendering under the System UI.
|
|
||||||
* For bigger apps the recommendation is to use `react-native-safe-area-context`:
|
|
||||||
* https://github.com/AppAndFlow/react-native-safe-area-context
|
|
||||||
*
|
|
||||||
* You can read more about it here:
|
|
||||||
* https://github.com/react-native-community/discussions-and-proposals/discussions/827
|
|
||||||
*/
|
|
||||||
const safePadding = '5%';
|
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<View style={backgroundStyle}>
|
<Provider store={store}>
|
||||||
<StatusBar
|
<AppContent />
|
||||||
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
|
</Provider>
|
||||||
backgroundColor={backgroundStyle.backgroundColor}
|
|
||||||
/>
|
|
||||||
<ScrollView
|
|
||||||
style={backgroundStyle}>
|
|
||||||
<View style={{paddingRight: safePadding}}>
|
|
||||||
<Header/>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
backgroundColor: isDarkMode ? Colors.black : Colors.white,
|
|
||||||
paddingHorizontal: safePadding,
|
|
||||||
paddingBottom: safePadding,
|
|
||||||
}}>
|
|
||||||
<Section title="Step One">
|
|
||||||
Edit <Text style={styles.highlight}>App.tsx</Text> to change this
|
|
||||||
screen and then come back to see your edits.
|
|
||||||
</Section>
|
|
||||||
<Section title="See Your Changes">
|
|
||||||
<ReloadInstructions />
|
|
||||||
</Section>
|
|
||||||
<Section title="Debug">
|
|
||||||
<DebugInstructions />
|
|
||||||
</Section>
|
|
||||||
<Section title="Learn More">
|
|
||||||
Read the docs to discover what to do next:
|
|
||||||
</Section>
|
|
||||||
<LearnMoreLinks />
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
sectionContainer: {
|
|
||||||
marginTop: 32,
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
sectionDescription: {
|
|
||||||
marginTop: 8,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '400',
|
|
||||||
},
|
|
||||||
highlight: {
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
|
|||||||
@ -117,3 +117,4 @@ dependencies {
|
|||||||
implementation jscFlavor
|
implementation jscFlavor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
apply from: file("../../node_modules/react-native-vector-icons/fonts.gradle")
|
||||||
BIN
android/app/src/main/assets/fonts/Roboto-Bold.ttf
Normal file
BIN
android/app/src/main/assets/fonts/Roboto-Bold.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/Roboto-Light.ttf
Normal file
BIN
android/app/src/main/assets/fonts/Roboto-Light.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/Roboto-Medium.ttf
Normal file
BIN
android/app/src/main/assets/fonts/Roboto-Medium.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/Roboto-Regular.ttf
Normal file
BIN
android/app/src/main/assets/fonts/Roboto-Regular.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/Roboto-SemiBold.ttf
Normal file
BIN
android/app/src/main/assets/fonts/Roboto-SemiBold.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/Roboto-Thin.ttf
Normal file
BIN
android/app/src/main/assets/fonts/Roboto-Thin.ttf
Normal file
Binary file not shown.
@ -4,6 +4,7 @@
|
|||||||
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<!-- Customize your theme here. -->
|
<!-- Customize your theme here. -->
|
||||||
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||||
|
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
29
android/link-assets-manifest.json
Normal file
29
android/link-assets-manifest.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"migIndex": 1,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"path": "src/assets/fonts/Roboto-Bold.ttf",
|
||||||
|
"sha1": "508c35dee818addce6cc6d1fb6e42f039da5a7cf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "src/assets/fonts/Roboto-Light.ttf",
|
||||||
|
"sha1": "318b44c0a32848f78bf11d4fbf3355d00647a796"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "src/assets/fonts/Roboto-Medium.ttf",
|
||||||
|
"sha1": "fa5192203f85ddb667579e1bdf26f12098bb873b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "src/assets/fonts/Roboto-Regular.ttf",
|
||||||
|
"sha1": "3bff51436aa7eb995d84cfc592cc63e1316bb400"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "src/assets/fonts/Roboto-SemiBold.ttf",
|
||||||
|
"sha1": "9ca139684fe902c8310dd82991648376ac9838db"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "src/assets/fonts/Roboto-Thin.ttf",
|
||||||
|
"sha1": "8e098a207d2ace83e873d02b76acba903d819f74"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -1,3 +1,15 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
presets: ['module:@react-native/babel-preset'],
|
presets: ['module:@react-native/babel-preset'],
|
||||||
|
plugins: [
|
||||||
|
'react-native-reanimated/plugin',
|
||||||
|
[
|
||||||
|
'module-resolver',
|
||||||
|
{
|
||||||
|
root: ['./'],
|
||||||
|
alias: {
|
||||||
|
'@': './src',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
2
index.js
2
index.js
@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
import 'react-native-gesture-handler';
|
||||||
import {AppRegistry} from 'react-native';
|
import {AppRegistry} from 'react-native';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import {name as appName} from './app.json';
|
import {name as appName} from './app.json';
|
||||||
|
|||||||
@ -11,6 +11,12 @@
|
|||||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||||
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; };
|
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; };
|
||||||
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
|
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
|
||||||
|
A801363574154F078613978B /* Roboto-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 458C914037F54A19AE2417A9 /* Roboto-Bold.ttf */; };
|
||||||
|
D8D552DE41234B46B618DA9C /* Roboto-Light.ttf in Resources */ = {isa = PBXBuildFile; fileRef = D3E3B83E3BE94F648FE32533 /* Roboto-Light.ttf */; };
|
||||||
|
41A05860AC884CE9B220E56B /* Roboto-Medium.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F33DA39824A64E8698EC1759 /* Roboto-Medium.ttf */; };
|
||||||
|
1DBB692CDE86454AA93F82CF /* Roboto-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 3F9A64BDC9774C47B9DA8A37 /* Roboto-Regular.ttf */; };
|
||||||
|
630A45FC21FC40178AA38E83 /* Roboto-SemiBold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 9E19FD65B95242228BFF7689 /* Roboto-SemiBold.ttf */; };
|
||||||
|
43C39FA7C3054FEF8ADEC8F1 /* Roboto-Thin.ttf in Resources */ = {isa = PBXBuildFile; fileRef = BCECBACE615341DA9967F551 /* Roboto-Thin.ttf */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@ -35,6 +41,12 @@
|
|||||||
761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = CentralizedReportingSystem/AppDelegate.swift; sourceTree = "<group>"; };
|
761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = CentralizedReportingSystem/AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = CentralizedReportingSystem/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = CentralizedReportingSystem/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
||||||
|
458C914037F54A19AE2417A9 /* Roboto-Bold.ttf */ = {isa = PBXFileReference; name = "Roboto-Bold.ttf"; path = "../src/assets/fonts/Roboto-Bold.ttf"; sourceTree = "<group>"; fileEncoding = undefined; lastKnownFileType = unknown; explicitFileType = undefined; includeInIndex = 0; };
|
||||||
|
D3E3B83E3BE94F648FE32533 /* Roboto-Light.ttf */ = {isa = PBXFileReference; name = "Roboto-Light.ttf"; path = "../src/assets/fonts/Roboto-Light.ttf"; sourceTree = "<group>"; fileEncoding = undefined; lastKnownFileType = unknown; explicitFileType = undefined; includeInIndex = 0; };
|
||||||
|
F33DA39824A64E8698EC1759 /* Roboto-Medium.ttf */ = {isa = PBXFileReference; name = "Roboto-Medium.ttf"; path = "../src/assets/fonts/Roboto-Medium.ttf"; sourceTree = "<group>"; fileEncoding = undefined; lastKnownFileType = unknown; explicitFileType = undefined; includeInIndex = 0; };
|
||||||
|
3F9A64BDC9774C47B9DA8A37 /* Roboto-Regular.ttf */ = {isa = PBXFileReference; name = "Roboto-Regular.ttf"; path = "../src/assets/fonts/Roboto-Regular.ttf"; sourceTree = "<group>"; fileEncoding = undefined; lastKnownFileType = unknown; explicitFileType = undefined; includeInIndex = 0; };
|
||||||
|
9E19FD65B95242228BFF7689 /* Roboto-SemiBold.ttf */ = {isa = PBXFileReference; name = "Roboto-SemiBold.ttf"; path = "../src/assets/fonts/Roboto-SemiBold.ttf"; sourceTree = "<group>"; fileEncoding = undefined; lastKnownFileType = unknown; explicitFileType = undefined; includeInIndex = 0; };
|
||||||
|
BCECBACE615341DA9967F551 /* Roboto-Thin.ttf */ = {isa = PBXFileReference; name = "Roboto-Thin.ttf"; path = "../src/assets/fonts/Roboto-Thin.ttf"; sourceTree = "<group>"; fileEncoding = undefined; lastKnownFileType = unknown; explicitFileType = undefined; includeInIndex = 0; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@ -93,6 +105,7 @@
|
|||||||
83CBBA001A601CBA00E9B192 /* Products */,
|
83CBBA001A601CBA00E9B192 /* Products */,
|
||||||
2D16E6871FA4F8E400B85C8A /* Frameworks */,
|
2D16E6871FA4F8E400B85C8A /* Frameworks */,
|
||||||
BBD78D7AC51CEA395F1C20DB /* Pods */,
|
BBD78D7AC51CEA395F1C20DB /* Pods */,
|
||||||
|
034FBE3A8610463F9085B792 /* Resources */,
|
||||||
);
|
);
|
||||||
indentWidth = 2;
|
indentWidth = 2;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -116,6 +129,20 @@
|
|||||||
path = Pods;
|
path = Pods;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
034FBE3A8610463F9085B792 /* Resources */ = {
|
||||||
|
isa = "PBXGroup";
|
||||||
|
children = (
|
||||||
|
458C914037F54A19AE2417A9 /* Roboto-Bold.ttf */,
|
||||||
|
D3E3B83E3BE94F648FE32533 /* Roboto-Light.ttf */,
|
||||||
|
F33DA39824A64E8698EC1759 /* Roboto-Medium.ttf */,
|
||||||
|
3F9A64BDC9774C47B9DA8A37 /* Roboto-Regular.ttf */,
|
||||||
|
9E19FD65B95242228BFF7689 /* Roboto-SemiBold.ttf */,
|
||||||
|
BCECBACE615341DA9967F551 /* Roboto-Thin.ttf */,
|
||||||
|
);
|
||||||
|
name = Resources;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
path = "";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@ -185,6 +212,12 @@
|
|||||||
files = (
|
files = (
|
||||||
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */,
|
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */,
|
||||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||||
|
A801363574154F078613978B /* Roboto-Bold.ttf in Resources */,
|
||||||
|
D8D552DE41234B46B618DA9C /* Roboto-Light.ttf in Resources */,
|
||||||
|
41A05860AC884CE9B220E56B /* Roboto-Medium.ttf in Resources */,
|
||||||
|
1DBB692CDE86454AA93F82CF /* Roboto-Regular.ttf in Resources */,
|
||||||
|
630A45FC21FC40178AA38E83 /* Roboto-SemiBold.ttf in Resources */,
|
||||||
|
43C39FA7C3054FEF8ADEC8F1 /* Roboto-Thin.ttf in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -26,14 +26,13 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<!-- Do not change NSAllowsArbitraryLoads to true, or you will risk app rejection! -->
|
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>NSAllowsLocalNetworking</key>
|
<key>NSAllowsLocalNetworking</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string></string>
|
<string/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIRequiredDeviceCapabilities</key>
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
@ -48,5 +47,14 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>UIAppFonts</key>
|
||||||
|
<array>
|
||||||
|
<string>Roboto-Bold.ttf</string>
|
||||||
|
<string>Roboto-Light.ttf</string>
|
||||||
|
<string>Roboto-Medium.ttf</string>
|
||||||
|
<string>Roboto-Regular.ttf</string>
|
||||||
|
<string>Roboto-SemiBold.ttf</string>
|
||||||
|
<string>Roboto-Thin.ttf</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
29
ios/link-assets-manifest.json
Normal file
29
ios/link-assets-manifest.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"migIndex": 1,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"path": "src/assets/fonts/Roboto-Bold.ttf",
|
||||||
|
"sha1": "508c35dee818addce6cc6d1fb6e42f039da5a7cf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "src/assets/fonts/Roboto-Light.ttf",
|
||||||
|
"sha1": "318b44c0a32848f78bf11d4fbf3355d00647a796"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "src/assets/fonts/Roboto-Medium.ttf",
|
||||||
|
"sha1": "fa5192203f85ddb667579e1bdf26f12098bb873b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "src/assets/fonts/Roboto-Regular.ttf",
|
||||||
|
"sha1": "3bff51436aa7eb995d84cfc592cc63e1316bb400"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "src/assets/fonts/Roboto-SemiBold.ttf",
|
||||||
|
"sha1": "9ca139684fe902c8310dd82991648376ac9838db"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "src/assets/fonts/Roboto-Thin.ttf",
|
||||||
|
"sha1": "8e098a207d2ace83e873d02b76acba903d819f74"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1278
package-lock.json
generated
1278
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
34
package.json
34
package.json
@ -3,15 +3,40 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "npx react-native run-android",
|
||||||
"ios": "react-native run-ios",
|
"ios": "npx react-native run-ios",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"start": "react-native start",
|
"start": "react-native start",
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
|
"@react-native-clipboard/clipboard": "^1.16.1",
|
||||||
|
"@react-native-community/datetimepicker": "^8.4.4",
|
||||||
|
"@react-native-community/netinfo": "^11.4.1",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.4.7",
|
||||||
|
"@react-navigation/native": "^7.1.17",
|
||||||
|
"@react-navigation/native-stack": "^7.3.26",
|
||||||
|
"@react-navigation/stack": "^7.4.8",
|
||||||
|
"@reduxjs/toolkit": "^2.9.0",
|
||||||
|
"apisauce": "^3.2.0",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-native": "0.79.0"
|
"react-native": "0.79.0",
|
||||||
|
"react-native-chart-kit": "^6.12.0",
|
||||||
|
"react-native-element-dropdown": "^2.12.4",
|
||||||
|
"react-native-gesture-handler": "^2.28.0",
|
||||||
|
"react-native-linear-gradient": "^2.8.3",
|
||||||
|
"react-native-permissions": "^5.2.4",
|
||||||
|
"react-native-raw-bottom-sheet": "^3.0.0",
|
||||||
|
"react-native-reanimated": "^3.19.1",
|
||||||
|
"react-native-safe-area-context": "^5.6.1",
|
||||||
|
"react-native-screens": "^4.16.0",
|
||||||
|
"react-native-share": "^12.0.9",
|
||||||
|
"react-native-svg": "^15.12.1",
|
||||||
|
"react-native-toast-message": "^2.2.1",
|
||||||
|
"react-native-vector-icons": "^10.3.0",
|
||||||
|
"react-redux": "^9.2.0",
|
||||||
|
"redux-persist": "^6.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
@ -27,6 +52,7 @@
|
|||||||
"@types/jest": "^29.5.13",
|
"@types/jest": "^29.5.13",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-test-renderer": "^19.0.0",
|
"@types/react-test-renderer": "^19.0.0",
|
||||||
|
"babel-plugin-module-resolver": "^5.0.2",
|
||||||
"eslint": "^8.19.0",
|
"eslint": "^8.19.0",
|
||||||
"jest": "^29.6.3",
|
"jest": "^29.6.3",
|
||||||
"prettier": "2.8.8",
|
"prettier": "2.8.8",
|
||||||
@ -36,4 +62,4 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
react-native.config.js
Normal file
7
react-native.config.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
project: {
|
||||||
|
ios: {},
|
||||||
|
android: {},
|
||||||
|
},
|
||||||
|
assets: ['./src/assets/fonts'], // adjust according to your path
|
||||||
|
};
|
||||||
BIN
src/assets/fonts/Roboto-Bold.ttf
Normal file
BIN
src/assets/fonts/Roboto-Bold.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/Roboto-Light.ttf
Normal file
BIN
src/assets/fonts/Roboto-Light.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/Roboto-Medium.ttf
Normal file
BIN
src/assets/fonts/Roboto-Medium.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/Roboto-Regular.ttf
Normal file
BIN
src/assets/fonts/Roboto-Regular.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/Roboto-SemiBold.ttf
Normal file
BIN
src/assets/fonts/Roboto-SemiBold.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/Roboto-Thin.ttf
Normal file
BIN
src/assets/fonts/Roboto-Thin.ttf
Normal file
Binary file not shown.
17
src/modules/auth/navigation/AuthNavigator.tsx
Normal file
17
src/modules/auth/navigation/AuthNavigator.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createStackNavigator } from '@react-navigation/stack';
|
||||||
|
import LoginScreen from '@/modules/auth/screens/LoginScreen';
|
||||||
|
|
||||||
|
const Stack = createStackNavigator();
|
||||||
|
|
||||||
|
const AuthNavigator = () => (
|
||||||
|
<Stack.Navigator>
|
||||||
|
<Stack.Screen name="Login" component={LoginScreen} options={{
|
||||||
|
headerShown: false, // Hide header for login screen
|
||||||
|
}}/>
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AuthNavigator;
|
||||||
|
|
||||||
|
|
||||||
297
src/modules/auth/screens/LoginScreen.tsx
Normal file
297
src/modules/auth/screens/LoginScreen.tsx
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
Pressable,
|
||||||
|
} from 'react-native';
|
||||||
|
import GradientBackground from '@/shared/components/layout/GradientBackground';
|
||||||
|
import LinearGradient from 'react-native-linear-gradient';
|
||||||
|
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { login } from '@/modules/auth/store/authSlice';
|
||||||
|
import type { RootState } from '@/store/store';
|
||||||
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
|
|
||||||
|
const LoginScreen: React.FC = () => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const { colors, fonts } = useTheme();
|
||||||
|
const { loading, error } = useSelector((s: RootState) => s.auth);
|
||||||
|
|
||||||
|
const [email, setEmail] = React.useState('');
|
||||||
|
const [password, setPassword] = React.useState('');
|
||||||
|
const [showPassword, setShowPassword] = React.useState(false);
|
||||||
|
const [rememberMe, setRememberMe] = React.useState(false);
|
||||||
|
const [focused, setFocused] = React.useState<null | 'email' | 'password'>(null);
|
||||||
|
|
||||||
|
const emailRef = React.useRef<TextInput>(null);
|
||||||
|
const passwordRef = React.useRef<TextInput>(null);
|
||||||
|
|
||||||
|
const handleLogin = () => {
|
||||||
|
// @ts-ignore
|
||||||
|
dispatch(login({ email, password }));
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<GradientBackground preset="warm" style={styles.gradient}>
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Card */}
|
||||||
|
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||||
|
{/* Logo placeholder */}
|
||||||
|
<View style={[styles.logoCircle, { backgroundColor: '#F1F5F9' }]}>
|
||||||
|
<Icon name="shield" size={28} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>Login</Text>
|
||||||
|
<Text style={[styles.subtitle, { color: colors.textLight, fontFamily: fonts.regular }]}>Enter your email and password to log in</Text>
|
||||||
|
|
||||||
|
{/* Email input */}
|
||||||
|
<Pressable
|
||||||
|
style={[
|
||||||
|
styles.inputWrapper,
|
||||||
|
{
|
||||||
|
borderColor: focused === 'email' ? colors.primary : colors.border,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={() => emailRef.current?.focus()}
|
||||||
|
>
|
||||||
|
<Icon name="email-outline" size={20} color={focused === 'email' ? colors.primary : colors.textLight} />
|
||||||
|
<TextInput
|
||||||
|
ref={emailRef}
|
||||||
|
placeholder="Email"
|
||||||
|
placeholderTextColor={colors.textLight}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
autoComplete="email"
|
||||||
|
keyboardType="email-address"
|
||||||
|
returnKeyType="next"
|
||||||
|
value={email}
|
||||||
|
onFocus={() => setFocused('email')}
|
||||||
|
onBlur={() => setFocused(null)}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
onSubmitEditing={() => passwordRef.current?.focus()}
|
||||||
|
style={[styles.input, { color: colors.text, fontFamily: fonts.regular }]}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Password input */}
|
||||||
|
<Pressable
|
||||||
|
style={[
|
||||||
|
styles.inputWrapper,
|
||||||
|
{
|
||||||
|
borderColor: focused === 'password' ? colors.primary : colors.border,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={() => passwordRef.current?.focus()}
|
||||||
|
>
|
||||||
|
<Icon name="lock-outline" size={20} color={focused === 'password' ? colors.primary : colors.textLight} />
|
||||||
|
<TextInput
|
||||||
|
ref={passwordRef}
|
||||||
|
placeholder="Password"
|
||||||
|
placeholderTextColor={colors.textLight}
|
||||||
|
secureTextEntry={!showPassword}
|
||||||
|
textContentType="password"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
value={password}
|
||||||
|
onFocus={() => setFocused('password')}
|
||||||
|
onBlur={() => setFocused(null)}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
style={[styles.input, { color: colors.text, fontFamily: fonts.regular }]}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.iconButton}
|
||||||
|
onPress={() => setShowPassword(v => !v)}
|
||||||
|
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={showPassword ? 'Hide password' : 'Show password'}
|
||||||
|
>
|
||||||
|
<Icon name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={22} color={colors.textLight} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Row: Remember me + Forgot password */}
|
||||||
|
<View style={styles.rowBetween}>
|
||||||
|
<Pressable style={styles.row} onPress={() => setRememberMe(v => !v)}>
|
||||||
|
<Icon name={rememberMe ? 'checkbox-marked' : 'checkbox-blank-outline'} size={20} color={colors.primary} />
|
||||||
|
<Text style={[styles.rememberText, { color: colors.text, fontFamily: fonts.regular }]}>Remember me</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<TouchableOpacity>
|
||||||
|
<Text style={[styles.link, { color: colors.primary, fontFamily: fonts.medium }]}>Forgot Password ?</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Login button */}
|
||||||
|
<TouchableOpacity activeOpacity={0.9} onPress={handleLogin} disabled={loading} style={{ marginTop: 12 }}>
|
||||||
|
<LinearGradient colors={["#3AA0FF", "#2D6BFF"]} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={styles.loginButton}>
|
||||||
|
<Text style={[styles.loginButtonText, { fontFamily: fonts.bold }]}>{loading ? 'Logging in...' : 'Log In'}</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Or divider */}
|
||||||
|
<View style={styles.orContainer}>
|
||||||
|
<View style={[styles.orLine, { backgroundColor: colors.border }]} />
|
||||||
|
<Text style={[styles.orText, { color: colors.textLight, fontFamily: fonts.regular }]}>Or login with</Text>
|
||||||
|
<View style={[styles.orLine, { backgroundColor: colors.border }]} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Social buttons */}
|
||||||
|
<View style={styles.socialRow}>
|
||||||
|
<TouchableOpacity style={[styles.socialButton, { borderColor: colors.border }]}>
|
||||||
|
<Icon name="google" size={20} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[styles.socialButton, { borderColor: colors.border }]}>
|
||||||
|
<Icon name="facebook" size={20} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[styles.socialButton, { borderColor: colors.border }]}>
|
||||||
|
<Icon name="apple" size={20} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Sign up */}
|
||||||
|
<View style={styles.signupRow}>
|
||||||
|
<Text style={[styles.signupText, { color: colors.textLight, fontFamily: fonts.regular }]}>Don’t have an account? </Text>
|
||||||
|
<TouchableOpacity>
|
||||||
|
<Text style={[styles.link, { color: colors.primary, fontFamily: fonts.medium }]}>Sign up</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{!!error && <Text style={[styles.errorText, { fontFamily: fonts.regular }]}>{error}</Text>}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
gradient: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 380,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
logoCircle: {
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 4,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
inputWrapper: {
|
||||||
|
marginTop: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
height: 52,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
paddingVertical: 0,
|
||||||
|
marginLeft: 8,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
iconButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 8,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 36,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
rowBetween: {
|
||||||
|
marginTop: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
rememberText: {
|
||||||
|
marginLeft: 6,
|
||||||
|
},
|
||||||
|
link: {},
|
||||||
|
loginButton: {
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
loginButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
orContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginVertical: 16,
|
||||||
|
},
|
||||||
|
orLine: {
|
||||||
|
flex: 1,
|
||||||
|
height: 1,
|
||||||
|
},
|
||||||
|
orText: {
|
||||||
|
marginHorizontal: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
socialRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
socialButton: {
|
||||||
|
width: 52,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginHorizontal: 6,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
signupRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
signupText: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
marginTop: 12,
|
||||||
|
color: '#EF4444',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default LoginScreen;
|
||||||
8
src/modules/auth/services/authAPI.ts
Normal file
8
src/modules/auth/services/authAPI.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import http from '@/services/http';
|
||||||
|
import { API_ENDPOINTS } from '@/shared/constants/API_ENDPOINTS';
|
||||||
|
|
||||||
|
export const authAPI = {
|
||||||
|
login: (email: string, password: string) => http.post(API_ENDPOINTS.AUTH_LOGIN, { email, password }),
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
63
src/modules/auth/store/authSlice.ts
Normal file
63
src/modules/auth/store/authSlice.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
user: AuthUser | null;
|
||||||
|
token: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: AuthState = {
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const login = createAsyncThunk(
|
||||||
|
'auth/login',
|
||||||
|
async (payload: { email: string; password: string }) => {
|
||||||
|
// TODO: integrate real API
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
return { token: 'mock-token', user: { id: '1', name: 'User', email: payload.email } as AuthUser };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const authSlice = createSlice({
|
||||||
|
name: 'auth',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
logout: state => {
|
||||||
|
state.user = null;
|
||||||
|
state.token = null;
|
||||||
|
state.error = null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: builder => {
|
||||||
|
builder
|
||||||
|
.addCase(login.pending, state => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(login.fulfilled, (state, action: PayloadAction<{ token: string; user: AuthUser }>) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.token = action.payload.token;
|
||||||
|
state.user = action.payload.user;
|
||||||
|
})
|
||||||
|
.addCase(login.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.error.message || 'Login failed';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { logout } = authSlice.actions;
|
||||||
|
export default authSlice;
|
||||||
|
|
||||||
|
|
||||||
15
src/modules/collab/navigation/CollabNavigator.tsx
Normal file
15
src/modules/collab/navigation/CollabNavigator.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createStackNavigator } from '@react-navigation/stack';
|
||||||
|
import CollabDashboardScreen from '@/modules/collab/screens/CollabDashboardScreen';
|
||||||
|
|
||||||
|
const Stack = createStackNavigator();
|
||||||
|
|
||||||
|
const CollabNavigator = () => (
|
||||||
|
<Stack.Navigator>
|
||||||
|
<Stack.Screen name="CollabDashboard" component={CollabDashboardScreen} options={{headerShown:false}} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default CollabNavigator;
|
||||||
|
|
||||||
|
|
||||||
197
src/modules/collab/screens/CollabDashboardScreen.tsx
Normal file
197
src/modules/collab/screens/CollabDashboardScreen.tsx
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { Container } from '@/shared/components/ui';
|
||||||
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
|
|
||||||
|
const CollabDashboardScreen: React.FC = () => {
|
||||||
|
const { colors, fonts } = useTheme();
|
||||||
|
|
||||||
|
const mock = useMemo(() => {
|
||||||
|
const messagesToday = 4820;
|
||||||
|
const activeChannels = 38;
|
||||||
|
const mentions = 126;
|
||||||
|
const calls = 24;
|
||||||
|
const messageTrend = [540, 620, 580, 640, 710, 680];
|
||||||
|
const heatmap = [
|
||||||
|
[1, 2, 3, 2, 4, 1, 0],
|
||||||
|
[0, 1, 2, 3, 2, 1, 2],
|
||||||
|
[2, 3, 4, 5, 4, 3, 2],
|
||||||
|
[1, 2, 3, 3, 2, 2, 1],
|
||||||
|
[0, 1, 2, 1, 2, 3, 4],
|
||||||
|
]; // rows=weeks, cols=days
|
||||||
|
const channelDist = [
|
||||||
|
{ label: 'General', value: 28, color: '#3AA0FF' },
|
||||||
|
{ label: 'Projects', value: 22, color: '#10B981' },
|
||||||
|
{ label: 'Support', value: 18, color: '#F59E0B' },
|
||||||
|
{ label: 'Social', value: 8, color: '#6366F1' },
|
||||||
|
{ label: 'Other', value: 6, color: '#EF4444' },
|
||||||
|
];
|
||||||
|
const activeList = [
|
||||||
|
{ name: '#proj-alpha', unread: 18 },
|
||||||
|
{ name: '#support', unread: 12 },
|
||||||
|
{ name: '#marketing', unread: 9 },
|
||||||
|
{ name: '#random', unread: 4 },
|
||||||
|
];
|
||||||
|
const mentionsList = [
|
||||||
|
{ user: 'Aarti', when: '2h', ctx: 'Please review the draft' },
|
||||||
|
{ user: 'Rahul', when: '5h', ctx: 'Meeting moved to 3 PM' },
|
||||||
|
{ user: 'Meera', when: '1d', ctx: 'Approved the change' },
|
||||||
|
];
|
||||||
|
return { messagesToday, activeChannels, mentions, calls, messageTrend, heatmap, channelDist, activeList, mentionsList };
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<View style={[styles.wrap, { backgroundColor: colors.background }]}>
|
||||||
|
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>Communication & Collaboration</Text>
|
||||||
|
|
||||||
|
{/* KPI Pills */}
|
||||||
|
<View style={styles.kpiRow}>
|
||||||
|
<Pill label="Messages Today" value={mock.messagesToday.toLocaleString()} color={colors.text} fonts={fonts} bg="#E6F2FF" dot="#3AA0FF" />
|
||||||
|
<Pill label="Active Channels" value={String(mock.activeChannels)} color={colors.text} fonts={fonts} bg="#E9FAF2" dot="#10B981" />
|
||||||
|
</View>
|
||||||
|
<View style={styles.kpiRow}>
|
||||||
|
<Pill label="Mentions" value={String(mock.mentions)} color={colors.text} fonts={fonts} bg="#FFF4E6" dot="#F59E0B" />
|
||||||
|
<Pill label="Calls" value={String(mock.calls)} color={colors.text} fonts={fonts} bg="#ECEBFF" dot="#6366F1" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Activity Heatmap */}
|
||||||
|
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Weekly Activity Heatmap</Text>
|
||||||
|
<Heatmap data={mock.heatmap} baseColor="#3AA0FF" />
|
||||||
|
<Text style={{ fontSize: 12, marginTop: 6, color: colors.text, fontFamily: fonts.regular }}>Rows represent weeks, columns represent days (Mon-Sun)</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Message Volume */}
|
||||||
|
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Message Volume</Text>
|
||||||
|
<Bars data={mock.messageTrend} max={Math.max(...mock.messageTrend)} color="#3AA0FF" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Channel Distribution */}
|
||||||
|
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Channel Distribution</Text>
|
||||||
|
<Stacked segments={mock.channelDist} total={mock.channelDist.reduce((a, b) => a + b.value, 0)} />
|
||||||
|
<View style={styles.legendRow}>
|
||||||
|
{mock.channelDist.map(s => (
|
||||||
|
<View key={s.label} style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: s.color }]} />
|
||||||
|
<Text style={{ color: colors.text, fontFamily: fonts.regular }}>{s.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Lists */}
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={[styles.card, styles.col, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Active Channels</Text>
|
||||||
|
{mock.activeList.map(c => (
|
||||||
|
<View key={c.name} style={styles.listRow}>
|
||||||
|
<Text style={[styles.listPrimary, { color: colors.text, fontFamily: fonts.regular }]}>{c.name}</Text>
|
||||||
|
<Badge text={String(c.unread)} fonts={fonts} />
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View style={[styles.card, styles.col, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Recent Mentions</Text>
|
||||||
|
{mock.mentionsList.map(m => (
|
||||||
|
<View key={`${m.user}-${m.when}`} style={styles.listRow}>
|
||||||
|
<Text style={[styles.listPrimary, { color: colors.text, fontFamily: fonts.regular }]}>{m.user}</Text>
|
||||||
|
<Text style={[styles.listSecondary, { color: colors.text, fontFamily: fonts.regular }]}>{m.ctx} · {m.when}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrap: { flex: 1, padding: 16 },
|
||||||
|
title: { fontSize: 18, marginBottom: 8 },
|
||||||
|
kpiRow: { flexDirection: 'row', justifyContent: 'space-between' },
|
||||||
|
pill: { flex: 1, marginRight: 12, borderRadius: 12, paddingVertical: 12, paddingHorizontal: 14 },
|
||||||
|
pillLast: { marginRight: 0 },
|
||||||
|
pillTop: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||||
|
dot: { width: 8, height: 8, borderRadius: 4 },
|
||||||
|
pillLabel: { fontSize: 12, opacity: 0.8 },
|
||||||
|
pillValue: { fontSize: 18, marginTop: 6 },
|
||||||
|
card: { borderRadius: 12, borderWidth: 1, padding: 12, marginTop: 12 },
|
||||||
|
cardTitle: { fontSize: 16, marginBottom: 8 },
|
||||||
|
heatRow: { flexDirection: 'row', marginBottom: 6 },
|
||||||
|
heatCell: { width: 14, height: 14, borderRadius: 3, marginRight: 6 },
|
||||||
|
bars: { flexDirection: 'row', alignItems: 'flex-end' },
|
||||||
|
bar: { flex: 1, marginRight: 6, borderTopLeftRadius: 4, borderTopRightRadius: 4 },
|
||||||
|
legendRow: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 8 },
|
||||||
|
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 12, marginTop: 6 },
|
||||||
|
legendDot: { width: 8, height: 8, borderRadius: 4, marginRight: 6 },
|
||||||
|
row: { marginTop: 12 },
|
||||||
|
col: { flex: 1, marginRight: 8 },
|
||||||
|
listRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#E5E7EB' },
|
||||||
|
listPrimary: { fontSize: 14, flex: 1, paddingRight: 8 },
|
||||||
|
listSecondary: { fontSize: 12 },
|
||||||
|
badge: { backgroundColor: '#E6F2FF', borderRadius: 10, paddingHorizontal: 8, paddingVertical: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default CollabDashboardScreen;
|
||||||
|
|
||||||
|
// UI pieces (no external deps)
|
||||||
|
const Pill: React.FC<{ label: string; value: string; color: string; fonts: any; bg: string; dot: string }> = ({ label, value, color, fonts, bg, dot }) => {
|
||||||
|
return (
|
||||||
|
<View style={[styles.pill, { backgroundColor: bg }]}>
|
||||||
|
<View style={styles.pillTop}>
|
||||||
|
<Text style={[styles.pillLabel, { color, fontFamily: fonts.regular }]}>{label}</Text>
|
||||||
|
<View style={[styles.dot, { backgroundColor: dot }]} />
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.pillValue, { color, fontFamily: fonts.bold }]}>{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Heatmap: React.FC<{ data: number[][]; baseColor: string }> = ({ data, baseColor }) => {
|
||||||
|
const max = Math.max(1, ...data.flat());
|
||||||
|
const tint = (val: number) => {
|
||||||
|
const alpha = 0.2 + (val / max) * 0.8; // 0.2 - 1
|
||||||
|
return `${baseColor}${Math.round(alpha * 255).toString(16).padStart(2, '0')}`; // add alpha to hex
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{data.map((row, i) => (
|
||||||
|
<View key={i} style={styles.heatRow}>
|
||||||
|
{row.map((v, j) => (
|
||||||
|
<View key={j} style={[styles.heatCell, { backgroundColor: tint(v) }]} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Bars: React.FC<{ data: number[]; max: number; color: string }> = ({ data, max, color }) => {
|
||||||
|
return (
|
||||||
|
<View style={styles.bars}>
|
||||||
|
{data.map((v, i) => (
|
||||||
|
<View key={i} style={[styles.bar, { height: Math.max(6, (v / Math.max(1, max)) * 64), backgroundColor: color }]} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Stacked: React.FC<{ segments: { label: string; value: number; color: string }[]; total: number }> = ({ segments, total }) => {
|
||||||
|
return (
|
||||||
|
<View style={{ height: 12, borderRadius: 8, backgroundColor: '#E5E7EB', overflow: 'hidden', flexDirection: 'row', marginTop: 8 }}>
|
||||||
|
{segments.map(s => (
|
||||||
|
<View key={s.label} style={{ width: `${(s.value / Math.max(1, total)) * 100}%`, backgroundColor: s.color }} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Badge: React.FC<{ text: string; fonts: any }> = ({ text, fonts }) => (
|
||||||
|
<View style={styles.badge}>
|
||||||
|
<Text style={{ fontSize: 12, fontFamily: fonts.medium }}>{text}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
15
src/modules/crm/navigation/CrmNavigator.tsx
Normal file
15
src/modules/crm/navigation/CrmNavigator.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createStackNavigator } from '@react-navigation/stack';
|
||||||
|
import CrmDashboardScreen from '@/modules/crm/screens/CrmDashboardScreen';
|
||||||
|
|
||||||
|
const Stack = createStackNavigator();
|
||||||
|
|
||||||
|
const CrmNavigator = () => (
|
||||||
|
<Stack.Navigator>
|
||||||
|
<Stack.Screen name="CrmDashboard" component={CrmDashboardScreen} options={{headerShown:false}} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default CrmNavigator;
|
||||||
|
|
||||||
|
|
||||||
164
src/modules/crm/screens/CrmDashboardScreen.tsx
Normal file
164
src/modules/crm/screens/CrmDashboardScreen.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { Container } from '@/shared/components/ui';
|
||||||
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
|
|
||||||
|
const CrmDashboardScreen: React.FC = () => {
|
||||||
|
const { colors, fonts } = useTheme();
|
||||||
|
|
||||||
|
const mock = useMemo(() => {
|
||||||
|
const leads = 420;
|
||||||
|
const opportunities = 76;
|
||||||
|
const wonDeals = 28;
|
||||||
|
const conversionPct = 37;
|
||||||
|
const leadsTrend = [60, 62, 68, 70, 76, 84];
|
||||||
|
const pipeline = [
|
||||||
|
{ label: 'Prospecting', value: 28, color: '#3AA0FF' },
|
||||||
|
{ label: 'Qualified', value: 18, color: '#10B981' },
|
||||||
|
{ label: 'Proposal', value: 12, color: '#F59E0B' },
|
||||||
|
{ label: 'Negotiation', value: 9, color: '#6366F1' },
|
||||||
|
{ label: 'Closed Won', value: 6, color: '#22C55E' },
|
||||||
|
{ label: 'Closed Lost', value: 7, color: '#EF4444' },
|
||||||
|
];
|
||||||
|
const topOpps = [
|
||||||
|
{ name: 'Acme Upgrade', value: 48000 },
|
||||||
|
{ name: 'Globex Renewal', value: 36000 },
|
||||||
|
{ name: 'Initech Expansion', value: 29000 },
|
||||||
|
];
|
||||||
|
const recent = [
|
||||||
|
{ who: 'Jane D.', what: 'Follow-up call completed', when: '2h' },
|
||||||
|
{ who: 'Sam R.', what: 'Demo scheduled', when: '5h' },
|
||||||
|
{ who: 'Priya K.', what: 'Proposal sent', when: '1d' },
|
||||||
|
];
|
||||||
|
const sourceDist = [
|
||||||
|
{ label: 'Website', value: 180, color: '#3AA0FF' },
|
||||||
|
{ label: 'Referral', value: 120, color: '#10B981' },
|
||||||
|
{ label: 'Events', value: 64, color: '#F59E0B' },
|
||||||
|
{ label: 'Ads', value: 56, color: '#EF4444' },
|
||||||
|
];
|
||||||
|
return { leads, opportunities, wonDeals, conversionPct, leadsTrend, pipeline, topOpps, recent, sourceDist };
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<View style={[styles.wrap, { backgroundColor: colors.background }]}>
|
||||||
|
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>CRM & Sales</Text>
|
||||||
|
|
||||||
|
{/* KPIs */}
|
||||||
|
<View style={styles.kpiGrid}>
|
||||||
|
<Kpi label="Leads (M)" value={String(mock.leads)} color={colors.text} fonts={fonts} accent="#3AA0FF" />
|
||||||
|
<Kpi label="Opportunities" value={String(mock.opportunities)} color={colors.text} fonts={fonts} accent="#6366F1" />
|
||||||
|
<Kpi label="Won Deals" value={String(mock.wonDeals)} color={colors.text} fonts={fonts} accent="#10B981" />
|
||||||
|
<Kpi label="Conversion" value={`${mock.conversionPct}%`} color={colors.text} fonts={fonts} accent="#F59E0B" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Leads Trend */}
|
||||||
|
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Leads Trend</Text>
|
||||||
|
<Bars data={mock.leadsTrend} max={Math.max(...mock.leadsTrend)} color="#3AA0FF" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Pipeline distribution */}
|
||||||
|
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Pipeline Stages</Text>
|
||||||
|
<Stacked segments={mock.pipeline} total={mock.pipeline.reduce((a, b) => a + b.value, 0)} />
|
||||||
|
<View style={styles.legendRow}>
|
||||||
|
{mock.pipeline.map(s => (
|
||||||
|
<View key={s.label} style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: s.color }]} />
|
||||||
|
<Text style={{ color: colors.text, fontFamily: fonts.regular }}>{s.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Lead Sources */}
|
||||||
|
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Leads by Source</Text>
|
||||||
|
<Stacked segments={mock.sourceDist} total={mock.sourceDist.reduce((a, b) => a + b.value, 0)} />
|
||||||
|
<View style={styles.legendRow}>
|
||||||
|
{mock.sourceDist.map(s => (
|
||||||
|
<View key={s.label} style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: s.color }]} />
|
||||||
|
<Text style={{ color: colors.text, fontFamily: fonts.regular }}>{s.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Lists */}
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={[styles.card, styles.col, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Top Opportunities</Text>
|
||||||
|
{mock.topOpps.map(o => (
|
||||||
|
<View key={o.name} style={styles.listRow}>
|
||||||
|
<Text style={[styles.listPrimary, { color: colors.text, fontFamily: fonts.regular }]}>{o.name}</Text>
|
||||||
|
<Text style={[styles.listSecondary, { color: colors.text, fontFamily: fonts.medium }]}>${o.value.toLocaleString()}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View style={[styles.card, styles.col, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Recent Activity</Text>
|
||||||
|
{mock.recent.map(r => (
|
||||||
|
<View key={`${r.who}-${r.when}`} style={styles.listRow}>
|
||||||
|
<Text style={[styles.listPrimary, { color: colors.text, fontFamily: fonts.regular }]}>{r.who}</Text>
|
||||||
|
<Text style={[styles.listSecondary, { color: colors.text, fontFamily: fonts.regular }]}>{r.what} · {r.when}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrap: { flex: 1, padding: 16 },
|
||||||
|
title: { fontSize: 18, marginBottom: 8 },
|
||||||
|
kpiGrid: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between' },
|
||||||
|
kpiCard: { width: '48%', borderRadius: 12, borderWidth: 1, borderColor: '#E2E8F0', backgroundColor: '#FFFFFF', padding: 12, marginBottom: 12 },
|
||||||
|
kpiLabel: { fontSize: 12, opacity: 0.8 },
|
||||||
|
kpiValueRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: 8 },
|
||||||
|
card: { borderRadius: 12, borderWidth: 1, padding: 12, marginTop: 12 },
|
||||||
|
cardTitle: { fontSize: 16, marginBottom: 8 },
|
||||||
|
bars: { flexDirection: 'row', alignItems: 'flex-end' },
|
||||||
|
bar: { flex: 1, marginRight: 6, borderTopLeftRadius: 4, borderTopRightRadius: 4 },
|
||||||
|
legendRow: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 8 },
|
||||||
|
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 12, marginTop: 6 },
|
||||||
|
legendDot: { width: 8, height: 8, borderRadius: 4, marginRight: 6 },
|
||||||
|
row: { marginTop: 12 },
|
||||||
|
col: { flex: 1, marginRight: 8 },
|
||||||
|
listRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#E5E7EB' },
|
||||||
|
listPrimary: { fontSize: 14, flex: 1, paddingRight: 8 },
|
||||||
|
listSecondary: { fontSize: 12 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default CrmDashboardScreen;
|
||||||
|
|
||||||
|
// UI helpers
|
||||||
|
const Kpi: React.FC<{ label: string; value: string; color: string; fonts: any; accent: string }> = ({ label, value, color, fonts, accent }) => (
|
||||||
|
<View style={styles.kpiCard}>
|
||||||
|
<Text style={[styles.kpiLabel, { color, fontFamily: fonts.regular }]}>{label}</Text>
|
||||||
|
<View style={styles.kpiValueRow}>
|
||||||
|
<Text style={{ color, fontSize: 20, fontFamily: fonts.bold }}>{value}</Text>
|
||||||
|
<View style={{ width: 10, height: 10, borderRadius: 5, backgroundColor: accent }} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Bars: React.FC<{ data: number[]; max: number; color: string }> = ({ data, max, color }) => (
|
||||||
|
<View style={styles.bars}>
|
||||||
|
{data.map((v, i) => (
|
||||||
|
<View key={i} style={[styles.bar, { height: Math.max(6, (v / Math.max(1, max)) * 64), backgroundColor: color }]} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Stacked: React.FC<{ segments: { label: string; value: number; color: string }[]; total: number }> = ({ segments, total }) => (
|
||||||
|
<View style={{ height: 12, borderRadius: 8, backgroundColor: '#E5E7EB', overflow: 'hidden', flexDirection: 'row', marginTop: 8 }}>
|
||||||
|
{segments.map(s => (
|
||||||
|
<View key={s.label} style={{ width: `${(s.value / Math.max(1, total)) * 100}%`, backgroundColor: s.color }} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
15
src/modules/finance/navigation/FinanceNavigator.tsx
Normal file
15
src/modules/finance/navigation/FinanceNavigator.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createStackNavigator } from '@react-navigation/stack';
|
||||||
|
import FinanceDashboardScreen from '@/modules/finance/screens/FinanceDashboardScreen';
|
||||||
|
|
||||||
|
const Stack = createStackNavigator();
|
||||||
|
|
||||||
|
const FinanceNavigator = () => (
|
||||||
|
<Stack.Navigator>
|
||||||
|
<Stack.Screen name="FinanceDashboard" component={FinanceDashboardScreen} options={{headerShown:false}}/>
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default FinanceNavigator;
|
||||||
|
|
||||||
|
|
||||||
230
src/modules/finance/screens/FinanceDashboardScreen.tsx
Normal file
230
src/modules/finance/screens/FinanceDashboardScreen.tsx
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { Container } from '@/shared/components/ui';
|
||||||
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
|
|
||||||
|
const FinanceDashboardScreen: React.FC = () => {
|
||||||
|
const { colors, fonts } = useTheme();
|
||||||
|
|
||||||
|
const mock = useMemo(() => {
|
||||||
|
// Zoho Books oriented metrics
|
||||||
|
const cash = 246000;
|
||||||
|
const invoices = { total: 485000, outstanding: 162000, overdue: 48000, paidThisMonth: 92000 };
|
||||||
|
const monthlySales = [84, 92, 88, 104, 112, 118]; // in thousands
|
||||||
|
const arAging = [45, 30, 18, 7]; // 0-30, 31-60, 61-90, 90+
|
||||||
|
const invoiceStatus = [
|
||||||
|
{ label: 'Draft', value: 30, color: '#94A3B8' },
|
||||||
|
{ label: 'Sent', value: 80, color: '#3AA0FF' },
|
||||||
|
{ label: 'Viewed', value: 50, color: '#6366F1' },
|
||||||
|
{ label: 'Paid', value: 210, color: '#10B981' },
|
||||||
|
{ label: 'Overdue', value: 18, color: '#EF4444' },
|
||||||
|
];
|
||||||
|
const taxes = { collected: 38000, paid: 12500 };
|
||||||
|
const topCustomers = [
|
||||||
|
{ client: 'Acme Corp', amount: 82000 },
|
||||||
|
{ client: 'Initech', amount: 54000 },
|
||||||
|
{ client: 'Umbrella', amount: 48000 },
|
||||||
|
];
|
||||||
|
const bankAccounts = [
|
||||||
|
{ name: 'HDFC 1234', balance: 152000 },
|
||||||
|
{ name: 'ICICI 9981', balance: 78000 },
|
||||||
|
];
|
||||||
|
const paymentModes = [
|
||||||
|
{ label: 'Online', value: 120, color: '#3AA0FF' },
|
||||||
|
{ label: 'Bank Transfer', value: 90, color: '#10B981' },
|
||||||
|
{ label: 'Cash', value: 40, color: '#F59E0B' },
|
||||||
|
{ label: 'Cheque', value: 12, color: '#6366F1' },
|
||||||
|
];
|
||||||
|
const estimates = { sent: 24, accepted: 16, declined: 3 };
|
||||||
|
return { cash, invoices, monthlySales, arAging, invoiceStatus, taxes, topCustomers, bankAccounts, paymentModes, estimates };
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<View style={[styles.wrap, { backgroundColor: colors.background }]}>
|
||||||
|
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>Accounts & Finance</Text>
|
||||||
|
|
||||||
|
{/* KPIs */}
|
||||||
|
<View style={styles.kpiGrid}>
|
||||||
|
<Kpi label="Outstanding" value={`$${mock.invoices.outstanding.toLocaleString()}`} color={colors.text} fonts={fonts} accent="#3AA0FF" />
|
||||||
|
<Kpi label="Overdue" value={`$${mock.invoices.overdue.toLocaleString()}`} color={colors.text} fonts={fonts} accent="#EF4444" />
|
||||||
|
<Kpi label="Paid (This Month)" value={`$${mock.invoices.paidThisMonth.toLocaleString()}`} color={colors.text} fonts={fonts} accent="#10B981" />
|
||||||
|
<Kpi label="Cash" value={`$${mock.cash.toLocaleString()}`} color={colors.text} fonts={fonts} accent="#6366F1" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Sales Trend */}
|
||||||
|
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Sales Trend</Text>
|
||||||
|
<Bars data={mock.monthlySales} max={Math.max(...mock.monthlySales)} color="#3AA0FF" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Taxes & AR Aging */}
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={[styles.card, styles.col, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Taxes</Text>
|
||||||
|
{(() => {
|
||||||
|
const total = Math.max(1, mock.taxes.collected + mock.taxes.paid);
|
||||||
|
const colPct = Math.round((mock.taxes.collected / total) * 100);
|
||||||
|
const paidPct = Math.round((mock.taxes.paid / total) * 100);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text style={{ fontSize: 12, color: colors.text, fontFamily: fonts.regular }}>Collected: ${mock.taxes.collected.toLocaleString()}</Text>
|
||||||
|
<Progress value={colPct} color="#10B981" fonts={fonts} />
|
||||||
|
<Text style={{ fontSize: 12, color: colors.text, fontFamily: fonts.regular, marginTop: 6 }}>Paid: ${mock.taxes.paid.toLocaleString()}</Text>
|
||||||
|
<Progress value={paidPct} color="#F59E0B" fonts={fonts} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</View>
|
||||||
|
<View style={[styles.card, styles.col, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>A/R Aging</Text>
|
||||||
|
<Bars data={mock.arAging} max={Math.max(...mock.arAging)} color="#F59E0B" />
|
||||||
|
<View style={styles.rowJustify}>
|
||||||
|
<Text style={{ fontSize: 12, color: colors.text, fontFamily: fonts.regular }}>0-30</Text>
|
||||||
|
<Text style={{ fontSize: 12, color: colors.text, fontFamily: fonts.regular }}>31-60</Text>
|
||||||
|
<Text style={{ fontSize: 12, color: colors.text, fontFamily: fonts.regular }}>61-90</Text>
|
||||||
|
<Text style={{ fontSize: 12, color: colors.text, fontFamily: fonts.regular }}>90+</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Invoice Status Distribution */}
|
||||||
|
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Invoice Status</Text>
|
||||||
|
<Stacked segments={mock.invoiceStatus} total={mock.invoiceStatus.reduce((a, b) => a + b.value, 0)} />
|
||||||
|
<View style={styles.legendRow}>
|
||||||
|
{mock.invoiceStatus.map(s => (
|
||||||
|
<View key={s.label} style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: s.color }]} />
|
||||||
|
<Text style={{ color: colors.text, fontFamily: fonts.regular }}>{s.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Lists */}
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={[styles.card, styles.col, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Top Customers</Text>
|
||||||
|
{mock.topCustomers.map(r => (
|
||||||
|
<View key={r.client} style={styles.listRow}>
|
||||||
|
<Text style={[styles.listPrimary, { color: colors.text, fontFamily: fonts.regular }]}>{r.client}</Text>
|
||||||
|
<Text style={[styles.listSecondary, { color: colors.text, fontFamily: fonts.medium }]}>${r.amount.toLocaleString()}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View style={[styles.card, styles.col, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Bank Accounts</Text>
|
||||||
|
{mock.bankAccounts.map(p => (
|
||||||
|
<View key={p.name} style={styles.listRow}>
|
||||||
|
<Text style={[styles.listPrimary, { color: colors.text, fontFamily: fonts.regular }]}>{p.name}</Text>
|
||||||
|
<Text style={[styles.listSecondary, { color: colors.text, fontFamily: fonts.medium }]}>${p.balance.toLocaleString()}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Estimates & Payment Modes */}
|
||||||
|
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Estimates</Text>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
|
||||||
|
<View style={{ paddingVertical: 6, paddingHorizontal: 10, backgroundColor: '#F3F4F6', borderRadius: 14, marginRight: 8, marginTop: 8 }}>
|
||||||
|
<Text style={{ fontSize: 12, fontFamily: fonts.regular, color: colors.text }}>Sent: {mock.estimates.sent}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ paddingVertical: 6, paddingHorizontal: 10, backgroundColor: '#E9FAF2', borderRadius: 14, marginRight: 8, marginTop: 8 }}>
|
||||||
|
<Text style={{ fontSize: 12, fontFamily: fonts.regular, color: colors.text }}>Accepted: {mock.estimates.accepted}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ paddingVertical: 6, paddingHorizontal: 10, backgroundColor: '#FFF4E6', borderRadius: 14, marginRight: 8, marginTop: 8 }}>
|
||||||
|
<Text style={{ fontSize: 12, fontFamily: fonts.regular, color: colors.text }}>Declined: {mock.estimates.declined}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Payment Modes</Text>
|
||||||
|
<Stacked segments={mock.paymentModes} total={mock.paymentModes.reduce((a, b) => a + b.value, 0)} />
|
||||||
|
<View style={styles.legendRow}>
|
||||||
|
{mock.paymentModes.map(s => (
|
||||||
|
<View key={s.label} style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: s.color }]} />
|
||||||
|
<Text style={{ color: colors.text, fontFamily: fonts.regular }}>{s.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrap: { flex: 1, padding: 16 },
|
||||||
|
title: { fontSize: 18, marginBottom: 8 },
|
||||||
|
kpiGrid: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between' },
|
||||||
|
kpiCard: { width: '48%', borderRadius: 12, borderWidth: 1, borderColor: '#E2E8F0', backgroundColor: '#FFFFFF', padding: 12, marginBottom: 12 },
|
||||||
|
kpiLabel: { fontSize: 12, opacity: 0.8 },
|
||||||
|
kpiValueRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: 8 },
|
||||||
|
card: { borderRadius: 12, borderWidth: 1, padding: 12, marginTop: 12 },
|
||||||
|
cardTitle: { fontSize: 16, marginBottom: 8 },
|
||||||
|
row: { flexDirection: 'row', marginTop: 12 },
|
||||||
|
col: { flex: 1, marginRight: 8 },
|
||||||
|
bars: { flexDirection: 'row', alignItems: 'flex-end' },
|
||||||
|
bar: { flex: 1, marginRight: 6, borderTopLeftRadius: 4, borderTopRightRadius: 4 },
|
||||||
|
progressWrap: { marginTop: 8 },
|
||||||
|
progressTrack: { height: 8, borderRadius: 6, backgroundColor: '#E5E7EB', overflow: 'hidden' },
|
||||||
|
progressFill: { height: '100%', borderRadius: 6 },
|
||||||
|
legendRow: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 8 },
|
||||||
|
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 12, marginTop: 6 },
|
||||||
|
legendDot: { width: 8, height: 8, borderRadius: 4, marginRight: 6 },
|
||||||
|
rowJustify: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 8 },
|
||||||
|
listRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#E5E7EB' },
|
||||||
|
listPrimary: { fontSize: 14, flex: 1, paddingRight: 8 },
|
||||||
|
listSecondary: { fontSize: 12 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default FinanceDashboardScreen;
|
||||||
|
|
||||||
|
// UI bits
|
||||||
|
const Kpi: React.FC<{ label: string; value: string; color: string; fonts: any; accent: string }> = ({ label, value, color, fonts, accent }) => {
|
||||||
|
return (
|
||||||
|
<View style={styles.kpiCard}>
|
||||||
|
<Text style={[styles.kpiLabel, { color, fontFamily: fonts.regular }]}>{label}</Text>
|
||||||
|
<View style={styles.kpiValueRow}>
|
||||||
|
<Text style={{ color, fontSize: 20, fontFamily: fonts.bold }}>{value}</Text>
|
||||||
|
<View style={{ width: 10, height: 10, borderRadius: 5, backgroundColor: accent }} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Bars: React.FC<{ data: number[]; max: number; color: string }> = ({ data, max, color }) => {
|
||||||
|
return (
|
||||||
|
<View style={styles.bars}>
|
||||||
|
{data.map((v, i) => (
|
||||||
|
<View key={i} style={[styles.bar, { height: Math.max(6, (v / Math.max(1, max)) * 64), backgroundColor: color }]} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Progress: React.FC<{ value: number; color: string; fonts: any }> = ({ value, color, fonts }) => {
|
||||||
|
return (
|
||||||
|
<View style={styles.progressWrap}>
|
||||||
|
<View style={styles.progressTrack}>
|
||||||
|
<View style={[styles.progressFill, { width: `${value}%`, backgroundColor: color }]} />
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 12, marginTop: 6, fontFamily: fonts.medium }}>{value}%</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Stacked: React.FC<{ segments: { label: string; value: number; color: string }[]; total: number }> = ({ segments, total }) => {
|
||||||
|
return (
|
||||||
|
<View style={{ height: 12, borderRadius: 8, backgroundColor: '#E5E7EB', overflow: 'hidden', flexDirection: 'row', marginTop: 8 }}>
|
||||||
|
{segments.map(s => (
|
||||||
|
<View key={s.label} style={{ width: `${(s.value / Math.max(1, total)) * 100}%`, backgroundColor: s.color }} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
15
src/modules/hr/navigation/HRNavigator.tsx
Normal file
15
src/modules/hr/navigation/HRNavigator.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createStackNavigator } from '@react-navigation/stack';
|
||||||
|
import HRDashboardScreen from '@/modules/hr/screens/HRDashboardScreen';
|
||||||
|
|
||||||
|
const Stack = createStackNavigator();
|
||||||
|
|
||||||
|
const HRNavigator = () => (
|
||||||
|
<Stack.Navigator>
|
||||||
|
<Stack.Screen name="HRDashboard" component={HRDashboardScreen} options={{headerShown:false}} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default HRNavigator;
|
||||||
|
|
||||||
|
|
||||||
209
src/modules/hr/screens/HRDashboardScreen.tsx
Normal file
209
src/modules/hr/screens/HRDashboardScreen.tsx
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { Container, LoadingSpinner, ErrorState } from '@/shared/components/ui';
|
||||||
|
import { fetchHRMetrics } from '@/modules/hr/store/hrSlice';
|
||||||
|
import type { RootState } from '@/store/store';
|
||||||
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
|
|
||||||
|
const HRDashboardScreen: React.FC = () => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const { colors, fonts } = useTheme();
|
||||||
|
const { metrics, loading, error } = useSelector((s: RootState) => s.hr);
|
||||||
|
|
||||||
|
// Mock HR analytics (UI only)
|
||||||
|
const mock = useMemo(() => {
|
||||||
|
const headcount = 148;
|
||||||
|
const newHires30d = 9;
|
||||||
|
const attritionPct = 6; // lower is better
|
||||||
|
const attendancePct = 93;
|
||||||
|
const engagementPct = 78;
|
||||||
|
const hiresTrend = [2, 1, 3, 1, 2, 0];
|
||||||
|
const exitsTrend = [1, 0, 1, 0, 1, 1];
|
||||||
|
const deptDist = [
|
||||||
|
{ label: 'Engineering', value: 62, color: '#3AA0FF' },
|
||||||
|
{ label: 'Sales', value: 34, color: '#F59E0B' },
|
||||||
|
{ label: 'HR', value: 12, color: '#10B981' },
|
||||||
|
{ label: 'Ops', value: 28, color: '#6366F1' },
|
||||||
|
{ label: 'Finance', value: 12, color: '#EF4444' },
|
||||||
|
];
|
||||||
|
const holidays = [
|
||||||
|
{ date: '2025-09-10', name: 'Ganesh Chaturthi' },
|
||||||
|
{ date: '2025-10-02', name: 'Gandhi Jayanti' },
|
||||||
|
{ date: '2025-10-31', name: 'Diwali' },
|
||||||
|
];
|
||||||
|
const topPerformers = [
|
||||||
|
{ name: 'Aarti N.', score: 96 },
|
||||||
|
{ name: 'Rahul K.', score: 94 },
|
||||||
|
{ name: 'Meera S.', score: 92 },
|
||||||
|
];
|
||||||
|
return { headcount, newHires30d, attritionPct, attendancePct, engagementPct, hiresTrend, exitsTrend, deptDist, holidays, topPerformers };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// @ts-ignore
|
||||||
|
dispatch(fetchHRMetrics());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
if (loading && metrics.length === 0) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return <ErrorState message={error} onRetry={() => dispatch(fetchHRMetrics() as any)} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>HR Dashboard</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.content}>
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<View style={styles.kpiGrid}>
|
||||||
|
<Kpi label="Headcount" value={String(mock.headcount)} color={colors.text} fonts={fonts} accent="#3AA0FF" />
|
||||||
|
<Kpi label="New hires (30d)" value={String(mock.newHires30d)} color={colors.text} fonts={fonts} accent="#10B981" />
|
||||||
|
<Kpi label="Attrition" value={`${mock.attritionPct}%`} color={colors.text} fonts={fonts} accent="#EF4444" />
|
||||||
|
<Kpi label="Attendance" value={`${mock.attendancePct}%`} color={colors.text} fonts={fonts} accent="#6366F1" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Trends: Hires vs Exits */}
|
||||||
|
<View style={[styles.card, { borderColor: '#E2E8F0', backgroundColor: '#FFFFFF' }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Workforce Movements</Text>
|
||||||
|
<DualBars data={mock.hiresTrend.map((h, i) => ({ in: h, out: mock.exitsTrend[i] }))} max={Math.max(...mock.hiresTrend.map((h, i) => Math.max(h, mock.exitsTrend[i])))} colorA="#10B981" colorB="#EF4444" />
|
||||||
|
<View style={styles.legendRow}>
|
||||||
|
<View style={styles.legendItem}><View style={[styles.legendDot, { backgroundColor: '#10B981' }]} /><Text style={{ fontSize: 12, color: colors.text, fontFamily: fonts.regular }}>Hires</Text></View>
|
||||||
|
<View style={styles.legendItem}><View style={[styles.legendDot, { backgroundColor: '#EF4444' }]} /><Text style={{ fontSize: 12, color: colors.text, fontFamily: fonts.regular }}>Exits</Text></View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* People Health */}
|
||||||
|
<View style={[styles.card, { borderColor: '#E2E8F0', backgroundColor: '#FFFFFF' }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>People Health</Text>
|
||||||
|
<Progress label="Attendance" value={mock.attendancePct} color="#3AA0FF" fonts={fonts} />
|
||||||
|
<Progress label="Engagement" value={mock.engagementPct} color="#6366F1" fonts={fonts} />
|
||||||
|
<Progress label="Attrition (inverse)" value={100 - mock.attritionPct} color="#10B981" fonts={fonts} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Department Distribution */}
|
||||||
|
<View style={[styles.card, { borderColor: '#E2E8F0', backgroundColor: '#FFFFFF' }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Department Distribution</Text>
|
||||||
|
<Stacked segments={mock.deptDist} total={mock.deptDist.reduce((a, b) => a + b.value, 0)} />
|
||||||
|
<View style={styles.legendRow}>
|
||||||
|
{mock.deptDist.map(s => (
|
||||||
|
<View key={s.label} style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: s.color }]} />
|
||||||
|
<Text style={{ color: colors.text, fontFamily: fonts.regular }}>{s.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Lists: Holidays and Top Performers */}
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={[styles.card, styles.col, { borderColor: '#E2E8F0', backgroundColor: '#FFFFFF' }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Upcoming Holidays</Text>
|
||||||
|
{mock.holidays.map(h => (
|
||||||
|
<View key={h.date} style={styles.listRow}>
|
||||||
|
<Text style={[styles.listPrimary, { color: colors.text, fontFamily: fonts.regular }]}>{h.name}</Text>
|
||||||
|
<Text style={[styles.listSecondary, { color: colors.text, fontFamily: fonts.medium }]}>{h.date}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View style={[styles.card, styles.col, { borderColor: '#E2E8F0', backgroundColor: '#FFFFFF' }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Top Performers</Text>
|
||||||
|
{mock.topPerformers.map(p => (
|
||||||
|
<View key={p.name} style={styles.listRow}>
|
||||||
|
<Text style={[styles.listPrimary, { color: colors.text, fontFamily: fonts.regular }]}>{p.name}</Text>
|
||||||
|
<Text style={[styles.listSecondary, { color: colors.text, fontFamily: fonts.medium }]}>{p.score}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
header: {
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
kpiGrid: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between' },
|
||||||
|
kpiCard: { width: '48%', borderRadius: 12, borderWidth: 1, borderColor: '#E2E8F0', backgroundColor: '#FFFFFF', padding: 12, marginBottom: 12 },
|
||||||
|
kpiLabel: { fontSize: 12, opacity: 0.8 },
|
||||||
|
kpiValueRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: 8 },
|
||||||
|
card: { borderRadius: 12, borderWidth: 1, padding: 12, marginTop: 12 },
|
||||||
|
cardTitle: { fontSize: 16, marginBottom: 8 },
|
||||||
|
dualBarsRow: { flexDirection: 'row', alignItems: 'flex-end' },
|
||||||
|
dualBarWrap: { flex: 1, marginRight: 8 },
|
||||||
|
dualBarA: { borderTopLeftRadius: 4, borderTopRightRadius: 4 },
|
||||||
|
dualBarB: { borderTopLeftRadius: 4, borderTopRightRadius: 4, marginTop: 4 },
|
||||||
|
legendRow: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 8 },
|
||||||
|
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 12, marginTop: 6 },
|
||||||
|
legendDot: { width: 8, height: 8, borderRadius: 4, marginRight: 6 },
|
||||||
|
row: { flexDirection: 'row', marginTop: 12 },
|
||||||
|
col: { flex: 1, marginRight: 8 },
|
||||||
|
listRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#E5E7EB' },
|
||||||
|
listPrimary: { fontSize: 14, flex: 1, paddingRight: 8 },
|
||||||
|
listSecondary: { fontSize: 12 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default HRDashboardScreen;
|
||||||
|
|
||||||
|
// UI helpers (no external deps)
|
||||||
|
const Kpi: React.FC<{ label: string; value: string; color: string; fonts: any; accent: string }> = ({ label, value, color, fonts, accent }) => {
|
||||||
|
return (
|
||||||
|
<View style={styles.kpiCard}>
|
||||||
|
<Text style={[styles.kpiLabel, { color, fontFamily: fonts.regular }]}>{label}</Text>
|
||||||
|
<View style={styles.kpiValueRow}>
|
||||||
|
<Text style={{ color, fontSize: 20, fontFamily: fonts.bold }}>{value}</Text>
|
||||||
|
<View style={{ width: 10, height: 10, borderRadius: 5, backgroundColor: accent }} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DualBars: React.FC<{ data: { in: number; out: number }[]; max: number; colorA: string; colorB: string }> = ({ data, max, colorA, colorB }) => {
|
||||||
|
return (
|
||||||
|
<View style={styles.dualBarsRow}>
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<View key={i} style={styles.dualBarWrap}>
|
||||||
|
<View style={[styles.dualBarA, { height: Math.max(6, (d.in / Math.max(1, max)) * 64), backgroundColor: colorA }]} />
|
||||||
|
<View style={[styles.dualBarB, { height: Math.max(6, (d.out / Math.max(1, max)) * 64), backgroundColor: colorB }]} />
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Progress: React.FC<{ label: string; value: number; color: string; fonts: any }> = ({ label, value, color, fonts }) => {
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: 8 }}>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||||
|
<Text style={{ fontSize: 12, fontFamily: fonts.regular }}>{label}</Text>
|
||||||
|
<Text style={{ fontSize: 12, fontFamily: fonts.medium }}>{value}%</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ height: 8, borderRadius: 6, backgroundColor: '#E5E7EB', overflow: 'hidden' }}>
|
||||||
|
<View style={{ height: '100%', width: `${value}%`, backgroundColor: color }} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Stacked: React.FC<{ segments: { label: string; value: number; color: string }[]; total: number }> = ({ segments, total }) => {
|
||||||
|
return (
|
||||||
|
<View style={{ height: 12, borderRadius: 8, backgroundColor: '#E5E7EB', overflow: 'hidden', flexDirection: 'row', marginTop: 8 }}>
|
||||||
|
{segments.map(s => (
|
||||||
|
<View key={s.label} style={{ width: `${(s.value / Math.max(1, total)) * 100}%`, backgroundColor: s.color }} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
8
src/modules/hr/services/hrAPI.ts
Normal file
8
src/modules/hr/services/hrAPI.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import http from '@/services/http';
|
||||||
|
import { API_ENDPOINTS } from '@/shared/constants/API_ENDPOINTS';
|
||||||
|
|
||||||
|
export const hrAPI = {
|
||||||
|
getMetrics: () => http.get(API_ENDPOINTS.HR_METRICS),
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
53
src/modules/hr/store/hrSlice.ts
Normal file
53
src/modules/hr/store/hrSlice.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
export interface EmployeeMetric {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HRState {
|
||||||
|
metrics: EmployeeMetric[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: HRState = {
|
||||||
|
metrics: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchHRMetrics = createAsyncThunk('hr/fetchMetrics', async () => {
|
||||||
|
// TODO: integrate real HR API
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
return [
|
||||||
|
{ id: '1', name: 'Headcount', value: 42 },
|
||||||
|
{ id: '2', name: 'Attendance %', value: 96 },
|
||||||
|
] as EmployeeMetric[];
|
||||||
|
});
|
||||||
|
|
||||||
|
const hrSlice = createSlice({
|
||||||
|
name: 'hr',
|
||||||
|
initialState,
|
||||||
|
reducers: {},
|
||||||
|
extraReducers: builder => {
|
||||||
|
builder
|
||||||
|
.addCase(fetchHRMetrics.pending, state => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchHRMetrics.fulfilled, (state, action: PayloadAction<EmployeeMetric[]>) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.metrics = action.payload;
|
||||||
|
})
|
||||||
|
.addCase(fetchHRMetrics.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.error.message || 'Failed to load HR metrics';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default hrSlice;
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createStackNavigator } from '@react-navigation/stack';
|
||||||
|
import IntegrationsHomeScreen from '../screens/IntegrationsHomeScreen';
|
||||||
|
import IntegrationCategoryScreen from '../screens/IntegrationCategoryScreen';
|
||||||
|
|
||||||
|
export type IntegrationsStackParamList = {
|
||||||
|
IntegrationsHome: undefined;
|
||||||
|
IntegrationCategory: { categoryKey: string; title: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const Stack = createStackNavigator<IntegrationsStackParamList>();
|
||||||
|
|
||||||
|
const IntegrationsNavigator = () => (
|
||||||
|
<Stack.Navigator>
|
||||||
|
<Stack.Screen name="IntegrationsHome" component={IntegrationsHomeScreen} options={{ title: 'Integrations',headerShown:false }} />
|
||||||
|
<Stack.Screen name="IntegrationCategory" component={IntegrationCategoryScreen} options={({ route }) => ({ title: route.params.title,headerShown:false })} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default IntegrationsNavigator;
|
||||||
|
|
||||||
|
|
||||||
105
src/modules/integrations/screens/IntegrationCategoryScreen.tsx
Normal file
105
src/modules/integrations/screens/IntegrationCategoryScreen.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet, FlatList, TouchableOpacity } from 'react-native';
|
||||||
|
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
|
import type { RouteProp } from '@react-navigation/native';
|
||||||
|
import type { IntegrationsStackParamList } from '@/modules/integrations/navigation/IntegrationsNavigator';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { setSelectedService } from '@/modules/integrations/store/integrationsSlice';
|
||||||
|
import type { AppDispatch } from '@/store/store';
|
||||||
|
|
||||||
|
type Route = RouteProp<IntegrationsStackParamList, 'IntegrationCategory'>;
|
||||||
|
|
||||||
|
const servicesMap: Record<string, { key: string; title: string; icon: string }[]> = {
|
||||||
|
operations: [
|
||||||
|
{ key: 'zohoProjects', title: 'Zoho Projects', icon: 'briefcase-check' },
|
||||||
|
],
|
||||||
|
finance: [
|
||||||
|
{ key: 'quickbooks', title: 'QuickBooks', icon: 'finance' },
|
||||||
|
{ key: 'zohoBooks', title: 'Zoho Books', icon: 'book-open-variant' },
|
||||||
|
{ key: 'tally', title: 'Tally', icon: 'calculator-variant' },
|
||||||
|
{ key: 'xero', title: 'Xero', icon: 'bank' },
|
||||||
|
{ key: 'sapb1', title: 'SAP Business One', icon: 'factory' },
|
||||||
|
],
|
||||||
|
hr: [
|
||||||
|
{ key: 'zohoPeople', title: 'Zoho People', icon: 'account' },
|
||||||
|
{ key: 'bamboohr', title: 'BambooHR', icon: 'sprout' },
|
||||||
|
{ key: 'workday', title: 'Workday', icon: 'briefcase' },
|
||||||
|
{ key: 'keka', title: 'Keka', icon: 'account-cash' },
|
||||||
|
],
|
||||||
|
crm: [
|
||||||
|
{ key: 'zohoCRM', title: 'Zoho CRM', icon: 'account-box-multiple' },
|
||||||
|
{ key: 'hubspot', title: 'HubSpot', icon: 'chart-timeline-variant' },
|
||||||
|
{ key: 'salesforce', title: 'Salesforce', icon: 'cloud' },
|
||||||
|
{ key: 'pipedrive', title: 'Pipedrive', icon: 'pipe' },
|
||||||
|
],
|
||||||
|
collab: [
|
||||||
|
{ key: 'slack', title: 'Slack', icon: 'slack' },
|
||||||
|
{ key: 'teams', title: 'Microsoft Teams', icon: 'microsoft-teams' },
|
||||||
|
{ key: 'zoom', title: 'Zoom', icon: 'video' },
|
||||||
|
{ key: 'gworkspace', title: 'Google Workspace', icon: 'google' },
|
||||||
|
],
|
||||||
|
storage: [
|
||||||
|
{ key: 'gdrive', title: 'Google Drive', icon: 'google-drive' },
|
||||||
|
{ key: 'dropbox', title: 'Dropbox Business', icon: 'dropbox' },
|
||||||
|
{ key: 'onedrive', title: 'OneDrive', icon: 'microsoft-onedrive' },
|
||||||
|
{ key: 'sharepoint', title: 'SharePoint', icon: 'microsoft-sharepoint' },
|
||||||
|
{ key: 'box', title: 'Box', icon: 'cube' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
route: Route;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IntegrationCategoryScreen: React.FC<Props> = ({ route }) => {
|
||||||
|
const { colors, fonts } = useTheme();
|
||||||
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
|
const services = servicesMap[route.params.categoryKey] ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||||
|
<FlatList
|
||||||
|
data={services}
|
||||||
|
keyExtractor={item => item.key}
|
||||||
|
contentContainerStyle={{ padding: 16 }}
|
||||||
|
ItemSeparatorComponent={() => <View style={[styles.sep, { backgroundColor: colors.border }]} />}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.row}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
onPress={() => dispatch(setSelectedService(item.key))}
|
||||||
|
>
|
||||||
|
<View style={[styles.iconCircle, { backgroundColor: '#F1F5F9' }]}>
|
||||||
|
<Icon name={item.icon} size={20} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.medium }]}>{item.title}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1 },
|
||||||
|
sep: { height: 1, opacity: 0.6 },
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
iconCircle: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
title: { fontSize: 14 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default IntegrationCategoryScreen;
|
||||||
|
|
||||||
|
|
||||||
102
src/modules/integrations/screens/IntegrationsHomeScreen.tsx
Normal file
102
src/modules/integrations/screens/IntegrationsHomeScreen.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Dimensions, StatusBar } from 'react-native';
|
||||||
|
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
|
import type { StackNavigationProp } from '@react-navigation/stack';
|
||||||
|
import type { IntegrationsStackParamList } from '@/modules/integrations/navigation/IntegrationsNavigator';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import GradientBackground from '@/shared/components/layout/GradientBackground';
|
||||||
|
|
||||||
|
type Nav = StackNavigationProp<IntegrationsStackParamList, 'IntegrationsHome'>;
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{ key: 'operations', title: 'Operations', icon: 'cog' },
|
||||||
|
{ key: 'finance', title: 'Accounting & Finance Integration', icon: 'currency-usd' },
|
||||||
|
{ key: 'hr', title: 'HR Systems Integration', icon: 'account-group' },
|
||||||
|
{ key: 'crm', title: 'CRM & Sales Integration', icon: 'chart-line' },
|
||||||
|
{ key: 'collab', title: 'Communication & Collaboration', icon: 'message' },
|
||||||
|
{ key: 'storage', title: 'File Storage & Document Management', icon: 'folder' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const NUM_COLUMNS = 2;
|
||||||
|
const GUTTER = 12;
|
||||||
|
const screenWidth = Dimensions.get('window').width;
|
||||||
|
const CARD_WIDTH = (screenWidth - GUTTER * (NUM_COLUMNS + 1)) / NUM_COLUMNS;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
navigation: Nav;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IntegrationsHomeScreen: React.FC<Props> = () => {
|
||||||
|
const { colors, shadows, fonts } = useTheme();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GradientBackground colors={['#FFE9CC', '#F6E6FF']} style={{flex:1}}>
|
||||||
|
|
||||||
|
<View style={[styles.container]}>
|
||||||
|
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>Choose a Service</Text>
|
||||||
|
<FlatList
|
||||||
|
data={categories}
|
||||||
|
keyExtractor={item => item.key}
|
||||||
|
numColumns={NUM_COLUMNS}
|
||||||
|
contentContainerStyle={{ padding: GUTTER }}
|
||||||
|
columnWrapperStyle={{ justifyContent: 'space-between' }}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.8}
|
||||||
|
// @ts-ignore
|
||||||
|
onPress={() => navigation.navigate('IntegrationCategory', { categoryKey: item.key, title: item.title })}
|
||||||
|
style={[
|
||||||
|
styles.card,
|
||||||
|
{ backgroundColor: colors.surface, borderColor: colors.border, width: CARD_WIDTH, ...shadows.light },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[styles.iconCircle, { backgroundColor: '#F1F5F9' }]}>
|
||||||
|
<Icon name={item.icon} size={24} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]} numberOfLines={2}>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 20,
|
||||||
|
marginTop: 12,
|
||||||
|
marginHorizontal: GUTTER,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: GUTTER,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
iconCircle: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default IntegrationsHomeScreen;
|
||||||
|
|
||||||
|
|
||||||
27
src/modules/integrations/store/integrationsSlice.ts
Normal file
27
src/modules/integrations/store/integrationsSlice.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
interface IntegrationsState {
|
||||||
|
selectedService: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: IntegrationsState = {
|
||||||
|
selectedService: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const integrationsSlice = createSlice({
|
||||||
|
name: 'integrations',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setSelectedService: (state, action: PayloadAction<string>) => {
|
||||||
|
state.selectedService = action.payload;
|
||||||
|
},
|
||||||
|
clearSelectedService: state => {
|
||||||
|
state.selectedService = null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setSelectedService, clearSelectedService } = integrationsSlice.actions;
|
||||||
|
export default integrationsSlice;
|
||||||
|
|
||||||
|
|
||||||
15
src/modules/profile/navigation/ProfileNavigator.tsx
Normal file
15
src/modules/profile/navigation/ProfileNavigator.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createStackNavigator } from '@react-navigation/stack';
|
||||||
|
import ProfileScreen from '@/modules/profile/screens/ProfileScreen';
|
||||||
|
|
||||||
|
const Stack = createStackNavigator();
|
||||||
|
|
||||||
|
const ProfileNavigator = () => (
|
||||||
|
<Stack.Navigator>
|
||||||
|
<Stack.Screen name="Profile" component={ProfileScreen} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ProfileNavigator;
|
||||||
|
|
||||||
|
|
||||||
169
src/modules/profile/screens/ProfileScreen.tsx
Normal file
169
src/modules/profile/screens/ProfileScreen.tsx
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity, Image } from 'react-native';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { Container, ConfirmModal } from '@/shared/components/ui';
|
||||||
|
import type { RootState } from '@/store/store';
|
||||||
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
|
import { logout } from '@/modules/auth/store/authSlice';
|
||||||
|
import { setProfile } from '@/modules/profile/store/profileSlice';
|
||||||
|
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
|
import { clearSelectedService } from '@/modules/integrations/store/integrationsSlice';
|
||||||
|
|
||||||
|
const ProfileScreen: React.FC = () => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const { colors, fonts } = useTheme();
|
||||||
|
const { name, email } = useSelector((s: RootState) => s.profile);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Seed dummy data if empty
|
||||||
|
if (!name && !email) {
|
||||||
|
dispatch(setProfile({ name: 'Jane Doe', email: 'jane.doe@example.com' }));
|
||||||
|
}
|
||||||
|
}, [dispatch, name, email]);
|
||||||
|
|
||||||
|
const [showLogout, setShowLogout] = React.useState(false);
|
||||||
|
const handleLogout = () => setShowLogout(true);
|
||||||
|
const handleConfirmLogout = () => {
|
||||||
|
setShowLogout(false);
|
||||||
|
dispatch(clearSelectedService());
|
||||||
|
dispatch(logout());
|
||||||
|
};
|
||||||
|
const handleCancelLogout = () => setShowLogout(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<View style={[styles.top, { backgroundColor: colors.background }]}>
|
||||||
|
{/* Avatar */}
|
||||||
|
<View style={styles.avatarWrap}>
|
||||||
|
<View style={[styles.avatarCircle, { backgroundColor: '#E5E7EB' }]}>
|
||||||
|
{/* If you have an image, replace with <Image source={{ uri: ... }} style={styles.avatarImage}/> */}
|
||||||
|
<Icon name="account" size={56} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<View style={[styles.editBadge, { backgroundColor: colors.primary }]}>
|
||||||
|
<Icon name="pencil" size={14} color={colors.surface} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<Text style={[styles.displayName, { color: colors.text, fontFamily: fonts.bold }]}>{name || 'Sana Afzal'}</Text>
|
||||||
|
|
||||||
|
{/* Email pill */}
|
||||||
|
<View style={[styles.emailPill, { backgroundColor: '#DFE9FF' }]}>
|
||||||
|
<Text style={{ color: colors.text, fontFamily: fonts.regular, fontSize: 12 }}>{email || 'sanaaafzal291@gmail.com'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Settings card */}
|
||||||
|
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||||
|
<MenuItem icon="pencil-outline" label="Edit Profile" onPress={() => {}} />
|
||||||
|
<Separator color={colors.border} />
|
||||||
|
<MenuItem icon="shield-lock-outline" label="Add Pin" onPress={() => {}} />
|
||||||
|
<Separator color={colors.border} />
|
||||||
|
<MenuItem icon="cog-outline" label="Settings" onPress={() => {}} />
|
||||||
|
<Separator color={colors.border} />
|
||||||
|
<MenuItem icon="account-plus-outline" label="Invite a friend" onPress={() => {}} />
|
||||||
|
<Separator color={colors.border} />
|
||||||
|
<MenuItem icon="logout" label="Logout" danger onPress={handleLogout} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
visible={showLogout}
|
||||||
|
title="Logout"
|
||||||
|
message="Are you sure you want to logout?"
|
||||||
|
confirmText="Logout"
|
||||||
|
cancelText="Cancel"
|
||||||
|
onConfirm={handleConfirmLogout}
|
||||||
|
onCancel={handleCancelLogout}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
top: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 32,
|
||||||
|
},
|
||||||
|
avatarWrap: {
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
avatarCircle: {
|
||||||
|
width: 104,
|
||||||
|
height: 104,
|
||||||
|
borderRadius: 52,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
avatarImage: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
editBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: -2,
|
||||||
|
bottom: 2,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
displayName: {
|
||||||
|
fontSize: 22,
|
||||||
|
marginTop: 14,
|
||||||
|
},
|
||||||
|
emailPill: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 14,
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
marginTop: 20,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
interface MenuItemProps { icon: string; label: string; onPress: () => void; danger?: boolean }
|
||||||
|
const MenuItem: React.FC<MenuItemProps> = ({ icon, label, onPress, danger }) => {
|
||||||
|
const { colors, fonts } = useTheme();
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={menuStyles.row} activeOpacity={0.8} onPress={onPress}>
|
||||||
|
<View style={[menuStyles.left, danger && { opacity: 0.9 }]}>
|
||||||
|
<Icon name={icon} size={18} color={danger ? '#EF4444' : colors.text} />
|
||||||
|
<Text style={[menuStyles.label, { color: danger ? '#EF4444' : colors.text, fontFamily: fonts.regular }]}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
<Icon name="chevron-right" size={20} color={danger ? '#EF4444' : colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Separator: React.FC<{ color: string }> = ({ color }) => (
|
||||||
|
<View style={{ height: 1, backgroundColor: color, opacity: 0.6 }} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const menuStyles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ProfileScreen;
|
||||||
|
|
||||||
|
|
||||||
38
src/modules/profile/store/profileSlice.ts
Normal file
38
src/modules/profile/store/profileSlice.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
export interface ProfileState {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ProfileState = {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const profileSlice = createSlice({
|
||||||
|
name: 'profile',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setProfile: (state, action: PayloadAction<{ name: string; email: string }>) => {
|
||||||
|
state.name = action.payload.name;
|
||||||
|
state.email = action.payload.email;
|
||||||
|
},
|
||||||
|
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.loading = action.payload;
|
||||||
|
},
|
||||||
|
setError: (state, action: PayloadAction<string | null>) => {
|
||||||
|
state.error = action.payload;
|
||||||
|
},
|
||||||
|
resetState: () => initialState,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setProfile, setLoading, setError, resetState } = profileSlice.actions;
|
||||||
|
export default profileSlice;
|
||||||
|
|
||||||
|
|
||||||
15
src/modules/storage/navigation/StorageNavigator.tsx
Normal file
15
src/modules/storage/navigation/StorageNavigator.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createStackNavigator } from '@react-navigation/stack';
|
||||||
|
import StorageDashboardScreen from '@/modules/storage/screens/StorageDashboardScreen';
|
||||||
|
|
||||||
|
const Stack = createStackNavigator();
|
||||||
|
|
||||||
|
const StorageNavigator = () => (
|
||||||
|
<Stack.Navigator>
|
||||||
|
<Stack.Screen name="StorageDashboard" component={StorageDashboardScreen} options={{headerShown:false}}/>
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default StorageNavigator;
|
||||||
|
|
||||||
|
|
||||||
155
src/modules/storage/screens/StorageDashboardScreen.tsx
Normal file
155
src/modules/storage/screens/StorageDashboardScreen.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { Container } from '@/shared/components/ui';
|
||||||
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import type { RootState } from '@/store/store';
|
||||||
|
|
||||||
|
const StorageDashboardScreen: React.FC = () => {
|
||||||
|
const { colors, fonts } = useTheme();
|
||||||
|
const selectedService = useSelector((s: RootState) => s.integrations.selectedService);
|
||||||
|
|
||||||
|
const mock = useMemo(() => {
|
||||||
|
const totalUsageGb = '824/2000'; // GB
|
||||||
|
const filesCount = 58240;
|
||||||
|
const sharedLinks = 312;
|
||||||
|
const activeUsers = 86;
|
||||||
|
const monthlyUsage = [620, 640, 660, 700, 760, 824]; // GB
|
||||||
|
// Distribution within the active provider (file types)
|
||||||
|
const typeDist = [
|
||||||
|
{ label: 'Images', value: 260, color: '#3AA0FF' },
|
||||||
|
{ label: 'Videos', value: 140, color: '#6366F1' },
|
||||||
|
{ label: 'Documents', value: 280, color: '#10B981' },
|
||||||
|
{ label: 'PDFs', value: 96, color: '#F59E0B' },
|
||||||
|
{ label: 'Others', value: 48, color: '#EF4444' },
|
||||||
|
];
|
||||||
|
const recentActivity = [
|
||||||
|
{ name: 'Q3_Report.pdf', action: 'Shared', when: '2h ago' },
|
||||||
|
{ name: 'BrandAssets.zip', action: 'Uploaded', when: '5h ago' },
|
||||||
|
{ name: 'Sales-2025.xlsx', action: 'Edited', when: '1d ago' },
|
||||||
|
{ name: 'Engineering-Docs', action: 'Moved', when: '2d ago' },
|
||||||
|
];
|
||||||
|
const topFolders = [
|
||||||
|
{ name: 'Product Design', size: 186 },
|
||||||
|
{ name: 'Client Deliverables', size: 152 },
|
||||||
|
{ name: 'Marketing Assets', size: 104 },
|
||||||
|
{ name: 'Engineering Docs', size: 88 },
|
||||||
|
];
|
||||||
|
return { totalUsageGb, filesCount, sharedLinks, activeUsers, monthlyUsage, typeDist, recentActivity, topFolders };
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<View style={[styles.wrap, { backgroundColor: colors.background }]}>
|
||||||
|
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>File Storage & Document Management</Text>
|
||||||
|
|
||||||
|
{/* KPIs */}
|
||||||
|
<View style={styles.kpiGrid}>
|
||||||
|
<Kpi label="Total Usage" value={`${mock.totalUsageGb} GB`} color={colors.text} fonts={fonts} accent="#3AA0FF" />
|
||||||
|
<Kpi label="Files" value={mock.filesCount.toLocaleString()} color={colors.text} fonts={fonts} accent="#10B981" />
|
||||||
|
<Kpi label="Shared Links" value={String(mock.sharedLinks)} color={colors.text} fonts={fonts} accent="#F59E0B" />
|
||||||
|
<Kpi label="Active Users" value={String(mock.activeUsers)} color={colors.text} fonts={fonts} accent="#6366F1" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Usage Trend */}
|
||||||
|
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Storage Growth</Text>
|
||||||
|
<Bars data={mock.monthlyUsage} max={Math.max(...mock.monthlyUsage)} color="#3AA0FF" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* File Types Distribution (scoped to current provider) */}
|
||||||
|
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>File Types</Text>
|
||||||
|
<Stacked segments={mock.typeDist} total={mock.typeDist.reduce((a, b) => a + b.value, 0)} />
|
||||||
|
<View style={styles.legendRow}>
|
||||||
|
{mock.typeDist.map(s => (
|
||||||
|
<View key={s.label} style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: s.color }]} />
|
||||||
|
<Text style={{ color: colors.text, fontFamily: fonts.regular }}>{s.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Lists */}
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={[styles.card, styles.col, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Recent Activity</Text>
|
||||||
|
{mock.recentActivity.map(a => (
|
||||||
|
<View key={`${a.name}-${a.when}`} style={styles.listRow}>
|
||||||
|
<Text style={[styles.listPrimary, { color: colors.text, fontFamily: fonts.regular }]}>{a.name}</Text>
|
||||||
|
<Text style={[styles.listSecondary, { color: colors.text, fontFamily: fonts.medium }]}>{a.action} · {a.when}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View style={[styles.card, styles.col, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Top Folders</Text>
|
||||||
|
{mock.topFolders.map(f => (
|
||||||
|
<View key={f.name} style={styles.listRow}>
|
||||||
|
<Text style={[styles.listPrimary, { color: colors.text, fontFamily: fonts.regular }]}>{f.name}</Text>
|
||||||
|
<Text style={[styles.listSecondary, { color: colors.text, fontFamily: fonts.medium }]}>{f.size} GB</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrap: { flex: 1, padding: 16 },
|
||||||
|
title: { fontSize: 18, marginBottom: 8 },
|
||||||
|
kpiGrid: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between' },
|
||||||
|
kpiCard: { width: '48%', borderRadius: 12, borderWidth: 1, borderColor: '#E2E8F0', backgroundColor: '#FFFFFF', padding: 12, marginBottom: 12 },
|
||||||
|
kpiLabel: { fontSize: 12, opacity: 0.8 },
|
||||||
|
kpiValueRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: 8 },
|
||||||
|
card: { borderRadius: 12, borderWidth: 1, padding: 12, marginTop: 12 },
|
||||||
|
cardTitle: { fontSize: 16, marginBottom: 8 },
|
||||||
|
bars: { flexDirection: 'row', alignItems: 'flex-end' },
|
||||||
|
bar: { flex: 1, marginRight: 6, borderTopLeftRadius: 4, borderTopRightRadius: 4 },
|
||||||
|
legendRow: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 8 },
|
||||||
|
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 12, marginTop: 6 },
|
||||||
|
legendDot: { width: 8, height: 8, borderRadius: 4, marginRight: 6 },
|
||||||
|
row: { marginTop: 12 },
|
||||||
|
col: { flex: 1, marginRight: 8 },
|
||||||
|
listRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#E5E7EB' },
|
||||||
|
listPrimary: { fontSize: 14, flex: 1, paddingRight: 8 },
|
||||||
|
listSecondary: { fontSize: 12 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default StorageDashboardScreen;
|
||||||
|
|
||||||
|
// UI bits
|
||||||
|
const Kpi: React.FC<{ label: string; value: string; color: string; fonts: any; accent: string }> = ({ label, value, color, fonts, accent }) => {
|
||||||
|
return (
|
||||||
|
<View style={styles.kpiCard}>
|
||||||
|
<Text style={[styles.kpiLabel, { color, fontFamily: fonts.regular }]}>{label}</Text>
|
||||||
|
<View style={styles.kpiValueRow}>
|
||||||
|
<Text style={{ color, fontSize: 20, fontFamily: fonts.bold }}>{value}</Text>
|
||||||
|
<View style={{ width: 10, height: 10, borderRadius: 5, backgroundColor: accent }} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Bars: React.FC<{ data: number[]; max: number; color: string }> = ({ data, max, color }) => {
|
||||||
|
return (
|
||||||
|
<View style={styles.bars}>
|
||||||
|
{data.map((v, i) => (
|
||||||
|
<View key={i} style={[styles.bar, { height: Math.max(6, (v / Math.max(1, max)) * 64), backgroundColor: color }]} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Stacked: React.FC<{ segments: { label: string; value: number; color: string }[]; total: number }> = ({ segments, total }) => {
|
||||||
|
return (
|
||||||
|
<View style={{ height: 12, borderRadius: 8, backgroundColor: '#E5E7EB', overflow: 'hidden', flexDirection: 'row', marginTop: 8 }}>
|
||||||
|
{segments.map(s => (
|
||||||
|
<View key={s.label} style={{ width: `${(s.value / Math.max(1, total)) * 100}%`, backgroundColor: s.color }} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createStackNavigator } from '@react-navigation/stack';
|
||||||
|
import ZohoProjectsDashboardScreen from '@/modules/zohoProjects/screens/ZohoProjectsDashboardScreen';
|
||||||
|
|
||||||
|
const Stack = createStackNavigator();
|
||||||
|
|
||||||
|
const ZohoProjectsNavigator = () => (
|
||||||
|
<Stack.Navigator>
|
||||||
|
<Stack.Screen name="ZohoProjectsDashboard" component={ZohoProjectsDashboardScreen} options={{headerShown:false}}/>
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ZohoProjectsNavigator;
|
||||||
|
|
||||||
|
|
||||||
520
src/modules/zohoProjects/screens/ZohoProjectsDashboardScreen.tsx
Normal file
520
src/modules/zohoProjects/screens/ZohoProjectsDashboardScreen.tsx
Normal file
@ -0,0 +1,520 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { View, Text, ScrollView, RefreshControl, StyleSheet } from 'react-native';
|
||||||
|
import Icon from 'react-native-vector-icons/MaterialIcons';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
import { Container, LoadingSpinner, ErrorState } from '@/shared/components/ui';
|
||||||
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
|
import { fetchZohoProjects } from '@/modules/zohoProjects/store/zohoProjectsSlice';
|
||||||
|
import type { RootState } from '@/store/store';
|
||||||
|
|
||||||
|
const ZohoProjectsDashboardScreen: React.FC = () => {
|
||||||
|
const { colors, fonts } = useTheme();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const { projects, loading, error } = useSelector((s: RootState) => s.zohoProjects);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch projects on mount
|
||||||
|
// Guard: avoid duplicate fetch while loading
|
||||||
|
if (!loading) {
|
||||||
|
// @ts-ignore
|
||||||
|
dispatch(fetchZohoProjects());
|
||||||
|
}
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
// @ts-ignore
|
||||||
|
await dispatch(fetchZohoProjects());
|
||||||
|
setRefreshing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock analytics data (UI only)
|
||||||
|
const mock = useMemo(() => {
|
||||||
|
const backlog = 128;
|
||||||
|
const inProgress = 36;
|
||||||
|
const completed = 412;
|
||||||
|
const blocked = 7;
|
||||||
|
const onTimePct = 84; // percent
|
||||||
|
const qualityScore = 92; // percent
|
||||||
|
const utilizationPct = 73; // percent
|
||||||
|
const burndown = [32, 30, 28, 26, 24, 20, 18];
|
||||||
|
const velocity = [18, 22, 19, 24, 26, 23];
|
||||||
|
const statusDist = [
|
||||||
|
{ label: 'Open', value: backlog, color: '#3AA0FF' },
|
||||||
|
{ label: 'In Progress', value: inProgress, color: '#F59E0B' },
|
||||||
|
{ label: 'Blocked', value: blocked, color: '#EF4444' },
|
||||||
|
{ label: 'Done', value: completed, color: '#10B981' },
|
||||||
|
];
|
||||||
|
const risks = [
|
||||||
|
{ id: 'R-1042', title: 'Scope creep in Phase 2', impact: 'High' },
|
||||||
|
{ id: 'R-1047', title: 'Resource bandwidth next sprint', impact: 'Medium' },
|
||||||
|
{ id: 'R-1051', title: 'Third-party API rate limits', impact: 'Medium' },
|
||||||
|
];
|
||||||
|
const topClients = [
|
||||||
|
{ name: 'Acme Corp', projects: 12 },
|
||||||
|
{ name: 'Globex', projects: 9 },
|
||||||
|
{ name: 'Initech', projects: 7 },
|
||||||
|
];
|
||||||
|
// New patterns
|
||||||
|
const kanban = { todo: 64, doing: 28, review: 12, done: completed };
|
||||||
|
const milestones = [
|
||||||
|
{ name: 'M1: Requirements Freeze', due: 'Aug 25', progress: 100 },
|
||||||
|
{ name: 'M2: MVP Complete', due: 'Sep 10', progress: 72 },
|
||||||
|
{ name: 'M3: Beta Release', due: 'Sep 28', progress: 38 },
|
||||||
|
];
|
||||||
|
const teams = [
|
||||||
|
{ name: 'Frontend', capacityPct: 76 },
|
||||||
|
{ name: 'Backend', capacityPct: 68 },
|
||||||
|
{ name: 'QA', capacityPct: 54 },
|
||||||
|
{ name: 'DevOps', capacityPct: 62 },
|
||||||
|
];
|
||||||
|
// Zoho Projects specific
|
||||||
|
const sprintName = 'Sprint 24 - September';
|
||||||
|
const sprintDates = 'Sep 01 → Sep 14';
|
||||||
|
const scopeChange = +6; // tasks added
|
||||||
|
const bugSeverityDist = [
|
||||||
|
{ label: 'Critical', value: 4, color: '#EF4444' },
|
||||||
|
{ label: 'High', value: 12, color: '#F59E0B' },
|
||||||
|
{ label: 'Medium', value: 18, color: '#3AA0FF' },
|
||||||
|
{ label: 'Low', value: 9, color: '#10B981' },
|
||||||
|
];
|
||||||
|
const priorityDist = [
|
||||||
|
{ label: 'P1', value: 8, color: '#EF4444' },
|
||||||
|
{ label: 'P2', value: 16, color: '#F59E0B' },
|
||||||
|
{ label: 'P3', value: 22, color: '#3AA0FF' },
|
||||||
|
{ label: 'P4', value: 12, color: '#10B981' },
|
||||||
|
];
|
||||||
|
const timesheets = { totalHours: 436, billableHours: 312 };
|
||||||
|
const backlogAging = [42, 31, 18, 12]; // 0-7, 8-14, 15-30, 30+
|
||||||
|
const assigneeLoad = [
|
||||||
|
{ name: 'Aarti', pct: 82 },
|
||||||
|
{ name: 'Rahul', pct: 74 },
|
||||||
|
{ name: 'Meera', pct: 66 },
|
||||||
|
{ name: 'Neeraj', pct: 58 },
|
||||||
|
];
|
||||||
|
return { backlog, inProgress, completed, blocked, onTimePct, qualityScore, utilizationPct, burndown, velocity, statusDist, risks, topClients, kanban, milestones, teams, sprintName, sprintDates, scopeChange, bugSeverityDist, priorityDist, timesheets, backlogAging, assigneeLoad };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading && projects.length === 0) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <ErrorState message={error} onRetry={() => dispatch(fetchZohoProjects() as any)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.container}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />}
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>Zoho Projects</Text>
|
||||||
|
<Icon name="insights" size={24} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
{/* Sprint Header */}
|
||||||
|
<View style={[styles.card, { borderColor: '#E2E8F0', backgroundColor: '#FFFFFF' }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Active Sprint</Text>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontSize: 14, color: colors.text, fontFamily: fonts.bold }}>{mock.sprintName}</Text>
|
||||||
|
<Text style={{ fontSize: 12, color: colors.text, fontFamily: fonts.regular, opacity: 0.8 }}>{mock.sprintDates}</Text>
|
||||||
|
</View>
|
||||||
|
<Chip label={mock.scopeChange > 0 ? 'Scope +' : 'Scope'} value={Math.abs(mock.scopeChange)} dot={mock.scopeChange > 0 ? '#F59E0B' : '#10B981'} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<View style={styles.kpiGrid}>
|
||||||
|
<KpiCard label="Backlog" value={mock.backlog} color={colors.text} accent="#3AA0FF" />
|
||||||
|
<KpiCard label="In Progress" value={mock.inProgress} color={colors.text} accent="#F59E0B" />
|
||||||
|
<KpiCard label="Completed" value={mock.completed} color={colors.text} accent="#10B981" />
|
||||||
|
<KpiCard label="Blocked" value={mock.blocked} color={colors.text} accent="#EF4444" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Trend: Burndown & Velocity (mini bars) */}
|
||||||
|
<View style={[styles.card, { borderColor: '#E2E8F0', backgroundColor: '#FFFFFF' }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Sprint Trends</Text>
|
||||||
|
<View style={styles.trendRow}>
|
||||||
|
<View style={styles.trendBlock}>
|
||||||
|
<Text style={[styles.trendTitle, { color: colors.text, fontFamily: fonts.medium }]}>Burndown</Text>
|
||||||
|
<MiniBars data={mock.burndown} color="#3AA0FF" max={Math.max(...mock.burndown)} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.trendBlock}>
|
||||||
|
<Text style={[styles.trendTitle, { color: colors.text, fontFamily: fonts.medium }]}>Velocity</Text>
|
||||||
|
<MiniBars data={mock.velocity} color="#10B981" max={Math.max(...mock.velocity)} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bugs & Priority Mix */}
|
||||||
|
<View style={styles.twoCol}>
|
||||||
|
<View style={[styles.card, styles.col, { borderColor: '#E2E8F0', backgroundColor: '#FFFFFF' }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Bugs by Severity</Text>
|
||||||
|
<StackedBar segments={mock.bugSeverityDist} total={mock.bugSeverityDist.reduce((a, b) => a + b.value, 0)} />
|
||||||
|
<View style={styles.legendRow}>
|
||||||
|
{mock.bugSeverityDist.map(s => (
|
||||||
|
<View key={s.label} style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: s.color }]} />
|
||||||
|
<Text style={{ color: colors.text, fontFamily: fonts.regular }}>{s.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.card, styles.col, { borderColor: '#E2E8F0', backgroundColor: '#FFFFFF' }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Tasks by Priority</Text>
|
||||||
|
<StackedBar segments={mock.priorityDist} total={mock.priorityDist.reduce((a, b) => a + b.value, 0)} />
|
||||||
|
<View style={styles.legendRow}>
|
||||||
|
{mock.priorityDist.map(s => (
|
||||||
|
<View key={s.label} style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: s.color }]} />
|
||||||
|
<Text style={{ color: colors.text, fontFamily: fonts.regular }}>{s.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Timesheets & Aging */}
|
||||||
|
<View style={styles.twoCol}>
|
||||||
|
<View style={[styles.card, styles.col, { borderColor: '#E2E8F0', backgroundColor: '#FFFFFF' }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Timesheets</Text>
|
||||||
|
<Text style={{ fontSize: 12, fontFamily: fonts.regular, color: colors.text }}>Total: {mock.timesheets.totalHours}h</Text>
|
||||||
|
{(() => {
|
||||||
|
const billablePct = Math.round((mock.timesheets.billableHours / Math.max(1, mock.timesheets.totalHours)) * 100);
|
||||||
|
return <ProgressRow label={`Billable (${mock.timesheets.billableHours}h)`} value={billablePct} color="#10B981" />;
|
||||||
|
})()}
|
||||||
|
{(() => {
|
||||||
|
const non = mock.timesheets.totalHours - mock.timesheets.billableHours;
|
||||||
|
const pct = Math.round((non / Math.max(1, mock.timesheets.totalHours)) * 100);
|
||||||
|
return <ProgressRow label={`Non-billable (${non}h)`} value={pct} color="#F59E0B" />;
|
||||||
|
})()}
|
||||||
|
</View>
|
||||||
|
<View style={[styles.card, styles.col, { borderColor: '#E2E8F0', backgroundColor: '#FFFFFF' }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Backlog Aging</Text>
|
||||||
|
<MiniBars data={mock.backlogAging} color="#6366F1" max={Math.max(...mock.backlogAging)} />
|
||||||
|
<View style={styles.legendRow}>
|
||||||
|
{['0-7d', '8-14d', '15-30d', '30+d'].map(label => (
|
||||||
|
<View key={label} style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: '#6366F1' }]} />
|
||||||
|
<Text style={{ color: colors.text, fontFamily: fonts.regular }}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Assignee Workload */}
|
||||||
|
<View style={[styles.card, { borderColor: '#E2E8F0', backgroundColor: '#FFFFFF' }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Assignee Workload</Text>
|
||||||
|
{mock.assigneeLoad.map(a => (
|
||||||
|
<ProgressRow key={a.name} label={a.name} value={a.pct} color="#3AA0FF" />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{/* Progress: On-time, Quality, Utilization */}
|
||||||
|
<View style={[styles.card, { borderColor: '#E2E8F0', backgroundColor: '#FFFFFF' }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Operational Health</Text>
|
||||||
|
<ProgressRow label="On-time delivery" value={mock.onTimePct} color="#3AA0FF" />
|
||||||
|
<ProgressRow label="Quality score" value={mock.qualityScore} color="#10B981" />
|
||||||
|
<ProgressRow label="Resource utilization" value={mock.utilizationPct} color="#F59E0B" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Status distribution */}
|
||||||
|
<View style={[styles.card, { borderColor: '#E2E8F0', backgroundColor: '#FFFFFF' }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Status Distribution</Text>
|
||||||
|
<StackedBar segments={mock.statusDist} total={mock.statusDist.reduce((a, b) => a + b.value, 0)} />
|
||||||
|
<View style={styles.legendRow}>
|
||||||
|
{mock.statusDist.map(s => (
|
||||||
|
<View key={s.label} style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: s.color }]} />
|
||||||
|
<Text style={{ color: colors.text, fontFamily: fonts.regular }}>{s.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Kanban Snapshot */}
|
||||||
|
<View style={[styles.card, { borderColor: '#E2E8F0', backgroundColor: '#FFFFFF' }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Kanban Snapshot</Text>
|
||||||
|
<View style={styles.badgeRow}>
|
||||||
|
<Chip label="To Do" value={mock.kanban.todo} dot="#3AA0FF" />
|
||||||
|
<Chip label="Doing" value={mock.kanban.doing} dot="#F59E0B" />
|
||||||
|
<Chip label="Review" value={mock.kanban.review} dot="#6366F1" />
|
||||||
|
<Chip label="Done" value={mock.kanban.done} dot="#10B981" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Lists: Risks and Top Clients */}
|
||||||
|
<View style={styles.twoCol}>
|
||||||
|
<View style={[styles.card, styles.col, { borderColor: '#E2E8F0', backgroundColor: '#FFFFFF' }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Top Risks</Text>
|
||||||
|
{mock.risks.map(r => (
|
||||||
|
<View key={r.id} style={styles.listRow}>
|
||||||
|
<Text style={[styles.listPrimary, { color: colors.text, fontFamily: fonts.regular }]}>{r.title}</Text>
|
||||||
|
<Text style={[styles.listSecondary, { color: '#EF4444', fontFamily: fonts.regular }]}>{r.impact}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View style={[styles.card, styles.col, { borderColor: '#E2E8F0', backgroundColor: '#FFFFFF' }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Top Clients</Text>
|
||||||
|
{mock.topClients.map(c => (
|
||||||
|
<View key={c.name} style={styles.listRow}>
|
||||||
|
<Text style={[styles.listPrimary, { color: colors.text, fontFamily: fonts.regular }]}>{c.name}</Text>
|
||||||
|
<Text style={[styles.listSecondary, { color: colors.text, fontFamily: fonts.regular }]}>{c.projects} projects</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Milestones Progress */}
|
||||||
|
<View style={[styles.card, { borderColor: '#E2E8F0', backgroundColor: '#FFFFFF' }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Milestones</Text>
|
||||||
|
{mock.milestones.map(m => (
|
||||||
|
<View key={m.name} style={{ marginTop: 8 }}>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||||
|
<Text style={{ fontSize: 12, fontFamily: fonts.regular, color: colors.text }}>{m.name}</Text>
|
||||||
|
<Text style={{ fontSize: 12, fontFamily: fonts.medium, color: colors.text }}>{m.due}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.progressTrack}>
|
||||||
|
<View style={[styles.progressFill, { width: `${m.progress}%`, backgroundColor: '#3AA0FF' }]} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Team Capacity */}
|
||||||
|
<View style={[styles.card, { borderColor: '#E2E8F0', backgroundColor: '#FFFFFF' }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Team Capacity</Text>
|
||||||
|
{mock.teams.map(t => (
|
||||||
|
<ProgressRow key={t.name} label={t.name} value={t.capacityPct} color="#6366F1" />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
kpiGrid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
kpiCard: {
|
||||||
|
width: '48%',
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E2E8F0',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
kpiLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
kpiValueRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 12,
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
trendRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
},
|
||||||
|
trendBlock: {
|
||||||
|
flex: 1,
|
||||||
|
paddingRight: 8,
|
||||||
|
},
|
||||||
|
trendTitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
opacity: 0.8,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
miniBars: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
miniBar: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 6,
|
||||||
|
borderTopLeftRadius: 4,
|
||||||
|
borderTopRightRadius: 4,
|
||||||
|
},
|
||||||
|
progressRow: {
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
progressLabelWrap: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
progressTrack: {
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: '#E5E7EB',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
progressFill: {
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
stackedBar: {
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: '#E5E7EB',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
legendRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
legendItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
legendDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
twoCol: {
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
badgeRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
borderRadius: 16,
|
||||||
|
marginRight: 8,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
chipDot: { width: 8, height: 8, borderRadius: 4, marginRight: 6 },
|
||||||
|
col: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
listRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
},
|
||||||
|
listPrimary: {
|
||||||
|
fontSize: 14,
|
||||||
|
flex: 1,
|
||||||
|
paddingRight: 8,
|
||||||
|
},
|
||||||
|
listSecondary: {
|
||||||
|
fontSize: 12,
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ZohoProjectsDashboardScreen;
|
||||||
|
|
||||||
|
// UI subcomponents (no external deps)
|
||||||
|
const KpiCard: React.FC<{ label: string; value: number | string; color: string; accent: string }> = ({ label, value, color, accent }) => {
|
||||||
|
const { fonts } = useTheme();
|
||||||
|
return (
|
||||||
|
<View style={styles.kpiCard}>
|
||||||
|
<Text style={[styles.kpiLabel, { color, fontFamily: fonts.regular }]}>{label}</Text>
|
||||||
|
<View style={styles.kpiValueRow}>
|
||||||
|
<Text style={{ color, fontSize: 22, fontFamily: fonts.bold }}>{value}</Text>
|
||||||
|
<View style={{ width: 10, height: 10, borderRadius: 5, backgroundColor: accent }} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MiniBars: React.FC<{ data: number[]; color: string; max: number }> = ({ data, color, max }) => {
|
||||||
|
return (
|
||||||
|
<View style={styles.miniBars}>
|
||||||
|
{data.map((v, i) => (
|
||||||
|
<View key={i} style={[styles.miniBar, { height: Math.max(6, (v / Math.max(1, max)) * 64), backgroundColor: color }]} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProgressRow: React.FC<{ label: string; value: number; color: string }> = ({ label, value, color }) => {
|
||||||
|
const { fonts } = useTheme();
|
||||||
|
return (
|
||||||
|
<View style={styles.progressRow}>
|
||||||
|
<View style={styles.progressLabelWrap}>
|
||||||
|
<Text style={{ fontSize: 12, fontFamily: fonts.regular }}>{label}</Text>
|
||||||
|
<Text style={{ fontSize: 12, fontFamily: fonts.medium }}>{value}%</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.progressTrack}>
|
||||||
|
<View style={[styles.progressFill, { width: `${value}%`, backgroundColor: color }]} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StackedBar: React.FC<{ segments: { label: string; value: number; color: string }[]; total: number }> = ({ segments, total }) => {
|
||||||
|
return (
|
||||||
|
<View style={[styles.stackedBar, { flexDirection: 'row', marginTop: 8 }]}>
|
||||||
|
{segments.map(s => (
|
||||||
|
<View key={s.label} style={{ width: `${(s.value / Math.max(1, total)) * 100}%`, backgroundColor: s.color }} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Chip: React.FC<{ label: string; value: number; dot: string }> = ({ label, value, dot }) => {
|
||||||
|
const { fonts, colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<View style={styles.chip}>
|
||||||
|
<View style={[styles.chipDot, { backgroundColor: dot }]} />
|
||||||
|
<Text style={{ fontSize: 12, fontFamily: fonts.regular, color: colors.text }}>{label}: </Text>
|
||||||
|
<Text style={{ fontSize: 12, fontFamily: fonts.medium, color: colors.text }}>{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
8
src/modules/zohoProjects/services/zohoProjectsAPI.ts
Normal file
8
src/modules/zohoProjects/services/zohoProjectsAPI.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import http from '@/services/http';
|
||||||
|
import { API_ENDPOINTS } from '@/shared/constants/API_ENDPOINTS';
|
||||||
|
|
||||||
|
export const zohoProjectsAPI = {
|
||||||
|
getProjects: () => http.get(API_ENDPOINTS.ZOHO_PROJECTS),
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
73
src/modules/zohoProjects/store/zohoProjectsSlice.ts
Normal file
73
src/modules/zohoProjects/store/zohoProjectsSlice.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
export interface ZohoProject {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
owner: string;
|
||||||
|
status: 'active' | 'completed' | 'onHold';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZohoProjectsFilters {
|
||||||
|
owner: 'all' | string;
|
||||||
|
status: 'all' | ZohoProject['status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZohoProjectsState {
|
||||||
|
projects: ZohoProject[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
filters: ZohoProjectsFilters;
|
||||||
|
lastUpdated: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ZohoProjectsState = {
|
||||||
|
projects: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
filters: { owner: 'all', status: 'all' },
|
||||||
|
lastUpdated: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchZohoProjects = createAsyncThunk('zohoProjects/fetch', async () => {
|
||||||
|
// TODO: integrate real service
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
return [
|
||||||
|
{ id: 'p1', name: 'CRM Revamp', owner: 'Alice', status: 'active' },
|
||||||
|
{ id: 'p2', name: 'Mobile App', owner: 'Bob', status: 'completed' },
|
||||||
|
] as ZohoProject[];
|
||||||
|
});
|
||||||
|
|
||||||
|
const zohoProjectsSlice = createSlice({
|
||||||
|
name: 'zohoProjects',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setFilters: (state, action: PayloadAction<Partial<ZohoProjectsFilters>>) => {
|
||||||
|
state.filters = { ...state.filters, ...action.payload } as ZohoProjectsFilters;
|
||||||
|
},
|
||||||
|
clearError: state => {
|
||||||
|
state.error = null;
|
||||||
|
},
|
||||||
|
resetState: () => initialState,
|
||||||
|
},
|
||||||
|
extraReducers: builder => {
|
||||||
|
builder
|
||||||
|
.addCase(fetchZohoProjects.pending, state => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchZohoProjects.fulfilled, (state, action: PayloadAction<ZohoProject[]>) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.projects = action.payload;
|
||||||
|
state.lastUpdated = Date.now();
|
||||||
|
})
|
||||||
|
.addCase(fetchZohoProjects.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.error.message || 'Failed to load projects';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setFilters, clearError, resetState } = zohoProjectsSlice.actions;
|
||||||
|
export default zohoProjectsSlice;
|
||||||
|
|
||||||
|
|
||||||
119
src/navigation/AppNavigator.tsx
Normal file
119
src/navigation/AppNavigator.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { NavigationContainer } from '@react-navigation/native';
|
||||||
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
|
import Icon from 'react-native-vector-icons/MaterialIcons';
|
||||||
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import type { RootState } from '@/store/store';
|
||||||
|
import { TouchableOpacity, View, StyleSheet } from 'react-native';
|
||||||
|
import { clearSelectedService } from '@/modules/integrations/store/integrationsSlice';
|
||||||
|
|
||||||
|
// Module navigators
|
||||||
|
import ZohoProjectsNavigator from '@/modules/zohoProjects/navigation/ZohoProjectsNavigator';
|
||||||
|
import HRNavigator from '@/modules/hr/navigation/HRNavigator';
|
||||||
|
import ProfileNavigator from '@/modules/profile/navigation/ProfileNavigator';
|
||||||
|
import FinanceNavigator from '@/modules/finance/navigation/FinanceNavigator';
|
||||||
|
import CrmNavigator from '@/modules/crm/navigation/CrmNavigator';
|
||||||
|
import CollabNavigator from '@/modules/collab/navigation/CollabNavigator';
|
||||||
|
import StorageNavigator from '@/modules/storage/navigation/StorageNavigator';
|
||||||
|
|
||||||
|
const Tab = createBottomTabNavigator();
|
||||||
|
// Stacks are now defined per module under their navigation folders
|
||||||
|
|
||||||
|
const DashboardRouter: React.FC = () => {
|
||||||
|
const selectedService = useSelector((s: RootState) => s.integrations.selectedService);
|
||||||
|
const hrServices = ['zohoPeople', 'bamboohr', 'workday', 'keka'];
|
||||||
|
const financeServices = ['quickbooks', 'zohoBooks', 'tally', 'xero', 'sapb1'];
|
||||||
|
const crmServices = ['zohoCRM', 'hubspot', 'salesforce', 'pipedrive'];
|
||||||
|
const collabServices = ['slack', 'teams', 'zoom', 'gworkspace'];
|
||||||
|
const storageServices = ['gdrive', 'dropbox', 'onedrive', 'sharepoint', 'box'];
|
||||||
|
if (selectedService === 'zohoProjects') {
|
||||||
|
return <ZohoProjectsNavigator />;
|
||||||
|
}
|
||||||
|
if (selectedService && hrServices.includes(selectedService)) {
|
||||||
|
return <HRNavigator />;
|
||||||
|
}
|
||||||
|
if (selectedService && financeServices.includes(selectedService)) {
|
||||||
|
return <FinanceNavigator />;
|
||||||
|
}
|
||||||
|
if (selectedService && crmServices.includes(selectedService)) {
|
||||||
|
return <CrmNavigator />;
|
||||||
|
}
|
||||||
|
if (selectedService && collabServices.includes(selectedService)) {
|
||||||
|
return <CollabNavigator />;
|
||||||
|
}
|
||||||
|
if (selectedService && storageServices.includes(selectedService)) {
|
||||||
|
return <StorageNavigator />;
|
||||||
|
}
|
||||||
|
// Default dashboard if unknown service
|
||||||
|
return <ZohoProjectsNavigator />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppTabs = () => {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<Tab.Navigator
|
||||||
|
screenOptions={({ route }) => ({
|
||||||
|
tabBarIcon: ({ color, size }) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
Dashboard: 'dashboard',
|
||||||
|
Profile: 'person',
|
||||||
|
};
|
||||||
|
const iconName = map[route.name] ?? 'dashboard';
|
||||||
|
return <Icon name={iconName} size={size} color={color} />;
|
||||||
|
},
|
||||||
|
headerShown: false,
|
||||||
|
tabBarActiveTintColor: colors.primary,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Tab.Screen name="Dashboard" component={DashboardWithHome} />
|
||||||
|
<Tab.Screen name="Profile" component={ProfileNavigator} />
|
||||||
|
</Tab.Navigator>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppNavigator = () => (
|
||||||
|
<NavigationContainer>
|
||||||
|
<AppTabs />
|
||||||
|
</NavigationContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AppNavigator;
|
||||||
|
|
||||||
|
// Local overlay wrapper to show a floating home button on Dashboard
|
||||||
|
const DashboardWithHome: React.FC = () => {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<DashboardRouter />
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.85}
|
||||||
|
onPress={() => dispatch(clearSelectedService())}
|
||||||
|
style={[styles.fab, { backgroundColor: colors.primary }]}
|
||||||
|
>
|
||||||
|
<Icon name="home" size={24} color="#FFFFFF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
fab: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 16,
|
||||||
|
bottom: 24, // sit above tab bar
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
elevation: 6,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
10
src/services/http.ts
Normal file
10
src/services/http.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { create } from 'apisauce';
|
||||||
|
|
||||||
|
const http = create({
|
||||||
|
baseURL: 'https://api.example.com',
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default http;
|
||||||
|
|
||||||
|
|
||||||
47
src/shared/components/layout/GradientBackground/index.tsx
Normal file
47
src/shared/components/layout/GradientBackground/index.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ViewStyle, StyleProp, StatusBar } from 'react-native';
|
||||||
|
import LinearGradient from 'react-native-linear-gradient';
|
||||||
|
|
||||||
|
type GradientPreset = 'warm' | 'cool';
|
||||||
|
|
||||||
|
interface GradientBackgroundProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
colors?: string[];
|
||||||
|
start?: { x: number; y: number };
|
||||||
|
end?: { x: number; y: number };
|
||||||
|
locations?: number[];
|
||||||
|
preset?: GradientPreset;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRESET_COLORS: Record<GradientPreset, string[]> = {
|
||||||
|
// Warm preset similar to the provided login mock
|
||||||
|
warm: ['#FFE9CC', '#F6E6FF'],
|
||||||
|
// Cool preset for headers/hero based on guidelines
|
||||||
|
cool: ['#3AA0FF', '#2D6BFF'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const GradientBackground: React.FC<GradientBackgroundProps> = ({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
colors,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
locations,
|
||||||
|
preset = 'warm',
|
||||||
|
}) => {
|
||||||
|
const gradientColors = colors ?? PRESET_COLORS[preset];
|
||||||
|
const gradientStart = start ?? { x: 0, y: 0 };
|
||||||
|
const gradientEnd = end ?? { x: 1, y: 1 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LinearGradient colors={gradientColors} start={gradientStart} end={gradientEnd} locations={locations} style={style}>
|
||||||
|
<StatusBar backgroundColor={'#FFE9CC'} barStyle={'dark-content'} />
|
||||||
|
{children}
|
||||||
|
</LinearGradient>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GradientBackground;
|
||||||
|
|
||||||
|
|
||||||
93
src/shared/components/ui/ConfirmModal/index.tsx
Normal file
93
src/shared/components/ui/ConfirmModal/index.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Modal, View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
|
|
||||||
|
interface ConfirmModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmModal: React.FC<ConfirmModalProps> = ({
|
||||||
|
visible,
|
||||||
|
title = 'Confirm',
|
||||||
|
message = 'Are you sure?',
|
||||||
|
confirmText = 'Yes',
|
||||||
|
cancelText = 'Cancel',
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}) => {
|
||||||
|
const { colors, fonts } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} animationType="fade" transparent onRequestClose={onCancel}>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||||
|
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>{title}</Text>
|
||||||
|
<Text style={[styles.message, { color: colors.text, fontFamily: fonts.regular }]}>{message}</Text>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<TouchableOpacity style={[styles.btn, styles.btnOutline, { borderColor: colors.border }]} onPress={onCancel}>
|
||||||
|
<Text style={{ color: colors.text, fontFamily: fonts.medium }}>{cancelText}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[styles.btn, styles.btnPrimary, { backgroundColor: colors.primary }]} onPress={onConfirm}>
|
||||||
|
<Text style={{ color: colors.surface, fontFamily: fonts.bold }}>{confirmText}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.35)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
marginBottom: 8,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
marginTop: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
flex: 1,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
btnOutline: {
|
||||||
|
borderWidth: 1,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
btnPrimary: {
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ConfirmModal;
|
||||||
|
|
||||||
|
|
||||||
23
src/shared/components/ui/Container/index.tsx
Normal file
23
src/shared/components/ui/Container/index.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, StyleSheet, ViewStyle, StyleProp, ScrollView } from 'react-native';
|
||||||
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
|
|
||||||
|
interface ContainerProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Container: React.FC<ContainerProps> = ({ children, style }) => {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return <ScrollView style={[styles.container, { backgroundColor: colors.background }, style]} showsVerticalScrollIndicator={false}>{children}</ScrollView>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Container;
|
||||||
|
|
||||||
|
|
||||||
47
src/shared/components/ui/ErrorState/index.tsx
Normal file
47
src/shared/components/ui/ErrorState/index.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
|
|
||||||
|
interface ErrorStateProps {
|
||||||
|
message?: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ErrorState: React.FC<ErrorStateProps> = ({ message = 'Something went wrong', onRetry }) => {
|
||||||
|
const { colors, spacing } = useTheme();
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { padding: spacing.md }]}>
|
||||||
|
<Text style={[styles.title, { color: colors.error }]}>{message}</Text>
|
||||||
|
{onRetry ? (
|
||||||
|
<TouchableOpacity onPress={onRetry} style={[styles.button, { borderColor: colors.error }]}>
|
||||||
|
<Text style={[styles.buttonText, { color: colors.error }]}>Retry</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ErrorState;
|
||||||
|
|
||||||
|
|
||||||
24
src/shared/components/ui/LoadingSpinner/index.tsx
Normal file
24
src/shared/components/ui/LoadingSpinner/index.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ActivityIndicator, View, StyleSheet } from 'react-native';
|
||||||
|
import { useTheme } from '@/shared/styles/useTheme';
|
||||||
|
|
||||||
|
const LoadingSpinner: React.FC = () => {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default LoadingSpinner;
|
||||||
|
|
||||||
|
|
||||||
6
src/shared/components/ui/index.ts
Normal file
6
src/shared/components/ui/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export { default as Container } from './Container';
|
||||||
|
export { default as LoadingSpinner } from './LoadingSpinner';
|
||||||
|
export { default as ErrorState } from './ErrorState';
|
||||||
|
export { default as ConfirmModal } from './ConfirmModal';
|
||||||
|
|
||||||
|
|
||||||
10
src/shared/constants/API_ENDPOINTS.ts
Normal file
10
src/shared/constants/API_ENDPOINTS.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export const API_ENDPOINTS = {
|
||||||
|
AUTH_LOGIN: '/auth/login',
|
||||||
|
HR_METRICS: '/hr/metrics',
|
||||||
|
ZOHO_PROJECTS: '/zoho/projects',
|
||||||
|
PROFILE: '/profile',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ApiEndpointKey = keyof typeof API_ENDPOINTS;
|
||||||
|
|
||||||
|
|
||||||
37
src/shared/store/uiSlice.ts
Normal file
37
src/shared/store/uiSlice.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
interface UIState {
|
||||||
|
networkOnline: boolean;
|
||||||
|
globalLoading: boolean;
|
||||||
|
toastMessage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: UIState = {
|
||||||
|
networkOnline: true,
|
||||||
|
globalLoading: false,
|
||||||
|
toastMessage: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const uiSlice = createSlice({
|
||||||
|
name: 'ui',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setNetworkOnline: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.networkOnline = action.payload;
|
||||||
|
},
|
||||||
|
setGlobalLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.globalLoading = action.payload;
|
||||||
|
},
|
||||||
|
showToast: (state, action: PayloadAction<string>) => {
|
||||||
|
state.toastMessage = action.payload;
|
||||||
|
},
|
||||||
|
clearToast: state => {
|
||||||
|
state.toastMessage = null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setNetworkOnline, setGlobalLoading, showToast, clearToast } = uiSlice.actions;
|
||||||
|
export default uiSlice;
|
||||||
|
|
||||||
|
|
||||||
57
src/shared/styles/ThemeProvider.tsx
Normal file
57
src/shared/styles/ThemeProvider.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { COLORS, FONTS, SPACING, SHADOWS } from './theme';
|
||||||
|
import type { ColorScheme, ThemeContextValue, ThemeTokens } from './types';
|
||||||
|
|
||||||
|
export const ThemeContext = React.createContext<ThemeContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'theme.scheme';
|
||||||
|
|
||||||
|
const getInitialScheme = async (): Promise<ColorScheme> => {
|
||||||
|
try {
|
||||||
|
const stored = await AsyncStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === 'dark' || stored === 'light') {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// ignore read errors
|
||||||
|
}
|
||||||
|
return 'light';
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTokens = (_scheme: ColorScheme): ThemeTokens => {
|
||||||
|
// For now, we use the same token set for both schemes.
|
||||||
|
return {
|
||||||
|
colors: COLORS,
|
||||||
|
spacing: SPACING,
|
||||||
|
fonts: FONTS,
|
||||||
|
shadows: SHADOWS,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ThemeProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
|
||||||
|
const [scheme, setSchemeState] = useState<ColorScheme>('light');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Hydrate theme on mount
|
||||||
|
getInitialScheme().then(setSchemeState);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setScheme = useCallback((next: ColorScheme) => {
|
||||||
|
setSchemeState(next);
|
||||||
|
AsyncStorage.setItem(STORAGE_KEY, next).catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tokens = useMemo(() => buildTokens(scheme), [scheme]);
|
||||||
|
|
||||||
|
const value: ThemeContextValue = useMemo(
|
||||||
|
() => ({ ...tokens, isDark: scheme === 'dark', setScheme }),
|
||||||
|
[tokens, scheme, setScheme],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThemeProvider;
|
||||||
|
|
||||||
|
|
||||||
87
src/shared/styles/theme.ts
Normal file
87
src/shared/styles/theme.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
export const COLORS = {
|
||||||
|
// Primary colors
|
||||||
|
primary: '#2C5F4A',
|
||||||
|
primaryLight: '#4A8B6A',
|
||||||
|
primaryDark: '#1A3D2E',
|
||||||
|
|
||||||
|
// Secondary colors
|
||||||
|
secondary: '#FF6B35',
|
||||||
|
secondaryLight: '#FF8F65',
|
||||||
|
secondaryDark: '#E55A2B',
|
||||||
|
|
||||||
|
// UI colors
|
||||||
|
background: '#F8F9FA',
|
||||||
|
surface: '#FFFFFF',
|
||||||
|
text: '#2D3748',
|
||||||
|
textLight: '#718096',
|
||||||
|
border: '#E2E8F0',
|
||||||
|
error: '#E53E3E',
|
||||||
|
success: '#38A169',
|
||||||
|
warning: '#D69E2E',
|
||||||
|
|
||||||
|
// Dashboard specific
|
||||||
|
chartPrimary: '#2C5F4A',
|
||||||
|
chartSecondary: '#FF6B35',
|
||||||
|
chartTertiary: '#4299E1',
|
||||||
|
chartQuaternary: '#48BB78',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FONTS = {
|
||||||
|
regular: 'Roboto-Regular',
|
||||||
|
medium: 'Roboto-Medium',
|
||||||
|
bold: 'Roboto-Bold',
|
||||||
|
light: 'Roboto-Light',
|
||||||
|
black: 'Roboto-Black',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FONT_SIZES = {
|
||||||
|
xs: 12,
|
||||||
|
sm: 14,
|
||||||
|
md: 16,
|
||||||
|
lg: 18,
|
||||||
|
xl: 20,
|
||||||
|
xxl: 24,
|
||||||
|
xxxl: 32,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SPACING = {
|
||||||
|
xs: 4,
|
||||||
|
sm: 8,
|
||||||
|
md: 16,
|
||||||
|
lg: 24,
|
||||||
|
xl: 32,
|
||||||
|
xxl: 48,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BORDER_RADIUS = {
|
||||||
|
sm: 4,
|
||||||
|
md: 8,
|
||||||
|
lg: 12,
|
||||||
|
xl: 16,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SHADOWS = {
|
||||||
|
light: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
medium: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
heavy: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
62
src/shared/styles/types.ts
Normal file
62
src/shared/styles/types.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
export type ColorScheme = 'light' | 'dark';
|
||||||
|
|
||||||
|
export interface ThemeColors {
|
||||||
|
primary: string;
|
||||||
|
primaryLight: string;
|
||||||
|
primaryDark: string;
|
||||||
|
|
||||||
|
secondary: string;
|
||||||
|
secondaryLight: string;
|
||||||
|
secondaryDark: string;
|
||||||
|
|
||||||
|
background: string;
|
||||||
|
surface: string;
|
||||||
|
text: string;
|
||||||
|
textLight: string;
|
||||||
|
border: string;
|
||||||
|
error: string;
|
||||||
|
success: string;
|
||||||
|
warning: string;
|
||||||
|
|
||||||
|
chartPrimary: string;
|
||||||
|
chartSecondary: string;
|
||||||
|
chartTertiary: string;
|
||||||
|
chartQuaternary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeSpacing {
|
||||||
|
xs: number;
|
||||||
|
sm: number;
|
||||||
|
md: number;
|
||||||
|
lg: number;
|
||||||
|
xl: number;
|
||||||
|
xxl: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeFonts {
|
||||||
|
regular: string;
|
||||||
|
medium: string;
|
||||||
|
bold: string;
|
||||||
|
light: string;
|
||||||
|
black: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeShadows {
|
||||||
|
light: object;
|
||||||
|
medium: object;
|
||||||
|
heavy: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeTokens {
|
||||||
|
colors: ThemeColors;
|
||||||
|
spacing: ThemeSpacing;
|
||||||
|
fonts: ThemeFonts;
|
||||||
|
shadows: ThemeShadows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeContextValue extends ThemeTokens {
|
||||||
|
isDark: boolean;
|
||||||
|
setScheme: (scheme: ColorScheme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
15
src/shared/styles/useTheme.ts
Normal file
15
src/shared/styles/useTheme.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { ThemeContext } from './ThemeProvider';
|
||||||
|
import type { ThemeContextValue } from './types';
|
||||||
|
|
||||||
|
export const useTheme = (): ThemeContextValue => {
|
||||||
|
const ctx = useContext(ThemeContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useTheme must be used within a ThemeProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useTheme;
|
||||||
|
|
||||||
|
|
||||||
31
src/shared/utils/Toast.ts
Normal file
31
src/shared/utils/Toast.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import Toast from 'react-native-toast-message';
|
||||||
|
|
||||||
|
export const showSuccess = (message?: string) => {
|
||||||
|
const text = (message ?? '').trim() || 'Action completed successfully';
|
||||||
|
Toast.show({
|
||||||
|
type: 'success',
|
||||||
|
text1: text,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const showError = (message?: string) => {
|
||||||
|
const text = (message ?? '').trim() || 'Something went wrong';
|
||||||
|
Toast.show({
|
||||||
|
type: 'error',
|
||||||
|
text1: text,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export const showWarning = (message?: string) => {
|
||||||
|
const text = (message ?? '').trim() || 'Please check this';
|
||||||
|
Toast.show({
|
||||||
|
type: 'warning',
|
||||||
|
text1: text,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export const showInfo = (message?: string) => {
|
||||||
|
const text = (message ?? '').trim() || 'Here is some information';
|
||||||
|
Toast.show({
|
||||||
|
type: 'info',
|
||||||
|
text1: text,
|
||||||
|
});
|
||||||
|
};
|
||||||
53
src/store/store.ts
Normal file
53
src/store/store.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { configureStore, combineReducers } from '@reduxjs/toolkit';
|
||||||
|
import { persistReducer, persistStore } from 'redux-persist';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
// Feature slices (to be added)
|
||||||
|
import uiSlice from '@/shared/store/uiSlice';
|
||||||
|
import authSlice from '@/modules/auth/store/authSlice';
|
||||||
|
import hrSlice from '@/modules/hr/store/hrSlice';
|
||||||
|
import zohoProjectsSlice from '@/modules/zohoProjects/store/zohoProjectsSlice';
|
||||||
|
import profileSlice from '@/modules/profile/store/profileSlice';
|
||||||
|
import integrationsSlice from '@/modules/integrations/store/integrationsSlice';
|
||||||
|
|
||||||
|
const rootReducer = combineReducers({
|
||||||
|
auth: authSlice.reducer,
|
||||||
|
hr: hrSlice.reducer,
|
||||||
|
zohoProjects: zohoProjectsSlice.reducer,
|
||||||
|
profile: profileSlice.reducer,
|
||||||
|
integrations: integrationsSlice.reducer,
|
||||||
|
ui: uiSlice.reducer,
|
||||||
|
});
|
||||||
|
|
||||||
|
const persistConfig = {
|
||||||
|
key: 'root',
|
||||||
|
storage: AsyncStorage,
|
||||||
|
whitelist: ['auth', 'hr', 'zohoProjects', 'profile', 'integrations'],
|
||||||
|
blacklist: ['ui'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistedReducer = persistReducer(persistConfig, rootReducer);
|
||||||
|
|
||||||
|
export const store = configureStore({
|
||||||
|
reducer: persistedReducer,
|
||||||
|
middleware: getDefaultMiddleware =>
|
||||||
|
getDefaultMiddleware({
|
||||||
|
serializableCheck: {
|
||||||
|
ignoredActions: [
|
||||||
|
'persist/FLUSH',
|
||||||
|
'persist/REHYDRATE',
|
||||||
|
'persist/PAUSE',
|
||||||
|
'persist/PERSIST',
|
||||||
|
'persist/PURGE',
|
||||||
|
'persist/REGISTER',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const persistor = persistStore(store);
|
||||||
|
|
||||||
|
export type RootState = ReturnType<typeof store.getState>;
|
||||||
|
export type AppDispatch = typeof store.dispatch;
|
||||||
|
|
||||||
|
|
||||||
25
src/types/react-native-vector-icons.d.ts
vendored
Normal file
25
src/types/react-native-vector-icons.d.ts
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
declare module 'react-native-vector-icons/MaterialIcons' {
|
||||||
|
import { ComponentType } from 'react';
|
||||||
|
import { TextProps } from 'react-native';
|
||||||
|
interface IconProps extends TextProps {
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
const Icon: ComponentType<IconProps>;
|
||||||
|
export default Icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'react-native-vector-icons/MaterialCommunityIcons' {
|
||||||
|
import { ComponentType } from 'react';
|
||||||
|
import { TextProps } from 'react-native';
|
||||||
|
interface IconProps extends TextProps {
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
const Icon: ComponentType<IconProps>;
|
||||||
|
export default Icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1,3 +1,10 @@
|
|||||||
{
|
{
|
||||||
"extends": "@react-native/typescript-config/tsconfig.json"
|
"extends": "@react-native/typescript-config/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src", "App.tsx", "index.js"]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user