import streamlit as st import pandas as pd from datetime import datetime, date import plotly.express as px import plotly.graph_objects as go from config_manager import ConfigManager from vendors import VendorManager class Dashboard: def __init__(self): self.config_manager = ConfigManager() def calculate_actualized_cost(self, vendors): """Calculate total cost for only booked/ordered/delivered items""" total_cost = 0 for vendor in vendors: vendor_type = vendor.get('type', 'Vendor/Service') vendor_total_cost = vendor.get('total_cost', vendor.get('cost', 0)) # Only include cost if vendor is booked or item is ordered/delivered should_include = False if vendor_type == 'Vendor/Service': if vendor.get('status') == 'Booked': should_include = True elif vendor_type == 'Item/Purchase': status = vendor.get('status', 'Researching') if status in ['Ordered', 'Shipped', 'Delivered']: should_include = True if should_include: total_cost += vendor_total_cost return total_cost def render(self, config): wedding_info = config.get('wedding_info', {}) venue_city = wedding_info.get('venue_city', '') # Display header with location if venue_city: st.markdown(f"## 📊 Wedding Dashboard - {venue_city}") else: st.markdown("## 📊 Wedding Dashboard") # Main metrics row col1, col2, col3, col4 = st.columns(4) with col1: self.render_countdown_card(wedding_info) with col2: self.render_guest_count_card() with col3: self.render_task_progress_card() with col4: self.render_budget_card() # Add prominent cost breakdown section st.markdown("---") # Calculate costs for the breakdown section vendors = self.config_manager.load_json_data('vendors.json') total_estimated_cost = sum([v.get('total_cost', v.get('cost', 0)) for v in vendors]) total_actualized_cost = self.calculate_actualized_cost(vendors) pending_cost = total_estimated_cost - total_actualized_cost col1, col2, col3 = st.columns(3) with col1: st.markdown("### 💰 Total Estimated Cost") st.markdown(f"**${total_estimated_cost:,.0f}**") st.caption("All vendors & items regardless of status") with col2: st.markdown("### ✅ Actualized Cost") st.markdown(f"**${total_actualized_cost:,.0f}**") st.caption("Only booked/ordered/delivered items") with col3: st.markdown("### ⏳ Pending Cost") st.markdown(f"**${pending_cost:,.0f}**") st.caption("Estimated costs not yet confirmed") # Charts section col1, col2 = st.columns(2) with col1: self.render_task_progress_chart() with col2: self.render_guest_rsvp_chart() # Upcoming payments self.render_upcoming_payments() # Food choices section self.render_food_choices_by_event() # Upcoming tasks self.render_upcoming_tasks() def render_countdown_card(self, wedding_info): st.markdown("""

⏰ Wedding Countdown

""", unsafe_allow_html=True) wedding_start_str = wedding_info.get('wedding_start_date', '') wedding_end_str = wedding_info.get('wedding_end_date', '') if wedding_start_str and wedding_end_str: try: wedding_start = datetime.fromisoformat(wedding_start_str).date() wedding_end = datetime.fromisoformat(wedding_end_str).date() today = date.today() if today < wedding_start: days_until = (wedding_start - today).days st.markdown(f"

{days_until}

", unsafe_allow_html=True) if days_until == 0: st.markdown("

Festivities begin today! 🎉

", unsafe_allow_html=True) elif days_until == 1: st.markdown("

Festivities begin tomorrow! 🎊

", unsafe_allow_html=True) elif days_until <= 7: st.markdown("

Festivities begin this week! ⚡

", unsafe_allow_html=True) else: st.markdown("

days until festivities begin

", unsafe_allow_html=True) elif wedding_start <= today <= wedding_end: days_remaining = (wedding_end - today).days + 1 st.markdown(f"

{days_remaining}

", unsafe_allow_html=True) st.markdown("

days of festivities remaining! 🎉

", unsafe_allow_html=True) else: days_since = (today - wedding_end).days st.markdown(f"

{days_since}

", unsafe_allow_html=True) st.markdown("

days since festivities ended

", unsafe_allow_html=True) except: st.markdown("

-

", unsafe_allow_html=True) st.markdown("

Dates not set

", unsafe_allow_html=True) else: st.markdown("

-

", unsafe_allow_html=True) st.markdown("

Dates not set

", unsafe_allow_html=True) st.markdown("
", unsafe_allow_html=True) def render_guest_count_card(self): st.markdown("""

👥 Confirmed Guests

""", unsafe_allow_html=True) # Load RSVP data to get confirmed guests rsvp_data = self.config_manager.load_json_data('rsvp_data.json') confirmed_guests = 0 # Handle case where rsvp_data is a list (empty file) instead of dict if isinstance(rsvp_data, list): rsvp_data = {} for group_code, group_data in rsvp_data.items(): overall_rsvp = group_data.get('overall_rsvp', '') if overall_rsvp == 'Yes': # Count the number of attendees for this group group_attendees = group_data.get('group_attendees', []) confirmed_guests += len(group_attendees) st.markdown(f"

{confirmed_guests}

", unsafe_allow_html=True) st.markdown("

confirmed attending

", unsafe_allow_html=True) st.markdown("
", unsafe_allow_html=True) def render_task_progress_card(self): st.markdown("""

✅ Task Progress

""", unsafe_allow_html=True) tasks = self.config_manager.load_json_data('tasks.json') if tasks: completed = len([task for task in tasks if task.get('completed', False)]) total = len(tasks) percentage = int((completed / total) * 100) if total > 0 else 0 st.markdown(f"

{percentage}%

", unsafe_allow_html=True) st.markdown(f"

{completed}/{total} completed

", unsafe_allow_html=True) else: st.markdown("

0%

", unsafe_allow_html=True) st.markdown("

No tasks yet

", unsafe_allow_html=True) st.markdown("
", unsafe_allow_html=True) def render_budget_card(self): st.markdown("""

💰 Wedding Budget

""", unsafe_allow_html=True) # Calculate both actualized and estimated costs from vendor data vendors = self.config_manager.load_json_data('vendors.json') total_estimated_cost = 0 total_actualized_cost = self.calculate_actualized_cost(vendors) total_paid = 0 for vendor in vendors: # Use total_cost field (newer format) or cost field (legacy format) cost = vendor.get('total_cost', vendor.get('cost', 0)) # Calculate total paid from payment history instead of just deposit_paid payment_history = vendor.get('payment_history', []) total_paid_from_history = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') != 'credit') total_credits_from_history = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') == 'credit') net_paid = total_paid_from_history - total_credits_from_history total_estimated_cost += cost total_paid += net_paid pending_cost = total_estimated_cost - total_actualized_cost actualized_remaining = total_actualized_cost - total_paid estimated_remaining = total_estimated_cost - total_paid st.markdown(f"

${total_actualized_cost:,.0f}

", unsafe_allow_html=True) st.markdown(f"

${total_estimated_cost:,.0f} estimated total

", unsafe_allow_html=True) st.markdown(f"

${total_paid:,.0f} paid

", unsafe_allow_html=True) st.markdown(f"

${actualized_remaining:,.0f} confirmed remaining

", unsafe_allow_html=True) st.markdown("
", unsafe_allow_html=True) def render_task_progress_chart(self): st.markdown("### Tasks by Event/Category") tasks = self.config_manager.load_json_data('tasks.json') if tasks: # Group tasks by category categories = {} for task in tasks: category = task.get('group', 'Uncategorized') if category not in categories: categories[category] = {'completed': 0, 'not_completed': 0} if task.get('completed', False): categories[category]['completed'] += 1 else: categories[category]['not_completed'] += 1 # Create data for stacked bar chart category_names = list(categories.keys()) completed_counts = [categories[cat]['completed'] for cat in category_names] not_completed_counts = [categories[cat]['not_completed'] for cat in category_names] if category_names: fig = go.Figure(data=[ go.Bar(name='Completed', x=category_names, y=completed_counts, marker_color='#4a7c59'), go.Bar(name='Not Completed', x=category_names, y=not_completed_counts, marker_color='#d32f2f') ]) fig.update_layout( title="Number of Tasks by Event/Category", barmode='stack', xaxis_tickangle=-45, height=400, yaxis=dict(tickmode='linear', dtick=1) # Show whole numbers only ) st.plotly_chart(fig, use_container_width=True) else: st.info("No tasks to display") else: st.info("No tasks created yet") def _create_comprehensive_guest_list(self): """Create a comprehensive list of all guests with RSVP data - same logic as guest management""" all_guests = [] # Load guest list and RSVP data guest_list_data = self.config_manager.load_json_data('guest_list_data.json') rsvp_data = self.config_manager.load_json_data('rsvp_data.json') # Handle case where rsvp_data is a list (empty file) instead of dict if isinstance(rsvp_data, list): rsvp_data = {} if not guest_list_data: return all_guests for group_name, group_data in guest_list_data.items(): # Get RSVP data for this group group_rsvp_data = rsvp_data.get(group_name, {}) if rsvp_data else {} # Add named guests for named_guest in group_data['named_guests']: guest = { 'display_name': named_guest['full_name'], 'first_name': named_guest['first_name'], 'last_name': named_guest['last_name'], 'group_name': group_name, 'party': group_data['party'], 'address': group_data['address'], 'type': 'Named Guest', 'phone': group_rsvp_data.get('phone_number', ''), 'rsvp_by_event': {}, 'meal_selections': {} } # Apply RSVP data self._apply_rsvp_to_guest(guest, group_rsvp_data) all_guests.append(guest) # Add plus one spots plus_one_spots = group_data['plus_one_spots'] if plus_one_spots > 0: # Get plus one names from RSVP data group_attendees = group_rsvp_data.get('group_attendees', []) named_guest_names = [g['full_name'] for g in group_data['named_guests']] # Find plus one names (attendees not in named guests) plus_one_names = [name for name in group_attendees if name not in named_guest_names] # Create plus one entries for i in range(plus_one_spots): if i < len(plus_one_names): # Named plus one plus_one_name = plus_one_names[i] guest = { 'display_name': plus_one_name, 'first_name': plus_one_name.split()[0] if plus_one_name.split() else plus_one_name, 'last_name': ' '.join(plus_one_name.split()[1:]) if len(plus_one_name.split()) > 1 else '', 'group_name': group_name, 'party': group_data['party'], 'address': group_data['address'], 'type': 'Plus One (Named)', 'phone': '', 'rsvp_by_event': {}, 'meal_selections': {} } else: # Unnamed plus one guest = { 'display_name': f'Unnamed Plus One {i+1}', 'first_name': '', 'last_name': '', 'group_name': group_name, 'party': group_data['party'], 'address': group_data['address'], 'type': 'Plus One (Unnamed)', 'phone': '', 'rsvp_by_event': {}, 'meal_selections': {} } # Apply RSVP data self._apply_rsvp_to_guest(guest, group_rsvp_data) all_guests.append(guest) return all_guests def _apply_rsvp_to_guest(self, guest, rsvp_data): """Apply RSVP data to a guest - same logic as guest management""" config = self.config_manager.load_config() wedding_events = config.get('wedding_events', []) # Initialize all events with "Pending" status for event in wedding_events: event_name = event.get('name', '') if event_name: guest['rsvp_by_event'][event_name] = 'Pending' if not rsvp_data: return event_responses = rsvp_data.get('event_responses', {}) group_attendees = rsvp_data.get('group_attendees', []) dietary_restrictions = rsvp_data.get('dietary_restrictions', {}) guest_name = guest['display_name'] # Apply dietary restrictions if guest_name in dietary_restrictions: guest['allergies'] = dietary_restrictions[guest_name] # Apply event responses for event in wedding_events: event_name = event.get('name', '') if event_name in event_responses: event_data = event_responses[event_name] attendees = event_data.get('attendees', []) party_rsvp = event_data.get('rsvp', 'Pending') # Determine individual RSVP status if guest_name in attendees: guest['rsvp_by_event'][event_name] = party_rsvp # Apply meal choice if attending and event requires meal choice if party_rsvp == 'Yes' and event.get('requires_meal_choice', False): meal_choice = event_data.get('meal_choice', {}) if guest_name in meal_choice: guest['meal_selections'][event_name] = meal_choice[guest_name] else: guest['rsvp_by_event'][event_name] = 'No' def render_guest_rsvp_chart(self): st.markdown("### Attendance by Event") # Get comprehensive guest list using the same logic as guest management all_guests = self._create_comprehensive_guest_list() if all_guests: # Get event names from wedding config config = self.config_manager.load_config() events = [event['name'] for event in config.get('wedding_events', [])] # Count attendance for each event event_attendance = {} for event in events: event_attendance[event] = {'Yes': 0, 'No': 0, 'Pending': 0} # Count attendance for each guest for guest in all_guests: rsvp_by_event = guest.get('rsvp_by_event', {}) for event_name in event_attendance.keys(): rsvp_status = rsvp_by_event.get(event_name, 'Pending') if rsvp_status in event_attendance[event_name]: event_attendance[event_name][rsvp_status] += 1 else: event_attendance[event_name]['Pending'] += 1 # Create bar chart showing attendance by event event_names = list(event_attendance.keys()) yes_counts = [event_attendance[event]['Yes'] for event in event_names] no_counts = [event_attendance[event]['No'] for event in event_names] pending_counts = [event_attendance[event]['Pending'] for event in event_names] fig = go.Figure(data=[ go.Bar(name='Yes', x=event_names, y=yes_counts, marker_color='#4a7c59'), go.Bar(name='No', x=event_names, y=no_counts, marker_color='#d32f2f'), go.Bar(name='Pending', x=event_names, y=pending_counts, marker_color='#ffa726') ]) # Calculate the maximum value across all events for better y-axis scaling max_value = max(max(yes_counts), max(no_counts), max(pending_counts)) # Add some padding to the max value for better visualization y_max = max_value + 2 if max_value > 0 else 10 # Calculate appropriate tick spacing based on the max value if y_max <= 10: tick_spacing = 2 elif y_max <= 20: tick_spacing = 5 elif y_max <= 50: tick_spacing = 10 else: tick_spacing = 20 fig.update_layout( title="RSVP Responses by Event", barmode='stack', xaxis_tickangle=-45, height=400, yaxis=dict( tickmode='linear', dtick=tick_spacing, # Show ticks at appropriate intervals range=[0, y_max] # Set max based on actual data ) ) st.plotly_chart(fig, use_container_width=True) else: st.info("No guest data available yet") def render_food_choices_by_event(self): st.markdown("### 🍽️ Food Choices by Event") # Load data rsvp_data = self.config_manager.load_json_data('rsvp_data.json') # Handle case where rsvp_data is a list (empty file) instead of dict if isinstance(rsvp_data, list): rsvp_data = {} config = self.config_manager.load_config() events = config.get('wedding_events', []) if not rsvp_data: st.info("No RSVP data available yet") return # Filter events that require meal choices meal_events = [event for event in events if event.get('requires_meal_choice', False)] if not meal_events: st.info("No events require meal choices.") return for event in meal_events: event_name = event['name'] meal_options = event.get('meal_options', []) if meal_options: st.markdown(f"**{event_name}**") # Count meal choices for this event meal_counts = {option: 0 for option in meal_options} meal_counts['Not Selected'] = 0 outdated_choices = {} for group_code, group_data in rsvp_data.items(): # Check if this group is attending this event event_responses = group_data.get('event_responses', {}) if event_name in event_responses: event_rsvp = event_responses[event_name].get('rsvp', '') if event_rsvp == 'Yes': attendees = event_responses[event_name].get('attendees', []) # Get meal selections for each attendee from the event response meal_choices = event_responses[event_name].get('meal_choice', {}) for attendee in attendees: selected_meal = meal_choices.get(attendee, 'Not Selected') if selected_meal in meal_counts: meal_counts[selected_meal] += 1 elif selected_meal != 'Not Selected': # This is an outdated meal choice if selected_meal not in outdated_choices: outdated_choices[selected_meal] = [] outdated_choices[selected_meal].append(attendee) else: meal_counts['Not Selected'] += 1 # Display meal choice counts col1, col2, col3, col4 = st.columns(4) cols = [col1, col2, col3, col4] for i, (meal, count) in enumerate(meal_counts.items()): if i < len(cols): with cols[i]: st.metric(meal, count) # Show outdated meal choices warning if outdated_choices: st.warning("⚠️ **Outdated Meal Choices Detected!**") st.markdown("The following guests selected meal options that are no longer available on the current menu:") for outdated_meal, guests in outdated_choices.items(): with st.expander(f"🔍 {outdated_meal} ({len(guests)} guests)", expanded=False): st.markdown("**Guests who selected this outdated option:**") # Create a table for better formatting guest_data = [] for guest_name in guests: # Find the group for this guest to get contact info guest_group = None for group_code, group_data in rsvp_data.items(): if guest_name in group_data.get('group_attendees', []): guest_group = group_data break # Get phone number phone = 'No phone provided' if guest_group: phone_number = guest_group.get('phone_number', '') if phone_number and phone_number.strip() and phone_number != 'No phone provided': phone = phone_number guest_data.append({ 'Name': guest_name, 'Phone': phone }) if guest_data: guest_df = pd.DataFrame(guest_data) st.dataframe(guest_df, use_container_width=True, hide_index=True) st.markdown("**Action Required:**") st.markdown(f"Please contact these {len(guests)} guests to update their meal choice from '{outdated_meal}' to one of the current options: {', '.join(meal_options)}") st.markdown("---") # Separator between events def render_upcoming_tasks(self): st.markdown("### Upcoming Tasks (Next 7 Days)") tasks = self.config_manager.load_json_data('tasks.json') vendors = self.config_manager.load_json_data('vendors.json') # Create a vendor lookup dictionary for quick access vendor_lookup = {} if vendors: for vendor in vendors: vendor_lookup[vendor.get('id', '')] = vendor.get('name', '') if tasks: # Filter incomplete tasks and sort by due date incomplete_tasks = [task for task in tasks if not task.get('completed', False)] incomplete_tasks.sort(key=lambda x: x.get('due_date') or '9999-12-31') # Filter tasks within the next 7 days today = date.today() week_from_now = date(today.year, today.month, today.day + 7) upcoming_tasks = [] for task in incomplete_tasks: due_date_str = task.get('due_date', '') if due_date_str: try: due_date = datetime.fromisoformat(due_date_str).date() if today <= due_date <= week_from_now: upcoming_tasks.append(task) except: # If date parsing fails, skip the task (don't include it) continue # If no due date, skip the task (don't include it) if upcoming_tasks: for i, task in enumerate(upcoming_tasks): with st.container(): # Task header with completion status and title title = task.get('title', 'Untitled Task') completed = task.get('completed', False) status_icon = "✅" if completed else "⏳" # Check if task is associated with a vendor vendor_id = task.get('vendor_id', '') vendor_name = vendor_lookup.get(vendor_id, '') if vendor_name: st.markdown(f"**{status_icon} {title}** - *{vendor_name}*") else: st.markdown(f"**{status_icon} {title}**") # Task details in columns col1, col2, col3, col4 = st.columns(4) with col1: due_date = task.get('due_date', '') if due_date: st.caption(f"📅 Due: {due_date}") else: st.caption("📅 No due date") with col2: group = task.get('group', 'Uncategorized') st.caption(f"📁 Group: {group}") with col3: priority = task.get('priority', 'Medium') # Priority with color coding if priority == "Urgent": st.caption(f"🔴 Priority: {priority}") elif priority == "High": st.caption(f"🔴 Priority: {priority}") elif priority == "Medium": st.caption(f"🟡 Priority: {priority}") else: st.caption(f"🟢 Priority: {priority}") with col4: assigned_to = task.get('assigned_to', '') # Handle both old single assignee and new multiple assignees format if isinstance(assigned_to, str): assigned_to_display = assigned_to if assigned_to else "Unassigned" elif isinstance(assigned_to, list): if assigned_to: assigned_to_display = ", ".join(assigned_to) else: assigned_to_display = "Unassigned" else: assigned_to_display = "Unassigned" if assigned_to_display and assigned_to_display != "Unassigned": st.caption(f"👤 Assigned: {assigned_to_display}") else: st.caption("👤 Unassigned") # Add some spacing st.markdown("---") else: st.info("No tasks due within the next 7 days") else: st.info("No tasks created yet") def render_upcoming_payments(self): st.markdown("### 💳 Upcoming Payments (Next 30 Days)") # Create VendorManager instance to use its payment logic vendor_manager = VendorManager() # Load vendors data vendors = self.config_manager.load_json_data('vendors.json') if not vendors: st.info("No vendors added yet") return # Get upcoming payments using the same logic as the vendors page upcoming_payments = [] today = date.today() for vendor in vendors: payment_installments = vendor.get('payment_installments', []) if payment_installments and len(payment_installments) > 1: # Handle installments for i, installment in enumerate(payment_installments): if not installment.get('paid', False): due_date_str = installment.get('due_date', '') if due_date_str: try: due_date = datetime.fromisoformat(due_date_str).date() days_until_due = (due_date - today).days # Only show if within next 30 days if 0 <= days_until_due <= 30: upcoming_payments.append({ 'vendor_name': vendor.get('name', ''), 'installment_num': i + 1, 'amount': installment.get('amount', 0), 'due_date': due_date, 'days_until': days_until_due, 'is_installment': True }) except: continue else: # Handle single payment payment_due_date_str = vendor.get('payment_due_date') if payment_due_date_str: try: due_date = datetime.fromisoformat(payment_due_date_str).date() days_until_due = (due_date - today).days # Only show if not fully paid and within next 30 days total_cost = vendor.get('total_cost', vendor.get('cost', 0)) payment_history = vendor.get('payment_history', []) total_paid_from_history = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') != 'credit') total_credits_from_history = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') == 'credit') remaining_balance = total_cost - total_paid_from_history + total_credits_from_history if remaining_balance > 0 and 0 <= days_until_due <= 30: upcoming_payments.append({ 'vendor_name': vendor.get('name', ''), 'installment_num': None, 'amount': remaining_balance, 'due_date': due_date, 'days_until': days_until_due, 'is_installment': False }) except: continue # Sort by days until due upcoming_payments.sort(key=lambda x: x['days_until']) if upcoming_payments: # Create table data table_data = [] for payment in upcoming_payments: # Format payment description if payment['is_installment']: payment_desc = f"{payment['vendor_name']} - Installment {payment['installment_num']}" else: payment_desc = f"{payment['vendor_name']} - Final Payment" # Format due date if payment['days_until'] == 0: due_text = "🟠 Today" elif payment['days_until'] == 1: due_text = "🟡 Tomorrow" elif payment['days_until'] <= 3: due_text = f"🟡 {payment['days_until']} days" else: due_text = f"🟢 {payment['days_until']} days" table_data.append({ 'Vendor & Payment': payment_desc, 'Amount': f"${payment['amount']:,.0f}", 'Due': due_text }) # Create and display the table df = pd.DataFrame(table_data) st.dataframe(df, use_container_width=True, hide_index=True) else: st.info("No payments due within the next 30 days")