Spaces:
Sleeping
Sleeping
| 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(""" | |
| <div class="metric-card"> | |
| <h3>β° Wedding Countdown</h3> | |
| """, 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"<h1 style='margin: 0; color: white;'>{days_until}</h1>", unsafe_allow_html=True) | |
| if days_until == 0: | |
| st.markdown("<p style='margin: 0; color: white;'>Festivities begin today! π</p>", unsafe_allow_html=True) | |
| elif days_until == 1: | |
| st.markdown("<p style='margin: 0; color: white;'>Festivities begin tomorrow! π</p>", unsafe_allow_html=True) | |
| elif days_until <= 7: | |
| st.markdown("<p style='margin: 0; color: white;'>Festivities begin this week! β‘</p>", unsafe_allow_html=True) | |
| else: | |
| st.markdown("<p style='margin: 0; color: white;'>days until festivities begin</p>", unsafe_allow_html=True) | |
| elif wedding_start <= today <= wedding_end: | |
| days_remaining = (wedding_end - today).days + 1 | |
| st.markdown(f"<h1 style='margin: 0; color: white;'>{days_remaining}</h1>", unsafe_allow_html=True) | |
| st.markdown("<p style='margin: 0; color: white;'>days of festivities remaining! π</p>", unsafe_allow_html=True) | |
| else: | |
| days_since = (today - wedding_end).days | |
| st.markdown(f"<h1 style='margin: 0; color: white;'>{days_since}</h1>", unsafe_allow_html=True) | |
| st.markdown("<p style='margin: 0; color: white;'>days since festivities ended</p>", unsafe_allow_html=True) | |
| except: | |
| st.markdown("<h1 style='margin: 0; color: white;'>-</h1>", unsafe_allow_html=True) | |
| st.markdown("<p style='margin: 0; color: white;'>Dates not set</p>", unsafe_allow_html=True) | |
| else: | |
| st.markdown("<h1 style='margin: 0; color: white;'>-</h1>", unsafe_allow_html=True) | |
| st.markdown("<p style='margin: 0; color: white;'>Dates not set</p>", unsafe_allow_html=True) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| def render_guest_count_card(self): | |
| st.markdown(""" | |
| <div class="metric-card"> | |
| <h3>π₯ Confirmed Guests</h3> | |
| """, 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"<h1 style='margin: 0; color: white;'>{confirmed_guests}</h1>", unsafe_allow_html=True) | |
| st.markdown("<p style='margin: 0; color: white;'>confirmed attending</p>", unsafe_allow_html=True) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| def render_task_progress_card(self): | |
| st.markdown(""" | |
| <div class="metric-card"> | |
| <h3>β Task Progress</h3> | |
| """, 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"<h1 style='margin: 0; color: white;'>{percentage}%</h1>", unsafe_allow_html=True) | |
| st.markdown(f"<p style='margin: 0; color: white;'>{completed}/{total} completed</p>", unsafe_allow_html=True) | |
| else: | |
| st.markdown("<h1 style='margin: 0; color: white;'>0%</h1>", unsafe_allow_html=True) | |
| st.markdown("<p style='margin: 0; color: white;'>No tasks yet</p>", unsafe_allow_html=True) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| def render_budget_card(self): | |
| st.markdown(""" | |
| <div class="metric-card"> | |
| <h3>π° Wedding Budget</h3> | |
| """, 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"<h1 style='margin: 0; color: white;'>${total_actualized_cost:,.0f}</h1>", unsafe_allow_html=True) | |
| st.markdown(f"<p style='margin: 0; color: white;'>${total_estimated_cost:,.0f} estimated total</p>", unsafe_allow_html=True) | |
| st.markdown(f"<p style='margin: 0; color: white;'>${total_paid:,.0f} paid</p>", unsafe_allow_html=True) | |
| st.markdown(f"<p style='margin: 0; color: white;'>${actualized_remaining:,.0f} confirmed remaining</p>", unsafe_allow_html=True) | |
| st.markdown("</div>", 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") | |