feat(app): integrate api
This commit is contained in:
parent
1f1b9aa264
commit
461feba0ba
10
app/api/dashboardProjectActivityApi.ts
Normal file
10
app/api/dashboardProjectActivityApi.ts
Normal 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}`,
|
||||
);
|
||||
};
|
||||
@ -6,6 +6,7 @@ export * from './customersApi';
|
||||
export * from './fcmTokenApi';
|
||||
export * from './notificationApi';
|
||||
export * from './dashboardLeadCountApi';
|
||||
export * from './dashboardProjectActivityApi';
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ export * from './customerItemCard';
|
||||
export * from './customerDetailHeader';
|
||||
export * from './notificationItem';
|
||||
export * from './leadDistribution';
|
||||
export * from './projectActivityFeed';
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
<View style={{ position: 'relative' }}>
|
||||
<TouchableOpacity
|
||||
onPress={onStatusPress}
|
||||
activeOpacity={onStatusPress ? 0.7 : 1}
|
||||
disabled={!onStatusPress}>
|
||||
<View style={[styles.statusBadge, { backgroundColor: badgeBg }]}>
|
||||
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>
|
||||
<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 */}
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -3,4 +3,5 @@ import { LeadItem } from '@interfaces';
|
||||
export interface LeadItemCardProps {
|
||||
item: LeadItem;
|
||||
onPress?: () => void;
|
||||
onStatusChange?: (statusId: string) => void;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
<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>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@ -79,6 +137,6 @@ export const LeadItemCard: React.FC<LeadItemCardProps> = ({ item, onPress }) =>
|
||||
<Text style={styles.emailButtonText}>Email</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
2
app/components/projectActivityFeed/index.ts
Normal file
2
app/components/projectActivityFeed/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './projectActivityFeed';
|
||||
export * from './projectActivityFeed.props';
|
||||
@ -0,0 +1,6 @@
|
||||
import { DashboardProjectActivityItem } from '@interfaces';
|
||||
|
||||
export interface ProjectActivityFeedProps {
|
||||
data: DashboardProjectActivityItem[];
|
||||
loading?: boolean;
|
||||
}
|
||||
250
app/components/projectActivityFeed/projectActivityFeed.styles.ts
Normal file
250
app/components/projectActivityFeed/projectActivityFeed.styles.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
185
app/components/projectActivityFeed/projectActivityFeed.tsx
Normal file
185
app/components/projectActivityFeed/projectActivityFeed.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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} />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
20
app/interfaces/dashboardProjectActivity.ts
Normal file
20
app/interfaces/dashboardProjectActivity.ts
Normal 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;
|
||||
}
|
||||
@ -7,5 +7,6 @@ export * from './customers';
|
||||
export * from './fcmToken';
|
||||
export * from './notification';
|
||||
export * from './dashboardLeadCount';
|
||||
export * from './dashboardProjectActivity';
|
||||
|
||||
|
||||
|
||||
@ -77,3 +77,8 @@ export interface AddLeadResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface UpdateLeadResponse {
|
||||
status: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
|
||||
100
app/mock-data/dashboardProjectActivity.ts
Normal file
100
app/mock-data/dashboardProjectActivity.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
@ -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';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user