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 type {PropsWithChildren} from 'react';
|
||||
import {
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
Text,
|
||||
useColorScheme,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Provider } from 'react-redux';
|
||||
import { PersistGate } from 'redux-persist/integration/react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import AppNavigator from '@/navigation/AppNavigator';
|
||||
import { store, persistor } from '@/store/store';
|
||||
import { ThemeProvider } from '@/shared/styles/ThemeProvider';
|
||||
import LoadingSpinner from '@/shared/components/ui/LoadingSpinner';
|
||||
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 {
|
||||
Colors,
|
||||
DebugInstructions,
|
||||
Header,
|
||||
LearnMoreLinks,
|
||||
ReloadInstructions,
|
||||
} from 'react-native/Libraries/NewAppScreen';
|
||||
|
||||
type SectionProps = PropsWithChildren<{
|
||||
title: string;
|
||||
}>;
|
||||
|
||||
function Section({children, title}: SectionProps): React.JSX.Element {
|
||||
const isDarkMode = useColorScheme() === 'dark';
|
||||
function AppContent(): React.JSX.Element {
|
||||
const isAuthenticated = useSelector((s: RootState) => Boolean(s.auth.token));
|
||||
const selectedService = useSelector((s: RootState) => s.integrations.selectedService);
|
||||
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>
|
||||
<ThemeProvider>
|
||||
<PersistGate loading={<LoadingSpinner />} persistor={persistor}>
|
||||
<StatusBar backgroundColor={'#FFFFFF'} barStyle={'dark-content'} />
|
||||
{!isAuthenticated ? (
|
||||
<NavigationContainer>
|
||||
<AuthNavigator />
|
||||
</NavigationContainer>
|
||||
) : (
|
||||
!selectedService ? (
|
||||
<NavigationContainer>
|
||||
<IntegrationsNavigator/>
|
||||
</NavigationContainer>
|
||||
) : (
|
||||
<AppNavigator />
|
||||
)
|
||||
)}
|
||||
</PersistGate>
|
||||
</ThemeProvider>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={backgroundStyle}>
|
||||
<StatusBar
|
||||
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
|
||||
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>
|
||||
<Provider store={store}>
|
||||
<AppContent />
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
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">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
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
|
||||
validateDistributionUrl=true
|
||||
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 = {
|
||||
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
|
||||
*/
|
||||
|
||||
import 'react-native-gesture-handler';
|
||||
import {AppRegistry} from 'react-native';
|
||||
import App from './App';
|
||||
import {name as appName} from './app.json';
|
||||
|
||||
@ -11,6 +11,12 @@
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@ -35,6 +41,12 @@
|
||||
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>"; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@ -93,6 +105,7 @@
|
||||
83CBBA001A601CBA00E9B192 /* Products */,
|
||||
2D16E6871FA4F8E400B85C8A /* Frameworks */,
|
||||
BBD78D7AC51CEA395F1C20DB /* Pods */,
|
||||
034FBE3A8610463F9085B792 /* Resources */,
|
||||
);
|
||||
indentWidth = 2;
|
||||
sourceTree = "<group>";
|
||||
@ -116,6 +129,20 @@
|
||||
path = Pods;
|
||||
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 */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@ -185,6 +212,12 @@
|
||||
files = (
|
||||
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard 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;
|
||||
};
|
||||
|
||||
@ -26,14 +26,13 @@
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<!-- Do not change NSAllowsArbitraryLoads to true, or you will risk app rejection! -->
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<false/>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string></string>
|
||||
<string/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
@ -48,5 +47,14 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<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>
|
||||
</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
32
package.json
32
package.json
@ -3,15 +3,40 @@
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
"ios": "react-native run-ios",
|
||||
"android": "npx react-native run-android",
|
||||
"ios": "npx react-native run-ios",
|
||||
"lint": "eslint .",
|
||||
"start": "react-native start",
|
||||
"test": "jest"
|
||||
},
|
||||
"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-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": {
|
||||
"@babel/core": "^7.25.2",
|
||||
@ -27,6 +52,7 @@
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-test-renderer": "^19.0.0",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"eslint": "^8.19.0",
|
||||
"jest": "^29.6.3",
|
||||
"prettier": "2.8.8",
|
||||
|
||||
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