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")