feat(app): integrate api

This commit is contained in:
uttam05111990 2026-07-31 17:37:28 +05:30
parent 1f1b9aa264
commit 461feba0ba
30 changed files with 1074 additions and 53 deletions

View File

@ -0,0 +1,10 @@
import { DashboardProjectActivityItem, DashboardProjectActivityPayload } from '@interfaces';
import { api } from '@utils';
export const getDashboardProjectActivityApi = async (
payload: DashboardProjectActivityPayload,
): Promise<DashboardProjectActivityItem[]> => {
return await api.get<DashboardProjectActivityItem[]>(
`/api/project_activity_log/0/${payload.staff_id}`,
);
};

View File

@ -6,6 +6,7 @@ export * from './customersApi';
export * from './fcmTokenApi';
export * from './notificationApi';
export * from './dashboardLeadCountApi';
export * from './dashboardProjectActivityApi';

View File

@ -1,4 +1,4 @@
import { LeadItem, LeadCountItem, AddLeadPayload, AddLeadResponse } from '@interfaces';
import { LeadItem, LeadCountItem, AddLeadPayload, AddLeadResponse, UpdateLeadResponse } from '@interfaces';
import { api } from '@utils';
export const getLeadsApi = async (
@ -37,3 +37,32 @@ export const addLeadApi = async (
return await api.post<AddLeadResponse>('/api/leads', formData);
};
export const updateLeadApi = async (
lead: LeadItem,
newStatusId: string,
): Promise<UpdateLeadResponse> => {
const body = {
source: lead.source || '0',
status: newStatusId,
name: lead.name || '',
assigned: lead.assigned || '',
tags: '',
title: lead.title || '',
email: lead.email || '',
website: lead.website || '',
phonenumber: lead.phonenumber || '',
company: lead.company || '',
address: lead.address || '',
city: lead.city || '',
state: lead.state || '',
country: lead.country || '0',
default_language: lead.default_language || '',
description: lead.description || '',
is_public: lead.is_public || '',
zip: lead.zip || '',
};
const url = `/api/leads/${lead.id}`;
return await api.put<UpdateLeadResponse>(url, body);
};

View File

@ -14,6 +14,7 @@ export * from './customerItemCard';
export * from './customerDetailHeader';
export * from './notificationItem';
export * from './leadDistribution';
export * from './projectActivityFeed';

View File

@ -3,10 +3,12 @@ export interface LeadDetailHeaderProps {
company?: string;
email?: string;
phonenumber?: string;
statusId?: string;
statusName: string;
statusColor: string;
avatarColor?: string;
onStatusPress?: () => void;
onEmailPress?: () => void;
onPhonePress?: () => void;
onStatusChange?: (statusId: string) => void;
}

View File

@ -75,6 +75,64 @@ export const getStyles = (colors: ThemeColors) =>
textTransform: 'uppercase',
letterSpacing: 0.3,
},
statusDropdownButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 20,
borderWidth: 1,
flexShrink: 0,
},
statusDropdownText: {
fontSize: 10,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.3,
},
menuOverlay: {
position: 'absolute',
top: 36,
right: 0,
backgroundColor: colors.card,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
zIndex: 9999,
elevation: 20,
shadowColor: '#000000',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.15,
shadowRadius: 10,
minWidth: 140,
paddingVertical: 4,
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingVertical: 9,
paddingHorizontal: 12,
},
menuItemActive: {
backgroundColor: `${colors.icon}08`,
},
menuItemText: {
fontSize: 12,
fontWeight: '600',
color: colors.textSecondary,
flex: 1,
},
menuItemTextActive: {
color: colors.text,
fontWeight: '700',
},
menuItemDot: {
width: 8,
height: 8,
borderRadius: 4,
},
actionRow: {
flexDirection: 'row',
gap: 10,

View File

@ -1,31 +1,51 @@
import React from 'react';
import React, { useState } from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
import { useTheme } from '@theme';
import { getStyles } from './leadDetailHeader.styles';
import { LeadDetailHeaderProps } from './leadDetailHeader.props';
import { getInitials, toRgba } from '@utils';
import { useAppSelector, RootState } from '@store';
export const LeadDetailHeader: React.FC<LeadDetailHeaderProps> = ({
name,
company,
email,
phonenumber,
statusId,
statusName,
statusColor,
avatarColor,
onStatusPress,
onEmailPress,
onPhonePress,
onStatusChange,
}) => {
const { theme: colors, isDark } = useTheme();
const styles = getStyles(colors);
const accent = statusColor || avatarColor || '#5B4CF5';
const { items: statusList } = useAppSelector((state: RootState) => state.statusList);
const [selectedStatusId, setSelectedStatusId] = useState(statusId);
const [menuVisible, setMenuVisible] = useState(false);
const currentStatus = statusList.find(s => s.id === selectedStatusId) || {
name: statusName,
color: statusColor,
};
const accent = currentStatus.color || avatarColor || '#5B4CF5';
const badgeBg = toRgba(accent, isDark ? 0.20 : 0.12);
const handleSelectStatus = (newStatusId: string) => {
setSelectedStatusId(newStatusId);
setMenuVisible(false);
if (onStatusChange) {
onStatusChange(newStatusId);
}
};
return (
<View style={styles.card}>
<View style={[styles.card, menuVisible && { zIndex: 100, elevation: 10 }]}>
{/* Top Row: Avatar + Info + Status */}
<View style={styles.topRow}>
<View style={[styles.avatar, { backgroundColor: accent }]}>
@ -45,17 +65,52 @@ export const LeadDetailHeader: React.FC<LeadDetailHeaderProps> = ({
</Text>
</View>
<TouchableOpacity
onPress={onStatusPress}
activeOpacity={onStatusPress ? 0.7 : 1}
disabled={!onStatusPress}>
<View style={[styles.statusBadge, { backgroundColor: badgeBg }]}>
<View style={{ position: 'relative' }}>
<TouchableOpacity
activeOpacity={0.7}
onPress={() => setMenuVisible(!menuVisible)}
style={[
styles.statusDropdownButton,
{
backgroundColor: badgeBg,
borderColor: toRgba(accent, 0.3),
},
]}>
<View style={[styles.statusDot, { backgroundColor: accent }]} />
<Text style={[styles.statusText, { color: accent }]}>
{statusName || 'No Status'}
<Text style={[styles.statusDropdownText, { color: accent }]}>
{currentStatus.name || 'No Status'}
</Text>
</View>
</TouchableOpacity>
<Icon name={menuVisible ? 'chevron-up' : 'chevron-down'} size={10} color={accent} />
</TouchableOpacity>
{menuVisible && (
<View style={styles.menuOverlay}>
{statusList.map(statusItem => {
const isActive = statusItem.id === selectedStatusId;
const statusItemColor = statusItem.color?.startsWith('#') ? statusItem.color : colors.icon;
return (
<TouchableOpacity
key={statusItem.id}
style={[styles.menuItem, isActive && styles.menuItemActive]}
onPress={() => handleSelectStatus(statusItem.id)}>
<View style={[styles.menuItemDot, { backgroundColor: statusItemColor }]} />
<Text
style={[
styles.menuItemText,
isActive && styles.menuItemTextActive,
]}
numberOfLines={1}>
{statusItem.name}
</Text>
{isActive && (
<Icon name="checkmark" size={12} color={colors.text} />
)}
</TouchableOpacity>
);
})}
</View>
)}
</View>
</View>
{/* Bottom Row: Call + Email Buttons */}

View File

@ -17,11 +17,13 @@ export const LeadDistribution = ({
const [menuVisible, setMenuVisible] = useState(false);
const actions = [
{ label: 'Day', value: 'day' },
{ label: 'Today', value: 'today' },
{ label: 'Week', value: 'week' },
{ label: 'Month', value: 'month' },
{ label: 'Quarter', value: 'quarter' },
{ label: 'Half year', value: 'halfyear' },
{ label: 'Year', value: 'year' },
{ label: 'All', value: 'all' },
];
const currentActionLabel =
@ -54,7 +56,7 @@ export const LeadDistribution = ({
};
return (
<View style={styles.card}>
<View style={[styles.card, menuVisible && { zIndex: 10, elevation: 10 }]}>
{/* Header */}
<View style={styles.header}>
<View style={styles.titleRow}>

View File

@ -3,4 +3,5 @@ import { LeadItem } from '@interfaces';
export interface LeadItemCardProps {
item: LeadItem;
onPress?: () => void;
onStatusChange?: (statusId: string) => void;
}

View File

@ -77,6 +77,61 @@ export const getStyles = (colors: ThemeColors) =>
fontWeight: '700',
color: colors.icon,
},
statusDropdownButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 20,
borderWidth: 1,
},
statusDropdownText: {
fontSize: 11,
fontWeight: '700',
},
menuOverlay: {
position: 'absolute',
top: 36,
right: 0,
backgroundColor: colors.card,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
zIndex: 9999,
elevation: 20,
shadowColor: '#000000',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.15,
shadowRadius: 10,
minWidth: 140,
paddingVertical: 4,
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingVertical: 9,
paddingHorizontal: 12,
},
menuItemActive: {
backgroundColor: `${colors.icon}08`,
},
menuItemText: {
fontSize: 12,
fontWeight: '600',
color: colors.textSecondary,
flex: 1,
},
menuItemTextActive: {
color: colors.text,
fontWeight: '700',
},
menuItemDot: {
width: 8,
height: 8,
borderRadius: 4,
},
cardFooter: {
flexDirection: 'row',
gap: 10,

View File

@ -1,26 +1,46 @@
import React from 'react';
import React, { useState } from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
import { useTheme } from '@theme';
import { getStyles } from './leadItemCard.styles';
import { LeadItemCardProps } from './leadItemCard.props';
import { getInitials, handleEmailPress, handlePhonePress, formatDate } from '@utils';
import { useAppSelector, RootState } from '@store';
export const LeadItemCard: React.FC<LeadItemCardProps> = ({ item, onPress }) => {
export const LeadItemCard: React.FC<LeadItemCardProps> = ({ item, onPress, onStatusChange }) => {
const { theme: colors } = useTheme();
const styles = getStyles(colors);
const { items: statusList } = useAppSelector((state: RootState) => state.statusList);
const [selectedStatusId, setSelectedStatusId] = useState(item.status);
const [menuVisible, setMenuVisible] = useState(false);
const currentStatus = statusList.find(s => s.id === selectedStatusId) || {
name: item.status_name || item.status,
color: item.color || colors.icon,
};
const displayName = item.name || 'No Name';
const initials = getInitials(displayName);
const accent = item.color?.startsWith('#') ? item.color : colors.icon;
const accent = currentStatus.color?.startsWith('#') ? currentStatus.color : colors.icon;
const handleSelectStatus = (statusId: string) => {
setSelectedStatusId(statusId);
setMenuVisible(false);
if (onStatusChange) {
onStatusChange(statusId);
}
};
return (
<TouchableOpacity
activeOpacity={onPress ? 0.8 : 1}
onPress={onPress}
style={styles.customerCard}>
<View style={[styles.customerCard, menuVisible && { zIndex: 100, elevation: 10 }]}>
<View style={styles.cardHeader}>
<View style={styles.cardHeaderLeft}>
<TouchableOpacity
activeOpacity={onPress ? 0.7 : 1}
onPress={onPress}
disabled={!onPress}
style={styles.cardHeaderLeft}>
<View style={[styles.avatarCircle, { backgroundColor: accent }]}>
<Text style={styles.avatarInitial}>{initials}</Text>
</View>
@ -51,16 +71,54 @@ export const LeadItemCard: React.FC<LeadItemCardProps> = ({ item, onPress }) =>
</Text>
) : null}
</View>
</View>
</TouchableOpacity>
<View style={styles.cardHeaderRight}>
{item.status_name || item.status ? (
<View style={[styles.badge, { backgroundColor: `${accent}15` }]}>
<Text style={[styles.badgeText, { color: accent }]}>
{item.status_name || item.status}
<View style={{ position: 'relative' }}>
<TouchableOpacity
activeOpacity={0.7}
onPress={() => setMenuVisible(!menuVisible)}
style={[
styles.statusDropdownButton,
{
backgroundColor: `${accent}12`,
borderColor: `${accent}40`,
},
]}>
<Text style={[styles.statusDropdownText, { color: accent }]}>
{currentStatus.name}
</Text>
</View>
) : null}
<Icon name={menuVisible ? 'chevron-up' : 'chevron-down'} size={10} color={accent} />
</TouchableOpacity>
{menuVisible && (
<View style={styles.menuOverlay}>
{statusList.map(statusItem => {
const isActive = statusItem.id === selectedStatusId;
const statusColor = statusItem.color?.startsWith('#') ? statusItem.color : colors.icon;
return (
<TouchableOpacity
key={statusItem.id}
style={[styles.menuItem, isActive && styles.menuItemActive]}
onPress={() => handleSelectStatus(statusItem.id)}>
<View style={[styles.menuItemDot, { backgroundColor: statusColor }]} />
<Text
style={[
styles.menuItemText,
isActive && styles.menuItemTextActive,
]}
numberOfLines={1}>
{statusItem.name}
</Text>
{isActive && (
<Icon name="checkmark" size={12} color={colors.text} />
)}
</TouchableOpacity>
);
})}
</View>
)}
</View>
</View>
</View>
@ -79,6 +137,6 @@ export const LeadItemCard: React.FC<LeadItemCardProps> = ({ item, onPress }) =>
<Text style={styles.emailButtonText}>Email</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</View>
);
};

View File

@ -0,0 +1,2 @@
export * from './projectActivityFeed';
export * from './projectActivityFeed.props';

View File

@ -0,0 +1,6 @@
import { DashboardProjectActivityItem } from '@interfaces';
export interface ProjectActivityFeedProps {
data: DashboardProjectActivityItem[];
loading?: boolean;
}

View File

@ -0,0 +1,250 @@
import { StyleSheet } from 'react-native';
import { ThemeColors, colors as globalColors } from '@theme';
export const getStyles = (colors: ThemeColors, isDark: boolean) =>
StyleSheet.create({
/* ── Card wrapper ── */
card: {
backgroundColor: colors.card,
borderRadius: 20,
marginBottom: 20,
shadowColor: isDark ? '#000000' : '#0F172A',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: isDark ? 0.25 : 0.08,
shadowRadius: 12,
elevation: 4,
borderWidth: 1,
borderColor: colors.border,
},
/* ── Header ── */
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 14,
gap: 10,
},
titleIconBadge: {
width: 34,
height: 34,
borderRadius: 10,
backgroundColor: globalColors.accent,
justifyContent: 'center',
alignItems: 'center',
shadowColor: globalColors.accent,
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.4,
shadowRadius: 6,
elevation: 4,
},
titleGroup: {
flex: 1,
},
title: {
fontSize: 15,
fontWeight: '700',
color: colors.text,
letterSpacing: 0.1,
},
subtitle: {
fontSize: 11,
fontWeight: '500',
color: colors.textMuted,
marginTop: 1,
letterSpacing: 0.2,
},
countBadge: {
paddingHorizontal: 9,
paddingVertical: 4,
borderRadius: 20,
backgroundColor: isDark
? `${globalColors.accent}28`
: `${globalColors.accent}15`,
borderWidth: 1,
borderColor: isDark
? `${globalColors.accent}55`
: `${globalColors.accent}35`,
},
countBadgeText: {
fontSize: 12,
fontWeight: '700',
color: globalColors.accent,
},
/* ── Divider ── */
divider: {
height: 1,
backgroundColor: colors.border,
marginHorizontal: 16,
marginBottom: 4,
opacity: isDark ? 0.5 : 0.8,
},
/* ── Timeline list ── */
listContent: {
paddingHorizontal: 16,
paddingBottom: 12,
paddingTop: 4,
},
/* ── Timeline row ── */
row: {
flexDirection: 'row',
paddingVertical: 12,
},
/* ── Left column: avatar + connector line ── */
leftCol: {
width: 38,
alignItems: 'center',
},
avatar: {
width: 34,
height: 34,
borderRadius: 17,
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 13,
fontWeight: '800',
color: '#FFFFFF',
},
connector: {
width: 2,
flex: 1,
marginTop: 4,
borderRadius: 1,
backgroundColor: colors.border,
opacity: isDark ? 0.5 : 0.7,
},
/* ── Right column: content bubble ── */
rightCol: {
flex: 1,
marginLeft: 10,
},
bubble: {
backgroundColor: isDark
? `${globalColors.primary}12`
: `${globalColors.primary}07`,
borderRadius: 14,
borderWidth: 1,
borderColor: isDark
? `${globalColors.primary}30`
: `${globalColors.primary}18`,
padding: 12,
},
/* row inside bubble: name + time */
bubbleHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
staffName: {
fontSize: 13,
fontWeight: '700',
color: colors.text,
flex: 1,
marginRight: 6,
},
timeAgo: {
fontSize: 10,
fontWeight: '600',
color: colors.textMuted,
letterSpacing: 0.2,
},
/* description */
descriptionRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
marginBottom: 6,
},
actionDot: {
width: 5,
height: 5,
borderRadius: 2.5,
backgroundColor: globalColors.accent,
},
description: {
fontSize: 13,
fontWeight: '500',
color: colors.textSecondary,
flex: 1,
},
/* project chip */
projectChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
backgroundColor: isDark
? `${globalColors.accentAlt}18`
: `${globalColors.accentAlt}12`,
borderRadius: 20,
paddingHorizontal: 9,
paddingVertical: 4,
alignSelf: 'flex-start',
borderWidth: 1,
borderColor: isDark
? `${globalColors.accentAlt}40`
: `${globalColors.accentAlt}30`,
marginBottom: 6,
},
projectChipText: {
fontSize: 10,
fontWeight: '700',
color: globalColors.accentAlt,
letterSpacing: 0.2,
},
/* additional data tag */
additionalTag: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
additionalText: {
fontSize: 11,
fontWeight: '500',
color: colors.textMuted,
flex: 1,
},
/* ── Empty / Loader states ── */
stateContainer: {
paddingVertical: 32,
alignItems: 'center',
justifyContent: 'center',
},
emptyText: {
fontSize: 13,
color: colors.textMuted,
marginTop: 8,
fontWeight: '500',
},
/* ── Footer ── */
footer: {
borderTopWidth: 1,
borderTopColor: colors.border,
paddingVertical: 12,
alignItems: 'center',
justifyContent: 'center',
},
footerButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
footerButtonText: {
fontSize: 13,
fontWeight: '700',
color: globalColors.primary,
},
});

View File

@ -0,0 +1,185 @@
import React, { useState } from 'react';
import {
View,
Text,
ActivityIndicator,
ScrollView,
TouchableOpacity,
} from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
import { useTheme } from '@theme';
import { DashboardProjectActivityItem } from '@interfaces';
import { getStyles } from './projectActivityFeed.styles';
import { ProjectActivityFeedProps } from './projectActivityFeed.props';
import { colors as globalColors } from '@theme';
const AVATAR_COLORS = [
'#5B4CF5', // indigo
'#8B5CF6', // violet
'#06B6D4', // cyan
'#10B981', // emerald
'#F59E0B', // amber
'#EF4444', // red
'#EC4899', // pink
];
const getInitials = (name: string): string => {
const parts = name.trim().split(' ');
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
return name.slice(0, 2).toUpperCase();
};
const ActivityRow = ({
item,
index,
isLast,
styles,
textMutedColor,
}: {
item: DashboardProjectActivityItem;
index: number;
isLast: boolean;
styles: ReturnType<typeof getStyles>;
textMutedColor: string;
}) => {
const avatarColor = AVATAR_COLORS[index % AVATAR_COLORS.length];
const initials = getInitials(item.fullname_formatted || item.fullname);
return (
<View style={styles.row}>
{/* ── Left: avatar + timeline connector ── */}
<View style={styles.leftCol}>
<View style={[styles.avatar, { backgroundColor: avatarColor }]}>
<Text style={styles.avatarText}>{initials}</Text>
</View>
{!isLast && <View style={styles.connector} />}
</View>
{/* ── Right: bubble content ── */}
<View style={styles.rightCol}>
<View style={styles.bubble}>
{/* Name + time */}
<View style={styles.bubbleHeader}>
<Text style={styles.staffName} numberOfLines={1}>
{item.fullname_formatted || item.fullname}
</Text>
<Text style={styles.timeAgo}>{item.date_time_ago}</Text>
</View>
{/* Action description */}
<View style={styles.descriptionRow}>
<View style={styles.actionDot} />
<Text style={styles.description} numberOfLines={2}>
{item.description}
</Text>
</View>
{/* Project chip */}
<View style={styles.projectChip}>
<Icon name="folder-outline" size={10} color={globalColors.accentAlt} />
<Text style={styles.projectChipText} numberOfLines={1}>
{item.project_name}
</Text>
</View>
{/* Additional data tag */}
{!!item.additional_data && (
<View style={styles.additionalTag}>
<Icon name="document-text-outline" size={11} color={textMutedColor} />
<Text style={styles.additionalText} numberOfLines={1}>
{item.additional_data}
</Text>
</View>
)}
</View>
</View>
</View>
);
};
export const ProjectActivityFeed = ({
data = [],
loading = false,
}: ProjectActivityFeedProps) => {
const { theme: colors, isDark } = useTheme();
const styles = getStyles(colors, isDark);
const [expanded, setExpanded] = useState(false);
const displayData = expanded ? data : data.slice(0, 5);
const hasMore = data.length > 5;
return (
<View style={styles.card}>
{/* ── Header ── */}
<View style={styles.header}>
<View style={styles.titleIconBadge}>
<Icon name="pulse" size={16} color="#FFFFFF" />
</View>
<View style={styles.titleGroup}>
<Text style={styles.title}>Latest Project Activity</Text>
<Text style={styles.subtitle}>recent actions across projects</Text>
</View>
{!loading && data.length > 0 && (
<View style={styles.countBadge}>
<Text style={styles.countBadgeText}>{data.length}</Text>
</View>
)}
</View>
{/* ── Divider ── */}
<View style={styles.divider} />
{/* ── Body ── */}
{loading ? (
<View style={styles.stateContainer}>
<ActivityIndicator size="large" color={globalColors.accent} />
</View>
) : data.length === 0 ? (
<View style={styles.stateContainer}>
<Icon name="calendar-outline" size={32} color={colors.textMuted} />
<Text style={styles.emptyText}>No recent activity</Text>
</View>
) : (
<>
<ScrollView
style={styles.listContent}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
>
{displayData.map((item, index) => (
<ActivityRow
key={item.id}
item={item}
index={index}
isLast={index === displayData.length - 1}
styles={styles}
textMutedColor={colors.textMuted}
/>
))}
</ScrollView>
{hasMore && (
<View style={styles.footer}>
<TouchableOpacity
style={styles.footerButton}
activeOpacity={0.7}
onPress={() => setExpanded(!expanded)}
>
<Text style={styles.footerButtonText}>
{expanded ? 'Show Less' : `Show More (${data.length - 5} more)`}
</Text>
<Icon
name={expanded ? 'chevron-up' : 'chevron-down'}
size={14}
color={globalColors.primary}
/>
</TouchableOpacity>
</View>
)}
</>
)}
</View>
);
};

View File

@ -107,7 +107,7 @@ export const CustomerDetailsScreen = () => {
<>
<TouchableOpacity
style={styles.infoRow}
onPress={() => handleEmailPress(customer.email)}
// onPress={() => handleEmailPress(customer.email)}
activeOpacity={0.7}>
<View style={styles.infoIconWrap}>
<Icon name="mail-outline" size={16} color={colors.icon} />
@ -125,7 +125,7 @@ export const CustomerDetailsScreen = () => {
<>
<TouchableOpacity
style={styles.infoRow}
onPress={() => handlePhonePress(customer.phonenumber)}
// onPress={() => handlePhonePress(customer.phonenumber)}
activeOpacity={0.7}>
<View style={styles.infoIconWrap}>
<Icon name="call-outline" size={16} color={colors.icon} />
@ -143,7 +143,7 @@ export const CustomerDetailsScreen = () => {
<>
<TouchableOpacity
style={styles.infoRow}
onPress={() => handleWebsitePress(customer.website)}
// onPress={() => handleWebsitePress(customer.website)}
activeOpacity={0.7}>
<View style={styles.infoIconWrap}>
<Icon name="globe-outline" size={16} color={colors.icon} />

View File

@ -2,9 +2,9 @@ import React, { useEffect, useState } from 'react';
import { ScrollView, View, Text, InteractionManager } from 'react-native';
import { getStyles } from './dashboard.styles';
import { useTheme } from '../../theme';
import { LeadDistribution } from '@components';
import { LeadDistribution, ProjectActivityFeed } from '@components';
import { useAppDispatch, useAppSelector, RootState } from '@store';
import { getDashboardLeadCount } from './thunk';
import { getDashboardLeadCount, getDashboardProjectActivity } from './thunk';
import { NotificationService } from '@services';
export const DashboardScreen = () => {
@ -12,10 +12,10 @@ export const DashboardScreen = () => {
const { theme: colors } = useTheme();
const styles = getStyles(colors);
const [selectedAction, setSelectedAction] = useState<string>('year');
const [selectedAction, setSelectedAction] = useState<string>('all');
const user_data = useAppSelector((state: RootState) => state.auth.user_data);
const { leadCountData, loading, error } = useAppSelector(
const { leadCountData, loading, error, projectActivityData, activityLoading, activityError } = useAppSelector(
(state: RootState) => state.dashboard,
);
@ -36,6 +36,17 @@ export const DashboardScreen = () => {
);
}
}, [dispatch, selectedAction, user_data]);
useEffect(() => {
if (user_data?.staffid) {
dispatch(
getDashboardProjectActivity({
staff_id: user_data.staffid,
}),
);
}
}, [dispatch, user_data]);
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{error && (
@ -51,6 +62,18 @@ export const DashboardScreen = () => {
activeAction={selectedAction}
onActionChange={setSelectedAction}
/>
{activityError && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{activityError}</Text>
</View>
)}
{/* Project Activity Feed */}
<ProjectActivityFeed
data={projectActivityData}
loading={activityLoading}
/>
</ScrollView>
);
};

View File

@ -1,21 +1,28 @@
import { createReducer } from '@reduxjs/toolkit';
import { DashboardLeadCountItem } from '@interfaces';
import { getDashboardLeadCount, resetDashboardState } from './thunk';
import { DashboardLeadCountItem, DashboardProjectActivityItem } from '@interfaces';
import { getDashboardLeadCount, getDashboardProjectActivity, resetDashboardState } from './thunk';
export interface DashboardState {
leadCountData: DashboardLeadCountItem[];
projectActivityData: DashboardProjectActivityItem[];
loading: boolean;
activityLoading: boolean;
error: string | null;
activityError: string | null;
}
const initialState: DashboardState = {
leadCountData: [],
projectActivityData: [],
loading: false,
activityLoading: false,
error: null,
activityError: null,
};
export const dashboardReducer = createReducer(initialState, builder => {
builder
// Lead Count
.addCase(getDashboardLeadCount.pending, acc => {
acc.loading = true;
acc.error = null;
@ -32,10 +39,31 @@ export const dashboardReducer = createReducer(initialState, builder => {
action.error.message ??
'Failed to load dashboard lead count';
})
// Project Activity
.addCase(getDashboardProjectActivity.pending, acc => {
acc.activityLoading = true;
acc.activityError = null;
})
.addCase(getDashboardProjectActivity.fulfilled, (acc, action) => {
acc.activityLoading = false;
acc.projectActivityData = action.payload;
acc.activityError = null;
})
.addCase(getDashboardProjectActivity.rejected, (acc, action) => {
acc.activityLoading = false;
acc.activityError =
(action.payload as string) ??
action.error.message ??
'Failed to load project activity log';
})
// Reset
.addCase(resetDashboardState, acc => {
acc.leadCountData = [];
acc.projectActivityData = [];
acc.loading = false;
acc.activityLoading = false;
acc.error = null;
acc.activityError = null;
});
});

View File

@ -1,6 +1,11 @@
import { createAction, createAsyncThunk } from '@reduxjs/toolkit';
import { getDashboardLeadCountApi } from '@api';
import { DashboardLeadCountItem, DashboardLeadCountPayload } from '@interfaces';
import { getDashboardLeadCountApi, getDashboardProjectActivityApi } from '@api';
import {
DashboardLeadCountItem,
DashboardLeadCountPayload,
DashboardProjectActivityItem,
DashboardProjectActivityPayload,
} from '@interfaces';
export const resetDashboardState = createAction('dashboard/resetState');
@ -17,3 +22,17 @@ export const getDashboardLeadCount = createAsyncThunk<
}
},
);
export const getDashboardProjectActivity = createAsyncThunk<
DashboardProjectActivityItem[],
DashboardProjectActivityPayload
>(
'dashboard/getProjectActivity',
async (payload, { rejectWithValue }) => {
try {
return await getDashboardProjectActivityApi(payload);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to load project activity log');
}
},
);

View File

@ -1,11 +1,12 @@
import React, { useEffect } from 'react';
import { View, Text, ScrollView, TouchableOpacity } from 'react-native';
import { View, Text, ScrollView, TouchableOpacity, Alert } from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
import { RouteProp, useRoute, useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useTheme } from '@theme';
import { useAppDispatch, useAppSelector, RootState } from '@store';
import { getLeadDetails } from './thunk';
import { updateLeadStatus } from '../leads/thunk';
import { getStyles } from './leadDetails.styles';
import { LeadsStackParamList } from '../../navigation/leadsStack';
import { Loader, LeadDetailHeader, ActionCard } from '@components';
@ -24,6 +25,30 @@ export const LeadDetailsScreen = () => {
const leadId = route.params?.lead?.id;
const { item: lead, loading, error } = useAppSelector((state: RootState) => state.leadDetails);
const user_data = useAppSelector((state: RootState) => state.auth.user_data);
const { items: statusItems } = useAppSelector((state: RootState) => state.statusList);
const handleStatusChange = (newStatusId: string) => {
if (!lead) return;
const matchedStatus = statusItems.find(s => s.id === newStatusId);
const newStatusName = matchedStatus ? matchedStatus.name : newStatusId;
const newColor = matchedStatus ? matchedStatus.color : colors.icon;
dispatch(
updateLeadStatus({
lead: lead as any,
newStatusId,
newStatusName,
newColor,
}),
)
.unwrap()
.then(res => {
Alert.alert('Success', res.message || 'Status updated successfully');
})
.catch(err => {
Alert.alert('Error', err || 'Failed to update status');
});
};
useEffect(() => {
if (leadId && user_data?.staffid) {
@ -64,10 +89,12 @@ export const LeadDetailsScreen = () => {
company={lead.company}
email={lead.email}
phonenumber={lead.phonenumber}
statusId={lead.status}
statusName={lead.status_name || lead.status}
statusColor={lead.color}
onEmailPress={() => handleEmailPress(lead.email)}
onPhonePress={() => handlePhonePress(lead.phonenumber)}
onStatusChange={handleStatusChange}
/>
<View style={styles.statsRow}>
@ -97,7 +124,7 @@ export const LeadDetailsScreen = () => {
<>
<TouchableOpacity
style={styles.infoRow}
onPress={() => handleEmailPress(lead.email)}
// onPress={() => handleEmailPress(lead.email)}
activeOpacity={0.7}>
<View style={styles.infoIconWrap}>
<Icon name="mail-outline" size={15} color={colors.icon} />
@ -115,7 +142,7 @@ export const LeadDetailsScreen = () => {
<>
<TouchableOpacity
style={styles.infoRow}
onPress={() => handlePhonePress(lead.phonenumber)}
// onPress={() => handlePhonePress(lead.phonenumber)}
activeOpacity={0.7}>
<View style={styles.infoIconWrap}>
<Icon name="call-outline" size={15} color={colors.icon} />
@ -133,7 +160,7 @@ export const LeadDetailsScreen = () => {
<>
<TouchableOpacity
style={styles.infoRow}
onPress={() => handleWebsitePress(lead.website)}
// onPress={() => handleWebsitePress(lead.website)}
activeOpacity={0.7}>
<View style={styles.infoIconWrap}>
<Icon name="globe-outline" size={15} color={colors.icon} />

View File

@ -1,5 +1,6 @@
import { createReducer } from '@reduxjs/toolkit';
import { getLeadDetails } from './thunk';
import { updateLeadStatus } from '../leads/thunk';
import { LeadDetailsItem } from '@interfaces';
export interface LeadDetailsState {
@ -31,6 +32,13 @@ export const reducers = createReducer(initialState, builder => {
(action.payload as string) ??
action.error.message ??
'Failed to load lead details';
})
.addCase(updateLeadStatus.fulfilled, (acc, action) => {
if (acc.item && acc.item.id === action.payload.leadId) {
acc.item.status = action.payload.statusId;
acc.item.status_name = action.payload.statusName;
acc.item.color = action.payload.color;
}
});
});

View File

@ -6,6 +6,7 @@ import {
ScrollView,
TouchableOpacity,
ActivityIndicator,
Alert,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
@ -13,7 +14,7 @@ import Icon from 'react-native-vector-icons/Ionicons';
import { getStyles } from './leads.styles';
import { useTheme } from '@theme';
import { useAppDispatch, useAppSelector, RootState } from '@store';
import { getLeads, getLeadCountList } from './thunk';
import { getLeads, getLeadCountList, updateLeadStatus } from './thunk';
import { LeadItem } from '@interfaces';
import { LeadItemCard, SearchInput, Loader, StatCard } from '@components';
import { LeadsStackParamList } from '../../navigation/leadsStack';
@ -35,6 +36,9 @@ export const LeadsScreen = () => {
const { items, loading, error, counts, countsLoading } = useAppSelector(
(state: RootState) => state.leads,
);
const { items: statusItems } = useAppSelector(
(state: RootState) => state.statusList,
);
const user_data = useAppSelector((state: RootState) => state.auth.user_data);
useEffect(() => {
@ -67,8 +71,37 @@ export const LeadsScreen = () => {
navigation.navigate(route.leadDetails, { lead });
};
const handleStatusChange = (lead: LeadItem, newStatusId: string) => {
const matchedStatus = statusItems.find(s => s.id === newStatusId);
const newStatusName = matchedStatus ? matchedStatus.name : newStatusId;
const newColor = matchedStatus ? matchedStatus.color : colors.icon;
dispatch(
updateLeadStatus({
lead,
newStatusId,
newStatusName,
newColor,
}),
)
.unwrap()
.then(res => {
Alert.alert('Success', res.message || 'Status updated successfully');
if (user_data?.staffid) {
dispatch(getLeadCountList(user_data.staffid));
}
})
.catch(err => {
Alert.alert('Error', err || 'Failed to update status');
});
};
const renderLead = ({ item }: { item: LeadItem }) => (
<LeadItemCard item={item} onPress={() => handleLeadPress(item)} />
<LeadItemCard
item={item}
onPress={() => handleLeadPress(item)}
onStatusChange={newStatusId => handleStatusChange(item, newStatusId)}
/>
);
return (

View File

@ -1,5 +1,5 @@
import { createReducer } from '@reduxjs/toolkit';
import { getLeads, getLeadCountList } from './thunk';
import { getLeads, getLeadCountList, updateLeadStatus } from './thunk';
import { LeadItem, LeadCountItem } from '@interfaces';
export interface LeadsState {
@ -53,6 +53,19 @@ export const reducers = createReducer(initialState, builder => {
(action.payload as string) ??
action.error.message ??
'Failed to load lead counts';
})
.addCase(updateLeadStatus.fulfilled, (acc, action) => {
const { leadId, statusId, statusName, color } = action.payload;
acc.items = acc.items.map(item =>
item.id === leadId
? {
...item,
status: statusId,
status_name: statusName,
color: color,
}
: item,
);
});
});

View File

@ -1,5 +1,5 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getLeadsApi, getLeadCountListApi } from '@api';
import { getLeadsApi, getLeadCountListApi, updateLeadApi } from '@api';
import { LeadItem, LeadCountItem } from '@interfaces';
export const getLeads = createAsyncThunk<
@ -23,3 +23,31 @@ export const getLeadCountList = createAsyncThunk<
return rejectWithValue(error.message || 'Failed to load lead counts');
}
});
export const updateLeadStatus = createAsyncThunk<
{ leadId: string; statusId: string; statusName: string; color: string; message: string },
{ lead: LeadItem; newStatusId: string; newStatusName: string; newColor: string },
{ rejectValue: string }
>(
'leads/updateStatus',
async (payload, { rejectWithValue }) => {
try {
const { lead, newStatusId } = payload;
const response = await updateLeadApi(lead, newStatusId);
if (response.status) {
return {
leadId: lead.id,
statusId: newStatusId,
statusName: payload.newStatusName,
color: payload.newColor,
message: response.message,
};
} else {
return rejectWithValue(response.message || 'Failed to update status');
}
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to update status');
}
},
);

View File

@ -7,7 +7,7 @@ export interface DashboardLeadCountItem {
count: number;
}
export type DashboardLeadCountAction = 'day' | 'week' | 'month' | 'quarter' | 'year';
export type DashboardLeadCountAction = 'today' | 'week' | 'month' | 'quarter' | 'halfyear' | 'year' | 'all';
export interface DashboardLeadCountPayload {
action: DashboardLeadCountAction | string;

View File

@ -0,0 +1,20 @@
export interface DashboardProjectActivityItem {
id: string;
project_id: string;
staff_id: string;
contact_id: string;
fullname: string;
dateadded: string;
description: string;
additional_data: string;
project_name: string;
fullname_formatted: string;
actor_url: string;
date_title: string;
date_time_ago: string;
project_url: string;
}
export interface DashboardProjectActivityPayload {
staff_id: string | number;
}

View File

@ -7,5 +7,6 @@ export * from './customers';
export * from './fcmToken';
export * from './notification';
export * from './dashboardLeadCount';
export * from './dashboardProjectActivity';

View File

@ -77,3 +77,8 @@ export interface AddLeadResponse {
message: string;
}
export interface UpdateLeadResponse {
status: boolean;
message: string;
}

View File

@ -0,0 +1,100 @@
import { DashboardProjectActivityItem } from '@interfaces';
export const MOCK_PROJECT_ACTIVITY: DashboardProjectActivityItem[] = [
{
id: '1',
project_id: '28',
staff_id: '5',
contact_id: '0',
fullname: 'John Doe',
dateadded: '2026-07-31 10:15:30',
description: 'Marked task as completed',
additional_data: 'Task: Design database structure',
project_name: 'CRM Mobile App Development',
fullname_formatted: 'John Doe',
actor_url: 'https://your-crm-domain.com/admin/profile/5',
date_title: '2026-07-31 10:15:30',
date_time_ago: '2 hours ago',
project_url: 'https://your-crm-domain.com/admin/projects/view/28',
},
{
id: '2',
project_id: '14',
staff_id: '3',
contact_id: '0',
fullname: 'Sarah Mitchell',
dateadded: '2026-07-31 08:45:00',
description: 'Added a new comment',
additional_data: 'Task: Review API endpoints documentation',
project_name: 'E-Commerce Platform Redesign',
fullname_formatted: 'Sarah Mitchell',
actor_url: 'https://your-crm-domain.com/admin/profile/3',
date_title: '2026-07-31 08:45:00',
date_time_ago: '4 hours ago',
project_url: 'https://your-crm-domain.com/admin/projects/view/14',
},
{
id: '3',
project_id: '7',
staff_id: '9',
contact_id: '0',
fullname: 'Alex Rivera',
dateadded: '2026-07-30 17:30:00',
description: 'Changed task status',
additional_data: 'Task: Integrate payment gateway — moved to In Progress',
project_name: 'FinTech Dashboard v2',
fullname_formatted: 'Alex Rivera',
actor_url: 'https://your-crm-domain.com/admin/profile/9',
date_title: '2026-07-30 17:30:00',
date_time_ago: '1 day ago',
project_url: 'https://your-crm-domain.com/admin/projects/view/7',
},
{
id: '4',
project_id: '21',
staff_id: '2',
contact_id: '0',
fullname: 'Priya Sharma',
dateadded: '2026-07-30 14:00:00',
description: 'Uploaded project file',
additional_data: 'File: UI_Wireframes_v3.fig',
project_name: 'Healthcare Portal',
fullname_formatted: 'Priya Sharma',
actor_url: 'https://your-crm-domain.com/admin/profile/2',
date_title: '2026-07-30 14:00:00',
date_time_ago: '1 day ago',
project_url: 'https://your-crm-domain.com/admin/projects/view/21',
},
{
id: '5',
project_id: '33',
staff_id: '11',
contact_id: '0',
fullname: 'Marcus Webb',
dateadded: '2026-07-29 11:20:00',
description: 'Created a new milestone',
additional_data: 'Milestone: Beta Release — Q3 2026',
project_name: 'SaaS Analytics Suite',
fullname_formatted: 'Marcus Webb',
actor_url: 'https://your-crm-domain.com/admin/profile/11',
date_title: '2026-07-29 11:20:00',
date_time_ago: '2 days ago',
project_url: 'https://your-crm-domain.com/admin/projects/view/33',
},
{
id: '6',
project_id: '28',
staff_id: '7',
contact_id: '0',
fullname: 'Emily Chen',
dateadded: '2026-07-29 09:05:00',
description: 'Left a note on project',
additional_data: 'Note: Awaiting client approval on color palette',
project_name: 'CRM Mobile App Development',
fullname_formatted: 'Emily Chen',
actor_url: 'https://your-crm-domain.com/admin/profile/7',
date_title: '2026-07-29 09:05:00',
date_time_ago: '2 days ago',
project_url: 'https://your-crm-domain.com/admin/projects/view/28',
},
];

View File

@ -8,4 +8,5 @@ export * from './projects';
export * from './proposal';
export * from './tasks';
export * from './tickets';
export * from './notification'
export * from './notification';
export * from './dashboardProjectActivity';