umangchaudhry commited on
Commit
005a706
·
verified ·
1 Parent(s): 75dd169

Upload 10 files

Browse files
Files changed (10) hide show
  1. Dockerfile +36 -0
  2. app.py +904 -0
  3. app_config.json +9 -0
  4. config_manager.py +334 -0
  5. dashboard.py +780 -0
  6. google_drive_manager.py +230 -0
  7. requirements.txt +10 -0
  8. tasks.py +665 -0
  9. vendors.py +0 -0
  10. wedding_party.py +799 -0
Dockerfile ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Python 3.11 slim image as base
2
+ FROM python:3.11-slim
3
+
4
+ # Set working directory
5
+ WORKDIR /app
6
+
7
+ # Set environment variables
8
+ ENV PYTHONUNBUFFERED=1
9
+ ENV PYTHONDONTWRITEBYTECODE=1
10
+
11
+ # Install system dependencies
12
+ RUN apt-get update && apt-get install -y \
13
+ gcc \
14
+ g++ \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ # Copy requirements first for better caching
18
+ COPY requirements.txt .
19
+
20
+ # Install Python dependencies
21
+ RUN pip install --no-cache-dir -r requirements.txt
22
+
23
+ # Copy application code
24
+ COPY . .
25
+
26
+ # Create necessary directories
27
+ RUN mkdir -p /tmp/wedding_data /tmp/demo_data
28
+
29
+ # Expose port (Streamlit default)
30
+ EXPOSE 8501
31
+
32
+ # Health check
33
+ HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
34
+
35
+ # Run the Streamlit app
36
+ CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0", "--server.headless=true", "--server.enableCORS=false", "--server.enableXsrfProtection=false"]
app.py ADDED
@@ -0,0 +1,904 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import json
3
+ import os
4
+ from datetime import datetime, date, timedelta
5
+ import pandas as pd
6
+ from config_manager import ConfigManager
7
+ from dashboard import Dashboard
8
+ from tasks import TasksManager
9
+ from guests import GuestManager
10
+ from wedding_party import WeddingPartyManager
11
+ from vendors import VendorManager
12
+
13
+ # Page configuration
14
+ st.set_page_config(
15
+ page_title="Wedding Planner",
16
+ page_icon="💒",
17
+ layout="wide",
18
+ initial_sidebar_state="expanded"
19
+ )
20
+
21
+ # Custom CSS for green/Adirondack theme
22
+ st.markdown("""
23
+ <style>
24
+ .main-header {
25
+ background: linear-gradient(90deg, #2d5016, #4a7c59);
26
+ color: white;
27
+ padding: 1rem;
28
+ border-radius: 10px;
29
+ text-align: center;
30
+ margin-bottom: 2rem;
31
+ }
32
+
33
+ .metric-card {
34
+ background: linear-gradient(135deg, #4a7c59, #6b8e6b);
35
+ color: white;
36
+ padding: 1rem;
37
+ border-radius: 10px;
38
+ text-align: center;
39
+ margin: 0.5rem;
40
+ height: 150px;
41
+ display: flex;
42
+ flex-direction: column;
43
+ justify-content: center;
44
+ align-items: center;
45
+ }
46
+
47
+ .sidebar .sidebar-content {
48
+ background: linear-gradient(180deg, #2d5016, #4a7c59);
49
+ }
50
+
51
+ .stButton > button {
52
+ background: linear-gradient(90deg, #4a7c59, #6b8e6b);
53
+ color: white;
54
+ border: none;
55
+ border-radius: 5px;
56
+ padding: 0.5rem 1rem;
57
+ }
58
+
59
+ .stButton > button:hover {
60
+ background: linear-gradient(90deg, #2d5016, #4a7c59);
61
+ color: white;
62
+ }
63
+
64
+ .task-card {
65
+ background: #f8f9fa;
66
+ border-left: 4px solid #4a7c59;
67
+ padding: 1rem;
68
+ margin: 0.5rem 0;
69
+ border-radius: 5px;
70
+ }
71
+
72
+ .guest-card {
73
+ background: #f8f9fa;
74
+ border: 1px solid #4a7c59;
75
+ padding: 1rem;
76
+ margin: 0.5rem 0;
77
+ border-radius: 5px;
78
+ }
79
+ </style>
80
+ """, unsafe_allow_html=True)
81
+
82
+ def main():
83
+ # Initialize session state
84
+ if 'config_manager' not in st.session_state:
85
+ st.session_state.config_manager = ConfigManager()
86
+
87
+ # Check if config exists, if not show setup
88
+ if not st.session_state.config_manager.config_exists():
89
+ show_setup_page()
90
+ else:
91
+ show_main_app()
92
+
93
+ def show_setup_page():
94
+ st.markdown('<div class="main-header"><h1>💒 Wedding Planner Setup</h1></div>', unsafe_allow_html=True)
95
+
96
+ st.markdown("### Welcome! Let's set up your wedding planning app.")
97
+
98
+ # Demo mode option
99
+ st.markdown("#### Choose Your Experience")
100
+ col1, col2 = st.columns(2)
101
+
102
+ with col1:
103
+ st.markdown("**🎭 Try Demo Mode**")
104
+ st.markdown("Experience the app with sample data including:")
105
+ st.markdown("• Sample wedding (Emma & James)")
106
+ st.markdown("• Demo guests with RSVPs")
107
+ st.markdown("• Vendors with complex payment schedules")
108
+ st.markdown("• Tasks in various stages")
109
+ st.markdown("• Wedding party information")
110
+
111
+ if st.button("Start with Demo Data", type="primary"):
112
+ if st.session_state.config_manager.set_demo_mode(True):
113
+ st.success("Demo mode enabled! Loading sample data...")
114
+ st.rerun()
115
+ else:
116
+ st.error("Failed to enable demo mode.")
117
+
118
+ with col2:
119
+ st.markdown("**📝 Start Fresh**")
120
+ st.markdown("Create your own wedding from scratch:")
121
+ st.markdown("• Enter your wedding details")
122
+ st.markdown("• Add your own events")
123
+ st.markdown("• Build your guest list")
124
+ st.markdown("• Manage your vendors")
125
+ st.markdown("• Track your tasks")
126
+
127
+ if st.button("Create My Wedding", type="secondary"):
128
+ if st.session_state.config_manager.set_demo_mode(False):
129
+ st.success("Demo mode disabled. Ready to create your wedding!")
130
+ st.rerun()
131
+ else:
132
+ st.error("Failed to disable demo mode.")
133
+
134
+ st.markdown("---")
135
+
136
+ # Initialize session state for events and form data if not exists
137
+ if 'setup_events' not in st.session_state:
138
+ st.session_state.setup_events = []
139
+ if 'setup_form_data' not in st.session_state:
140
+ st.session_state.setup_form_data = {
141
+ 'partner1_name': '',
142
+ 'partner2_name': '',
143
+ 'venue_city': '',
144
+ 'wedding_start_date': date.today(),
145
+ 'wedding_end_date': date.today(),
146
+ 'custom_tags': '',
147
+ 'task_assignees': ''
148
+ }
149
+
150
+ # Basic wedding information form
151
+ with st.form("wedding_setup"):
152
+ st.markdown("#### Basic Wedding Information")
153
+
154
+ col1, col2 = st.columns(2)
155
+ with col1:
156
+ partner1_name = st.text_input("Partner 1 Name", value=st.session_state.setup_form_data['partner1_name'], placeholder="Enter first partner's name")
157
+ partner2_name = st.text_input("Partner 2 Name", value=st.session_state.setup_form_data['partner2_name'], placeholder="Enter second partner's name")
158
+ venue_city = st.text_input("City", value=st.session_state.setup_form_data['venue_city'], placeholder="Enter city")
159
+
160
+ with col2:
161
+ st.markdown("**Wedding Date Range**")
162
+ wedding_start_date = st.date_input("Start Date", value=st.session_state.setup_form_data['wedding_start_date'])
163
+ wedding_end_date = st.date_input("End Date", value=st.session_state.setup_form_data['wedding_end_date'])
164
+
165
+ if wedding_end_date < wedding_start_date:
166
+ st.error("End date must be after start date")
167
+ wedding_end_date = wedding_start_date
168
+
169
+ st.markdown("#### Task Organization")
170
+ st.info("Tasks will be automatically grouped by your wedding events.")
171
+
172
+ st.markdown("#### Custom Tags")
173
+ st.markdown("Enter custom tags (one per line):")
174
+ custom_tags = st.text_area("Custom Tags", value=st.session_state.setup_form_data['custom_tags'], placeholder="e.g.,\nUrgent\nHigh Priority\nDeposit Required\nResearch Needed")
175
+
176
+ st.markdown("#### Task Assignees")
177
+ st.markdown("Enter people who will regularly be assigned tasks (one per line):")
178
+ task_assignees = st.text_area("Task Assignees", value=st.session_state.setup_form_data['task_assignees'], placeholder="e.g.,\nMom\nDad\nWedding Planner\nBest Friend\nCoordinator")
179
+
180
+ form_submitted = st.form_submit_button("Update Wedding Information")
181
+
182
+ if form_submitted:
183
+ # Update session state with form data
184
+ st.session_state.setup_form_data = {
185
+ 'partner1_name': partner1_name,
186
+ 'partner2_name': partner2_name,
187
+ 'venue_city': venue_city,
188
+ 'wedding_start_date': wedding_start_date,
189
+ 'wedding_end_date': wedding_end_date,
190
+ 'custom_tags': custom_tags,
191
+ 'task_assignees': task_assignees
192
+ }
193
+ st.success("Wedding information updated!")
194
+ st.rerun()
195
+
196
+ # Event management section (outside form)
197
+ st.markdown("#### Wedding Events")
198
+ st.markdown("Define all your wedding events with their details:")
199
+
200
+ # Add/Remove event buttons
201
+ col1, col2 = st.columns(2)
202
+ with col1:
203
+ if st.button("➕ Add Event"):
204
+ # Set default date to wedding start date
205
+ wedding_start = st.session_state.setup_form_data['wedding_start_date']
206
+ st.session_state.setup_events.append({
207
+ "name": "New Event",
208
+ "description": "",
209
+ "date_offset": 0,
210
+ "requires_meal_choice": False,
211
+ "meal_options": [],
212
+ "location": "",
213
+ "address": ""
214
+ })
215
+ st.rerun()
216
+
217
+ with col2:
218
+ if len(st.session_state.setup_events) > 0 and st.button("➖ Remove Last Event"):
219
+ st.session_state.setup_events.pop()
220
+ st.rerun()
221
+
222
+ # Display events
223
+ if st.session_state.setup_events:
224
+ for i, event in enumerate(st.session_state.setup_events):
225
+ with st.expander(f"Event {i+1}: {event['name']}", expanded=True):
226
+ col1, col2 = st.columns(2)
227
+
228
+ with col1:
229
+ event_name = st.text_input("Event Name", value=event['name'], key=f"event_name_{i}")
230
+ event_description = st.text_input("Time", value=event['description'], placeholder="e.g., 2:00 PM, 6:30 PM", key=f"event_desc_{i}")
231
+ event_location = st.text_input("Location Name", value=event.get('location', ''), placeholder="e.g., Central Park, Grand Ballroom", key=f"event_location_{i}")
232
+
233
+ with col2:
234
+ # Get wedding date range
235
+ wedding_start = st.session_state.setup_form_data['wedding_start_date']
236
+ wedding_end = st.session_state.setup_form_data['wedding_end_date']
237
+
238
+ # Calculate current event date from date_offset
239
+ current_event_date = wedding_start + timedelta(days=event['date_offset'])
240
+
241
+ # Use date input without constraints - allow any date
242
+ event_date = st.date_input(
243
+ "Event Date",
244
+ value=current_event_date,
245
+ key=f"event_date_{i}",
246
+ help="Select any date for this event"
247
+ )
248
+
249
+ # Show warning if date is outside wedding range
250
+ if event_date < wedding_start or event_date > wedding_end:
251
+ st.warning(f"⚠️ Selected date is outside your wedding date range ({wedding_start.strftime('%B %d, %Y')} - {wedding_end.strftime('%B %d, %Y')})")
252
+
253
+ requires_meal_choice = st.checkbox("Requires Meal Choice", value=event['requires_meal_choice'], key=f"event_meal_{i}")
254
+
255
+ # Meal options section (only show if meal choice is required)
256
+ if requires_meal_choice:
257
+ st.markdown("**Meal Options**")
258
+ st.markdown("Enter meal options (one per line):")
259
+ current_meal_options = event.get('meal_options', [])
260
+ meal_options_text = '\n'.join(current_meal_options) if current_meal_options else ''
261
+ meal_options = st.text_area("Meal Options", value=meal_options_text, placeholder="e.g.,\nDuck\nSurf & Turf\nRisotto (vegetarian)\nStuffed Squash (vegetarian)", key=f"event_meal_options_{i}", height=100)
262
+ else:
263
+ meal_options = ""
264
+
265
+ event_address = st.text_area("Address", value=event.get('address', ''), placeholder="Enter full address (street, city, state, zip code)", key=f"event_address_{i}", height=80)
266
+
267
+ # Calculate date_offset from the selected date
268
+ date_offset = (event_date - wedding_start).days
269
+
270
+ # Parse meal options
271
+ meal_options_list = []
272
+ if requires_meal_choice and meal_options:
273
+ meal_options_list = [option.strip() for option in meal_options.split('\n') if option.strip()]
274
+
275
+ # Update session state
276
+ st.session_state.setup_events[i] = {
277
+ "name": event_name,
278
+ "description": event_description,
279
+ "date_offset": date_offset,
280
+ "requires_meal_choice": requires_meal_choice,
281
+ "meal_options": meal_options_list,
282
+ "location": event_location,
283
+ "address": event_address
284
+ }
285
+ else:
286
+ st.info("No events added yet. Click 'Add Event' to get started!")
287
+
288
+ # Save configuration button (after event management)
289
+ st.markdown("---")
290
+ if st.button("Save Configuration", type="primary"):
291
+ # Get form values from session state
292
+ form_data = st.session_state.setup_form_data
293
+
294
+ if form_data['partner1_name'] and form_data['partner2_name'] and form_data['wedding_start_date'] and form_data['wedding_end_date']:
295
+ # Parse tags (task groups will be auto-generated from events)
296
+ custom_tags_list = [tag.strip() for tag in form_data['custom_tags'].split('\n') if tag.strip()]
297
+ task_assignees_list = [assignee.strip() for assignee in form_data['task_assignees'].split('\n') if assignee.strip()]
298
+
299
+ # Create configuration
300
+ config = {
301
+ 'wedding_info': {
302
+ 'partner1_name': form_data['partner1_name'],
303
+ 'partner2_name': form_data['partner2_name'],
304
+ 'wedding_start_date': form_data['wedding_start_date'].isoformat(),
305
+ 'wedding_end_date': form_data['wedding_end_date'].isoformat(),
306
+ 'venue_city': form_data['venue_city']
307
+ },
308
+ 'custom_settings': {
309
+ 'custom_tags': custom_tags_list,
310
+ 'task_assignees': task_assignees_list
311
+ },
312
+ 'wedding_events': st.session_state.setup_events
313
+ }
314
+
315
+ # Save configuration
316
+ st.session_state.config_manager.save_config(config)
317
+ # Clear setup session state
318
+ if 'setup_events' in st.session_state:
319
+ del st.session_state.setup_events
320
+ if 'setup_form_data' in st.session_state:
321
+ del st.session_state.setup_form_data
322
+ st.success("Configuration saved successfully!")
323
+ st.rerun()
324
+ else:
325
+ st.error("Please fill in at least the partner names and wedding date range in the form above.")
326
+
327
+ def show_main_app():
328
+ config = st.session_state.config_manager.load_config()
329
+ wedding_info = config.get('wedding_info', {})
330
+
331
+ # Check demo mode status
332
+ is_demo_mode = st.session_state.config_manager.is_demo_mode()
333
+
334
+ # Header
335
+ partner1 = wedding_info.get('partner1_name', 'Partner 1')
336
+ partner2 = wedding_info.get('partner2_name', 'Partner 2')
337
+ venue_city = wedding_info.get('venue_city', '')
338
+ wedding_start_str = wedding_info.get('wedding_start_date', '')
339
+ wedding_end_str = wedding_info.get('wedding_end_date', '')
340
+
341
+ # Build base header text with location
342
+ if venue_city:
343
+ header_text = f"{partner1} & {partner2}'s Wedding Planner - {venue_city} \n"
344
+ else:
345
+ header_text = f"{partner1} & {partner2}'s Wedding Planner \n"
346
+
347
+ # Add demo mode indicator
348
+ if is_demo_mode:
349
+ header_text += "🎭 DEMO MODE - Sample Data"
350
+
351
+ if wedding_start_str and wedding_end_str:
352
+ try:
353
+ wedding_start = datetime.fromisoformat(wedding_start_str).date()
354
+ wedding_end = datetime.fromisoformat(wedding_end_str).date()
355
+ today = date.today()
356
+
357
+ if today < wedding_start:
358
+ days_until = (wedding_start - today).days
359
+ header_text += f"\n{days_until} days until wedding festivities begin!"
360
+ elif wedding_start <= today <= wedding_end:
361
+ header_text += " - Wedding festivities are happening now! 🎉"
362
+ else:
363
+ days_since = (today - wedding_end).days
364
+ header_text += f" - {days_since} days since the wedding celebration ended!"
365
+ except:
366
+ pass # Keep the base header text if date parsing fails
367
+
368
+ st.markdown(f'<div class="main-header"><h1>{header_text}</h1></div>', unsafe_allow_html=True)
369
+
370
+ # Sidebar navigation
371
+ with st.sidebar:
372
+ st.markdown("### Navigation")
373
+ page = st.radio(
374
+ "Choose a page:",
375
+ ["Dashboard", "Tasks", "Guest Management", "Wedding Party", "Wedding Overview", "Vendors & Purchases", "Settings"]
376
+ )
377
+
378
+ # Route to appropriate page
379
+ if page == "Dashboard":
380
+ Dashboard().render(config)
381
+ elif page == "Tasks":
382
+ TasksManager().render(config)
383
+ elif page == "Guest Management":
384
+ GuestManager().render(config)
385
+ elif page == "Wedding Party":
386
+ WeddingPartyManager().render(config)
387
+ elif page == "Wedding Overview":
388
+ show_wedding_timeline_page(config)
389
+ elif page == "Vendors & Purchases":
390
+ VendorManager().render(config)
391
+ elif page == "Settings":
392
+ show_settings_page(config)
393
+
394
+ def show_wedding_timeline_page(config):
395
+ st.markdown("## 📅 Wedding Overview")
396
+
397
+ # Show wedding events directly without tabs
398
+ show_wedding_events_section(config)
399
+
400
+ def get_vendors_for_event(event_name, vendors_data):
401
+ """Get vendors associated with a specific event, handling multiple categories per vendor"""
402
+ event_vendors = []
403
+ for vendor in vendors_data:
404
+ vendor_events = vendor.get('events', [])
405
+ if event_name in vendor_events:
406
+ # Get all categories for this vendor
407
+ categories = vendor.get('categories', [])
408
+ primary_category = vendor.get('category', '')
409
+
410
+ # If no categories array, use the primary category
411
+ if not categories and primary_category:
412
+ categories = [primary_category]
413
+
414
+ # If still no categories, use a default
415
+ if not categories:
416
+ categories = ['Vendor/Service']
417
+
418
+ # Create an entry for each category this vendor serves
419
+ for category in categories:
420
+ event_vendors.append({
421
+ 'name': vendor.get('name', ''),
422
+ 'category': category,
423
+ 'status': vendor.get('status', ''),
424
+ 'vendor_id': vendor.get('id', '') # Add ID to help identify duplicates
425
+ })
426
+
427
+ return event_vendors
428
+
429
+ def get_meal_choices_for_event(event_name, rsvp_data, meal_options):
430
+ """Get meal choice counts for a specific event from RSVP data"""
431
+ meal_counts = {}
432
+
433
+ # Initialize counts for all meal options
434
+ for option in meal_options:
435
+ meal_counts[option] = 0
436
+
437
+ # Count meal choices from RSVP data
438
+ for group_code, group_data in rsvp_data.items():
439
+ event_responses = group_data.get('event_responses', {})
440
+ if event_name in event_responses:
441
+ event_data = event_responses[event_name]
442
+ meal_choices = event_data.get('meal_choice', {})
443
+
444
+ for attendee, choice in meal_choices.items():
445
+ if choice in meal_counts:
446
+ meal_counts[choice] += 1
447
+
448
+ return meal_counts
449
+
450
+ def show_wedding_events_section(config):
451
+ st.markdown("## 📅 Wedding Events")
452
+
453
+ wedding_events = config.get('wedding_events', [])
454
+ wedding_info = config.get('wedding_info', {})
455
+
456
+ if not wedding_events:
457
+ st.info("No events configured yet. Please complete the setup to define your wedding events.")
458
+ return
459
+
460
+ # Get wedding dates
461
+ wedding_start_str = wedding_info.get('wedding_start_date', '')
462
+ wedding_end_str = wedding_info.get('wedding_end_date', '')
463
+
464
+ if wedding_start_str and wedding_end_str:
465
+ try:
466
+ wedding_start = datetime.fromisoformat(wedding_start_str).date()
467
+ wedding_end = datetime.fromisoformat(wedding_end_str).date()
468
+ except:
469
+ st.error("Invalid wedding date format. Please check your settings.")
470
+ return
471
+ else:
472
+ st.warning("Wedding dates not set. Please complete the setup.")
473
+ return
474
+
475
+ # Load vendors and RSVP data
476
+ config_manager = ConfigManager()
477
+ vendors_data = config_manager.load_json_data('vendors.json')
478
+ rsvp_data = config_manager.load_json_data('rsvp_data.json')
479
+
480
+ # Display events
481
+ st.markdown(f"### Your Wedding Events ({len(wedding_events)} total)")
482
+
483
+ # Group events by day
484
+ events_by_day = {}
485
+ for event in wedding_events:
486
+ event_date = wedding_start + timedelta(days=event.get('date_offset', 0))
487
+ day_key = event_date.strftime('%Y-%m-%d')
488
+ if day_key not in events_by_day:
489
+ events_by_day[day_key] = []
490
+ events_by_day[day_key].append(event)
491
+
492
+ # Sort days
493
+ sorted_days = sorted(events_by_day.keys())
494
+
495
+ for day in sorted_days:
496
+ day_date = datetime.fromisoformat(day).date()
497
+ day_events = events_by_day[day]
498
+
499
+ # Determine day label
500
+ if day_date == wedding_start:
501
+ day_label = "Day 1 - Wedding Start"
502
+ elif day_date == wedding_end:
503
+ day_label = "Final Day - Wedding End"
504
+ else:
505
+ days_from_start = (day_date - wedding_start).days
506
+ day_label = f"Day {days_from_start + 1}"
507
+
508
+ st.markdown(f"#### {day_label} - {day_date.strftime('%B %d, %Y')}")
509
+
510
+ for event in day_events:
511
+ # Simple event display
512
+ time_info = event.get('description', '') or 'Time TBD'
513
+ location = event.get('location', '') or 'Location TBD'
514
+ address = event.get('address', '')
515
+ meal_required = event.get('requires_meal_choice', False)
516
+ event_name = event.get('name', 'Untitled Event')
517
+
518
+ st.markdown(f"**{event_name}**")
519
+ st.markdown(f"🕐 **Time:** {time_info}")
520
+ st.markdown(f"📍 **Location:** {location}")
521
+ if address:
522
+ st.markdown(f"🏠 **Address:** {address}")
523
+ st.markdown(f"🍽️ **Meal Choice:** {'Required' if meal_required else 'Not Required'}")
524
+
525
+ # Display meal choices and counts if meal choice is required
526
+ if meal_required:
527
+ meal_options = event.get('meal_options', [])
528
+ if meal_options:
529
+ meal_counts = get_meal_choices_for_event(event_name, rsvp_data, meal_options)
530
+ st.markdown("🍽️ **Meal Choices:**")
531
+ for option in meal_options:
532
+ count = meal_counts.get(option, 0)
533
+ st.markdown(f" • **{option}:** {count} orders")
534
+ else:
535
+ st.markdown("🍽️ **Meal Choices:** No options configured")
536
+
537
+ # Display vendors for this event
538
+ event_vendors = get_vendors_for_event(event_name, vendors_data)
539
+ if event_vendors:
540
+ st.markdown("🏢 **Vendors:**")
541
+
542
+ # Group vendors by name to handle multiple categories
543
+ vendors_by_name = {}
544
+ for vendor in event_vendors:
545
+ vendor_name = vendor['name']
546
+ if vendor_name not in vendors_by_name:
547
+ vendors_by_name[vendor_name] = {
548
+ 'categories': [],
549
+ 'status': vendor['status']
550
+ }
551
+ vendors_by_name[vendor_name]['categories'].append(vendor['category'])
552
+
553
+ # Display grouped vendors
554
+ for vendor_name, vendor_info in vendors_by_name.items():
555
+ status_emoji = "✅" if vendor_info['status'] == "Booked" else "⏳" if vendor_info['status'] == "Researching" else "📋"
556
+ categories_text = ", ".join(vendor_info['categories'])
557
+ st.markdown(f" • {status_emoji} **{categories_text}:** {vendor_name}")
558
+ else:
559
+ st.markdown("🏢 **Vendors:** None assigned")
560
+
561
+ st.markdown("---")
562
+
563
+ # Event summary
564
+ st.markdown("### Event Summary")
565
+
566
+ col1, col2, col3 = st.columns(3)
567
+
568
+ with col1:
569
+ total_events = len(wedding_events)
570
+ st.metric("Total Events", total_events)
571
+
572
+ with col2:
573
+ meal_events = len([e for e in wedding_events if e.get('requires_meal_choice', False)])
574
+ st.metric("Events with Meals", meal_events)
575
+
576
+ with col3:
577
+ days_span = (wedding_end - wedding_start).days + 1
578
+ st.metric("Celebration Days", days_span)
579
+
580
+ def show_settings_page(config):
581
+ st.markdown("### Settings")
582
+
583
+ # Check demo mode status
584
+ is_demo_mode = st.session_state.config_manager.is_demo_mode()
585
+
586
+ # Demo mode toggle at the top
587
+ st.markdown("#### Demo Mode")
588
+ col1, col2 = st.columns([3, 1])
589
+ with col1:
590
+ if is_demo_mode:
591
+ st.info("🎭 **Demo Mode is ON** - You are currently viewing sample data. This includes demo guests, vendors with complex payment schedules, tasks, and wedding party information.")
592
+ else:
593
+ st.info("📝 **Demo Mode is OFF** - You are viewing your actual wedding data.")
594
+
595
+ with col2:
596
+ if st.button("Toggle Demo Mode", type="secondary"):
597
+ if st.session_state.config_manager.toggle_demo_mode():
598
+ st.success("Demo mode toggled! Please refresh the page.")
599
+ st.rerun()
600
+ else:
601
+ st.error("Failed to toggle demo mode.")
602
+
603
+ # Create tabs for different settings
604
+ tab1, tab2, tab3, tab4, tab5 = st.tabs(["Edit Configuration", "Manage Events", "Google Drive", "Current Configuration", "Reset"])
605
+
606
+ with tab1:
607
+ st.markdown("#### Edit Wedding Configuration")
608
+
609
+ # Initialize session state for editing if not exists
610
+ if 'edit_config' not in st.session_state:
611
+ st.session_state.edit_config = config.copy()
612
+
613
+ with st.form("edit_wedding_config"):
614
+ st.markdown("##### Basic Wedding Information")
615
+
616
+ col1, col2 = st.columns(2)
617
+ with col1:
618
+ partner1_name = st.text_input("Partner 1 Name", value=st.session_state.edit_config.get('wedding_info', {}).get('partner1_name', ''))
619
+ partner2_name = st.text_input("Partner 2 Name", value=st.session_state.edit_config.get('wedding_info', {}).get('partner2_name', ''))
620
+ venue_city = st.text_input("City", value=st.session_state.edit_config.get('wedding_info', {}).get('venue_city', ''))
621
+
622
+ with col2:
623
+ # Get current dates
624
+ wedding_start_str = st.session_state.edit_config.get('wedding_info', {}).get('wedding_start_date', '')
625
+ wedding_end_str = st.session_state.edit_config.get('wedding_info', {}).get('wedding_end_date', '')
626
+
627
+ try:
628
+ if wedding_start_str:
629
+ wedding_start = datetime.fromisoformat(wedding_start_str).date()
630
+ else:
631
+ wedding_start = date.today()
632
+
633
+ if wedding_end_str:
634
+ wedding_end = datetime.fromisoformat(wedding_end_str).date()
635
+ else:
636
+ wedding_end = date.today()
637
+ except:
638
+ wedding_start = date.today()
639
+ wedding_end = date.today()
640
+
641
+ wedding_start_date = st.date_input("Wedding Start Date", value=wedding_start)
642
+ wedding_end_date = st.date_input("Wedding End Date", value=wedding_end)
643
+
644
+ if wedding_end_date < wedding_start_date:
645
+ st.error("End date must be after start date")
646
+ wedding_end_date = wedding_start_date
647
+
648
+ st.markdown("##### Custom Tags")
649
+ current_tags = st.session_state.edit_config.get('custom_settings', {}).get('custom_tags', [])
650
+ custom_tags_text = '\n'.join(current_tags) if current_tags else ''
651
+ custom_tags = st.text_area("Custom Tags (one per line)", value=custom_tags_text, placeholder="e.g.,\nUrgent\nHigh Priority\nDeposit Required")
652
+
653
+ st.markdown("##### Task Assignees")
654
+ current_assignees = st.session_state.edit_config.get('custom_settings', {}).get('task_assignees', [])
655
+ task_assignees_text = '\n'.join(current_assignees) if current_assignees else ''
656
+ task_assignees = st.text_area("Task Assignees (one per line)", value=task_assignees_text, placeholder="e.g.,\nMom\nDad\nWedding Planner\nBest Friend\nCoordinator")
657
+
658
+ submitted = st.form_submit_button("Save Changes", type="primary")
659
+
660
+ if submitted:
661
+ # Update the configuration
662
+ updated_config = st.session_state.edit_config.copy()
663
+ updated_config['wedding_info'] = {
664
+ 'partner1_name': partner1_name,
665
+ 'partner2_name': partner2_name,
666
+ 'venue_city': venue_city,
667
+ 'wedding_start_date': wedding_start_date.isoformat(),
668
+ 'wedding_end_date': wedding_end_date.isoformat()
669
+ }
670
+
671
+ # Parse custom tags and task assignees
672
+ custom_tags_list = [tag.strip() for tag in custom_tags.split('\n') if tag.strip()]
673
+ task_assignees_list = [assignee.strip() for assignee in task_assignees.split('\n') if assignee.strip()]
674
+ updated_config['custom_settings'] = {
675
+ 'custom_tags': custom_tags_list,
676
+ 'task_assignees': task_assignees_list
677
+ }
678
+
679
+ # Save the updated configuration
680
+ st.session_state.config_manager.save_config(updated_config)
681
+ st.success("Configuration updated successfully!")
682
+ st.rerun()
683
+
684
+ with tab2:
685
+ show_event_management_section(config)
686
+
687
+ with tab3:
688
+ show_google_drive_section()
689
+
690
+ with tab4:
691
+ st.markdown("#### Current Configuration")
692
+ st.json(config)
693
+
694
+ with tab5:
695
+ st.markdown("#### Reset Configuration")
696
+ st.warning("⚠️ This will permanently delete all your wedding configuration data. This action cannot be undone.")
697
+
698
+ if st.button("Reset Configuration", type="secondary"):
699
+ if st.session_state.config_manager.reset_config():
700
+ st.success("Configuration reset! Please refresh the page.")
701
+ st.rerun()
702
+
703
+ def show_google_drive_section():
704
+ st.markdown("#### Google Drive Integration")
705
+
706
+ config_manager = st.session_state.config_manager
707
+ drive_status = config_manager.get_google_drive_status()
708
+
709
+ # Status display
710
+ if drive_status['enabled']:
711
+ if drive_status['status'] == 'Online':
712
+ st.success(f"✅ {drive_status['message']}")
713
+ elif drive_status['status'] == 'Offline':
714
+ st.warning(f"⚠️ {drive_status['message']}")
715
+ else:
716
+ st.error(f"❌ {drive_status['message']}")
717
+ else:
718
+ st.info(f"ℹ️ {drive_status['message']}")
719
+
720
+ # Show files if available
721
+ if drive_status['enabled'] and 'files' in drive_status:
722
+ st.markdown("**Files in Google Drive:**")
723
+ for file_name in drive_status['files']:
724
+ st.markdown(f"• {file_name}")
725
+
726
+ # Manual sync buttons
727
+ if drive_status['enabled']:
728
+ st.markdown("#### Manual Sync")
729
+ col1, col2 = st.columns(2)
730
+
731
+ with col1:
732
+ if st.button("📥 Sync from Google Drive", help="Download latest data from Google Drive"):
733
+ with st.spinner("Syncing from Google Drive..."):
734
+ if config_manager.manual_sync_from_drive():
735
+ st.success("Successfully synced from Google Drive!")
736
+ st.rerun()
737
+ else:
738
+ st.error("Failed to sync from Google Drive")
739
+
740
+ with col2:
741
+ if st.button("📤 Sync to Google Drive", help="Upload current data to Google Drive"):
742
+ with st.spinner("Syncing to Google Drive..."):
743
+ if config_manager.manual_sync_to_drive():
744
+ st.success("Successfully synced to Google Drive!")
745
+ else:
746
+ st.error("Failed to sync to Google Drive")
747
+
748
+ # Configuration info
749
+ st.markdown("#### Configuration")
750
+ st.markdown("To enable Google Drive integration, set the following environment variables:")
751
+ st.code("""
752
+ GOOGLE_DRIVE_FOLDER_ID=your_folder_id
753
+ GOOGLE_PROJECT_ID=your_project_id
754
+ GOOGLE_PRIVATE_KEY_ID=your_private_key_id
755
+ GOOGLE_PRIVATE_KEY=your_private_key
756
+ GOOGLE_CLIENT_EMAIL=your_client_email
757
+ GOOGLE_CLIENT_ID=your_client_id
758
+ """)
759
+
760
+ st.markdown("**Note:** Google Drive integration is automatically enabled when running on Hugging Face Spaces with proper credentials configured.")
761
+
762
+ def show_event_management_section(config):
763
+ st.markdown("#### Manage Wedding Events")
764
+
765
+ # Initialize session state for event editing if not exists
766
+ if 'edit_events' not in st.session_state:
767
+ st.session_state.edit_events = config.get('wedding_events', []).copy()
768
+
769
+ wedding_events = st.session_state.edit_events
770
+ wedding_info = config.get('wedding_info', {})
771
+
772
+ # Get wedding dates for date calculations
773
+ wedding_start_str = wedding_info.get('wedding_start_date', '')
774
+ wedding_end_str = wedding_info.get('wedding_end_date', '')
775
+
776
+ if not wedding_start_str or not wedding_end_str:
777
+ st.warning("Please set your wedding date range in the 'Edit Configuration' tab first.")
778
+ return
779
+
780
+ try:
781
+ wedding_start = datetime.fromisoformat(wedding_start_str).date()
782
+ wedding_end = datetime.fromisoformat(wedding_end_str).date()
783
+ except:
784
+ st.error("Invalid wedding date format. Please check your configuration.")
785
+ return
786
+
787
+ # Add event button
788
+ if st.button("➕ Add New Event"):
789
+ st.session_state.edit_events.append({
790
+ "name": "New Event",
791
+ "description": "",
792
+ "date_offset": 0,
793
+ "requires_meal_choice": False,
794
+ "meal_options": [],
795
+ "location": "",
796
+ "address": ""
797
+ })
798
+ st.rerun()
799
+
800
+ # Display events for editing
801
+ if st.session_state.edit_events:
802
+ st.markdown("##### Edit Events")
803
+
804
+ for i, event in enumerate(st.session_state.edit_events):
805
+ with st.expander(f"Event {i+1}: {event['name']}", expanded=True):
806
+ # Add delete button at the top right of each event
807
+ col_header1, col_header2 = st.columns([4, 1])
808
+ with col_header2:
809
+ if st.button("🗑️ Delete", key=f"delete_event_{i}", help="Delete this event"):
810
+ st.session_state.edit_events.pop(i)
811
+ st.rerun()
812
+
813
+ col1, col2 = st.columns(2)
814
+
815
+ with col1:
816
+ event_name = st.text_input("Event Name", value=event['name'], key=f"settings_event_name_{i}")
817
+ event_description = st.text_input("Time", value=event['description'], placeholder="e.g., 2:00 PM, 6:30 PM", key=f"settings_event_desc_{i}")
818
+ event_location = st.text_input("Location Name", value=event.get('location', ''), placeholder="e.g., Central Park, Grand Ballroom", key=f"settings_event_location_{i}")
819
+
820
+ with col2:
821
+ # Calculate current event date from date_offset
822
+ current_event_date = wedding_start + timedelta(days=event['date_offset'])
823
+
824
+ # Use date input without constraints - allow any date
825
+ event_date = st.date_input(
826
+ "Event Date",
827
+ value=current_event_date,
828
+ key=f"settings_event_date_{i}",
829
+ help="Select any date for this event"
830
+ )
831
+
832
+ # Show warning if date is outside wedding range
833
+ if event_date < wedding_start or event_date > wedding_end:
834
+ st.warning(f"⚠️ Selected date is outside your wedding date range ({wedding_start.strftime('%B %d, %Y')} - {wedding_end.strftime('%B %d, %Y')})")
835
+
836
+ requires_meal_choice = st.checkbox("Requires Meal Choice", value=event['requires_meal_choice'], key=f"settings_event_meal_{i}")
837
+
838
+ # Meal options section (only show if meal choice is required)
839
+ if requires_meal_choice:
840
+ st.markdown("**Meal Options**")
841
+ st.markdown("Enter meal options (one per line):")
842
+ current_meal_options = event.get('meal_options', [])
843
+ meal_options_text = '\n'.join(current_meal_options) if current_meal_options else ''
844
+ meal_options = st.text_area("Meal Options", value=meal_options_text, placeholder="e.g.,\nDuck\nSurf & Turf\nRisotto (vegetarian)\nStuffed Squash (vegetarian)", key=f"settings_event_meal_options_{i}", height=100)
845
+ else:
846
+ meal_options = ""
847
+
848
+ event_address = st.text_area("Address", value=event.get('address', ''), placeholder="Enter full address (street, city, state, zip code)", key=f"settings_event_address_{i}", height=80)
849
+
850
+ # Calculate date_offset from the selected date
851
+ date_offset = (event_date - wedding_start).days
852
+
853
+ # Parse meal options
854
+ meal_options_list = []
855
+ if requires_meal_choice and meal_options:
856
+ meal_options_list = [option.strip() for option in meal_options.split('\n') if option.strip()]
857
+
858
+ # Update session state
859
+ st.session_state.edit_events[i] = {
860
+ "name": event_name,
861
+ "description": event_description,
862
+ "date_offset": date_offset,
863
+ "requires_meal_choice": requires_meal_choice,
864
+ "meal_options": meal_options_list,
865
+ "location": event_location,
866
+ "address": event_address
867
+ }
868
+
869
+ # Save events button
870
+ st.markdown("---")
871
+ col1, col2, col3 = st.columns([1, 1, 1])
872
+ with col2:
873
+ if st.button("💾 Save Event Changes", type="primary"):
874
+ # Update the configuration with edited events
875
+ updated_config = config.copy()
876
+ updated_config['wedding_events'] = st.session_state.edit_events
877
+
878
+ # Save the updated configuration
879
+ st.session_state.config_manager.save_config(updated_config)
880
+ st.success("Event changes saved successfully!")
881
+ st.rerun()
882
+ else:
883
+ st.info("No events added yet. Click 'Add New Event' to get started!")
884
+
885
+ # Event summary
886
+ if st.session_state.edit_events:
887
+ st.markdown("##### Event Summary")
888
+
889
+ col1, col2, col3 = st.columns(3)
890
+
891
+ with col1:
892
+ total_events = len(st.session_state.edit_events)
893
+ st.metric("Total Events", total_events)
894
+
895
+ with col2:
896
+ meal_events = len([e for e in st.session_state.edit_events if e.get('requires_meal_choice', False)])
897
+ st.metric("Events with Meals", meal_events)
898
+
899
+ with col3:
900
+ days_span = (wedding_end - wedding_start).days + 1
901
+ st.metric("Celebration Days", days_span)
902
+
903
+ if __name__ == "__main__":
904
+ main()
app_config.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "demo_mode": false,
3
+ "demo_data_path": "demo_data",
4
+ "app_settings": {
5
+ "theme": "green_adirondack",
6
+ "auto_save": true,
7
+ "backup_enabled": true
8
+ }
9
+ }
config_manager.py ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from datetime import datetime
4
+ from google_drive_manager import GoogleDriveManager
5
+
6
+ class ConfigManager:
7
+ def __init__(self):
8
+ self.config_file = "wedding_config.json"
9
+ self.data_dir = "data"
10
+ self.app_config_file = "app_config.json"
11
+ self.demo_data_dir = "demo_data"
12
+
13
+ # Check if running on Hugging Face Spaces
14
+ self.is_huggingface = os.getenv('SPACE_ID') is not None
15
+
16
+ # Initialize Google Drive manager
17
+ self.drive_manager = GoogleDriveManager()
18
+ self.google_drive_enabled = False
19
+
20
+ # Set up data directory
21
+ if self.is_huggingface:
22
+ self.data_dir = "/tmp/wedding_data"
23
+ self.demo_data_dir = "/tmp/demo_data"
24
+
25
+ # Create data directory if it doesn't exist
26
+ if not os.path.exists(self.data_dir):
27
+ os.makedirs(self.data_dir)
28
+ if not os.path.exists(self.demo_data_dir):
29
+ os.makedirs(self.demo_data_dir)
30
+
31
+ # Load app configuration
32
+ self.app_config = self.load_app_config()
33
+
34
+ # Initialize Google Drive if enabled
35
+ self._initialize_google_drive()
36
+
37
+ def _initialize_google_drive(self):
38
+ """Initialize Google Drive connection"""
39
+ try:
40
+ folder_id = os.getenv('GOOGLE_DRIVE_FOLDER_ID')
41
+ if folder_id:
42
+ if self.drive_manager.initialize(folder_id):
43
+ self.google_drive_enabled = True
44
+ # Sync data from Google Drive on startup
45
+ self._sync_from_google_drive()
46
+ except Exception as e:
47
+ print(f"Google Drive initialization failed: {e}")
48
+ self.google_drive_enabled = False
49
+
50
+ def _sync_from_google_drive(self):
51
+ """Sync data files from Google Drive to local storage"""
52
+ if not self.google_drive_enabled:
53
+ return
54
+
55
+ # List of data files to sync
56
+ data_files = [
57
+ 'guest_list_data.json',
58
+ 'rsvp_data.json',
59
+ 'tasks.json',
60
+ 'vendors.json',
61
+ 'wedding_party.json'
62
+ ]
63
+
64
+ # Sync config file
65
+ config_content = self.drive_manager.download_file('wedding_config.json')
66
+ if config_content:
67
+ config_path = self.get_config_file_path()
68
+ with open(config_path, 'w') as f:
69
+ json.dump(config_content, f, indent=2)
70
+
71
+ # Sync data files
72
+ for file_name in data_files:
73
+ content = self.drive_manager.download_file(file_name)
74
+ if content:
75
+ file_path = self.get_data_file_path(file_name)
76
+ with open(file_path, 'w') as f:
77
+ json.dump(content, f, indent=2)
78
+
79
+ def _sync_to_google_drive(self):
80
+ """Sync local data files to Google Drive"""
81
+ if not self.google_drive_enabled:
82
+ return
83
+
84
+ # Sync config file
85
+ config_path = self.get_config_file_path()
86
+ if os.path.exists(config_path):
87
+ with open(config_path, 'r') as f:
88
+ config_content = json.load(f)
89
+ self.drive_manager.upload_file('wedding_config.json', config_content)
90
+
91
+ # Sync data files
92
+ data_files = [
93
+ 'guest_list_data.json',
94
+ 'rsvp_data.json',
95
+ 'tasks.json',
96
+ 'vendors.json',
97
+ 'wedding_party.json'
98
+ ]
99
+
100
+ for file_name in data_files:
101
+ file_path = self.get_data_file_path(file_name)
102
+ if os.path.exists(file_path):
103
+ with open(file_path, 'r') as f:
104
+ content = json.load(f)
105
+ self.drive_manager.upload_file(file_name, content)
106
+
107
+ def load_app_config(self):
108
+ """Load app configuration from file"""
109
+ if os.path.exists(self.app_config_file):
110
+ try:
111
+ with open(self.app_config_file, 'r') as f:
112
+ return json.load(f)
113
+ except (json.JSONDecodeError, FileNotFoundError):
114
+ return self.get_default_app_config()
115
+ return self.get_default_app_config()
116
+
117
+ def get_default_app_config(self):
118
+ """Get default app configuration"""
119
+ return {
120
+ "demo_mode": False,
121
+ "demo_data_path": "demo_data",
122
+ "app_settings": {
123
+ "theme": "green_adirondack",
124
+ "auto_save": True,
125
+ "backup_enabled": True
126
+ }
127
+ }
128
+
129
+ def is_demo_mode(self):
130
+ """Check if demo mode is enabled"""
131
+ return self.app_config.get("demo_mode", False)
132
+
133
+ def get_data_directory(self):
134
+ """Get the appropriate data directory based on demo mode"""
135
+ if self.is_demo_mode():
136
+ return self.demo_data_dir
137
+ return self.data_dir
138
+
139
+ def get_config_file_path(self):
140
+ """Get the appropriate config file path based on demo mode"""
141
+ if self.is_demo_mode():
142
+ return os.path.join(self.demo_data_dir, "wedding_config.json")
143
+ return self.config_file
144
+
145
+ def config_exists(self):
146
+ """Check if configuration file exists"""
147
+ config_path = self.get_config_file_path()
148
+ return os.path.exists(config_path)
149
+
150
+ def load_config(self):
151
+ """Load configuration from file"""
152
+ if self.config_exists():
153
+ try:
154
+ config_path = self.get_config_file_path()
155
+ with open(config_path, 'r') as f:
156
+ return json.load(f)
157
+ except (json.JSONDecodeError, FileNotFoundError):
158
+ return self.get_default_config()
159
+ return self.get_default_config()
160
+
161
+ def save_config(self, config):
162
+ """Save configuration to file"""
163
+ try:
164
+ config_path = self.get_config_file_path()
165
+ with open(config_path, 'w') as f:
166
+ json.dump(config, f, indent=2)
167
+
168
+ # Sync to Google Drive if enabled
169
+ if self.google_drive_enabled:
170
+ self.drive_manager.upload_file('wedding_config.json', config)
171
+
172
+ return True
173
+ except Exception as e:
174
+ print(f"Error saving config: {e}")
175
+ return False
176
+
177
+ def reset_config(self):
178
+ """Reset configuration by deleting the config file"""
179
+ try:
180
+ config_path = self.get_config_file_path()
181
+ if os.path.exists(config_path):
182
+ os.remove(config_path)
183
+ return True
184
+ except Exception as e:
185
+ print(f"Error resetting config: {e}")
186
+ return False
187
+
188
+ def get_default_config(self):
189
+ """Get default configuration"""
190
+ return {
191
+ 'wedding_info': {
192
+ 'partner1_name': '',
193
+ 'partner2_name': '',
194
+ 'wedding_start_date': '',
195
+ 'wedding_end_date': '',
196
+ 'venue_city': ''
197
+ },
198
+ 'custom_settings': {
199
+ 'custom_tags': [
200
+ "Wedding Party", "Urgent", "Rehearsal", "Timeline",
201
+ "Maid of Honor", "Best Man", "Bridesmaid", "Groomsman",
202
+ "Flower Girl", "Ring Bearer", "Usher", "Reader",
203
+ "Venue", "Catering", "Photography", "Videography", "Music/DJ",
204
+ "Flowers", "Decor", "Attire", "Hair & Makeup", "Transportation",
205
+ "Invitations", "Cake", "Officiant", "Other",
206
+ "Decorations", "Centerpieces", "Favors", "Signage", "Linens",
207
+ "Tableware", "Lighting", "Accessories", "Stationery", "Gifts",
208
+ "Vendor", "Item", "Deposit Required", "Final Payment", "Installment",
209
+ "Food & Beverage", "Music", "Entertainment", "Lodging"
210
+ ],
211
+ 'task_assignees': []
212
+ },
213
+ 'wedding_events': [
214
+ {"name": "Welcome Dinner", "date_offset": -1, "description": "Welcome dinner for out-of-town guests", "requires_meal_choice": True, "meal_options": ["Duck", "Surf & Turf", "Risotto (vegetarian)", "Stuffed Squash (vegetarian)"], "location": ""},
215
+ {"name": "Church Ceremony", "date_offset": 0, "description": "Main wedding ceremony", "requires_meal_choice": False, "meal_options": [], "location": ""},
216
+ {"name": "Reception", "date_offset": 0, "description": "Wedding reception with dinner", "requires_meal_choice": True, "meal_options": ["Duck", "Surf & Turf", "Risotto (vegetarian)", "Stuffed Squash (vegetarian)"], "location": ""},
217
+ {"name": "Mehndi Afterparty", "date_offset": 1, "description": "Mehndi celebration and afterparty", "requires_meal_choice": False, "meal_options": [], "location": ""},
218
+ {"name": "Indian Ceremony", "date_offset": 1, "description": "Traditional Indian wedding ceremony", "requires_meal_choice": False, "meal_options": [], "location": ""},
219
+ {"name": "Indian Reception", "date_offset": 1, "description": "Indian reception celebration", "requires_meal_choice": True, "meal_options": ["Duck", "Surf & Turf", "Risotto (vegetarian)", "Stuffed Squash (vegetarian)"], "location": ""}
220
+ ]
221
+ }
222
+
223
+ def get_data_file_path(self, filename):
224
+ """Get full path for data files"""
225
+ data_dir = self.get_data_directory()
226
+ return os.path.join(data_dir, filename)
227
+
228
+ def load_json_data(self, filename):
229
+ """Load JSON data from data directory"""
230
+ filepath = self.get_data_file_path(filename)
231
+ if os.path.exists(filepath):
232
+ try:
233
+ with open(filepath, 'r') as f:
234
+ return json.load(f)
235
+ except (json.JSONDecodeError, FileNotFoundError):
236
+ return []
237
+ return []
238
+
239
+ def save_json_data(self, filename, data):
240
+ """Save JSON data to data directory"""
241
+ filepath = self.get_data_file_path(filename)
242
+ try:
243
+ with open(filepath, 'w') as f:
244
+ json.dump(data, f, indent=2)
245
+
246
+ # Sync to Google Drive if enabled
247
+ if self.google_drive_enabled:
248
+ self.drive_manager.upload_file(filename, data)
249
+
250
+ return True
251
+ except Exception as e:
252
+ print(f"Error saving data to {filename}: {e}")
253
+ return False
254
+
255
+ def toggle_demo_mode(self):
256
+ """Toggle demo mode on/off"""
257
+ self.app_config["demo_mode"] = not self.app_config.get("demo_mode", False)
258
+ try:
259
+ with open(self.app_config_file, 'w') as f:
260
+ json.dump(self.app_config, f, indent=2)
261
+ return True
262
+ except Exception as e:
263
+ print(f"Error saving app config: {e}")
264
+ return False
265
+
266
+ def set_demo_mode(self, enabled):
267
+ """Set demo mode to specific value"""
268
+ self.app_config["demo_mode"] = enabled
269
+ try:
270
+ with open(self.app_config_file, 'w') as f:
271
+ json.dump(self.app_config, f, indent=2)
272
+ return True
273
+ except Exception as e:
274
+ print(f"Error saving app config: {e}")
275
+ return False
276
+
277
+ def is_google_drive_enabled(self):
278
+ """Check if Google Drive integration is enabled and working"""
279
+ return self.google_drive_enabled and self.drive_manager.is_online()
280
+
281
+ def manual_sync_to_drive(self):
282
+ """Manually sync all data to Google Drive"""
283
+ if not self.google_drive_enabled:
284
+ return False
285
+
286
+ try:
287
+ self._sync_to_google_drive()
288
+ return True
289
+ except Exception as e:
290
+ print(f"Error syncing to Google Drive: {e}")
291
+ return False
292
+
293
+ def manual_sync_from_drive(self):
294
+ """Manually sync all data from Google Drive"""
295
+ if not self.google_drive_enabled:
296
+ return False
297
+
298
+ try:
299
+ self._sync_from_google_drive()
300
+ return True
301
+ except Exception as e:
302
+ print(f"Error syncing from Google Drive: {e}")
303
+ return False
304
+
305
+ def get_google_drive_status(self):
306
+ """Get status information about Google Drive integration"""
307
+ if not self.google_drive_enabled:
308
+ return {
309
+ 'enabled': False,
310
+ 'status': 'Disabled',
311
+ 'message': 'Google Drive integration not configured'
312
+ }
313
+
314
+ if not self.drive_manager.is_online():
315
+ return {
316
+ 'enabled': True,
317
+ 'status': 'Offline',
318
+ 'message': 'Google Drive service unavailable'
319
+ }
320
+
321
+ try:
322
+ files = self.drive_manager.list_files()
323
+ return {
324
+ 'enabled': True,
325
+ 'status': 'Online',
326
+ 'message': f'Connected to Google Drive ({len(files)} files found)',
327
+ 'files': [f['name'] for f in files]
328
+ }
329
+ except Exception as e:
330
+ return {
331
+ 'enabled': True,
332
+ 'status': 'Error',
333
+ 'message': f'Error connecting to Google Drive: {str(e)}'
334
+ }
dashboard.py ADDED
@@ -0,0 +1,780 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import pandas as pd
3
+ from datetime import datetime, date
4
+ import plotly.express as px
5
+ import plotly.graph_objects as go
6
+ from config_manager import ConfigManager
7
+ from vendors import VendorManager
8
+
9
+ class Dashboard:
10
+ def __init__(self):
11
+ self.config_manager = ConfigManager()
12
+
13
+ def calculate_actualized_cost(self, vendors):
14
+ """Calculate total cost for only booked/ordered/delivered items"""
15
+ total_cost = 0
16
+
17
+ for vendor in vendors:
18
+ vendor_type = vendor.get('type', 'Vendor/Service')
19
+ vendor_total_cost = vendor.get('total_cost', vendor.get('cost', 0))
20
+
21
+ # Only include cost if vendor is booked or item is ordered/delivered
22
+ should_include = False
23
+ if vendor_type == 'Vendor/Service':
24
+ if vendor.get('status') == 'Booked':
25
+ should_include = True
26
+ elif vendor_type == 'Item/Purchase':
27
+ status = vendor.get('status', 'Researching')
28
+ if status in ['Ordered', 'Shipped', 'Delivered']:
29
+ should_include = True
30
+
31
+ if should_include:
32
+ total_cost += vendor_total_cost
33
+
34
+ return total_cost
35
+
36
+ def render(self, config):
37
+ wedding_info = config.get('wedding_info', {})
38
+ venue_city = wedding_info.get('venue_city', '')
39
+
40
+ # Display header with location
41
+ if venue_city:
42
+ st.markdown(f"## 📊 Wedding Dashboard - {venue_city}")
43
+ else:
44
+ st.markdown("## 📊 Wedding Dashboard")
45
+
46
+ # Main metrics row
47
+ col1, col2, col3, col4 = st.columns(4)
48
+
49
+ with col1:
50
+ self.render_countdown_card(wedding_info)
51
+
52
+ with col2:
53
+ self.render_guest_count_card()
54
+
55
+ with col3:
56
+ self.render_task_progress_card()
57
+
58
+ with col4:
59
+ self.render_budget_card()
60
+
61
+ # Add prominent cost breakdown section
62
+ st.markdown("---")
63
+
64
+ # Calculate costs for the breakdown section
65
+ vendors = self.config_manager.load_json_data('vendors.json')
66
+ total_estimated_cost = sum([v.get('total_cost', v.get('cost', 0)) for v in vendors])
67
+ total_actualized_cost = self.calculate_actualized_cost(vendors)
68
+ pending_cost = total_estimated_cost - total_actualized_cost
69
+
70
+ col1, col2, col3 = st.columns(3)
71
+
72
+ with col1:
73
+ st.markdown("### 💰 Total Estimated Cost")
74
+ st.markdown(f"**${total_estimated_cost:,.0f}**")
75
+ st.caption("All vendors & items regardless of status")
76
+
77
+ with col2:
78
+ st.markdown("### ✅ Actualized Cost")
79
+ st.markdown(f"**${total_actualized_cost:,.0f}**")
80
+ st.caption("Only booked/ordered/delivered items")
81
+
82
+ with col3:
83
+ st.markdown("### ⏳ Pending Cost")
84
+ st.markdown(f"**${pending_cost:,.0f}**")
85
+ st.caption("Estimated costs not yet confirmed")
86
+
87
+ # Charts section
88
+ col1, col2 = st.columns(2)
89
+
90
+ with col1:
91
+ self.render_task_progress_chart()
92
+
93
+ with col2:
94
+ self.render_guest_rsvp_chart()
95
+
96
+ # Upcoming payments
97
+ self.render_upcoming_payments()
98
+
99
+ # Food choices section
100
+ self.render_food_choices_by_event()
101
+
102
+ # Upcoming tasks
103
+ self.render_upcoming_tasks()
104
+
105
+ def render_countdown_card(self, wedding_info):
106
+ st.markdown("""
107
+ <div class="metric-card">
108
+ <h3>⏰ Wedding Countdown</h3>
109
+ """, unsafe_allow_html=True)
110
+
111
+ wedding_start_str = wedding_info.get('wedding_start_date', '')
112
+ wedding_end_str = wedding_info.get('wedding_end_date', '')
113
+
114
+ if wedding_start_str and wedding_end_str:
115
+ try:
116
+ wedding_start = datetime.fromisoformat(wedding_start_str).date()
117
+ wedding_end = datetime.fromisoformat(wedding_end_str).date()
118
+ today = date.today()
119
+
120
+ if today < wedding_start:
121
+ days_until = (wedding_start - today).days
122
+ st.markdown(f"<h1 style='margin: 0;'>{days_until}</h1>", unsafe_allow_html=True)
123
+ if days_until == 0:
124
+ st.markdown("<p style='margin: 0;'>Festivities begin today! 🎉</p>", unsafe_allow_html=True)
125
+ elif days_until == 1:
126
+ st.markdown("<p style='margin: 0;'>Festivities begin tomorrow! 🎊</p>", unsafe_allow_html=True)
127
+ elif days_until <= 7:
128
+ st.markdown("<p style='margin: 0;'>Festivities begin this week! ⚡</p>", unsafe_allow_html=True)
129
+ else:
130
+ st.markdown("<p style='margin: 0;'>days until festivities begin</p>", unsafe_allow_html=True)
131
+ elif wedding_start <= today <= wedding_end:
132
+ days_remaining = (wedding_end - today).days + 1
133
+ st.markdown(f"<h1 style='margin: 0;'>{days_remaining}</h1>", unsafe_allow_html=True)
134
+ st.markdown("<p style='margin: 0;'>days of festivities remaining! 🎉</p>", unsafe_allow_html=True)
135
+ else:
136
+ days_since = (today - wedding_end).days
137
+ st.markdown(f"<h1 style='margin: 0;'>{days_since}</h1>", unsafe_allow_html=True)
138
+ st.markdown("<p style='margin: 0;'>days since festivities ended</p>", unsafe_allow_html=True)
139
+ except:
140
+ st.markdown("<h1 style='margin: 0;'>-</h1>", unsafe_allow_html=True)
141
+ st.markdown("<p style='margin: 0;'>Dates not set</p>", unsafe_allow_html=True)
142
+ else:
143
+ st.markdown("<h1 style='margin: 0;'>-</h1>", unsafe_allow_html=True)
144
+ st.markdown("<p style='margin: 0;'>Dates not set</p>", unsafe_allow_html=True)
145
+
146
+ st.markdown("</div>", unsafe_allow_html=True)
147
+
148
+ def render_guest_count_card(self):
149
+ st.markdown("""
150
+ <div class="metric-card">
151
+ <h3>👥 Confirmed Guests</h3>
152
+ """, unsafe_allow_html=True)
153
+
154
+ # Load RSVP data to get confirmed guests
155
+ rsvp_data = self.config_manager.load_json_data('rsvp_data.json')
156
+ confirmed_guests = 0
157
+
158
+ # Handle case where rsvp_data is a list (empty file) instead of dict
159
+ if isinstance(rsvp_data, list):
160
+ rsvp_data = {}
161
+
162
+ for group_code, group_data in rsvp_data.items():
163
+ overall_rsvp = group_data.get('overall_rsvp', '')
164
+ if overall_rsvp == 'Yes':
165
+ # Count the number of attendees for this group
166
+ group_attendees = group_data.get('group_attendees', [])
167
+ confirmed_guests += len(group_attendees)
168
+
169
+ st.markdown(f"<h1 style='margin: 0;'>{confirmed_guests}</h1>", unsafe_allow_html=True)
170
+ st.markdown("<p style='margin: 0;'>confirmed attending</p>", unsafe_allow_html=True)
171
+
172
+ st.markdown("</div>", unsafe_allow_html=True)
173
+
174
+ def render_task_progress_card(self):
175
+ st.markdown("""
176
+ <div class="metric-card">
177
+ <h3>✅ Task Progress</h3>
178
+ """, unsafe_allow_html=True)
179
+
180
+ tasks = self.config_manager.load_json_data('tasks.json')
181
+ if tasks:
182
+ completed = len([task for task in tasks if task.get('completed', False)])
183
+ total = len(tasks)
184
+ percentage = int((completed / total) * 100) if total > 0 else 0
185
+
186
+ st.markdown(f"<h1 style='margin: 0;'>{percentage}%</h1>", unsafe_allow_html=True)
187
+ st.markdown(f"<p style='margin: 0;'>{completed}/{total} completed</p>", unsafe_allow_html=True)
188
+ else:
189
+ st.markdown("<h1 style='margin: 0;'>0%</h1>", unsafe_allow_html=True)
190
+ st.markdown("<p style='margin: 0;'>No tasks yet</p>", unsafe_allow_html=True)
191
+
192
+ st.markdown("</div>", unsafe_allow_html=True)
193
+
194
+ def render_budget_card(self):
195
+ st.markdown("""
196
+ <div class="metric-card">
197
+ <h3>💰 Wedding Budget</h3>
198
+ """, unsafe_allow_html=True)
199
+
200
+ # Calculate both actualized and estimated costs from vendor data
201
+ vendors = self.config_manager.load_json_data('vendors.json')
202
+ total_estimated_cost = 0
203
+ total_actualized_cost = self.calculate_actualized_cost(vendors)
204
+ total_paid = 0
205
+
206
+ for vendor in vendors:
207
+ # Use total_cost field (newer format) or cost field (legacy format)
208
+ cost = vendor.get('total_cost', vendor.get('cost', 0))
209
+
210
+ # Calculate total paid from payment history instead of just deposit_paid
211
+ payment_history = vendor.get('payment_history', [])
212
+ total_paid_from_history = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') != 'credit')
213
+ total_credits_from_history = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') == 'credit')
214
+ net_paid = total_paid_from_history - total_credits_from_history
215
+
216
+ total_estimated_cost += cost
217
+ total_paid += net_paid
218
+
219
+ pending_cost = total_estimated_cost - total_actualized_cost
220
+ actualized_remaining = total_actualized_cost - total_paid
221
+ estimated_remaining = total_estimated_cost - total_paid
222
+
223
+ st.markdown(f"<h1 style='margin: 0;'>${total_actualized_cost:,.0f}</h1>", unsafe_allow_html=True)
224
+ st.markdown(f"<p style='margin: 0; color: #666;'>${total_estimated_cost:,.0f} estimated total</p>", unsafe_allow_html=True)
225
+ st.markdown(f"<p style='margin: 0;'>${total_paid:,.0f} paid</p>", unsafe_allow_html=True)
226
+ st.markdown(f"<p style='margin: 0; color: #d32f2f;'>${actualized_remaining:,.0f} confirmed remaining</p>", unsafe_allow_html=True)
227
+
228
+ st.markdown("</div>", unsafe_allow_html=True)
229
+
230
+
231
+ def render_task_progress_chart(self):
232
+ st.markdown("### Tasks by Event/Category")
233
+
234
+ tasks = self.config_manager.load_json_data('tasks.json')
235
+ if tasks:
236
+ # Group tasks by category
237
+ categories = {}
238
+ for task in tasks:
239
+ category = task.get('group', 'Uncategorized')
240
+ if category not in categories:
241
+ categories[category] = {'completed': 0, 'not_completed': 0}
242
+ if task.get('completed', False):
243
+ categories[category]['completed'] += 1
244
+ else:
245
+ categories[category]['not_completed'] += 1
246
+
247
+ # Create data for stacked bar chart
248
+ category_names = list(categories.keys())
249
+ completed_counts = [categories[cat]['completed'] for cat in category_names]
250
+ not_completed_counts = [categories[cat]['not_completed'] for cat in category_names]
251
+
252
+ if category_names:
253
+ fig = go.Figure(data=[
254
+ go.Bar(name='Completed', x=category_names, y=completed_counts, marker_color='#4a7c59'),
255
+ go.Bar(name='Not Completed', x=category_names, y=not_completed_counts, marker_color='#d32f2f')
256
+ ])
257
+
258
+ fig.update_layout(
259
+ title="Number of Tasks by Event/Category",
260
+ barmode='stack',
261
+ xaxis_tickangle=-45,
262
+ height=400,
263
+ yaxis=dict(tickmode='linear', dtick=1) # Show whole numbers only
264
+ )
265
+ st.plotly_chart(fig, use_container_width=True)
266
+ else:
267
+ st.info("No tasks to display")
268
+ else:
269
+ st.info("No tasks created yet")
270
+
271
+ def _create_comprehensive_guest_list(self):
272
+ """Create a comprehensive list of all guests with RSVP data - same logic as guest management"""
273
+ all_guests = []
274
+
275
+ # Load guest list and RSVP data
276
+ guest_list_data = self.config_manager.load_json_data('guest_list_data.json')
277
+ rsvp_data = self.config_manager.load_json_data('rsvp_data.json')
278
+
279
+ # Handle case where rsvp_data is a list (empty file) instead of dict
280
+ if isinstance(rsvp_data, list):
281
+ rsvp_data = {}
282
+
283
+ if not guest_list_data:
284
+ return all_guests
285
+
286
+ for group_name, group_data in guest_list_data.items():
287
+ # Get RSVP data for this group
288
+ group_rsvp_data = rsvp_data.get(group_name, {}) if rsvp_data else {}
289
+
290
+ # Add named guests
291
+ for named_guest in group_data['named_guests']:
292
+ guest = {
293
+ 'display_name': named_guest['full_name'],
294
+ 'first_name': named_guest['first_name'],
295
+ 'last_name': named_guest['last_name'],
296
+ 'group_name': group_name,
297
+ 'party': group_data['party'],
298
+ 'address': group_data['address'],
299
+ 'type': 'Named Guest',
300
+ 'phone': group_rsvp_data.get('phone_number', ''),
301
+ 'rsvp_by_event': {},
302
+ 'meal_selections': {}
303
+ }
304
+
305
+ # Apply RSVP data
306
+ self._apply_rsvp_to_guest(guest, group_rsvp_data)
307
+ all_guests.append(guest)
308
+
309
+ # Add plus one spots
310
+ plus_one_spots = group_data['plus_one_spots']
311
+ if plus_one_spots > 0:
312
+ # Get plus one names from RSVP data
313
+ group_attendees = group_rsvp_data.get('group_attendees', [])
314
+ named_guest_names = [g['full_name'] for g in group_data['named_guests']]
315
+
316
+ # Find plus one names (attendees not in named guests)
317
+ plus_one_names = [name for name in group_attendees if name not in named_guest_names]
318
+
319
+ # Create plus one entries
320
+ for i in range(plus_one_spots):
321
+ if i < len(plus_one_names):
322
+ # Named plus one
323
+ plus_one_name = plus_one_names[i]
324
+ guest = {
325
+ 'display_name': plus_one_name,
326
+ 'first_name': plus_one_name.split()[0] if plus_one_name.split() else plus_one_name,
327
+ 'last_name': ' '.join(plus_one_name.split()[1:]) if len(plus_one_name.split()) > 1 else '',
328
+ 'group_name': group_name,
329
+ 'party': group_data['party'],
330
+ 'address': group_data['address'],
331
+ 'type': 'Plus One (Named)',
332
+ 'phone': '',
333
+ 'rsvp_by_event': {},
334
+ 'meal_selections': {}
335
+ }
336
+ else:
337
+ # Unnamed plus one
338
+ guest = {
339
+ 'display_name': f'Unnamed Plus One {i+1}',
340
+ 'first_name': '',
341
+ 'last_name': '',
342
+ 'group_name': group_name,
343
+ 'party': group_data['party'],
344
+ 'address': group_data['address'],
345
+ 'type': 'Plus One (Unnamed)',
346
+ 'phone': '',
347
+ 'rsvp_by_event': {},
348
+ 'meal_selections': {}
349
+ }
350
+
351
+ # Apply RSVP data
352
+ self._apply_rsvp_to_guest(guest, group_rsvp_data)
353
+ all_guests.append(guest)
354
+
355
+ return all_guests
356
+
357
+ def _apply_rsvp_to_guest(self, guest, rsvp_data):
358
+ """Apply RSVP data to a guest - same logic as guest management"""
359
+ config = self.config_manager.load_config()
360
+ wedding_events = config.get('wedding_events', [])
361
+
362
+ # Initialize all events with "Pending" status
363
+ for event in wedding_events:
364
+ event_name = event.get('name', '')
365
+ if event_name:
366
+ guest['rsvp_by_event'][event_name] = 'Pending'
367
+
368
+ if not rsvp_data:
369
+ return
370
+
371
+ event_responses = rsvp_data.get('event_responses', {})
372
+ group_attendees = rsvp_data.get('group_attendees', [])
373
+ dietary_restrictions = rsvp_data.get('dietary_restrictions', {})
374
+
375
+ guest_name = guest['display_name']
376
+
377
+ # Apply dietary restrictions
378
+ if guest_name in dietary_restrictions:
379
+ guest['allergies'] = dietary_restrictions[guest_name]
380
+
381
+ # Apply event responses
382
+ for event in wedding_events:
383
+ event_name = event.get('name', '')
384
+ if event_name in event_responses:
385
+ event_data = event_responses[event_name]
386
+ attendees = event_data.get('attendees', [])
387
+ party_rsvp = event_data.get('rsvp', 'Pending')
388
+
389
+ # Determine individual RSVP status
390
+ if guest_name in attendees:
391
+ guest['rsvp_by_event'][event_name] = party_rsvp
392
+
393
+ # Apply meal choice if attending and event requires meal choice
394
+ if party_rsvp == 'Yes' and event.get('requires_meal_choice', False):
395
+ meal_choice = event_data.get('meal_choice', {})
396
+ if guest_name in meal_choice:
397
+ guest['meal_selections'][event_name] = meal_choice[guest_name]
398
+ else:
399
+ guest['rsvp_by_event'][event_name] = 'No'
400
+
401
+ def render_guest_rsvp_chart(self):
402
+ st.markdown("### Attendance by Event")
403
+
404
+ # Get comprehensive guest list using the same logic as guest management
405
+ all_guests = self._create_comprehensive_guest_list()
406
+
407
+ if all_guests:
408
+ # Get event names from wedding config
409
+ config = self.config_manager.load_config()
410
+ events = [event['name'] for event in config.get('wedding_events', [])]
411
+
412
+ # Count attendance for each event
413
+ event_attendance = {}
414
+ for event in events:
415
+ event_attendance[event] = {'Yes': 0, 'No': 0, 'Pending': 0}
416
+
417
+ # Count attendance for each guest
418
+ for guest in all_guests:
419
+ rsvp_by_event = guest.get('rsvp_by_event', {})
420
+ for event_name in event_attendance.keys():
421
+ rsvp_status = rsvp_by_event.get(event_name, 'Pending')
422
+ if rsvp_status in event_attendance[event_name]:
423
+ event_attendance[event_name][rsvp_status] += 1
424
+ else:
425
+ event_attendance[event_name]['Pending'] += 1
426
+
427
+ # Create bar chart showing attendance by event
428
+ event_names = list(event_attendance.keys())
429
+ yes_counts = [event_attendance[event]['Yes'] for event in event_names]
430
+ no_counts = [event_attendance[event]['No'] for event in event_names]
431
+ pending_counts = [event_attendance[event]['Pending'] for event in event_names]
432
+
433
+ fig = go.Figure(data=[
434
+ go.Bar(name='Yes', x=event_names, y=yes_counts, marker_color='#4a7c59'),
435
+ go.Bar(name='No', x=event_names, y=no_counts, marker_color='#d32f2f'),
436
+ go.Bar(name='Pending', x=event_names, y=pending_counts, marker_color='#ffa726')
437
+ ])
438
+
439
+ # Calculate the maximum value across all events for better y-axis scaling
440
+ max_value = max(max(yes_counts), max(no_counts), max(pending_counts))
441
+ # Add some padding to the max value for better visualization
442
+ y_max = max_value + 2 if max_value > 0 else 10
443
+
444
+ # Calculate appropriate tick spacing based on the max value
445
+ if y_max <= 10:
446
+ tick_spacing = 2
447
+ elif y_max <= 20:
448
+ tick_spacing = 5
449
+ elif y_max <= 50:
450
+ tick_spacing = 10
451
+ else:
452
+ tick_spacing = 20
453
+
454
+ fig.update_layout(
455
+ title="RSVP Responses by Event",
456
+ barmode='stack',
457
+ xaxis_tickangle=-45,
458
+ height=400,
459
+ yaxis=dict(
460
+ tickmode='linear',
461
+ dtick=tick_spacing, # Show ticks at appropriate intervals
462
+ range=[0, y_max] # Set max based on actual data
463
+ )
464
+ )
465
+ st.plotly_chart(fig, use_container_width=True)
466
+ else:
467
+ st.info("No guest data available yet")
468
+
469
+ def render_food_choices_by_event(self):
470
+ st.markdown("### 🍽️ Food Choices by Event")
471
+
472
+ # Load data
473
+ rsvp_data = self.config_manager.load_json_data('rsvp_data.json')
474
+
475
+ # Handle case where rsvp_data is a list (empty file) instead of dict
476
+ if isinstance(rsvp_data, list):
477
+ rsvp_data = {}
478
+
479
+ config = self.config_manager.load_config()
480
+ events = config.get('wedding_events', [])
481
+
482
+ if not rsvp_data:
483
+ st.info("No RSVP data available yet")
484
+ return
485
+
486
+ # Filter events that require meal choices
487
+ meal_events = [event for event in events if event.get('requires_meal_choice', False)]
488
+
489
+ if not meal_events:
490
+ st.info("No events require meal choices.")
491
+ return
492
+
493
+ for event in meal_events:
494
+ event_name = event['name']
495
+ meal_options = event.get('meal_options', [])
496
+
497
+ if meal_options:
498
+ st.markdown(f"**{event_name}**")
499
+
500
+ # Count meal choices for this event
501
+ meal_counts = {option: 0 for option in meal_options}
502
+ meal_counts['Not Selected'] = 0
503
+ outdated_choices = {}
504
+
505
+ for group_code, group_data in rsvp_data.items():
506
+ # Check if this group is attending this event
507
+ event_responses = group_data.get('event_responses', {})
508
+ if event_name in event_responses:
509
+ event_rsvp = event_responses[event_name].get('rsvp', '')
510
+ if event_rsvp == 'Yes':
511
+ attendees = event_responses[event_name].get('attendees', [])
512
+
513
+ # Get meal selections for each attendee from the event response
514
+ meal_choices = event_responses[event_name].get('meal_choice', {})
515
+ for attendee in attendees:
516
+ selected_meal = meal_choices.get(attendee, 'Not Selected')
517
+ if selected_meal in meal_counts:
518
+ meal_counts[selected_meal] += 1
519
+ elif selected_meal != 'Not Selected':
520
+ # This is an outdated meal choice
521
+ if selected_meal not in outdated_choices:
522
+ outdated_choices[selected_meal] = []
523
+ outdated_choices[selected_meal].append(attendee)
524
+ else:
525
+ meal_counts['Not Selected'] += 1
526
+
527
+ # Display meal choice counts
528
+ col1, col2, col3, col4 = st.columns(4)
529
+ cols = [col1, col2, col3, col4]
530
+
531
+ for i, (meal, count) in enumerate(meal_counts.items()):
532
+ if i < len(cols):
533
+ with cols[i]:
534
+ st.metric(meal, count)
535
+
536
+ # Show outdated meal choices warning
537
+ if outdated_choices:
538
+ st.warning("⚠️ **Outdated Meal Choices Detected!**")
539
+ st.markdown("The following guests selected meal options that are no longer available on the current menu:")
540
+
541
+ for outdated_meal, guests in outdated_choices.items():
542
+ with st.expander(f"🔍 {outdated_meal} ({len(guests)} guests)", expanded=False):
543
+ st.markdown("**Guests who selected this outdated option:**")
544
+
545
+ # Create a table for better formatting
546
+ guest_data = []
547
+ for guest_name in guests:
548
+ # Find the group for this guest to get contact info
549
+ guest_group = None
550
+ for group_code, group_data in rsvp_data.items():
551
+ if guest_name in group_data.get('group_attendees', []):
552
+ guest_group = group_data
553
+ break
554
+
555
+ # Get phone number
556
+ phone = 'No phone provided'
557
+ if guest_group:
558
+ phone_number = guest_group.get('phone_number', '')
559
+ if phone_number and phone_number.strip() and phone_number != 'No phone provided':
560
+ phone = phone_number
561
+
562
+ guest_data.append({
563
+ 'Name': guest_name,
564
+ 'Phone': phone
565
+ })
566
+
567
+ if guest_data:
568
+ guest_df = pd.DataFrame(guest_data)
569
+ st.dataframe(guest_df, use_container_width=True, hide_index=True)
570
+
571
+ st.markdown("**Action Required:**")
572
+ 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)}")
573
+
574
+ st.markdown("---") # Separator between events
575
+
576
+ def render_upcoming_tasks(self):
577
+ st.markdown("### Upcoming Tasks (Next 7 Days)")
578
+
579
+ tasks = self.config_manager.load_json_data('tasks.json')
580
+ vendors = self.config_manager.load_json_data('vendors.json')
581
+
582
+ # Create a vendor lookup dictionary for quick access
583
+ vendor_lookup = {}
584
+ if vendors:
585
+ for vendor in vendors:
586
+ vendor_lookup[vendor.get('id', '')] = vendor.get('name', '')
587
+
588
+ if tasks:
589
+ # Filter incomplete tasks and sort by due date
590
+ incomplete_tasks = [task for task in tasks if not task.get('completed', False)]
591
+ incomplete_tasks.sort(key=lambda x: x.get('due_date') or '9999-12-31')
592
+
593
+ # Filter tasks within the next 7 days
594
+ today = date.today()
595
+ week_from_now = date(today.year, today.month, today.day + 7)
596
+
597
+ upcoming_tasks = []
598
+ for task in incomplete_tasks:
599
+ due_date_str = task.get('due_date', '')
600
+ if due_date_str:
601
+ try:
602
+ due_date = datetime.fromisoformat(due_date_str).date()
603
+ if today <= due_date <= week_from_now:
604
+ upcoming_tasks.append(task)
605
+ except:
606
+ # If date parsing fails, skip the task (don't include it)
607
+ continue
608
+ # If no due date, skip the task (don't include it)
609
+
610
+ if upcoming_tasks:
611
+ for i, task in enumerate(upcoming_tasks):
612
+ with st.container():
613
+ # Task header with completion status and title
614
+ title = task.get('title', 'Untitled Task')
615
+ completed = task.get('completed', False)
616
+ status_icon = "✅" if completed else "⏳"
617
+
618
+ # Check if task is associated with a vendor
619
+ vendor_id = task.get('vendor_id', '')
620
+ vendor_name = vendor_lookup.get(vendor_id, '')
621
+
622
+ if vendor_name:
623
+ st.markdown(f"**{status_icon} {title}** - *{vendor_name}*")
624
+ else:
625
+ st.markdown(f"**{status_icon} {title}**")
626
+
627
+ # Task details in columns
628
+ col1, col2, col3, col4 = st.columns(4)
629
+
630
+ with col1:
631
+ due_date = task.get('due_date', '')
632
+ if due_date:
633
+ st.caption(f"📅 Due: {due_date}")
634
+ else:
635
+ st.caption("📅 No due date")
636
+
637
+ with col2:
638
+ group = task.get('group', 'Uncategorized')
639
+ st.caption(f"📁 Group: {group}")
640
+
641
+ with col3:
642
+ priority = task.get('priority', 'Medium')
643
+ # Priority with color coding
644
+ if priority == "Urgent":
645
+ st.caption(f"🔴 Priority: {priority}")
646
+ elif priority == "High":
647
+ st.caption(f"🔴 Priority: {priority}")
648
+ elif priority == "Medium":
649
+ st.caption(f"🟡 Priority: {priority}")
650
+ else:
651
+ st.caption(f"🟢 Priority: {priority}")
652
+
653
+ with col4:
654
+ assigned_to = task.get('assigned_to', '')
655
+ # Handle both old single assignee and new multiple assignees format
656
+ if isinstance(assigned_to, str):
657
+ assigned_to_display = assigned_to if assigned_to else "Unassigned"
658
+ elif isinstance(assigned_to, list):
659
+ if assigned_to:
660
+ assigned_to_display = ", ".join(assigned_to)
661
+ else:
662
+ assigned_to_display = "Unassigned"
663
+ else:
664
+ assigned_to_display = "Unassigned"
665
+
666
+ if assigned_to_display and assigned_to_display != "Unassigned":
667
+ st.caption(f"👤 Assigned: {assigned_to_display}")
668
+ else:
669
+ st.caption("👤 Unassigned")
670
+
671
+ # Add some spacing
672
+ st.markdown("---")
673
+ else:
674
+ st.info("No tasks due within the next 7 days")
675
+ else:
676
+ st.info("No tasks created yet")
677
+
678
+ def render_upcoming_payments(self):
679
+ st.markdown("### 💳 Upcoming Payments (Next 30 Days)")
680
+
681
+ # Create VendorManager instance to use its payment logic
682
+ vendor_manager = VendorManager()
683
+
684
+ # Load vendors data
685
+ vendors = self.config_manager.load_json_data('vendors.json')
686
+
687
+ if not vendors:
688
+ st.info("No vendors added yet")
689
+ return
690
+
691
+ # Get upcoming payments using the same logic as the vendors page
692
+ upcoming_payments = []
693
+ today = date.today()
694
+
695
+ for vendor in vendors:
696
+ payment_installments = vendor.get('payment_installments', [])
697
+
698
+ if payment_installments and len(payment_installments) > 1:
699
+ # Handle installments
700
+ for i, installment in enumerate(payment_installments):
701
+ if not installment.get('paid', False):
702
+ due_date_str = installment.get('due_date', '')
703
+ if due_date_str:
704
+ try:
705
+ due_date = datetime.fromisoformat(due_date_str).date()
706
+ days_until_due = (due_date - today).days
707
+
708
+ # Only show if within next 30 days
709
+ if 0 <= days_until_due <= 30:
710
+ upcoming_payments.append({
711
+ 'vendor_name': vendor.get('name', ''),
712
+ 'installment_num': i + 1,
713
+ 'amount': installment.get('amount', 0),
714
+ 'due_date': due_date,
715
+ 'days_until': days_until_due,
716
+ 'is_installment': True
717
+ })
718
+ except:
719
+ continue
720
+ else:
721
+ # Handle single payment
722
+ payment_due_date_str = vendor.get('payment_due_date')
723
+ if payment_due_date_str:
724
+ try:
725
+ due_date = datetime.fromisoformat(payment_due_date_str).date()
726
+ days_until_due = (due_date - today).days
727
+
728
+ # Only show if not fully paid and within next 30 days
729
+ total_cost = vendor.get('total_cost', vendor.get('cost', 0))
730
+ payment_history = vendor.get('payment_history', [])
731
+ total_paid_from_history = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') != 'credit')
732
+ total_credits_from_history = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') == 'credit')
733
+ remaining_balance = total_cost - total_paid_from_history + total_credits_from_history
734
+
735
+ if remaining_balance > 0 and 0 <= days_until_due <= 30:
736
+ upcoming_payments.append({
737
+ 'vendor_name': vendor.get('name', ''),
738
+ 'installment_num': None,
739
+ 'amount': remaining_balance,
740
+ 'due_date': due_date,
741
+ 'days_until': days_until_due,
742
+ 'is_installment': False
743
+ })
744
+ except:
745
+ continue
746
+
747
+ # Sort by days until due
748
+ upcoming_payments.sort(key=lambda x: x['days_until'])
749
+
750
+ if upcoming_payments:
751
+ # Create table data
752
+ table_data = []
753
+ for payment in upcoming_payments:
754
+ # Format payment description
755
+ if payment['is_installment']:
756
+ payment_desc = f"{payment['vendor_name']} - Installment {payment['installment_num']}"
757
+ else:
758
+ payment_desc = f"{payment['vendor_name']} - Final Payment"
759
+
760
+ # Format due date
761
+ if payment['days_until'] == 0:
762
+ due_text = "🟠 Today"
763
+ elif payment['days_until'] == 1:
764
+ due_text = "🟡 Tomorrow"
765
+ elif payment['days_until'] <= 3:
766
+ due_text = f"🟡 {payment['days_until']} days"
767
+ else:
768
+ due_text = f"🟢 {payment['days_until']} days"
769
+
770
+ table_data.append({
771
+ 'Vendor & Payment': payment_desc,
772
+ 'Amount': f"${payment['amount']:,.0f}",
773
+ 'Due': due_text
774
+ })
775
+
776
+ # Create and display the table
777
+ df = pd.DataFrame(table_data)
778
+ st.dataframe(df, use_container_width=True, hide_index=True)
779
+ else:
780
+ st.info("No payments due within the next 30 days")
google_drive_manager.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import tempfile
4
+ from typing import Dict, List, Optional, Any
5
+ from google.oauth2 import service_account
6
+ from google.oauth2.credentials import Credentials
7
+ from googleapiclient.discovery import build
8
+ from googleapiclient.errors import HttpError
9
+ import streamlit as st
10
+
11
+ class GoogleDriveManager:
12
+ def __init__(self):
13
+ self.service = None
14
+ self.folder_id = None
15
+ self.is_huggingface = os.getenv('SPACE_ID') is not None
16
+ self.temp_dir = "/tmp/wedding_data" if self.is_huggingface else "temp_data"
17
+
18
+ # Ensure temp directory exists
19
+ os.makedirs(self.temp_dir, exist_ok=True)
20
+
21
+ def initialize(self, folder_id: str = None):
22
+ """Initialize Google Drive service and set folder ID"""
23
+ try:
24
+ if self.is_huggingface:
25
+ self._setup_huggingface_auth()
26
+ else:
27
+ self._setup_local_auth()
28
+
29
+ if folder_id:
30
+ self.folder_id = folder_id
31
+ else:
32
+ # Try to get folder ID from environment
33
+ self.folder_id = os.getenv('GOOGLE_DRIVE_FOLDER_ID')
34
+
35
+ if not self.folder_id:
36
+ st.error("Google Drive folder ID not found. Please set GOOGLE_DRIVE_FOLDER_ID environment variable.")
37
+ return False
38
+
39
+ return True
40
+ except Exception as e:
41
+ st.error(f"Failed to initialize Google Drive: {str(e)}")
42
+ return False
43
+
44
+ def _setup_huggingface_auth(self):
45
+ """Set up authentication for Hugging Face Spaces"""
46
+ # For Hugging Face, we'll use service account credentials
47
+ # stored as environment variables
48
+ service_account_info = {
49
+ "type": "service_account",
50
+ "project_id": os.getenv('GOOGLE_PROJECT_ID'),
51
+ "private_key_id": os.getenv('GOOGLE_PRIVATE_KEY_ID'),
52
+ "private_key": os.getenv('GOOGLE_PRIVATE_KEY', '').replace('\\n', '\n'),
53
+ "client_email": os.getenv('GOOGLE_CLIENT_EMAIL'),
54
+ "client_id": os.getenv('GOOGLE_CLIENT_ID'),
55
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
56
+ "token_uri": "https://oauth2.googleapis.com/token",
57
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
58
+ "client_x509_cert_url": f"https://www.googleapis.com/robot/v1/metadata/x509/{os.getenv('GOOGLE_CLIENT_EMAIL')}"
59
+ }
60
+
61
+ # Validate that all required fields are present
62
+ required_fields = ['project_id', 'private_key_id', 'private_key', 'client_email', 'client_id']
63
+ missing_fields = [field for field in required_fields if not service_account_info.get(field)]
64
+
65
+ if missing_fields:
66
+ raise ValueError(f"Missing Google service account credentials: {missing_fields}")
67
+
68
+ credentials = service_account.Credentials.from_service_account_info(
69
+ service_account_info,
70
+ scopes=['https://www.googleapis.com/auth/drive']
71
+ )
72
+
73
+ self.service = build('drive', 'v3', credentials=credentials)
74
+
75
+ def _setup_local_auth(self):
76
+ """Set up authentication for local development"""
77
+ # For local development, you can use OAuth or service account
78
+ # This is a simplified version - you might want to implement OAuth flow
79
+ service_account_path = os.getenv('GOOGLE_SERVICE_ACCOUNT_PATH')
80
+
81
+ if service_account_path and os.path.exists(service_account_path):
82
+ credentials = service_account.Credentials.from_service_account_file(
83
+ service_account_path,
84
+ scopes=['https://www.googleapis.com/auth/drive']
85
+ )
86
+ self.service = build('drive', 'v3', credentials=credentials)
87
+ else:
88
+ st.warning("Google service account file not found. Using local data only.")
89
+ self.service = None
90
+
91
+ def list_files(self) -> List[Dict[str, Any]]:
92
+ """List all files in the Google Drive folder"""
93
+ if not self.service or not self.folder_id:
94
+ return []
95
+
96
+ try:
97
+ query = f"'{self.folder_id}' in parents and trashed=false"
98
+ results = self.service.files().list(
99
+ q=query,
100
+ fields="files(id, name, modifiedTime, size)"
101
+ ).execute()
102
+
103
+ return results.get('files', [])
104
+ except HttpError as e:
105
+ st.error(f"Error listing files: {str(e)}")
106
+ return []
107
+
108
+ def download_file(self, file_name: str) -> Optional[Dict[str, Any]]:
109
+ """Download a file from Google Drive and return its content"""
110
+ if not self.service or not self.folder_id:
111
+ return None
112
+
113
+ try:
114
+ # Find the file
115
+ query = f"name='{file_name}' and '{self.folder_id}' in parents and trashed=false"
116
+ results = self.service.files().list(q=query).execute()
117
+ files = results.get('files', [])
118
+
119
+ if not files:
120
+ st.warning(f"File '{file_name}' not found in Google Drive")
121
+ return None
122
+
123
+ file_id = files[0]['id']
124
+
125
+ # Download file content
126
+ request = self.service.files().get_media(fileId=file_id)
127
+ content = request.execute()
128
+
129
+ # Try to parse as JSON
130
+ try:
131
+ return json.loads(content.decode('utf-8'))
132
+ except json.JSONDecodeError:
133
+ # If not JSON, return as string
134
+ return content.decode('utf-8')
135
+
136
+ except HttpError as e:
137
+ st.error(f"Error downloading file '{file_name}': {str(e)}")
138
+ return None
139
+
140
+ def upload_file(self, file_name: str, content: Any) -> bool:
141
+ """Upload a file to Google Drive"""
142
+ if not self.service or not self.folder_id:
143
+ return False
144
+
145
+ try:
146
+ # Convert content to JSON string if it's a dict/list
147
+ if isinstance(content, (dict, list)):
148
+ content_str = json.dumps(content, indent=2)
149
+ else:
150
+ content_str = str(content)
151
+
152
+ # Check if file already exists
153
+ query = f"name='{file_name}' and '{self.folder_id}' in parents and trashed=false"
154
+ results = self.service.files().list(q=query).execute()
155
+ existing_files = results.get('files', [])
156
+
157
+ if existing_files:
158
+ # Update existing file
159
+ file_id = existing_files[0]['id']
160
+ media_body = self.service.files().update(
161
+ fileId=file_id,
162
+ media_body=content_str.encode('utf-8')
163
+ ).execute()
164
+ else:
165
+ # Create new file
166
+ file_metadata = {
167
+ 'name': file_name,
168
+ 'parents': [self.folder_id]
169
+ }
170
+ media_body = self.service.files().create(
171
+ body=file_metadata,
172
+ media_body=content_str.encode('utf-8')
173
+ ).execute()
174
+
175
+ return True
176
+
177
+ except HttpError as e:
178
+ st.error(f"Error uploading file '{file_name}': {str(e)}")
179
+ return False
180
+
181
+ def sync_from_drive(self, file_names: List[str]) -> Dict[str, Any]:
182
+ """Download multiple files from Google Drive"""
183
+ synced_files = {}
184
+
185
+ for file_name in file_names:
186
+ content = self.download_file(file_name)
187
+ if content is not None:
188
+ synced_files[file_name] = content
189
+ # Save to local temp directory
190
+ local_path = os.path.join(self.temp_dir, file_name)
191
+ with open(local_path, 'w') as f:
192
+ if isinstance(content, (dict, list)):
193
+ json.dump(content, f, indent=2)
194
+ else:
195
+ f.write(str(content))
196
+
197
+ return synced_files
198
+
199
+ def sync_to_drive(self, file_names: List[str], local_data: Dict[str, Any]) -> bool:
200
+ """Upload multiple files to Google Drive"""
201
+ success = True
202
+
203
+ for file_name in file_names:
204
+ if file_name in local_data:
205
+ if not self.upload_file(file_name, local_data[file_name]):
206
+ success = False
207
+
208
+ return success
209
+
210
+ def get_file_info(self, file_name: str) -> Optional[Dict[str, Any]]:
211
+ """Get metadata for a specific file"""
212
+ if not self.service or not self.folder_id:
213
+ return None
214
+
215
+ try:
216
+ query = f"name='{file_name}' and '{self.folder_id}' in parents and trashed=false"
217
+ results = self.service.files().list(q=query).execute()
218
+ files = results.get('files', [])
219
+
220
+ if files:
221
+ return files[0]
222
+ return None
223
+
224
+ except HttpError as e:
225
+ st.error(f"Error getting file info for '{file_name}': {str(e)}")
226
+ return None
227
+
228
+ def is_online(self) -> bool:
229
+ """Check if Google Drive service is available"""
230
+ return self.service is not None and self.folder_id is not None
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ streamlit==1.28.1
2
+ pandas==2.1.3
3
+ plotly==5.17.0
4
+ datetime
5
+ json5==0.9.14
6
+ openpyxl==3.1.2
7
+ google-api-python-client>=2.0.0
8
+ google-auth-httplib2>=0.1.0
9
+ google-auth-oauthlib>=0.5.0
10
+ google-auth>=2.0.0
tasks.py ADDED
@@ -0,0 +1,665 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import json
3
+ import html
4
+ from datetime import datetime, date
5
+ from config_manager import ConfigManager
6
+
7
+ class TasksManager:
8
+ def __init__(self):
9
+ self.config_manager = ConfigManager()
10
+ self.vendors_cache = None
11
+
12
+ def get_vendor_name(self, vendor_id):
13
+ """Get vendor name by vendor ID"""
14
+ if not self.vendors_cache or not vendor_id:
15
+ return None
16
+
17
+ for vendor in self.vendors_cache:
18
+ if vendor.get('id') == vendor_id:
19
+ return vendor.get('name')
20
+ return None
21
+
22
+ def get_vendor_contact_info(self, vendor_id):
23
+ """Get vendor contact information by vendor ID"""
24
+ if not self.vendors_cache or not vendor_id:
25
+ return None
26
+
27
+ for vendor in self.vendors_cache:
28
+ if vendor.get('id') == vendor_id:
29
+ vendor_type = vendor.get('type', 'Vendor/Service')
30
+
31
+ if vendor_type == 'Vendor/Service':
32
+ # For vendors, return their contact information
33
+ return {
34
+ 'contact_person': vendor.get('contact_person', ''),
35
+ 'phone': vendor.get('phone', ''),
36
+ 'email': vendor.get('email', ''),
37
+ 'website': vendor.get('website', ''),
38
+ 'address': vendor.get('address', '')
39
+ }
40
+ else:
41
+ # For items, return seller contact information
42
+ return {
43
+ 'contact_person': '', # Items don't have contact person
44
+ 'phone': vendor.get('seller_phone', ''),
45
+ 'email': vendor.get('seller_email', ''),
46
+ 'website': vendor.get('seller_website', ''),
47
+ 'address': '' # Items don't have address
48
+ }
49
+ return None
50
+
51
+ def render(self, config):
52
+ st.markdown("## ✅ Task Management")
53
+
54
+ # Load tasks and vendors
55
+ tasks = self.config_manager.load_json_data('tasks.json')
56
+ self.vendors_cache = self.config_manager.load_json_data('vendors.json')
57
+ custom_settings = config.get('custom_settings', {})
58
+ custom_tags = custom_settings.get('custom_tags', [])
59
+
60
+ # Get event-based task groups
61
+ wedding_events = config.get('wedding_events', [])
62
+ event_names = [event['name'] for event in wedding_events] if wedding_events else []
63
+
64
+ # Add general planning categories
65
+ task_groups = event_names + ['General Planning', 'Vendor Management', 'Vendor & Item Management', 'Wedding Party', 'Guest Management', 'Timeline']
66
+
67
+ # Task creation section
68
+ with st.expander("➕ Add New Task", expanded=False):
69
+ self.render_task_form(task_groups, custom_tags)
70
+
71
+ # View toggle and filters
72
+ col1, col2, col3, col4, col5 = st.columns(5)
73
+
74
+ with col1:
75
+ view_mode = st.radio("View Mode", ["Detailed View", "Checklist View"], horizontal=True)
76
+
77
+ with col2:
78
+ filter_group = st.selectbox("Filter by Group", ["All"] + task_groups)
79
+
80
+ with col3:
81
+ filter_status = st.selectbox("Filter by Status", ["All", "Completed", "Incomplete"])
82
+
83
+ with col4:
84
+ # Get unique assignees from tasks (handle both single and multiple assignees)
85
+ assignees = set()
86
+ for task in tasks:
87
+ assigned_to = task.get('assigned_to', '')
88
+ if isinstance(assigned_to, str) and assigned_to.strip():
89
+ assignees.add(assigned_to.strip())
90
+ elif isinstance(assigned_to, list):
91
+ for assignee in assigned_to:
92
+ if assignee and assignee.strip():
93
+ assignees.add(assignee.strip())
94
+ assignee_list = sorted(list(assignees))
95
+ filter_assignees = st.multiselect("Filter by Assignees", assignee_list, help="Select one or more assignees to filter tasks")
96
+
97
+ with col5:
98
+ sort_by = st.selectbox("Sort by", ["Due Date", "Created Date", "Title", "Group"])
99
+
100
+ # Filter and sort tasks
101
+ filtered_tasks = self.filter_tasks(tasks, filter_group, filter_status, filter_assignees)
102
+ sorted_tasks = self.sort_tasks(filtered_tasks, sort_by)
103
+
104
+ # Display tasks based on view mode
105
+ if sorted_tasks:
106
+ st.markdown(f"### Tasks ({len(sorted_tasks)} total)")
107
+
108
+ if view_mode == "Checklist View":
109
+ self.render_checklist_view(sorted_tasks)
110
+ else:
111
+ # Group tasks by their group/category for detailed view
112
+ grouped_tasks = {}
113
+ for task in sorted_tasks:
114
+ group = task.get('group', 'Uncategorized')
115
+ if group not in grouped_tasks:
116
+ grouped_tasks[group] = []
117
+ grouped_tasks[group].append(task)
118
+
119
+ # Display tasks grouped by category
120
+ for group_name, tasks in grouped_tasks.items():
121
+ st.markdown(f"## {group_name} ({len(tasks)} tasks)")
122
+ for task in tasks:
123
+ self.render_task_card(task, task_groups, custom_tags)
124
+ else:
125
+ st.info("No tasks found. Create your first task above!")
126
+
127
+ def render_checklist_view(self, tasks):
128
+ """Render tasks in a compact checklist format for easy reading"""
129
+ # Display all tasks in a single checklist without grouping
130
+ st.markdown("#### All Tasks")
131
+
132
+ # Create a container for the checklist
133
+ with st.container():
134
+ for task in tasks:
135
+ self.render_checklist_item(task)
136
+
137
+ def render_checklist_item(self, task):
138
+ """Render a single task as a checklist item with interactive checkbox"""
139
+ task_id = task.get('id', '')
140
+ title = task.get('title', 'Untitled Task')
141
+ description = task.get('description', '')
142
+ due_date = task.get('due_date', '')
143
+ assigned_to = task.get('assigned_to', '')
144
+ tags = task.get('tags', [])
145
+ completed = task.get('completed', False)
146
+ vendor_id = task.get('vendor_id', '')
147
+ vendor_name = self.get_vendor_name(vendor_id) if vendor_id else None
148
+
149
+ # Handle both old single assignee and new multiple assignees format
150
+ if isinstance(assigned_to, str):
151
+ assigned_to_display = assigned_to if assigned_to else "Unassigned"
152
+ elif isinstance(assigned_to, list):
153
+ if assigned_to:
154
+ assigned_to_display = ", ".join(assigned_to)
155
+ else:
156
+ assigned_to_display = "Unassigned"
157
+ else:
158
+ assigned_to_display = "Unassigned"
159
+
160
+ # Create a compact checklist item
161
+ with st.container():
162
+ # Use a horizontal layout with better spacing
163
+ col1, col2, col3, col4 = st.columns([0.3, 3.2, 1, 1])
164
+
165
+ with col1:
166
+ # Interactive checkbox for completion status with label
167
+ new_completed = st.checkbox(
168
+ " ", # Single space as label to provide spacing
169
+ value=completed,
170
+ key=f"checklist_{task_id}",
171
+ help="Click to toggle completion status"
172
+ )
173
+
174
+ # If completion status changed, update the task
175
+ if new_completed != completed:
176
+ self.toggle_task_completion(task_id, new_completed)
177
+
178
+ with col2:
179
+ # Task title and description with proper spacing
180
+ if completed:
181
+ st.markdown(f"~~**{title}**~~")
182
+ else:
183
+ st.markdown(f"**{title}**")
184
+
185
+ if description:
186
+ st.caption(f"📝 {description}")
187
+
188
+ # Display tags if they exist
189
+ if tags and len(tags) > 0:
190
+ st.caption(f"🏷️ {', '.join(tags)}")
191
+
192
+ with col3:
193
+ # Due date only
194
+ if due_date:
195
+ st.caption(f"📅 {due_date}")
196
+
197
+ with col4:
198
+ # Assigned to and vendor
199
+ if assigned_to_display and assigned_to_display != "Unassigned":
200
+ st.caption(f"👤 {assigned_to_display}")
201
+
202
+ if vendor_name:
203
+ st.caption(f"🏢 {vendor_name}")
204
+
205
+ # Add a subtle separator
206
+ st.markdown("---")
207
+
208
+ def render_task_form(self, task_groups, custom_tags):
209
+ with st.form("task_form"):
210
+ col1, col2 = st.columns(2)
211
+
212
+ with col1:
213
+ title = st.text_input("Task Title *", placeholder="Enter task title")
214
+
215
+ # Show event-based groups first, then general categories
216
+ if task_groups:
217
+ group = st.selectbox("Event/Category", task_groups, help="Select the wedding event or general category this task relates to")
218
+ else:
219
+ group = st.selectbox("Category", ["General Planning"])
220
+
221
+ due_date = st.date_input("Due Date", value=None)
222
+ priority = st.selectbox("Priority", ["Low", "Medium", "High", "Urgent"])
223
+
224
+ with col2:
225
+ description = st.text_area("Description", placeholder="Enter task description")
226
+
227
+ # Assigned to field with wedding party and task assignees selection
228
+ wedding_party = self.config_manager.load_json_data('wedding_party.json')
229
+ wedding_party_names = [member.get('name', '') for member in wedding_party if member.get('name')]
230
+
231
+ # Get task assignees from config
232
+ config = self.config_manager.load_config()
233
+ custom_settings = config.get('custom_settings', {})
234
+ task_assignees = custom_settings.get('task_assignees', [])
235
+
236
+ # Create combined options for multiselect
237
+ all_assignee_options = []
238
+ if wedding_party_names:
239
+ all_assignee_options.extend([f"Wedding Party: {name}" for name in wedding_party_names])
240
+ if task_assignees:
241
+ all_assignee_options.extend([f"Task Assignee: {assignee}" for assignee in task_assignees])
242
+
243
+ # Multiple assignees selection
244
+ selected_assignees = st.multiselect("Assign to (select multiple people)", all_assignee_options, key="create_assignees")
245
+
246
+ # Custom assignee text input (for additional people not in the lists)
247
+ custom_assignee = st.text_input("Additional Custom Assignee", placeholder="Enter additional assignee name (optional)", key="create_custom_assignee")
248
+
249
+ # Combine selected assignees and custom assignee
250
+ assigned_to_list = []
251
+ for assignee in selected_assignees:
252
+ if assignee.startswith("Wedding Party: "):
253
+ assigned_to_list.append(assignee.replace("Wedding Party: ", ""))
254
+ elif assignee.startswith("Task Assignee: "):
255
+ assigned_to_list.append(assignee.replace("Task Assignee: ", ""))
256
+
257
+ if custom_assignee and custom_assignee.strip():
258
+ assigned_to_list.append(custom_assignee.strip())
259
+
260
+ # Store as list for multiple assignees
261
+ assigned_to = assigned_to_list
262
+
263
+ # Tags selection
264
+ selected_tags = st.multiselect("Tags", custom_tags, default=[])
265
+
266
+ submitted = st.form_submit_button("Create Task", type="primary")
267
+
268
+ if submitted:
269
+ if title:
270
+ new_task = {
271
+ 'id': datetime.now().strftime("%Y%m%d_%H%M%S"),
272
+ 'title': title,
273
+ 'description': description,
274
+ 'group': group,
275
+ 'due_date': due_date.isoformat() if due_date else None,
276
+ 'priority': priority,
277
+ 'assigned_to': assigned_to,
278
+ 'tags': selected_tags,
279
+ 'completed': False,
280
+ 'created_date': datetime.now().isoformat(),
281
+ 'completed_date': None
282
+ }
283
+
284
+ # Load existing tasks and add new one
285
+ tasks = self.config_manager.load_json_data('tasks.json')
286
+ tasks.append(new_task)
287
+
288
+ if self.config_manager.save_json_data('tasks.json', tasks):
289
+ st.success("Task created successfully!")
290
+ st.rerun()
291
+ else:
292
+ st.error("Error saving task")
293
+ else:
294
+ st.error("Please enter a task title")
295
+
296
+ def filter_tasks(self, tasks, filter_group, filter_status, filter_assignees):
297
+ filtered = tasks.copy()
298
+
299
+ # Filter by group
300
+ if filter_group != "All":
301
+ filtered = [task for task in filtered if task.get('group') == filter_group]
302
+
303
+ # Filter by status
304
+ if filter_status == "Completed":
305
+ filtered = [task for task in filtered if task.get('completed', False)]
306
+ elif filter_status == "Incomplete":
307
+ filtered = [task for task in filtered if not task.get('completed', False)]
308
+
309
+ # Filter by assignees (handle both single and multiple assignees)
310
+ if filter_assignees: # If any assignees are selected
311
+ filtered_tasks = []
312
+ for task in filtered:
313
+ assigned_to = task.get('assigned_to', '')
314
+ task_assignees = []
315
+
316
+ # Extract assignees from task (handle both old single and new multiple assignees format)
317
+ if isinstance(assigned_to, str) and assigned_to.strip():
318
+ task_assignees = [assigned_to.strip()]
319
+ elif isinstance(assigned_to, list):
320
+ task_assignees = [assignee.strip() for assignee in assigned_to if assignee and assignee.strip()]
321
+
322
+ # Check if any of the task's assignees match any of the selected filter assignees
323
+ if any(assignee in filter_assignees for assignee in task_assignees):
324
+ filtered_tasks.append(task)
325
+
326
+ filtered = filtered_tasks
327
+
328
+ return filtered
329
+
330
+ def sort_tasks(self, tasks, sort_by):
331
+ if sort_by == "Due Date":
332
+ return sorted(tasks, key=lambda x: x.get('due_date') or '9999-12-31')
333
+ elif sort_by == "Created Date":
334
+ return sorted(tasks, key=lambda x: x.get('created_date', ''), reverse=True)
335
+ elif sort_by == "Title":
336
+ return sorted(tasks, key=lambda x: x.get('title', '').lower())
337
+ elif sort_by == "Group":
338
+ return sorted(tasks, key=lambda x: x.get('group', ''))
339
+ else:
340
+ return tasks
341
+
342
+ def render_task_card(self, task, task_groups, custom_tags):
343
+ task_id = task.get('id', '')
344
+ title = task.get('title', 'Untitled Task')
345
+ description = task.get('description', '')
346
+ group = task.get('group', 'Uncategorized')
347
+ due_date = task.get('due_date', '')
348
+ priority = task.get('priority', 'Medium')
349
+ assigned_to = task.get('assigned_to', '')
350
+ tags = task.get('tags', [])
351
+ completed = task.get('completed', False)
352
+ vendor_id = task.get('vendor_id', '')
353
+ vendor_name = self.get_vendor_name(vendor_id) if vendor_id else None
354
+ vendor_contact_info = self.get_vendor_contact_info(vendor_id) if vendor_id else None
355
+
356
+ # Handle both old single assignee and new multiple assignees format
357
+ if isinstance(assigned_to, str):
358
+ assigned_to_display = assigned_to if assigned_to else "Unassigned"
359
+ elif isinstance(assigned_to, list):
360
+ if assigned_to:
361
+ assigned_to_display = ", ".join(assigned_to)
362
+ else:
363
+ assigned_to_display = "Unassigned"
364
+ else:
365
+ assigned_to_display = "Unassigned"
366
+
367
+ # Create a container for the task card
368
+ with st.container():
369
+ # Task header with completion status and title - make this the most prominent
370
+ status_icon = "✅" if completed else "⏳"
371
+ st.markdown(f"### {status_icon} {title}")
372
+
373
+ # Task details in columns
374
+ col1, col2, col3 = st.columns(3)
375
+
376
+ with col1:
377
+ if due_date:
378
+ st.caption(f"📅 Due: {due_date}")
379
+ else:
380
+ st.caption("📅 No due date")
381
+
382
+ if vendor_name:
383
+ st.caption(f"🏢 Vendor: {vendor_name}")
384
+
385
+ with col2:
386
+ # Priority with color coding
387
+ if priority == "Urgent":
388
+ st.caption(f"🔴 Priority: {priority}")
389
+ elif priority == "High":
390
+ st.caption(f"🔴 Priority: {priority}")
391
+ elif priority == "Medium":
392
+ st.caption(f"🟡 Priority: {priority}")
393
+ else:
394
+ st.caption(f"🟢 Priority: {priority}")
395
+
396
+ if assigned_to_display and assigned_to_display != "Unassigned":
397
+ st.caption(f"👤 Assigned: {assigned_to_display}")
398
+
399
+ with col3:
400
+ st.caption(f"📁 Group: {group}")
401
+
402
+ if tags and len(tags) > 0:
403
+ st.caption(f"🏷️ Tags: {', '.join(tags)}")
404
+
405
+ # Description if available
406
+ if description:
407
+ st.caption(f"📝 {description}")
408
+
409
+ # Vendor contact information if available
410
+ if vendor_contact_info and vendor_name:
411
+ contact_info_items = []
412
+
413
+ # Determine if this is a vendor or item based on contact person
414
+ is_vendor = vendor_contact_info.get('contact_person', '') != ''
415
+ contact_type = "Vendor Contact" if is_vendor else "Seller Contact"
416
+
417
+ if is_vendor and vendor_contact_info.get('contact_person'):
418
+ contact_info_items.append(f"**Contact Person:** {vendor_contact_info['contact_person']}")
419
+
420
+ if vendor_contact_info.get('phone'):
421
+ contact_info_items.append(f"**Phone:** {vendor_contact_info['phone']}")
422
+
423
+ if vendor_contact_info.get('email'):
424
+ contact_info_items.append(f"**Email:** {vendor_contact_info['email']}")
425
+
426
+ if vendor_contact_info.get('website'):
427
+ contact_info_items.append(f"**Website:** [{vendor_contact_info['website']}]({vendor_contact_info['website']})")
428
+
429
+ if is_vendor and vendor_contact_info.get('address'):
430
+ contact_info_items.append(f"**Address:** {vendor_contact_info['address']}")
431
+
432
+ if contact_info_items:
433
+ st.markdown(f"**{contact_type} Information:**")
434
+ for info in contact_info_items:
435
+ st.markdown(f"<small>{info}</small>", unsafe_allow_html=True)
436
+
437
+ # Add some spacing
438
+ st.markdown("---")
439
+
440
+ # Action buttons below the task card
441
+ col1, col2, col3, col4 = st.columns([1, 1, 1, 1])
442
+
443
+ with col1:
444
+ if st.button("Edit", key=f"edit_{task_id}", help="Edit task", use_container_width=True):
445
+ st.session_state[f"editing_task_{task_id}"] = True
446
+
447
+ with col2:
448
+ if st.button("Duplicate", key=f"duplicate_{task_id}", help="Duplicate task", use_container_width=True):
449
+ self.duplicate_task(task_id)
450
+
451
+ with col3:
452
+ if completed:
453
+ if st.button("Undo", key=f"undo_{task_id}", help="Mark incomplete", use_container_width=True):
454
+ self.toggle_task_completion(task_id, False)
455
+ else:
456
+ if st.button("Complete", key=f"complete_{task_id}", help="Mark complete", use_container_width=True):
457
+ self.toggle_task_completion(task_id, True)
458
+
459
+ with col4:
460
+ if st.button("Delete", key=f"delete_{task_id}", help="Delete task", use_container_width=True):
461
+ self.delete_task(task_id)
462
+
463
+ # Show edit form if editing (outside columns to span full width)
464
+ if st.session_state.get(f"editing_task_{task_id}", False):
465
+ self.render_edit_task_form(task, task_groups, custom_tags)
466
+
467
+ def toggle_task_completion(self, task_id, completed):
468
+ tasks = self.config_manager.load_json_data('tasks.json')
469
+ for task in tasks:
470
+ if task.get('id') == task_id:
471
+ task['completed'] = completed
472
+ task['completed_date'] = datetime.now().isoformat() if completed else None
473
+ break
474
+
475
+ self.config_manager.save_json_data('tasks.json', tasks)
476
+ st.rerun()
477
+
478
+ def delete_task(self, task_id):
479
+ tasks = self.config_manager.load_json_data('tasks.json')
480
+ tasks = [task for task in tasks if task.get('id') != task_id]
481
+ self.config_manager.save_json_data('tasks.json', tasks)
482
+ st.rerun()
483
+
484
+ def duplicate_task(self, task_id):
485
+ tasks = self.config_manager.load_json_data('tasks.json')
486
+
487
+ # Find the task to duplicate
488
+ original_task = None
489
+ for task in tasks:
490
+ if task.get('id') == task_id:
491
+ original_task = task
492
+ break
493
+
494
+ if original_task:
495
+ # Create a duplicate with new ID and modified title
496
+ duplicated_task = original_task.copy()
497
+ duplicated_task['id'] = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
498
+ duplicated_task['title'] = f"{original_task.get('title', 'Untitled Task')}"
499
+ duplicated_task['completed'] = False
500
+ duplicated_task['completed_date'] = None
501
+ duplicated_task['created_date'] = datetime.now().isoformat()
502
+
503
+ # Add the duplicated task to the list
504
+ tasks.append(duplicated_task)
505
+
506
+ if self.config_manager.save_json_data('tasks.json', tasks):
507
+ st.success("Task duplicated successfully!")
508
+ st.rerun()
509
+ else:
510
+ st.error("Error saving duplicated task")
511
+ else:
512
+ st.error("Task not found")
513
+
514
+ def render_edit_task_form(self, task, task_groups, custom_tags):
515
+ task_id = task.get('id', '')
516
+ st.markdown("### Edit Task")
517
+
518
+ with st.form(f"edit_task_form_{task_id}"):
519
+ col1, col2 = st.columns(2)
520
+
521
+ with col1:
522
+ title = st.text_input("Task Title *", value=task.get('title', ''), key=f"edit_title_{task_id}")
523
+
524
+ # Show event-based groups first, then general categories
525
+ if task_groups:
526
+ current_group = task.get('group', '')
527
+ group_index = 0
528
+ if current_group in task_groups:
529
+ group_index = task_groups.index(current_group)
530
+ group = st.selectbox("Event/Category", task_groups, index=group_index, key=f"edit_group_{task_id}")
531
+ else:
532
+ group = st.selectbox("Category", ["General Planning"], key=f"edit_group_{task_id}")
533
+
534
+ due_date_str = task.get('due_date', '')
535
+ due_date = None
536
+ if due_date_str:
537
+ try:
538
+ due_date = datetime.fromisoformat(due_date_str).date()
539
+ except:
540
+ due_date = None
541
+ due_date = st.date_input("Due Date", value=due_date, key=f"edit_due_date_{task_id}")
542
+
543
+ priority_options = ["Low", "Medium", "High", "Urgent"]
544
+ current_priority = task.get('priority', 'Medium')
545
+ priority_index = priority_options.index(current_priority) if current_priority in priority_options else 1
546
+ priority = st.selectbox("Priority", priority_options, index=priority_index, key=f"edit_priority_{task_id}")
547
+
548
+ with col2:
549
+ description = st.text_area("Description", value=task.get('description', ''), key=f"edit_description_{task_id}")
550
+
551
+ # Assigned to field with wedding party and task assignees selection
552
+ wedding_party = self.config_manager.load_json_data('wedding_party.json')
553
+ wedding_party_names = [member.get('name', '') for member in wedding_party if member.get('name')]
554
+
555
+ # Get task assignees from config
556
+ config = self.config_manager.load_config()
557
+ custom_settings = config.get('custom_settings', {})
558
+ task_assignees = custom_settings.get('task_assignees', [])
559
+
560
+ # Get current assigned_to value (handle both old single assignee and new multiple assignees)
561
+ current_assigned_to = task.get('assigned_to', '')
562
+
563
+ # Handle backward compatibility - convert single assignee to list
564
+ if isinstance(current_assigned_to, str):
565
+ if current_assigned_to:
566
+ current_assignees = [current_assigned_to]
567
+ else:
568
+ current_assignees = []
569
+ elif isinstance(current_assigned_to, list):
570
+ current_assignees = current_assigned_to
571
+ else:
572
+ current_assignees = []
573
+
574
+ # Create combined options for multiselect
575
+ all_assignee_options = []
576
+ if wedding_party_names:
577
+ all_assignee_options.extend([f"Wedding Party: {name}" for name in wedding_party_names])
578
+ if task_assignees:
579
+ all_assignee_options.extend([f"Task Assignee: {assignee}" for assignee in task_assignees])
580
+
581
+ # Determine initial selected values
582
+ initial_selected = []
583
+ custom_assignees = []
584
+
585
+ for assignee in current_assignees:
586
+ if assignee in wedding_party_names:
587
+ initial_selected.append(f"Wedding Party: {assignee}")
588
+ elif assignee in task_assignees:
589
+ initial_selected.append(f"Task Assignee: {assignee}")
590
+ else:
591
+ custom_assignees.append(assignee)
592
+
593
+ # Multiple assignees selection
594
+ selected_assignees = st.multiselect("Assign to (select multiple people)", all_assignee_options, default=initial_selected, key=f"edit_assignees_{task_id}")
595
+
596
+ # Custom assignee text input (for additional people not in the lists)
597
+ custom_assignee_text = ", ".join(custom_assignees) if custom_assignees else ""
598
+ custom_assignee = st.text_input("Additional Custom Assignees", value=custom_assignee_text, placeholder="Enter additional assignee names (comma-separated)", key=f"edit_custom_assignee_{task_id}")
599
+
600
+ # Combine selected assignees and custom assignees
601
+ assigned_to_list = []
602
+ for assignee in selected_assignees:
603
+ if assignee.startswith("Wedding Party: "):
604
+ assigned_to_list.append(assignee.replace("Wedding Party: ", ""))
605
+ elif assignee.startswith("Task Assignee: "):
606
+ assigned_to_list.append(assignee.replace("Task Assignee: ", ""))
607
+
608
+ # Parse custom assignees (comma-separated)
609
+ if custom_assignee and custom_assignee.strip():
610
+ custom_list = [name.strip() for name in custom_assignee.split(',') if name.strip()]
611
+ assigned_to_list.extend(custom_list)
612
+
613
+ # Store as list for multiple assignees
614
+ assigned_to = assigned_to_list
615
+
616
+ # Tags selection
617
+ current_tags = task.get('tags', [])
618
+ # Filter current tags to only include those that exist in custom_tags
619
+ valid_current_tags = [tag for tag in current_tags if tag in custom_tags]
620
+ selected_tags = st.multiselect("Tags", custom_tags, default=valid_current_tags, key=f"edit_tags_{task_id}")
621
+
622
+ # Form buttons
623
+ col1, col2 = st.columns(2)
624
+ with col1:
625
+ save_clicked = st.form_submit_button("Save Changes", type="primary")
626
+ with col2:
627
+ cancel_clicked = st.form_submit_button("Cancel")
628
+
629
+ if save_clicked:
630
+ if title:
631
+ # Update the task
632
+ updated_task = {
633
+ 'id': task_id,
634
+ 'title': title,
635
+ 'description': description,
636
+ 'group': group,
637
+ 'due_date': due_date.isoformat() if due_date else None,
638
+ 'priority': priority,
639
+ 'assigned_to': assigned_to,
640
+ 'tags': selected_tags,
641
+ 'completed': task.get('completed', False),
642
+ 'created_date': task.get('created_date', datetime.now().isoformat()),
643
+ 'completed_date': task.get('completed_date', None),
644
+ 'vendor_id': task.get('vendor_id', '') # Preserve vendor_id if it exists
645
+ }
646
+
647
+ # Load existing tasks and update the specific one
648
+ tasks = self.config_manager.load_json_data('tasks.json')
649
+ for i, t in enumerate(tasks):
650
+ if t.get('id') == task_id:
651
+ tasks[i] = updated_task
652
+ break
653
+
654
+ if self.config_manager.save_json_data('tasks.json', tasks):
655
+ st.success("Task updated successfully!")
656
+ st.session_state[f"editing_task_{task_id}"] = False
657
+ st.rerun()
658
+ else:
659
+ st.error("Error saving task")
660
+ else:
661
+ st.error("Please enter a task title")
662
+
663
+ if cancel_clicked:
664
+ st.session_state[f"editing_task_{task_id}"] = False
665
+ st.rerun()
vendors.py ADDED
The diff for this file is too large to render. See raw diff
 
wedding_party.py ADDED
@@ -0,0 +1,799 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import json
3
+ from datetime import datetime
4
+ from config_manager import ConfigManager
5
+
6
+ class WeddingPartyManager:
7
+ def __init__(self):
8
+ self.config_manager = ConfigManager()
9
+
10
+ def render(self, config):
11
+ st.markdown("## 👰🤵 Wedding Party Tracker")
12
+
13
+ # Load wedding party members
14
+ party_members = self.config_manager.load_json_data('wedding_party.json')
15
+
16
+ # Add new member section
17
+ with st.expander("➕ Add Wedding Party Member", expanded=False):
18
+ self.render_party_member_form()
19
+
20
+ # Display wedding party members
21
+ if party_members:
22
+ st.markdown(f"### Wedding Party ({len(party_members)} members)")
23
+
24
+ # Group by role
25
+ roles = {}
26
+ for member in party_members:
27
+ role = member.get('role', 'Other')
28
+ if role not in roles:
29
+ roles[role] = []
30
+ roles[role].append(member)
31
+
32
+ # Display by role
33
+ for role, members in roles.items():
34
+ st.markdown(f"#### {role}")
35
+
36
+ for member in members:
37
+ self.render_party_member_card(member)
38
+ else:
39
+ st.info("No wedding party members added yet. Add your first member above!")
40
+
41
+ def render_party_member_form(self):
42
+ # Option to select from existing guests or add new
43
+ add_option = st.radio("Add Wedding Party Member", ["Select from Guest List", "Add New Person"], horizontal=True)
44
+
45
+ if add_option == "Select from Guest List":
46
+ # Load guests from the correct data files
47
+ guest_list_data = self.config_manager.load_json_data('guest_list_data.json')
48
+ rsvp_data = self.config_manager.load_json_data('rsvp_data.json')
49
+
50
+ if guest_list_data:
51
+ # Create a list of guest names for selection
52
+ guest_options = []
53
+ guest_data_map = {}
54
+
55
+ for group_code, group_data in guest_list_data.items():
56
+ named_guests = group_data.get('named_guests', [])
57
+ for guest in named_guests:
58
+ first_name = guest.get('first_name', '')
59
+ last_name = guest.get('last_name', '')
60
+ full_name = guest.get('full_name', '')
61
+
62
+ if full_name and first_name != 'nan' and last_name != 'nan':
63
+ guest_options.append(full_name)
64
+
65
+ # Get additional info from RSVP data if available
66
+ rsvp_info = rsvp_data.get(group_code, {})
67
+ phone = rsvp_info.get('phone_number', '')
68
+
69
+ guest_data_map[full_name] = {
70
+ 'first_name': first_name,
71
+ 'last_name': last_name,
72
+ 'full_name': full_name,
73
+ 'phone': phone,
74
+ 'address': group_data.get('address', ''),
75
+ 'group': group_code,
76
+ 'party': group_data.get('party', '')
77
+ }
78
+
79
+ if guest_options:
80
+ # Guest selection outside the form so it updates immediately
81
+ guest_options_with_empty = ["-- Select a guest --"] + guest_options
82
+ selected_guest_name = st.selectbox("Select Guest", guest_options_with_empty, key="guest_selection")
83
+
84
+ # Check if a valid guest is selected
85
+ is_guest_selected = selected_guest_name and selected_guest_name != "-- Select a guest --"
86
+
87
+ # Show selected guest information only if a valid guest is selected
88
+ if is_guest_selected:
89
+ selected_guest = guest_data_map[selected_guest_name]
90
+
91
+ st.markdown("### Selected Guest Information")
92
+ col1, col2 = st.columns(2)
93
+
94
+ with col1:
95
+ st.info(f"**Name:** {selected_guest_name}")
96
+ st.info(f"**Phone:** {selected_guest.get('phone', 'Not provided')}")
97
+
98
+ with col2:
99
+ st.info(f"**Address:** {selected_guest.get('address', 'Not provided')}")
100
+ st.info(f"**Party:** {selected_guest.get('party', 'Not specified')}")
101
+ else:
102
+ st.info("Please select a guest from the dropdown above to see their information and add them to the wedding party.")
103
+
104
+ # Now create the form with the selected guest
105
+ with st.form("add_selected_guest_form"):
106
+ # Role selection
107
+ role = st.selectbox("Wedding Party Role", [
108
+ "Maid of Honor", "Best Man", "Bridesmaid", "Groomsman",
109
+ "Flower Girl", "Ring Bearer", "Usher", "Reader", "Other"
110
+ ], key="role_selection")
111
+
112
+ # Submit button - always present but disabled when no guest selected
113
+ submitted = st.form_submit_button("Add to Wedding Party", type="primary", disabled=not is_guest_selected)
114
+
115
+ # Handle form submission
116
+ if submitted and is_guest_selected:
117
+ selected_guest = guest_data_map[selected_guest_name]
118
+ new_member = {
119
+ 'id': datetime.now().strftime("%Y%m%d_%H%M%S"),
120
+ 'name': selected_guest_name,
121
+ 'role': role,
122
+ 'phone': selected_guest.get('phone', ''),
123
+ 'address': selected_guest.get('address', ''),
124
+ 'group': selected_guest.get('group', ''),
125
+ 'party': selected_guest.get('party', ''),
126
+ 'created_date': datetime.now().isoformat(),
127
+ 'guest_id': selected_guest.get('group', '') # Link to group code
128
+ }
129
+
130
+ # Load existing members and add new one
131
+ party_members = self.config_manager.load_json_data('wedding_party.json')
132
+ if not party_members:
133
+ party_members = []
134
+ party_members.append(new_member)
135
+
136
+ if self.config_manager.save_json_data('wedding_party.json', party_members):
137
+ st.success(f"{selected_guest_name} added to wedding party successfully!")
138
+ st.rerun()
139
+ else:
140
+ st.error("Error saving member")
141
+ else:
142
+ st.info("No guests found in the guest list. Please add guests first or choose 'Add New Person'.")
143
+ else:
144
+ st.info("No guests found in the guest list. Please add guests first or choose 'Add New Person'.")
145
+
146
+ else: # Add New Person
147
+ with st.form("add_new_person_form"):
148
+ st.markdown("### Add New Wedding Party Member")
149
+ col1, col2 = st.columns(2)
150
+
151
+ with col1:
152
+ name = st.text_input("Name *", placeholder="Enter full name")
153
+ role = st.selectbox("Wedding Party Role", [
154
+ "Maid of Honor", "Best Man", "Bridesmaid", "Groomsman",
155
+ "Flower Girl", "Ring Bearer", "Usher", "Reader", "Other"
156
+ ])
157
+
158
+ with col2:
159
+ phone = st.text_input("Phone Number", placeholder="Enter phone number")
160
+ address = st.text_input("Address", placeholder="Enter address")
161
+
162
+ submitted = st.form_submit_button("Add Member", type="primary")
163
+
164
+ if submitted:
165
+ if name:
166
+ new_member = {
167
+ 'id': datetime.now().strftime("%Y%m%d_%H%M%S"),
168
+ 'name': name,
169
+ 'role': role,
170
+ 'phone': phone,
171
+ 'address': address,
172
+ 'created_date': datetime.now().isoformat()
173
+ }
174
+
175
+ # Load existing members and add new one
176
+ party_members = self.config_manager.load_json_data('wedding_party.json')
177
+ if not party_members:
178
+ party_members = []
179
+ party_members.append(new_member)
180
+
181
+ if self.config_manager.save_json_data('wedding_party.json', party_members):
182
+ st.success("Wedding party member added successfully!")
183
+ st.rerun()
184
+ else:
185
+ st.error("Error saving member")
186
+ else:
187
+ st.error("Please enter a name")
188
+
189
+ def render_party_member_card(self, member):
190
+ member_id = member.get('id', '')
191
+ name = member.get('name', '')
192
+ role = member.get('role', '')
193
+ phone = member.get('phone', '')
194
+ address = member.get('address', '')
195
+ group = member.get('group', '')
196
+
197
+ # Get tasks assigned to this member
198
+ all_tasks = self.config_manager.load_json_data('tasks.json')
199
+ # Handle both old single assignee and new multiple assignees format
200
+ member_tasks = []
201
+ for task in all_tasks:
202
+ assigned_to = task.get('assigned_to', '')
203
+ if isinstance(assigned_to, str) and assigned_to.lower() == name.lower():
204
+ member_tasks.append(task)
205
+ elif isinstance(assigned_to, list) and name.lower() in [assignee.lower() for assignee in assigned_to]:
206
+ member_tasks.append(task)
207
+
208
+ with st.container():
209
+ st.markdown(f"**{name} - {role}**")
210
+
211
+ # Display contact and address information
212
+ col1, col2 = st.columns(2)
213
+ with col1:
214
+ if phone:
215
+ st.markdown(f"📞 **Phone:** {phone}")
216
+ if group:
217
+ st.markdown(f"👥 **Group:** {group}")
218
+
219
+ with col2:
220
+ if address:
221
+ st.markdown(f"🏠 **Address:** {address}")
222
+ party = member.get('party', '')
223
+ if party:
224
+ st.markdown(f"💒 **Party:** {party}")
225
+
226
+ # Tasks section
227
+ if member_tasks:
228
+ st.markdown(f"**📋 Tasks ({len(member_tasks)}):**")
229
+ for task in member_tasks:
230
+ # Task header with completion status and title
231
+ title = task.get('title', 'Untitled Task')
232
+ completed = task.get('completed', False)
233
+ status_icon = "✅" if completed else "⏳"
234
+ st.markdown(f"**{status_icon} {title}**")
235
+
236
+ # Task details in columns
237
+ col1, col2, col3 = st.columns(3)
238
+
239
+ with col1:
240
+ due_date = task.get('due_date', '')
241
+ if due_date:
242
+ st.caption(f"📅 Due: {due_date}")
243
+ else:
244
+ st.caption("📅 No due date")
245
+
246
+ with col2:
247
+ priority = task.get('priority', 'Medium')
248
+ # Priority with color coding
249
+ if priority == "Urgent":
250
+ st.caption(f"🔴 Priority: {priority}")
251
+ elif priority == "High":
252
+ st.caption(f"🔴 Priority: {priority}")
253
+ elif priority == "Medium":
254
+ st.caption(f"🟡 Priority: {priority}")
255
+ else:
256
+ st.caption(f"🟢 Priority: {priority}")
257
+
258
+ with col3:
259
+ if completed:
260
+ st.caption("✅ Completed")
261
+ else:
262
+ st.caption("⏳ In Progress")
263
+
264
+ # Description if available
265
+ description = task.get('description', '')
266
+ if description:
267
+ st.caption(f"📝 {description}")
268
+
269
+ # Add some spacing
270
+ st.markdown("---")
271
+ else:
272
+ st.markdown("📋 **Tasks:** None assigned")
273
+
274
+ # Action buttons before the dividing line
275
+ button_col1, button_col2, button_col3 = st.columns([1, 1, 1])
276
+
277
+ with button_col1:
278
+ edit_clicked = st.button("Edit", key=f"edit_party_{member_id}", help="Edit member")
279
+
280
+ with button_col2:
281
+ tasks_clicked = st.button("Tasks", key=f"tasks_party_{member_id}", help="View/Add Tasks")
282
+
283
+ with button_col3:
284
+ delete_clicked = st.button("Delete", key=f"delete_party_{member_id}", help="Delete member")
285
+
286
+ st.markdown("---")
287
+
288
+ # Handle button clicks
289
+ if edit_clicked:
290
+ # Close other modes and open edit mode
291
+ st.session_state[f"viewing_tasks_{member_id}"] = False
292
+ st.session_state[f"deleting_member_{member_id}"] = False
293
+ st.session_state[f"editing_member_{member_id}"] = True
294
+
295
+ if tasks_clicked:
296
+ # Close other modes and open task mode
297
+ st.session_state[f"editing_member_{member_id}"] = False
298
+ st.session_state[f"deleting_member_{member_id}"] = False
299
+ st.session_state[f"viewing_tasks_{member_id}"] = True
300
+
301
+ if delete_clicked:
302
+ # Close other modes and open delete mode
303
+ st.session_state[f"editing_member_{member_id}"] = False
304
+ st.session_state[f"viewing_tasks_{member_id}"] = False
305
+ st.session_state[f"deleting_member_{member_id}"] = True
306
+
307
+ # Handle edit mode
308
+ if st.session_state.get(f"editing_member_{member_id}", False):
309
+ self.edit_party_member(member)
310
+
311
+ # Handle task view mode
312
+ if st.session_state.get(f"viewing_tasks_{member_id}", False):
313
+ self.manage_member_tasks(member)
314
+
315
+ # Handle delete confirmation
316
+ if st.session_state.get(f"deleting_member_{member_id}", False):
317
+ self.delete_party_member(member_id)
318
+
319
+ def edit_party_member(self, member):
320
+ st.markdown("### Edit Wedding Party Member")
321
+
322
+ member_id = member.get('id', '')
323
+ form_key = f"edit_form_{member_id}"
324
+
325
+ with st.form(form_key):
326
+ col1, col2 = st.columns(2)
327
+
328
+ with col1:
329
+ name = st.text_input("Name", value=member.get('name', ''), disabled=True, key=f"edit_name_{member_id}")
330
+ role = st.selectbox("Wedding Party Role", [
331
+ "Maid of Honor", "Best Man", "Bridesmaid", "Groomsman",
332
+ "Flower Girl", "Ring Bearer", "Usher", "Reader", "Other"
333
+ ], index=self.get_role_index(member.get('role', '')), key=f"edit_role_{member_id}")
334
+ phone = st.text_input("Phone", value=member.get('phone', ''), disabled=True, key=f"edit_phone_{member_id}")
335
+
336
+ with col2:
337
+ address = st.text_input("Address", value=member.get('address', ''), disabled=True, key=f"edit_address_{member_id}")
338
+ group = st.text_input("Group", value=member.get('group', ''), disabled=True, key=f"edit_group_{member_id}")
339
+ party = st.text_input("Party", value=member.get('party', ''), disabled=True, key=f"edit_party_field_{member_id}")
340
+
341
+ # Form submit buttons
342
+ col1, col2 = st.columns(2)
343
+ with col1:
344
+ save_clicked = st.form_submit_button("Save Changes", type="primary")
345
+
346
+ with col2:
347
+ cancel_clicked = st.form_submit_button("Cancel")
348
+
349
+ # Handle form submission
350
+ if save_clicked:
351
+ self.save_member_edits(member, name, role)
352
+
353
+ if cancel_clicked:
354
+ st.session_state[f"editing_member_{member_id}"] = False
355
+ st.rerun()
356
+
357
+ def get_role_index(self, role):
358
+ roles = ["Maid of Honor", "Best Man", "Bridesmaid", "Groomsman",
359
+ "Flower Girl", "Ring Bearer", "Usher", "Reader", "Other"]
360
+ try:
361
+ return roles.index(role)
362
+ except ValueError:
363
+ return 8 # Default to "Other"
364
+
365
+ def save_member_edits(self, member, name, role):
366
+ if name:
367
+ # Load existing members
368
+ party_members = self.config_manager.load_json_data('wedding_party.json')
369
+
370
+ # Find and update the member
371
+ for i, m in enumerate(party_members):
372
+ if m.get('id') == member.get('id'):
373
+ party_members[i] = {
374
+ 'id': member.get('id'),
375
+ 'name': name,
376
+ 'role': role,
377
+ 'phone': member.get('phone', ''),
378
+ 'address': member.get('address', ''),
379
+ 'group': member.get('group', ''),
380
+ 'party': member.get('party', ''),
381
+ 'created_date': member.get('created_date', datetime.now().isoformat()),
382
+ 'guest_id': member.get('guest_id', '')
383
+ }
384
+ break
385
+
386
+ # Save updated members
387
+ if self.config_manager.save_json_data('wedding_party.json', party_members):
388
+ st.success("Member updated successfully!")
389
+ st.session_state[f"editing_member_{member.get('id', '')}"] = False
390
+ st.rerun()
391
+ else:
392
+ st.error("Error saving changes")
393
+ else:
394
+ st.error("Please enter a name")
395
+
396
+ def manage_member_tasks(self, member):
397
+ st.markdown(f"### Tasks for {member.get('name', '')}")
398
+
399
+ # Load all tasks
400
+ all_tasks = self.config_manager.load_json_data('tasks.json')
401
+ member_name = member.get('name', '')
402
+
403
+ # Filter tasks assigned to this member
404
+ # Handle both old single assignee and new multiple assignees format
405
+ member_tasks = []
406
+ for task in all_tasks:
407
+ assigned_to = task.get('assigned_to', '')
408
+ if isinstance(assigned_to, str) and assigned_to.lower() == member_name.lower():
409
+ member_tasks.append(task)
410
+ elif isinstance(assigned_to, list) and member_name.lower() in [assignee.lower() for assignee in assigned_to]:
411
+ member_tasks.append(task)
412
+
413
+ if member_tasks:
414
+ st.markdown(f"#### Current Tasks ({len(member_tasks)})")
415
+ for task in member_tasks:
416
+ self.render_member_task_card(task)
417
+ else:
418
+ st.info(f"No tasks currently assigned to {member_name}")
419
+
420
+ # Add new task for this member - full width
421
+ st.markdown("#### Add New Task")
422
+ self.render_member_task_form(member_name)
423
+
424
+ # Close tasks view button
425
+ if st.button("Close Tasks View", key=f"close_tasks_{member.get('id', '')}"):
426
+ st.session_state[f"viewing_tasks_{member.get('id', '')}"] = False
427
+ st.rerun()
428
+
429
+ def render_member_task_card(self, task):
430
+ task_id = task.get('id', '')
431
+ title = task.get('title', 'Untitled Task')
432
+ description = task.get('description', '')
433
+ due_date = task.get('due_date', '')
434
+ priority = task.get('priority', 'Medium')
435
+ completed = task.get('completed', False)
436
+
437
+ # Determine card color based on priority and completion
438
+ if completed:
439
+ border_color = "#4a7c59"
440
+ bg_color = "#f0f8f0"
441
+ elif priority == "Urgent":
442
+ border_color = "#d32f2f"
443
+ bg_color = "#fff5f5"
444
+ elif priority == "High":
445
+ border_color = "#ff9800"
446
+ bg_color = "#fff8e1"
447
+ else:
448
+ border_color = "#4a7c59"
449
+ bg_color = "#f8f9fa"
450
+
451
+ with st.container():
452
+ # Use Streamlit's native components for task cards
453
+ status_icon = "✅" if completed else "📋"
454
+ st.markdown(f"{status_icon} **{title}**")
455
+
456
+ if due_date:
457
+ st.markdown(f"📅 Due: {due_date}")
458
+
459
+ if priority:
460
+ st.markdown(f"⚡ Priority: {priority}")
461
+
462
+ if description:
463
+ st.markdown(f"📄 {description}")
464
+
465
+ # Quick action buttons before the separator
466
+ button_col1, button_col2, button_col3 = st.columns([1, 1, 1])
467
+
468
+ with button_col1:
469
+ if st.button("Edit", key=f"edit_member_task_{task_id}", help="Edit task"):
470
+ st.session_state[f"editing_member_task_{task_id}"] = True
471
+
472
+ with button_col2:
473
+ if not completed:
474
+ if st.button("Mark as Done", key=f"complete_task_{task_id}", help="Mark task as complete"):
475
+ self.toggle_task_completion(task_id, True)
476
+ else:
477
+ if st.button("Mark as Incomplete", key=f"undo_task_{task_id}", help="Mark task as incomplete"):
478
+ self.toggle_task_completion(task_id, False)
479
+
480
+ with button_col3:
481
+ if st.button("Delete", key=f"delete_member_task_{task_id}", help="Delete task"):
482
+ self.delete_member_task(task_id)
483
+
484
+ st.markdown("---")
485
+
486
+ # Show edit form if editing
487
+ if st.session_state.get(f"editing_member_task_{task_id}", False):
488
+ self.render_edit_member_task_form(task)
489
+
490
+ def render_member_task_form(self, member_name):
491
+ # Load custom tags from config
492
+ config = self.config_manager.load_config()
493
+ if isinstance(config, dict):
494
+ custom_settings = config.get('custom_settings', {})
495
+ custom_tags = custom_settings.get('custom_tags', [])
496
+ else:
497
+ custom_tags = []
498
+
499
+
500
+ with st.form(f"member_task_form_{member_name}"):
501
+ col1, col2 = st.columns(2)
502
+
503
+ with col1:
504
+ title = st.text_input("Task Title *", placeholder="Enter task title")
505
+ due_date = st.date_input("Due Date", value=None)
506
+
507
+ with col2:
508
+ priority = st.selectbox("Priority", ["Low", "Medium", "High", "Urgent"])
509
+
510
+ # Additional assignees selection (beyond the default member)
511
+ wedding_party = self.config_manager.load_json_data('wedding_party.json')
512
+ wedding_party_names = [member.get('name', '') for member in wedding_party if member.get('name')]
513
+
514
+ # Get task assignees from config
515
+ config = self.config_manager.load_config()
516
+ custom_settings = config.get('custom_settings', {})
517
+ task_assignees = custom_settings.get('task_assignees', [])
518
+
519
+ # Create combined options for multiselect (excluding the current member)
520
+ all_assignee_options = []
521
+ if wedding_party_names:
522
+ all_assignee_options.extend([f"Wedding Party: {name}" for name in wedding_party_names if name != member_name])
523
+ if task_assignees:
524
+ all_assignee_options.extend([f"Task Assignee: {assignee}" for assignee in task_assignees])
525
+
526
+ # Additional assignees selection
527
+ additional_assignees = st.multiselect("Also assign to (optional)", all_assignee_options, key=f"member_additional_assignees_{member_name}")
528
+
529
+ # Custom assignee text input (for additional people not in the lists)
530
+ custom_assignee = st.text_input("Additional Custom Assignee", placeholder="Enter additional assignee name (optional)", key=f"member_custom_assignee_{member_name}")
531
+
532
+ description = st.text_area("Description", placeholder="Enter task description", height=100)
533
+
534
+ # Tags selection - show all available tags
535
+ # Pre-select "Wedding Party" tag for consistency with vendor forms
536
+ default_tags = []
537
+ if 'Wedding Party' in custom_tags:
538
+ default_tags.append('Wedding Party')
539
+
540
+ if custom_tags:
541
+ selected_tags = st.multiselect("Tags", custom_tags, default=default_tags)
542
+ else:
543
+ # Fallback if no custom tags are loaded
544
+ selected_tags = st.multiselect("Tags", ['Wedding Party', 'Urgent', 'Rehearsal', 'Attire', 'Transportation', 'Photography', 'Decorations', 'Music', 'Food & Beverage', 'Timeline'], default=['Wedding Party'])
545
+
546
+ submitted = st.form_submit_button("Add Task", type="primary")
547
+
548
+ if submitted:
549
+ if title:
550
+ # Combine the default member with additional assignees
551
+ assigned_to_list = [member_name] # Start with the default member
552
+
553
+ # Add additional assignees from dropdown
554
+ for assignee in additional_assignees:
555
+ if assignee.startswith("Wedding Party: "):
556
+ assigned_to_list.append(assignee.replace("Wedding Party: ", ""))
557
+ elif assignee.startswith("Task Assignee: "):
558
+ assigned_to_list.append(assignee.replace("Task Assignee: ", ""))
559
+
560
+ # Add custom assignee if provided
561
+ if custom_assignee and custom_assignee.strip():
562
+ assigned_to_list.append(custom_assignee.strip())
563
+
564
+ new_task = {
565
+ 'id': datetime.now().strftime("%Y%m%d_%H%M%S"),
566
+ 'title': title,
567
+ 'description': description,
568
+ 'assigned_to': assigned_to_list,
569
+ 'due_date': due_date.isoformat() if due_date else None,
570
+ 'priority': priority,
571
+ 'group': 'Wedding Party',
572
+ 'tags': selected_tags,
573
+ 'completed': False,
574
+ 'created_date': datetime.now().isoformat(),
575
+ 'completed_date': None
576
+ }
577
+
578
+ # Load existing tasks and add new one
579
+ tasks = self.config_manager.load_json_data('tasks.json')
580
+ tasks.append(new_task)
581
+
582
+ if self.config_manager.save_json_data('tasks.json', tasks):
583
+ st.success("Task added successfully!")
584
+ st.rerun()
585
+ else:
586
+ st.error("Error saving task")
587
+ else:
588
+ st.error("Please enter a task title")
589
+
590
+ def render_edit_member_task_form(self, task):
591
+ task_id = task.get('id', '')
592
+ st.markdown("### Edit Task")
593
+
594
+ # Load custom tags from config
595
+ config = self.config_manager.load_config()
596
+ if isinstance(config, dict):
597
+ custom_settings = config.get('custom_settings', {})
598
+ custom_tags = custom_settings.get('custom_tags', [])
599
+ else:
600
+ custom_tags = []
601
+
602
+ with st.form(f"edit_member_task_form_{task_id}"):
603
+ col1, col2 = st.columns(2)
604
+
605
+ with col1:
606
+ title = st.text_input("Task Title *", value=task.get('title', ''), key=f"edit_member_title_{task_id}")
607
+ due_date_str = task.get('due_date', '')
608
+ due_date = None
609
+ if due_date_str:
610
+ try:
611
+ from datetime import datetime
612
+ due_date = datetime.fromisoformat(due_date_str).date()
613
+ except:
614
+ due_date = None
615
+ due_date = st.date_input("Due Date", value=due_date, key=f"edit_member_due_date_{task_id}")
616
+
617
+ with col2:
618
+ priority_options = ["Low", "Medium", "High", "Urgent"]
619
+ current_priority = task.get('priority', 'Medium')
620
+ priority_index = priority_options.index(current_priority) if current_priority in priority_options else 1
621
+ priority = st.selectbox("Priority", priority_options, index=priority_index, key=f"edit_member_priority_{task_id}")
622
+
623
+ # Additional assignees selection (beyond the current assignees)
624
+ wedding_party = self.config_manager.load_json_data('wedding_party.json')
625
+ wedding_party_names = [member.get('name', '') for member in wedding_party if member.get('name')]
626
+
627
+ # Get task assignees from config
628
+ config = self.config_manager.load_config()
629
+ custom_settings = config.get('custom_settings', {})
630
+ task_assignees = custom_settings.get('task_assignees', [])
631
+
632
+ # Get current assigned_to value (handle both old single assignee and new multiple assignees)
633
+ current_assigned_to = task.get('assigned_to', '')
634
+
635
+ # Handle backward compatibility - convert single assignee to list
636
+ if isinstance(current_assigned_to, str):
637
+ if current_assigned_to:
638
+ current_assignees = [current_assigned_to]
639
+ else:
640
+ current_assignees = []
641
+ elif isinstance(current_assigned_to, list):
642
+ current_assignees = current_assigned_to
643
+ else:
644
+ current_assignees = []
645
+
646
+ # Create combined options for multiselect
647
+ all_assignee_options = []
648
+ if wedding_party_names:
649
+ all_assignee_options.extend([f"Wedding Party: {name}" for name in wedding_party_names])
650
+ if task_assignees:
651
+ all_assignee_options.extend([f"Task Assignee: {assignee}" for assignee in task_assignees])
652
+
653
+ # Determine initial selected values
654
+ initial_selected = []
655
+ custom_assignees = []
656
+
657
+ for assignee in current_assignees:
658
+ if assignee in wedding_party_names:
659
+ initial_selected.append(f"Wedding Party: {assignee}")
660
+ elif assignee in task_assignees:
661
+ initial_selected.append(f"Task Assignee: {assignee}")
662
+ else:
663
+ custom_assignees.append(assignee)
664
+
665
+ # Multiple assignees selection
666
+ selected_assignees = st.multiselect("Assign to (select multiple people)", all_assignee_options, default=initial_selected, key=f"edit_member_assignees_{task_id}")
667
+
668
+ # Custom assignee text input (for additional people not in the lists)
669
+ custom_assignee_text = ", ".join(custom_assignees) if custom_assignees else ""
670
+ custom_assignee = st.text_input("Additional Custom Assignees", value=custom_assignee_text, placeholder="Enter additional assignee names (comma-separated)", key=f"edit_member_custom_assignee_{task_id}")
671
+
672
+ description = st.text_area("Description", value=task.get('description', ''), key=f"edit_member_description_{task_id}")
673
+
674
+ # Tags selection
675
+ current_tags = task.get('tags', [])
676
+ # Ensure "Wedding Party" tag is included for consistency
677
+ if 'Wedding Party' in custom_tags and 'Wedding Party' not in current_tags:
678
+ current_tags.append('Wedding Party')
679
+
680
+ if custom_tags:
681
+ selected_tags = st.multiselect("Tags", custom_tags, default=current_tags, key=f"edit_member_tags_{task_id}")
682
+ else:
683
+ # Ensure "Wedding Party" is in the fallback tags and selected
684
+ fallback_tags = ['Wedding Party', 'Urgent', 'Rehearsal', 'Attire', 'Transportation', 'Photography', 'Decorations', 'Music', 'Food & Beverage', 'Timeline']
685
+ if 'Wedding Party' not in current_tags:
686
+ current_tags.append('Wedding Party')
687
+ selected_tags = st.multiselect("Tags", fallback_tags, default=current_tags, key=f"edit_member_tags_{task_id}")
688
+
689
+ # Form buttons
690
+ col1, col2 = st.columns(2)
691
+ with col1:
692
+ save_clicked = st.form_submit_button("Save Changes", type="primary")
693
+ with col2:
694
+ cancel_clicked = st.form_submit_button("Cancel")
695
+
696
+ if save_clicked:
697
+ if title:
698
+ # Combine selected assignees and custom assignees
699
+ assigned_to_list = []
700
+ for assignee in selected_assignees:
701
+ if assignee.startswith("Wedding Party: "):
702
+ assigned_to_list.append(assignee.replace("Wedding Party: ", ""))
703
+ elif assignee.startswith("Task Assignee: "):
704
+ assigned_to_list.append(assignee.replace("Task Assignee: ", ""))
705
+
706
+ # Parse custom assignees (comma-separated)
707
+ if custom_assignee and custom_assignee.strip():
708
+ custom_list = [name.strip() for name in custom_assignee.split(',') if name.strip()]
709
+ assigned_to_list.extend(custom_list)
710
+
711
+ # Update the task
712
+ updated_task = {
713
+ 'id': task_id,
714
+ 'title': title,
715
+ 'description': description,
716
+ 'assigned_to': assigned_to_list,
717
+ 'due_date': due_date.isoformat() if due_date else None,
718
+ 'priority': priority,
719
+ 'group': 'Wedding Party',
720
+ 'tags': selected_tags,
721
+ 'completed': task.get('completed', False),
722
+ 'created_date': task.get('created_date', datetime.now().isoformat()),
723
+ 'completed_date': task.get('completed_date', None)
724
+ }
725
+
726
+ # Load existing tasks and update the specific one
727
+ tasks = self.config_manager.load_json_data('tasks.json')
728
+ for i, t in enumerate(tasks):
729
+ if t.get('id') == task_id:
730
+ tasks[i] = updated_task
731
+ break
732
+
733
+ if self.config_manager.save_json_data('tasks.json', tasks):
734
+ st.success("Task updated successfully!")
735
+ st.session_state[f"editing_member_task_{task_id}"] = False
736
+ st.rerun()
737
+ else:
738
+ st.error("Error saving task")
739
+ else:
740
+ st.error("Please enter a task title")
741
+
742
+ if cancel_clicked:
743
+ st.session_state[f"editing_member_task_{task_id}"] = False
744
+ st.rerun()
745
+
746
+ def delete_member_task(self, task_id):
747
+ tasks = self.config_manager.load_json_data('tasks.json')
748
+ tasks = [task for task in tasks if task.get('id') != task_id]
749
+ self.config_manager.save_json_data('tasks.json', tasks)
750
+ st.rerun()
751
+
752
+ def toggle_task_completion(self, task_id, completed):
753
+ tasks = self.config_manager.load_json_data('tasks.json')
754
+ for task in tasks:
755
+ if task.get('id') == task_id:
756
+ task['completed'] = completed
757
+ task['completed_date'] = datetime.now().isoformat() if completed else None
758
+ break
759
+
760
+ self.config_manager.save_json_data('tasks.json', tasks)
761
+ st.rerun()
762
+
763
+ def delete_party_member(self, member_id):
764
+ # Find the member to get their name
765
+ party_members = self.config_manager.load_json_data('wedding_party.json')
766
+ member_to_delete = None
767
+ for member in party_members:
768
+ if member.get('id') == member_id:
769
+ member_to_delete = member
770
+ break
771
+
772
+ if member_to_delete:
773
+ st.markdown("### Delete Wedding Party Member")
774
+ st.warning(f"Are you sure you want to delete **{member_to_delete.get('name', '')}** from the wedding party?")
775
+ st.markdown("This action cannot be undone.")
776
+
777
+ col1, col2, col3 = st.columns([1, 1, 1])
778
+
779
+ with col1:
780
+ if st.button("✅ Yes, Delete", key=f"confirm_delete_{member_id}", type="primary"):
781
+ # Remove the member
782
+ updated_members = [member for member in party_members if member.get('id') != member_id]
783
+
784
+ if self.config_manager.save_json_data('wedding_party.json', updated_members):
785
+ st.success(f"{member_to_delete.get('name', '')} has been deleted from the wedding party.")
786
+ st.session_state[f"deleting_member_{member_id}"] = False
787
+ st.rerun()
788
+ else:
789
+ st.error("Error deleting member")
790
+
791
+ with col2:
792
+ if st.button("❌ Cancel", key=f"cancel_delete_{member_id}"):
793
+ st.session_state[f"deleting_member_{member_id}"] = False
794
+ st.rerun()
795
+
796
+ with col3:
797
+ if st.button("🔙 Back", key=f"back_delete_{member_id}"):
798
+ st.session_state[f"deleting_member_{member_id}"] = False
799
+ st.rerun()