diff --git a/src/components/layout/DashboardLayout.tsx b/src/components/layout/DashboardLayout.tsx index adca7af..4c2c21d 100644 --- a/src/components/layout/DashboardLayout.tsx +++ b/src/components/layout/DashboardLayout.tsx @@ -33,6 +33,11 @@ import { Hub, Dashboard, Notifications, + FamilyRestroom, + Person, + Business, + Work, + People, } from '@mui/icons-material'; import BottomNavigation from '../navigation/BottomNavigation'; import { notificationService } from '../../services/notificationService'; @@ -52,6 +57,14 @@ interface DashboardLayoutProps { children: ReactNode; } +interface RelationshipCategory { + id: string; + name: string; + icon: ReactNode; + color: string; + count: number; +} + const DashboardLayout = ({ children }: DashboardLayoutProps) => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); @@ -64,6 +77,7 @@ const DashboardLayout = ({ children }: DashboardLayoutProps) => { pending: 0, byType: { vouch: 0, praise: 0, connection: 0, group_invite: 0, message: 0, system: 0 } }); + const [dragOverCategory, setDragOverCategory] = useState(null); const location = useLocation(); const navigate = useNavigate(); @@ -74,6 +88,15 @@ const DashboardLayout = ({ children }: DashboardLayoutProps) => { { text: 'Chat', icon: , path: '/messages' }, ]; + const relationshipCategories: RelationshipCategory[] = [ + { id: 'close_family', name: 'Close Family', icon: , color: '#d32f2f', count: 0 }, + { id: 'family', name: 'Family', icon: , color: '#f57c00', count: 0 }, + { id: 'friend', name: 'Friend', icon: , color: '#388e3c', count: 0 }, + { id: 'colleague', name: 'Colleague', icon: , color: '#1976d2', count: 0 }, + { id: 'business', name: 'Business', icon: , color: '#7b1fa2', count: 0 }, + { id: 'acquaintance', name: 'Acquaintance', icon: , color: '#616161', count: 0 }, + ]; + // Load notification summary for badge useEffect(() => { const loadNotificationSummary = async () => { @@ -133,6 +156,34 @@ const DashboardLayout = ({ children }: DashboardLayoutProps) => { return false; }; + const handleDragOver = (e: React.DragEvent, categoryId: string) => { + e.preventDefault(); + setDragOverCategory(categoryId); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setDragOverCategory(null); + }; + + const handleDrop = (e: React.DragEvent, categoryId: string) => { + e.preventDefault(); + setDragOverCategory(null); + + const contactData = e.dataTransfer.getData('application/json'); + if (contactData) { + try { + const contact = JSON.parse(contactData); + // Emit custom event for contact categorization + window.dispatchEvent(new CustomEvent('contactCategorized', { + detail: { contactId: contact.id, category: categoryId, contact } + })); + } catch (error) { + console.error('Error parsing contact data:', error); + } + } + }; + const renderNavItem = (item: NavItem, level: number = 0) => { const hasChildren = item.children && item.children.length > 0; const isExpanded = expandedItems.has(item.text); @@ -211,10 +262,77 @@ const DashboardLayout = ({ children }: DashboardLayoutProps) => { - + {navItems.map((item) => renderNavItem(item))} + {/* Relationship Categories */} + + + + Relationship Categories + + + Drag and drop contacts into a category to automatically set sharing permissions. + + + {relationshipCategories.map((category) => ( + handleDragOver(e, category.id)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, category.id)} + sx={{ + minHeight: 80, + border: 2, + borderColor: dragOverCategory === category.id ? category.color : 'divider', + borderStyle: dragOverCategory === category.id ? 'solid' : 'dashed', + borderRadius: 2, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + p: 1, + cursor: 'pointer', + backgroundColor: dragOverCategory === category.id ? `${category.color}10` : 'transparent', + transition: 'all 0.2s ease-in-out', + '&:hover': { + borderColor: category.color, + backgroundColor: `${category.color}08`, + }, + }} + > + + {category.icon} + + + {category.name} + + {category.count > 0 && ( + + {category.count} + + )} + + ))} + + + ); diff --git a/src/pages/ContactListPage.tsx b/src/pages/ContactListPage.tsx index f9c5037..bc4da18 100644 --- a/src/pages/ContactListPage.tsx +++ b/src/pages/ContactListPage.tsx @@ -14,7 +14,15 @@ import { CardContent, Grid, alpha, - useTheme + useTheme, + FormControl, + InputLabel, + Select, + MenuItem, + IconButton, + Menu, + ListItemIcon, + ListItemText } from '@mui/material'; import NetworkGraph from '../components/NetworkGraph'; import { @@ -35,7 +43,17 @@ import { Schedule, Send, ArrowUpward, - ArrowDownward + ArrowDownward, + FilterList, + Sort, + SortByAlpha, + Business, + AccessTime, + FamilyRestroom, + Person, + Work, + People, + DragIndicator } from '@mui/icons-material'; import { dataService } from '../services/dataService'; import type { Contact } from '../types/contact'; @@ -47,6 +65,12 @@ const ContactListPage = () => { const [searchQuery, setSearchQuery] = useState(''); const [tabValue, setTabValue] = useState(0); const [isLoading, setIsLoading] = useState(true); + const [relationshipFilter, setRelationshipFilter] = useState('all'); + const [groupFilter, setGroupFilter] = useState('all'); + const [sortBy, setSortBy] = useState('name'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + const [filterMenuAnchor, setFilterMenuAnchor] = useState(null); + const [sortMenuAnchor, setSortMenuAnchor] = useState(null); const theme = useTheme(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -70,17 +94,79 @@ const ContactListPage = () => { } }; loadContacts(); + + // Listen for contact categorization events from the sidebar + const handleContactCategorized = (event: CustomEvent) => { + const { contactId, category } = event.detail; + setContacts(prevContacts => + prevContacts.map(contact => + contact.id === contactId + ? { ...contact, relationshipCategory: category } + : contact + ) + ); + }; + + window.addEventListener('contactCategorized', handleContactCategorized as EventListener); + return () => { + window.removeEventListener('contactCategorized', handleContactCategorized as EventListener); + }; }, []); useEffect(() => { - const filtered = contacts.filter(contact => - contact.name.toLowerCase().includes(searchQuery.toLowerCase()) || - contact.email.toLowerCase().includes(searchQuery.toLowerCase()) || - contact.company?.toLowerCase().includes(searchQuery.toLowerCase()) || - contact.position?.toLowerCase().includes(searchQuery.toLowerCase()) - ); + let filtered = contacts.filter(contact => { + // Search filter + const matchesSearch = searchQuery === '' || + contact.name.toLowerCase().includes(searchQuery.toLowerCase()) || + contact.email.toLowerCase().includes(searchQuery.toLowerCase()) || + contact.company?.toLowerCase().includes(searchQuery.toLowerCase()) || + contact.position?.toLowerCase().includes(searchQuery.toLowerCase()); + + // Relationship filter + const matchesRelationship = relationshipFilter === 'all' || + (relationshipFilter === 'undefined' && !contact.relationshipCategory) || + contact.relationshipCategory === relationshipFilter; + + // Group filter + const matchesGroup = groupFilter === 'all' || + (groupFilter === 'has_groups' && contact.groupIds && contact.groupIds.length > 0) || + (groupFilter === 'no_groups' && (!contact.groupIds || contact.groupIds.length === 0)); + + return matchesSearch && matchesRelationship && matchesGroup; + }); + + // Sort the filtered results + filtered.sort((a, b) => { + let compareValue = 0; + + switch (sortBy) { + case 'name': + compareValue = a.name.localeCompare(b.name); + break; + case 'company': + const aCompany = a.company || ''; + const bCompany = b.company || ''; + compareValue = aCompany.localeCompare(bCompany); + break; + case 'naoStatus': + const statusOrder = { 'member': 0, 'invited': 1, 'not_invited': 2 }; + compareValue = (statusOrder[a.naoStatus as keyof typeof statusOrder] || 3) - + (statusOrder[b.naoStatus as keyof typeof statusOrder] || 3); + break; + case 'groupCount': + const aGroups = a.groupIds?.length || 0; + const bGroups = b.groupIds?.length || 0; + compareValue = aGroups - bGroups; + break; + default: + compareValue = 0; + } + + return sortDirection === 'asc' ? compareValue : -compareValue; + }); + setFilteredContacts(filtered); - }, [searchQuery, contacts]); + }, [searchQuery, contacts, relationshipFilter, groupFilter, sortBy, sortDirection]); const handleContactClick = (contactId: string) => { if (isSelectionMode) { @@ -149,6 +235,55 @@ const ContactListPage = () => { navigate(`/invite?inviteeName=${encodeURIComponent(contact.name)}&inviteeEmail=${encodeURIComponent(contact.email)}`); }; + const handleFilterClick = (event: React.MouseEvent) => { + setFilterMenuAnchor(event.currentTarget); + }; + + const handleSortClick = (event: React.MouseEvent) => { + setSortMenuAnchor(event.currentTarget); + }; + + const handleFilterClose = () => { + setFilterMenuAnchor(null); + }; + + const handleSortClose = () => { + setSortMenuAnchor(null); + }; + + const handleSortChange = (newSortBy: string) => { + if (sortBy === newSortBy) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + setSortBy(newSortBy); + setSortDirection('asc'); + } + handleSortClose(); + }; + + const clearFilters = () => { + setRelationshipFilter('all'); + setGroupFilter('all'); + setSearchQuery(''); + }; + + const getRelationshipCategoryInfo = (category?: string) => { + const categories = { + 'close_family': { name: 'Close Family', icon: , color: '#d32f2f' }, + 'family': { name: 'Family', icon: , color: '#f57c00' }, + 'friend': { name: 'Friend', icon: , color: '#388e3c' }, + 'colleague': { name: 'Colleague', icon: , color: '#1976d2' }, + 'business': { name: 'Business', icon: , color: '#7b1fa2' }, + 'acquaintance': { name: 'Acquaintance', icon: , color: '#616161' }, + }; + return category ? categories[category as keyof typeof categories] : null; + }; + + const handleDragStart = (e: React.DragEvent, contact: Contact) => { + e.dataTransfer.setData('application/json', JSON.stringify(contact)); + e.dataTransfer.effectAllowed = 'move'; + }; + const getVouchPraiseCounts = (contact: Contact) => { // Based on the contact detail page data, return appropriate counts // This would normally come from a real data service, but for demo purposes: @@ -369,9 +504,112 @@ const ContactListPage = () => { ), }} - sx={{ mb: 3 }} + sx={{ mb: 2 }} /> + {/* Filter and Sort Controls */} + + {/* Relationship Filter */} + + Relationship + + + + {/* Group Filter */} + + Groups + + + + {/* Sort Button */} + + + {/* Clear Filters */} + {(relationshipFilter !== 'all' || groupFilter !== 'all' || searchQuery !== '') && ( + + )} + + {/* Results Count */} + + {filteredContacts.length} of {contacts.length} contacts + + + + {/* Sort Menu */} + + handleSortChange('name')}> + + + + Name + + handleSortChange('company')}> + + + + Company + + handleSortChange('naoStatus')}> + + + + NAO Status + + handleSortChange('groupCount')}> + + + + Group Count + + + {isLoading ? ( @@ -391,31 +629,54 @@ const ContactListPage = () => { ) : ( - + {filteredContacts.map((contact) => ( - handleContactClick(contact.id)} - sx={{ - cursor: isSelectionMode ? 'default' : 'pointer', - transition: 'all 0.2s ease-in-out', - border: 1, - borderColor: 'divider', - '&:hover': !isSelectionMode ? { - borderColor: 'primary.main', - boxShadow: theme.shadows[2], - transform: 'translateY(-1px)', - } : {}, - }} - > - + + {/* Drag Handle - positioned to align with filter dropdown */} + {!isSelectionMode && ( + + )} + + + + handleDragStart(e, contact)} + onClick={() => handleContactClick(contact.id)} + sx={{ + cursor: isSelectionMode ? 'default' : 'grab', + transition: 'all 0.2s ease-in-out', + border: 1, + borderColor: 'divider', + '&:hover': !isSelectionMode ? { + borderColor: 'primary.main', + boxShadow: theme.shadows[2], + transform: 'translateY(-1px)', + } : {}, + '&:active': { + cursor: 'grabbing', + }, + position: 'relative', + flex: 1, + }} + > + {/* Desktop Layout */} - + {/* Avatar */} { fontSize: '1.25rem', fontWeight: 600, flexShrink: 0, - mr: 3 + mr: 2 }} > {!contact.profileImage && contact.name.charAt(0)} - {/* Left Column - Basic Info */} - - {/* Name with source icon inline */} + {/* First Column - Name & Title */} + + {/* Name with source icon */} {contact.name} @@ -451,279 +716,164 @@ const ContactListPage = () => { {getSourceIcon(contact.source)} - {/* Job Title & Company */} + {/* Job Title & Company - single line */} {contact.position}{contact.company && ` at ${contact.company}`} - - {/* Email */} + + + {/* Second Column - Email */} + {contact.email} + {contact.name === 'Aza Mafi' && ( + + +1 (415)555-7892 + + )} - {/* Middle Column - Vouch & Praise Counts + Tags */} + {/* Right Column - Relationship, Vouches, Praise */} - {/* Vouches and Praises Row */} - - {(() => { - const counts = getVouchPraiseCounts(contact); - const totalVouches = counts.vouchesSent + counts.vouchesReceived; - const totalPraises = counts.praisesSent + counts.praisesReceived; - - return ( - <> - } - label={ - - {counts.vouchesReceived > 0 && ( - <> - - {counts.vouchesReceived} - - )} - {counts.vouchesSent > 0 && counts.vouchesReceived > 0 && ( - - )} - {counts.vouchesSent > 0 && ( - <> - - {counts.vouchesSent} - - )} - {totalVouches === 0 && 0} - - } - size="small" - variant="outlined" - title={contact.naoStatus === 'member' - ? `Vouches: ${counts.vouchesReceived} received, ${counts.vouchesSent} sent` - : `Vouches: ${counts.vouchesSent} sent (hidden until they join)`} - sx={{ - fontSize: '0.75rem', - height: 24, - borderRadius: 1, - backgroundColor: alpha(theme.palette.primary.main, 0.04), - borderColor: alpha(theme.palette.primary.main, 0.12), - color: 'primary.main', - fontWeight: 500, - '& .MuiChip-icon': { fontSize: 14 }, - '& .MuiChip-label': { - fontSize: '0.75rem', - padding: '0 4px', - }, - }} - /> + {/* Top Row - Relationship Category */} + + {contact.relationshipCategory && ( + + )} - } - label={ - - {counts.praisesReceived > 0 && ( - <> - - {counts.praisesReceived} - - )} - {counts.praisesSent > 0 && counts.praisesReceived > 0 && ( - - )} - {counts.praisesSent > 0 && ( - <> - - {counts.praisesSent} - - )} - {totalPraises === 0 && 0} - - } - size="small" - variant="outlined" - title={contact.naoStatus === 'member' - ? `Praises: ${counts.praisesReceived} received, ${counts.praisesSent} sent` - : `Praises: ${counts.praisesSent} sent (hidden until they join)`} - sx={{ - fontSize: '0.75rem', - height: 24, - borderRadius: 1, - backgroundColor: alpha('#f8bbd9', 0.3), - borderColor: alpha('#d81b60', 0.3), - color: '#d81b60', - fontWeight: 500, - '& .MuiChip-icon': { fontSize: 14 }, - '& .MuiChip-label': { - fontSize: '0.75rem', - padding: '0 4px', - }, - }} - /> - - ); - })()} + {/* Selection mode button */} + {isSelectionMode && ( + + )} - {/* Tags Row */} - {contact.tags && contact.tags.length > 0 && ( - - {contact.tags.slice(0, 3).map((tag) => ( - - ))} - {contact.tags.length > 3 && ( - + {/* Vouches Count */} + {(() => { + const counts = getVouchPraiseCounts(contact); + const totalVouches = counts.vouchesSent + counts.vouchesReceived; + return totalVouches > 0 ? ( + } + label={totalVouches} + size="small" variant="outlined" - sx={{ - borderRadius: 1, - backgroundColor: alpha(theme.palette.grey[500], 0.04), - borderColor: alpha(theme.palette.grey[500], 0.12), - color: 'text.secondary', + sx={{ + height: 20, fontSize: '0.65rem', - height: 18 + backgroundColor: alpha(theme.palette.primary.main, 0.04), + borderColor: alpha(theme.palette.primary.main, 0.12), + color: 'primary.main', + '& .MuiChip-icon': { fontSize: 10 }, }} /> - )} - - )} - + ) : null; + })()} - {/* Right Column - Network Status Only (Push to Far Right) */} - - {(() => { - const naoStatus = getNaoStatusIndicator(contact); - if (isSelectionMode) { - return ( - - ); - } else if (contact.naoStatus === 'not_invited') { - return ( - - ); - } else { - return naoStatus ? ( + {/* Praise Count */} + {(() => { + const counts = getVouchPraiseCounts(contact); + const totalPraises = counts.praisesSent + counts.praisesReceived; + return totalPraises > 0 ? ( } + label={totalPraises} size="small" + variant="outlined" sx={{ - backgroundColor: naoStatus.bgColor, - borderColor: naoStatus.borderColor, - color: naoStatus.color, - fontWeight: 500, - fontSize: '0.75rem', - height: 28, - minWidth: 80, - '& .MuiChip-icon': { fontSize: 14 } + height: 20, + fontSize: '0.65rem', + backgroundColor: alpha('#f8bbd9', 0.3), + borderColor: alpha('#d81b60', 0.3), + color: '#d81b60', + '& .MuiChip-icon': { fontSize: 10 }, }} /> ) : null; - } - })()} - - {contact.groupIds && contact.groupIds.length > 0 && ( - } - label={`${contact.groupIds.length} group${contact.groupIds.length > 1 ? 's' : ''}`} - size="small" - variant="outlined" - sx={{ - backgroundColor: alpha(theme.palette.primary.main, 0.04), - borderColor: alpha(theme.palette.primary.main, 0.2), - color: 'primary.main', - fontWeight: 500, - fontSize: '0.75rem', - height: 24 - }} - /> - )} + })()} + {/* Mobile Layout */} - + {/* Avatar */} { justifyContent: 'center', backgroundColor: contact.profileImage ? 'transparent' : 'primary.main', color: 'white', - fontSize: '1.125rem', + fontSize: '1rem', fontWeight: 600, flexShrink: 0 }} @@ -741,16 +891,20 @@ const ContactListPage = () => { {!contact.profileImage && contact.name.charAt(0)} - {/* Main Content */} + {/* Name & Company Column */} - {/* Name with source icon */} - + {/* Name with icons */} + {contact.name} @@ -759,211 +913,114 @@ const ContactListPage = () => { {/* Job Title & Company */} - - {contact.position}{contact.company && ` at ${contact.company}`} - - - {/* Email */} - {contact.email} + {contact.position}{contact.company && ` at ${contact.company}`} + + + {/* Right side - Compact chips */} + + {/* Relationship Category */} + {contact.relationshipCategory && ( + + )} - {/* Info Chips Row (excluding NAO status) */} - - {/* Groups Count */} - {contact.groupIds && contact.groupIds.length > 0 && ( + {/* Vouches Count */} + {(() => { + const counts = getVouchPraiseCounts(contact); + const totalVouches = counts.vouchesSent + counts.vouchesReceived; + return totalVouches > 0 ? ( } - label={`${contact.groupIds.length} group${contact.groupIds.length > 1 ? 's' : ''}`} + icon={} + label={totalVouches} size="small" variant="outlined" sx={{ + height: 18, + fontSize: '0.6rem', backgroundColor: alpha(theme.palette.primary.main, 0.04), - borderColor: alpha(theme.palette.primary.main, 0.2), + borderColor: alpha(theme.palette.primary.main, 0.12), color: 'primary.main', - fontWeight: 500, - fontSize: '0.65rem', - height: 20 + '& .MuiChip-icon': { fontSize: 10 }, }} /> - )} - - {/* Vouch & Praise Counts */} - {(() => { - const counts = getVouchPraiseCounts(contact); - const totalVouches = counts.vouchesSent + counts.vouchesReceived; - const totalPraises = counts.praisesSent + counts.praisesReceived; - - return ( - <> - {totalVouches > 0 && ( - } - label={totalVouches} - size="small" - variant="outlined" - sx={{ - fontSize: '0.6rem', - height: 18, - borderRadius: 1, - backgroundColor: alpha(theme.palette.primary.main, 0.04), - borderColor: alpha(theme.palette.primary.main, 0.12), - color: 'primary.main', - '& .MuiChip-icon': { fontSize: 10 }, - }} - /> - )} - {totalPraises > 0 && ( - } - label={totalPraises} - size="small" - variant="outlined" - sx={{ - fontSize: '0.6rem', - height: 18, - borderRadius: 1, - backgroundColor: alpha('#f8bbd9', 0.3), - borderColor: alpha('#d81b60', 0.3), - color: '#d81b60', - '& .MuiChip-icon': { fontSize: 10 }, - }} - /> - )} - - ); - })()} - - - {/* Tags Row */} - {contact.tags && contact.tags.length > 0 && ( - - {contact.tags.slice(0, 2).map((tag) => ( - - ))} - {contact.tags.length > 2 && ( - - )} - - )} - + ) : null; + })()} - {/* Right Column - Status & Action */} - + {/* Praise Count */} {(() => { - const naoStatus = getNaoStatusIndicator(contact); - if (isSelectionMode) { - return ( - - ); - } else if (contact.naoStatus === 'not_invited') { - return ( - - ); - } else { - return naoStatus ? ( - - ) : null; - } + const counts = getVouchPraiseCounts(contact); + const totalPraises = counts.praisesSent + counts.praisesReceived; + return totalPraises > 0 ? ( + } + label={totalPraises} + size="small" + variant="outlined" + sx={{ + height: 18, + fontSize: '0.6rem', + backgroundColor: alpha('#f8bbd9', 0.3), + borderColor: alpha('#d81b60', 0.3), + color: '#d81b60', + '& .MuiChip-icon': { fontSize: 10 }, + }} + /> + ) : null; })()} + + {/* Selection mode button */} + {isSelectionMode && ( + + )} + ))} diff --git a/src/types/contact.ts b/src/types/contact.ts index 0e8cf8e..b0071ee 100644 --- a/src/types/contact.ts +++ b/src/types/contact.ts @@ -12,6 +12,7 @@ export interface Contact { tags?: string[]; groupIds?: string[]; naoStatus?: 'member' | 'invited' | 'not_invited'; + relationshipCategory?: 'close_family' | 'family' | 'friend' | 'colleague' | 'business' | 'acquaintance'; invitedAt?: Date; joinedAt?: Date; createdAt: Date;